668 lines
25 KiB
Rust
668 lines
25 KiB
Rust
//! This module implements terminal find functionality for the blocklist.
|
|
use std::{collections::HashMap, iter, ops::RangeInclusive};
|
|
|
|
use itertools::Itertools;
|
|
use galaxyui::{units::Lines, AppContext, EntityId};
|
|
|
|
use crate::terminal::{
|
|
model::{
|
|
block::Block,
|
|
blocks::{
|
|
BlockHeight, BlockHeightItem, BlockHeightSummary, BlockList, RichContentItem,
|
|
TotalIndex,
|
|
},
|
|
find::{FindConfig, RegexDFAs},
|
|
index::Point,
|
|
terminal_model::{BlockIndex, BlockSortDirection},
|
|
},
|
|
GridType,
|
|
};
|
|
use crate::view_components::find::FindDirection;
|
|
|
|
use super::{
|
|
rich_content::{FindableRichContentHandle, RichContentMatchId},
|
|
FindOptions,
|
|
};
|
|
|
|
/// Runs a find operation on the blocklist using the given `options` and returns a
|
|
/// `BlockListFindRun` with the results.
|
|
///
|
|
/// If the given `options` does not contain a query, short-circuits and returns a find run with no
|
|
/// matches.
|
|
pub(super) fn run_find_on_block_list(
|
|
mut options: FindOptions,
|
|
block_list: &BlockList,
|
|
findable_rich_content_views: &HashMap<EntityId, Box<dyn FindableRichContentHandle>>,
|
|
block_sort_direction: BlockSortDirection,
|
|
ctx: &mut AppContext,
|
|
) -> BlockListFindRun {
|
|
let Some(dfas) = options.query.as_ref().and_then(|query| {
|
|
RegexDFAs::new_with_config(
|
|
query.as_str(),
|
|
FindConfig {
|
|
is_regex_enabled: options.is_regex_enabled,
|
|
is_case_sensitive: options.is_case_sensitive,
|
|
},
|
|
)
|
|
.ok()
|
|
}) else {
|
|
// Clear rich content matches.
|
|
for rich_content_view in findable_rich_content_views.values() {
|
|
rich_content_view.clear_matches(ctx);
|
|
}
|
|
return BlockListFindRun {
|
|
options,
|
|
block_sort_direction,
|
|
dfas: None,
|
|
matches: vec![],
|
|
raw_focused_match_index: None,
|
|
};
|
|
};
|
|
|
|
let mut matches = vec![];
|
|
|
|
// If find in block is enabled, find matches in selected blocks only
|
|
if let Some(blocks_to_include_in_results) = options.blocks_to_include_in_results.as_mut() {
|
|
// Sort blocks in descending order so that the most recent block is last, which is the order we expect.
|
|
blocks_to_include_in_results.sort_by(|i, j| j.cmp(i));
|
|
|
|
// Must update matches in order, from first block to last block,
|
|
// so that matches for the latest blocks come before matches for earlier blocks.
|
|
// Note that this is true regardless of the blocklist orientation (inverted or not)
|
|
// In both cases we want the most recent block updated last, which means the sort direction
|
|
// here should always be MostRecentLast
|
|
for block_index in blocks_to_include_in_results {
|
|
let agent_view_state = block_list.agent_view_state();
|
|
if let Some(block) = block_list
|
|
.block_at(*block_index)
|
|
.filter(|block| !block.is_empty(agent_view_state))
|
|
{
|
|
if block.height(agent_view_state) == Lines::zero() {
|
|
// This should not happen in practice, because `blocks_to_include_in_results`
|
|
// is set by selecting blocks, which are presumably visible.
|
|
continue;
|
|
}
|
|
matches.extend(run_find_on_block(
|
|
&dfas,
|
|
block,
|
|
*block_index,
|
|
block_sort_direction,
|
|
));
|
|
}
|
|
}
|
|
} else {
|
|
// Otherwise, loop through all the blocks in the terminal's blocklist, executing find on each block.
|
|
run_find_on_sumtree(
|
|
&options,
|
|
&dfas,
|
|
block_list,
|
|
findable_rich_content_views,
|
|
block_sort_direction,
|
|
&mut matches,
|
|
ctx,
|
|
);
|
|
}
|
|
|
|
let raw_focused_match_index = (!matches.is_empty()).then_some(0);
|
|
BlockListFindRun {
|
|
dfas: Some(dfas),
|
|
matches,
|
|
raw_focused_match_index,
|
|
options,
|
|
block_sort_direction,
|
|
}
|
|
}
|
|
|
|
/// Runs a find operation over blocks yielded by the given `blocks_iter`, appending `BlockListMatches`
|
|
/// to the `matches` output parameter in the same order as blocks in the `blocks_iter`.
|
|
fn run_find_on_sumtree(
|
|
options: &FindOptions,
|
|
dfas: &RegexDFAs,
|
|
block_list: &BlockList,
|
|
rich_content_views: &HashMap<EntityId, Box<dyn FindableRichContentHandle>>,
|
|
block_sort_direction: BlockSortDirection,
|
|
matches: &mut Vec<BlockListMatch>,
|
|
ctx: &mut AppContext,
|
|
) {
|
|
let mut cursor = block_list
|
|
.block_heights()
|
|
.cursor::<BlockHeight, BlockHeightSummary>();
|
|
cursor.descend_to_last_item(block_list.block_heights());
|
|
|
|
while let Some(item) = cursor.item() {
|
|
match item {
|
|
BlockHeightItem::Block(height) if height.into_lines() > Lines::zero() => {
|
|
let block_index = cursor.start().block_count;
|
|
if let Some(block) = block_list.block_at(block_index.into()) {
|
|
matches.extend(run_find_on_block(
|
|
dfas,
|
|
block,
|
|
block_index.into(),
|
|
block_sort_direction,
|
|
));
|
|
}
|
|
}
|
|
BlockHeightItem::RichContent(RichContentItem {
|
|
view_id,
|
|
last_laid_out_height,
|
|
..
|
|
}) if last_laid_out_height.into_lines() > Lines::zero() => {
|
|
if let Some(findable_view) = rich_content_views.get(view_id) {
|
|
let mut rich_content_matches = findable_view.run_find(options, ctx);
|
|
if matches!(block_sort_direction, BlockSortDirection::MostRecentLast) {
|
|
rich_content_matches.reverse();
|
|
}
|
|
|
|
matches.extend(rich_content_matches.into_iter().map(|match_id| {
|
|
BlockListMatch::RichContent {
|
|
match_id,
|
|
view_id: *view_id,
|
|
index: cursor.start().total_count.into(),
|
|
}
|
|
}));
|
|
}
|
|
}
|
|
_ => (),
|
|
}
|
|
cursor.prev();
|
|
}
|
|
}
|
|
|
|
/// Runs a find operation on the given block and returns the resulting vector of `BlockListMatch`es.
|
|
fn run_find_on_block(
|
|
dfas: &RegexDFAs,
|
|
block: &Block,
|
|
block_index: BlockIndex,
|
|
block_sort_direction: BlockSortDirection,
|
|
) -> Vec<BlockListMatch> {
|
|
let grid_order = match block_sort_direction {
|
|
BlockSortDirection::MostRecentFirst => &[GridType::PromptAndCommand, GridType::Output],
|
|
BlockSortDirection::MostRecentLast => &[GridType::Output, GridType::PromptAndCommand],
|
|
};
|
|
|
|
let mut block_matches = vec![];
|
|
for grid_type in grid_order.iter() {
|
|
let mut grid_matches = match grid_type {
|
|
GridType::PromptAndCommand => block
|
|
.find_prompt_and_command_grid_matches(dfas)
|
|
.into_iter()
|
|
.map(|range| {
|
|
BlockListMatch::CommandBlock(BlockGridMatch {
|
|
grid_type: GridType::PromptAndCommand,
|
|
range,
|
|
block_index,
|
|
is_filtered: false,
|
|
})
|
|
})
|
|
.collect_vec(),
|
|
GridType::Output => {
|
|
let mut output_grid_matches = block
|
|
.find_output_grid_matches(dfas)
|
|
.into_iter()
|
|
.map(|range| {
|
|
BlockListMatch::CommandBlock(BlockGridMatch {
|
|
grid_type: GridType::Output,
|
|
range,
|
|
block_index,
|
|
is_filtered: false,
|
|
})
|
|
})
|
|
.collect_vec();
|
|
update_matches_for_filtered_block(
|
|
output_grid_matches.iter_mut(),
|
|
block,
|
|
block_sort_direction,
|
|
);
|
|
output_grid_matches
|
|
}
|
|
_ => continue,
|
|
};
|
|
if matches!(block_sort_direction, BlockSortDirection::MostRecentFirst) {
|
|
grid_matches.reverse();
|
|
}
|
|
block_matches.extend(grid_matches);
|
|
}
|
|
block_matches
|
|
}
|
|
|
|
/// Represents a single find match in a grid-based block in the blocklist..
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct BlockGridMatch {
|
|
/// The type of grid in which the match was found.
|
|
pub grid_type: GridType,
|
|
|
|
/// The character index range of the match.
|
|
pub range: RangeInclusive<Point>,
|
|
|
|
/// The index of the containing block.
|
|
pub block_index: BlockIndex,
|
|
|
|
/// `true` if the match should be filtered out from displayed results (e.g. if the containing
|
|
/// row has been filtered out via block filtering).
|
|
pub is_filtered: bool,
|
|
}
|
|
|
|
/// Represents a single find match in the blocklist.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub enum BlockListMatch {
|
|
CommandBlock(BlockGridMatch),
|
|
RichContent {
|
|
match_id: RichContentMatchId,
|
|
view_id: EntityId,
|
|
index: TotalIndex,
|
|
},
|
|
}
|
|
|
|
impl BlockListMatch {
|
|
pub fn is_filtered(&self) -> bool {
|
|
match self {
|
|
BlockListMatch::CommandBlock(BlockGridMatch { is_filtered, .. }) => *is_filtered,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn grid_type(&self) -> Option<GridType> {
|
|
match self {
|
|
BlockListMatch::CommandBlock(BlockGridMatch { grid_type, .. }) => Some(*grid_type),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
pub fn matches_block(&self, block_index: BlockIndex) -> bool {
|
|
match self {
|
|
BlockListMatch::CommandBlock(BlockGridMatch {
|
|
block_index: match_block_index,
|
|
..
|
|
}) => block_index == *match_block_index,
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn matches_blockgrid(&self, block_index: BlockIndex, grid_type: GridType) -> bool {
|
|
match self {
|
|
BlockListMatch::CommandBlock(BlockGridMatch {
|
|
block_index: match_block_index,
|
|
grid_type: match_grid_type,
|
|
..
|
|
}) => block_index == *match_block_index && grid_type == *match_grid_type,
|
|
_ => false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Represents the result of a find "run" on the blocklist.
|
|
#[derive(Debug)]
|
|
pub struct BlockListFindRun {
|
|
/// Compiled [`RegexDFAs`] for the find query.
|
|
///
|
|
/// If the query in `options` is Some(), this is guaranteed to be `Some()`.
|
|
dfas: Option<RegexDFAs>,
|
|
|
|
/// Matches found in the blocklist.
|
|
///
|
|
/// Matches in this vector are ordered by block index in order of decreasing recency. Within
|
|
/// the slice for a given block, matches ordering depends on the `block_sort_direction`. For
|
|
/// `BlockSortDirection::MostRecentLast`, matches are ordered from "bottom" to "top". For
|
|
/// `BlockSortDirection::MostRecentFirst`, matches are ordered from "top" to "bottom". This
|
|
/// ensures that iterating over matches occurs in the order that is expected in the UI.
|
|
///
|
|
/// The match at index 0 is the first match to be focused after a fresh find run - for
|
|
/// pin-to-bottom and waterfall input modes, this is the match closest to the bottom of the
|
|
/// most recent block. For pin-to-top, this is the match closest to the top of the most recent
|
|
/// block.
|
|
matches: Vec<BlockListMatch>,
|
|
|
|
/// The index of the currently focused match in the `matches` vector.
|
|
///
|
|
/// Note that this may differ from the focused match index displayed in the find bar UI, since
|
|
/// the number of visible matches may be affected by block filtering. The focused match index
|
|
/// in the UI thus is relative to visible matches, while this field is relative to all matches.
|
|
raw_focused_match_index: Option<usize>,
|
|
|
|
/// The `FindOptions` used to configure the find run.
|
|
options: FindOptions,
|
|
|
|
/// The block sort direction, reflected in the ordering of intra-block matches in `matches`.
|
|
block_sort_direction: BlockSortDirection,
|
|
}
|
|
|
|
impl BlockListFindRun {
|
|
/// Returns the UI focused match index, relative to the list of visible matches.
|
|
pub fn focused_match_index(&self) -> Option<usize> {
|
|
self.raw_focused_match_index.map(|focused_index| {
|
|
focused_index
|
|
- self.matches[..focused_index]
|
|
.iter()
|
|
.fold(0, |count, find_match| {
|
|
if find_match.is_filtered() {
|
|
count + 1
|
|
} else {
|
|
count
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
/// Returns an iterator over visible `BlockListMatch`es.
|
|
pub fn matches(&self) -> impl Iterator<Item = &BlockListMatch> {
|
|
self.matches.iter().filter(|m| !m.is_filtered())
|
|
}
|
|
|
|
/// Returns an iterator over matches for the given block index and grid type, in "ascending"
|
|
/// order.
|
|
///
|
|
/// This logic relies on the ordering of `self.matches` explained in the field declaration.
|
|
pub fn matches_for_block_grid(
|
|
&self,
|
|
block_index: BlockIndex,
|
|
grid_type: GridType,
|
|
) -> Box<dyn Iterator<Item = &RangeInclusive<Point>> + '_> {
|
|
let Some(start_index) = self
|
|
.matches
|
|
.iter()
|
|
.position(|m| m.matches_blockgrid(block_index, grid_type))
|
|
else {
|
|
return Box::new(iter::empty());
|
|
};
|
|
let match_slice = if let Some(relative_end_index) = self.matches[start_index..]
|
|
.iter()
|
|
.position(|m| !m.matches_blockgrid(block_index, grid_type))
|
|
{
|
|
&self.matches[start_index..(start_index + relative_end_index)]
|
|
} else {
|
|
&self.matches[start_index..]
|
|
};
|
|
match self.block_sort_direction {
|
|
BlockSortDirection::MostRecentLast => Box::new(
|
|
match_slice
|
|
.iter()
|
|
.filter_map(|m| match m {
|
|
BlockListMatch::CommandBlock(BlockGridMatch {
|
|
range, is_filtered, ..
|
|
}) => {
|
|
if !is_filtered {
|
|
Some(range)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
_ => None,
|
|
})
|
|
.rev(),
|
|
),
|
|
BlockSortDirection::MostRecentFirst => {
|
|
Box::new(match_slice.iter().filter_map(|m| match m {
|
|
BlockListMatch::CommandBlock(BlockGridMatch {
|
|
range, is_filtered, ..
|
|
}) => {
|
|
if !is_filtered {
|
|
Some(range)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
_ => None,
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn focused_match(&self) -> Option<&BlockListMatch> {
|
|
self.raw_focused_match_index
|
|
.and_then(|i| self.matches.get(i))
|
|
}
|
|
|
|
pub fn options(&self) -> &FindOptions {
|
|
&self.options
|
|
}
|
|
|
|
/// Focuses the next match in `matches` based on the given `direction` and `block_sort_direction`.
|
|
pub(super) fn focus_next_match(
|
|
&mut self,
|
|
direction: FindDirection,
|
|
block_sort_direction: BlockSortDirection,
|
|
) {
|
|
let new_focused_index = match (self.raw_focused_match_index, self.matches.is_empty()) {
|
|
(_, true) => None,
|
|
(Some(mut current_index), false) => {
|
|
let mut new_focused_index = None;
|
|
for _ in 0..self.matches.len() {
|
|
current_index = match (direction, block_sort_direction) {
|
|
(FindDirection::Up, BlockSortDirection::MostRecentLast)
|
|
| (FindDirection::Down, BlockSortDirection::MostRecentFirst) => {
|
|
if current_index + 1 < self.matches.len() {
|
|
current_index + 1
|
|
} else {
|
|
0
|
|
}
|
|
}
|
|
(FindDirection::Down, BlockSortDirection::MostRecentLast)
|
|
| (FindDirection::Up, BlockSortDirection::MostRecentFirst) => {
|
|
if current_index > 0 {
|
|
current_index - 1
|
|
} else {
|
|
self.matches.len() - 1
|
|
}
|
|
}
|
|
};
|
|
if !self.matches[current_index].is_filtered() {
|
|
new_focused_index = Some(current_index);
|
|
break;
|
|
}
|
|
}
|
|
new_focused_index
|
|
}
|
|
(None, false) => Some(0),
|
|
};
|
|
self.raw_focused_match_index = new_focused_index;
|
|
}
|
|
|
|
/// Reruns the find operation on the block at the given index, updates the matches with the new results.
|
|
///
|
|
/// Note that matches for a given block are expected to appear in a contiguous slice (per the
|
|
/// expected ordering of `self.matches`).
|
|
pub(super) fn rerun_on_block(
|
|
mut self,
|
|
block: &Block,
|
|
block_index: BlockIndex,
|
|
block_sort_direction: BlockSortDirection,
|
|
) -> Self {
|
|
let Some(dfas) = self.dfas.as_ref() else {
|
|
return self;
|
|
};
|
|
|
|
let old_block_matches_start_index = self
|
|
.matches
|
|
.iter()
|
|
.position(|find_match| find_match.matches_block(block_index));
|
|
let mut new_matches = run_find_on_block(dfas, block, block_index, block_sort_direction);
|
|
if let Some(start_index) = old_block_matches_start_index {
|
|
let end_index = old_block_matches_start_index
|
|
.and_then(|i| {
|
|
self.matches[(i + 1)..]
|
|
.iter()
|
|
.position(|find_match| !find_match.matches_block(block_index))
|
|
.map(|j| i + j + 1)
|
|
})
|
|
.unwrap_or(self.matches.len());
|
|
|
|
// Splice in the new matches where the old block matches used to exist.
|
|
self.matches.splice(start_index..end_index, new_matches);
|
|
} else {
|
|
new_matches.append(&mut self.matches);
|
|
self.matches = new_matches;
|
|
}
|
|
|
|
if self.matches.is_empty() {
|
|
self.raw_focused_match_index = None;
|
|
} else if let Some(mut focused_match_index) = self.raw_focused_match_index {
|
|
// Ensure the focused match index is still valid.
|
|
while focused_match_index >= self.matches.len() {
|
|
focused_match_index = focused_match_index.saturating_sub(1);
|
|
}
|
|
self.raw_focused_match_index = Some(focused_match_index);
|
|
}
|
|
|
|
self
|
|
}
|
|
|
|
pub(super) fn update_matches_for_filtered_block(
|
|
&mut self,
|
|
block: &Block,
|
|
block_index: BlockIndex,
|
|
block_sort_direction: BlockSortDirection,
|
|
) {
|
|
update_matches_for_filtered_block(
|
|
self.matches.iter_mut().filter(|find_match| {
|
|
find_match.matches_block(block_index)
|
|
&& matches!(find_match.grid_type(), Some(GridType::Output))
|
|
}),
|
|
block,
|
|
block_sort_direction,
|
|
);
|
|
}
|
|
|
|
pub(super) fn cleared(mut self) -> Self {
|
|
let new_dfas =
|
|
self.options.query.as_ref().and_then(|query| {
|
|
match RegexDFAs::new_with_config(
|
|
query.as_str(),
|
|
FindConfig {
|
|
is_regex_enabled: self.options.is_regex_enabled,
|
|
is_case_sensitive: self.options.is_case_sensitive,
|
|
},
|
|
) {
|
|
Ok(dfas) => Some(dfas),
|
|
Err(e) => {
|
|
log::warn!(
|
|
"Failed to construct new RegexDFAs for cleared BlockListFindRun: {e:?}"
|
|
);
|
|
None
|
|
}
|
|
}
|
|
});
|
|
self.dfas = new_dfas;
|
|
self.matches = vec![];
|
|
self.raw_focused_match_index = None;
|
|
self
|
|
}
|
|
}
|
|
|
|
fn update_matches_for_filtered_block<'a>(
|
|
mut matches: impl Iterator<Item = &'a mut BlockListMatch>,
|
|
block: &Block,
|
|
block_sort_direction: BlockSortDirection,
|
|
) {
|
|
let Some(displayed_rows) = block.displayed_output_row_ranges() else {
|
|
matches.for_each(|find_match| {
|
|
if let BlockListMatch::CommandBlock(BlockGridMatch {
|
|
ref mut is_filtered,
|
|
..
|
|
}) = find_match
|
|
{
|
|
*is_filtered = false;
|
|
}
|
|
});
|
|
return;
|
|
};
|
|
|
|
match block_sort_direction {
|
|
BlockSortDirection::MostRecentLast => {
|
|
let mut current_find_match = matches.next();
|
|
|
|
let mut displayed_row_ranges = displayed_rows.rev();
|
|
let mut current_row_range = displayed_row_ranges.next();
|
|
|
|
loop {
|
|
match (current_find_match.take(), current_row_range.take()) {
|
|
(Some(find_match), Some(row_range)) => {
|
|
match find_match {
|
|
BlockListMatch::CommandBlock(BlockGridMatch {
|
|
range,
|
|
is_filtered,
|
|
..
|
|
}) if range.end().row > *row_range.end() => {
|
|
*is_filtered = true;
|
|
current_find_match = matches.next();
|
|
current_row_range = Some(row_range);
|
|
}
|
|
BlockListMatch::CommandBlock(BlockGridMatch {
|
|
range,
|
|
is_filtered,
|
|
..
|
|
}) if range.start().row >= *row_range.start() => {
|
|
*is_filtered = false;
|
|
current_find_match = matches.next();
|
|
current_row_range = Some(row_range);
|
|
}
|
|
_ => {
|
|
current_find_match = Some(find_match);
|
|
current_row_range = displayed_row_ranges.next();
|
|
}
|
|
};
|
|
}
|
|
(Some(find_match), None) => {
|
|
if let BlockListMatch::CommandBlock(BlockGridMatch {
|
|
is_filtered, ..
|
|
}) = find_match
|
|
{
|
|
*is_filtered = true;
|
|
}
|
|
current_find_match = matches.next();
|
|
}
|
|
(None, _) => break,
|
|
}
|
|
}
|
|
}
|
|
BlockSortDirection::MostRecentFirst => {
|
|
let mut current_find_match = matches.next();
|
|
|
|
let mut displayed_row_ranges = displayed_rows;
|
|
let mut current_row_range = displayed_row_ranges.next();
|
|
|
|
loop {
|
|
match (current_find_match.take(), current_row_range.take()) {
|
|
(Some(find_match), Some(row_range)) => {
|
|
match find_match {
|
|
BlockListMatch::CommandBlock(BlockGridMatch {
|
|
range,
|
|
is_filtered,
|
|
..
|
|
}) if range.start().row < *row_range.start() => {
|
|
*is_filtered = true;
|
|
current_find_match = matches.next();
|
|
}
|
|
BlockListMatch::CommandBlock(BlockGridMatch {
|
|
range,
|
|
is_filtered,
|
|
..
|
|
}) if range.end().row <= *row_range.end() => {
|
|
*is_filtered = false;
|
|
current_find_match = matches.next();
|
|
}
|
|
_ => {
|
|
current_find_match = Some(find_match);
|
|
current_row_range = displayed_row_ranges.next();
|
|
}
|
|
};
|
|
}
|
|
(Some(find_match), None) => {
|
|
if let BlockListMatch::CommandBlock(BlockGridMatch {
|
|
is_filtered, ..
|
|
}) = find_match
|
|
{
|
|
*is_filtered = true;
|
|
}
|
|
current_find_match = matches.next();
|
|
}
|
|
(None, _) => break,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "block_list_test.rs"]
|
|
mod tests;
|