first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
+4
-5
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
+147
-10
@@ -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"
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user