first pass of merging in warp (doesn't build)

This commit is contained in:
Ryan Ward
2026-07-01 16:08:58 -05:00
parent 2f64909469
commit 4770ac06b5
3662 changed files with 414574 additions and 89772 deletions
+2 -2
View File
@@ -2,6 +2,6 @@
pub mod model;
pub use model::{
BlockGridMatch, BlockListFindRun, BlockListMatch, FindOptions, FindableRichContentView,
RichContentMatchId, TerminalFindModel,
BlockFindRenderData, BlockGridMatch, BlockListFindRun, BlockListMatch, FindOptions,
FindableRichContentView, RichContentMatchId, TerminalFindModel,
};
+508 -62
View File
@@ -1,31 +1,176 @@
mod alt_screen;
pub mod async_find;
mod block_list;
#[allow(dead_code)]
mod rich_content;
#[cfg(any(test, feature = "integration_tests"))]
mod testing;
pub use block_list::{BlockGridMatch, BlockListFindRun, BlockListMatch};
pub use rich_content::{FindableRichContentView, RichContentMatchId};
use crate::terminal::block_list_viewport::InputMode;
use std::{collections::HashMap, sync::Arc};
use std::collections::HashMap;
use std::ops::RangeInclusive;
use std::sync::Arc;
use alt_screen::{run_find_on_alt_screen, AltScreenFindRun};
use galaxyui::{AppContext, Entity, EntityId, ModelContext, SingletonEntity, ViewHandle};
pub use async_find::{AsyncFindController, AsyncFindStatus};
use block_list::run_find_on_block_list;
pub use block_list::{BlockGridMatch, BlockListFindRun, BlockListMatch};
use parking_lot::FairMutex;
use rich_content::FindableRichContentHandle;
pub use rich_content::{FindableRichContentView, RichContentMatchId};
use settings::Setting as _;
use crate::{
settings::InputModeSettings,
terminal::model::{terminal_model::BlockIndex, TerminalModel},
view_components::find::{FindEvent, FindModel},
};
use crate::settings::InputModeSettings;
use crate::terminal::block_list_element::GridType;
use crate::terminal::block_list_viewport::InputMode;
use crate::terminal::model::grid::grid_handler::GridHandler;
use crate::terminal::model::index::Point;
use crate::terminal::model::terminal_model::BlockIndex;
use crate::terminal::model::TerminalModel;
use crate::terminal::settings::TerminalSettings;
use crate::view_components::find::{FindDirection, FindEvent, FindModel};
use crate::view_components::find::FindDirection;
/// Pre-computed find data for rendering a single block.
///
/// This struct provides a unified interface for both sync (`BlockListFindRun`) and async
/// (`AsyncFindController`) find paths, allowing the rendering code to work with either.
///
/// Stores references to the underlying data source and provides methods to create iterators
/// on demand (since iterators can only be consumed once).
pub enum BlockFindRenderData<'a> {
/// Data from the synchronous find path.
Sync {
run: &'a BlockListFindRun,
block_index: BlockIndex,
},
/// Data from the asynchronous find path.
///
/// For the async path, we pre-compute and store converted matches since they use
/// absolute coordinates internally and need conversion to relative Points.
Async {
/// Pre-converted command grid matches (filtered for truncation).
command_matches: Vec<RangeInclusive<Point>>,
/// Pre-converted output grid matches (filtered for truncation).
output_matches: Vec<RangeInclusive<Point>>,
/// Focused range in command grid, if any.
focused_command_range: Option<RangeInclusive<Point>>,
/// Focused range in output grid, if any.
focused_output_range: Option<RangeInclusive<Point>>,
},
}
use block_list::run_find_on_block_list;
use rich_content::FindableRichContentHandle;
impl<'a> BlockFindRenderData<'a> {
/// Creates render data from the sync `BlockListFindRun`.
pub fn from_sync(run: &'a BlockListFindRun, block_index: BlockIndex) -> Self {
Self::Sync { run, block_index }
}
/// Creates render data from the async `AsyncFindController`.
///
/// This pre-converts matches from absolute to relative coordinates, filtering out
/// any matches that have been truncated from scrollback.
pub fn from_async(
controller: &'a AsyncFindController,
block_index: BlockIndex,
command_grid: Option<&GridHandler>,
output_grid: Option<&GridHandler>,
) -> Self {
// Convert command grid matches.
let command_matches = command_grid
.and_then(|grid| {
controller
.matches_for_block_grid(block_index, GridType::PromptAndCommand)
.map(|matches| {
matches
.iter()
.filter_map(|m| m.to_range(grid))
.collect::<Vec<_>>()
})
})
.unwrap_or_default();
// Convert output grid matches.
let output_matches = output_grid
.and_then(|grid| {
controller
.matches_for_block_grid(block_index, GridType::Output)
.map(|matches| {
matches
.iter()
.filter_map(|m| m.to_range(grid))
.collect::<Vec<_>>()
})
})
.unwrap_or_default();
// Get focused match ranges.
let focused_match = controller.focused_terminal_match();
let focused_command_range = focused_match
.as_ref()
.filter(|m| m.block_index == block_index && m.grid_type == GridType::PromptAndCommand)
.and_then(|m| command_grid.and_then(|grid| m.range.to_range(grid)));
let focused_output_range = focused_match
.as_ref()
.filter(|m| m.block_index == block_index && m.grid_type == GridType::Output)
.and_then(|m| output_grid.and_then(|grid| m.range.to_range(grid)));
Self::Async {
command_matches,
output_matches,
focused_command_range,
focused_output_range,
}
}
/// Returns an iterator over match ranges for the command grid.
pub fn command_grid_matches(
&self,
) -> Option<Box<dyn Iterator<Item = &RangeInclusive<Point>> + '_>> {
match self {
Self::Sync { run, block_index } => {
Some(run.matches_for_block_grid(*block_index, GridType::PromptAndCommand))
}
Self::Async {
command_matches, ..
} => Some(Box::new(command_matches.iter())),
}
}
/// Returns an iterator over match ranges for the output grid.
pub fn output_grid_matches(
&self,
) -> Option<Box<dyn Iterator<Item = &RangeInclusive<Point>> + '_>> {
match self {
Self::Sync { run, block_index } => {
Some(run.matches_for_block_grid(*block_index, GridType::Output))
}
Self::Async { output_matches, .. } => Some(Box::new(output_matches.iter())),
}
}
/// Returns the focused match range if it's in the specified grid.
pub fn focused_range_for_grid(&self, grid_type: GridType) -> Option<RangeInclusive<Point>> {
match self {
Self::Sync { run, block_index } => run.focused_match().and_then(|m| match m {
BlockListMatch::CommandBlock(grid_match)
if grid_match.block_index == *block_index
&& grid_match.grid_type == grid_type =>
{
Some(grid_match.range.clone())
}
_ => None,
}),
Self::Async {
focused_command_range,
focused_output_range,
..
} => match grid_type {
GridType::PromptAndCommand => focused_command_range.clone(),
GridType::Output => focused_output_range.clone(),
_ => None,
},
}
}
}
/// `TerminalView`-scoped model for the find bar.
pub struct TerminalFindModel {
@@ -36,11 +181,14 @@ pub struct TerminalFindModel {
/// The most recent find "run" on the alt screen, if any.
alt_screen_find_run: Option<AltScreenFindRun>,
/// The most recent find "run" on the block list, if any.
/// The most recent find "run" on the block list, if any (sync path).
block_list_find_run: Option<BlockListFindRun>,
/// `true` if the find bar is open.
is_find_bar_open: bool,
/// Controller for async find operations.
pub(crate) async_find_controller: Option<AsyncFindController>,
}
impl FindModel for TerminalFindModel {
@@ -49,6 +197,8 @@ impl FindModel for TerminalFindModel {
self.alt_screen_find_run
.as_ref()
.and_then(|run| run.focused_match_index())
} else if let Some(controller) = &self.async_find_controller {
controller.focused_match_index()
} else {
self.block_list_find_run
.as_ref()
@@ -62,6 +212,8 @@ impl FindModel for TerminalFindModel {
.as_ref()
.map(|run| run.matches().len())
.unwrap_or(0)
} else if let Some(controller) = &self.async_find_controller {
controller.match_count()
} else {
self.block_list_find_run
.as_ref()
@@ -77,24 +229,42 @@ impl FindModel for TerminalFindModel {
InputMode::PinnedToTop => FindDirection::Down,
}
}
fn is_scanning(&self) -> bool {
self.is_async_find_scanning()
}
}
impl TerminalFindModel {
pub fn new(terminal_model: Arc<FairMutex<TerminalModel>>) -> Self {
pub fn new(terminal_model: Arc<FairMutex<TerminalModel>>, ctx: &AppContext) -> Self {
let async_find_controller = if TerminalSettings::as_ref(ctx).is_async_find_enabled() {
Some(AsyncFindController::new(terminal_model.clone()))
} else {
None
};
Self {
terminal_model,
rich_content_views: HashMap::new(),
alt_screen_find_run: None,
block_list_find_run: None,
is_find_bar_open: false,
async_find_controller,
}
}
pub fn register_findable_rich_content_view<T: FindableRichContentView>(
&mut self,
view_handle: ViewHandle<T>,
) {
self.rich_content_views
.insert(view_handle.id(), Box::new(view_handle));
let view_id = view_handle.id();
let boxed_handle: Box<dyn FindableRichContentHandle> = Box::new(view_handle.clone());
// Register with async find controller if enabled.
if let Some(controller) = &mut self.async_find_controller {
controller.register_rich_content_view(view_id, Box::new(view_handle));
}
self.rich_content_views.insert(view_id, boxed_handle);
}
/// Returns `true` if the find bar is currently open.
@@ -117,10 +287,115 @@ impl TerminalFindModel {
self.block_list_find_run.as_ref()
}
/// Returns the currently focused match as a `BlockListMatch`.
///
/// This works for both sync and async find paths.
pub(crate) fn focused_block_list_match(&self) -> Option<BlockListMatch> {
let model = self.terminal_model.lock();
if model.is_alt_screen_active() {
// Alt screen doesn't use BlockListMatch.
return None;
}
if let Some(controller) = &self.async_find_controller {
// Async path: the focused match is either a terminal match or an
// AI match (or neither). Try each in turn and synthesize the
// corresponding `BlockListMatch` variant so consumers don't need
// to know which path produced the focus.
if let Some(async_match) = controller.focused_terminal_match() {
let block = model.block_list().block_at(async_match.block_index)?;
let grid = match async_match.grid_type {
GridType::PromptAndCommand => block.prompt_and_command_grid().grid_handler(),
GridType::Output => block.output_grid().grid_handler(),
_ => return None,
};
let range = async_match.range.to_range(grid)?;
return Some(BlockListMatch::CommandBlock(BlockGridMatch {
block_index: async_match.block_index,
grid_type: async_match.grid_type,
range,
is_filtered: false,
}));
}
if let Some(ai_match) = controller.focused_ai_match() {
return Some(BlockListMatch::RichContent {
match_id: ai_match.match_id,
view_id: ai_match.view_id,
index: ai_match.total_index,
});
}
None
} else {
// Sync path: get from block_list_find_run.
self.block_list_find_run
.as_ref()
.and_then(|run| run.focused_match())
.cloned()
}
}
/// Returns the focused rich content (AI) match id, if any.
///
/// This works for both sync and async find paths and is used by AI block
/// rendering to apply the focused-match highlight color.
pub(crate) fn focused_rich_content_match_id(&self) -> Option<RichContentMatchId> {
if self.terminal_model.lock().is_alt_screen_active() {
return None;
}
if let Some(controller) = &self.async_find_controller {
controller.focused_ai_match().map(|m| m.match_id)
} else {
self.block_list_find_run
.as_ref()
.and_then(|run| run.focused_match())
.and_then(|m| match m {
BlockListMatch::RichContent { match_id, .. } => Some(*match_id),
_ => None,
})
}
}
/// Returns find render data for a specific block, if find is active.
///
/// This works for both sync and async find paths.
///
/// Note: This method does NOT check if alt screen is active. It is intended
/// for use during blocklist rendering where the caller has already determined
/// that we are not in alt screen mode. Callers who need alt screen checking
/// should do so before calling this method.
///
/// For async find, the grid handlers are needed to convert from absolute to
/// relative coordinates and filter truncated matches.
pub(crate) fn find_render_data_for_block(
&self,
block_index: BlockIndex,
command_grid: Option<&GridHandler>,
output_grid: Option<&GridHandler>,
) -> Option<BlockFindRenderData<'_>> {
if let Some(controller) = &self.async_find_controller {
if !controller.has_active_find() {
return None;
}
Some(BlockFindRenderData::from_async(
controller,
block_index,
command_grid,
output_grid,
))
} else {
self.block_list_find_run
.as_ref()
.map(|run| BlockFindRenderData::from_sync(run, block_index))
}
}
/// Returns `FindOptions` applied to the active find run, if any.
pub fn active_find_options(&self) -> Option<&FindOptions> {
if self.terminal_model.lock().is_alt_screen_active() {
self.alt_screen_find_run.as_ref().map(|run| run.options())
} else if let Some(controller) = &self.async_find_controller {
controller.find_options()
} else {
self.block_list_find_run.as_ref().map(|run| run.options())
}
@@ -134,22 +409,35 @@ impl TerminalFindModel {
options,
self.terminal_model.lock().alt_screen(),
));
} else {
let _ = self.block_list_find_run.take();
let block_sort_direction = InputModeSettings::as_ref(ctx)
.input_mode
.value()
.block_sort_direction();
self.block_list_find_run = Some(run_find_on_block_list(
options,
self.terminal_model.lock().block_list(),
&self.rich_content_views,
block_sort_direction,
ctx,
));
ctx.emit(FindEvent::RanFind);
return;
}
let block_sort_direction = InputModeSettings::as_ref(ctx)
.input_mode
.value()
.block_sort_direction();
// Use async find if the feature flag is enabled.
if let Some(controller) = &mut self.async_find_controller {
log::trace!(
"[async_find] Starting async find with query: {:?}",
options.query
);
controller.start_find(&options, block_sort_direction, ctx);
ctx.emit(FindEvent::RanFind);
return;
}
// Synchronous path.
let _ = self.block_list_find_run.take();
self.block_list_find_run = Some(run_find_on_block_list(
options,
self.terminal_model.lock().block_list(),
&self.rich_content_views,
block_sort_direction,
ctx,
));
ctx.emit(FindEvent::RanFind);
}
@@ -162,37 +450,82 @@ impl TerminalFindModel {
Some(old_find_state.rerun(self.terminal_model.lock().alt_screen()));
ctx.emit(FindEvent::RanFind);
}
} else {
// Find the last block index. This is the only block whose state may change.
let last_block_index = self
.terminal_model
.lock()
.block_list()
.last_non_hidden_block_by_index()
.unwrap_or_default();
return;
}
// Call find on the the last block's command and output grids.
// If the block is a new finished block, the matches are inserted at a new key, the block's index in the blocklist.
// If the block is an active, running block, its matches are overwritten in the terminal's block_matches.
if let Some(block) = self
.terminal_model
.lock()
.block_list()
.block_at(last_block_index)
{
let block_sort_direction = InputModeSettings::as_ref(ctx)
.input_mode
.value()
.block_sort_direction();
// Handle async find path.
if let Some(controller) = &self.async_find_controller {
if !controller.has_active_find() {
return;
}
if let Some(old_find_run) = self.block_list_find_run.take() {
self.block_list_find_run = Some(old_find_run.rerun_on_block(
block,
last_block_index,
block_sort_direction,
));
ctx.emit(FindEvent::RanFind);
}
// Get the active block index and dirty range info.
// We use active_block_index() (not last_non_hidden_block_by_index) because
// the active block is where output is being written, even if it's still
// "empty" and would be filtered out by the default BlockFilter.
let mut model = self.terminal_model.lock();
let active_block_index = model.block_list().active_block_index();
// Consume dirty ranges from both grids. We need mutable access
// because take_find_dirty_rows_range is destructive.
let active_block = model.block_list_mut().active_block_mut();
let output_dirty_info =
active_block
.grid_of_type_mut(GridType::Output)
.and_then(|grid| {
let dirty = grid.grid_handler_mut().take_find_dirty_rows_range()?;
let truncated = grid.grid_handler().num_lines_truncated();
Some((dirty, GridType::Output, truncated))
});
let command_dirty_info = active_block
.grid_of_type_mut(GridType::PromptAndCommand)
.and_then(|grid| {
let dirty = grid.grid_handler_mut().take_find_dirty_rows_range()?;
let truncated = grid.grid_handler().num_lines_truncated();
Some((dirty, GridType::PromptAndCommand, truncated))
});
// Drop the model lock before emitting events.
drop(model);
self.invalidate_async_find_block(active_block_index, output_dirty_info, ctx);
if let Some(info) = command_dirty_info {
self.invalidate_async_find_block(active_block_index, Some(info), ctx);
}
return;
}
// Sync find path.
// Find the last block index. This is the only block whose state may change.
let last_block_index = self
.terminal_model
.lock()
.block_list()
.last_non_hidden_block_by_index()
.unwrap_or_default();
// Call find on the the last block's command and output grids.
// If the block is a new finished block, the matches are inserted at a new key, the block's index in the blocklist.
// If the block is an active, running block, its matches are overwritten in the terminal's block_matches.
if let Some(block) = self
.terminal_model
.lock()
.block_list()
.block_at(last_block_index)
{
let block_sort_direction = InputModeSettings::handle(ctx)
.as_ref(ctx)
.input_mode
.value()
.block_sort_direction();
if let Some(old_find_run) = self.block_list_find_run.take() {
self.block_list_find_run = Some(old_find_run.rerun_on_block(
block,
last_block_index,
block_sort_direction,
));
ctx.emit(FindEvent::RanFind);
}
}
}
@@ -210,6 +543,8 @@ impl TerminalFindModel {
if let Some(alt_screen_find_run) = self.alt_screen_find_run.as_mut() {
alt_screen_find_run.focus_next_match(find_direction);
}
} else if let Some(controller) = &mut self.async_find_controller {
controller.focus_next_match(find_direction);
} else if let Some(block_list_find_run) = self.block_list_find_run.as_mut() {
let block_sort_direction = InputModeSettings::as_ref(ctx)
.input_mode
@@ -221,12 +556,31 @@ impl TerminalFindModel {
ctx.emit(FindEvent::UpdatedFocusedMatch);
}
/// Notifies every registered rich-content child view (e.g. AI blocks) to
/// drop its cached find state and repaint, **without** touching the active
/// find run's options/config.
///
/// Callers that just need stale highlights to disappear (e.g.
/// `close_find_bar`) must use this rather than [`Self::clear_matches`].
/// On the async path, `clear_matches` routes through
/// `AsyncFindController::clear_results`, which also drops
/// `current_find_options` — losing the query that `open_find_bar` later
/// reads back via [`Self::active_find_options`] to restore the previous
/// search.
pub fn clear_rich_content_matches(&self, ctx: &mut ModelContext<Self>) {
for view in self.rich_content_views.values() {
view.clear_matches(ctx);
}
}
/// Clears matches in the active find run, if any.
pub fn clear_matches(&mut self, ctx: &mut ModelContext<Self>) {
if self.terminal_model.lock().is_alt_screen_active() {
if let Some(run) = self.alt_screen_find_run.take() {
self.alt_screen_find_run = Some(run.cleared());
}
} else if let Some(controller) = &mut self.async_find_controller {
controller.clear_results(ctx);
} else if let Some(run) = self.block_list_find_run.take() {
for (_, rich_content_view) in self.rich_content_views.iter() {
rich_content_view.clear_matches(ctx);
@@ -247,6 +601,11 @@ impl TerminalFindModel {
block_index: BlockIndex,
ctx: &mut ModelContext<Self>,
) {
// Async find handles block invalidation differently via invalidate_block().
if self.async_find_controller.is_some() {
return;
}
let terminal_model = self.terminal_model.lock();
if let (Some(block_list_find_run), Some(filtered_block)) = (
self.block_list_find_run.as_mut(),
@@ -265,6 +624,93 @@ impl TerminalFindModel {
ctx.emit(FindEvent::RanFind);
}
}
/// Returns true if an async find operation is currently scanning.
pub fn is_async_find_scanning(&self) -> bool {
self.async_find_controller
.as_ref()
.map(|c| c.is_scanning())
.unwrap_or(false)
}
/// Invalidates results for a specific block in async find.
///
/// This should be called when a block's content changes.
///
/// # 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.
/// * `ctx` - The model context.
pub fn invalidate_async_find_block(
&mut self,
block_index: BlockIndex,
dirty_info: Option<(RangeInclusive<usize>, GridType, u64)>,
ctx: &mut ModelContext<Self>,
) {
if let Some(controller) = self.async_find_controller.as_mut() {
controller.invalidate_block(block_index, dirty_info);
} else {
return;
}
ctx.emit(FindEvent::RanFind);
}
/// Notifies async find that a block has completed.
///
/// This should be called when a command finishes, so that the completed block
/// (which now has its final output) gets scanned for matches if find is active.
/// Uses the dirty range accumulated during execution for incremental scanning.
pub fn notify_block_completed(
&mut self,
block_index: BlockIndex,
ctx: &mut ModelContext<Self>,
) {
if self.async_find_controller.is_none() {
return;
}
// Check if there's an active find before acquiring the lock.
let has_active_find = self
.async_find_controller
.as_ref()
.map(|c| c.has_active_find())
.unwrap_or(false);
if !has_active_find {
return;
}
log::trace!(
"[async_find] notify_block_completed: block_index={:?}",
block_index
);
// Get the dirty range from the completed block's output grid.
// We need mutable access to consume the dirty range.
let (dirty_range, num_lines_truncated) = {
let mut model = self.terminal_model.lock();
model
.block_list_mut()
.block_at_mut(block_index)
.and_then(|block| block.grid_of_type_mut(GridType::Output))
.map(|output_grid| {
let dirty_range = output_grid.grid_handler_mut().take_find_dirty_rows_range();
let num_lines_truncated = output_grid.grid_handler().num_lines_truncated();
(dirty_range, num_lines_truncated)
})
.unwrap_or((None, 0))
};
// Use invalidate_async_find_block which handles the dirty range properly.
let dirty_info = dirty_range.map(|range| (range, GridType::Output, num_lines_truncated));
self.invalidate_async_find_block(block_index, dirty_info, ctx);
}
/// Returns the async find controller, if enabled.
pub fn async_find_controller(&self) -> Option<&AsyncFindController> {
self.async_find_controller.as_ref()
}
}
impl Entity for TerminalFindModel {
+5 -10
View File
@@ -1,16 +1,11 @@
//! This module implements terminal find functionality for the alt screen.
use std::ops::RangeInclusive;
use crate::{
terminal::model::{
alt_screen::AltScreen,
find::{FindConfig, RegexDFAs},
index::Point,
},
view_components::find::FindDirection,
};
use super::FindOptions;
use crate::terminal::model::alt_screen::AltScreen;
use crate::terminal::model::find::{FindConfig, RegexDFAs};
use crate::terminal::model::index::Point;
use crate::view_components::find::FindDirection;
/// Runs a find operation on the blocklist using the given `options` and returns an
/// `AltScreenFindRun` with the results.
@@ -170,5 +165,5 @@ impl AltScreenFindRun {
}
#[cfg(test)]
#[path = "alt_screen_test.rs"]
#[path = "alt_screen_tests.rs"]
mod tests;
@@ -1,8 +1,7 @@
use crate::terminal::{
find::model::{alt_screen::run_find_on_alt_screen, FindOptions},
model::index::Point,
TerminalModel,
};
use crate::terminal::find::model::alt_screen::run_find_on_alt_screen;
use crate::terminal::find::model::FindOptions;
use crate::terminal::model::index::Point;
use crate::terminal::TerminalModel;
#[test]
fn test_run_find_on_alt_screen() {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,292 @@
//! Background task for async find operations.
//!
//! This module contains the logic that runs on a background thread to scan
//! terminal blocks for matches without blocking the main thread. The task
//! pulls work items from a shared [`FindWorkQueue`] and streams results
//! back via an `async_channel`.
use std::ops::RangeInclusive;
use std::sync::Arc;
use futures_lite::future::yield_now;
use instant::Instant;
use parking_lot::FairMutex;
use warp_terminal::model::grid::Dimensions;
use warpui::{Entity, ModelContext};
use super::work_queue::{FindWorkItem, FindWorkQueue};
use super::{AbsoluteMatch, AsyncFindConfig, FindTaskMessage};
use crate::terminal::block_list_element::GridType;
use crate::terminal::model::find::{FindConfig, RegexDFAs};
use crate::terminal::model::grid::grid_handler::GridHandler;
use crate::terminal::model::index::Point;
use crate::terminal::model::terminal_model::BlockIndex;
use crate::terminal::model::TerminalModel;
/// Maximum time (in milliseconds) to hold the terminal model lock during a find chunk.
const MAX_LOCK_DURATION_MS: u64 = 5;
/// Number of rows to scan per chunk within a terminal block.
const ROWS_PER_CHUNK: usize = 1000;
/// Spawns a background find task that pulls work from the given queue.
///
/// Returns a handle that can be used to abort the spawned future.
pub fn spawn_find_task<E: Entity>(
config: AsyncFindConfig,
terminal_model: Arc<FairMutex<TerminalModel>>,
queue: FindWorkQueue,
result_tx: async_channel::Sender<FindTaskMessage>,
ctx: &mut ModelContext<E>,
) -> warpui::r#async::SpawnedFutureHandle {
ctx.spawn(
async move {
run_find_task_loop(config, terminal_model, queue, result_tx).await;
},
|_me, (), _ctx| {
// Task completed — nothing to do here as results are sent via channel.
},
)
}
/// Runs the main find task loop, pulling work items from the queue.
async fn run_find_task_loop(
config: AsyncFindConfig,
terminal_model: Arc<FairMutex<TerminalModel>>,
queue: FindWorkQueue,
result_tx: async_channel::Sender<FindTaskMessage>,
) {
// Build RegexDFAs from config.
let Ok(dfas) = RegexDFAs::new_with_config(
config.query.as_str(),
FindConfig {
is_regex_enabled: config.is_regex_enabled,
is_case_sensitive: config.is_case_sensitive,
},
) else {
// Invalid regex — signal completion with no matches.
let _ = result_tx.send(FindTaskMessage::Done).await;
return;
};
while let Ok((item, queue_drained)) = queue.pop().await {
match item {
FindWorkItem::FullBlock { block_index } => {
scan_terminal_block_chunked(
block_index,
&terminal_model,
&dfas,
&result_tx,
config.block_sort_direction,
)
.await;
}
FindWorkItem::DirtyRange {
block_index,
grid_type,
row_range,
num_lines_truncated,
} => {
scan_grid_chunked(
block_index,
grid_type,
*row_range.start(),
Some(*row_range.end() + 1),
ScanResultMode::DirtyRange {
num_lines_truncated,
},
&terminal_model,
&dfas,
&result_tx,
)
.await;
}
FindWorkItem::AIBlock {
view_id,
total_index,
} => {
// Forward to main thread for execution.
let _ = result_tx
.send(FindTaskMessage::ScanAIBlock {
view_id,
total_index,
})
.await;
}
}
// The emptiness flag is checked atomically with the pop inside
// the queue lock, avoiding the TOCTOU race of a separate
// `is_empty()` call.
if queue_drained {
let _ = result_tx.send(FindTaskMessage::Done).await;
}
}
}
/// Scans a terminal block in chunks, streaming results back to the main thread.
async fn scan_terminal_block_chunked(
block_index: BlockIndex,
terminal_model: &Arc<FairMutex<TerminalModel>>,
dfas: &RegexDFAs,
result_tx: &async_channel::Sender<FindTaskMessage>,
block_sort_direction: crate::terminal::model::terminal_model::BlockSortDirection,
) {
// Determine grid order based on sort direction.
let grid_order = match block_sort_direction {
crate::terminal::model::terminal_model::BlockSortDirection::MostRecentFirst => {
&[GridType::PromptAndCommand, GridType::Output]
}
crate::terminal::model::terminal_model::BlockSortDirection::MostRecentLast => {
&[GridType::Output, GridType::PromptAndCommand]
}
};
for &grid_type in grid_order {
scan_grid_chunked(
block_index,
grid_type,
0,
None,
ScanResultMode::FullBlock,
terminal_model,
dfas,
result_tx,
)
.await;
}
}
/// Controls how each chunk's matches are sent to the main thread.
enum ScanResultMode {
/// Send [`FindTaskMessage::BlockGridMatches`] per chunk. Empty chunks are
/// skipped (no message sent).
FullBlock,
/// Send [`FindTaskMessage::DirtyRangeMatches`] per chunk, converting the
/// scanned row range to absolute coordinates using the provided truncation
/// offset. Messages are always sent, even for empty chunks, so that old
/// matches in the sub-range are cleared.
DirtyRange { num_lines_truncated: u64 },
}
/// Scans a range of rows within a single grid in chunks, releasing the
/// terminal model lock between chunks to avoid blocking the main thread.
///
/// Both full-block scanning and dirty-range scanning delegate to this
/// function; the [`ScanResultMode`] determines the message type sent per
/// chunk.
///
/// # Arguments
/// * `start_row` — First row to scan (inclusive).
/// * `end_row` — Upper bound on rows to scan (exclusive). `None` scans to
/// the end of the grid.
/// * `mode` — Determines the message type sent per chunk.
#[allow(clippy::too_many_arguments)]
async fn scan_grid_chunked(
block_index: BlockIndex,
grid_type: GridType,
start_row: usize,
end_row: Option<usize>,
mode: ScanResultMode,
terminal_model: &Arc<FairMutex<TerminalModel>>,
dfas: &RegexDFAs,
result_tx: &async_channel::Sender<FindTaskMessage>,
) {
let mut current_row = start_row;
loop {
let chunk_result = {
let lock_start = Instant::now();
let model = terminal_model.lock();
let Some(block) = model.block_list().block_at(block_index) else {
// Block no longer exists.
return;
};
let grid = match grid_type {
GridType::Output => block.output_grid(),
GridType::PromptAndCommand => block.prompt_and_command_grid(),
_ => return,
};
let grid_handler = grid.grid_handler();
let total_rows = grid_handler.total_rows();
let effective_end = end_row.unwrap_or(total_rows).min(total_rows);
if current_row >= effective_end {
return;
}
let chunk_end = (current_row + ROWS_PER_CHUNK).min(effective_end);
let point_matches = scan_grid_range(grid_handler, dfas, current_row, chunk_end);
let matches: Vec<AbsoluteMatch> = point_matches
.iter()
.map(|range| AbsoluteMatch::from_range(range, grid_handler))
.collect();
let elapsed = lock_start.elapsed();
(matches, chunk_end, effective_end, elapsed)
};
let (mut matches, chunk_end, effective_end, elapsed) = chunk_result;
// find_in_range returns matches in descending order; reverse to ascending.
matches.reverse();
// Send chunk results based on mode.
match &mode {
ScanResultMode::FullBlock => {
if !matches.is_empty() {
let _ = result_tx
.send(FindTaskMessage::BlockGridMatches {
block_index,
grid_type,
matches,
})
.await;
}
}
ScanResultMode::DirtyRange {
num_lines_truncated,
} => {
let absolute_start = current_row as u64 + num_lines_truncated;
let absolute_end = (chunk_end - 1) as u64 + num_lines_truncated;
let _ = result_tx
.send(FindTaskMessage::DirtyRangeMatches {
block_index,
grid_type,
dirty_range: absolute_start..=absolute_end,
matches,
})
.await;
}
}
if chunk_end >= effective_end {
break;
}
current_row = chunk_end;
// Yield to let other tasks run if we held the lock for a while.
if elapsed.as_millis() > MAX_LOCK_DURATION_MS as u128 / 2 {
yield_now().await;
}
}
}
/// Scans a range of rows in a grid for matches.
fn scan_grid_range(
grid: &GridHandler,
dfas: &RegexDFAs,
start_row: usize,
end_row: usize,
) -> Vec<RangeInclusive<Point>> {
let columns = Dimensions::columns(grid);
let start_point = Point::new(start_row, 0);
let end_point = Point::new(end_row.saturating_sub(1), columns.saturating_sub(1));
grid.find_in_range(dfas, start_point, end_point).collect()
}
@@ -0,0 +1,204 @@
//! Work queue for the async find background task.
//!
//! The [`FindWorkQueue`] is shared between the main thread (which enqueues work)
//! and the background task (which pulls items via [`FindWorkQueue::pop`]).
//! Internally it uses an [`event_listener::Event`] to efficiently wake the
//! background task when new work is available.
use std::collections::VecDeque;
use std::ops::RangeInclusive;
use std::sync::{Arc, Mutex};
use event_listener::Event;
use warpui::EntityId;
use super::BlockInfo;
use crate::terminal::block_list_element::GridType;
use crate::terminal::model::blocks::TotalIndex;
use crate::terminal::model::terminal_model::BlockIndex;
/// A unit of work for the background find task.
#[derive(Debug, Clone)]
pub enum FindWorkItem {
/// Scan an entire terminal block.
FullBlock { block_index: BlockIndex },
/// Scan a dirty range within a specific grid of a terminal block.
DirtyRange {
block_index: BlockIndex,
grid_type: GridType,
row_range: RangeInclusive<usize>,
num_lines_truncated: u64,
},
/// Request scanning of an AI block on the main thread.
AIBlock {
view_id: EntityId,
total_index: TotalIndex,
},
}
/// Error returned by [`FindWorkQueue::pop`] when the queue has been closed.
#[derive(Debug)]
pub struct QueueClosed;
struct FindWorkQueueInner {
items: VecDeque<FindWorkItem>,
closed: bool,
}
/// A shared work queue for async find operations.
///
/// The controller enqueues work items from the main thread, and the background
/// task pulls them via the async [`pop`](FindWorkQueue::pop) method. When the
/// queue is empty, `pop` blocks until new work arrives or the queue is closed.
#[derive(Clone)]
pub struct FindWorkQueue {
inner: Arc<Mutex<FindWorkQueueInner>>,
event: Arc<Event>,
}
impl FindWorkQueue {
/// Creates a new empty work queue.
pub fn new() -> Self {
Self {
inner: Arc::new(Mutex::new(FindWorkQueueInner {
items: VecDeque::new(),
closed: false,
})),
event: Arc::new(Event::new()),
}
}
/// Populates the queue with initial scan items from a block info list.
///
/// Terminal blocks become [`FindWorkItem::ScanFullBlock`] items and rich
/// content blocks become [`FindWorkItem::ScanAIBlock`] items. Items are
/// pushed in the order provided (typically newest-first from
/// [`collect_block_info`](super::collect_block_info)).
pub fn enqueue_full_scan(&self, blocks: &[BlockInfo]) {
let mut inner = self.inner.lock().unwrap();
for block in blocks {
let item = match block {
BlockInfo::Terminal { block_index, .. } => FindWorkItem::FullBlock {
block_index: *block_index,
},
BlockInfo::RichContent {
view_id,
total_index,
} => FindWorkItem::AIBlock {
view_id: *view_id,
total_index: *total_index,
},
};
inner.items.push_back(item);
}
drop(inner);
// Wake the background task if it is waiting.
self.event.notify(1);
}
/// Enqueues work for a block that has been invalidated.
///
/// If a [`FindWorkItem::ScanFullBlock`] for this block is already pending in
/// the queue, this is a no-op: the pending scan will pick up the latest
/// content. Otherwise, the appropriate work item is pushed to the **front**
/// of the queue so it is processed before remaining initial-scan items.
pub fn invalidate_block(
&self,
block_index: BlockIndex,
dirty_range: Option<(RangeInclusive<usize>, GridType, u64)>,
) {
let mut inner = self.inner.lock().unwrap();
// If there is already a pending full scan for this block, do nothing.
let has_pending_full_scan = inner.items.iter().any(|item| {
matches!(item, FindWorkItem::FullBlock { block_index: idx } if *idx == block_index)
});
if has_pending_full_scan {
return;
}
// Enqueue the appropriate item at the front (high priority).
let item = match dirty_range {
Some((row_range, grid_type, num_lines_truncated)) => FindWorkItem::DirtyRange {
block_index,
grid_type,
row_range,
num_lines_truncated,
},
None => FindWorkItem::FullBlock { block_index },
};
inner.items.push_front(item);
drop(inner);
self.event.notify(1);
}
/// Pulls the next work item from the queue.
///
/// If the queue is empty, the returned future blocks until an item is
/// enqueued or the queue is closed. Returns `Err(QueueClosed)` when the
/// queue has been closed and no items remain.
///
/// The returned `bool` indicates whether the queue was empty immediately
/// after the pop (checked atomically within the same lock scope).
pub async fn pop(&self) -> Result<(FindWorkItem, bool), QueueClosed> {
loop {
// Check for an available item or closed state.
{
let mut inner = self.inner.lock().unwrap();
if let Some(item) = inner.items.pop_front() {
let is_empty = inner.items.is_empty();
return Ok((item, is_empty));
}
if inner.closed {
return Err(QueueClosed);
}
}
// Queue is empty and not closed. Register a listener before
// re-checking to avoid a race between the check and the listen.
let listener = self.event.listen();
// Re-check after registering the listener.
{
let mut inner = self.inner.lock().unwrap();
if let Some(item) = inner.items.pop_front() {
let is_empty = inner.items.is_empty();
return Ok((item, is_empty));
}
if inner.closed {
return Err(QueueClosed);
}
}
// Wait for a notification.
listener.await;
}
}
/// Closes the queue, waking any blocked [`pop`](FindWorkQueue::pop) call.
///
/// After closing, `pop` will drain remaining items and then return
/// `Err(QueueClosed)`.
pub fn close(&self) {
let mut inner = self.inner.lock().unwrap();
inner.closed = true;
drop(inner);
self.event.notify(usize::MAX);
}
/// Removes all pending items from the queue.
pub fn clear(&self) {
let mut inner = self.inner.lock().unwrap();
inner.items.clear();
}
/// Returns `true` if the queue has no pending items.
pub fn is_empty(&self) -> bool {
self.inner.lock().unwrap().items.is_empty()
}
/// Returns the number of pending items.
pub fn len(&self) -> usize {
self.inner.lock().unwrap().items.len()
}
}
@@ -0,0 +1,948 @@
//! Tests for async find functionality.
use std::collections::HashMap;
use std::sync::Arc;
use parking_lot::FairMutex;
use warpui::{App, EntityId};
use super::{
is_query_refinement, AbsoluteMatch, AsyncFindConfig, AsyncFindController, AsyncFindStatus,
BlockFindResults, FindTaskMessage,
};
use crate::terminal::block_list_element::GridType;
use crate::terminal::find::model::block_list::run_find_on_block_list;
use crate::terminal::find::model::{FindOptions, TerminalFindModel};
use crate::terminal::find::{BlockListMatch, RichContentMatchId};
use crate::terminal::model::blocks::TotalIndex;
use crate::terminal::model::grid::grid_handler::AbsolutePoint;
use crate::terminal::model::index::Point;
use crate::terminal::model::terminal_model::{BlockIndex, BlockSortDirection};
use crate::terminal::model::TerminalModel;
use crate::test_util::settings::initialize_settings_for_tests;
use crate::view_components::find::FindDirection;
/// Helper to create an AbsoluteMatch at a given row with default column span.
fn make_match(row: u64) -> AbsoluteMatch {
AbsoluteMatch {
start: AbsolutePoint { row, col: 0 },
end: AbsolutePoint { row, col: 5 },
}
}
/// Helper to create an AbsoluteMatch at a given row and column range.
fn make_match_at(row: u64, start_col: usize, end_col: usize) -> AbsoluteMatch {
AbsoluteMatch {
start: AbsolutePoint {
row,
col: start_col,
},
end: AbsolutePoint { row, col: end_col },
}
}
#[test]
fn test_async_find_produces_same_results_as_sync_find() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
let mut mock_terminal_model = TerminalModel::mock(None, None);
mock_terminal_model.simulate_block("foobar", "foo\r\nbar\r\n");
mock_terminal_model.simulate_block("barbaz", "bar baz\r\n");
let terminal_model = Arc::new(FairMutex::new(mock_terminal_model));
// Run sync find for comparison.
let sync_run = app.update(|ctx| {
run_find_on_block_list(
FindOptions {
query: Some("bar".to_owned().into()),
is_regex_enabled: false,
is_case_sensitive: false,
..Default::default()
},
terminal_model.lock().block_list(),
&HashMap::new(),
BlockSortDirection::MostRecentLast,
ctx,
)
});
// Run async find using TerminalFindModel.
let test_model = app.add_model(|ctx| {
let mut model = TerminalFindModel::new(terminal_model.clone(), ctx);
if model.async_find_controller.is_none() {
model.async_find_controller =
Some(AsyncFindController::new(terminal_model.clone()));
}
model
});
test_model.update(&mut app, |model, ctx| {
model.async_find_controller.as_mut().unwrap().start_find(
&FindOptions {
query: Some("bar".to_owned().into()),
is_regex_enabled: false,
is_case_sensitive: false,
..Default::default()
},
BlockSortDirection::MostRecentLast,
ctx,
);
});
// Wait for async find to complete. The stream-based delivery processes
// results automatically; we just need to yield to the executor.
for _ in 0..100 {
let is_complete = test_model.update(&mut app, |model, _ctx| {
model
.async_find_controller
.as_ref()
.map(|c| matches!(c.status(), AsyncFindStatus::Complete))
.unwrap_or(false)
});
if is_complete {
break;
}
// Small delay to let background task and stream delivery run.
warpui::r#async::Timer::after(std::time::Duration::from_millis(10)).await;
}
let (status, async_count) = test_model.update(&mut app, |model, _ctx| {
let c = model.async_find_controller.as_ref().unwrap();
(c.status().clone(), c.match_count())
});
assert_eq!(
status,
AsyncFindStatus::Complete,
"Async find should complete"
);
// Compare match counts.
let sync_count = sync_run.matches().count();
assert_eq!(
async_count, sync_count,
"Async find should produce same number of matches as sync find"
);
// Verify the matches are in the expected blocks and grids.
let model = terminal_model.lock();
for sync_match in sync_run.matches() {
if let BlockListMatch::CommandBlock(grid_match) = sync_match {
let async_matches = test_model.update(&mut app, |m, _ctx| {
m.async_find_controller
.as_ref()
.unwrap()
.matches_for_block_grid(grid_match.block_index, grid_match.grid_type)
.cloned()
});
assert!(
async_matches.is_some(),
"Async find should have matches for block {:?} grid {:?}",
grid_match.block_index,
grid_match.grid_type
);
// Convert async match to relative range and compare.
let block = model.block_list().block_at(grid_match.block_index).unwrap();
let grid = match grid_match.grid_type {
GridType::Output => block.output_grid().grid_handler(),
GridType::PromptAndCommand => block.prompt_and_command_grid().grid_handler(),
_ => continue,
};
let async_ranges: Vec<_> = async_matches
.unwrap()
.iter()
.filter_map(|m| m.to_range(grid))
.collect();
assert!(
async_ranges.contains(&grid_match.range),
"Async find should contain match {:?} in block {:?} grid {:?}",
grid_match.range,
grid_match.block_index,
grid_match.grid_type
);
}
}
});
}
#[test]
fn test_async_find_cancellation() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
let mut mock_terminal_model = TerminalModel::mock(None, None);
// Create some blocks with content.
mock_terminal_model.simulate_block("cmd1", "line1\r\nline2\r\n");
mock_terminal_model.simulate_block("cmd2", "line3\r\nline4\r\n");
let terminal_model = Arc::new(FairMutex::new(mock_terminal_model));
let test_model = app.add_model(|ctx| {
let mut model = TerminalFindModel::new(terminal_model.clone(), ctx);
if model.async_find_controller.is_none() {
model.async_find_controller =
Some(AsyncFindController::new(terminal_model.clone()));
}
model
});
// Start a find operation.
test_model.update(&mut app, |model, ctx| {
model.async_find_controller.as_mut().unwrap().start_find(
&FindOptions {
query: Some("line".to_owned().into()),
is_regex_enabled: false,
is_case_sensitive: false,
..Default::default()
},
BlockSortDirection::MostRecentLast,
ctx,
);
});
// Verify we're scanning.
let is_scanning = test_model.update(&mut app, |model, _ctx| {
model.async_find_controller.as_ref().unwrap().is_scanning()
});
assert!(is_scanning, "Should be scanning after starting find");
// Cancel the find.
test_model.update(&mut app, |model, _ctx| {
model
.async_find_controller
.as_mut()
.unwrap()
.cancel_current_find();
});
// Verify cancellation state.
let (is_scanning, has_active) = test_model.update(&mut app, |model, _ctx| {
let c = model.async_find_controller.as_ref().unwrap();
(c.is_scanning(), c.has_active_find())
});
assert!(!is_scanning, "Should not be scanning after cancellation");
assert!(has_active, "Config should still be set after cancellation");
// Clear results should reset everything.
test_model.update(&mut app, |model, ctx| {
model
.async_find_controller
.as_mut()
.unwrap()
.clear_results(ctx);
});
let (has_active, status) = test_model.update(&mut app, |model, _ctx| {
let c = model.async_find_controller.as_ref().unwrap();
(c.has_active_find(), c.status().clone())
});
assert!(
!has_active,
"Should not have active find after clear_results"
);
assert_eq!(
status,
AsyncFindStatus::Idle,
"Status should be Idle after clear_results"
);
});
}
#[test]
fn test_message_processing_updates_state() {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
let mock_terminal_model = TerminalModel::mock(None, None);
let terminal_model = Arc::new(FairMutex::new(mock_terminal_model));
let test_model = app.add_model(|ctx| {
let mut model = TerminalFindModel::new(terminal_model.clone(), ctx);
let mut controller = AsyncFindController::new(terminal_model);
// Manually set up state as if a find is in progress.
controller.set_test_status(AsyncFindStatus::Scanning);
model.async_find_controller = Some(controller);
model
});
// Process a BlockGridMatches message directly.
test_model.update(&mut app, |model, ctx| {
model
.async_find_controller
.as_mut()
.unwrap()
.process_message(
FindTaskMessage::BlockGridMatches {
block_index: BlockIndex(1),
grid_type: GridType::Output,
matches: vec![make_match_at(0, 0, 2), make_match_at(1, 0, 2)],
},
ctx,
);
});
// Verify state updates.
let (match_count, status, focused_idx) = test_model.update(&mut app, |model, _ctx| {
let c = model.async_find_controller.as_ref().unwrap();
(c.match_count(), c.status().clone(), c.focused_match_index())
});
assert_eq!(match_count, 2, "Should have 2 matches");
assert_eq!(
status,
AsyncFindStatus::Scanning,
"Status should still be scanning until Done is received"
);
assert_eq!(focused_idx, Some(0), "Should auto-focus first match");
// Process a Done message.
test_model.update(&mut app, |model, ctx| {
model
.async_find_controller
.as_mut()
.unwrap()
.process_message(FindTaskMessage::Done, ctx);
});
let status = test_model.update(&mut app, |model, _ctx| {
model
.async_find_controller
.as_ref()
.unwrap()
.status()
.clone()
});
assert_eq!(
status,
AsyncFindStatus::Complete,
"Status should be Complete after Done message"
);
});
}
#[test]
fn test_block_invalidation_with_dirty_range() {
// Test that dirty range invalidation merges correctly with existing matches.
let mut results = BlockFindResults::default();
let block_index = BlockIndex(0);
let grid_type = GridType::Output;
// Seed with matches at absolute rows 5, 15, 25.
results.terminal_matches.insert(
(block_index, grid_type),
vec![
make_match_at(5, 0, 2),
make_match_at(15, 0, 2),
make_match_at(25, 0, 2),
],
);
// Dirty range 10..=20 overlaps with match at row 15.
// New matches found in dirty range: rows 12 and 18.
let new_matches = vec![make_match_at(12, 0, 2), make_match_at(18, 0, 2)];
results.update_dirty_matches(block_index, grid_type, 10..=20, new_matches);
let stored = results
.terminal_matches
.get(&(block_index, grid_type))
.unwrap();
// Should have: 5, 12, 18, 25 (match at 15 was replaced).
assert_eq!(stored.len(), 4);
assert_eq!(stored[0].start_row(), 5);
assert_eq!(stored[1].start_row(), 12);
assert_eq!(stored[2].start_row(), 18);
assert_eq!(stored[3].start_row(), 25);
}
#[test]
fn test_focus_next_match_wraps_around() {
let mock_terminal_model = TerminalModel::mock(None, None);
let terminal_model = Arc::new(FairMutex::new(mock_terminal_model));
let mut controller = AsyncFindController::new(terminal_model);
// Manually add some matches.
controller.block_results_mut().terminal_matches.insert(
(BlockIndex(0), GridType::Output),
vec![
make_match_at(0, 0, 2),
make_match_at(1, 0, 2),
make_match_at(2, 0, 2),
],
);
assert_eq!(controller.match_count(), 3);
// Default block_sort_direction is MostRecentLast, so:
// Down = decrement (toward newest/index 0)
// Up = increment (toward oldest/higher indices)
// Focus first match from None.
controller.focus_next_match(FindDirection::Down);
assert_eq!(controller.focused_match_index(), Some(0));
// Down decrements: 0 wraps to last index.
controller.focus_next_match(FindDirection::Down);
assert_eq!(controller.focused_match_index(), Some(2));
// Down decrements: 2 → 1.
controller.focus_next_match(FindDirection::Down);
assert_eq!(controller.focused_match_index(), Some(1));
// Down decrements: 1 → 0.
controller.focus_next_match(FindDirection::Down);
assert_eq!(controller.focused_match_index(), Some(0));
// Up increments: 0 → 1.
controller.focus_next_match(FindDirection::Up);
assert_eq!(controller.focused_match_index(), Some(1));
// Up increments: 1 → 2.
controller.focus_next_match(FindDirection::Up);
assert_eq!(controller.focused_match_index(), Some(2));
// Up wraps: 2 → 0.
controller.focus_next_match(FindDirection::Up);
assert_eq!(controller.focused_match_index(), Some(0));
}
#[test]
fn test_is_query_refinement() {
assert!(is_query_refinement("hel", "hello"));
assert!(is_query_refinement("foo", "foobar"));
assert!(!is_query_refinement("hello", "hel"));
assert!(!is_query_refinement("hello", "hello"));
assert!(!is_query_refinement("bar", "foo"));
assert!(!is_query_refinement("", "hello"));
}
#[test]
fn test_async_find_config_from_options() {
// Empty query should return None.
let options = FindOptions::default();
assert!(AsyncFindConfig::from_options(&options, BlockSortDirection::MostRecentLast).is_none());
// Query with only whitespace should return None.
let options = FindOptions {
query: Some(Arc::new(" ".to_string())),
..Default::default()
};
assert!(AsyncFindConfig::from_options(&options, BlockSortDirection::MostRecentLast).is_none());
// Valid query should return Some config.
let options = FindOptions {
query: Some(Arc::new("hello".to_string())),
is_case_sensitive: true,
is_regex_enabled: false,
blocks_to_include_in_results: Some(vec![BlockIndex(0), BlockIndex(1)]),
};
let config = AsyncFindConfig::from_options(&options, BlockSortDirection::MostRecentFirst);
assert!(config.is_some());
let config = config.unwrap();
assert_eq!(config.query.as_str(), "hello");
assert!(config.is_case_sensitive);
assert!(!config.is_regex_enabled);
assert_eq!(
config.blocks_to_include,
Some(vec![BlockIndex(0), BlockIndex(1)])
);
}
#[test]
fn test_block_find_results_total_count() {
let mut results = BlockFindResults::default();
assert_eq!(results.total_match_count(), 0);
// Add some terminal matches.
results
.terminal_matches
.entry((BlockIndex(0), GridType::Output))
.or_default()
.push(make_match(0));
assert_eq!(results.total_match_count(), 1);
// Add more terminal matches.
results
.terminal_matches
.entry((BlockIndex(0), GridType::Output))
.or_default()
.push(make_match(1));
results
.terminal_matches
.entry((BlockIndex(1), GridType::PromptAndCommand))
.or_default()
.push(make_match(0));
assert_eq!(results.total_match_count(), 3);
}
#[test]
fn test_block_find_results_remove_block() {
let mut results = BlockFindResults::default();
// Add matches for block 0 and block 1.
results
.terminal_matches
.entry((BlockIndex(0), GridType::Output))
.or_default()
.push(make_match(0));
results
.terminal_matches
.entry((BlockIndex(0), GridType::PromptAndCommand))
.or_default()
.push(make_match(0));
results
.terminal_matches
.entry((BlockIndex(1), GridType::Output))
.or_default()
.push(make_match(0));
assert_eq!(results.total_match_count(), 3);
// Remove block 0.
results.remove_block(BlockIndex(0));
assert_eq!(results.total_match_count(), 1);
// Block 1 should still have its matches.
assert!(results
.terminal_matches
.contains_key(&(BlockIndex(1), GridType::Output)));
}
#[test]
fn test_async_find_status_display() {
assert_eq!(format!("{}", AsyncFindStatus::Idle), "Idle");
assert_eq!(format!("{}", AsyncFindStatus::Complete), "Complete");
assert_eq!(format!("{}", AsyncFindStatus::Scanning), "Scanning");
}
#[test]
fn test_absolute_match_is_truncated() {
let match_at_row_5 = make_match(5);
// Not truncated when num_lines_truncated <= start row.
assert!(!match_at_row_5.is_truncated(0));
assert!(!match_at_row_5.is_truncated(5));
// Truncated when num_lines_truncated > start row.
assert!(match_at_row_5.is_truncated(6));
assert!(match_at_row_5.is_truncated(100));
}
#[test]
fn test_update_dirty_matches_empty_existing() {
let mut results = BlockFindResults::default();
let block_index = BlockIndex(0);
let grid_type = GridType::Output;
// Update with new matches when there are no existing matches.
let new_matches = vec![make_match(5), make_match(10), make_match(15)];
results.update_dirty_matches(block_index, grid_type, 5..=15, new_matches.clone());
let stored = results
.terminal_matches
.get(&(block_index, grid_type))
.unwrap();
assert_eq!(stored.len(), 3);
assert_eq!(stored[0].start_row(), 5);
assert_eq!(stored[1].start_row(), 10);
assert_eq!(stored[2].start_row(), 15);
}
#[test]
fn test_update_dirty_matches_prepend() {
let mut results = BlockFindResults::default();
let block_index = BlockIndex(0);
let grid_type = GridType::Output;
// Seed with matches at rows 20, 30.
results.terminal_matches.insert(
(block_index, grid_type),
vec![make_match(20), make_match(30)],
);
// Update with dirty range before all existing matches.
let new_matches = vec![make_match(5), make_match(10)];
results.update_dirty_matches(block_index, grid_type, 5..=10, new_matches);
let stored = results
.terminal_matches
.get(&(block_index, grid_type))
.unwrap();
assert_eq!(stored.len(), 4);
assert_eq!(stored[0].start_row(), 5);
assert_eq!(stored[1].start_row(), 10);
assert_eq!(stored[2].start_row(), 20);
assert_eq!(stored[3].start_row(), 30);
}
#[test]
fn test_update_dirty_matches_append() {
let mut results = BlockFindResults::default();
let block_index = BlockIndex(0);
let grid_type = GridType::Output;
// Seed with matches at rows 5, 10.
results.terminal_matches.insert(
(block_index, grid_type),
vec![make_match(5), make_match(10)],
);
// Update with dirty range after all existing matches.
let new_matches = vec![make_match(20), make_match(30)];
results.update_dirty_matches(block_index, grid_type, 20..=30, new_matches);
let stored = results
.terminal_matches
.get(&(block_index, grid_type))
.unwrap();
assert_eq!(stored.len(), 4);
assert_eq!(stored[0].start_row(), 5);
assert_eq!(stored[1].start_row(), 10);
assert_eq!(stored[2].start_row(), 20);
assert_eq!(stored[3].start_row(), 30);
}
#[test]
fn test_update_dirty_matches_replace_middle() {
let mut results = BlockFindResults::default();
let block_index = BlockIndex(0);
let grid_type = GridType::Output;
// Seed with matches at rows 5, 15, 25.
results.terminal_matches.insert(
(block_index, grid_type),
vec![make_match(5), make_match(15), make_match(25)],
);
// Update dirty range 10..=20, which overlaps with the match at row 15.
// Replace it with matches at rows 12 and 18.
let new_matches = vec![make_match(12), make_match(18)];
results.update_dirty_matches(block_index, grid_type, 10..=20, new_matches);
let stored = results
.terminal_matches
.get(&(block_index, grid_type))
.unwrap();
assert_eq!(stored.len(), 4);
assert_eq!(stored[0].start_row(), 5);
assert_eq!(stored[1].start_row(), 12);
assert_eq!(stored[2].start_row(), 18);
assert_eq!(stored[3].start_row(), 25);
}
#[test]
fn test_update_dirty_matches_clear_range() {
let mut results = BlockFindResults::default();
let block_index = BlockIndex(0);
let grid_type = GridType::Output;
// Seed with matches at rows 5, 15, 25.
results.terminal_matches.insert(
(block_index, grid_type),
vec![make_match(5), make_match(15), make_match(25)],
);
// Update dirty range 10..=20 with no new matches (clears the match at row 15).
results.update_dirty_matches(block_index, grid_type, 10..=20, vec![]);
let stored = results
.terminal_matches
.get(&(block_index, grid_type))
.unwrap();
assert_eq!(stored.len(), 2);
assert_eq!(stored[0].start_row(), 5);
assert_eq!(stored[1].start_row(), 25);
}
fn assert_async_focused_order_matches_sync(block_sort_direction: BlockSortDirection) {
App::test((), |mut app| async move {
initialize_settings_for_tests(&mut app);
let mut mock_terminal_model = TerminalModel::mock(None, None);
mock_terminal_model.simulate_block(
"ordtok command old ordtok",
"ordtok old output one\r\nold output ordtok two\r\n",
);
mock_terminal_model.simulate_block(
"ordtok command new ordtok",
"ordtok new output one\r\nnew output ordtok two\r\n",
);
let terminal_model = Arc::new(FairMutex::new(mock_terminal_model));
let find_options = FindOptions {
query: Some("ordtok".to_owned().into()),
is_regex_enabled: false,
is_case_sensitive: false,
..Default::default()
};
let sync_order = app.update(|ctx| {
run_find_on_block_list(
find_options.clone(),
terminal_model.lock().block_list(),
&HashMap::new(),
block_sort_direction,
ctx,
)
.matches()
.filter_map(|m| match m {
BlockListMatch::CommandBlock(grid_match) => Some((
grid_match.block_index,
grid_match.grid_type,
*grid_match.range.start(),
*grid_match.range.end(),
)),
_ => None,
})
.collect::<Vec<_>>()
});
let test_model = app.add_model(|ctx| {
let mut model = TerminalFindModel::new(terminal_model.clone(), ctx);
if model.async_find_controller.is_none() {
model.async_find_controller =
Some(AsyncFindController::new(terminal_model.clone()));
}
model
});
test_model.update(&mut app, |model, ctx| {
model
.async_find_controller
.as_mut()
.expect("Async find controller should exist in test.")
.start_find(&find_options, block_sort_direction, ctx);
});
for _ in 0..100 {
let is_complete = test_model.update(&mut app, |model, _ctx| {
model
.async_find_controller
.as_ref()
.map(|c| matches!(c.status(), AsyncFindStatus::Complete))
.unwrap_or(false)
});
if is_complete {
break;
}
warpui::r#async::Timer::after(std::time::Duration::from_millis(10)).await;
}
let (status, async_match_count) = test_model.update(&mut app, |model, _ctx| {
let controller = model
.async_find_controller
.as_ref()
.expect("Async find controller should exist in test.");
(controller.status().clone(), controller.match_count())
});
assert_eq!(
status,
AsyncFindStatus::Complete,
"Async find should complete.",
);
assert_eq!(
async_match_count,
sync_order.len(),
"Async and sync paths should find the same number of terminal matches.",
);
let async_order_absolute = test_model.update(&mut app, |model, _ctx| {
let controller = model
.async_find_controller
.as_mut()
.expect("Async find controller should exist in test.");
let mut ordered = Vec::new();
for index in 0..controller.match_count() {
controller.focused_match_index = Some(index);
controller.update_cached_focused_match();
let focused = controller
.focused_terminal_match()
.expect("Every focused index should resolve to a terminal match in this test.");
ordered.push((focused.block_index, focused.grid_type, focused.range));
}
ordered
});
let async_order = {
let model = terminal_model.lock();
async_order_absolute
.into_iter()
.map(|(block_index, grid_type, absolute_match)| {
let block = model
.block_list()
.block_at(block_index)
.expect("Block should exist for focused async match.");
let grid = match grid_type {
GridType::Output => block.output_grid().grid_handler(),
GridType::PromptAndCommand => {
block.prompt_and_command_grid().grid_handler()
}
_ => panic!("Unexpected grid type in async focused match."),
};
let range = absolute_match
.to_range(grid)
.expect("Async focused match should map to a non-truncated range.");
(block_index, grid_type, *range.start(), *range.end())
})
.collect::<Vec<(BlockIndex, GridType, Point, Point)>>()
};
assert_eq!(
async_order, sync_order,
"Async focused ordering should match sync ordering for {:?}.",
block_sort_direction
);
});
}
#[test]
fn test_async_focused_order_matches_sync_most_recent_last() {
assert_async_focused_order_matches_sync(BlockSortDirection::MostRecentLast);
}
#[test]
fn test_async_focused_order_matches_sync_most_recent_first() {
assert_async_focused_order_matches_sync(BlockSortDirection::MostRecentFirst);
}
#[test]
fn test_focused_ai_match_resolves_only_ai_block() {
let mock_terminal_model = TerminalModel::mock(None, None);
let terminal_model = Arc::new(FairMutex::new(mock_terminal_model));
let mut controller = AsyncFindController::new(terminal_model);
// Seed a single AI block with two matches. Default block sort direction is
// MostRecentLast, which reverses per-AI-block traversal at iteration time.
let view_id = EntityId::from_usize(42);
let ai_match_a = RichContentMatchId::default();
let ai_match_b = RichContentMatchId::default();
{
let results = controller.block_results_mut();
results
.ai_matches
.insert(view_id, vec![ai_match_a, ai_match_b]);
results.ai_total_indices.insert(view_id, TotalIndex(7));
}
assert_eq!(controller.match_count(), 2);
assert!(
controller.focused_terminal_match().is_none(),
"There are no terminal matches; focused_terminal_match should be None."
);
// MostRecentLast reverses per-AI-block iteration, so index 0 resolves to
// the last stored match (ai_match_b) and index 1 to the first.
controller.focused_match_index = Some(0);
controller.update_cached_focused_match();
let focused = controller
.focused_ai_match()
.expect("AI match should be focused at index 0.");
assert_eq!(focused.view_id, view_id);
assert_eq!(focused.match_id, ai_match_b);
assert_eq!(focused.total_index, TotalIndex(7));
assert!(
controller.focused_terminal_match().is_none(),
"Terminal cache must be cleared when focus lands on an AI match."
);
controller.focused_match_index = Some(1);
controller.update_cached_focused_match();
let focused = controller
.focused_ai_match()
.expect("AI match should be focused at index 1.");
assert_eq!(focused.match_id, ai_match_a);
}
#[test]
fn test_focused_ai_match_most_recent_first_preserves_storage_order() {
let mock_terminal_model = TerminalModel::mock(None, None);
let terminal_model = Arc::new(FairMutex::new(mock_terminal_model));
let mut controller = AsyncFindController::new(terminal_model);
// Override the default MostRecentLast so we exercise the un-reversed
// per-AI-block iteration path.
controller.block_sort_direction = BlockSortDirection::MostRecentFirst;
let view_id = EntityId::from_usize(99);
let ai_match_a = RichContentMatchId::default();
let ai_match_b = RichContentMatchId::default();
{
let results = controller.block_results_mut();
results
.ai_matches
.insert(view_id, vec![ai_match_a, ai_match_b]);
results.ai_total_indices.insert(view_id, TotalIndex(3));
}
// MostRecentFirst iterates storage order: index 0 -> first, index 1 -> last.
controller.focused_match_index = Some(0);
controller.update_cached_focused_match();
let focused = controller
.focused_ai_match()
.expect("AI match should be focused at index 0.");
assert_eq!(focused.match_id, ai_match_a);
controller.focused_match_index = Some(1);
controller.update_cached_focused_match();
let focused = controller
.focused_ai_match()
.expect("AI match should be focused at index 1.");
assert_eq!(focused.match_id, ai_match_b);
}
#[test]
fn test_focused_match_index_walks_across_terminal_and_ai_blocks() {
let mock_terminal_model = TerminalModel::mock(None, None);
let terminal_model = Arc::new(FairMutex::new(mock_terminal_model));
let mut controller = AsyncFindController::new(terminal_model);
// Two blocks at different TotalIndex positions:
// - AI block (TotalIndex 5, newer) with one match.
// - Terminal block at BlockIndex(0) (TotalIndex 1, older) with one
// Output match. The AI block is sorted first because its TotalIndex
// is higher.
let ai_view_id = EntityId::from_usize(11);
let ai_match = RichContentMatchId::default();
let terminal_match = make_match(0);
{
let results = controller.block_results_mut();
results.ai_matches.insert(ai_view_id, vec![ai_match]);
results.ai_total_indices.insert(ai_view_id, TotalIndex(5));
results
.terminal_matches
.insert((BlockIndex(0), GridType::Output), vec![terminal_match]);
results
.terminal_total_indices
.insert(BlockIndex(0), TotalIndex(1));
}
assert_eq!(controller.match_count(), 2);
// Index 0 -> AI match (newest block, AI block in this fixture).
controller.focused_match_index = Some(0);
controller.update_cached_focused_match();
let focused_ai = controller
.focused_ai_match()
.expect("Index 0 should resolve to AI match.");
assert_eq!(focused_ai.view_id, ai_view_id);
assert_eq!(focused_ai.match_id, ai_match);
assert!(
controller.focused_terminal_match().is_none(),
"Terminal cache must be empty when focus is on AI block."
);
// Index 1 -> terminal match (older block).
controller.focused_match_index = Some(1);
controller.update_cached_focused_match();
assert!(
controller.focused_ai_match().is_none(),
"AI cache must be empty when focus is on terminal block."
);
let focused_terminal = controller
.focused_terminal_match()
.expect("Index 1 should resolve to terminal match.");
assert_eq!(focused_terminal.block_index, BlockIndex(0));
assert_eq!(focused_terminal.grid_type, GridType::Output);
}
+112 -25
View File
@@ -1,29 +1,25 @@
//! This module implements terminal find functionality for the blocklist.
use std::{collections::HashMap, iter, ops::RangeInclusive};
use std::collections::HashMap;
use std::iter;
use std::ops::RangeInclusive;
use galaxyui::{units::Lines, AppContext, EntityId};
use itertools::Itertools;
use galaxyui::units::Lines;
use galaxyui::{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 super::rich_content::{FindableRichContentHandle, RichContentMatchId};
use super::FindOptions;
use crate::terminal::model::block::Block;
use crate::terminal::model::blocks::{
BlockHeight, BlockHeightItem, BlockHeightSummary, BlockList, RichContentItem, TotalIndex,
};
use crate::terminal::model::find::{FindConfig, RegexDFAs};
use crate::terminal::model::index::Point;
use crate::terminal::model::terminal_model::{BlockIndex, BlockSortDirection};
use crate::terminal::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.
///
@@ -243,6 +239,18 @@ pub struct BlockGridMatch {
}
/// Represents a single find match in the blocklist.
///
/// Match values are snapshots of the find run that produced them. The grid
/// `range` on `CommandBlock` and the `index` on `RichContent` are captured at
/// scan time and can be invalidated by subsequent block list mutations (new
/// blocks, removals, rich content rescans, etc.). Callers should consume
/// cloned values inline; long-lived storage outside a `BlockListFindRun` is
/// not supported.
///
/// TODO(vkodithala): The `RichContent` variant mirrors `AsyncFocusedAiMatch` in the async
/// 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, PartialEq, Eq)]
pub enum BlockListMatch {
CommandBlock(BlockGridMatch),
@@ -288,6 +296,40 @@ impl BlockListMatch {
_ => false,
}
}
/// Returns `true` if `self` and `other` refer to the same matched span, ignoring transient
/// state like `is_filtered`.
fn same_span(&self, other: &BlockListMatch) -> bool {
match (self, other) {
(
BlockListMatch::CommandBlock(BlockGridMatch {
grid_type: g1,
range: r1,
block_index: b1,
..
}),
BlockListMatch::CommandBlock(BlockGridMatch {
grid_type: g2,
range: r2,
block_index: b2,
..
}),
) => g1 == g2 && r1 == r2 && b1 == b2,
(
BlockListMatch::RichContent {
match_id: id1,
view_id: v1,
index: i1,
},
BlockListMatch::RichContent {
match_id: id2,
view_id: v2,
index: i2,
},
) => id1 == id2 && v1 == v2 && i1 == i2,
_ => false,
}
}
}
/// Represents the result of a find "run" on the blocklist.
@@ -471,11 +513,17 @@ impl BlockListFindRun {
return self;
};
// Remember the currently focused match so we can relocate it after splicing.
let old_focused_match = self
.raw_focused_match_index
.and_then(|i| self.matches.get(i).cloned());
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);
let new_matches = run_find_on_block(dfas, block, block_index, block_sort_direction);
let new_block_match_count = new_matches.len();
if let Some(start_index) = old_block_matches_start_index {
let end_index = old_block_matches_start_index
.and_then(|i| {
@@ -486,21 +534,60 @@ impl BlockListFindRun {
})
.unwrap_or(self.matches.len());
let old_block_match_count = end_index - start_index;
// Splice in the new matches where the old block matches used to exist.
self.matches.splice(start_index..end_index, new_matches);
// Adjust the focused match index so it still points to the same match.
if let Some(focused_index) = self.raw_focused_match_index {
if focused_index >= start_index && focused_index < end_index {
// The focused match was inside the rerun block. Try to find the same
// match (by span identity) in the new results.
self.raw_focused_match_index = old_focused_match
.as_ref()
.and_then(|old_match| {
self.matches[start_index..(start_index + new_block_match_count)]
.iter()
.position(|m| m.same_span(old_match))
.map(|p| start_index + p)
})
.or_else(|| {
// The old match no longer exists; clamp to a valid index.
if self.matches.is_empty() {
None
} else {
Some(focused_index.min(self.matches.len() - 1))
}
});
} else if focused_index >= end_index {
// The focused match was after the rerun block. Shift by the change in
// match count so it continues to point at the same match.
let new_index = focused_index + new_block_match_count - old_block_match_count;
self.raw_focused_match_index =
Some(new_index.min(self.matches.len().saturating_sub(1)));
}
// If focused_index < start_index the match is before the rerun block and
// needs no adjustment.
}
} else {
let mut new_matches = new_matches;
new_matches.append(&mut self.matches);
self.matches = new_matches;
// All previous indices shifted forward by the number of newly prepended matches.
if let Some(focused_index) = self.raw_focused_match_index {
self.raw_focused_match_index = Some(focused_index + new_block_match_count);
}
}
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);
} else if let Some(focused_match_index) = self.raw_focused_match_index {
// Final bounds check.
if focused_match_index >= self.matches.len() {
self.raw_focused_match_index = Some(self.matches.len() - 1);
}
self.raw_focused_match_index = Some(focused_match_index);
}
self
@@ -663,5 +750,5 @@ fn update_matches_for_filtered_block<'a>(
}
#[cfg(test)]
#[path = "block_list_test.rs"]
#[path = "block_list_tests.rs"]
mod tests;
@@ -3,17 +3,15 @@ use std::collections::HashMap;
use galaxyui::App;
use itertools::Itertools;
use crate::terminal::{
block_filter::BlockFilterQuery,
find::{
model::{block_list::run_find_on_block_list, FindOptions},
BlockGridMatch,
},
model::{index::Point, terminal_model::BlockSortDirection},
GridType, TerminalModel,
};
use super::{BlockListFindRun, BlockListMatch};
use crate::terminal::block_filter::BlockFilterQuery;
use crate::terminal::find::model::block_list::run_find_on_block_list;
use crate::terminal::find::model::FindOptions;
use crate::terminal::find::BlockGridMatch;
use crate::terminal::model::index::Point;
use crate::terminal::model::terminal_model::{BlockIndex, BlockSortDirection};
use crate::terminal::{GridType, TerminalModel};
use crate::view_components::find::FindDirection;
impl BlockListFindRun {
fn all_matches(&self) -> &[BlockListMatch] {
@@ -353,3 +351,142 @@ fn test_run_find_on_block_list_with_filtered_block() {
);
});
}
/// Regression test for https://github.com/warpdotdev/warp/issues/9542
///
/// When the active block's output is still streaming and the find results are refreshed,
/// the focused match must remain on the same text span even though new matches are
/// inserted before it in the match vector.
#[test]
fn test_rerun_on_block_preserves_focused_match_in_active_block() {
App::test((), |mut app| async move {
let mut mock_terminal_model = TerminalModel::mock(None, None);
// Block 1: a finished block.
mock_terminal_model.simulate_block("echo bar", "bar\r\n");
// Block 2: a long-running block whose command also matches so there are both
// output and prompt matches. This lets us navigate to the prompt match and then
// verify it stays focused after new output matches are spliced in before it.
mock_terminal_model.simulate_long_running_block("barserver", "request bar\r\n");
let last_block_index: BlockIndex = 2.into();
let mut run = app.update(|ctx| {
run_find_on_block_list(
FindOptions {
query: Some("bar".to_owned().into()),
is_regex_enabled: false,
is_case_sensitive: false,
..Default::default()
},
mock_terminal_model.block_list(),
&HashMap::new(),
BlockSortDirection::MostRecentLast,
ctx,
)
});
// In MostRecentLast the match order for block 2 is:
// [0] Output row 0, col 8..=10 ("bar" in "request bar")
// [1] Prompt row 0, col 0..=2 ("bar" in "barserver")
// Navigate "Up" once to move from the output match to the prompt match.
run.focus_next_match(FindDirection::Up, BlockSortDirection::MostRecentLast);
let focused_before = run.focused_match().cloned();
assert_eq!(
focused_before,
Some(BlockListMatch::CommandBlock(BlockGridMatch {
grid_type: GridType::PromptAndCommand,
range: Point { row: 0, col: 0 }..=Point { row: 0, col: 2 },
block_index: last_block_index,
is_filtered: false,
}))
);
// Simulate more streaming output that introduces new output matches before the
// prompt match in the match vector.
mock_terminal_model.process_bytes("request bar\r\nrequest bar\r\n");
let block = mock_terminal_model
.block_list()
.block_at(last_block_index)
.unwrap();
let run = run.rerun_on_block(block, last_block_index, BlockSortDirection::MostRecentLast);
// The focused match must still be the same prompt span, even though new output
// matches were inserted before it in the active block's match slice.
assert_eq!(run.focused_match().cloned(), focused_before);
});
}
/// Regression test for https://github.com/warpdotdev/warp/issues/9542
///
/// When the user is focused on a match in an older (finished) block and the active block
/// receives new streaming output, the focus must not drift to a different match.
#[test]
fn test_rerun_on_block_preserves_focused_match_in_older_block() {
App::test((), |mut app| async move {
let mut mock_terminal_model = TerminalModel::mock(None, None);
mock_terminal_model.simulate_block("echo bar", "bar\r\n");
mock_terminal_model.simulate_long_running_block("server", "request bar\r\n");
let last_block_index: BlockIndex = 2.into();
let older_block_index: BlockIndex = 1.into();
let mut run = app.update(|ctx| {
run_find_on_block_list(
FindOptions {
query: Some("bar".to_owned().into()),
is_regex_enabled: false,
is_case_sensitive: false,
..Default::default()
},
mock_terminal_model.block_list(),
&HashMap::new(),
BlockSortDirection::MostRecentLast,
ctx,
)
});
// Navigate past the active block's matches to reach block 1's output match.
// Matches order (MostRecentLast): block 2 output, block 2 prompt ("server" has no
// match), block 1 output, block 1 prompt.
// Initial focus is at index 0 (block 2 output row 0).
// "Up" in MostRecentLast moves toward older blocks (higher index).
let match_count = run.all_matches().len();
for _ in 0..match_count {
if run
.focused_match()
.is_some_and(|m| m.matches_block(older_block_index))
{
break;
}
run.focus_next_match(FindDirection::Up, BlockSortDirection::MostRecentLast);
}
let focused_before = run.focused_match().cloned();
assert!(
focused_before
.as_ref()
.is_some_and(|m| m.matches_block(older_block_index)),
"expected focus on block 1, got {focused_before:?}"
);
let ui_index_before = run.focused_match_index();
// Simulate new streaming output in the active block.
mock_terminal_model.process_bytes("request bar\r\nrequest bar\r\n");
let block = mock_terminal_model
.block_list()
.block_at(last_block_index)
.unwrap();
let run = run.rerun_on_block(block, last_block_index, BlockSortDirection::MostRecentLast);
// The focused match must still be the same span in block 1.
assert_eq!(run.focused_match().cloned(), focused_before);
// The UI index should have shifted to account for the newly inserted matches.
assert_ne!(
run.focused_match_index(),
ui_index_before,
"UI index should change when new matches are inserted before the focused match"
);
});
}
+1 -1
View File
@@ -45,7 +45,7 @@ pub trait FindableRichContentView: View {
/// New rich content views do _not_ require a new `FindableRichContentHandle` implementation;
/// this is an implementation detail of the `FindModel`-internal usage of the
/// `FindableRichContentView` trait.
pub(super) trait FindableRichContentHandle {
pub(crate) trait FindableRichContentHandle {
fn run_find(&self, options: &FindOptions, ctx: &mut AppContext) -> Vec<RichContentMatchId>;
fn clear_matches(&self, ctx: &mut AppContext);
+2 -2
View File
@@ -1,10 +1,10 @@
//! Exports helper test-only methods for use in unit and integration tests.
use itertools::Itertools;
use super::block_list::BlockListMatch;
use super::{BlockListFindRun, TerminalFindModel};
use crate::terminal::model::terminal_model::BlockIndex;
use super::{block_list::BlockListMatch, BlockListFindRun, TerminalFindModel};
impl TerminalFindModel {
pub fn visible_block_list_match_count(&self) -> usize {
self.block_list_find_run