//! 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>, 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>, block_sort_direction: BlockSortDirection, matches: &mut Vec, ctx: &mut AppContext, ) { let mut cursor = block_list .block_heights() .cursor::(); 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 { 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, /// 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 { 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, /// 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, /// 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, /// 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 { 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 { 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> + '_> { 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, 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;