Files
galaxy/app/src/terminal/find/model/async_find.rs
T

1273 lines
47 KiB
Rust

//! 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<AbsoluteMatch>,
},
/// 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<u64>,
matches: Vec<AbsoluteMatch>,
},
/// 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<String>,
/// 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<Vec<BlockIndex>>,
/// 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<Self> {
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<Point>, 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<RangeInclusive<Point>> {
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<std::cmp::Ordering> {
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<AbsoluteMatch>>,
/// Matches for AI blocks, keyed by view_id.
pub(crate) ai_matches: HashMap<EntityId, Vec<RichContentMatchId>>,
/// TotalIndex for each terminal block that has been scanned.
terminal_total_indices: HashMap<BlockIndex, TotalIndex>,
/// TotalIndex for each AI block that has been scanned.
ai_total_indices: HashMap<EntityId, TotalIndex>,
}
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<u64>,
new_matches: Vec<AbsoluteMatch>,
) {
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<FairMutex<TerminalModel>>,
/// Current find configuration, if any.
current_config: Option<AsyncFindConfig>,
/// 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<async_channel::Sender<FindTaskMessage>>,
/// 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<async_channel::Sender<()>>,
/// Handle to abort the background task's future.
task_handle: Option<SpawnedFutureHandle>,
/// Shared work queue for the background task.
work_queue: Option<FindWorkQueue>,
/// Rich content views for AI block searching.
rich_content_views: HashMap<EntityId, Box<dyn FindableRichContentHandle>>,
/// 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<usize>,
/// The FindOptions for the current find run, stored for `active_find_options()` access.
current_find_options: Option<FindOptions>,
/// 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<FocusedMatchResolution>,
/// 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<FairMutex<TerminalModel>>) -> 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<usize> {
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<AsyncBlockGridMatch> {
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<AsyncFocusedAiMatch> {
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<dyn Iterator<Item = &AbsoluteMatch>> =
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<dyn Iterator<Item = &RichContentMatchId>> =
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<dyn FindableRichContentHandle>,
) {
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<TerminalFindModel>,
) {
// 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(&current_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<TerminalFindModel>,
) {
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<TerminalFindModel>) {
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<usize>, 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<AbsoluteMatch>> {
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<RichContentMatchId>> {
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<TerminalFindModel>,
) {
// 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<FindTaskMessage>,
ctx: &mut ModelContext<TerminalFindModel>,
) {
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<BlockInfo> {
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::<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() > 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::<BlockIndex, ()>();
TotalIndex(
cursor
.slice(&block_index, SeekBias::Right)
.summary()
.total_count,
)
}
#[cfg(test)]
#[path = "async_find_tests.rs"]
mod tests;