//! Async find implementation for terminal content. //! //! This module provides asynchronous find functionality that runs on a background thread, //! streaming results back to the main thread to avoid blocking the UI. mod background_task; mod work_queue; use std::collections::HashMap; use std::ops::RangeInclusive; use std::sync::Arc; use std::time::Duration; use background_task::spawn_find_task; use parking_lot::FairMutex; use sum_tree::SeekBias; use warpui::r#async::SpawnedFutureHandle; use warpui::{EntityId, ModelContext}; use work_queue::FindWorkQueue; use super::rich_content::{FindableRichContentHandle, RichContentMatchId}; use super::FindOptions; use crate::terminal::block_list_element::GridType; use crate::terminal::find::model::TerminalFindModel; use crate::terminal::model::blocks::{ BlockHeight, BlockHeightItem, BlockHeightSummary, BlockList, TotalIndex, }; use crate::terminal::model::grid::grid_handler::{AbsolutePoint, GridHandler}; use crate::terminal::model::index::Point; use crate::terminal::model::terminal_model::{BlockIndex, BlockSortDirection}; use crate::terminal::model::TerminalModel; use crate::throttle::throttle; use crate::view_components::find::{FindDirection, FindEvent}; /// Status of an async find operation. #[derive(Default, Debug, Clone, PartialEq, Eq)] pub enum AsyncFindStatus { /// No find operation in progress. #[default] Idle, /// Find operation is running. Scanning, /// Find operation completed. Complete, } impl std::fmt::Display for AsyncFindStatus { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::Idle => write!(f, "Idle"), Self::Scanning => write!(f, "Scanning"), Self::Complete => write!(f, "Complete"), } } } /// Message streamed from background find task to main thread. #[derive(Debug)] pub enum FindTaskMessage { /// Matches found in a terminal block's grid. BlockGridMatches { block_index: BlockIndex, grid_type: GridType, matches: Vec, }, /// Matches found in a dirty range within a terminal block's grid. /// /// Unlike `BlockGridMatches` (which extends results), these matches are /// merged into existing results using `update_dirty_matches`. DirtyRangeMatches { block_index: BlockIndex, grid_type: GridType, /// The dirty range in absolute row coordinates, used for merging. dirty_range: RangeInclusive, matches: Vec, }, /// Request to scan an AI block on the main thread. ScanAIBlock { view_id: EntityId, total_index: TotalIndex, }, /// The work queue has drained (current batch of work is complete). Done, } /// Configuration for an async find run. #[derive(Debug, Clone)] pub struct AsyncFindConfig { /// The search query. pub query: Arc, /// Whether the search is case-sensitive. pub is_case_sensitive: bool, /// Whether the query should be interpreted as a regex. pub is_regex_enabled: bool, /// If `Some`, only search within these block indices. pub blocks_to_include: Option>, /// The sort direction of blocks in the view. pub block_sort_direction: BlockSortDirection, } impl AsyncFindConfig { /// Creates a new config from FindOptions and a block sort direction. /// /// Returns `None` if there is no query or if the query is empty/whitespace. pub fn from_options( options: &FindOptions, block_sort_direction: BlockSortDirection, ) -> Option { let query = options.query.clone()?; if query.trim().is_empty() { return None; } Some(Self { query, is_case_sensitive: options.is_case_sensitive, is_regex_enabled: options.is_regex_enabled, blocks_to_include: options.blocks_to_include_in_results.clone(), block_sort_direction, }) } } /// A match stored with absolute row indices to handle scrollback truncation. /// /// Using absolute indices (offset from original row 0) allows us to: /// 1. Avoid updating all match indices when rows are truncated /// 2. Efficiently filter out truncated matches at query time /// 3. Support incremental dirty-range scanning without full rescans #[derive(Debug, Clone, PartialEq, Eq)] pub struct AbsoluteMatch { /// Start point with absolute row index. pub start: AbsolutePoint, /// End point with absolute row index. pub end: AbsolutePoint, } impl AbsoluteMatch { /// Creates an AbsoluteMatch from a relative Point range. pub fn from_range(range: &RangeInclusive, grid: &GridHandler) -> Self { Self { start: AbsolutePoint::from_point(*range.start(), grid), end: AbsolutePoint::from_point(*range.end(), grid), } } /// Converts back to a relative Point range. /// /// Returns `None` if either point has been truncated from scrollback. pub fn to_range(&self, grid: &GridHandler) -> Option> { let start = self.start.to_point(grid)?; let end = self.end.to_point(grid)?; Some(start..=end) } /// Returns true if this match has been truncated from scrollback. pub fn is_truncated(&self, num_lines_truncated: u64) -> bool { // A match is truncated if its start point is truncated. self.start.is_truncated(num_lines_truncated) } /// Returns the absolute start row. pub fn start_row(&self) -> u64 { self.start.row } /// Returns the absolute end row. pub fn end_row(&self) -> u64 { self.end.row } } impl PartialOrd for AbsoluteMatch { fn partial_cmp(&self, other: &Self) -> Option { Some(self.cmp(other)) } } impl Ord for AbsoluteMatch { fn cmp(&self, other: &Self) -> std::cmp::Ordering { // Sort by end point (for ascending order iteration during rendering), // then by start point for consistency with derived PartialEq. // // This ordering assumes matches within a single grid do not overlap, // which is guaranteed by `find_in_range` producing non-overlapping // results. `update_dirty_matches` relies on this property to use // positional range-based splicing for merging. self.end.cmp(&other.end).then(self.start.cmp(&other.start)) } } /// A focused match from the async find controller. /// /// Similar to `BlockGridMatch` but uses `AbsoluteMatch` for the range, allowing /// the caller to convert to relative coordinates when needed. #[derive(Debug, Clone)] pub struct AsyncBlockGridMatch { /// The type of grid in which the match was found. pub grid_type: GridType, /// The match range in absolute coordinates. pub range: AbsoluteMatch, /// The index of the containing block. pub block_index: BlockIndex, } /// A focused match in a rich content (AI) block. /// /// Mirrors the data carried by `BlockListMatch::RichContent` in the sync path, /// so callers can synthesize a `BlockListMatch` from either path. /// /// This is a snapshot of the controller's state at the time it was produced. /// `match_id` is the most volatile field: `AIBlock::run_find` regenerates all /// match ids from a process-global atomic counter on every rescan (see /// `app/src/ai/blocklist/block/find.rs`), so any cached id is invalidated the /// next time that AI block is scanned. `total_index` is more stable — it only /// shifts when the blocklist sumtree itself is mutated at a non-end position /// (banner/gap insertion, scrollback truncation), not when new output streams /// into an existing AI block. Callers should still consume the value inline /// and not hold it across `process_message` deliveries. /// /// TODO(vkodithala): This mirrors `BlockListMatch::RichContent` in the sync path. Both /// derive `Clone` even though their contents are short-lived; explore removing /// `Clone` from both in a future PR to enforce the snapshot contract in the /// type system. #[derive(Debug, Clone)] pub struct AsyncFocusedAiMatch { /// The view id of the rich content block. pub view_id: EntityId, /// The id of the focused match within the rich content block. pub match_id: RichContentMatchId, /// The total index of the rich content block in the blocklist sumtree. pub total_index: TotalIndex, } /// Resolution of the global focused match index to either a terminal or an AI match. #[derive(Debug, Clone)] enum FocusedMatchResolution { Terminal(AsyncBlockGridMatch), Ai(AsyncFocusedAiMatch), } /// Per-block find results, keyed by block index. #[derive(Debug, Default)] pub(crate) struct BlockFindResults { /// Matches for terminal blocks, keyed by (block_index, grid_type). /// Matches are stored in ascending order by end point. pub(crate) terminal_matches: HashMap<(BlockIndex, GridType), Vec>, /// Matches for AI blocks, keyed by view_id. pub(crate) ai_matches: HashMap>, /// TotalIndex for each terminal block that has been scanned. terminal_total_indices: HashMap, /// TotalIndex for each AI block that has been scanned. ai_total_indices: HashMap, } impl BlockFindResults { /// Returns the total number of matches across all blocks. fn total_match_count(&self) -> usize { let terminal_count: usize = self.terminal_matches.values().map(|v| v.len()).sum(); let ai_count: usize = self.ai_matches.values().map(|v| v.len()).sum(); terminal_count + ai_count } /// Clears all results. fn clear(&mut self) { self.terminal_matches.clear(); self.ai_matches.clear(); self.terminal_total_indices.clear(); self.ai_total_indices.clear(); } /// Removes all results for a specific block index. fn remove_block(&mut self, block_index: BlockIndex) { self.terminal_matches .retain(|(idx, _), _| *idx != block_index); self.terminal_total_indices.remove(&block_index); } /// Updates matches for a dirty range within a specific block grid. /// /// This removes all existing matches that overlap with the dirty range and /// inserts the new matches in their place, maintaining ascending order. /// /// Adapted from `FilterState::update_dirty_matches` in filtering.rs. fn update_dirty_matches( &mut self, block_index: BlockIndex, grid_type: GridType, dirty_range: RangeInclusive, new_matches: Vec, ) { let matches = self .terminal_matches .entry((block_index, grid_type)) .or_default(); // If there are no current matches, just insert the new ones. if matches.is_empty() { *matches = new_matches; return; } // If the dirty range is before all existing matches, insert at the start. if matches .first() .is_some_and(|first_match| *dirty_range.end() < first_match.start_row()) { matches.splice(0..0, new_matches); return; } // If the dirty range is after all existing matches, append at the end. if matches .last() .is_some_and(|last_match| last_match.end_row() < *dirty_range.start()) { matches.extend(new_matches); return; } // Find the range of matches that overlap with the dirty range. // A match overlaps if: match.start <= dirty.end AND match.end >= dirty.start let replace_start = matches .iter() .position(|m| m.end_row() >= *dirty_range.start()) .unwrap_or(matches.len()); let replace_end = matches .iter() .rposition(|m| m.start_row() <= *dirty_range.end()) .map(|pos| pos + 1) .unwrap_or(replace_start); let replace_range = if replace_start <= replace_end { replace_start..replace_end } else { // Dirty range lies between two adjacent matches; insert without replacing. replace_start..replace_start }; matches.splice(replace_range, new_matches); // Assert that matches are still in ascending order. debug_assert!( matches.windows(2).all(|w| w[0] <= w[1]), "Matches should be in ascending order after update_dirty_matches" ); } } /// Information about a block to be searched. #[derive(Debug, Clone)] pub enum BlockInfo { /// A terminal command block. Terminal { block_index: BlockIndex, total_index: TotalIndex, }, /// A rich content block (e.g., AI block). RichContent { view_id: EntityId, total_index: TotalIndex, }, } /// Controller for async find operations. /// /// This model manages background find tasks, processes results, and maintains /// find state for the terminal view. pub struct AsyncFindController { /// Reference to the terminal model. terminal_model: Arc>, /// Current find configuration, if any. current_config: Option, /// Per-block results for the current find run. block_results: BlockFindResults, /// Current status of the find operation. status: AsyncFindStatus, /// Sender for the result channel. The receiver is consumed by /// `spawn_stream_local` in `start_find`, so only the sender is stored /// here (to be cloned into each background task via `spawn_find_task`). result_tx: Option>, /// Sender for the throttled UI-update channel. `process_message` sends /// a `()` signal here; a throttled stream on the other end emits /// `FindEvent::RanFind` at most every 50 ms. throttle_tx: Option>, /// Handle to abort the background task's future. task_handle: Option, /// Shared work queue for the background task. work_queue: Option, /// Rich content views for AI block searching. rich_content_views: HashMap>, /// The block sort direction for the current/last find run. block_sort_direction: BlockSortDirection, /// The currently focused match index (0-based), if any. focused_match_index: Option, /// The FindOptions for the current find run, stored for `active_find_options()` access. current_find_options: Option, /// Cached resolution of `focused_match_index` to either a terminal match, /// an AI match, or `None` (out of range). Updated when focus or matches /// change cached_focused_match: Option, /// Monotonically increasing generation counter, bumped each time new streams /// are spawned. The result stream callback captures the generation at spawn /// time and skips messages that arrive after a newer generation has started, /// preventing stale `Done` messages from prematurely ending a new scan. generation: u64, } impl AsyncFindController { /// Creates a new AsyncFindController. pub fn new(terminal_model: Arc>) -> Self { Self { terminal_model, current_config: None, block_results: BlockFindResults::default(), status: AsyncFindStatus::Idle, result_tx: None, throttle_tx: None, task_handle: None, work_queue: None, rich_content_views: HashMap::new(), block_sort_direction: BlockSortDirection::MostRecentLast, focused_match_index: None, current_find_options: None, cached_focused_match: None, generation: 0, } } /// Returns the current find status. pub fn status(&self) -> &AsyncFindStatus { &self.status } /// Returns the current config, if any. pub fn current_config(&self) -> Option<&AsyncFindConfig> { self.current_config.as_ref() } /// Returns the FindOptions for the current find run, if any. pub fn find_options(&self) -> Option<&FindOptions> { self.current_find_options.as_ref() } /// Returns the total number of matches found so far. pub fn match_count(&self) -> usize { self.block_results.total_match_count() } /// Returns true if a find operation is currently in progress. pub fn is_scanning(&self) -> bool { matches!(self.status, AsyncFindStatus::Scanning) } /// Returns true if there is an active find configuration. /// /// This indicates that find is active and new blocks should be scanned. pub fn has_active_find(&self) -> bool { self.current_config.is_some() } /// Returns the currently focused match index (0-based), if any. pub fn focused_match_index(&self) -> Option { self.focused_match_index } /// Focuses the next or previous match based on the given direction. /// /// Since matches are stored newest-first (index 0 = most recent block), /// the mapping between UI direction and index direction depends on the /// block sort direction: /// - MostRecentLast: Down decrements (toward newest), Up increments (toward oldest). /// - MostRecentFirst: Down increments (toward oldest), Up decrements (toward newest). pub fn focus_next_match(&mut self, direction: FindDirection) { let total = self.match_count(); if total == 0 { self.focused_match_index = None; return; } let new_index = match (self.focused_match_index, direction) { (None, _) => 0, (Some(current), direction) => { // Determine whether this direction means "toward lower indices" // (newest/bottom for MostRecentLast, newest/top for MostRecentFirst). let decrement = matches!( (direction, self.block_sort_direction), (FindDirection::Down, BlockSortDirection::MostRecentLast) | (FindDirection::Up, BlockSortDirection::MostRecentFirst) ); if decrement { if current == 0 { total - 1 // Wrap around. } else { current - 1 } } else if current + 1 >= total { 0 // Wrap around. } else { current + 1 } } }; self.focused_match_index = Some(new_index); self.update_cached_focused_match(); } /// Returns the focused match as an [`AsyncBlockGridMatch`] if it's a terminal match. /// /// Returns a cached value that is updated when focus or matches change, /// avoiding the cost of sorting and iterating on every call. Returns /// `None` if focus is on an AI match or out of range. pub fn focused_terminal_match(&self) -> Option { match &self.cached_focused_match { Some(FocusedMatchResolution::Terminal(m)) => Some(m.clone()), _ => None, } } /// Returns the focused match as an `AsyncFocusedAiMatch` if it's an AI match. /// /// Returns a cached value that is updated when focus or matches change, /// avoiding the cost of sorting and iterating on every call. Returns /// `None` if focus is on a terminal match or out of range. pub fn focused_ai_match(&self) -> Option { match &self.cached_focused_match { Some(FocusedMatchResolution::Ai(m)) => Some(m.clone()), _ => None, } } /// Recomputes the cached focused match by iterating through all matches /// (terminal and AI) in visual display order, derived from the TotalIndex /// maps stored in the block results. /// /// Resolves the global `focused_match_index` to either a terminal match or /// an AI match (or `None`, if out of range) and stores it in the unified /// cache. fn update_cached_focused_match(&mut self) { let Some(focused_idx) = self.focused_match_index else { self.cached_focused_match = None; return; }; let mut current_idx = 0; // Determine grid iteration order within each terminal block. let grid_types: [GridType; 2] = match self.block_sort_direction { BlockSortDirection::MostRecentFirst => [GridType::PromptAndCommand, GridType::Output], BlockSortDirection::MostRecentLast => [GridType::Output, GridType::PromptAndCommand], }; // Build a unified list of all blocks with results, sorted by TotalIndex. let mut ordered_blocks: Vec<(TotalIndex, BlockInfo)> = Vec::new(); for (&block_index, &total_index) in &self.block_results.terminal_total_indices { ordered_blocks.push(( total_index, BlockInfo::Terminal { block_index, total_index, }, )); } for (&view_id, &total_index) in &self.block_results.ai_total_indices { ordered_blocks.push(( total_index, BlockInfo::RichContent { view_id, total_index, }, )); } // Sort by TotalIndex descending so that matches closest to the end // of the blocklist (newest blocks, near the prompt) come first. ordered_blocks.sort_by_key(|b| std::cmp::Reverse(b.0)); let reverse_within_block = matches!( self.block_sort_direction, BlockSortDirection::MostRecentLast ); for (_, block_info) in &ordered_blocks { match block_info { BlockInfo::Terminal { block_index, .. } => { for &grid_type in &grid_types { if let Some(matches) = self .block_results .terminal_matches .get(&(*block_index, grid_type)) { // For MostRecentLast, sync focus traversal // iterates from the bottom of each grid first. let iter: Box> = if reverse_within_block { Box::new(matches.iter().rev()) } else { Box::new(matches.iter()) }; for match_range in iter { if current_idx == focused_idx { self.cached_focused_match = Some( FocusedMatchResolution::Terminal(AsyncBlockGridMatch { block_index: *block_index, grid_type, range: match_range.clone(), }), ); return; } current_idx += 1; } } } } BlockInfo::RichContent { view_id, total_index, } => { if let Some(ai_matches) = self.block_results.ai_matches.get(view_id) { // Mirror sync find's per-AI-block traversal order. Sync // reverses rich-content match ids for MostRecentLast so // that matches inside one AI block are walked from // bottom to top; we apply the same reversal at iteration // time to keep stored order canonical. let iter: Box> = if reverse_within_block { Box::new(ai_matches.iter().rev()) } else { Box::new(ai_matches.iter()) }; for &match_id in iter { if current_idx == focused_idx { self.cached_focused_match = Some(FocusedMatchResolution::Ai(AsyncFocusedAiMatch { view_id: *view_id, match_id, total_index: *total_index, })); return; } current_idx += 1; } } } } } // No match at the focused index (index out of range). self.cached_focused_match = None; } /// Registers a rich content view for AI block searching. pub(crate) fn register_rich_content_view( &mut self, view_id: EntityId, handle: Box, ) { self.rich_content_views.insert(view_id, handle); } /// Unregisters a rich content view. pub(crate) fn unregister_rich_content_view(&mut self, view_id: EntityId) { self.rich_content_views.remove(&view_id); } /// Starts a new find operation with the given options. /// /// If a find operation is already in progress, it will be cancelled first. /// This spawns a background task, a result stream, and a throttled UI-update /// stream on the provided context. pub fn start_find( &mut self, options: &FindOptions, block_sort_direction: BlockSortDirection, ctx: &mut ModelContext, ) { // Cancel any existing find operation. self.cancel_current_find(); // Check for query refinement optimization. if let (Some(current_config), Some(new_query)) = (&self.current_config, options.query.as_ref()) { if !options.is_regex_enabled && !current_config.is_regex_enabled && options.is_case_sensitive == current_config.is_case_sensitive && is_query_refinement(¤t_config.query, new_query) { // New query is a refinement of the old query — filter existing results. self.filter_results_for_refinement(options, block_sort_direction, ctx); return; } } // Create new config. let Some(config) = AsyncFindConfig::from_options(options, block_sort_direction) else { // No query — clear results and return. self.clear_results(ctx); return; }; self.current_config = Some(config.clone()); self.block_sort_direction = block_sort_direction; self.block_results.clear(); self.focused_match_index = None; self.cached_focused_match = None; self.current_find_options = Some(options.clone()); self.status = AsyncFindStatus::Scanning; // Build the work queue from the current block list. let queue = FindWorkQueue::new(); let block_info = { let mut model = self.terminal_model.lock(); let info = collect_block_info(model.block_list(), &config); // Clear stale dirty ranges on the active block, since the full scan // covers all of its current content. Without this, the first // incremental update could redundantly re-scan already-covered rows. if let Some(output_grid) = model .block_list_mut() .active_block_mut() .grid_of_type_mut(GridType::Output) { output_grid.grid_handler_mut().take_find_dirty_rows_range(); } info }; queue.enqueue_full_scan(&block_info); self.work_queue = Some(queue.clone()); // Populate TotalIndex maps from the block info so that // compute_focused_terminal_match can order results correctly. for info in &block_info { match info { BlockInfo::Terminal { block_index, total_index, } => { self.block_results .terminal_total_indices .insert(*block_index, *total_index); } BlockInfo::RichContent { view_id, total_index, } => { self.block_results .ai_total_indices .insert(*view_id, *total_index); } } } // Create result channel and spawn streams. let (result_tx, result_rx) = async_channel::unbounded(); self.result_tx = Some(result_tx.clone()); self.spawn_result_and_throttle_streams(result_rx, ctx); // Spawn background task. self.task_handle = Some(spawn_find_task( config, self.terminal_model.clone(), queue, result_tx, ctx, )); } /// Processes a single message from the background find task. /// /// Called by the result stream's `on_item` callback for each message /// delivered from the background task. pub fn process_message( &mut self, msg: FindTaskMessage, ctx: &mut ModelContext, ) { match msg { FindTaskMessage::BlockGridMatches { block_index, grid_type, matches, } => { if !matches.is_empty() { // Store TotalIndex for this block if not already known // (e.g. the block was added after the initial scan). if !self .block_results .terminal_total_indices .contains_key(&block_index) { let total_index = { let model = self.terminal_model.lock(); total_index_for_block(block_index, model.block_list()) }; self.block_results .terminal_total_indices .insert(block_index, total_index); } self.block_results .terminal_matches .entry((block_index, grid_type)) .or_default() .extend(matches); // Auto-select the first match when results first arrive. if self.focused_match_index.is_none() { self.focused_match_index = Some(0); } self.clamp_focused_match_index(); } } FindTaskMessage::DirtyRangeMatches { block_index, grid_type, dirty_range, matches, } => { self.block_results.update_dirty_matches( block_index, grid_type, dirty_range, matches, ); // Prune matches that have been truncated from scrollback. // Dirty range messages arrive when the active block receives // new output, which is exactly when truncation can occur. self.prune_truncated_matches(block_index, grid_type); self.clamp_focused_match_index(); } FindTaskMessage::ScanAIBlock { view_id, total_index, } => { // Scan AI block on main thread. if let Some(view) = self.rich_content_views.get(&view_id) { if let Some(config) = &self.current_config { let options = FindOptions { query: Some(config.query.clone()), is_case_sensitive: config.is_case_sensitive, is_regex_enabled: config.is_regex_enabled, blocks_to_include_in_results: None, }; let start = instant::Instant::now(); let match_ids = view.run_find(&options, ctx); let elapsed = start.elapsed(); log::trace!( "[async_find] AI block scan took {}ms for view_id={:?}", elapsed.as_millis(), view_id ); if !match_ids.is_empty() { self.block_results.ai_matches.insert(view_id, match_ids); self.block_results .ai_total_indices .insert(view_id, total_index); self.clamp_focused_match_index(); } } } } FindTaskMessage::Done => { self.status = AsyncFindStatus::Complete; } } // Signal the throttled UI-update stream. if let Some(tx) = &self.throttle_tx { let _ = tx.try_send(()); } } /// Cancels the current find operation, if any. /// /// Dropping the senders closes the result and throttle streams naturally. /// The find configuration is preserved so `has_active_find()` remains true. pub fn cancel_current_find(&mut self) { // Close the work queue, which causes the background task's pop() to // return Err(QueueClosed) and exit. if let Some(queue) = self.work_queue.take() { queue.close(); } // Abort the background future so it stops being polled. if let Some(handle) = self.task_handle.take() { handle.abort(); } // Drop senders to close the result and throttle streams. self.result_tx = None; self.throttle_tx = None; self.status = AsyncFindStatus::Idle; } /// Clears all find results and resets state. pub fn clear_results(&mut self, ctx: &mut ModelContext) { self.cancel_current_find(); self.current_config = None; self.block_results.clear(); self.focused_match_index = None; self.cached_focused_match = None; self.status = AsyncFindStatus::Idle; self.current_find_options = None; // Clear matches in AI blocks. for view in self.rich_content_views.values() { view.clear_matches(ctx); } // Note: The owning entity is responsible for emitting events to notify listeners. } /// Invalidates results for a specific block and rescans it. /// /// This enqueues work into the shared queue so the background task handles /// it asynchronously. No scanning happens on the main thread. /// /// # Arguments /// * `block_index` - The index of the block that changed. /// * `dirty_info` - If provided, a `(row_range, grid_type, num_lines_truncated)` /// tuple describing the dirty region. If `None`, a full block rescan is enqueued. pub fn invalidate_block( &mut self, block_index: BlockIndex, dirty_info: Option<(RangeInclusive, GridType, u64)>, ) { if self.current_config.is_none() { return; } let Some(queue) = self.work_queue.clone() else { return; }; // For a full block rescan (no dirty range), clear existing results now // so stale matches are not shown while the rescan is pending. if dirty_info.is_none() { self.block_results.remove_block(block_index); self.clamp_focused_match_index(); } log::trace!( "[async_find] invalidate_block: enqueuing work for block {:?}, dirty={:?}", block_index, dirty_info.as_ref().map(|(r, _, _)| r), ); queue.invalidate_block(block_index, dirty_info); // Mark status as scanning so the polling loop stays alive while the // background task processes the new work. self.status = AsyncFindStatus::Scanning; } /// Returns matches for a specific terminal block grid as AbsoluteMatch references. /// /// Callers should convert to relative `Point` ranges using `AbsoluteMatch::to_range()`. pub fn matches_for_block_grid( &self, block_index: BlockIndex, grid_type: GridType, ) -> Option<&Vec> { self.block_results .terminal_matches .get(&(block_index, grid_type)) } /// Returns matches for a specific AI block. pub fn matches_for_ai_block(&self, view_id: EntityId) -> Option<&Vec> { self.block_results.ai_matches.get(&view_id) } /// Prunes truncated matches for a specific block and grid type. /// /// This removes matches whose start row has been truncated from scrollback, /// keeping the match count and focused index accurate. fn prune_truncated_matches(&mut self, block_index: BlockIndex, grid_type: GridType) { let num_lines_truncated = { let model = self.terminal_model.lock(); let Some(block) = model.block_list().block_at(block_index) else { return; }; match grid_type { GridType::Output => block.output_grid().grid_handler().num_lines_truncated(), GridType::PromptAndCommand => block .prompt_and_command_grid() .grid_handler() .num_lines_truncated(), _ => return, } }; if num_lines_truncated == 0 { return; } if let Some(matches) = self .block_results .terminal_matches .get_mut(&(block_index, grid_type)) { matches.retain(|m| !m.is_truncated(num_lines_truncated)); } } /// Clamps the focused match index to the current match count. /// /// If the count is 0, sets the index to `None`. This should be called after /// any operation that can reduce the total match count (e.g. dirty range /// updates, block removal). fn clamp_focused_match_index(&mut self) { let total = self.match_count(); if total == 0 { self.focused_match_index = None; } else { self.focused_match_index = self.focused_match_index.map(|i| i.min(total - 1)); } self.update_cached_focused_match(); } /// Filters existing results for a query refinement. fn filter_results_for_refinement( &mut self, options: &FindOptions, block_sort_direction: BlockSortDirection, ctx: &mut ModelContext, ) { // For now, we do a full rescan when the query is refined. // A future optimization could filter existing matches without rescanning, // and update the query for pending queue items. // Create new config. let Some(config) = AsyncFindConfig::from_options(options, block_sort_direction) else { self.clear_results(ctx); return; }; // Cancel existing operation and clear results, but keep the task alive // by re-using start_find which handles everything. self.cancel_current_find(); self.current_config = Some(config.clone()); self.block_sort_direction = block_sort_direction; self.block_results.clear(); self.focused_match_index = None; self.cached_focused_match = None; self.current_find_options = Some(options.clone()); self.status = AsyncFindStatus::Scanning; // Build the work queue from the current block list. let queue = FindWorkQueue::new(); let block_info = { let mut model = self.terminal_model.lock(); let info = collect_block_info(model.block_list(), &config); // Clear stale dirty ranges (same rationale as in start_find). if let Some(output_grid) = model .block_list_mut() .active_block_mut() .grid_of_type_mut(GridType::Output) { output_grid.grid_handler_mut().take_find_dirty_rows_range(); } info }; queue.enqueue_full_scan(&block_info); self.work_queue = Some(queue.clone()); // Populate TotalIndex maps from the block info. for info in &block_info { match info { BlockInfo::Terminal { block_index, total_index, } => { self.block_results .terminal_total_indices .insert(*block_index, *total_index); } BlockInfo::RichContent { view_id, total_index, } => { self.block_results .ai_total_indices .insert(*view_id, *total_index); } } } // Create result channel and spawn streams. let (result_tx, result_rx) = async_channel::unbounded(); self.result_tx = Some(result_tx.clone()); self.spawn_result_and_throttle_streams(result_rx, ctx); // Spawn background task. self.task_handle = Some(spawn_find_task( config, self.terminal_model.clone(), queue, result_tx, ctx, )); } /// Spawns the result delivery stream and the throttled UI-update stream. /// /// The result stream invokes `process_message` for every `FindTaskMessage` /// received from the background task. The throttle stream coalesces rapid /// signals and emits `FindEvent::RanFind` at most every 50 ms (with the /// first signal passing through immediately). fn spawn_result_and_throttle_streams( &mut self, result_rx: async_channel::Receiver, ctx: &mut ModelContext, ) { const THROTTLE_INTERVAL: Duration = Duration::from_millis(50); // Bump generation so that stale messages from an old find's stream // are discarded by the callback check below. self.generation += 1; let generation = self.generation; // Result stream: delivers every message to process_message. ctx.spawn_stream_local( result_rx, move |me, msg, ctx| { if let Some(controller) = &mut me.async_find_controller { if controller.generation == generation { controller.process_message(msg, ctx); } } }, |_me, _ctx| {}, ); // Throttle stream: coalesces rapid signals into periodic UI updates. let (throttle_tx, throttle_rx) = async_channel::unbounded(); self.throttle_tx = Some(throttle_tx); ctx.spawn_stream_local( throttle(THROTTLE_INTERVAL, throttle_rx), |_me, (), ctx| { ctx.emit(FindEvent::RanFind); }, |_me, _ctx| {}, ); } } #[cfg(test)] impl AsyncFindController { /// Sets up internal state for testing by setting the status directly. pub(crate) fn set_test_status(&mut self, status: AsyncFindStatus) { self.status = status; } /// Returns a mutable reference to block results for testing. pub(crate) fn block_results_mut(&mut self) -> &mut BlockFindResults { &mut self.block_results } } /// Returns true if `new_query` is a refinement of `old_query`. /// /// A query is considered a refinement if it starts with the old query, /// meaning any match of the new query must also be a match of the old query. /// An empty old_query is not considered a valid refinement base. fn is_query_refinement(old_query: &str, new_query: &str) -> bool { !old_query.is_empty() && new_query.starts_with(old_query) && new_query.len() > old_query.len() } /// Collects information about blocks to search. /// /// The returned list is always in newest-first order, regardless of /// `config.block_sort_direction`. This gives the background task a /// consistent iteration order; the main thread re-sorts results by /// `block_sort_direction` when computing the focused match. pub fn collect_block_info(block_list: &BlockList, config: &AsyncFindConfig) -> Vec { let mut block_info = Vec::new(); // If specific blocks are requested, only collect those. if let Some(blocks_to_include) = &config.blocks_to_include { for &block_index in blocks_to_include { if block_list.block_at(block_index).is_some() { let total_index = total_index_for_block(block_index, block_list); block_info.push(BlockInfo::Terminal { block_index, total_index, }); } } // Sort by recency (newest first). block_info.sort_by(|a, b| { let idx_a = match a { BlockInfo::Terminal { block_index, .. } => block_index.0, BlockInfo::RichContent { .. } => 0, }; let idx_b = match b { BlockInfo::Terminal { block_index, .. } => block_index.0, BlockInfo::RichContent { .. } => 0, }; idx_b.cmp(&idx_a) }); return block_info; } // Otherwise, iterate through all blocks via the height cursor. 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() > warpui::units::Lines::zero() => { let summary = cursor.start(); block_info.push(BlockInfo::Terminal { block_index: summary.block_count.into(), total_index: summary.total_count.into(), }); } BlockHeightItem::RichContent(rich_content_item) if rich_content_item.last_laid_out_height.into_lines() > warpui::units::Lines::zero() => { block_info.push(BlockInfo::RichContent { view_id: rich_content_item.view_id, total_index: cursor.start().total_count.into(), }); } _ => {} } cursor.prev(); } block_info } /// Computes the TotalIndex for a terminal block at the given BlockIndex. /// /// Uses the block list's height tree to find the block's position among /// all items (blocks, gaps, rich content, etc.). fn total_index_for_block(block_index: BlockIndex, block_list: &BlockList) -> TotalIndex { let mut cursor = block_list.block_heights().cursor::(); TotalIndex( cursor .slice(&block_index, SeekBias::Right) .summary() .total_count, ) } #[cfg(test)] #[path = "async_find_tests.rs"] mod tests;