From 46fceefeedb9016b5374d4145047a1c5d2b2885d Mon Sep 17 00:00:00 2001 From: David Bonan Date: Tue, 9 Dec 2025 23:50:59 +0100 Subject: [PATCH 01/35] Adds quick search modal Implements a quick search modal for project-wide searching. This modal allows users to quickly search for files and content within the project. It includes a picker for displaying search results and a preview editor for showing the selected result. The modal is resizable using drag handles on the right, bottom, and corner. --- Cargo.lock | 4 + crates/search/Cargo.toml | 4 + crates/search/src/quick_search.rs | 1047 +++++++++++++++++++++++++++++ crates/search/src/search.rs | 2 + 4 files changed, 1057 insertions(+) create mode 100644 crates/search/src/quick_search.rs diff --git a/Cargo.lock b/Cargo.lock index 616e8988683983..13a800baccbfcf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14561,12 +14561,15 @@ dependencies = [ "client", "collections", "editor", + "file_icons", "futures 0.3.31", "gpui", "itertools 0.14.0", "language", + "log", "lsp", "menu", + "picker", "pretty_assertions", "project", "schemars", @@ -14574,6 +14577,7 @@ dependencies = [ "serde_json", "settings", "smol", + "text", "theme", "tracing", "ui", diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index 02eb611fc22570..c4d370aef1f7b8 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -26,16 +26,20 @@ any_vec.workspace = true bitflags.workspace = true collections.workspace = true editor.workspace = true +file_icons.workspace = true futures.workspace = true gpui.workspace = true language.workspace = true +log.workspace = true menu.workspace = true +picker.workspace = true project.workspace = true schemars.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true smol.workspace = true +text.workspace = true theme.workspace = true ui.workspace = true util.workspace = true diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs new file mode 100644 index 00000000000000..e05781ca90ac89 --- /dev/null +++ b/crates/search/src/quick_search.rs @@ -0,0 +1,1047 @@ +use editor::Editor; +use file_icons::FileIcons; +use futures::StreamExt; +use gpui::{ + App, Bounds, Context, DismissEvent, DragMoveEvent, Entity, EventEmitter, FocusHandle, + Focusable, MouseButton, Pixels, Render, SharedString, Subscription, Task, WeakEntity, Window, + actions, canvas, prelude::*, +}; +use language::Buffer; +use picker::{Picker, PickerDelegate}; +use project::{Project, ProjectPath, search::SearchQuery}; +use std::{fmt::Write as _, path::Path, pin::pin, sync::Arc, time::Duration}; +use text::ToPoint as _; +use ui::{Color, Icon, IconName, Label, ListItem, ListItemSpacing, prelude::*}; +use util::{ResultExt, paths::PathMatcher}; +use workspace::{ModalView, Workspace}; + +use crate::SearchOptions; + +const DEFAULT_WIDTH: Pixels = px(1100.); +const DEFAULT_HEIGHT: Pixels = px(650.); +const MIN_WIDTH: Pixels = px(600.); +const MIN_HEIGHT: Pixels = px(300.); +const MAX_WIDTH: Pixels = px(1800.); +const MAX_HEIGHT: Pixels = px(1000.); +const LEFT_PANEL_WIDTH: Pixels = px(350.); +const RESIZE_HANDLE_SIZE: Pixels = px(6.); + +actions!(search, [QuickSearch]); + +pub fn init(cx: &mut App) { + cx.observe_new(QuickSearchModal::register).detach(); +} + +#[derive(Clone)] +struct DragResizeHandle { + axis: ResizeAxis, +} + +#[derive(Clone, Copy, PartialEq)] +enum ResizeAxis { + Horizontal, + Vertical, + Both, +} + +impl Render for DragResizeHandle { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + gpui::Empty + } +} + +enum QuickSearchItem { + FileHeader { + file_name: SharedString, + parent_path: SharedString, + }, + LineMatch { + project_path: ProjectPath, + buffer: Entity, + line: u32, + line_label: SharedString, + preview_text: SharedString, + }, +} + +pub struct QuickSearchDelegate { + workspace: WeakEntity, + project: Entity, + search_options: SearchOptions, + items: Vec, + selected_index: usize, + pending_search_id: usize, + quick_search: WeakEntity, +} + +pub struct QuickSearchModal { + picker: Entity>, + preview_editor: Option>, + preview_buffer: Option>, + width: Pixels, + height: Pixels, + bounds: Bounds, + _subscriptions: Vec, +} + +impl ModalView for QuickSearchModal {} + +impl EventEmitter for QuickSearchModal {} + +impl Focusable for QuickSearchModal { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.picker.focus_handle(cx) + } +} + +impl Render for QuickSearchModal { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let preview_editor = self.preview_editor.clone(); + let width = self.width; + let height = self.height; + let entity = cx.entity(); + + let picker = self.picker.clone(); + + div() + .id("quick-search-modal") + .relative() + .w(width) + .h(height) + .child( + canvas( + move |bounds, _, cx| { + entity.update(cx, |this, _| { + this.bounds = bounds; + }); + }, + |_, _, _, _| {}, + ) + .absolute() + .size_full(), + ) + .child( + v_flex() + .elevation_3(cx) + .size_full() + .overflow_hidden() + .border_1() + .border_color(cx.theme().colors().border) + .on_mouse_down_out(cx.listener(|_, _, _, cx| { + cx.emit(DismissEvent); + })) + .child( + h_flex() + .w_full() + .px_3() + .py_2() + .bg(cx.theme().colors().title_bar_background) + .border_b_1() + .border_color(cx.theme().colors().border) + .child( + Label::new("Quick Search") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + .child( + h_flex() + .flex_1() + .overflow_hidden() + .child( + v_flex() + .w(LEFT_PANEL_WIDTH) + .h_full() + .border_r_1() + .border_color(cx.theme().colors().border) + .child(self.picker.clone()), + ) + .child( + div() + .id("quick-search-preview") + .relative() + .flex_1() + .h_full() + .overflow_hidden() + .bg(cx.theme().colors().editor_background) + .on_click(move |_, window, cx| { + window.focus(&picker.focus_handle(cx)); + }) + .when_some(preview_editor, |this, editor| { + this.child(div().size_full().child(editor)) + }) + .when(self.preview_editor.is_none(), |this| { + this.child( + div() + .size_full() + .flex() + .items_center() + .justify_center() + .child( + Label::new("Select a result to preview") + .color(Color::Muted), + ), + ) + }) + .child(self.render_resize_handle_right(cx)) + .child(self.render_resize_handle_bottom(cx)) + .child(self.render_resize_handle_corner(cx)), + ), + ), + ) + } +} + +impl QuickSearchModal { + fn render_resize_handle_right(&self, cx: &mut Context) -> impl IntoElement { + div() + .id("resize-handle-right") + .absolute() + .top_0() + .right_0() + .w(RESIZE_HANDLE_SIZE) + .h(self.height - RESIZE_HANDLE_SIZE) + .cursor_e_resize() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_drag( + DragResizeHandle { + axis: ResizeAxis::Horizontal, + }, + |drag, _, _, cx| cx.new(|_| drag.clone()), + ) + .on_drag_move(cx.listener(Self::handle_resize)) + } + + fn render_resize_handle_bottom(&self, cx: &mut Context) -> impl IntoElement { + div() + .id("resize-handle-bottom") + .absolute() + .bottom_0() + .left_0() + .w_full() + .h(RESIZE_HANDLE_SIZE) + .cursor_s_resize() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_drag( + DragResizeHandle { + axis: ResizeAxis::Vertical, + }, + |drag, _, _, cx| cx.new(|_| drag.clone()), + ) + .on_drag_move(cx.listener(Self::handle_resize)) + } + + fn render_resize_handle_corner(&self, cx: &mut Context) -> impl IntoElement { + div() + .id("resize-handle-corner") + .absolute() + .bottom_0() + .right_0() + .w(RESIZE_HANDLE_SIZE * 2.) + .h(RESIZE_HANDLE_SIZE * 2.) + .cursor_nwse_resize() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_drag( + DragResizeHandle { + axis: ResizeAxis::Both, + }, + |drag, _, _, cx| cx.new(|_| drag.clone()), + ) + .on_drag_move(cx.listener(Self::handle_resize)) + } + + fn handle_resize( + &mut self, + event: &DragMoveEvent, + _window: &mut Window, + cx: &mut Context, + ) { + let drag = event.drag(cx); + let position = event.event.position; + + let new_width = match drag.axis { + ResizeAxis::Horizontal | ResizeAxis::Both => (position.x - self.bounds.origin.x) + .max(MIN_WIDTH) + .min(MAX_WIDTH), + ResizeAxis::Vertical => self.width, + }; + + let new_height = match drag.axis { + ResizeAxis::Vertical | ResizeAxis::Both => (position.y - self.bounds.origin.y) + .max(MIN_HEIGHT) + .min(MAX_HEIGHT), + ResizeAxis::Horizontal => self.height, + }; + + if new_width != self.width || new_height != self.height { + self.width = new_width; + self.height = new_height; + cx.notify(); + } + } +} + +impl QuickSearchModal { + fn register( + workspace: &mut Workspace, + _window: Option<&mut Window>, + _cx: &mut Context, + ) { + workspace.register_action(|workspace, _: &QuickSearch, window, cx| { + let project = workspace.project().clone(); + let weak_workspace = cx.entity().downgrade(); + workspace.toggle_modal(window, cx, |window, cx| { + QuickSearchModal::new(weak_workspace, project, window, cx) + }); + }); + } + + fn new( + workspace: WeakEntity, + project: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let weak_self = cx.entity().downgrade(); + + let delegate = QuickSearchDelegate { + workspace, + project, + search_options: SearchOptions::NONE, + items: Vec::new(), + selected_index: 0, + pending_search_id: 0, + quick_search: weak_self, + }; + + let picker = cx.new(|cx| { + Picker::uniform_list(delegate, window, cx) + .modal(false) + .max_height(None) + .show_scrollbar(true) + }); + + let subscriptions = vec![cx.subscribe_in(&picker, window, Self::on_picker_event)]; + + Self { + picker, + preview_editor: None, + preview_buffer: None, + width: DEFAULT_WIDTH, + height: DEFAULT_HEIGHT, + bounds: Bounds::default(), + _subscriptions: subscriptions, + } + } + + fn on_picker_event( + &mut self, + _picker: &Entity>, + _event: &DismissEvent, + _window: &mut Window, + cx: &mut Context, + ) { + cx.emit(DismissEvent); + } + + fn update_preview( + &mut self, + buffer: Option<(Entity, u32)>, + window: &mut Window, + cx: &mut Context, + ) { + let Some((buffer, line)) = buffer else { + self.preview_editor = None; + self.preview_buffer = None; + cx.notify(); + return; + }; + + let same_buffer = self + .preview_buffer + .as_ref() + .map_or(false, |b| b.entity_id() == buffer.entity_id()); + + if same_buffer { + if let Some(editor) = &self.preview_editor { + editor.update(cx, |editor, cx| { + let point = text::Point::new(line, 0); + editor.go_to_singleton_buffer_point(point, window, cx); + }); + } + } else { + let editor = cx.new(|cx| { + let mut editor = Editor::for_buffer(buffer.clone(), None, window, cx); + editor.set_read_only(true); + editor.set_show_gutter(true, cx); + editor + }); + + editor.update(cx, |editor, cx| { + let point = text::Point::new(line, 0); + editor.go_to_singleton_buffer_point(point, window, cx); + }); + + self.preview_editor = Some(editor); + self.preview_buffer = Some(buffer); + } + cx.notify(); + } +} + +impl PickerDelegate for QuickSearchDelegate { + type ListItem = ListItem; + + fn match_count(&self) -> usize { + self.items.len() + } + + fn selected_index(&self) -> usize { + self.selected_index + } + + fn set_selected_index( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) { + if self.items.is_empty() { + self.selected_index = 0; + return; + } + + let ix = ix.min(self.items.len().saturating_sub(1)); + + if matches!(self.items.get(ix), Some(QuickSearchItem::LineMatch { .. })) { + self.selected_index = ix; + return; + } + + let going_down = ix >= self.selected_index; + + if going_down { + if let Some(next) = self + .items + .iter() + .skip(ix) + .position(|item| matches!(item, QuickSearchItem::LineMatch { .. })) + { + self.selected_index = ix + next; + return; + } + } + + let upper_bound = ix.min(self.items.len().saturating_sub(1)); + if let Some(prev) = self.items[..=upper_bound] + .iter() + .rposition(|item| matches!(item, QuickSearchItem::LineMatch { .. })) + { + self.selected_index = prev; + } else if let Some(next) = self + .items + .iter() + .position(|item| matches!(item, QuickSearchItem::LineMatch { .. })) + { + self.selected_index = next; + } + } + + fn selected_index_changed( + &self, + _ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option> { + let quick_search = self.quick_search.clone(); + let preview_data = match self.items.get(self.selected_index) { + Some(QuickSearchItem::LineMatch { buffer, line, .. }) => Some((buffer.clone(), *line)), + _ => None, + }; + + Some(Box::new(move |window, cx| { + if let Some(quick_search) = quick_search.upgrade() { + quick_search.update(cx, |qs, cx| { + qs.update_preview(preview_data.clone(), window, cx); + }); + } + })) + } + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + "Search in project...".into() + } + + fn update_matches( + &mut self, + query: String, + window: &mut Window, + cx: &mut Context>, + ) -> Task<()> { + if query.is_empty() { + self.items.clear(); + self.pending_search_id = 0; + let quick_search = self.quick_search.clone(); + cx.defer_in(window, move |_, window, cx| { + if let Some(quick_search) = quick_search.upgrade() { + quick_search.update(cx, |qs, cx| { + qs.preview_editor = None; + qs.preview_buffer = None; + cx.notify(); + }); + } + let _ = window; + }); + cx.notify(); + return Task::ready(()); + } + + self.pending_search_id += 1; + let search_id = self.pending_search_id; + let project = self.project.clone(); + let search_options = self.search_options; + let quick_search = self.quick_search.clone(); + + cx.spawn_in(window, async move |picker, cx| { + smol::Timer::after(Duration::from_millis(100)).await; + + let is_stale = picker + .update(cx, |picker, _| { + picker.delegate.pending_search_id != search_id + }) + .unwrap_or(true); + if is_stale { + return; + } + + let search_query = match SearchQuery::text( + &query, + search_options.contains(SearchOptions::WHOLE_WORD), + search_options.contains(SearchOptions::CASE_SENSITIVE), + search_options.contains(SearchOptions::INCLUDE_IGNORED), + PathMatcher::default(), + PathMatcher::default(), + false, + None, + ) { + Ok(q) => q, + Err(err) => { + log::warn!("Quick search: invalid query '{}': {}", query, err); + return; + } + }; + + let search_results = project + .update(cx, |project, cx| project.search(search_query, cx)) + .ok(); + + let Some(search_results) = search_results else { + return; + }; + + let mut items = Vec::new(); + let max_line_matches = 200; + let mut line_match_count = 0; + + let mut search_results = pin!(search_results); + while let Some(result) = search_results.next().await { + match result { + project::search::SearchResult::Buffer { buffer, ranges } => { + if ranges.is_empty() { + continue; + } + + let match_data_list = cx + .read_entity(&buffer, |buf, cx| { + let snapshot = buf.snapshot(); + let file = buf.file(); + let project_path = file.map(|f| ProjectPath { + worktree_id: f.worktree_id(cx), + path: f.path().clone(), + }); + + let Some(project_path) = project_path else { + return Vec::new(); + }; + + let file_name: SharedString = project_path + .path + .file_name() + .map(|n| n.to_string()) + .unwrap_or_default() + .into(); + let parent_path: SharedString = project_path + .path + .parent() + .map(|p| p.as_unix_str().to_string()) + .unwrap_or_default() + .into(); + + let mut seen_lines = std::collections::HashSet::new(); + let mut results = Vec::new(); + + for range in &ranges { + let start_point = range.start.to_point(&snapshot); + let line = start_point.row; + + if !seen_lines.insert(line) { + continue; + } + + let line_start = + snapshot.point_to_offset(text::Point::new(line, 0)); + let line_end_col = snapshot.line_len(line); + let line_end = snapshot + .point_to_offset(text::Point::new(line, line_end_col)); + + const MAX_PREVIEW_CHARS: usize = 200; + let mut preview_text = String::with_capacity(MAX_PREVIEW_CHARS); + let mut chars_remaining = MAX_PREVIEW_CHARS; + let mut started = false; + + for chunk in snapshot.chunks(line_start..line_end, false) { + let text = if !started { + started = true; + chunk.text.trim_start() + } else { + chunk.text + }; + + if text.len() <= chars_remaining { + preview_text.push_str(text); + chars_remaining -= text.len(); + } else { + for ch in text.chars() { + if chars_remaining == 0 { + break; + } + preview_text.push(ch); + chars_remaining -= 1; + } + preview_text.push('…'); + break; + } + } + + let preview_text: SharedString = + preview_text.trim_end().to_string().into(); + + let mut line_label = String::with_capacity(8); + let _ = write!(line_label, "{}", line + 1); + let line_label: SharedString = line_label.into(); + + results.push(( + project_path.clone(), + line, + line_label, + preview_text, + file_name.clone(), + parent_path.clone(), + )); + } + + results + }) + .log_err() + .unwrap_or_default(); + + if !match_data_list.is_empty() { + let first = &match_data_list[0]; + items.push(QuickSearchItem::FileHeader { + file_name: first.4.clone(), + parent_path: first.5.clone(), + }); + + for ( + project_path, + line, + line_label, + preview, + _file_name, + _parent_path, + ) in match_data_list + { + items.push(QuickSearchItem::LineMatch { + project_path, + buffer: buffer.clone(), + line, + line_label, + preview_text: preview, + }); + + line_match_count += 1; + if line_match_count >= max_line_matches { + break; + } + } + } + + if line_match_count >= max_line_matches { + break; + } + } + project::search::SearchResult::LimitReached => break, + } + } + + let first_line_match = items.iter().find_map(|item| { + if let QuickSearchItem::LineMatch { buffer, line, .. } = item { + Some((buffer.clone(), *line)) + } else { + None + } + }); + + let first_selectable = items + .iter() + .position(|item| matches!(item, QuickSearchItem::LineMatch { .. })) + .unwrap_or(0); + + picker + .update_in(cx, |picker, window, cx| { + if picker.delegate.pending_search_id == search_id { + picker.delegate.items = items; + picker.delegate.selected_index = first_selectable; + cx.notify(); + + if let Some(quick_search) = quick_search.upgrade() { + quick_search.update(cx, |qs, cx| { + qs.update_preview(first_line_match, window, cx); + }); + } + } + }) + .ok(); + }) + } + + fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { + let Some(QuickSearchItem::LineMatch { + project_path, line, .. + }) = self.items.get(self.selected_index) + else { + return; + }; + + let project_path = project_path.clone(); + let line = *line; + + if let Some(workspace) = self.workspace.upgrade() { + workspace.update(cx, |workspace, cx| { + let task = workspace.open_path(project_path, None, true, window, cx); + cx.spawn_in(window, async move |_, cx| { + if let Some(item) = task.await.log_err() { + if let Some(editor) = item.downcast::() { + editor + .update_in(cx, |editor, window, cx| { + let point = text::Point::new(line, 0); + editor.go_to_singleton_buffer_point(point, window, cx); + }) + .ok(); + } + } + anyhow::Ok(()) + }) + .detach_and_log_err(cx); + }); + } + cx.emit(DismissEvent); + } + + fn dismissed(&mut self, _window: &mut Window, cx: &mut Context>) { + cx.emit(DismissEvent); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + let item = self.items.get(ix)?; + + match item { + QuickSearchItem::FileHeader { + file_name, + parent_path, + } => { + let icon = FileIcons::get_icon(Path::new(file_name.as_ref()), cx) + .map(Icon::from_path) + .unwrap_or_else(|| Icon::new(IconName::File)); + + Some( + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .disabled(true) + .child( + h_flex() + .gap_1p5() + .child(icon.color(Color::Muted).size(ui::IconSize::Small)) + .child(Label::new(file_name.clone()).size(ui::LabelSize::Small)) + .when(!parent_path.is_empty(), |this| { + this.child( + Label::new(parent_path.clone()) + .size(ui::LabelSize::Small) + .color(Color::Muted), + ) + }), + ), + ) + } + QuickSearchItem::LineMatch { + line_label, + preview_text, + .. + } => Some( + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .child( + h_flex() + .gap_2() + .pl(px(20.)) + .child( + Label::new(line_label.clone()) + .size(ui::LabelSize::Small) + .color(Color::Muted), + ) + .child( + Label::new(preview_text.clone()) + .size(ui::LabelSize::Small) + .color(Color::Default) + .truncate(), + ), + ), + ), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{TestAppContext, VisualTestContext}; + use project::FakeFs; + use serde_json::json; + use settings::SettingsStore; + use std::ops::Deref; + use util::path; + + fn init_test(cx: &mut TestAppContext) { + cx.update(|cx| { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + theme::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + crate::init(cx); + }); + } + + #[gpui::test] + async fn test_quick_search_modal_creation(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + "file.rs": "fn main() {}\n", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let workspace = window.root(cx).unwrap(); + let mut cx = VisualTestContext::from_window(*window.deref(), cx); + + let quick_search = cx.new_window_entity({ + let workspace = workspace.downgrade(); + |window, cx| QuickSearchModal::new(workspace, project, window, cx) + }); + + quick_search.update(&mut cx, |modal, cx| { + assert!(modal.preview_editor.is_none()); + assert!(modal.preview_buffer.is_none()); + assert_eq!(modal.picker.read(cx).delegate.items.len(), 0); + }); + } + + #[gpui::test] + async fn test_quick_search_empty_query_clears_results(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + "file.rs": "fn test() {}\n", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let workspace = window.root(cx).unwrap(); + let mut cx = VisualTestContext::from_window(*window.deref(), cx); + + let quick_search = cx.new_window_entity({ + let workspace = workspace.downgrade(); + |window, cx| QuickSearchModal::new(workspace, project, window, cx) + }); + + quick_search.update_in(&mut cx, |modal, window, cx| { + modal.picker.update(cx, |picker, cx| { + picker.set_query("test", window, cx); + }); + }); + + quick_search.update(&mut cx, |modal, cx| { + assert_eq!(modal.picker.read(cx).delegate.pending_search_id, 1); + }); + + quick_search.update_in(&mut cx, |modal, window, cx| { + modal.picker.update(cx, |picker, cx| { + picker.set_query("", window, cx); + }); + }); + + cx.background_executor.run_until_parked(); + + quick_search.update(&mut cx, |modal, cx| { + let delegate = &modal.picker.read(cx).delegate; + assert_eq!(delegate.items.len(), 0, "Empty query should clear results"); + assert_eq!( + delegate.pending_search_id, 0, + "Empty query should reset search id" + ); + }); + } + + #[gpui::test] + fn test_quick_search_item_types(cx: &mut TestAppContext) { + init_test(cx); + + let header = QuickSearchItem::FileHeader { + file_name: "test.rs".into(), + parent_path: "src".into(), + }; + assert!(matches!(header, QuickSearchItem::FileHeader { .. })); + + cx.update(|cx| { + let buffer = cx.new(|cx| language::Buffer::local("fn test() {}", cx)); + let line_match = QuickSearchItem::LineMatch { + project_path: ProjectPath { + worktree_id: project::WorktreeId::from_usize(0), + path: util::rel_path::rel_path("src/test.rs").into(), + }, + buffer, + line: 0, + line_label: "1".into(), + preview_text: "fn test()".into(), + }; + assert!(matches!(line_match, QuickSearchItem::LineMatch { .. })); + }); + } + + #[gpui::test] + async fn test_quick_search_no_results_for_nonexistent_query(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + "file.rs": "fn main() {}\n", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let workspace = window.root(cx).unwrap(); + let mut cx = VisualTestContext::from_window(*window.deref(), cx); + + let quick_search = cx.new_window_entity({ + let workspace = workspace.downgrade(); + |window, cx| QuickSearchModal::new(workspace, project, window, cx) + }); + + quick_search.update_in(&mut cx, |modal, window, cx| { + modal.picker.update(cx, |picker, cx| { + picker.set_query("nonexistent_string_xyz_123", window, cx); + }); + }); + + cx.executor().advance_clock(Duration::from_millis(150)); + cx.background_executor.run_until_parked(); + + quick_search.update(&mut cx, |modal, cx| { + let delegate = &modal.picker.read(cx).delegate; + assert_eq!( + delegate.items.len(), + 0, + "Should have no results for non-matching query" + ); + }); + } + + #[gpui::test] + async fn test_quick_search_query_updates_search_id(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + "file.rs": "fn hello() {}\nfn world() {}\n", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let workspace = window.root(cx).unwrap(); + let mut cx = VisualTestContext::from_window(*window.deref(), cx); + + let quick_search = cx.new_window_entity({ + let workspace = workspace.downgrade(); + |window, cx| QuickSearchModal::new(workspace, project, window, cx) + }); + + quick_search.update(&mut cx, |modal, cx| { + assert_eq!(modal.picker.read(cx).delegate.pending_search_id, 0); + }); + + quick_search.update_in(&mut cx, |modal, window, cx| { + modal.picker.update(cx, |picker, cx| { + picker.set_query("hello", window, cx); + }); + }); + + quick_search.update(&mut cx, |modal, cx| { + assert_eq!( + modal.picker.read(cx).delegate.pending_search_id, + 1, + "First search should have id 1" + ); + }); + + quick_search.update_in(&mut cx, |modal, window, cx| { + modal.picker.update(cx, |picker, cx| { + picker.set_query("world", window, cx); + }); + }); + + quick_search.update(&mut cx, |modal, cx| { + assert_eq!( + modal.picker.read(cx).delegate.pending_search_id, + 2, + "Second search should have id 2" + ); + }); + } +} diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index 3aa40894ea91ed..41f3285e1a5a0a 100644 --- a/crates/search/src/search.rs +++ b/crates/search/src/search.rs @@ -15,6 +15,7 @@ use crate::project_search::ProjectSearchBar; pub mod buffer_search; pub mod project_search; +pub mod quick_search; pub(crate) mod search_bar; pub mod search_status_button; @@ -22,6 +23,7 @@ pub fn init(cx: &mut App) { menu::init(); buffer_search::init(cx); project_search::init(cx); + quick_search::init(cx); } actions!( From c6304c8b555d9543da4eec5ca1c2ed2ac21756ba Mon Sep 17 00:00:00 2001 From: David Bonan Date: Wed, 10 Dec 2025 22:45:14 +0100 Subject: [PATCH 02/35] Improves quick search modal. Refactors the quick search modal to enhance usability and performance. It introduces the following changes: - Persists search queries between openings for a smoother experience. - Implements file collapsing/expanding for better organization. - Adds an "Open in Split" button for more flexible workflow. - Updates the UI, including removing window resizing, to be more efficient and user-friendly, reducing complexity. - Fixes the preview editor to not allow input. --- crates/search/src/quick_search.rs | 714 +++++++++++++++++++++--------- 1 file changed, 503 insertions(+), 211 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index e05781ca90ac89..051f36a11ef21d 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -1,55 +1,60 @@ +use collections::{HashMap, HashSet}; use editor::Editor; use file_icons::FileIcons; use futures::StreamExt; use gpui::{ - App, Bounds, Context, DismissEvent, DragMoveEvent, Entity, EventEmitter, FocusHandle, - Focusable, MouseButton, Pixels, Render, SharedString, Subscription, Task, WeakEntity, Window, - actions, canvas, prelude::*, + Action, App, Context, DismissEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, + Global, Pixels, Render, SharedString, Subscription, Task, WeakEntity, Window, actions, + prelude::*, }; use language::Buffer; use picker::{Picker, PickerDelegate}; use project::{Project, ProjectPath, search::SearchQuery}; use std::{fmt::Write as _, path::Path, pin::pin, sync::Arc, time::Duration}; use text::ToPoint as _; -use ui::{Color, Icon, IconName, Label, ListItem, ListItemSpacing, prelude::*}; +use ui::{Button, Color, Icon, IconName, KeyBinding, Label, ListItem, ListItemSpacing, SpinnerLabel, prelude::*, rems_from_px}; use util::{ResultExt, paths::PathMatcher}; use workspace::{ModalView, Workspace}; -use crate::SearchOptions; - -const DEFAULT_WIDTH: Pixels = px(1100.); -const DEFAULT_HEIGHT: Pixels = px(650.); -const MIN_WIDTH: Pixels = px(600.); -const MIN_HEIGHT: Pixels = px(300.); -const MAX_WIDTH: Pixels = px(1800.); -const MAX_HEIGHT: Pixels = px(1000.); -const LEFT_PANEL_WIDTH: Pixels = px(350.); -const RESIZE_HANDLE_SIZE: Pixels = px(6.); +#[derive(Default)] +struct LastQuickSearchQuery(HashMap); -actions!(search, [QuickSearch]); +impl Global for LastQuickSearchQuery {} -pub fn init(cx: &mut App) { - cx.observe_new(QuickSearchModal::register).detach(); +fn get_last_query(workspace_id: EntityId, cx: &App) -> Option { + cx.try_global::() + .and_then(|storage| storage.0.get(&workspace_id).cloned()) } -#[derive(Clone)] -struct DragResizeHandle { - axis: ResizeAxis, +fn set_last_query(workspace_id: EntityId, query: String, cx: &mut App) { + if !cx.has_global::() { + cx.set_global(LastQuickSearchQuery::default()); + } + cx.global_mut::() + .0 + .insert(workspace_id, query); } -#[derive(Clone, Copy, PartialEq)] -enum ResizeAxis { - Horizontal, - Vertical, - Both, -} +use crate::SearchOptions; + +const MODAL_HEIGHT: Pixels = px(650.); +const MODAL_WIDTH: Pixels = px(1100.); +const LEFT_PANEL_WIDTH: Pixels = px(300.); + +actions!(search, [QuickSearch]); -impl Render for DragResizeHandle { - fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { - gpui::Empty +fn format_file_key(parent_path: &str, file_name: &str) -> String { + if parent_path.is_empty() { + file_name.to_string() + } else { + format!("{}/{}", parent_path, file_name) } } +pub fn init(cx: &mut App) { + cx.observe_new(QuickSearchModal::register).detach(); +} + enum QuickSearchItem { FileHeader { file_name: SharedString, @@ -64,23 +69,53 @@ enum QuickSearchItem { }, } +impl QuickSearchItem { + fn file_key(&self) -> String { + match self { + QuickSearchItem::FileHeader { + file_name, + parent_path, + } => format_file_key(parent_path, file_name), + QuickSearchItem::LineMatch { project_path, .. } => { + let file_name = project_path + .path + .file_name() + .map(|n| n.to_string()) + .unwrap_or_default(); + let parent_path = project_path + .path + .parent() + .map(|p| p.as_unix_str().to_string()) + .unwrap_or_default(); + format_file_key(&parent_path, &file_name) + } + } + } +} + pub struct QuickSearchDelegate { workspace: WeakEntity, + workspace_id: EntityId, project: Entity, search_options: SearchOptions, items: Vec, + visible_indices: Vec, + collapsed_files: HashSet, selected_index: usize, pending_search_id: usize, quick_search: WeakEntity, + match_count: usize, + file_count: usize, + is_limited: bool, + is_searching: bool, + current_query: String, + focus_handle: Option, } pub struct QuickSearchModal { picker: Entity>, preview_editor: Option>, preview_buffer: Option>, - width: Pixels, - height: Pixels, - bounds: Bounds, _subscriptions: Vec, } @@ -97,29 +132,19 @@ impl Focusable for QuickSearchModal { impl Render for QuickSearchModal { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let preview_editor = self.preview_editor.clone(); - let width = self.width; - let height = self.height; - let entity = cx.entity(); - let picker = self.picker.clone(); + let delegate = &self.picker.read(cx).delegate; + let match_count = delegate.match_count; + let file_count = delegate.file_count; + let is_limited = delegate.is_limited; + let is_searching = delegate.is_searching; + div() .id("quick-search-modal") .relative() - .w(width) - .h(height) - .child( - canvas( - move |bounds, _, cx| { - entity.update(cx, |this, _| { - this.bounds = bounds; - }); - }, - |_, _, _, _| {}, - ) - .absolute() - .size_full(), - ) + .h(MODAL_HEIGHT) + .w(MODAL_WIDTH) .child( v_flex() .elevation_3(cx) @@ -133,31 +158,62 @@ impl Render for QuickSearchModal { .child( h_flex() .w_full() - .px_3() - .py_2() - .bg(cx.theme().colors().title_bar_background) - .border_b_1() - .border_color(cx.theme().colors().border) - .child( - Label::new("Quick Search") - .size(LabelSize::Small) - .color(Color::Muted), - ), - ) - .child( - h_flex() .flex_1() + .min_h_0() .overflow_hidden() .child( v_flex() .w(LEFT_PANEL_WIDTH) + .flex_shrink_0() .h_full() + .min_h_0() + .overflow_hidden() .border_r_1() .border_color(cx.theme().colors().border) + .child( + h_flex() + .w_full() + .px_3() + .py_2() + .bg(cx.theme().colors().title_bar_background) + .border_b_1() + .border_color(cx.theme().colors().border) + .justify_between() + .child( + h_flex() + .gap_2() + .child( + Label::new("Quick Search") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .when(is_searching, |this| { + this.child( + SpinnerLabel::new() + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ) + .when(match_count > 0 && !is_searching, |this| { + let results_text = if is_limited { + format!("{}+ results (limited)", match_count) + } else { + let result_word = if match_count == 1 { "result" } else { "results" }; + let file_word = if file_count == 1 { "file" } else { "files" }; + format!("{} {} in {} {}", match_count, result_word, file_count, file_word) + }; + this.child( + Label::new(results_text) + .size(LabelSize::Small) + .color(Color::Muted), + ) + }), + ) .child(self.picker.clone()), ) .child( - div() + v_flex() .id("quick-search-preview") .relative() .flex_1() @@ -168,7 +224,7 @@ impl Render for QuickSearchModal { window.focus(&picker.focus_handle(cx)); }) .when_some(preview_editor, |this, editor| { - this.child(div().size_full().child(editor)) + this.child(editor) }) .when(self.preview_editor.is_none(), |this| { this.child( @@ -182,105 +238,13 @@ impl Render for QuickSearchModal { .color(Color::Muted), ), ) - }) - .child(self.render_resize_handle_right(cx)) - .child(self.render_resize_handle_bottom(cx)) - .child(self.render_resize_handle_corner(cx)), + }), ), ), ) } } -impl QuickSearchModal { - fn render_resize_handle_right(&self, cx: &mut Context) -> impl IntoElement { - div() - .id("resize-handle-right") - .absolute() - .top_0() - .right_0() - .w(RESIZE_HANDLE_SIZE) - .h(self.height - RESIZE_HANDLE_SIZE) - .cursor_e_resize() - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_drag( - DragResizeHandle { - axis: ResizeAxis::Horizontal, - }, - |drag, _, _, cx| cx.new(|_| drag.clone()), - ) - .on_drag_move(cx.listener(Self::handle_resize)) - } - - fn render_resize_handle_bottom(&self, cx: &mut Context) -> impl IntoElement { - div() - .id("resize-handle-bottom") - .absolute() - .bottom_0() - .left_0() - .w_full() - .h(RESIZE_HANDLE_SIZE) - .cursor_s_resize() - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_drag( - DragResizeHandle { - axis: ResizeAxis::Vertical, - }, - |drag, _, _, cx| cx.new(|_| drag.clone()), - ) - .on_drag_move(cx.listener(Self::handle_resize)) - } - - fn render_resize_handle_corner(&self, cx: &mut Context) -> impl IntoElement { - div() - .id("resize-handle-corner") - .absolute() - .bottom_0() - .right_0() - .w(RESIZE_HANDLE_SIZE * 2.) - .h(RESIZE_HANDLE_SIZE * 2.) - .cursor_nwse_resize() - .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) - .on_drag( - DragResizeHandle { - axis: ResizeAxis::Both, - }, - |drag, _, _, cx| cx.new(|_| drag.clone()), - ) - .on_drag_move(cx.listener(Self::handle_resize)) - } - - fn handle_resize( - &mut self, - event: &DragMoveEvent, - _window: &mut Window, - cx: &mut Context, - ) { - let drag = event.drag(cx); - let position = event.event.position; - - let new_width = match drag.axis { - ResizeAxis::Horizontal | ResizeAxis::Both => (position.x - self.bounds.origin.x) - .max(MIN_WIDTH) - .min(MAX_WIDTH), - ResizeAxis::Vertical => self.width, - }; - - let new_height = match drag.axis { - ResizeAxis::Vertical | ResizeAxis::Both => (position.y - self.bounds.origin.y) - .max(MIN_HEIGHT) - .min(MAX_HEIGHT), - ResizeAxis::Horizontal => self.height, - }; - - if new_width != self.width || new_height != self.height { - self.width = new_width; - self.height = new_height; - cx.notify(); - } - } -} - impl QuickSearchModal { fn register( workspace: &mut Workspace, @@ -289,36 +253,54 @@ impl QuickSearchModal { ) { workspace.register_action(|workspace, _: &QuickSearch, window, cx| { let project = workspace.project().clone(); - let weak_workspace = cx.entity().downgrade(); + let workspace_entity = cx.entity(); + let workspace_id = workspace_entity.entity_id(); + let weak_workspace = workspace_entity.downgrade(); workspace.toggle_modal(window, cx, |window, cx| { - QuickSearchModal::new(weak_workspace, project, window, cx) + QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) }); }); } fn new( workspace: WeakEntity, + workspace_id: EntityId, project: Entity, window: &mut Window, cx: &mut Context, ) -> Self { let weak_self = cx.entity().downgrade(); + let last_query = get_last_query(workspace_id, cx); let delegate = QuickSearchDelegate { workspace, + workspace_id, project, search_options: SearchOptions::NONE, items: Vec::new(), + visible_indices: Vec::new(), + collapsed_files: HashSet::default(), selected_index: 0, pending_search_id: 0, quick_search: weak_self, + match_count: 0, + file_count: 0, + is_limited: false, + is_searching: false, + current_query: last_query.clone().unwrap_or_default(), + focus_handle: None, }; let picker = cx.new(|cx| { - Picker::uniform_list(delegate, window, cx) + let mut picker = Picker::uniform_list(delegate, window, cx) .modal(false) .max_height(None) - .show_scrollbar(true) + .show_scrollbar(true); + picker.delegate.focus_handle = Some(picker.focus_handle(cx)); + if let Some(query) = last_query { + picker.set_query(query, window, cx); + } + picker }); let subscriptions = vec![cx.subscribe_in(&picker, window, Self::on_picker_event)]; @@ -327,9 +309,6 @@ impl QuickSearchModal { picker, preview_editor: None, preview_buffer: None, - width: DEFAULT_WIDTH, - height: DEFAULT_HEIGHT, - bounds: Bounds::default(), _subscriptions: subscriptions, } } @@ -373,7 +352,9 @@ impl QuickSearchModal { let editor = cx.new(|cx| { let mut editor = Editor::for_buffer(buffer.clone(), None, window, cx); editor.set_read_only(true); + editor.set_input_enabled(false); editor.set_show_gutter(true, cx); + editor.set_show_line_numbers(false, cx); editor }); @@ -389,11 +370,44 @@ impl QuickSearchModal { } } +impl QuickSearchDelegate { + fn update_visible_indices(&mut self) { + self.visible_indices.clear(); + + for (idx, item) in self.items.iter().enumerate() { + match item { + QuickSearchItem::FileHeader { .. } => { + self.visible_indices.push(idx); + } + QuickSearchItem::LineMatch { .. } => { + let file_key = item.file_key(); + if !self.collapsed_files.contains(&file_key) { + self.visible_indices.push(idx); + } + } + } + } + } + + fn toggle_file_collapsed(&mut self, file_key: &str) { + if self.collapsed_files.contains(file_key) { + self.collapsed_files.remove(file_key); + } else { + self.collapsed_files.insert(file_key.to_string()); + } + self.update_visible_indices(); + } + + fn actual_index(&self, visible_index: usize) -> Option { + self.visible_indices.get(visible_index).copied() + } +} + impl PickerDelegate for QuickSearchDelegate { type ListItem = ListItem; fn match_count(&self) -> usize { - self.items.len() + self.visible_indices.len() } fn selected_index(&self) -> usize { @@ -406,14 +420,15 @@ impl PickerDelegate for QuickSearchDelegate { _window: &mut Window, _cx: &mut Context>, ) { - if self.items.is_empty() { + if self.visible_indices.is_empty() { self.selected_index = 0; return; } - let ix = ix.min(self.items.len().saturating_sub(1)); + let ix = ix.min(self.visible_indices.len().saturating_sub(1)); - if matches!(self.items.get(ix), Some(QuickSearchItem::LineMatch { .. })) { + let actual_ix = self.visible_indices[ix]; + if matches!(self.items.get(actual_ix), Some(QuickSearchItem::LineMatch { .. })) { self.selected_index = ix; return; } @@ -421,28 +436,28 @@ impl PickerDelegate for QuickSearchDelegate { let going_down = ix >= self.selected_index; if going_down { - if let Some(next) = self - .items + if let Some(next) = self.visible_indices[ix..] .iter() - .skip(ix) - .position(|item| matches!(item, QuickSearchItem::LineMatch { .. })) + .position(|&actual_idx| { + matches!(self.items.get(actual_idx), Some(QuickSearchItem::LineMatch { .. })) + }) { self.selected_index = ix + next; return; } } - let upper_bound = ix.min(self.items.len().saturating_sub(1)); - if let Some(prev) = self.items[..=upper_bound] + let upper_bound = ix.min(self.visible_indices.len().saturating_sub(1)); + if let Some(prev) = self.visible_indices[..=upper_bound] .iter() - .rposition(|item| matches!(item, QuickSearchItem::LineMatch { .. })) + .rposition(|&actual_idx| { + matches!(self.items.get(actual_idx), Some(QuickSearchItem::LineMatch { .. })) + }) { self.selected_index = prev; - } else if let Some(next) = self - .items - .iter() - .position(|item| matches!(item, QuickSearchItem::LineMatch { .. })) - { + } else if let Some(next) = self.visible_indices.iter().position(|&actual_idx| { + matches!(self.items.get(actual_idx), Some(QuickSearchItem::LineMatch { .. })) + }) { self.selected_index = next; } } @@ -454,10 +469,11 @@ impl PickerDelegate for QuickSearchDelegate { _cx: &mut Context>, ) -> Option> { let quick_search = self.quick_search.clone(); - let preview_data = match self.items.get(self.selected_index) { + let actual_index = self.actual_index(self.selected_index); + let preview_data = actual_index.and_then(|idx| match self.items.get(idx) { Some(QuickSearchItem::LineMatch { buffer, line, .. }) => Some((buffer.clone(), *line)), _ => None, - }; + }); Some(Box::new(move |window, cx| { if let Some(quick_search) = quick_search.upgrade() { @@ -478,9 +494,16 @@ impl PickerDelegate for QuickSearchDelegate { window: &mut Window, cx: &mut Context>, ) -> Task<()> { + self.current_query = query.clone(); + if query.is_empty() { self.items.clear(); + self.visible_indices.clear(); self.pending_search_id = 0; + self.match_count = 0; + self.file_count = 0; + self.is_limited = false; + self.is_searching = false; let quick_search = self.quick_search.clone(); cx.defer_in(window, move |_, window, cx| { if let Some(quick_search) = quick_search.upgrade() { @@ -496,6 +519,8 @@ impl PickerDelegate for QuickSearchDelegate { return Task::ready(()); } + self.is_searching = true; + self.pending_search_id += 1; let search_id = self.pending_search_id; let project = self.project.clone(); @@ -542,6 +567,8 @@ impl PickerDelegate for QuickSearchDelegate { let mut items = Vec::new(); let max_line_matches = 200; let mut line_match_count = 0; + let mut file_count = 0; + let mut is_limited = false; let mut search_results = pin!(search_results); while let Some(result) = search_results.next().await { @@ -651,6 +678,7 @@ impl PickerDelegate for QuickSearchDelegate { file_name: first.4.clone(), parent_path: first.5.clone(), }); + file_count += 1; for ( project_path, @@ -671,6 +699,7 @@ impl PickerDelegate for QuickSearchDelegate { line_match_count += 1; if line_match_count >= max_line_matches { + is_limited = true; break; } } @@ -680,7 +709,10 @@ impl PickerDelegate for QuickSearchDelegate { break; } } - project::search::SearchResult::LimitReached => break, + project::search::SearchResult::LimitReached => { + is_limited = true; + break; + } } } @@ -692,16 +724,29 @@ impl PickerDelegate for QuickSearchDelegate { } }); - let first_selectable = items - .iter() - .position(|item| matches!(item, QuickSearchItem::LineMatch { .. })) - .unwrap_or(0); - picker .update_in(cx, |picker, window, cx| { if picker.delegate.pending_search_id == search_id { picker.delegate.items = items; + picker.delegate.update_visible_indices(); + + let first_selectable = picker + .delegate + .visible_indices + .iter() + .position(|&actual_idx| { + matches!( + picker.delegate.items.get(actual_idx), + Some(QuickSearchItem::LineMatch { .. }) + ) + }) + .unwrap_or(0); + picker.delegate.selected_index = first_selectable; + picker.delegate.match_count = line_match_count; + picker.delegate.file_count = file_count; + picker.delegate.is_limited = is_limited; + picker.delegate.is_searching = false; cx.notify(); if let Some(quick_search) = quick_search.upgrade() { @@ -715,10 +760,17 @@ impl PickerDelegate for QuickSearchDelegate { }) } - fn confirm(&mut self, _secondary: bool, window: &mut Window, cx: &mut Context>) { + fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { + set_last_query(self.workspace_id, self.current_query.clone(), cx); + + let actual_index = match self.actual_index(self.selected_index) { + Some(idx) => idx, + None => return, + }; + let Some(QuickSearchItem::LineMatch { project_path, line, .. - }) = self.items.get(self.selected_index) + }) = self.items.get(actual_index) else { return; }; @@ -728,7 +780,11 @@ impl PickerDelegate for QuickSearchDelegate { if let Some(workspace) = self.workspace.upgrade() { workspace.update(cx, |workspace, cx| { - let task = workspace.open_path(project_path, None, true, window, cx); + let task = if secondary { + workspace.split_path_preview(project_path, false, None, window, cx) + } else { + workspace.open_path(project_path, None, true, window, cx) + }; cx.spawn_in(window, async move |_, cx| { if let Some(item) = task.await.log_err() { if let Some(editor) = item.downcast::() { @@ -749,6 +805,7 @@ impl PickerDelegate for QuickSearchDelegate { } fn dismissed(&mut self, _window: &mut Window, cx: &mut Context>) { + set_last_query(self.workspace_id, self.current_query.clone(), cx); cx.emit(DismissEvent); } @@ -759,26 +816,56 @@ impl PickerDelegate for QuickSearchDelegate { _window: &mut Window, cx: &mut Context>, ) -> Option { - let item = self.items.get(ix)?; + let actual_ix = *self.visible_indices.get(ix)?; + let item = self.items.get(actual_ix)?; match item { QuickSearchItem::FileHeader { file_name, parent_path, } => { - let icon = FileIcons::get_icon(Path::new(file_name.as_ref()), cx) + let file_key = format_file_key(parent_path, file_name); + let is_collapsed = self.collapsed_files.contains(&file_key); + + let chevron_icon = if is_collapsed { + IconName::ChevronRight + } else { + IconName::ChevronDown + }; + + let file_icon = FileIcons::get_icon(Path::new(file_name.as_ref()), cx) .map(Icon::from_path) .unwrap_or_else(|| Icon::new(IconName::File)); + let quick_search = self.quick_search.clone(); + Some( ListItem::new(ix) .inset(true) .spacing(ListItemSpacing::Sparse) - .disabled(true) .child( h_flex() - .gap_1p5() - .child(icon.color(Color::Muted).size(ui::IconSize::Small)) + .id(("file-header", ix)) + .w_full() + .gap_1() + .cursor_pointer() + .on_click(move |_, _window, cx| { + cx.stop_propagation(); + if let Some(qs) = quick_search.upgrade() { + qs.update(cx, |qs, cx| { + qs.picker.update(cx, |picker, cx| { + picker.delegate.toggle_file_collapsed(&file_key); + cx.notify(); + }); + }); + } + }) + .child( + Icon::new(chevron_icon) + .color(Color::Muted) + .size(ui::IconSize::Small), + ) + .child(file_icon.color(Color::Muted).size(ui::IconSize::Small)) .child(Label::new(file_name.clone()).size(ui::LabelSize::Small)) .when(!parent_path.is_empty(), |this| { this.child( @@ -801,23 +888,66 @@ impl PickerDelegate for QuickSearchDelegate { .toggle_state(selected) .child( h_flex() + .w_full() .gap_2() .pl(px(20.)) + .justify_between() .child( - Label::new(line_label.clone()) - .size(ui::LabelSize::Small) - .color(Color::Muted), + div().flex_1().min_w_0().overflow_hidden().child( + Label::new(preview_text.clone()) + .size(ui::LabelSize::Small) + .color(Color::Default) + .truncate(), + ), ) .child( - Label::new(preview_text.clone()) + Label::new(line_label.clone()) .size(ui::LabelSize::Small) - .color(Color::Default) - .truncate(), + .color(Color::Muted), ), ), ), } } + + fn render_footer( + &self, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + let focus_handle = self.focus_handle.clone()?; + + Some( + h_flex() + .w_full() + .p_1p5() + .gap_1() + .justify_end() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .child( + Button::new("open-split", "Open in Split") + .key_binding( + KeyBinding::for_action_in(&menu::SecondaryConfirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx); + }), + ) + .child( + Button::new("open", "Open") + .key_binding( + KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) + .map(|kb| kb.size(rems_from_px(12.))), + ) + .on_click(|_, window, cx| { + window.dispatch_action(menu::Confirm.boxed_clone(), cx); + }), + ) + .into_any(), + ) + } } #[cfg(test)] @@ -858,9 +988,10 @@ mod tests { let workspace = window.root(cx).unwrap(); let mut cx = VisualTestContext::from_window(*window.deref(), cx); + let workspace_id = workspace.entity_id(); let quick_search = cx.new_window_entity({ - let workspace = workspace.downgrade(); - |window, cx| QuickSearchModal::new(workspace, project, window, cx) + let weak_workspace = workspace.downgrade(); + move |window, cx| QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) }); quick_search.update(&mut cx, |modal, cx| { @@ -888,9 +1019,10 @@ mod tests { let workspace = window.root(cx).unwrap(); let mut cx = VisualTestContext::from_window(*window.deref(), cx); + let workspace_id = workspace.entity_id(); let quick_search = cx.new_window_entity({ - let workspace = workspace.downgrade(); - |window, cx| QuickSearchModal::new(workspace, project, window, cx) + let weak_workspace = workspace.downgrade(); + move |window, cx| QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) }); quick_search.update_in(&mut cx, |modal, window, cx| { @@ -965,9 +1097,10 @@ mod tests { let workspace = window.root(cx).unwrap(); let mut cx = VisualTestContext::from_window(*window.deref(), cx); + let workspace_id = workspace.entity_id(); let quick_search = cx.new_window_entity({ - let workspace = workspace.downgrade(); - |window, cx| QuickSearchModal::new(workspace, project, window, cx) + let weak_workspace = workspace.downgrade(); + move |window, cx| QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) }); quick_search.update_in(&mut cx, |modal, window, cx| { @@ -1007,9 +1140,10 @@ mod tests { let workspace = window.root(cx).unwrap(); let mut cx = VisualTestContext::from_window(*window.deref(), cx); + let workspace_id = workspace.entity_id(); let quick_search = cx.new_window_entity({ - let workspace = workspace.downgrade(); - |window, cx| QuickSearchModal::new(workspace, project, window, cx) + let weak_workspace = workspace.downgrade(); + move |window, cx| QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) }); quick_search.update(&mut cx, |modal, cx| { @@ -1044,4 +1178,162 @@ mod tests { ); }); } + + #[gpui::test] + async fn test_quick_search_persists_query_between_openings(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/project"), + json!({ + "file.rs": "fn hello() {}\n", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; + let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let workspace = window.root(cx).unwrap(); + let mut cx = VisualTestContext::from_window(*window.deref(), cx); + let workspace_id = workspace.entity_id(); + + let quick_search = cx.new_window_entity({ + let weak_workspace = workspace.downgrade(); + let project = project.clone(); + move |window, cx| QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + }); + + quick_search.update_in(&mut cx, |modal, window, cx| { + modal.picker.update(cx, |picker, cx| { + picker.set_query("hello", window, cx); + }); + }); + + quick_search.update(&mut cx, |modal, cx| { + assert_eq!(modal.picker.read(cx).delegate.current_query, "hello"); + }); + + quick_search.update_in(&mut cx, |modal, window, cx| { + modal.picker.update(cx, |picker, cx| { + picker.cancel(&menu::Cancel, window, cx); + }); + }); + + cx.background_executor.run_until_parked(); + + let quick_search2 = cx.new_window_entity({ + let weak_workspace = workspace.downgrade(); + move |window, cx| QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + }); + + quick_search2.update(&mut cx, |modal, cx| { + assert_eq!( + modal.picker.read(cx).delegate.current_query, "hello", + "Query should be restored from previous session" + ); + }); + } + + #[gpui::test] + fn test_quick_search_collapse_expand_files(cx: &mut TestAppContext) { + init_test(cx); + + cx.update(|cx| { + let buffer = cx.new(|cx| language::Buffer::local("fn test() {}\nfn other() {}", cx)); + + let items = [ + QuickSearchItem::FileHeader { + file_name: "test.rs".into(), + parent_path: "src".into(), + }, + QuickSearchItem::LineMatch { + project_path: ProjectPath { + worktree_id: project::WorktreeId::from_usize(0), + path: util::rel_path::rel_path("src/test.rs").into(), + }, + buffer: buffer.clone(), + line: 0, + line_label: "1".into(), + preview_text: "fn test()".into(), + }, + QuickSearchItem::LineMatch { + project_path: ProjectPath { + worktree_id: project::WorktreeId::from_usize(0), + path: util::rel_path::rel_path("src/test.rs").into(), + }, + buffer, + line: 1, + line_label: "2".into(), + preview_text: "fn other()".into(), + }, + ]; + + let mut visible_indices = Vec::new(); + let mut collapsed_files: HashSet = HashSet::default(); + + for (idx, item) in items.iter().enumerate() { + match item { + QuickSearchItem::FileHeader { .. } => { + visible_indices.push(idx); + } + QuickSearchItem::LineMatch { .. } => { + let file_key = item.file_key(); + if !collapsed_files.contains(&file_key) { + visible_indices.push(idx); + } + } + } + } + + assert_eq!(visible_indices.len(), 3, "All 3 items should be visible"); + assert_eq!(visible_indices, vec![0, 1, 2]); + + collapsed_files.insert("src/test.rs".to_string()); + visible_indices.clear(); + for (idx, item) in items.iter().enumerate() { + match item { + QuickSearchItem::FileHeader { .. } => { + visible_indices.push(idx); + } + QuickSearchItem::LineMatch { .. } => { + let file_key = item.file_key(); + if !collapsed_files.contains(&file_key) { + visible_indices.push(idx); + } + } + } + } + + assert_eq!( + visible_indices.len(), + 1, + "Only file header should be visible after collapse" + ); + assert_eq!(visible_indices, vec![0]); + + collapsed_files.remove("src/test.rs"); + visible_indices.clear(); + for (idx, item) in items.iter().enumerate() { + match item { + QuickSearchItem::FileHeader { .. } => { + visible_indices.push(idx); + } + QuickSearchItem::LineMatch { .. } => { + let file_key = item.file_key(); + if !collapsed_files.contains(&file_key) { + visible_indices.push(idx); + } + } + } + } + + assert_eq!( + visible_indices.len(), + 3, + "All items should be visible after expand" + ); + assert_eq!(visible_indices, vec![0, 1, 2]); + }); + } } From cbe4c71d03d23ad1d7133e9c4116e14642ec5ec7 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Wed, 10 Dec 2025 23:39:24 +0100 Subject: [PATCH 03/35] Adds search options to quick search Adds case-sensitive, whole-word, regex, and include-ignored search options to the quick search modal. Also persists the last search state (query and options) per workspace. Fixes regex errors and displays in header. --- crates/search/src/quick_search.rs | 369 ++++++++++++++++++++++++------ 1 file changed, 304 insertions(+), 65 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 051f36a11ef21d..475ef2bb2a9a08 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -12,30 +12,36 @@ use picker::{Picker, PickerDelegate}; use project::{Project, ProjectPath, search::SearchQuery}; use std::{fmt::Write as _, path::Path, pin::pin, sync::Arc, time::Duration}; use text::ToPoint as _; -use ui::{Button, Color, Icon, IconName, KeyBinding, Label, ListItem, ListItemSpacing, SpinnerLabel, prelude::*, rems_from_px}; +use ui::{ + Button, ButtonStyle, Color, Icon, IconButton, IconButtonShape, IconName, KeyBinding, Label, + ListItem, ListItemSpacing, SpinnerLabel, Tooltip, prelude::*, rems_from_px, +}; use util::{ResultExt, paths::PathMatcher}; use workspace::{ModalView, Workspace}; #[derive(Default)] -struct LastQuickSearchQuery(HashMap); +struct LastQuickSearchState(HashMap); -impl Global for LastQuickSearchQuery {} +impl Global for LastQuickSearchState {} -fn get_last_query(workspace_id: EntityId, cx: &App) -> Option { - cx.try_global::() +fn get_last_state(workspace_id: EntityId, cx: &App) -> Option<(String, SearchOptions)> { + cx.try_global::() .and_then(|storage| storage.0.get(&workspace_id).cloned()) } -fn set_last_query(workspace_id: EntityId, query: String, cx: &mut App) { - if !cx.has_global::() { - cx.set_global(LastQuickSearchQuery::default()); +fn set_last_state(workspace_id: EntityId, query: String, options: SearchOptions, cx: &mut App) { + if !cx.has_global::() { + cx.set_global(LastQuickSearchState::default()); } - cx.global_mut::() + cx.global_mut::() .0 - .insert(workspace_id, query); + .insert(workspace_id, (query, options)); } -use crate::SearchOptions; +use crate::{ + SearchOption, SearchOptions, ToggleCaseSensitive, ToggleIncludeIgnored, ToggleRegex, + ToggleWholeWord, +}; const MODAL_HEIGHT: Pixels = px(650.); const MODAL_WIDTH: Pixels = px(1100.); @@ -110,6 +116,7 @@ pub struct QuickSearchDelegate { is_searching: bool, current_query: String, focus_handle: Option, + regex_error: Option, } pub struct QuickSearchModal { @@ -135,10 +142,9 @@ impl Render for QuickSearchModal { let picker = self.picker.clone(); let delegate = &self.picker.read(cx).delegate; - let match_count = delegate.match_count; - let file_count = delegate.file_count; - let is_limited = delegate.is_limited; let is_searching = delegate.is_searching; + let search_options = delegate.search_options; + let focus_handle = self.picker.focus_handle(cx); div() .id("quick-search-modal") @@ -151,6 +157,7 @@ impl Render for QuickSearchModal { .size_full() .overflow_hidden() .border_1() + .rounded_none() .border_color(cx.theme().colors().border) .on_mouse_down_out(cx.listener(|_, _, _, cx| { cx.emit(DismissEvent); @@ -195,20 +202,34 @@ impl Render for QuickSearchModal { ) }), ) - .when(match_count > 0 && !is_searching, |this| { - let results_text = if is_limited { - format!("{}+ results (limited)", match_count) - } else { - let result_word = if match_count == 1 { "result" } else { "results" }; - let file_word = if file_count == 1 { "file" } else { "files" }; - format!("{} {} in {} {}", match_count, result_word, file_count, file_word) - }; - this.child( - Label::new(results_text) - .size(LabelSize::Small) - .color(Color::Muted), - ) - }), + .child( + h_flex() + .gap_0p5() + .child(Self::render_search_option_button( + SearchOption::CaseSensitive, + search_options, + focus_handle.clone(), + cx, + )) + .child(Self::render_search_option_button( + SearchOption::WholeWord, + search_options, + focus_handle.clone(), + cx, + )) + .child(Self::render_search_option_button( + SearchOption::Regex, + search_options, + focus_handle.clone(), + cx, + )) + .child(Self::render_search_option_button( + SearchOption::IncludeIgnored, + search_options, + focus_handle, + cx, + )), + ), ) .child(self.picker.clone()), ) @@ -223,9 +244,7 @@ impl Render for QuickSearchModal { .on_click(move |_, window, cx| { window.focus(&picker.focus_handle(cx)); }) - .when_some(preview_editor, |this, editor| { - this.child(editor) - }) + .when_some(preview_editor, |this, editor| this.child(editor)) .when(self.preview_editor.is_none(), |this| { this.child( div() @@ -260,6 +279,94 @@ impl QuickSearchModal { QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) }); }); + workspace.register_action(Self::toggle_case_sensitive); + workspace.register_action(Self::toggle_whole_word); + workspace.register_action(Self::toggle_regex); + workspace.register_action(Self::toggle_include_ignored); + } + + fn toggle_search_option( + workspace: &mut Workspace, + option: SearchOptions, + window: &mut Window, + cx: &mut Context, + ) { + if let Some(modal) = workspace.active_modal::(cx) { + modal.update(cx, |modal, cx| { + modal.picker.update(cx, |picker, cx| { + picker.delegate.toggle_search_option(option); + cx.notify(); + }); + }); + modal.update(cx, |modal, cx| { + let query = modal.picker.read(cx).delegate.current_query.clone(); + modal.picker.update(cx, |picker, cx| { + picker.set_query(query, window, cx); + }); + }); + } + } + + fn toggle_case_sensitive( + workspace: &mut Workspace, + _: &ToggleCaseSensitive, + window: &mut Window, + cx: &mut Context, + ) { + Self::toggle_search_option(workspace, SearchOptions::CASE_SENSITIVE, window, cx); + } + + fn toggle_whole_word( + workspace: &mut Workspace, + _: &ToggleWholeWord, + window: &mut Window, + cx: &mut Context, + ) { + Self::toggle_search_option(workspace, SearchOptions::WHOLE_WORD, window, cx); + } + + fn toggle_regex( + workspace: &mut Workspace, + _: &ToggleRegex, + window: &mut Window, + cx: &mut Context, + ) { + Self::toggle_search_option(workspace, SearchOptions::REGEX, window, cx); + } + + fn toggle_include_ignored( + workspace: &mut Workspace, + _: &ToggleIncludeIgnored, + window: &mut Window, + cx: &mut Context, + ) { + Self::toggle_search_option(workspace, SearchOptions::INCLUDE_IGNORED, window, cx); + } + + fn render_search_option_button( + option: SearchOption, + active: SearchOptions, + focus_handle: FocusHandle, + cx: &Context, + ) -> impl IntoElement { + let action = option.to_toggle_action(); + let label = option.label(); + let search_option = option.as_options(); + IconButton::new(label, option.icon()) + .on_click(cx.listener(move |modal, _, window, cx| { + modal.picker.update(cx, |picker, cx| { + picker.delegate.toggle_search_option(search_option); + cx.notify(); + }); + let query = modal.picker.read(cx).delegate.current_query.clone(); + modal.picker.update(cx, |picker, cx| { + picker.set_query(query, window, cx); + }); + })) + .style(ButtonStyle::Subtle) + .shape(IconButtonShape::Square) + .toggle_state(active.contains(option.as_options())) + .tooltip(move |_window, cx| Tooltip::for_action_in(label, action, &focus_handle, cx)) } fn new( @@ -270,13 +377,16 @@ impl QuickSearchModal { cx: &mut Context, ) -> Self { let weak_self = cx.entity().downgrade(); - let last_query = get_last_query(workspace_id, cx); + let last_state = get_last_state(workspace_id, cx); + let (last_query, last_options) = last_state + .map(|(q, o)| (Some(q), o)) + .unwrap_or((None, SearchOptions::NONE)); let delegate = QuickSearchDelegate { workspace, workspace_id, project, - search_options: SearchOptions::NONE, + search_options: last_options, items: Vec::new(), visible_indices: Vec::new(), collapsed_files: HashSet::default(), @@ -289,6 +399,7 @@ impl QuickSearchModal { is_searching: false, current_query: last_query.clone().unwrap_or_default(), focus_handle: None, + regex_error: None, }; let picker = cx.new(|cx| { @@ -398,6 +509,10 @@ impl QuickSearchDelegate { self.update_visible_indices(); } + fn toggle_search_option(&mut self, option: SearchOptions) { + self.search_options.toggle(option); + } + fn actual_index(&self, visible_index: usize) -> Option { self.visible_indices.get(visible_index).copied() } @@ -428,7 +543,10 @@ impl PickerDelegate for QuickSearchDelegate { let ix = ix.min(self.visible_indices.len().saturating_sub(1)); let actual_ix = self.visible_indices[ix]; - if matches!(self.items.get(actual_ix), Some(QuickSearchItem::LineMatch { .. })) { + if matches!( + self.items.get(actual_ix), + Some(QuickSearchItem::LineMatch { .. }) + ) { self.selected_index = ix; return; } @@ -436,12 +554,12 @@ impl PickerDelegate for QuickSearchDelegate { let going_down = ix >= self.selected_index; if going_down { - if let Some(next) = self.visible_indices[ix..] - .iter() - .position(|&actual_idx| { - matches!(self.items.get(actual_idx), Some(QuickSearchItem::LineMatch { .. })) - }) - { + if let Some(next) = self.visible_indices[ix..].iter().position(|&actual_idx| { + matches!( + self.items.get(actual_idx), + Some(QuickSearchItem::LineMatch { .. }) + ) + }) { self.selected_index = ix + next; return; } @@ -451,12 +569,18 @@ impl PickerDelegate for QuickSearchDelegate { if let Some(prev) = self.visible_indices[..=upper_bound] .iter() .rposition(|&actual_idx| { - matches!(self.items.get(actual_idx), Some(QuickSearchItem::LineMatch { .. })) + matches!( + self.items.get(actual_idx), + Some(QuickSearchItem::LineMatch { .. }) + ) }) { self.selected_index = prev; } else if let Some(next) = self.visible_indices.iter().position(|&actual_idx| { - matches!(self.items.get(actual_idx), Some(QuickSearchItem::LineMatch { .. })) + matches!( + self.items.get(actual_idx), + Some(QuickSearchItem::LineMatch { .. }) + ) }) { self.selected_index = next; } @@ -504,6 +628,7 @@ impl PickerDelegate for QuickSearchDelegate { self.file_count = 0; self.is_limited = false; self.is_searching = false; + self.regex_error = None; let quick_search = self.quick_search.clone(); cx.defer_in(window, move |_, window, cx| { if let Some(quick_search) = quick_search.upgrade() { @@ -539,19 +664,54 @@ impl PickerDelegate for QuickSearchDelegate { return; } - let search_query = match SearchQuery::text( - &query, - search_options.contains(SearchOptions::WHOLE_WORD), - search_options.contains(SearchOptions::CASE_SENSITIVE), - search_options.contains(SearchOptions::INCLUDE_IGNORED), - PathMatcher::default(), - PathMatcher::default(), - false, - None, - ) { - Ok(q) => q, + let search_query_result = if search_options.contains(SearchOptions::REGEX) { + SearchQuery::regex( + &query, + search_options.contains(SearchOptions::WHOLE_WORD), + search_options.contains(SearchOptions::CASE_SENSITIVE), + search_options.contains(SearchOptions::INCLUDE_IGNORED), + false, + PathMatcher::default(), + PathMatcher::default(), + false, + None, + ) + } else { + SearchQuery::text( + &query, + search_options.contains(SearchOptions::WHOLE_WORD), + search_options.contains(SearchOptions::CASE_SENSITIVE), + search_options.contains(SearchOptions::INCLUDE_IGNORED), + PathMatcher::default(), + PathMatcher::default(), + false, + None, + ) + }; + + let search_query = match search_query_result { + Ok(q) => { + picker + .update(cx, |picker, cx| { + picker.delegate.regex_error = None; + cx.notify(); + }) + .ok(); + q + } Err(err) => { - log::warn!("Quick search: invalid query '{}': {}", query, err); + let error_message = err.to_string(); + picker + .update(cx, |picker, cx| { + picker.delegate.regex_error = Some(error_message); + picker.delegate.items.clear(); + picker.delegate.visible_indices.clear(); + picker.delegate.match_count = 0; + picker.delegate.file_count = 0; + picker.delegate.is_searching = false; + cx.notify(); + }) + .ok(); return; } }; @@ -761,7 +921,12 @@ impl PickerDelegate for QuickSearchDelegate { } fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { - set_last_query(self.workspace_id, self.current_query.clone(), cx); + set_last_state( + self.workspace_id, + self.current_query.clone(), + self.search_options, + cx, + ); let actual_index = match self.actual_index(self.selected_index) { Some(idx) => idx, @@ -805,7 +970,12 @@ impl PickerDelegate for QuickSearchDelegate { } fn dismissed(&mut self, _window: &mut Window, cx: &mut Context>) { - set_last_query(self.workspace_id, self.current_query.clone(), cx); + set_last_state( + self.workspace_id, + self.current_query.clone(), + self.search_options, + cx, + ); cx.emit(DismissEvent); } @@ -910,6 +1080,62 @@ impl PickerDelegate for QuickSearchDelegate { } } + fn render_header( + &self, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option { + if let Some(error) = &self.regex_error { + return Some( + h_flex() + .w_full() + .px_3() + .py_1() + .child( + Label::new(format!("Invalid regex: {}", error)) + .size(LabelSize::Small) + .color(Color::Error), + ) + .into_any(), + ); + } + + if self.match_count > 0 && !self.is_searching { + let results_text = if self.is_limited { + format!("{}+ results (limited)", self.match_count) + } else { + let result_word = if self.match_count == 1 { + "result" + } else { + "results" + }; + let file_word = if self.file_count == 1 { + "file" + } else { + "files" + }; + format!( + "{} {} in {} {}", + self.match_count, result_word, self.file_count, file_word + ) + }; + return Some( + h_flex() + .w_full() + .px_3() + .py_1() + .child( + Label::new(results_text) + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any(), + ); + } + + None + } + fn render_footer( &self, _window: &mut Window, @@ -929,7 +1155,7 @@ impl PickerDelegate for QuickSearchDelegate { Button::new("open-split", "Open in Split") .key_binding( KeyBinding::for_action_in(&menu::SecondaryConfirm, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), + .map(|kb| kb.size(rems_from_px(11.))), ) .on_click(|_, window, cx| { window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx); @@ -939,7 +1165,7 @@ impl PickerDelegate for QuickSearchDelegate { Button::new("open", "Open") .key_binding( KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(12.))), + .map(|kb| kb.size(rems_from_px(11.))), ) .on_click(|_, window, cx| { window.dispatch_action(menu::Confirm.boxed_clone(), cx); @@ -991,7 +1217,9 @@ mod tests { let workspace_id = workspace.entity_id(); let quick_search = cx.new_window_entity({ let weak_workspace = workspace.downgrade(); - move |window, cx| QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + move |window, cx| { + QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + } }); quick_search.update(&mut cx, |modal, cx| { @@ -1022,7 +1250,9 @@ mod tests { let workspace_id = workspace.entity_id(); let quick_search = cx.new_window_entity({ let weak_workspace = workspace.downgrade(); - move |window, cx| QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + move |window, cx| { + QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + } }); quick_search.update_in(&mut cx, |modal, window, cx| { @@ -1100,7 +1330,9 @@ mod tests { let workspace_id = workspace.entity_id(); let quick_search = cx.new_window_entity({ let weak_workspace = workspace.downgrade(); - move |window, cx| QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + move |window, cx| { + QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + } }); quick_search.update_in(&mut cx, |modal, window, cx| { @@ -1143,7 +1375,9 @@ mod tests { let workspace_id = workspace.entity_id(); let quick_search = cx.new_window_entity({ let weak_workspace = workspace.downgrade(); - move |window, cx| QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + move |window, cx| { + QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + } }); quick_search.update(&mut cx, |modal, cx| { @@ -1201,7 +1435,9 @@ mod tests { let quick_search = cx.new_window_entity({ let weak_workspace = workspace.downgrade(); let project = project.clone(); - move |window, cx| QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + move |window, cx| { + QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + } }); quick_search.update_in(&mut cx, |modal, window, cx| { @@ -1224,12 +1460,15 @@ mod tests { let quick_search2 = cx.new_window_entity({ let weak_workspace = workspace.downgrade(); - move |window, cx| QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + move |window, cx| { + QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + } }); quick_search2.update(&mut cx, |modal, cx| { assert_eq!( - modal.picker.read(cx).delegate.current_query, "hello", + modal.picker.read(cx).delegate.current_query, + "hello", "Query should be restored from previous session" ); }); From 3212ea54751ebc6ba5775fe182d16af908912888 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Wed, 10 Dec 2025 23:41:21 +0100 Subject: [PATCH 04/35] Increases keybinding button size. Increases the keybinding button size in the quick search picker for better visibility and user experience. The size is adjusted from 11 to 12 rems. --- crates/search/src/quick_search.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 475ef2bb2a9a08..22d579f779fb69 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -1155,7 +1155,7 @@ impl PickerDelegate for QuickSearchDelegate { Button::new("open-split", "Open in Split") .key_binding( KeyBinding::for_action_in(&menu::SecondaryConfirm, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(11.))), + .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(|_, window, cx| { window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx); @@ -1165,7 +1165,7 @@ impl PickerDelegate for QuickSearchDelegate { Button::new("open", "Open") .key_binding( KeyBinding::for_action_in(&menu::Confirm, &focus_handle, cx) - .map(|kb| kb.size(rems_from_px(11.))), + .map(|kb| kb.size(rems_from_px(12.))), ) .on_click(|_, window, cx| { window.dispatch_action(menu::Confirm.boxed_clone(), cx); From 8f6293144f5cee4a865c03dfec8a09a041dd62db Mon Sep 17 00:00:00 2001 From: David Bonan Date: Thu, 11 Dec 2025 09:10:16 +0100 Subject: [PATCH 05/35] Improves quick search performance and UX Refactors quick search to improve performance, especially with large search results. Adds constants for max line matches and preview characters, which enhances configuration and limits resource consumption. Improves line match navigation. --- crates/search/src/quick_search.rs | 240 ++++++++++++++---------------- 1 file changed, 109 insertions(+), 131 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 22d579f779fb69..14afbb9dbdfaaf 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -10,7 +10,7 @@ use gpui::{ use language::Buffer; use picker::{Picker, PickerDelegate}; use project::{Project, ProjectPath, search::SearchQuery}; -use std::{fmt::Write as _, path::Path, pin::pin, sync::Arc, time::Duration}; +use std::{path::Path, pin::pin, sync::Arc, time::Duration}; use text::ToPoint as _; use ui::{ Button, ButtonStyle, Color, Icon, IconButton, IconButtonShape, IconName, KeyBinding, Label, @@ -46,17 +46,33 @@ use crate::{ const MODAL_HEIGHT: Pixels = px(650.); const MODAL_WIDTH: Pixels = px(1100.); const LEFT_PANEL_WIDTH: Pixels = px(300.); +const MAX_LINE_MATCHES: usize = 200; +const MAX_PREVIEW_CHARS: usize = 200; actions!(search, [QuickSearch]); -fn format_file_key(parent_path: &str, file_name: &str) -> String { +fn format_file_key(parent_path: &str, file_name: &str) -> SharedString { if parent_path.is_empty() { - file_name.to_string() + file_name.to_string().into() } else { - format!("{}/{}", parent_path, file_name) + format!("{}/{}", parent_path, file_name).into() } } +fn extract_path_parts(path: &Arc) -> (SharedString, SharedString) { + let file_name: SharedString = path + .file_name() + .map(|n| n.to_string()) + .unwrap_or_default() + .into(); + let parent_path: SharedString = path + .parent() + .map(|p| p.as_unix_str().to_string()) + .unwrap_or_default() + .into(); + (file_name, parent_path) +} + pub fn init(cx: &mut App) { cx.observe_new(QuickSearchModal::register).detach(); } @@ -65,9 +81,11 @@ enum QuickSearchItem { FileHeader { file_name: SharedString, parent_path: SharedString, + file_key: SharedString, }, LineMatch { project_path: ProjectPath, + file_key: SharedString, buffer: Entity, line: u32, line_label: SharedString, @@ -75,30 +93,6 @@ enum QuickSearchItem { }, } -impl QuickSearchItem { - fn file_key(&self) -> String { - match self { - QuickSearchItem::FileHeader { - file_name, - parent_path, - } => format_file_key(parent_path, file_name), - QuickSearchItem::LineMatch { project_path, .. } => { - let file_name = project_path - .path - .file_name() - .map(|n| n.to_string()) - .unwrap_or_default(); - let parent_path = project_path - .path - .parent() - .map(|p| p.as_unix_str().to_string()) - .unwrap_or_default(); - format_file_key(&parent_path, &file_name) - } - } - } -} - pub struct QuickSearchDelegate { workspace: WeakEntity, workspace_id: EntityId, @@ -295,12 +289,7 @@ impl QuickSearchModal { modal.update(cx, |modal, cx| { modal.picker.update(cx, |picker, cx| { picker.delegate.toggle_search_option(option); - cx.notify(); - }); - }); - modal.update(cx, |modal, cx| { - let query = modal.picker.read(cx).delegate.current_query.clone(); - modal.picker.update(cx, |picker, cx| { + let query = picker.delegate.current_query.clone(); picker.set_query(query, window, cx); }); }); @@ -356,10 +345,7 @@ impl QuickSearchModal { .on_click(cx.listener(move |modal, _, window, cx| { modal.picker.update(cx, |picker, cx| { picker.delegate.toggle_search_option(search_option); - cx.notify(); - }); - let query = modal.picker.read(cx).delegate.current_query.clone(); - modal.picker.update(cx, |picker, cx| { + let query = picker.delegate.current_query.clone(); picker.set_query(query, window, cx); }); })) @@ -490,9 +476,8 @@ impl QuickSearchDelegate { QuickSearchItem::FileHeader { .. } => { self.visible_indices.push(idx); } - QuickSearchItem::LineMatch { .. } => { - let file_key = item.file_key(); - if !self.collapsed_files.contains(&file_key) { + QuickSearchItem::LineMatch { file_key, .. } => { + if !self.collapsed_files.contains(file_key.as_ref()) { self.visible_indices.push(idx); } } @@ -500,11 +485,12 @@ impl QuickSearchDelegate { } } - fn toggle_file_collapsed(&mut self, file_key: &str) { - if self.collapsed_files.contains(file_key) { - self.collapsed_files.remove(file_key); + fn toggle_file_collapsed(&mut self, file_key: &SharedString) { + let key = file_key.as_ref(); + if self.collapsed_files.contains(key) { + self.collapsed_files.remove(key); } else { - self.collapsed_files.insert(file_key.to_string()); + self.collapsed_files.insert(key.to_string()); } self.update_visible_indices(); } @@ -516,6 +502,30 @@ impl QuickSearchDelegate { fn actual_index(&self, visible_index: usize) -> Option { self.visible_indices.get(visible_index).copied() } + + fn is_line_match_at_visible_index(&self, visible_index: usize) -> bool { + self.visible_indices + .get(visible_index) + .and_then(|&actual_idx| self.items.get(actual_idx)) + .map_or(false, |item| { + matches!(item, QuickSearchItem::LineMatch { .. }) + }) + } + + fn find_nearest_line_match( + &self, + from_visible_index: usize, + going_down: bool, + ) -> Option { + if going_down { + (from_visible_index..self.visible_indices.len()) + .find(|&i| self.is_line_match_at_visible_index(i)) + } else { + (0..=from_visible_index) + .rev() + .find(|&i| self.is_line_match_at_visible_index(i)) + } + } } impl PickerDelegate for QuickSearchDelegate { @@ -542,47 +552,17 @@ impl PickerDelegate for QuickSearchDelegate { let ix = ix.min(self.visible_indices.len().saturating_sub(1)); - let actual_ix = self.visible_indices[ix]; - if matches!( - self.items.get(actual_ix), - Some(QuickSearchItem::LineMatch { .. }) - ) { + if self.is_line_match_at_visible_index(ix) { self.selected_index = ix; return; } let going_down = ix >= self.selected_index; - if going_down { - if let Some(next) = self.visible_indices[ix..].iter().position(|&actual_idx| { - matches!( - self.items.get(actual_idx), - Some(QuickSearchItem::LineMatch { .. }) - ) - }) { - self.selected_index = ix + next; - return; - } - } - - let upper_bound = ix.min(self.visible_indices.len().saturating_sub(1)); - if let Some(prev) = self.visible_indices[..=upper_bound] - .iter() - .rposition(|&actual_idx| { - matches!( - self.items.get(actual_idx), - Some(QuickSearchItem::LineMatch { .. }) - ) - }) - { - self.selected_index = prev; - } else if let Some(next) = self.visible_indices.iter().position(|&actual_idx| { - matches!( - self.items.get(actual_idx), - Some(QuickSearchItem::LineMatch { .. }) - ) - }) { - self.selected_index = next; + if let Some(found) = self.find_nearest_line_match(ix, going_down) { + self.selected_index = found; + } else if let Some(found) = self.find_nearest_line_match(ix, !going_down) { + self.selected_index = found; } } @@ -716,16 +696,14 @@ impl PickerDelegate for QuickSearchDelegate { } }; - let search_results = project + let Some(search_results) = project .update(cx, |project, cx| project.search(search_query, cx)) - .ok(); - - let Some(search_results) = search_results else { + .log_err() + else { return; }; let mut items = Vec::new(); - let max_line_matches = 200; let mut line_match_count = 0; let mut file_count = 0; let mut is_limited = false; @@ -751,21 +729,13 @@ impl PickerDelegate for QuickSearchDelegate { return Vec::new(); }; - let file_name: SharedString = project_path - .path - .file_name() - .map(|n| n.to_string()) - .unwrap_or_default() - .into(); - let parent_path: SharedString = project_path - .path - .parent() - .map(|p| p.as_unix_str().to_string()) - .unwrap_or_default() - .into(); - - let mut seen_lines = std::collections::HashSet::new(); + let (file_name, parent_path) = + extract_path_parts(&project_path.path); + let file_key = format_file_key(&parent_path, &file_name); + + let mut seen_lines = HashSet::default(); let mut results = Vec::new(); + let mut preview_buffer = String::with_capacity(MAX_PREVIEW_CHARS); for range in &ranges { let start_point = range.start.to_point(&snapshot); @@ -781,8 +751,7 @@ impl PickerDelegate for QuickSearchDelegate { let line_end = snapshot .point_to_offset(text::Point::new(line, line_end_col)); - const MAX_PREVIEW_CHARS: usize = 200; - let mut preview_text = String::with_capacity(MAX_PREVIEW_CHARS); + preview_buffer.clear(); let mut chars_remaining = MAX_PREVIEW_CHARS; let mut started = false; @@ -795,30 +764,29 @@ impl PickerDelegate for QuickSearchDelegate { }; if text.len() <= chars_remaining { - preview_text.push_str(text); + preview_buffer.push_str(text); chars_remaining -= text.len(); } else { for ch in text.chars() { if chars_remaining == 0 { break; } - preview_text.push(ch); + preview_buffer.push(ch); chars_remaining -= 1; } - preview_text.push('…'); + preview_buffer.push('…'); break; } } let preview_text: SharedString = - preview_text.trim_end().to_string().into(); + preview_buffer.trim_end().to_string().into(); - let mut line_label = String::with_capacity(8); - let _ = write!(line_label, "{}", line + 1); - let line_label: SharedString = line_label.into(); + let line_label: SharedString = format!("{}", line + 1).into(); results.push(( project_path.clone(), + file_key.clone(), line, line_label, preview_text, @@ -835,13 +803,15 @@ impl PickerDelegate for QuickSearchDelegate { if !match_data_list.is_empty() { let first = &match_data_list[0]; items.push(QuickSearchItem::FileHeader { - file_name: first.4.clone(), - parent_path: first.5.clone(), + file_name: first.5.clone(), + parent_path: first.6.clone(), + file_key: first.1.clone(), }); file_count += 1; for ( project_path, + file_key, line, line_label, preview, @@ -851,6 +821,7 @@ impl PickerDelegate for QuickSearchDelegate { { items.push(QuickSearchItem::LineMatch { project_path, + file_key, buffer: buffer.clone(), line, line_label, @@ -858,14 +829,14 @@ impl PickerDelegate for QuickSearchDelegate { }); line_match_count += 1; - if line_match_count >= max_line_matches { + if line_match_count >= MAX_LINE_MATCHES { is_limited = true; break; } } } - if line_match_count >= max_line_matches { + if line_match_count >= MAX_LINE_MATCHES { break; } } @@ -993,9 +964,9 @@ impl PickerDelegate for QuickSearchDelegate { QuickSearchItem::FileHeader { file_name, parent_path, + file_key, } => { - let file_key = format_file_key(parent_path, file_name); - let is_collapsed = self.collapsed_files.contains(&file_key); + let is_collapsed = self.collapsed_files.contains(file_key.as_ref()); let chevron_icon = if is_collapsed { IconName::ChevronRight @@ -1019,15 +990,20 @@ impl PickerDelegate for QuickSearchDelegate { .w_full() .gap_1() .cursor_pointer() - .on_click(move |_, _window, cx| { - cx.stop_propagation(); - if let Some(qs) = quick_search.upgrade() { - qs.update(cx, |qs, cx| { - qs.picker.update(cx, |picker, cx| { - picker.delegate.toggle_file_collapsed(&file_key); - cx.notify(); + .on_click({ + let file_key = file_key.clone(); + move |_, _window, cx| { + cx.stop_propagation(); + if let Some(qs) = quick_search.upgrade() { + qs.update(cx, |qs, cx| { + qs.picker.update(cx, |picker, cx| { + picker + .delegate + .toggle_file_collapsed(&file_key); + cx.notify(); + }); }); - }); + } } }) .child( @@ -1290,6 +1266,7 @@ mod tests { let header = QuickSearchItem::FileHeader { file_name: "test.rs".into(), parent_path: "src".into(), + file_key: "src/test.rs".into(), }; assert!(matches!(header, QuickSearchItem::FileHeader { .. })); @@ -1300,6 +1277,7 @@ mod tests { worktree_id: project::WorktreeId::from_usize(0), path: util::rel_path::rel_path("src/test.rs").into(), }, + file_key: "src/test.rs".into(), buffer, line: 0, line_label: "1".into(), @@ -1485,12 +1463,14 @@ mod tests { QuickSearchItem::FileHeader { file_name: "test.rs".into(), parent_path: "src".into(), + file_key: "src/test.rs".into(), }, QuickSearchItem::LineMatch { project_path: ProjectPath { worktree_id: project::WorktreeId::from_usize(0), path: util::rel_path::rel_path("src/test.rs").into(), }, + file_key: "src/test.rs".into(), buffer: buffer.clone(), line: 0, line_label: "1".into(), @@ -1501,6 +1481,7 @@ mod tests { worktree_id: project::WorktreeId::from_usize(0), path: util::rel_path::rel_path("src/test.rs").into(), }, + file_key: "src/test.rs".into(), buffer, line: 1, line_label: "2".into(), @@ -1516,9 +1497,8 @@ mod tests { QuickSearchItem::FileHeader { .. } => { visible_indices.push(idx); } - QuickSearchItem::LineMatch { .. } => { - let file_key = item.file_key(); - if !collapsed_files.contains(&file_key) { + QuickSearchItem::LineMatch { file_key, .. } => { + if !collapsed_files.contains(file_key.as_ref()) { visible_indices.push(idx); } } @@ -1535,9 +1515,8 @@ mod tests { QuickSearchItem::FileHeader { .. } => { visible_indices.push(idx); } - QuickSearchItem::LineMatch { .. } => { - let file_key = item.file_key(); - if !collapsed_files.contains(&file_key) { + QuickSearchItem::LineMatch { file_key, .. } => { + if !collapsed_files.contains(file_key.as_ref()) { visible_indices.push(idx); } } @@ -1558,9 +1537,8 @@ mod tests { QuickSearchItem::FileHeader { .. } => { visible_indices.push(idx); } - QuickSearchItem::LineMatch { .. } => { - let file_key = item.file_key(); - if !collapsed_files.contains(&file_key) { + QuickSearchItem::LineMatch { file_key, .. } => { + if !collapsed_files.contains(file_key.as_ref()) { visible_indices.push(idx); } } From ff005c046c7b204547acdb0d753201506f713f80 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Thu, 11 Dec 2025 09:11:25 +0100 Subject: [PATCH 06/35] Improves quick search result extraction. Refactors quick search to extract file matches and line previews more efficiently. This change introduces a new function to extract file matches and line previews from a buffer, improving code readability and maintainability. It also leverages SharedString for file keys and parent paths to reduce memory allocations. Additionally, the quick search modal now accepts an optional initial query, allowing for pre-population of the search field. --- crates/search/src/quick_search.rs | 302 ++++++++++++++++-------------- 1 file changed, 159 insertions(+), 143 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 14afbb9dbdfaaf..093b8950815551 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -17,7 +17,7 @@ use ui::{ ListItem, ListItemSpacing, SpinnerLabel, Tooltip, prelude::*, rems_from_px, }; use util::{ResultExt, paths::PathMatcher}; -use workspace::{ModalView, Workspace}; +use workspace::{ModalView, Workspace, searchable::SearchableItemHandle}; #[derive(Default)] struct LastQuickSearchState(HashMap); @@ -51,6 +51,97 @@ const MAX_PREVIEW_CHARS: usize = 200; actions!(search, [QuickSearch]); +struct LineMatchData { + project_path: ProjectPath, + file_key: SharedString, + line: u32, + line_label: SharedString, + preview_text: SharedString, +} + +struct FileMatchResult { + file_name: SharedString, + parent_path: SharedString, + file_key: SharedString, + matches: Vec, +} + +fn truncate_preview(text: &str, max_chars: usize) -> SharedString { + let trimmed = text.trim(); + if trimmed.len() <= max_chars { + return trimmed.to_string().into(); + } + + let mut end = max_chars; + while end > 0 && !trimmed.is_char_boundary(end) { + end -= 1; + } + + let mut result = trimmed[..end].to_string(); + result.push('…'); + result.into() +} + +fn extract_file_matches( + buf: &Buffer, + ranges: &[std::ops::Range], + cx: &App, +) -> Option { + let file = buf.file()?; + let project_path = ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path().clone(), + }; + + let (file_name, parent_path) = extract_path_parts(&project_path.path); + let file_key = format_file_key(&parent_path, &file_name); + + let snapshot = buf.snapshot(); + let mut seen_lines = HashSet::default(); + let mut matches = Vec::with_capacity(ranges.len().min(MAX_LINE_MATCHES)); + + for range in ranges { + let start_point = range.start.to_point(&snapshot); + let line = start_point.row; + + if !seen_lines.insert(line) { + continue; + } + + let line_start = snapshot.point_to_offset(text::Point::new(line, 0)); + let line_end_col = snapshot.line_len(line); + let line_end = snapshot.point_to_offset(text::Point::new(line, line_end_col)); + + let line_text: String = snapshot.text_for_range(line_start..line_end).collect(); + + let preview_text = truncate_preview(&line_text, MAX_PREVIEW_CHARS); + let line_label: SharedString = format!("{}", line + 1).into(); + + matches.push(LineMatchData { + project_path: project_path.clone(), + file_key: file_key.clone(), + line, + line_label, + preview_text, + }); + + if matches.len() >= MAX_LINE_MATCHES { + break; + } + } + + if matches.is_empty() { + return None; + } + + Some(FileMatchResult { + file_name, + parent_path, + file_key, + matches, + }) +} + fn format_file_key(parent_path: &str, file_name: &str) -> SharedString { if parent_path.is_empty() { file_name.to_string().into() @@ -100,7 +191,7 @@ pub struct QuickSearchDelegate { search_options: SearchOptions, items: Vec, visible_indices: Vec, - collapsed_files: HashSet, + collapsed_files: HashSet, selected_index: usize, pending_search_id: usize, quick_search: WeakEntity, @@ -265,12 +356,25 @@ impl QuickSearchModal { _cx: &mut Context, ) { workspace.register_action(|workspace, _: &QuickSearch, window, cx| { + let selected_text = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + .map(|editor| editor.query_suggestion(window, cx)) + .filter(|query| !query.is_empty()); + let project = workspace.project().clone(); let workspace_entity = cx.entity(); let workspace_id = workspace_entity.entity_id(); let weak_workspace = workspace_entity.downgrade(); workspace.toggle_modal(window, cx, |window, cx| { - QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + QuickSearchModal::new( + weak_workspace, + workspace_id, + project, + selected_text, + window, + cx, + ) }); }); workspace.register_action(Self::toggle_case_sensitive); @@ -359,6 +463,7 @@ impl QuickSearchModal { workspace: WeakEntity, workspace_id: EntityId, project: Entity, + initial_query: Option, window: &mut Window, cx: &mut Context, ) -> Self { @@ -368,6 +473,8 @@ impl QuickSearchModal { .map(|(q, o)| (Some(q), o)) .unwrap_or((None, SearchOptions::NONE)); + let query = initial_query.or(last_query); + let delegate = QuickSearchDelegate { workspace, workspace_id, @@ -383,7 +490,7 @@ impl QuickSearchModal { file_count: 0, is_limited: false, is_searching: false, - current_query: last_query.clone().unwrap_or_default(), + current_query: query.clone().unwrap_or_default(), focus_handle: None, regex_error: None, }; @@ -394,8 +501,8 @@ impl QuickSearchModal { .max_height(None) .show_scrollbar(true); picker.delegate.focus_handle = Some(picker.focus_handle(cx)); - if let Some(query) = last_query { - picker.set_query(query, window, cx); + if let Some(q) = query { + picker.set_query(q, window, cx); } picker }); @@ -448,10 +555,7 @@ impl QuickSearchModal { } else { let editor = cx.new(|cx| { let mut editor = Editor::for_buffer(buffer.clone(), None, window, cx); - editor.set_read_only(true); - editor.set_input_enabled(false); editor.set_show_gutter(true, cx); - editor.set_show_line_numbers(false, cx); editor }); @@ -477,7 +581,7 @@ impl QuickSearchDelegate { self.visible_indices.push(idx); } QuickSearchItem::LineMatch { file_key, .. } => { - if !self.collapsed_files.contains(file_key.as_ref()) { + if !self.collapsed_files.contains(file_key) { self.visible_indices.push(idx); } } @@ -486,11 +590,10 @@ impl QuickSearchDelegate { } fn toggle_file_collapsed(&mut self, file_key: &SharedString) { - let key = file_key.as_ref(); - if self.collapsed_files.contains(key) { - self.collapsed_files.remove(key); + if self.collapsed_files.contains(file_key) { + self.collapsed_files.remove(file_key); } else { - self.collapsed_files.insert(key.to_string()); + self.collapsed_files.insert(file_key.clone()); } self.update_visible_indices(); } @@ -610,7 +713,7 @@ impl PickerDelegate for QuickSearchDelegate { self.is_searching = false; self.regex_error = None; let quick_search = self.quick_search.clone(); - cx.defer_in(window, move |_, window, cx| { + cx.defer_in(window, move |_, _window, cx| { if let Some(quick_search) = quick_search.upgrade() { quick_search.update(cx, |qs, cx| { qs.preview_editor = None; @@ -618,7 +721,6 @@ impl PickerDelegate for QuickSearchDelegate { cx.notify(); }); } - let _ = window; }); cx.notify(); return Task::ready(()); @@ -716,123 +818,36 @@ impl PickerDelegate for QuickSearchDelegate { continue; } - let match_data_list = cx - .read_entity(&buffer, |buf, cx| { - let snapshot = buf.snapshot(); - let file = buf.file(); - let project_path = file.map(|f| ProjectPath { - worktree_id: f.worktree_id(cx), - path: f.path().clone(), - }); - - let Some(project_path) = project_path else { - return Vec::new(); - }; - - let (file_name, parent_path) = - extract_path_parts(&project_path.path); - let file_key = format_file_key(&parent_path, &file_name); - - let mut seen_lines = HashSet::default(); - let mut results = Vec::new(); - let mut preview_buffer = String::with_capacity(MAX_PREVIEW_CHARS); - - for range in &ranges { - let start_point = range.start.to_point(&snapshot); - let line = start_point.row; - - if !seen_lines.insert(line) { - continue; - } - - let line_start = - snapshot.point_to_offset(text::Point::new(line, 0)); - let line_end_col = snapshot.line_len(line); - let line_end = snapshot - .point_to_offset(text::Point::new(line, line_end_col)); - - preview_buffer.clear(); - let mut chars_remaining = MAX_PREVIEW_CHARS; - let mut started = false; - - for chunk in snapshot.chunks(line_start..line_end, false) { - let text = if !started { - started = true; - chunk.text.trim_start() - } else { - chunk.text - }; - - if text.len() <= chars_remaining { - preview_buffer.push_str(text); - chars_remaining -= text.len(); - } else { - for ch in text.chars() { - if chars_remaining == 0 { - break; - } - preview_buffer.push(ch); - chars_remaining -= 1; - } - preview_buffer.push('…'); - break; - } - } - - let preview_text: SharedString = - preview_buffer.trim_end().to_string().into(); - - let line_label: SharedString = format!("{}", line + 1).into(); + let file_result = cx + .read_entity(&buffer, |buf, cx| extract_file_matches(buf, &ranges, cx)) + .ok() + .flatten(); - results.push(( - project_path.clone(), - file_key.clone(), - line, - line_label, - preview_text, - file_name.clone(), - parent_path.clone(), - )); - } - - results - }) - .log_err() - .unwrap_or_default(); - - if !match_data_list.is_empty() { - let first = &match_data_list[0]; - items.push(QuickSearchItem::FileHeader { - file_name: first.5.clone(), - parent_path: first.6.clone(), - file_key: first.1.clone(), + let Some(file_result) = file_result else { + continue; + }; + + items.push(QuickSearchItem::FileHeader { + file_name: file_result.file_name, + parent_path: file_result.parent_path, + file_key: file_result.file_key, + }); + file_count += 1; + + for match_data in file_result.matches { + items.push(QuickSearchItem::LineMatch { + project_path: match_data.project_path, + file_key: match_data.file_key, + buffer: buffer.clone(), + line: match_data.line, + line_label: match_data.line_label, + preview_text: match_data.preview_text, }); - file_count += 1; - - for ( - project_path, - file_key, - line, - line_label, - preview, - _file_name, - _parent_path, - ) in match_data_list - { - items.push(QuickSearchItem::LineMatch { - project_path, - file_key, - buffer: buffer.clone(), - line, - line_label, - preview_text: preview, - }); - - line_match_count += 1; - if line_match_count >= MAX_LINE_MATCHES { - is_limited = true; - break; - } + + line_match_count += 1; + if line_match_count >= MAX_LINE_MATCHES { + is_limited = true; + break; } } @@ -1194,7 +1209,7 @@ mod tests { let quick_search = cx.new_window_entity({ let weak_workspace = workspace.downgrade(); move |window, cx| { - QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + QuickSearchModal::new(weak_workspace, workspace_id, project, None, window, cx) } }); @@ -1227,7 +1242,7 @@ mod tests { let quick_search = cx.new_window_entity({ let weak_workspace = workspace.downgrade(); move |window, cx| { - QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + QuickSearchModal::new(weak_workspace, workspace_id, project, None, window, cx) } }); @@ -1309,7 +1324,7 @@ mod tests { let quick_search = cx.new_window_entity({ let weak_workspace = workspace.downgrade(); move |window, cx| { - QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + QuickSearchModal::new(weak_workspace, workspace_id, project, None, window, cx) } }); @@ -1354,7 +1369,7 @@ mod tests { let quick_search = cx.new_window_entity({ let weak_workspace = workspace.downgrade(); move |window, cx| { - QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + QuickSearchModal::new(weak_workspace, workspace_id, project, None, window, cx) } }); @@ -1414,7 +1429,7 @@ mod tests { let weak_workspace = workspace.downgrade(); let project = project.clone(); move |window, cx| { - QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + QuickSearchModal::new(weak_workspace, workspace_id, project, None, window, cx) } }); @@ -1439,7 +1454,7 @@ mod tests { let quick_search2 = cx.new_window_entity({ let weak_workspace = workspace.downgrade(); move |window, cx| { - QuickSearchModal::new(weak_workspace, workspace_id, project, window, cx) + QuickSearchModal::new(weak_workspace, workspace_id, project, None, window, cx) } }); @@ -1490,7 +1505,7 @@ mod tests { ]; let mut visible_indices = Vec::new(); - let mut collapsed_files: HashSet = HashSet::default(); + let mut collapsed_files: HashSet = HashSet::default(); for (idx, item) in items.iter().enumerate() { match item { @@ -1498,7 +1513,7 @@ mod tests { visible_indices.push(idx); } QuickSearchItem::LineMatch { file_key, .. } => { - if !collapsed_files.contains(file_key.as_ref()) { + if !collapsed_files.contains(file_key) { visible_indices.push(idx); } } @@ -1508,7 +1523,8 @@ mod tests { assert_eq!(visible_indices.len(), 3, "All 3 items should be visible"); assert_eq!(visible_indices, vec![0, 1, 2]); - collapsed_files.insert("src/test.rs".to_string()); + let file_key: SharedString = "src/test.rs".into(); + collapsed_files.insert(file_key.clone()); visible_indices.clear(); for (idx, item) in items.iter().enumerate() { match item { @@ -1516,7 +1532,7 @@ mod tests { visible_indices.push(idx); } QuickSearchItem::LineMatch { file_key, .. } => { - if !collapsed_files.contains(file_key.as_ref()) { + if !collapsed_files.contains(file_key) { visible_indices.push(idx); } } @@ -1530,7 +1546,7 @@ mod tests { ); assert_eq!(visible_indices, vec![0]); - collapsed_files.remove("src/test.rs"); + collapsed_files.remove(&file_key); visible_indices.clear(); for (idx, item) in items.iter().enumerate() { match item { @@ -1538,7 +1554,7 @@ mod tests { visible_indices.push(idx); } QuickSearchItem::LineMatch { file_key, .. } => { - if !collapsed_files.contains(file_key.as_ref()) { + if !collapsed_files.contains(file_key) { visible_indices.push(idx); } } From 0518043dc6fb2e3881cd40a352a0cbf14374aca6 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Thu, 11 Dec 2025 09:30:32 +0100 Subject: [PATCH 07/35] Adds click handler to quick search items Adds a click handler to each quick search item to allow for selecting an item and updating the preview. Also adds a double click handler to confirm the selected item, dispatching a Confirm action. --- crates/search/src/quick_search.rs | 86 ++++++++++++++++++++++--------- 1 file changed, 62 insertions(+), 24 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 093b8950815551..7d851053c59192 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -1042,32 +1042,70 @@ impl PickerDelegate for QuickSearchDelegate { line_label, preview_text, .. - } => Some( - ListItem::new(ix) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .child( - h_flex() - .w_full() - .gap_2() - .pl(px(20.)) - .justify_between() - .child( - div().flex_1().min_w_0().overflow_hidden().child( - Label::new(preview_text.clone()) + } => { + let quick_search = self.quick_search.clone(); + let visible_ix = ix; + + Some( + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .on_click({ + let quick_search = quick_search.clone(); + move |event, window, cx| { + cx.stop_propagation(); + if event.click_count() >= 2 { + window.dispatch_action(menu::Confirm.boxed_clone(), cx); + } else if let Some(qs) = quick_search.upgrade() { + let preview_data = { + let modal = qs.read(cx); + let delegate = &modal.picker.read(cx).delegate; + delegate.actual_index(visible_ix).and_then(|idx| { + match delegate.items.get(idx) { + Some(QuickSearchItem::LineMatch { + buffer, line, .. + }) => Some((buffer.clone(), *line)), + _ => None, + } + }) + }; + + qs.update(cx, |modal, cx| { + modal.picker.update(cx, |picker, cx| { + picker.delegate.selected_index = visible_ix; + cx.notify(); + }); + }); + + qs.update(cx, |modal, cx| { + modal.update_preview(preview_data, window, cx); + }); + } + } + }) + .child( + h_flex() + .w_full() + .gap_2() + .pl(px(20.)) + .justify_between() + .child( + div().flex_1().min_w_0().overflow_hidden().child( + Label::new(preview_text.clone()) + .size(ui::LabelSize::Small) + .color(Color::Default) + .truncate(), + ), + ) + .child( + Label::new(line_label.clone()) .size(ui::LabelSize::Small) - .color(Color::Default) - .truncate(), + .color(Color::Muted), ), - ) - .child( - Label::new(line_label.clone()) - .size(ui::LabelSize::Small) - .color(Color::Muted), - ), - ), - ), + ), + ) + } } } From 8efb4c5a8a73e733469d1103cfa13c856f2d0ce8 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Thu, 11 Dec 2025 09:35:04 +0100 Subject: [PATCH 08/35] Highlights search matches in preview editor. --- crates/search/src/quick_search.rs | 131 +++++++++++++++++++++++------- 1 file changed, 100 insertions(+), 31 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 7d851053c59192..9075e377eaebcc 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -1,5 +1,5 @@ use collections::{HashMap, HashSet}; -use editor::Editor; +use editor::{Anchor as MultiBufferAnchor, Editor}; use file_icons::FileIcons; use futures::StreamExt; use gpui::{ @@ -57,8 +57,11 @@ struct LineMatchData { line: u32, line_label: SharedString, preview_text: SharedString, + match_ranges: Vec>, } +enum QuickSearchHighlights {} + struct FileMatchResult { file_name: SharedString, parent_path: SharedString, @@ -97,43 +100,59 @@ fn extract_file_matches( let file_key = format_file_key(&parent_path, &file_name); let snapshot = buf.snapshot(); - let mut seen_lines = HashSet::default(); - let mut matches = Vec::with_capacity(ranges.len().min(MAX_LINE_MATCHES)); + let mut lines_data: HashMap< + u32, + ( + SharedString, + SharedString, + Vec>, + ), + > = HashMap::default(); + let mut line_order = Vec::new(); for range in ranges { let start_point = range.start.to_point(&snapshot); let line = start_point.row; - if !seen_lines.insert(line) { - continue; - } - - let line_start = snapshot.point_to_offset(text::Point::new(line, 0)); - let line_end_col = snapshot.line_len(line); - let line_end = snapshot.point_to_offset(text::Point::new(line, line_end_col)); - - let line_text: String = snapshot.text_for_range(line_start..line_end).collect(); + if let Some((_, _, line_ranges)) = lines_data.get_mut(&line) { + line_ranges.push(range.clone()); + } else { + let line_start = snapshot.point_to_offset(text::Point::new(line, 0)); + let line_end_col = snapshot.line_len(line); + let line_end = snapshot.point_to_offset(text::Point::new(line, line_end_col)); - let preview_text = truncate_preview(&line_text, MAX_PREVIEW_CHARS); - let line_label: SharedString = format!("{}", line + 1).into(); + let line_text: String = snapshot.text_for_range(line_start..line_end).collect(); + let preview_text = truncate_preview(&line_text, MAX_PREVIEW_CHARS); + let line_label: SharedString = format!("{}", line + 1).into(); - matches.push(LineMatchData { - project_path: project_path.clone(), - file_key: file_key.clone(), - line, - line_label, - preview_text, - }); + lines_data.insert(line, (line_label, preview_text, vec![range.clone()])); + line_order.push(line); + } - if matches.len() >= MAX_LINE_MATCHES { + if line_order.len() >= MAX_LINE_MATCHES { break; } } - if matches.is_empty() { + if line_order.is_empty() { return None; } + let matches = line_order + .into_iter() + .filter_map(|line| { + let (line_label, preview_text, match_ranges) = lines_data.remove(&line)?; + Some(LineMatchData { + project_path: project_path.clone(), + file_key: file_key.clone(), + line, + line_label, + preview_text, + match_ranges, + }) + }) + .collect(); + Some(FileMatchResult { file_name, parent_path, @@ -181,6 +200,7 @@ enum QuickSearchItem { line: u32, line_label: SharedString, preview_text: SharedString, + match_ranges: Vec>, }, } @@ -529,11 +549,11 @@ impl QuickSearchModal { fn update_preview( &mut self, - buffer: Option<(Entity, u32)>, + buffer: Option<(Entity, u32, Vec>)>, window: &mut Window, cx: &mut Context, ) { - let Some((buffer, line)) = buffer else { + let Some((buffer, line, match_ranges)) = buffer else { self.preview_editor = None; self.preview_buffer = None; cx.notify(); @@ -550,6 +570,21 @@ impl QuickSearchModal { editor.update(cx, |editor, cx| { let point = text::Point::new(line, 0); editor.go_to_singleton_buffer_point(point, window, cx); + + let multi_buffer = editor.buffer().read(cx); + if let Some(excerpt_id) = multi_buffer.excerpt_ids().first().copied() { + let multi_buffer_ranges: Vec<_> = match_ranges + .iter() + .map(|range| { + MultiBufferAnchor::range_in_buffer(excerpt_id, range.clone()) + }) + .collect(); + editor.highlight_background::( + &multi_buffer_ranges, + |_, theme| theme.colors().search_match_background, + cx, + ); + } }); } } else { @@ -562,6 +597,19 @@ impl QuickSearchModal { editor.update(cx, |editor, cx| { let point = text::Point::new(line, 0); editor.go_to_singleton_buffer_point(point, window, cx); + + let multi_buffer = editor.buffer().read(cx); + if let Some(excerpt_id) = multi_buffer.excerpt_ids().first().copied() { + let multi_buffer_ranges: Vec<_> = match_ranges + .iter() + .map(|range| MultiBufferAnchor::range_in_buffer(excerpt_id, range.clone())) + .collect(); + editor.highlight_background::( + &multi_buffer_ranges, + |_, theme| theme.colors().search_match_background, + cx, + ); + } }); self.preview_editor = Some(editor); @@ -678,7 +726,12 @@ impl PickerDelegate for QuickSearchDelegate { let quick_search = self.quick_search.clone(); let actual_index = self.actual_index(self.selected_index); let preview_data = actual_index.and_then(|idx| match self.items.get(idx) { - Some(QuickSearchItem::LineMatch { buffer, line, .. }) => Some((buffer.clone(), *line)), + Some(QuickSearchItem::LineMatch { + buffer, + line, + match_ranges, + .. + }) => Some((buffer.clone(), *line, match_ranges.clone())), _ => None, }); @@ -842,6 +895,7 @@ impl PickerDelegate for QuickSearchDelegate { line: match_data.line, line_label: match_data.line_label, preview_text: match_data.preview_text, + match_ranges: match_data.match_ranges, }); line_match_count += 1; @@ -863,8 +917,14 @@ impl PickerDelegate for QuickSearchDelegate { } let first_line_match = items.iter().find_map(|item| { - if let QuickSearchItem::LineMatch { buffer, line, .. } = item { - Some((buffer.clone(), *line)) + if let QuickSearchItem::LineMatch { + buffer, + line, + match_ranges, + .. + } = item + { + Some((buffer.clone(), *line, match_ranges.clone())) } else { None } @@ -1052,7 +1112,6 @@ impl PickerDelegate for QuickSearchDelegate { .spacing(ListItemSpacing::Sparse) .toggle_state(selected) .on_click({ - let quick_search = quick_search.clone(); move |event, window, cx| { cx.stop_propagation(); if event.click_count() >= 2 { @@ -1064,8 +1123,15 @@ impl PickerDelegate for QuickSearchDelegate { delegate.actual_index(visible_ix).and_then(|idx| { match delegate.items.get(idx) { Some(QuickSearchItem::LineMatch { - buffer, line, .. - }) => Some((buffer.clone(), *line)), + buffer, + line, + match_ranges, + .. + }) => Some(( + buffer.clone(), + *line, + match_ranges.clone(), + )), _ => None, } }) @@ -1335,6 +1401,7 @@ mod tests { line: 0, line_label: "1".into(), preview_text: "fn test()".into(), + match_ranges: Vec::new(), }; assert!(matches!(line_match, QuickSearchItem::LineMatch { .. })); }); @@ -1528,6 +1595,7 @@ mod tests { line: 0, line_label: "1".into(), preview_text: "fn test()".into(), + match_ranges: Vec::new(), }, QuickSearchItem::LineMatch { project_path: ProjectPath { @@ -1539,6 +1607,7 @@ mod tests { line: 1, line_label: "2".into(), preview_text: "fn other()".into(), + match_ranges: Vec::new(), }, ]; From f9183b1381d565f3d2af40429036621be909d88f Mon Sep 17 00:00:00 2001 From: David Bonan Date: Thu, 11 Dec 2025 09:41:40 +0100 Subject: [PATCH 09/35] Focuses the picker on selection Ensures the quick search picker receives focus when an item is selected. This improves keyboard navigation and overall user experience by immediately directing focus to the picker after a selection is made. --- crates/search/src/quick_search.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 9075e377eaebcc..76d65ffab1a215 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -1067,10 +1067,11 @@ impl PickerDelegate for QuickSearchDelegate { .cursor_pointer() .on_click({ let file_key = file_key.clone(); - move |_, _window, cx| { + move |_, window, cx| { cx.stop_propagation(); if let Some(qs) = quick_search.upgrade() { qs.update(cx, |qs, cx| { + window.focus(&qs.picker.focus_handle(cx)); qs.picker.update(cx, |picker, cx| { picker .delegate @@ -1138,6 +1139,7 @@ impl PickerDelegate for QuickSearchDelegate { }; qs.update(cx, |modal, cx| { + window.focus(&modal.picker.focus_handle(cx)); modal.picker.update(cx, |picker, cx| { picker.delegate.selected_index = visible_ix; cx.notify(); From 89eb7935e83b4d2091a3f2d0b992f1199a5e74b6 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Thu, 11 Dec 2025 09:46:05 +0100 Subject: [PATCH 10/35] Edit and save project directly from preview window Ensures the editor has access to the project context when it is created for a buffer, which is needed to resolve workspace paths. --- crates/search/src/quick_search.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 76d65ffab1a215..ac17a19b744f34 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -588,8 +588,9 @@ impl QuickSearchModal { }); } } else { + let project = self.picker.read(cx).delegate.project.clone(); let editor = cx.new(|cx| { - let mut editor = Editor::for_buffer(buffer.clone(), None, window, cx); + let mut editor = Editor::for_buffer(buffer.clone(), Some(project), window, cx); editor.set_show_gutter(true, cx); editor }); From 241fca9bf4050b480f4c57b9bfb7511303fe687e Mon Sep 17 00:00:00 2001 From: David Bonan Date: Thu, 11 Dec 2025 09:49:36 +0100 Subject: [PATCH 11/35] Removes quick search state persistence Stops persisting the last quick search state, ensuring a clean state for each new search. This simplifies the search process by not restoring previous queries and options. --- crates/search/src/quick_search.rs | 146 +++--------------------------- 1 file changed, 11 insertions(+), 135 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index ac17a19b744f34..a672e76efd09f1 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -3,9 +3,8 @@ use editor::{Anchor as MultiBufferAnchor, Editor}; use file_icons::FileIcons; use futures::StreamExt; use gpui::{ - Action, App, Context, DismissEvent, Entity, EntityId, EventEmitter, FocusHandle, Focusable, - Global, Pixels, Render, SharedString, Subscription, Task, WeakEntity, Window, actions, - prelude::*, + Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Pixels, + Render, SharedString, Subscription, Task, WeakEntity, Window, actions, prelude::*, }; use language::Buffer; use picker::{Picker, PickerDelegate}; @@ -19,25 +18,6 @@ use ui::{ use util::{ResultExt, paths::PathMatcher}; use workspace::{ModalView, Workspace, searchable::SearchableItemHandle}; -#[derive(Default)] -struct LastQuickSearchState(HashMap); - -impl Global for LastQuickSearchState {} - -fn get_last_state(workspace_id: EntityId, cx: &App) -> Option<(String, SearchOptions)> { - cx.try_global::() - .and_then(|storage| storage.0.get(&workspace_id).cloned()) -} - -fn set_last_state(workspace_id: EntityId, query: String, options: SearchOptions, cx: &mut App) { - if !cx.has_global::() { - cx.set_global(LastQuickSearchState::default()); - } - cx.global_mut::() - .0 - .insert(workspace_id, (query, options)); -} - use crate::{ SearchOption, SearchOptions, ToggleCaseSensitive, ToggleIncludeIgnored, ToggleRegex, ToggleWholeWord, @@ -206,7 +186,6 @@ enum QuickSearchItem { pub struct QuickSearchDelegate { workspace: WeakEntity, - workspace_id: EntityId, project: Entity, search_options: SearchOptions, items: Vec, @@ -383,18 +362,9 @@ impl QuickSearchModal { .filter(|query| !query.is_empty()); let project = workspace.project().clone(); - let workspace_entity = cx.entity(); - let workspace_id = workspace_entity.entity_id(); - let weak_workspace = workspace_entity.downgrade(); + let weak_workspace = cx.entity().downgrade(); workspace.toggle_modal(window, cx, |window, cx| { - QuickSearchModal::new( - weak_workspace, - workspace_id, - project, - selected_text, - window, - cx, - ) + QuickSearchModal::new(weak_workspace, project, selected_text, window, cx) }); }); workspace.register_action(Self::toggle_case_sensitive); @@ -481,25 +451,17 @@ impl QuickSearchModal { fn new( workspace: WeakEntity, - workspace_id: EntityId, project: Entity, initial_query: Option, window: &mut Window, cx: &mut Context, ) -> Self { let weak_self = cx.entity().downgrade(); - let last_state = get_last_state(workspace_id, cx); - let (last_query, last_options) = last_state - .map(|(q, o)| (Some(q), o)) - .unwrap_or((None, SearchOptions::NONE)); - - let query = initial_query.or(last_query); let delegate = QuickSearchDelegate { workspace, - workspace_id, project, - search_options: last_options, + search_options: SearchOptions::NONE, items: Vec::new(), visible_indices: Vec::new(), collapsed_files: HashSet::default(), @@ -510,7 +472,7 @@ impl QuickSearchModal { file_count: 0, is_limited: false, is_searching: false, - current_query: query.clone().unwrap_or_default(), + current_query: initial_query.clone().unwrap_or_default(), focus_handle: None, regex_error: None, }; @@ -521,7 +483,7 @@ impl QuickSearchModal { .max_height(None) .show_scrollbar(true); picker.delegate.focus_handle = Some(picker.focus_handle(cx)); - if let Some(q) = query { + if let Some(q) = initial_query { picker.set_query(q, window, cx); } picker @@ -968,13 +930,6 @@ impl PickerDelegate for QuickSearchDelegate { } fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { - set_last_state( - self.workspace_id, - self.current_query.clone(), - self.search_options, - cx, - ); - let actual_index = match self.actual_index(self.selected_index) { Some(idx) => idx, None => return, @@ -1017,12 +972,6 @@ impl PickerDelegate for QuickSearchDelegate { } fn dismissed(&mut self, _window: &mut Window, cx: &mut Context>) { - set_last_state( - self.workspace_id, - self.current_query.clone(), - self.search_options, - cx, - ); cx.emit(DismissEvent); } @@ -1312,12 +1261,9 @@ mod tests { let workspace = window.root(cx).unwrap(); let mut cx = VisualTestContext::from_window(*window.deref(), cx); - let workspace_id = workspace.entity_id(); let quick_search = cx.new_window_entity({ let weak_workspace = workspace.downgrade(); - move |window, cx| { - QuickSearchModal::new(weak_workspace, workspace_id, project, None, window, cx) - } + move |window, cx| QuickSearchModal::new(weak_workspace, project, None, window, cx) }); quick_search.update(&mut cx, |modal, cx| { @@ -1345,12 +1291,9 @@ mod tests { let workspace = window.root(cx).unwrap(); let mut cx = VisualTestContext::from_window(*window.deref(), cx); - let workspace_id = workspace.entity_id(); let quick_search = cx.new_window_entity({ let weak_workspace = workspace.downgrade(); - move |window, cx| { - QuickSearchModal::new(weak_workspace, workspace_id, project, None, window, cx) - } + move |window, cx| QuickSearchModal::new(weak_workspace, project, None, window, cx) }); quick_search.update_in(&mut cx, |modal, window, cx| { @@ -1428,12 +1371,9 @@ mod tests { let workspace = window.root(cx).unwrap(); let mut cx = VisualTestContext::from_window(*window.deref(), cx); - let workspace_id = workspace.entity_id(); let quick_search = cx.new_window_entity({ let weak_workspace = workspace.downgrade(); - move |window, cx| { - QuickSearchModal::new(weak_workspace, workspace_id, project, None, window, cx) - } + move |window, cx| QuickSearchModal::new(weak_workspace, project, None, window, cx) }); quick_search.update_in(&mut cx, |modal, window, cx| { @@ -1473,12 +1413,9 @@ mod tests { let workspace = window.root(cx).unwrap(); let mut cx = VisualTestContext::from_window(*window.deref(), cx); - let workspace_id = workspace.entity_id(); let quick_search = cx.new_window_entity({ let weak_workspace = workspace.downgrade(); - move |window, cx| { - QuickSearchModal::new(weak_workspace, workspace_id, project, None, window, cx) - } + move |window, cx| QuickSearchModal::new(weak_workspace, project, None, window, cx) }); quick_search.update(&mut cx, |modal, cx| { @@ -1514,67 +1451,6 @@ mod tests { }); } - #[gpui::test] - async fn test_quick_search_persists_query_between_openings(cx: &mut TestAppContext) { - init_test(cx); - - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - path!("/project"), - json!({ - "file.rs": "fn hello() {}\n", - }), - ) - .await; - - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let workspace = window.root(cx).unwrap(); - let mut cx = VisualTestContext::from_window(*window.deref(), cx); - let workspace_id = workspace.entity_id(); - - let quick_search = cx.new_window_entity({ - let weak_workspace = workspace.downgrade(); - let project = project.clone(); - move |window, cx| { - QuickSearchModal::new(weak_workspace, workspace_id, project, None, window, cx) - } - }); - - quick_search.update_in(&mut cx, |modal, window, cx| { - modal.picker.update(cx, |picker, cx| { - picker.set_query("hello", window, cx); - }); - }); - - quick_search.update(&mut cx, |modal, cx| { - assert_eq!(modal.picker.read(cx).delegate.current_query, "hello"); - }); - - quick_search.update_in(&mut cx, |modal, window, cx| { - modal.picker.update(cx, |picker, cx| { - picker.cancel(&menu::Cancel, window, cx); - }); - }); - - cx.background_executor.run_until_parked(); - - let quick_search2 = cx.new_window_entity({ - let weak_workspace = workspace.downgrade(); - move |window, cx| { - QuickSearchModal::new(weak_workspace, workspace_id, project, None, window, cx) - } - }); - - quick_search2.update(&mut cx, |modal, cx| { - assert_eq!( - modal.picker.read(cx).delegate.current_query, - "hello", - "Query should be restored from previous session" - ); - }); - } - #[gpui::test] fn test_quick_search_collapse_expand_files(cx: &mut TestAppContext) { init_test(cx); From da7ea305c9fc4b79be52f77398e86e732d4faa62 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Thu, 11 Dec 2025 10:20:30 +0100 Subject: [PATCH 12/35] Fixes preview update for non-open files Ensures the quick search preview updates correctly when the file to be previewed is not already open in an editor. --- crates/search/src/quick_search.rs | 157 ++++++++++++++++++++---------- 1 file changed, 104 insertions(+), 53 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index a672e76efd09f1..11a307a40b2855 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -16,7 +16,9 @@ use ui::{ ListItem, ListItemSpacing, SpinnerLabel, Tooltip, prelude::*, rems_from_px, }; use util::{ResultExt, paths::PathMatcher}; -use workspace::{ModalView, Workspace, searchable::SearchableItemHandle}; +use workspace::{ + Item, ModalView, Save, Workspace, item::SaveOptions, searchable::SearchableItemHandle, +}; use crate::{ SearchOption, SearchOptions, ToggleCaseSensitive, ToggleIncludeIgnored, ToggleRegex, @@ -176,7 +178,6 @@ enum QuickSearchItem { LineMatch { project_path: ProjectPath, file_key: SharedString, - buffer: Entity, line: u32, line_label: SharedString, preview_text: SharedString, @@ -205,8 +206,10 @@ pub struct QuickSearchDelegate { pub struct QuickSearchModal { picker: Entity>, + project: Entity, preview_editor: Option>, preview_buffer: Option>, + preview_pending_path: Option, _subscriptions: Vec, } @@ -317,7 +320,9 @@ impl Render for QuickSearchModal { ) .child(self.picker.clone()), ) - .child( + .child({ + let project = self.project.clone(); + let save_preview_editor = preview_editor.clone(); v_flex() .id("quick-search-preview") .relative() @@ -328,6 +333,22 @@ impl Render for QuickSearchModal { .on_click(move |_, window, cx| { window.focus(&picker.focus_handle(cx)); }) + .on_action({ + move |_: &Save, window, cx| { + if let Some(editor) = save_preview_editor.clone() { + editor.update(cx, |editor, cx| { + editor + .save( + SaveOptions::default(), + project.clone(), + window, + cx, + ) + .detach_and_log_err(cx); + }); + } + } + }) .when_some(preview_editor, |this, editor| this.child(editor)) .when(self.preview_editor.is_none(), |this| { this.child( @@ -341,8 +362,8 @@ impl Render for QuickSearchModal { .color(Color::Muted), ), ) - }), - ), + }) + }), ), ) } @@ -460,7 +481,7 @@ impl QuickSearchModal { let delegate = QuickSearchDelegate { workspace, - project, + project: project.clone(), search_options: SearchOptions::NONE, items: Vec::new(), visible_indices: Vec::new(), @@ -493,8 +514,10 @@ impl QuickSearchModal { Self { picker, + project, preview_editor: None, preview_buffer: None, + preview_pending_path: None, _subscriptions: subscriptions, } } @@ -511,23 +534,28 @@ impl QuickSearchModal { fn update_preview( &mut self, - buffer: Option<(Entity, u32, Vec>)>, + data: Option<(ProjectPath, u32, Vec>)>, window: &mut Window, cx: &mut Context, ) { - let Some((buffer, line, match_ranges)) = buffer else { + let Some((project_path, line, match_ranges)) = data else { self.preview_editor = None; self.preview_buffer = None; + self.preview_pending_path = None; cx.notify(); return; }; - let same_buffer = self + let same_path = self .preview_buffer .as_ref() - .map_or(false, |b| b.entity_id() == buffer.entity_id()); + .and_then(|b| b.read(cx).file()) + .map_or(false, |file| { + file.worktree_id(cx) == project_path.worktree_id + && file.path() == &project_path.path + }); - if same_buffer { + if same_path { if let Some(editor) = &self.preview_editor { editor.update(cx, |editor, cx| { let point = text::Point::new(line, 0); @@ -549,36 +577,66 @@ impl QuickSearchModal { } }); } - } else { - let project = self.picker.read(cx).delegate.project.clone(); - let editor = cx.new(|cx| { - let mut editor = Editor::for_buffer(buffer.clone(), Some(project), window, cx); - editor.set_show_gutter(true, cx); - editor - }); + cx.notify(); + return; + } + + if self.preview_pending_path.as_ref() == Some(&project_path) { + return; + } + + self.preview_pending_path = Some(project_path.clone()); + + let project = self.project.clone(); + let open_buffer_task = project.update(cx, |project, cx| { + project.open_buffer(project_path.clone(), cx) + }); - editor.update(cx, |editor, cx| { - let point = text::Point::new(line, 0); - editor.go_to_singleton_buffer_point(point, window, cx); - - let multi_buffer = editor.buffer().read(cx); - if let Some(excerpt_id) = multi_buffer.excerpt_ids().first().copied() { - let multi_buffer_ranges: Vec<_> = match_ranges - .iter() - .map(|range| MultiBufferAnchor::range_in_buffer(excerpt_id, range.clone())) - .collect(); - editor.highlight_background::( - &multi_buffer_ranges, - |_, theme| theme.colors().search_match_background, - cx, - ); + cx.spawn_in(window, async move |this, cx| { + let Ok(buffer) = open_buffer_task.await else { + return; + }; + + this.update_in(cx, |this, window, cx| { + if this.preview_pending_path.as_ref() != Some(&project_path) { + return; } - }); + this.preview_pending_path = None; - self.preview_editor = Some(editor); - self.preview_buffer = Some(buffer); - } - cx.notify(); + let project = this.project.clone(); + let editor = cx.new(|cx| { + let mut editor = Editor::for_buffer(buffer.clone(), Some(project), window, cx); + editor.set_show_gutter(true, cx); + editor + }); + + editor.update(cx, |editor, cx| { + let point = text::Point::new(line, 0); + editor.go_to_singleton_buffer_point(point, window, cx); + + let multi_buffer = editor.buffer().read(cx); + if let Some(excerpt_id) = multi_buffer.excerpt_ids().first().copied() { + let multi_buffer_ranges: Vec<_> = match_ranges + .iter() + .map(|range| { + MultiBufferAnchor::range_in_buffer(excerpt_id, range.clone()) + }) + .collect(); + editor.highlight_background::( + &multi_buffer_ranges, + |_, theme| theme.colors().search_match_background, + cx, + ); + } + }); + + this.preview_editor = Some(editor); + this.preview_buffer = Some(buffer); + cx.notify(); + }) + .ok(); + }) + .detach(); } } @@ -690,11 +748,11 @@ impl PickerDelegate for QuickSearchDelegate { let actual_index = self.actual_index(self.selected_index); let preview_data = actual_index.and_then(|idx| match self.items.get(idx) { Some(QuickSearchItem::LineMatch { - buffer, + project_path, line, match_ranges, .. - }) => Some((buffer.clone(), *line, match_ranges.clone())), + }) => Some((project_path.clone(), *line, match_ranges.clone())), _ => None, }); @@ -854,7 +912,6 @@ impl PickerDelegate for QuickSearchDelegate { items.push(QuickSearchItem::LineMatch { project_path: match_data.project_path, file_key: match_data.file_key, - buffer: buffer.clone(), line: match_data.line, line_label: match_data.line_label, preview_text: match_data.preview_text, @@ -881,13 +938,13 @@ impl PickerDelegate for QuickSearchDelegate { let first_line_match = items.iter().find_map(|item| { if let QuickSearchItem::LineMatch { - buffer, + project_path, line, match_ranges, .. } = item { - Some((buffer.clone(), *line, match_ranges.clone())) + Some((project_path.clone(), *line, match_ranges.clone())) } else { None } @@ -1074,12 +1131,12 @@ impl PickerDelegate for QuickSearchDelegate { delegate.actual_index(visible_ix).and_then(|idx| { match delegate.items.get(idx) { Some(QuickSearchItem::LineMatch { - buffer, + project_path, line, match_ranges, .. }) => Some(( - buffer.clone(), + project_path.clone(), *line, match_ranges.clone(), )), @@ -1335,15 +1392,13 @@ mod tests { }; assert!(matches!(header, QuickSearchItem::FileHeader { .. })); - cx.update(|cx| { - let buffer = cx.new(|cx| language::Buffer::local("fn test() {}", cx)); + cx.update(|_cx| { let line_match = QuickSearchItem::LineMatch { project_path: ProjectPath { worktree_id: project::WorktreeId::from_usize(0), path: util::rel_path::rel_path("src/test.rs").into(), }, file_key: "src/test.rs".into(), - buffer, line: 0, line_label: "1".into(), preview_text: "fn test()".into(), @@ -1455,9 +1510,7 @@ mod tests { fn test_quick_search_collapse_expand_files(cx: &mut TestAppContext) { init_test(cx); - cx.update(|cx| { - let buffer = cx.new(|cx| language::Buffer::local("fn test() {}\nfn other() {}", cx)); - + cx.update(|_cx| { let items = [ QuickSearchItem::FileHeader { file_name: "test.rs".into(), @@ -1470,7 +1523,6 @@ mod tests { path: util::rel_path::rel_path("src/test.rs").into(), }, file_key: "src/test.rs".into(), - buffer: buffer.clone(), line: 0, line_label: "1".into(), preview_text: "fn test()".into(), @@ -1482,7 +1534,6 @@ mod tests { path: util::rel_path::rel_path("src/test.rs").into(), }, file_key: "src/test.rs".into(), - buffer, line: 1, line_label: "2".into(), preview_text: "fn other()".into(), From 29fd19ed6a966c084759a2e28518ac41ffc07678 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Thu, 11 Dec 2025 10:41:37 +0100 Subject: [PATCH 13/35] Opens previewed file in workspace on edit Opens the currently previewed file in the workspace when it is edited. --- crates/search/src/quick_search.rs | 82 ++++++++++++++++++++++++++++++- 1 file changed, 80 insertions(+), 2 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 11a307a40b2855..836a2d68a368ba 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -1,5 +1,5 @@ use collections::{HashMap, HashSet}; -use editor::{Anchor as MultiBufferAnchor, Editor}; +use editor::{Anchor as MultiBufferAnchor, Editor, EditorEvent}; use file_icons::FileIcons; use futures::StreamExt; use gpui::{ @@ -206,11 +206,14 @@ pub struct QuickSearchDelegate { pub struct QuickSearchModal { picker: Entity>, + workspace: WeakEntity, project: Entity, preview_editor: Option>, preview_buffer: Option>, preview_pending_path: Option, + preview_opened_in_workspace: Option, _subscriptions: Vec, + _open_in_workspace_task: Option>, } impl ModalView for QuickSearchModal {} @@ -480,7 +483,7 @@ impl QuickSearchModal { let weak_self = cx.entity().downgrade(); let delegate = QuickSearchDelegate { - workspace, + workspace: workspace.clone(), project: project.clone(), search_options: SearchOptions::NONE, items: Vec::new(), @@ -514,11 +517,14 @@ impl QuickSearchModal { Self { picker, + workspace, project, preview_editor: None, preview_buffer: None, preview_pending_path: None, + preview_opened_in_workspace: None, _subscriptions: subscriptions, + _open_in_workspace_task: None, } } @@ -532,6 +538,71 @@ impl QuickSearchModal { cx.emit(DismissEvent); } + fn on_preview_editor_event( + &mut self, + _editor: &Entity, + event: &EditorEvent, + window: &mut Window, + cx: &mut Context, + ) { + if !matches!(event, EditorEvent::Edited { .. }) { + return; + } + + if self.preview_opened_in_workspace.is_some() { + return; + } + + let Some(buffer) = &self.preview_buffer else { + return; + }; + + let Some(file) = buffer.read(cx).file() else { + return; + }; + + let project_path = ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path().clone(), + }; + + let Some(preview_editor) = self.preview_editor.clone() else { + return; + }; + + self._open_in_workspace_task = Some(cx.spawn_in(window, async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(200)) + .await; + + this.update_in(cx, |this, window, cx| { + if this.preview_opened_in_workspace.is_some() { + return; + } + + this.preview_opened_in_workspace = Some(project_path.clone()); + + let Some(workspace) = this.workspace.upgrade() else { + return; + }; + + let open_task = workspace.update(cx, |workspace, cx| { + workspace.open_path_preview(project_path, None, false, false, false, window, cx) + }); + + cx.spawn_in(window, async move |_, cx| { + let _ = open_task.await; + cx.update(|window, cx| { + window.focus(&preview_editor.focus_handle(cx)); + }) + .ok(); + }) + .detach(); + }) + .ok(); + })); + } + fn update_preview( &mut self, data: Option<(ProjectPath, u32, Vec>)>, @@ -542,6 +613,7 @@ impl QuickSearchModal { self.preview_editor = None; self.preview_buffer = None; self.preview_pending_path = None; + self.preview_opened_in_workspace = None; cx.notify(); return; }; @@ -630,8 +702,14 @@ impl QuickSearchModal { } }); + this._subscriptions.push(cx.subscribe_in( + &editor, + window, + Self::on_preview_editor_event, + )); this.preview_editor = Some(editor); this.preview_buffer = Some(buffer); + this.preview_opened_in_workspace = None; cx.notify(); }) .ok(); From cda726cc2c3752588a291b5180083bb2040f73a7 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Thu, 11 Dec 2025 10:48:59 +0100 Subject: [PATCH 14/35] Fix race condition on fast navigation Ensures that the pending preview path is cleared when the path is the same as the current one. This prevents unnecessary reloads when the user is already viewing the correct content, resolving a race condition on fast navigation. --- crates/search/src/quick_search.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 836a2d68a368ba..ace77218d46a9d 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -628,6 +628,8 @@ impl QuickSearchModal { }); if same_path { + self.preview_pending_path = None; + if let Some(editor) = &self.preview_editor { editor.update(cx, |editor, cx| { let point = text::Point::new(line, 0); From 273f43d2e39d0777f9586ceb7927dcdb10e19372 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Thu, 11 Dec 2025 11:13:03 +0100 Subject: [PATCH 15/35] Removes unnecessary log dependency --- Cargo.lock | 1 - crates/search/Cargo.toml | 1 - 2 files changed, 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 13a800baccbfcf..e469d26ecffe90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -14566,7 +14566,6 @@ dependencies = [ "gpui", "itertools 0.14.0", "language", - "log", "lsp", "menu", "picker", diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index c4d370aef1f7b8..9511e5c42fcf9c 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -30,7 +30,6 @@ file_icons.workspace = true futures.workspace = true gpui.workspace = true language.workspace = true -log.workspace = true menu.workspace = true picker.workspace = true project.workspace = true From f28fb5e300bc72ade720f905305ab232af70790f Mon Sep 17 00:00:00 2001 From: David Bonan Date: Thu, 11 Dec 2025 12:02:03 +0100 Subject: [PATCH 16/35] Improves quick search UX and performance Changes truncate preview to operate on bytes instead of chars, avoiding unicode issues. Improves performance by caching file matches, and by using binary search to identify visible indices. Adds a few constants to control timings. --- crates/search/src/quick_search.rs | 725 ++++++++++++++++++------------ 1 file changed, 446 insertions(+), 279 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index ace77218d46a9d..71c557a28aded5 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -25,11 +25,22 @@ use crate::{ ToggleWholeWord, }; +type AnchorRange = std::ops::Range; + +struct LineData { + line_label: SharedString, + preview_text: SharedString, + match_ranges: Vec, +} + const MODAL_HEIGHT: Pixels = px(650.); const MODAL_WIDTH: Pixels = px(1100.); const LEFT_PANEL_WIDTH: Pixels = px(300.); const MAX_LINE_MATCHES: usize = 200; -const MAX_PREVIEW_CHARS: usize = 200; +const MAX_PREVIEW_BYTES: usize = 200; +const SEARCH_DEBOUNCE_MS: u64 = 100; +const PREVIEW_DEBOUNCE_MS: u64 = 50; +const EDIT_OPEN_DELAY_MS: u64 = 200; actions!(search, [QuickSearch]); @@ -39,7 +50,7 @@ struct LineMatchData { line: u32, line_label: SharedString, preview_text: SharedString, - match_ranges: Vec>, + match_ranges: Arc>, } enum QuickSearchHighlights {} @@ -51,13 +62,13 @@ struct FileMatchResult { matches: Vec, } -fn truncate_preview(text: &str, max_chars: usize) -> SharedString { +fn truncate_preview(text: &str, max_bytes: usize) -> SharedString { let trimmed = text.trim(); - if trimmed.len() <= max_chars { + if trimmed.len() <= max_bytes { return trimmed.to_string().into(); } - let mut end = max_chars; + let mut end = max_bytes; while end > 0 && !trimmed.is_char_boundary(end) { end -= 1; } @@ -69,7 +80,7 @@ fn truncate_preview(text: &str, max_chars: usize) -> SharedString { fn extract_file_matches( buf: &Buffer, - ranges: &[std::ops::Range], + ranges: &[AnchorRange], cx: &App, ) -> Option { let file = buf.file()?; @@ -82,32 +93,32 @@ fn extract_file_matches( let file_key = format_file_key(&parent_path, &file_name); let snapshot = buf.snapshot(); - let mut lines_data: HashMap< - u32, - ( - SharedString, - SharedString, - Vec>, - ), - > = HashMap::default(); + let mut lines_data: HashMap = HashMap::default(); let mut line_order = Vec::new(); for range in ranges { let start_point = range.start.to_point(&snapshot); let line = start_point.row; - if let Some((_, _, line_ranges)) = lines_data.get_mut(&line) { - line_ranges.push(range.clone()); + if let Some(data) = lines_data.get_mut(&line) { + data.match_ranges.push(range.clone()); } else { let line_start = snapshot.point_to_offset(text::Point::new(line, 0)); let line_end_col = snapshot.line_len(line); let line_end = snapshot.point_to_offset(text::Point::new(line, line_end_col)); let line_text: String = snapshot.text_for_range(line_start..line_end).collect(); - let preview_text = truncate_preview(&line_text, MAX_PREVIEW_CHARS); - let line_label: SharedString = format!("{}", line + 1).into(); + let preview_text = truncate_preview(&line_text, MAX_PREVIEW_BYTES); + let line_label: SharedString = (line + 1).to_string().into(); - lines_data.insert(line, (line_label, preview_text, vec![range.clone()])); + lines_data.insert( + line, + LineData { + line_label, + preview_text, + match_ranges: vec![range.clone()], + }, + ); line_order.push(line); } @@ -123,14 +134,14 @@ fn extract_file_matches( let matches = line_order .into_iter() .filter_map(|line| { - let (line_label, preview_text, match_ranges) = lines_data.remove(&line)?; + let data = lines_data.remove(&line)?; Some(LineMatchData { project_path: project_path.clone(), file_key: file_key.clone(), line, - line_label, - preview_text, - match_ranges, + line_label: data.line_label, + preview_text: data.preview_text, + match_ranges: Arc::new(data.match_ranges), }) }) .collect(); @@ -181,7 +192,7 @@ enum QuickSearchItem { line: u32, line_label: SharedString, preview_text: SharedString, - match_ranges: Vec>, + match_ranges: Arc>, }, } @@ -191,6 +202,7 @@ pub struct QuickSearchDelegate { search_options: SearchOptions, items: Vec, visible_indices: Vec, + visible_line_match_indices: Vec, collapsed_files: HashSet, selected_index: usize, pending_search_id: usize, @@ -212,8 +224,11 @@ pub struct QuickSearchModal { preview_buffer: Option>, preview_pending_path: Option, preview_opened_in_workspace: Option, - _subscriptions: Vec, + pending_preview_data: Option<(ProjectPath, u32, Arc>)>, + _picker_subscription: Subscription, + _preview_editor_subscription: Option, _open_in_workspace_task: Option>, + _preview_debounce_task: Option>, } impl ModalView for QuickSearchModal {} @@ -488,6 +503,7 @@ impl QuickSearchModal { search_options: SearchOptions::NONE, items: Vec::new(), visible_indices: Vec::new(), + visible_line_match_indices: Vec::new(), collapsed_files: HashSet::default(), selected_index: 0, pending_search_id: 0, @@ -513,7 +529,7 @@ impl QuickSearchModal { picker }); - let subscriptions = vec![cx.subscribe_in(&picker, window, Self::on_picker_event)]; + let picker_subscription = cx.subscribe_in(&picker, window, Self::on_picker_event); Self { picker, @@ -523,8 +539,11 @@ impl QuickSearchModal { preview_buffer: None, preview_pending_path: None, preview_opened_in_workspace: None, - _subscriptions: subscriptions, + pending_preview_data: None, + _picker_subscription: picker_subscription, + _preview_editor_subscription: None, _open_in_workspace_task: None, + _preview_debounce_task: None, } } @@ -572,7 +591,7 @@ impl QuickSearchModal { self._open_in_workspace_task = Some(cx.spawn_in(window, async move |this, cx| { cx.background_executor() - .timer(Duration::from_millis(200)) + .timer(Duration::from_millis(EDIT_OPEN_DELAY_MS)) .await; this.update_in(cx, |this, window, cx| { @@ -595,17 +614,94 @@ impl QuickSearchModal { cx.update(|window, cx| { window.focus(&preview_editor.focus_handle(cx)); }) - .ok(); + .log_err(); }) .detach(); }) - .ok(); + .log_err(); + })); + } + + fn navigate_and_highlight_matches( + editor: &mut Editor, + line: u32, + match_ranges: &[AnchorRange], + window: &mut Window, + cx: &mut Context, + ) { + let point = text::Point::new(line, 0); + editor.go_to_singleton_buffer_point(point, window, cx); + + let multi_buffer = editor.buffer().read(cx); + if let Some(excerpt_id) = multi_buffer.excerpt_ids().first().copied() { + let multi_buffer_ranges: Vec<_> = match_ranges + .iter() + .map(|range| MultiBufferAnchor::range_in_buffer(excerpt_id, range.clone())) + .collect(); + editor.highlight_background::( + &multi_buffer_ranges, + |_, theme| theme.colors().search_match_background, + cx, + ); + } + } + + fn schedule_preview_update( + &mut self, + data: Option<(ProjectPath, u32, Arc>)>, + window: &mut Window, + cx: &mut Context, + ) { + self.pending_preview_data = data.clone(); + + if data.is_none() { + self._preview_debounce_task = None; + self.update_preview(None, window, cx); + return; + } + + if let Some((ref project_path, line, _)) = data { + let same_path = self + .preview_buffer + .as_ref() + .and_then(|b| b.read(cx).file()) + .map_or(false, |file| { + file.worktree_id(cx) == project_path.worktree_id + && file.path() == &project_path.path + }); + + if same_path { + self._preview_debounce_task = None; + if let Some(editor) = &self.preview_editor { + editor.update(cx, |editor, cx| { + let match_ranges = data + .as_ref() + .map(|(_, _, ranges)| ranges.as_slice()) + .unwrap_or(&[]); + Self::navigate_and_highlight_matches(editor, line, match_ranges, window, cx); + }); + } + cx.notify(); + return; + } + } + + self._preview_debounce_task = Some(cx.spawn_in(window, async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(PREVIEW_DEBOUNCE_MS)) + .await; + + this.update_in(cx, |this, window, cx| { + let data = this.pending_preview_data.take(); + this.update_preview(data, window, cx); + }) + .log_err(); })); } fn update_preview( &mut self, - data: Option<(ProjectPath, u32, Vec>)>, + data: Option<(ProjectPath, u32, Arc>)>, window: &mut Window, cx: &mut Context, ) { @@ -614,6 +710,7 @@ impl QuickSearchModal { self.preview_buffer = None; self.preview_pending_path = None; self.preview_opened_in_workspace = None; + self._preview_editor_subscription = None; cx.notify(); return; }; @@ -632,23 +729,7 @@ impl QuickSearchModal { if let Some(editor) = &self.preview_editor { editor.update(cx, |editor, cx| { - let point = text::Point::new(line, 0); - editor.go_to_singleton_buffer_point(point, window, cx); - - let multi_buffer = editor.buffer().read(cx); - if let Some(excerpt_id) = multi_buffer.excerpt_ids().first().copied() { - let multi_buffer_ranges: Vec<_> = match_ranges - .iter() - .map(|range| { - MultiBufferAnchor::range_in_buffer(excerpt_id, range.clone()) - }) - .collect(); - editor.highlight_background::( - &multi_buffer_ranges, - |_, theme| theme.colors().search_match_background, - cx, - ); - } + Self::navigate_and_highlight_matches(editor, line, &match_ranges, window, cx); }); } cx.notify(); @@ -685,44 +766,79 @@ impl QuickSearchModal { }); editor.update(cx, |editor, cx| { - let point = text::Point::new(line, 0); - editor.go_to_singleton_buffer_point(point, window, cx); - - let multi_buffer = editor.buffer().read(cx); - if let Some(excerpt_id) = multi_buffer.excerpt_ids().first().copied() { - let multi_buffer_ranges: Vec<_> = match_ranges - .iter() - .map(|range| { - MultiBufferAnchor::range_in_buffer(excerpt_id, range.clone()) - }) - .collect(); - editor.highlight_background::( - &multi_buffer_ranges, - |_, theme| theme.colors().search_match_background, - cx, - ); - } + Self::navigate_and_highlight_matches(editor, line, &match_ranges, window, cx); }); - this._subscriptions.push(cx.subscribe_in( - &editor, - window, - Self::on_preview_editor_event, - )); + this._preview_editor_subscription = + Some(cx.subscribe_in(&editor, window, Self::on_preview_editor_event)); this.preview_editor = Some(editor); this.preview_buffer = Some(buffer); this.preview_opened_in_workspace = None; cx.notify(); }) - .ok(); + .log_err(); }) .detach(); } } +struct SearchResults { + items: Vec, + line_match_count: usize, + file_count: usize, + is_limited: bool, +} + +impl SearchResults { + fn first_line_match(&self) -> Option<(ProjectPath, u32, Arc>)> { + self.items.iter().find_map(|item| { + if let QuickSearchItem::LineMatch { + project_path, + line, + match_ranges, + .. + } = item + { + Some((project_path.clone(), *line, match_ranges.clone())) + } else { + None + } + }) + } +} + +fn build_search_query(query: &str, search_options: SearchOptions) -> Result { + if search_options.contains(SearchOptions::REGEX) { + SearchQuery::regex( + query, + search_options.contains(SearchOptions::WHOLE_WORD), + search_options.contains(SearchOptions::CASE_SENSITIVE), + search_options.contains(SearchOptions::INCLUDE_IGNORED), + false, + PathMatcher::default(), + PathMatcher::default(), + false, + None, + ) + } else { + SearchQuery::text( + query, + search_options.contains(SearchOptions::WHOLE_WORD), + search_options.contains(SearchOptions::CASE_SENSITIVE), + search_options.contains(SearchOptions::INCLUDE_IGNORED), + PathMatcher::default(), + PathMatcher::default(), + false, + None, + ) + } + .map_err(|e| e.to_string()) +} + impl QuickSearchDelegate { fn update_visible_indices(&mut self) { self.visible_indices.clear(); + self.visible_line_match_indices.clear(); for (idx, item) in self.items.iter().enumerate() { match item { @@ -731,7 +847,9 @@ impl QuickSearchDelegate { } QuickSearchItem::LineMatch { file_key, .. } => { if !self.collapsed_files.contains(file_key) { + let visible_idx = self.visible_indices.len(); self.visible_indices.push(idx); + self.visible_line_match_indices.push(visible_idx); } } } @@ -739,12 +857,75 @@ impl QuickSearchDelegate { } fn toggle_file_collapsed(&mut self, file_key: &SharedString) { - if self.collapsed_files.contains(file_key) { + let is_expanding = self.collapsed_files.contains(file_key); + + if is_expanding { self.collapsed_files.remove(file_key); + self.expand_file_indices(file_key); } else { self.collapsed_files.insert(file_key.clone()); + self.collapse_file_indices(file_key); + } + } + + fn collapse_file_indices(&mut self, file_key: &SharedString) { + let mut indices_to_remove = Vec::new(); + + for (visible_idx, &actual_idx) in self.visible_indices.iter().enumerate() { + if matches!( + self.items.get(actual_idx), + Some(QuickSearchItem::LineMatch { file_key: fk, .. }) if fk == file_key + ) { + indices_to_remove.push(visible_idx); + } + } + + for &visible_idx in indices_to_remove.iter().rev() { + self.visible_indices.remove(visible_idx); + } + + self.rebuild_visible_line_match_indices(); + } + + fn expand_file_indices(&mut self, file_key: &SharedString) { + let header_visible_pos = self.visible_indices.iter().position(|&idx| { + matches!( + self.items.get(idx), + Some(QuickSearchItem::FileHeader { file_key: fk, .. }) if fk == file_key + ) + }); + + let Some(header_visible_pos) = header_visible_pos else { + return; + }; + + let header_actual_idx = self.visible_indices[header_visible_pos]; + + let line_indices: Vec = self + .items + .iter() + .enumerate() + .skip(header_actual_idx + 1) + .take_while(|(_, item)| { + matches!(item, QuickSearchItem::LineMatch { file_key: fk, .. } if fk == file_key) + }) + .map(|(idx, _)| idx) + .collect(); + + let insert_pos = header_visible_pos + 1; + self.visible_indices + .splice(insert_pos..insert_pos, line_indices); + + self.rebuild_visible_line_match_indices(); + } + + fn rebuild_visible_line_match_indices(&mut self) { + self.visible_line_match_indices.clear(); + for (visible_idx, &actual_idx) in self.visible_indices.iter().enumerate() { + if matches!(self.items.get(actual_idx), Some(QuickSearchItem::LineMatch { .. })) { + self.visible_line_match_indices.push(visible_idx); + } } - self.update_visible_indices(); } fn toggle_search_option(&mut self, option: SearchOptions) { @@ -769,14 +950,164 @@ impl QuickSearchDelegate { from_visible_index: usize, going_down: bool, ) -> Option { - if going_down { - (from_visible_index..self.visible_indices.len()) - .find(|&i| self.is_line_match_at_visible_index(i)) - } else { - (0..=from_visible_index) - .rev() - .find(|&i| self.is_line_match_at_visible_index(i)) + if self.visible_line_match_indices.is_empty() { + return None; } + + let search_result = self + .visible_line_match_indices + .binary_search(&from_visible_index); + + match search_result { + Ok(pos) => Some(self.visible_line_match_indices[pos]), + Err(insert_pos) => { + if going_down { + if insert_pos < self.visible_line_match_indices.len() { + Some(self.visible_line_match_indices[insert_pos]) + } else { + None + } + } else if insert_pos > 0 { + Some(self.visible_line_match_indices[insert_pos - 1]) + } else { + None + } + } + } + } + + fn render_file_header( + &self, + ix: usize, + file_name: &SharedString, + parent_path: &SharedString, + file_key: &SharedString, + cx: &App, + ) -> ListItem { + let is_collapsed = self.collapsed_files.contains(file_key.as_ref()); + + let chevron_icon = if is_collapsed { + IconName::ChevronRight + } else { + IconName::ChevronDown + }; + + let file_icon = FileIcons::get_icon(Path::new(file_name.as_ref()), cx) + .map(Icon::from_path) + .unwrap_or_else(|| Icon::new(IconName::File)); + + let quick_search = self.quick_search.clone(); + let file_key = file_key.clone(); + let file_name = file_name.clone(); + let parent_path = parent_path.clone(); + + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .child( + h_flex() + .id(("file-header", ix)) + .w_full() + .gap_1() + .cursor_pointer() + .on_click(move |_, window, cx| { + cx.stop_propagation(); + if let Some(qs) = quick_search.upgrade() { + qs.update(cx, |qs, cx| { + window.focus(&qs.picker.focus_handle(cx)); + qs.picker.update(cx, |picker, cx| { + picker.delegate.toggle_file_collapsed(&file_key); + cx.notify(); + }); + }); + } + }) + .child( + Icon::new(chevron_icon) + .color(Color::Muted) + .size(ui::IconSize::Small), + ) + .child(file_icon.color(Color::Muted).size(ui::IconSize::Small)) + .child(Label::new(file_name).size(ui::LabelSize::Small)) + .when(!parent_path.is_empty(), |this| { + this.child( + Label::new(parent_path) + .size(ui::LabelSize::Small) + .color(Color::Muted), + ) + }), + ) + } + + fn render_line_match( + &self, + ix: usize, + selected: bool, + line_label: &SharedString, + preview_text: &SharedString, + ) -> ListItem { + let quick_search = self.quick_search.clone(); + let visible_ix = ix; + let line_label = line_label.clone(); + let preview_text = preview_text.clone(); + + ListItem::new(ix) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .toggle_state(selected) + .on_click({ + move |event, window, cx| { + cx.stop_propagation(); + if event.click_count() >= 2 { + window.dispatch_action(menu::Confirm.boxed_clone(), cx); + } else if let Some(qs) = quick_search.upgrade() { + let preview_data = { + let modal = qs.read(cx); + let delegate = &modal.picker.read(cx).delegate; + delegate.actual_index(visible_ix).and_then(|idx| { + match delegate.items.get(idx) { + Some(QuickSearchItem::LineMatch { + project_path, + line, + match_ranges, + .. + }) => Some((project_path.clone(), *line, match_ranges.clone())), + _ => None, + } + }) + }; + + qs.update(cx, |modal, cx| { + window.focus(&modal.picker.focus_handle(cx)); + modal.picker.update(cx, |picker, cx| { + picker.delegate.selected_index = visible_ix; + cx.notify(); + }); + modal.schedule_preview_update(preview_data, window, cx); + }); + } + } + }) + .child( + h_flex() + .w_full() + .gap_2() + .pl(px(20.)) + .justify_between() + .child( + div().flex_1().min_w_0().overflow_hidden().child( + Label::new(preview_text) + .size(ui::LabelSize::Small) + .color(Color::Default) + .truncate(), + ), + ) + .child( + Label::new(line_label) + .size(ui::LabelSize::Small) + .color(Color::Muted), + ), + ) } } @@ -839,7 +1170,7 @@ impl PickerDelegate for QuickSearchDelegate { Some(Box::new(move |window, cx| { if let Some(quick_search) = quick_search.upgrade() { quick_search.update(cx, |qs, cx| { - qs.update_preview(preview_data.clone(), window, cx); + qs.schedule_preview_update(preview_data.clone(), window, cx); }); } })) @@ -889,7 +1220,7 @@ impl PickerDelegate for QuickSearchDelegate { let quick_search = self.quick_search.clone(); cx.spawn_in(window, async move |picker, cx| { - smol::Timer::after(Duration::from_millis(100)).await; + smol::Timer::after(Duration::from_millis(SEARCH_DEBOUNCE_MS)).await; let is_stale = picker .update(cx, |picker, _| { @@ -900,43 +1231,17 @@ impl PickerDelegate for QuickSearchDelegate { return; } - let search_query_result = if search_options.contains(SearchOptions::REGEX) { - SearchQuery::regex( - &query, - search_options.contains(SearchOptions::WHOLE_WORD), - search_options.contains(SearchOptions::CASE_SENSITIVE), - search_options.contains(SearchOptions::INCLUDE_IGNORED), - false, - PathMatcher::default(), - PathMatcher::default(), - false, - None, - ) - } else { - SearchQuery::text( - &query, - search_options.contains(SearchOptions::WHOLE_WORD), - search_options.contains(SearchOptions::CASE_SENSITIVE), - search_options.contains(SearchOptions::INCLUDE_IGNORED), - PathMatcher::default(), - PathMatcher::default(), - false, - None, - ) - }; - - let search_query = match search_query_result { + let search_query = match build_search_query(&query, search_options) { Ok(q) => { picker .update(cx, |picker, cx| { picker.delegate.regex_error = None; cx.notify(); }) - .ok(); + .log_err(); q } - Err(err) => { - let error_message = err.to_string(); + Err(error_message) => { picker .update(cx, |picker, cx| { picker.delegate.regex_error = Some(error_message); @@ -947,25 +1252,27 @@ impl PickerDelegate for QuickSearchDelegate { picker.delegate.is_searching = false; cx.notify(); }) - .ok(); + .log_err(); return; } }; - let Some(search_results) = project + let Some(project_search_results) = project .update(cx, |project, cx| project.search(search_query, cx)) .log_err() else { return; }; - let mut items = Vec::new(); - let mut line_match_count = 0; - let mut file_count = 0; - let mut is_limited = false; + let mut results = SearchResults { + items: Vec::with_capacity(MAX_LINE_MATCHES + MAX_LINE_MATCHES / 10), + line_match_count: 0, + file_count: 0, + is_limited: false, + }; - let mut search_results = pin!(search_results); - while let Some(result) = search_results.next().await { + let mut project_search_results = pin!(project_search_results); + while let Some(result) = project_search_results.next().await { match result { project::search::SearchResult::Buffer { buffer, ranges } => { if ranges.is_empty() { @@ -981,15 +1288,15 @@ impl PickerDelegate for QuickSearchDelegate { continue; }; - items.push(QuickSearchItem::FileHeader { + results.items.push(QuickSearchItem::FileHeader { file_name: file_result.file_name, parent_path: file_result.parent_path, file_key: file_result.file_key, }); - file_count += 1; + results.file_count += 1; for match_data in file_result.matches { - items.push(QuickSearchItem::LineMatch { + results.items.push(QuickSearchItem::LineMatch { project_path: match_data.project_path, file_key: match_data.file_key, line: match_data.line, @@ -998,42 +1305,30 @@ impl PickerDelegate for QuickSearchDelegate { match_ranges: match_data.match_ranges, }); - line_match_count += 1; - if line_match_count >= MAX_LINE_MATCHES { - is_limited = true; + results.line_match_count += 1; + if results.line_match_count >= MAX_LINE_MATCHES { + results.is_limited = true; break; } } - if line_match_count >= MAX_LINE_MATCHES { + if results.line_match_count >= MAX_LINE_MATCHES { break; } } project::search::SearchResult::LimitReached => { - is_limited = true; + results.is_limited = true; break; } } } - let first_line_match = items.iter().find_map(|item| { - if let QuickSearchItem::LineMatch { - project_path, - line, - match_ranges, - .. - } = item - { - Some((project_path.clone(), *line, match_ranges.clone())) - } else { - None - } - }); + let first_line_match = results.first_line_match(); picker .update_in(cx, |picker, window, cx| { if picker.delegate.pending_search_id == search_id { - picker.delegate.items = items; + picker.delegate.items = results.items; picker.delegate.update_visible_indices(); let first_selectable = picker @@ -1049,9 +1344,9 @@ impl PickerDelegate for QuickSearchDelegate { .unwrap_or(0); picker.delegate.selected_index = first_selectable; - picker.delegate.match_count = line_match_count; - picker.delegate.file_count = file_count; - picker.delegate.is_limited = is_limited; + picker.delegate.match_count = results.line_match_count; + picker.delegate.file_count = results.file_count; + picker.delegate.is_limited = results.is_limited; picker.delegate.is_searching = false; cx.notify(); @@ -1062,7 +1357,7 @@ impl PickerDelegate for QuickSearchDelegate { } } }) - .ok(); + .log_err(); }) } @@ -1097,7 +1392,7 @@ impl PickerDelegate for QuickSearchDelegate { let point = text::Point::new(line, 0); editor.go_to_singleton_buffer_point(point, window, cx); }) - .ok(); + .log_err(); } } anyhow::Ok(()) @@ -1127,140 +1422,12 @@ impl PickerDelegate for QuickSearchDelegate { file_name, parent_path, file_key, - } => { - let is_collapsed = self.collapsed_files.contains(file_key.as_ref()); - - let chevron_icon = if is_collapsed { - IconName::ChevronRight - } else { - IconName::ChevronDown - }; - - let file_icon = FileIcons::get_icon(Path::new(file_name.as_ref()), cx) - .map(Icon::from_path) - .unwrap_or_else(|| Icon::new(IconName::File)); - - let quick_search = self.quick_search.clone(); - - Some( - ListItem::new(ix) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .child( - h_flex() - .id(("file-header", ix)) - .w_full() - .gap_1() - .cursor_pointer() - .on_click({ - let file_key = file_key.clone(); - move |_, window, cx| { - cx.stop_propagation(); - if let Some(qs) = quick_search.upgrade() { - qs.update(cx, |qs, cx| { - window.focus(&qs.picker.focus_handle(cx)); - qs.picker.update(cx, |picker, cx| { - picker - .delegate - .toggle_file_collapsed(&file_key); - cx.notify(); - }); - }); - } - } - }) - .child( - Icon::new(chevron_icon) - .color(Color::Muted) - .size(ui::IconSize::Small), - ) - .child(file_icon.color(Color::Muted).size(ui::IconSize::Small)) - .child(Label::new(file_name.clone()).size(ui::LabelSize::Small)) - .when(!parent_path.is_empty(), |this| { - this.child( - Label::new(parent_path.clone()) - .size(ui::LabelSize::Small) - .color(Color::Muted), - ) - }), - ), - ) - } + } => Some(self.render_file_header(ix, file_name, parent_path, file_key, cx)), QuickSearchItem::LineMatch { line_label, preview_text, .. - } => { - let quick_search = self.quick_search.clone(); - let visible_ix = ix; - - Some( - ListItem::new(ix) - .inset(true) - .spacing(ListItemSpacing::Sparse) - .toggle_state(selected) - .on_click({ - move |event, window, cx| { - cx.stop_propagation(); - if event.click_count() >= 2 { - window.dispatch_action(menu::Confirm.boxed_clone(), cx); - } else if let Some(qs) = quick_search.upgrade() { - let preview_data = { - let modal = qs.read(cx); - let delegate = &modal.picker.read(cx).delegate; - delegate.actual_index(visible_ix).and_then(|idx| { - match delegate.items.get(idx) { - Some(QuickSearchItem::LineMatch { - project_path, - line, - match_ranges, - .. - }) => Some(( - project_path.clone(), - *line, - match_ranges.clone(), - )), - _ => None, - } - }) - }; - - qs.update(cx, |modal, cx| { - window.focus(&modal.picker.focus_handle(cx)); - modal.picker.update(cx, |picker, cx| { - picker.delegate.selected_index = visible_ix; - cx.notify(); - }); - }); - - qs.update(cx, |modal, cx| { - modal.update_preview(preview_data, window, cx); - }); - } - } - }) - .child( - h_flex() - .w_full() - .gap_2() - .pl(px(20.)) - .justify_between() - .child( - div().flex_1().min_w_0().overflow_hidden().child( - Label::new(preview_text.clone()) - .size(ui::LabelSize::Small) - .color(Color::Default) - .truncate(), - ), - ) - .child( - Label::new(line_label.clone()) - .size(ui::LabelSize::Small) - .color(Color::Muted), - ), - ), - ) - } + } => Some(self.render_line_match(ix, selected, line_label, preview_text)), } } @@ -1482,7 +1649,7 @@ mod tests { line: 0, line_label: "1".into(), preview_text: "fn test()".into(), - match_ranges: Vec::new(), + match_ranges: Arc::new(Vec::new()), }; assert!(matches!(line_match, QuickSearchItem::LineMatch { .. })); }); @@ -1606,7 +1773,7 @@ mod tests { line: 0, line_label: "1".into(), preview_text: "fn test()".into(), - match_ranges: Vec::new(), + match_ranges: Arc::new(Vec::new()), }, QuickSearchItem::LineMatch { project_path: ProjectPath { @@ -1617,7 +1784,7 @@ mod tests { line: 1, line_label: "2".into(), preview_text: "fn other()".into(), - match_ranges: Vec::new(), + match_ranges: Arc::new(Vec::new()), }, ]; From 6789dbc6b2ee88fffa7d7205f11b8ecd31703b30 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Thu, 11 Dec 2025 12:18:49 +0100 Subject: [PATCH 17/35] Adds collapse all functionality to quick search The user can now hold the `Alt` key while clicking a file header to collapse/expand all file headers. --- crates/search/src/quick_search.rs | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 71c557a28aded5..111f9f81988474 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -868,6 +868,21 @@ impl QuickSearchDelegate { } } + fn toggle_all_files_collapsed(&mut self, clicked_file_key: &SharedString) { + let is_clicked_collapsed = self.collapsed_files.contains(clicked_file_key); + + if is_clicked_collapsed { + self.collapsed_files.clear(); + } else { + for item in &self.items { + if let QuickSearchItem::FileHeader { file_key, .. } = item { + self.collapsed_files.insert(file_key.clone()); + } + } + } + self.update_visible_indices(); + } + fn collapse_file_indices(&mut self, file_key: &SharedString) { let mut indices_to_remove = Vec::new(); @@ -1010,13 +1025,17 @@ impl QuickSearchDelegate { .w_full() .gap_1() .cursor_pointer() - .on_click(move |_, window, cx| { + .on_click(move |event, window, cx| { cx.stop_propagation(); if let Some(qs) = quick_search.upgrade() { qs.update(cx, |qs, cx| { window.focus(&qs.picker.focus_handle(cx)); qs.picker.update(cx, |picker, cx| { - picker.delegate.toggle_file_collapsed(&file_key); + if event.modifiers().alt { + picker.delegate.toggle_all_files_collapsed(&file_key); + } else { + picker.delegate.toggle_file_collapsed(&file_key); + } cx.notify(); }); }); From a04fc2d152a1ae3b525222dbbb96f26360dffe1c Mon Sep 17 00:00:00 2001 From: David Bonan Date: Thu, 11 Dec 2025 12:47:48 +0100 Subject: [PATCH 18/35] Improves quick search modal responsiveness Increases the size of the quick search modal and the number of allowed matches. Ensures the modal adapts to different screen sizes by limiting its dimensions to 90% of the viewport width and 80% of the viewport height. --- crates/search/src/quick_search.rs | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 111f9f81988474..e291934404004b 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -33,10 +33,10 @@ struct LineData { match_ranges: Vec, } -const MODAL_HEIGHT: Pixels = px(650.); -const MODAL_WIDTH: Pixels = px(1100.); -const LEFT_PANEL_WIDTH: Pixels = px(300.); -const MAX_LINE_MATCHES: usize = 200; +const MODAL_HEIGHT: Pixels = px(800.); +const MODAL_WIDTH: Pixels = px(1400.); +const LEFT_PANEL_WIDTH: Pixels = px(400.); +const MAX_LINE_MATCHES: usize = 800; const MAX_PREVIEW_BYTES: usize = 200; const SEARCH_DEBOUNCE_MS: u64 = 100; const PREVIEW_DEBOUNCE_MS: u64 = 50; @@ -242,7 +242,7 @@ impl Focusable for QuickSearchModal { } impl Render for QuickSearchModal { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let preview_editor = self.preview_editor.clone(); let picker = self.picker.clone(); @@ -251,11 +251,17 @@ impl Render for QuickSearchModal { let search_options = delegate.search_options; let focus_handle = self.picker.focus_handle(cx); + let viewport_size = window.viewport_size(); + let max_width = viewport_size.width * 0.9; + let max_height = viewport_size.height * 0.8; + let modal_width = MODAL_WIDTH.min(max_width); + let modal_height = MODAL_HEIGHT.min(max_height); + div() .id("quick-search-modal") .relative() - .h(MODAL_HEIGHT) - .w(MODAL_WIDTH) + .h(modal_height) + .w(modal_width) .child( v_flex() .elevation_3(cx) From 3cf6d672bdb57dd64b1de7776aa346a1c5540333 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Thu, 11 Dec 2025 13:14:31 +0100 Subject: [PATCH 19/35] Improve tests on Quick Search --- crates/search/src/quick_search.rs | 606 +++++++++++++++++------------- 1 file changed, 354 insertions(+), 252 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index e291934404004b..acb6ca16f33db4 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -78,11 +78,7 @@ fn truncate_preview(text: &str, max_bytes: usize) -> SharedString { result.into() } -fn extract_file_matches( - buf: &Buffer, - ranges: &[AnchorRange], - cx: &App, -) -> Option { +fn extract_file_matches(buf: &Buffer, ranges: &[AnchorRange], cx: &App) -> Option { let file = buf.file()?; let project_path = ProjectPath { worktree_id: file.worktree_id(cx), @@ -684,7 +680,13 @@ impl QuickSearchModal { .as_ref() .map(|(_, _, ranges)| ranges.as_slice()) .unwrap_or(&[]); - Self::navigate_and_highlight_matches(editor, line, match_ranges, window, cx); + Self::navigate_and_highlight_matches( + editor, + line, + match_ranges, + window, + cx, + ); }); } cx.notify(); @@ -943,7 +945,10 @@ impl QuickSearchDelegate { fn rebuild_visible_line_match_indices(&mut self) { self.visible_line_match_indices.clear(); for (visible_idx, &actual_idx) in self.visible_indices.iter().enumerate() { - if matches!(self.items.get(actual_idx), Some(QuickSearchItem::LineMatch { .. })) { + if matches!( + self.items.get(actual_idx), + Some(QuickSearchItem::LineMatch { .. }) + ) { self.visible_line_match_indices.push(visible_idx); } } @@ -1562,40 +1567,142 @@ mod tests { use std::ops::Deref; use util::path; - fn init_test(cx: &mut TestAppContext) { - cx.update(|cx| { - let settings = SettingsStore::test(cx); - cx.set_global(settings); - theme::init(theme::LoadThemes::JustBase, cx); - editor::init(cx); - crate::init(cx); - }); + struct TestFixture { + quick_search: Entity, + cx: VisualTestContext, } - #[gpui::test] - async fn test_quick_search_modal_creation(cx: &mut TestAppContext) { - init_test(cx); + impl TestFixture { + async fn new(cx: &mut TestAppContext, files: serde_json::Value) -> Self { + Self::new_with_query(cx, files, None).await + } - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - path!("/project"), - json!({ - "file.rs": "fn main() {}\n", - }), - ) - .await; + async fn new_with_query( + cx: &mut TestAppContext, + files: serde_json::Value, + initial_query: Option, + ) -> Self { + cx.update(|cx| { + let settings = SettingsStore::test(cx); + cx.set_global(settings); + theme::init(theme::LoadThemes::JustBase, cx); + editor::init(cx); + crate::init(cx); + }); - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let workspace = window.root(cx).unwrap(); - let mut cx = VisualTestContext::from_window(*window.deref(), cx); + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree(path!("/project"), files).await; - let quick_search = cx.new_window_entity({ - let weak_workspace = workspace.downgrade(); - move |window, cx| QuickSearchModal::new(weak_workspace, project, None, window, cx) - }); + let project = Project::test(fs, [path!("/project").as_ref()], cx).await; + let window = + cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); + let workspace = window.root(cx).unwrap(); + let mut visual_cx = VisualTestContext::from_window(*window.deref(), cx); + + let quick_search = visual_cx.new_window_entity({ + let weak_workspace = workspace.downgrade(); + move |window, cx| { + QuickSearchModal::new(weak_workspace, project, initial_query, window, cx) + } + }); - quick_search.update(&mut cx, |modal, cx| { + Self { + quick_search, + cx: visual_cx, + } + } + + async fn search(&mut self, query: &str) { + self.quick_search + .update_in(&mut self.cx, |modal, window, cx| { + modal.picker.update(cx, |picker, cx| { + picker + .delegate + .update_matches(query.to_string(), window, cx) + }) + }) + .await; + } + + fn set_query(&mut self, query: &str) { + self.quick_search + .update_in(&mut self.cx, |modal, window, cx| { + modal.picker.update(cx, |picker, cx| { + picker.set_query(query, window, cx); + }); + }); + } + + fn toggle_option(&mut self, option: SearchOptions) { + self.quick_search.update(&mut self.cx, |modal, cx| { + modal.picker.update(cx, |picker, _cx| { + picker.delegate.toggle_search_option(option); + }); + }); + } + + fn set_items(&mut self, items: Vec) { + self.quick_search.update(&mut self.cx, |modal, cx| { + modal.picker.update(cx, |picker, _cx| { + picker.delegate.items = items; + picker.delegate.update_visible_indices(); + }); + }); + } + + fn toggle_file_collapsed(&mut self, file_key: &SharedString) { + let file_key = file_key.clone(); + self.quick_search.update(&mut self.cx, |modal, cx| { + modal.picker.update(cx, |picker, _cx| { + picker.delegate.toggle_file_collapsed(&file_key); + }); + }); + } + + fn toggle_all_files_collapsed(&mut self, file_key: &SharedString) { + let file_key = file_key.clone(); + self.quick_search.update(&mut self.cx, |modal, cx| { + modal.picker.update(cx, |picker, _cx| { + picker.delegate.toggle_all_files_collapsed(&file_key); + }); + }); + } + + fn delegate(&mut self, read_fn: impl FnOnce(&QuickSearchDelegate) -> T) -> T { + self.quick_search.update(&mut self.cx, |modal, cx| { + read_fn(&modal.picker.read(cx).delegate) + }) + } + } + + fn file_header(file_name: &str, parent_path: &str) -> QuickSearchItem { + let file_key = format_file_key(parent_path, file_name); + QuickSearchItem::FileHeader { + file_name: SharedString::from(file_name.to_string()), + parent_path: SharedString::from(parent_path.to_string()), + file_key, + } + } + + fn line_match(file_key: &str, line: u32, preview: &str) -> QuickSearchItem { + QuickSearchItem::LineMatch { + project_path: ProjectPath { + worktree_id: project::WorktreeId::from_usize(0), + path: util::rel_path::rel_path(file_key).into(), + }, + file_key: SharedString::from(file_key.to_string()), + line, + line_label: SharedString::from((line + 1).to_string()), + preview_text: SharedString::from(preview.to_string()), + match_ranges: Arc::new(Vec::new()), + } + } + + #[gpui::test] + async fn test_quick_search_modal_creation(cx: &mut TestAppContext) { + let mut fixture = TestFixture::new(cx, json!({"file.rs": "fn main() {}\n"})).await; + + fixture.quick_search.update(&mut fixture.cx, |modal, cx| { assert!(modal.preview_editor.is_none()); assert!(modal.preview_buffer.is_none()); assert_eq!(modal.picker.read(cx).delegate.items.len(), 0); @@ -1604,278 +1711,273 @@ mod tests { #[gpui::test] async fn test_quick_search_empty_query_clears_results(cx: &mut TestAppContext) { - init_test(cx); + let mut fixture = TestFixture::new(cx, json!({"file.rs": "fn test() {}\n"})).await; + + fixture.set_query("test"); + assert_eq!(fixture.delegate(|d| d.pending_search_id), 1); + + fixture.search("").await; + fixture.delegate(|d| { + assert_eq!(d.items.len(), 0); + assert_eq!(d.pending_search_id, 0); + }); + } + + #[gpui::test] + async fn test_quick_search_no_results_for_nonexistent_query(cx: &mut TestAppContext) { + let mut fixture = TestFixture::new(cx, json!({"file.rs": "fn main() {}\n"})).await; + + fixture.search("nonexistent_string_xyz_123").await; + assert_eq!(fixture.delegate(|d| d.items.len()), 0); + } - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - path!("/project"), + #[gpui::test] + async fn test_quick_search_query_updates_search_id(cx: &mut TestAppContext) { + let mut fixture = + TestFixture::new(cx, json!({"file.rs": "fn hello() {}\nfn world() {}\n"})).await; + + assert_eq!(fixture.delegate(|d| d.pending_search_id), 0); + + fixture.set_query("hello"); + assert_eq!(fixture.delegate(|d| d.pending_search_id), 1); + + fixture.set_query("world"); + assert_eq!(fixture.delegate(|d| d.pending_search_id), 2); + } + + #[gpui::test] + async fn test_quick_search_finds_matches(cx: &mut TestAppContext) { + let mut fixture = TestFixture::new( + cx, json!({ - "file.rs": "fn test() {}\n", + "src": { + "main.rs": "fn main() {\n println!(\"hello world\");\n}\n", + "lib.rs": "pub fn hello() {}\npub fn hello_world() {}\n", + }, + "tests": { "test.rs": "fn test_hello() {}\n" } }), ) .await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let workspace = window.root(cx).unwrap(); - let mut cx = VisualTestContext::from_window(*window.deref(), cx); + fixture.search("hello").await; - let quick_search = cx.new_window_entity({ - let weak_workspace = workspace.downgrade(); - move |window, cx| QuickSearchModal::new(weak_workspace, project, None, window, cx) + fixture.delegate(|d| { + assert!(d.match_count >= 3); + assert!(d.file_count >= 2); + assert!(!d.is_searching); + assert!(d.regex_error.is_none()); }); + } - quick_search.update_in(&mut cx, |modal, window, cx| { - modal.picker.update(cx, |picker, cx| { - picker.set_query("test", window, cx); - }); - }); + #[gpui::test] + async fn test_quick_search_case_sensitive_option(cx: &mut TestAppContext) { + let mut fixture = TestFixture::new( + cx, + json!({"file.rs": "fn Hello() {}\nfn hello() {}\nfn HELLO() {}\n"}), + ) + .await; - quick_search.update(&mut cx, |modal, cx| { - assert_eq!(modal.picker.read(cx).delegate.pending_search_id, 1); - }); + fixture.search("Hello").await; + let case_insensitive_count = fixture.delegate(|d| d.match_count); - quick_search.update_in(&mut cx, |modal, window, cx| { - modal.picker.update(cx, |picker, cx| { - picker.set_query("", window, cx); - }); + fixture.toggle_option(SearchOptions::CASE_SENSITIVE); + fixture.search("Hello").await; + + fixture.delegate(|d| { + assert!(d.search_options.contains(SearchOptions::CASE_SENSITIVE)); + assert_eq!(d.match_count, 1); + assert!(case_insensitive_count > d.match_count); }); + } - cx.background_executor.run_until_parked(); + #[gpui::test] + async fn test_quick_search_whole_word_option(cx: &mut TestAppContext) { + let mut fixture = TestFixture::new( + cx, + json!({"file.rs": "fn test() {}\nfn testing() {}\nfn my_test_fn() {}\n"}), + ) + .await; - quick_search.update(&mut cx, |modal, cx| { - let delegate = &modal.picker.read(cx).delegate; - assert_eq!(delegate.items.len(), 0, "Empty query should clear results"); - assert_eq!( - delegate.pending_search_id, 0, - "Empty query should reset search id" - ); + fixture.search("test").await; + let partial_count = fixture.delegate(|d| d.match_count); + + fixture.toggle_option(SearchOptions::WHOLE_WORD); + fixture.search("test").await; + + fixture.delegate(|d| { + assert!(d.search_options.contains(SearchOptions::WHOLE_WORD)); + assert!(d.match_count < partial_count); }); } #[gpui::test] - fn test_quick_search_item_types(cx: &mut TestAppContext) { - init_test(cx); + async fn test_quick_search_regex_option(cx: &mut TestAppContext) { + let mut fixture = TestFixture::new( + cx, + json!({"file.rs": "fn test1() {}\nfn test2() {}\nfn test10() {}\nfn other() {}\n"}), + ) + .await; - let header = QuickSearchItem::FileHeader { - file_name: "test.rs".into(), - parent_path: "src".into(), - file_key: "src/test.rs".into(), - }; - assert!(matches!(header, QuickSearchItem::FileHeader { .. })); + fixture.toggle_option(SearchOptions::REGEX); + fixture.search("test\\d+").await; - cx.update(|_cx| { - let line_match = QuickSearchItem::LineMatch { - project_path: ProjectPath { - worktree_id: project::WorktreeId::from_usize(0), - path: util::rel_path::rel_path("src/test.rs").into(), - }, - file_key: "src/test.rs".into(), - line: 0, - line_label: "1".into(), - preview_text: "fn test()".into(), - match_ranges: Arc::new(Vec::new()), - }; - assert!(matches!(line_match, QuickSearchItem::LineMatch { .. })); + fixture.delegate(|d| { + assert!(d.search_options.contains(SearchOptions::REGEX)); + assert_eq!(d.match_count, 3); + assert!(d.regex_error.is_none()); }); } #[gpui::test] - async fn test_quick_search_no_results_for_nonexistent_query(cx: &mut TestAppContext) { - init_test(cx); + async fn test_quick_search_invalid_regex_shows_error(cx: &mut TestAppContext) { + let mut fixture = TestFixture::new(cx, json!({"file.rs": "fn test() {}\n"})).await; - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - path!("/project"), - json!({ - "file.rs": "fn main() {}\n", - }), - ) - .await; + fixture.toggle_option(SearchOptions::REGEX); + fixture.search("[invalid(regex").await; - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let workspace = window.root(cx).unwrap(); - let mut cx = VisualTestContext::from_window(*window.deref(), cx); + fixture.delegate(|d| { + assert!(d.regex_error.is_some()); + assert_eq!(d.items.len(), 0); + assert!(!d.is_searching); + }); + } - let quick_search = cx.new_window_entity({ - let weak_workspace = workspace.downgrade(); - move |window, cx| QuickSearchModal::new(weak_workspace, project, None, window, cx) + #[gpui::test] + async fn test_quick_search_delegate_collapse_expand(cx: &mut TestAppContext) { + let mut fixture = TestFixture::new(cx, json!({"file.rs": ""})).await; + + fixture.set_items(vec![ + file_header("test.rs", "src"), + line_match("src/test.rs", 0, "fn test()"), + line_match("src/test.rs", 1, "fn other()"), + file_header("lib.rs", "src"), + line_match("src/lib.rs", 0, "pub fn lib_test()"), + ]); + + fixture.delegate(|d| { + assert_eq!(d.visible_indices.len(), 5); + assert_eq!(d.visible_line_match_indices.len(), 3); }); - quick_search.update_in(&mut cx, |modal, window, cx| { - modal.picker.update(cx, |picker, cx| { - picker.set_query("nonexistent_string_xyz_123", window, cx); - }); + let file_key: SharedString = "src/test.rs".into(); + fixture.toggle_file_collapsed(&file_key); + + fixture.delegate(|d| { + assert!(d.collapsed_files.contains(&file_key)); + assert_eq!(d.visible_indices.len(), 3); + assert_eq!(d.visible_line_match_indices.len(), 1); }); - cx.executor().advance_clock(Duration::from_millis(150)); - cx.background_executor.run_until_parked(); + fixture.toggle_file_collapsed(&file_key); - quick_search.update(&mut cx, |modal, cx| { - let delegate = &modal.picker.read(cx).delegate; - assert_eq!( - delegate.items.len(), - 0, - "Should have no results for non-matching query" - ); + fixture.delegate(|d| { + assert!(!d.collapsed_files.contains(&file_key)); + assert_eq!(d.visible_indices.len(), 5); + assert_eq!(d.visible_line_match_indices.len(), 3); }); } #[gpui::test] - async fn test_quick_search_query_updates_search_id(cx: &mut TestAppContext) { - init_test(cx); + async fn test_quick_search_toggle_all_files_collapsed(cx: &mut TestAppContext) { + let mut fixture = TestFixture::new(cx, json!({"file.rs": ""})).await; - let fs = FakeFs::new(cx.background_executor.clone()); - fs.insert_tree( - path!("/project"), - json!({ - "file.rs": "fn hello() {}\nfn world() {}\n", - }), - ) - .await; + fixture.set_items(vec![ + file_header("test.rs", "src"), + line_match("src/test.rs", 0, "fn test()"), + file_header("lib.rs", "src"), + line_match("src/lib.rs", 0, "pub fn lib()"), + ]); - let project = Project::test(fs.clone(), [path!("/project").as_ref()], cx).await; - let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx)); - let workspace = window.root(cx).unwrap(); - let mut cx = VisualTestContext::from_window(*window.deref(), cx); + assert_eq!(fixture.delegate(|d| d.visible_indices.len()), 4); - let quick_search = cx.new_window_entity({ - let weak_workspace = workspace.downgrade(); - move |window, cx| QuickSearchModal::new(weak_workspace, project, None, window, cx) - }); + let file_key: SharedString = "src/test.rs".into(); + fixture.toggle_all_files_collapsed(&file_key); - quick_search.update(&mut cx, |modal, cx| { - assert_eq!(modal.picker.read(cx).delegate.pending_search_id, 0); + fixture.delegate(|d| { + assert_eq!(d.collapsed_files.len(), 2); + assert_eq!(d.visible_indices.len(), 2); + assert_eq!(d.visible_line_match_indices.len(), 0); }); - quick_search.update_in(&mut cx, |modal, window, cx| { - modal.picker.update(cx, |picker, cx| { - picker.set_query("hello", window, cx); - }); - }); + fixture.toggle_all_files_collapsed(&file_key); - quick_search.update(&mut cx, |modal, cx| { - assert_eq!( - modal.picker.read(cx).delegate.pending_search_id, - 1, - "First search should have id 1" - ); + fixture.delegate(|d| { + assert_eq!(d.collapsed_files.len(), 0); + assert_eq!(d.visible_indices.len(), 4); }); + } - quick_search.update_in(&mut cx, |modal, window, cx| { - modal.picker.update(cx, |picker, cx| { - picker.set_query("world", window, cx); - }); + #[gpui::test] + async fn test_quick_search_find_nearest_line_match(cx: &mut TestAppContext) { + let mut fixture = TestFixture::new(cx, json!({"file.rs": ""})).await; + + fixture.set_items(vec![ + file_header("test.rs", "src"), + line_match("src/test.rs", 0, "fn test()"), + file_header("lib.rs", "src"), + line_match("src/lib.rs", 0, "pub fn lib()"), + ]); + + fixture.delegate(|d| { + assert_eq!(d.find_nearest_line_match(0, true), Some(1)); + assert_eq!(d.find_nearest_line_match(2, false), Some(1)); + assert_eq!(d.find_nearest_line_match(1, true), Some(1)); + assert_eq!(d.find_nearest_line_match(3, true), Some(3)); }); + } - quick_search.update(&mut cx, |modal, cx| { - assert_eq!( - modal.picker.read(cx).delegate.pending_search_id, - 2, - "Second search should have id 2" - ); - }); + #[gpui::test] + fn test_truncate_preview() { + assert_eq!( + truncate_preview("fn test() {}", MAX_PREVIEW_BYTES).as_ref(), + "fn test() {}" + ); + + let long_text = "a".repeat(300); + let truncated = truncate_preview(&long_text, MAX_PREVIEW_BYTES); + assert!(truncated.len() <= MAX_PREVIEW_BYTES + 3); + assert!(truncated.ends_with('…')); + + assert_eq!( + truncate_preview(" fn test() ", MAX_PREVIEW_BYTES).as_ref(), + "fn test()" + ); } #[gpui::test] - fn test_quick_search_collapse_expand_files(cx: &mut TestAppContext) { - init_test(cx); - - cx.update(|_cx| { - let items = [ - QuickSearchItem::FileHeader { - file_name: "test.rs".into(), - parent_path: "src".into(), - file_key: "src/test.rs".into(), - }, - QuickSearchItem::LineMatch { - project_path: ProjectPath { - worktree_id: project::WorktreeId::from_usize(0), - path: util::rel_path::rel_path("src/test.rs").into(), - }, - file_key: "src/test.rs".into(), - line: 0, - line_label: "1".into(), - preview_text: "fn test()".into(), - match_ranges: Arc::new(Vec::new()), - }, - QuickSearchItem::LineMatch { - project_path: ProjectPath { - worktree_id: project::WorktreeId::from_usize(0), - path: util::rel_path::rel_path("src/test.rs").into(), - }, - file_key: "src/test.rs".into(), - line: 1, - line_label: "2".into(), - preview_text: "fn other()".into(), - match_ranges: Arc::new(Vec::new()), - }, - ]; + fn test_format_file_key() { + assert_eq!(format_file_key("src", "main.rs").as_ref(), "src/main.rs"); + assert_eq!(format_file_key("", "main.rs").as_ref(), "main.rs"); + } - let mut visible_indices = Vec::new(); - let mut collapsed_files: HashSet = HashSet::default(); + #[gpui::test] + fn test_build_search_query_text() { + assert!(build_search_query("test", SearchOptions::NONE).is_ok()); + assert!(build_search_query("test", SearchOptions::CASE_SENSITIVE).is_ok()); + assert!(build_search_query("test", SearchOptions::WHOLE_WORD).is_ok()); + } - for (idx, item) in items.iter().enumerate() { - match item { - QuickSearchItem::FileHeader { .. } => { - visible_indices.push(idx); - } - QuickSearchItem::LineMatch { file_key, .. } => { - if !collapsed_files.contains(file_key) { - visible_indices.push(idx); - } - } - } - } + #[gpui::test] + fn test_build_search_query_regex() { + assert!(build_search_query("test\\d+", SearchOptions::REGEX).is_ok()); - assert_eq!(visible_indices.len(), 3, "All 3 items should be visible"); - assert_eq!(visible_indices, vec![0, 1, 2]); + let query = build_search_query("[invalid", SearchOptions::REGEX); + assert!(query.is_err()); + assert!(!query.unwrap_err().is_empty()); + } - let file_key: SharedString = "src/test.rs".into(); - collapsed_files.insert(file_key.clone()); - visible_indices.clear(); - for (idx, item) in items.iter().enumerate() { - match item { - QuickSearchItem::FileHeader { .. } => { - visible_indices.push(idx); - } - QuickSearchItem::LineMatch { file_key, .. } => { - if !collapsed_files.contains(file_key) { - visible_indices.push(idx); - } - } - } - } + #[gpui::test] + async fn test_quick_search_initial_query_from_selection(cx: &mut TestAppContext) { + let mut fixture = TestFixture::new_with_query( + cx, + json!({"file.rs": "fn hello() {}\nfn world() {}\n"}), + Some("hello".to_string()), + ) + .await; - assert_eq!( - visible_indices.len(), - 1, - "Only file header should be visible after collapse" - ); - assert_eq!(visible_indices, vec![0]); - - collapsed_files.remove(&file_key); - visible_indices.clear(); - for (idx, item) in items.iter().enumerate() { - match item { - QuickSearchItem::FileHeader { .. } => { - visible_indices.push(idx); - } - QuickSearchItem::LineMatch { file_key, .. } => { - if !collapsed_files.contains(file_key) { - visible_indices.push(idx); - } - } - } - } + assert_eq!(fixture.delegate(|d| d.current_query.clone()), "hello"); - assert_eq!( - visible_indices.len(), - 3, - "All items should be visible after expand" - ); - assert_eq!(visible_indices, vec![0, 1, 2]); - }); + fixture.search("hello").await; + assert!(fixture.delegate(|d| d.match_count) > 0); } } From 82283954cf8deff6225428136f82ca4a01e8b048 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 15 Dec 2025 12:07:16 -0800 Subject: [PATCH 20/35] Prevent row count header from disappearing when searching --- crates/search/src/quick_search.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index acb6ca16f33db4..77ccf2ee26aba8 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -1481,8 +1481,10 @@ impl PickerDelegate for QuickSearchDelegate { ); } - if self.match_count > 0 && !self.is_searching { - let results_text = if self.is_limited { + if self.match_count > 0 || self.is_searching { + let results_text = if self.is_searching { + "Searching…".to_string() + } else if self.is_limited { format!("{}+ results (limited)", self.match_count) } else { let result_word = if self.match_count == 1 { @@ -1514,7 +1516,18 @@ impl PickerDelegate for QuickSearchDelegate { ); } - None + Some( + h_flex() + .w_full() + .px_3() + .py_1() + .child( + Label::new("0 results") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any(), + ) } fn render_footer( From a4d35902bd80d9ba6c46d99500fc7d368c735c0f Mon Sep 17 00:00:00 2001 From: David Bonan Date: Tue, 16 Dec 2025 10:45:36 +0100 Subject: [PATCH 21/35] Improves quick search performance and UX Add syntax highlighting to the preview. Caches buffers to improve performance. --- crates/search/src/quick_search.rs | 776 +++++++++++++++++++++--------- 1 file changed, 547 insertions(+), 229 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 77ccf2ee26aba8..7653d36c526e2d 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -3,17 +3,18 @@ use editor::{Anchor as MultiBufferAnchor, Editor, EditorEvent}; use file_icons::FileIcons; use futures::StreamExt; use gpui::{ - Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, Pixels, - Render, SharedString, Subscription, Task, WeakEntity, Window, actions, prelude::*, + Action, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, + HighlightStyle, Pixels, Render, SharedString, StyledText, Subscription, Task, WeakEntity, + Window, actions, prelude::*, }; -use language::Buffer; +use language::{Buffer, BufferEvent, HighlightId}; use picker::{Picker, PickerDelegate}; use project::{Project, ProjectPath, search::SearchQuery}; use std::{path::Path, pin::pin, sync::Arc, time::Duration}; -use text::ToPoint as _; +use text::{ToOffset as _, ToPoint as _}; use ui::{ - Button, ButtonStyle, Color, Icon, IconButton, IconButtonShape, IconName, KeyBinding, Label, - ListItem, ListItemSpacing, SpinnerLabel, Tooltip, prelude::*, rems_from_px, + Button, ButtonStyle, Color, Divider, Icon, IconButton, IconButtonShape, IconName, KeyBinding, + Label, ListItem, ListItemSpacing, Tooltip, prelude::*, rems_from_px, }; use util::{ResultExt, paths::PathMatcher}; use workspace::{ @@ -27,20 +28,17 @@ use crate::{ type AnchorRange = std::ops::Range; -struct LineData { - line_label: SharedString, - preview_text: SharedString, - match_ranges: Vec, -} - const MODAL_HEIGHT: Pixels = px(800.); const MODAL_WIDTH: Pixels = px(1400.); const LEFT_PANEL_WIDTH: Pixels = px(400.); -const MAX_LINE_MATCHES: usize = 800; const MAX_PREVIEW_BYTES: usize = 200; const SEARCH_DEBOUNCE_MS: u64 = 100; const PREVIEW_DEBOUNCE_MS: u64 = 50; const EDIT_OPEN_DELAY_MS: u64 = 200; +const STREAM_CHUNK_SIZE: usize = 1024; +const MAX_LINES_PER_FILE: usize = 800; +const MAX_SEARCH_RESULT_FILES: usize = 5_000; +const MAX_SEARCH_RESULT_RANGES: usize = 10_000; actions!(search, [QuickSearch]); @@ -51,6 +49,9 @@ struct LineMatchData { line_label: SharedString, preview_text: SharedString, match_ranges: Arc>, + match_positions: Arc>>, + trim_start: usize, + syntax_highlights: Option, HighlightId)>>>, } enum QuickSearchHighlights {} @@ -78,6 +79,17 @@ fn truncate_preview(text: &str, max_bytes: usize) -> SharedString { result.into() } +#[inline] +fn preview_content_len(preview_text: &str) -> usize { + preview_text + .len() + .saturating_sub(if preview_text.ends_with('…') { + '…'.len_utf8() + } else { + 0 + }) +} + fn extract_file_matches(buf: &Buffer, ranges: &[AnchorRange], cx: &App) -> Option { let file = buf.file()?; let project_path = ProjectPath { @@ -89,8 +101,18 @@ fn extract_file_matches(buf: &Buffer, ranges: &[AnchorRange], cx: &App) -> Optio let file_key = format_file_key(&parent_path, &file_name); let snapshot = buf.snapshot(); - let mut lines_data: HashMap = HashMap::default(); - let mut line_order = Vec::new(); + + struct LineInfo { + line_start_offset: usize, + line_text: String, + trim_start: usize, + match_ranges: Vec, + } + + let estimated_lines = ranges.len().min(MAX_LINES_PER_FILE); + let mut lines_data: HashMap = HashMap::default(); + lines_data.reserve(estimated_lines); + let mut line_order = Vec::with_capacity(estimated_lines); for range in ranges { let start_point = range.start.to_point(&snapshot); @@ -104,21 +126,21 @@ fn extract_file_matches(buf: &Buffer, ranges: &[AnchorRange], cx: &App) -> Optio let line_end = snapshot.point_to_offset(text::Point::new(line, line_end_col)); let line_text: String = snapshot.text_for_range(line_start..line_end).collect(); - let preview_text = truncate_preview(&line_text, MAX_PREVIEW_BYTES); - let line_label: SharedString = (line + 1).to_string().into(); + let trim_start = line_text.len() - line_text.trim_start().len(); lines_data.insert( line, - LineData { - line_label, - preview_text, + LineInfo { + line_start_offset: line_start, + line_text, + trim_start, match_ranges: vec![range.clone()], }, ); line_order.push(line); } - if line_order.len() >= MAX_LINE_MATCHES { + if line_order.len() >= MAX_LINES_PER_FILE { break; } } @@ -130,14 +152,60 @@ fn extract_file_matches(buf: &Buffer, ranges: &[AnchorRange], cx: &App) -> Optio let matches = line_order .into_iter() .filter_map(|line| { - let data = lines_data.remove(&line)?; + let info = lines_data.remove(&line)?; + let preview_text = truncate_preview(&info.line_text, MAX_PREVIEW_BYTES); + let preview_len = preview_content_len(&preview_text); + let line_label: SharedString = (line + 1).to_string().into(); + + let mut match_positions = Vec::new(); + let preview_str: &str = preview_text.as_ref(); + for range in &info.match_ranges { + let match_start_offset = range.start.to_offset(&snapshot); + let match_end_offset = range.end.to_offset(&snapshot); + + let start_in_line = match_start_offset.saturating_sub(info.line_start_offset); + let end_in_line = match_end_offset.saturating_sub(info.line_start_offset); + + let start_in_preview = start_in_line.saturating_sub(info.trim_start); + let end_in_preview = end_in_line.saturating_sub(info.trim_start); + + if start_in_preview < preview_len && end_in_preview > 0 { + let clamped_start = start_in_preview.min(preview_len); + let clamped_end = end_in_preview.min(preview_len); + if clamped_start < clamped_end { + let mut safe_start = clamped_start.min(preview_str.len()); + while safe_start > 0 && !preview_str.is_char_boundary(safe_start) { + safe_start -= 1; + } + + let mut safe_end = clamped_end.min(preview_str.len()); + while safe_end < preview_str.len() + && !preview_str.is_char_boundary(safe_end) + { + safe_end += 1; + } + + if safe_start < safe_end { + match_positions.push(safe_start..safe_end); + } + } + } + } + + let syntax_highlights = + extract_syntax_highlights_for_line(&snapshot, line, info.trim_start, preview_len) + .map(Arc::new); + Some(LineMatchData { project_path: project_path.clone(), file_key: file_key.clone(), line, - line_label: data.line_label, - preview_text: data.preview_text, - match_ranges: Arc::new(data.match_ranges), + line_label, + preview_text, + match_ranges: Arc::new(info.match_ranges), + match_positions: Arc::new(match_positions), + trim_start: info.trim_start, + syntax_highlights, }) }) .collect(); @@ -150,6 +218,49 @@ fn extract_file_matches(buf: &Buffer, ranges: &[AnchorRange], cx: &App) -> Optio }) } +fn extract_syntax_highlights_for_line( + snapshot: &language::BufferSnapshot, + line: u32, + trim_start: usize, + preview_len: usize, +) -> Option, HighlightId)>> { + let line_start = snapshot.point_to_offset(text::Point::new(line, 0)); + let line_len = snapshot.line_len(line); + let line_end = snapshot.point_to_offset(text::Point::new(line, line_len)); + + let mut highlights = Vec::new(); + let mut current_offset = 0; + + for chunk in snapshot.chunks(line_start..line_end, true) { + let chunk_len = chunk.text.len(); + + if let Some(highlight_id) = chunk.syntax_highlight_id { + let abs_start = current_offset; + let abs_end = current_offset + chunk_len; + + let rel_start = abs_start.saturating_sub(trim_start); + let rel_end = abs_end.saturating_sub(trim_start); + + if rel_end > 0 && rel_start < preview_len { + let clamped_start = rel_start.min(preview_len); + let clamped_end = rel_end.min(preview_len); + + if clamped_start < clamped_end { + highlights.push((clamped_start..clamped_end, highlight_id)); + } + } + } + + current_offset += chunk_len; + } + + if highlights.is_empty() { + None + } else { + Some(highlights) + } +} + fn format_file_key(parent_path: &str, file_name: &str) -> SharedString { if parent_path.is_empty() { file_name.to_string().into() @@ -189,6 +300,9 @@ enum QuickSearchItem { line_label: SharedString, preview_text: SharedString, match_ranges: Arc>, + match_positions: Arc>>, + trim_start: usize, + syntax_highlights: Option, HighlightId)>>>, }, } @@ -210,6 +324,8 @@ pub struct QuickSearchDelegate { current_query: String, focus_handle: Option, regex_error: Option, + buffer_cache: HashMap>, + buffer_subscriptions: HashMap, } pub struct QuickSearchModal { @@ -242,11 +358,6 @@ impl Render for QuickSearchModal { let preview_editor = self.preview_editor.clone(); let picker = self.picker.clone(); - let delegate = &self.picker.read(cx).delegate; - let is_searching = delegate.is_searching; - let search_options = delegate.search_options; - let focus_handle = self.picker.focus_handle(cx); - let viewport_size = window.viewport_size(); let max_width = viewport_size.width * 0.9; let max_height = viewport_size.height * 0.8; @@ -264,11 +375,7 @@ impl Render for QuickSearchModal { .size_full() .overflow_hidden() .border_1() - .rounded_none() .border_color(cx.theme().colors().border) - .on_mouse_down_out(cx.listener(|_, _, _, cx| { - cx.emit(DismissEvent); - })) .child( h_flex() .w_full() @@ -284,60 +391,6 @@ impl Render for QuickSearchModal { .overflow_hidden() .border_r_1() .border_color(cx.theme().colors().border) - .child( - h_flex() - .w_full() - .px_3() - .py_2() - .bg(cx.theme().colors().title_bar_background) - .border_b_1() - .border_color(cx.theme().colors().border) - .justify_between() - .child( - h_flex() - .gap_2() - .child( - Label::new("Quick Search") - .size(LabelSize::Small) - .color(Color::Muted), - ) - .when(is_searching, |this| { - this.child( - SpinnerLabel::new() - .size(LabelSize::Small) - .color(Color::Muted), - ) - }), - ) - .child( - h_flex() - .gap_0p5() - .child(Self::render_search_option_button( - SearchOption::CaseSensitive, - search_options, - focus_handle.clone(), - cx, - )) - .child(Self::render_search_option_button( - SearchOption::WholeWord, - search_options, - focus_handle.clone(), - cx, - )) - .child(Self::render_search_option_button( - SearchOption::Regex, - search_options, - focus_handle.clone(), - cx, - )) - .child(Self::render_search_option_button( - SearchOption::IncludeIgnored, - search_options, - focus_handle, - cx, - )), - ), - ) .child(self.picker.clone()), ) .child({ @@ -467,29 +520,6 @@ impl QuickSearchModal { Self::toggle_search_option(workspace, SearchOptions::INCLUDE_IGNORED, window, cx); } - fn render_search_option_button( - option: SearchOption, - active: SearchOptions, - focus_handle: FocusHandle, - cx: &Context, - ) -> impl IntoElement { - let action = option.to_toggle_action(); - let label = option.label(); - let search_option = option.as_options(); - IconButton::new(label, option.icon()) - .on_click(cx.listener(move |modal, _, window, cx| { - modal.picker.update(cx, |picker, cx| { - picker.delegate.toggle_search_option(search_option); - let query = picker.delegate.current_query.clone(); - picker.set_query(query, window, cx); - }); - })) - .style(ButtonStyle::Subtle) - .shape(IconButtonShape::Square) - .toggle_state(active.contains(option.as_options())) - .tooltip(move |_window, cx| Tooltip::for_action_in(label, action, &focus_handle, cx)) - } - fn new( workspace: WeakEntity, project: Entity, @@ -517,6 +547,8 @@ impl QuickSearchModal { current_query: initial_query.clone().unwrap_or_default(), focus_handle: None, regex_error: None, + buffer_cache: HashMap::default(), + buffer_subscriptions: HashMap::default(), }; let picker = cx.new(|cx| { @@ -624,6 +656,16 @@ impl QuickSearchModal { })); } + fn is_same_preview_path(&self, project_path: &ProjectPath, cx: &App) -> bool { + self.preview_buffer + .as_ref() + .and_then(|b| b.read(cx).file()) + .map_or(false, |file| { + file.worktree_id(cx) == project_path.worktree_id + && file.path() == &project_path.path + }) + } + fn navigate_and_highlight_matches( editor: &mut Editor, line: u32, @@ -663,16 +705,7 @@ impl QuickSearchModal { } if let Some((ref project_path, line, _)) = data { - let same_path = self - .preview_buffer - .as_ref() - .and_then(|b| b.read(cx).file()) - .map_or(false, |file| { - file.worktree_id(cx) == project_path.worktree_id - && file.path() == &project_path.path - }); - - if same_path { + if self.is_same_preview_path(project_path, cx) { self._preview_debounce_task = None; if let Some(editor) = &self.preview_editor { editor.update(cx, |editor, cx| { @@ -723,16 +756,7 @@ impl QuickSearchModal { return; }; - let same_path = self - .preview_buffer - .as_ref() - .and_then(|b| b.read(cx).file()) - .map_or(false, |file| { - file.worktree_id(cx) == project_path.worktree_id - && file.path() == &project_path.path - }); - - if same_path { + if self.is_same_preview_path(&project_path, cx) { self.preview_pending_path = None; if let Some(editor) = &self.preview_editor { @@ -750,6 +774,37 @@ impl QuickSearchModal { self.preview_pending_path = Some(project_path.clone()); + let cached_buffer = self + .picker + .read(cx) + .delegate + .buffer_cache + .get(&project_path) + .cloned(); + + if let Some(buffer) = cached_buffer { + self.preview_pending_path = None; + + let project = self.project.clone(); + let editor = cx.new(|cx| { + let mut editor = Editor::for_buffer(buffer.clone(), Some(project), window, cx); + editor.set_show_gutter(true, cx); + editor + }); + + editor.update(cx, |editor, cx| { + Self::navigate_and_highlight_matches(editor, line, &match_ranges, window, cx); + }); + + self._preview_editor_subscription = + Some(cx.subscribe_in(&editor, window, Self::on_preview_editor_event)); + self.preview_editor = Some(editor); + self.preview_buffer = Some(buffer); + self.preview_opened_in_workspace = None; + cx.notify(); + return; + } + let project = self.project.clone(); let open_buffer_task = project.update(cx, |project, cx| { project.open_buffer(project_path.clone(), cx) @@ -792,9 +847,54 @@ impl QuickSearchModal { struct SearchResults { items: Vec, - line_match_count: usize, - file_count: usize, - is_limited: bool, + buffers: HashMap>, +} + +struct BatchCounters { + total_files: usize, + total_line_matches: usize, + search_limited: bool, +} + +fn process_buffer_into_batch( + file_result: FileMatchResult, + buffer: Entity, + batch: &mut SearchResults, + counters: &mut BatchCounters, +) { + if let Some(first_match) = file_result.matches.first() { + batch + .buffers + .insert(first_match.project_path.clone(), buffer); + } + + batch.items.push(QuickSearchItem::FileHeader { + file_name: file_result.file_name, + parent_path: file_result.parent_path, + file_key: file_result.file_key, + }); + counters.total_files += 1; + + for match_data in file_result.matches { + batch.items.push(QuickSearchItem::LineMatch { + project_path: match_data.project_path, + file_key: match_data.file_key, + line: match_data.line, + line_label: match_data.line_label, + preview_text: match_data.preview_text, + match_ranges: match_data.match_ranges, + match_positions: match_data.match_positions, + trim_start: match_data.trim_start, + syntax_highlights: match_data.syntax_highlights, + }); + counters.total_line_matches += 1; + } + + if counters.total_files > MAX_SEARCH_RESULT_FILES + || counters.total_line_matches > MAX_SEARCH_RESULT_RANGES + { + counters.search_limited = true; + } } impl SearchResults { @@ -844,9 +944,36 @@ fn build_search_query(query: &str, search_options: SearchOptions) -> Result Option { self.visible_indices.get(visible_index).copied() } + #[inline] fn is_line_match_at_visible_index(&self, visible_index: usize) -> bool { self.visible_indices .get(visible_index) @@ -971,6 +1133,7 @@ impl QuickSearchDelegate { }) } + #[inline] fn find_nearest_line_match( &self, from_visible_index: usize, @@ -1075,11 +1238,31 @@ impl QuickSearchDelegate { selected: bool, line_label: &SharedString, preview_text: &SharedString, + match_positions: &Arc>>, + syntax_highlights: &Option, HighlightId)>>>, + cx: &App, ) -> ListItem { let quick_search = self.quick_search.clone(); - let visible_ix = ix; - let line_label = line_label.clone(); - let preview_text = preview_text.clone(); + + let syntax_theme = cx.theme().syntax(); + let mut highlights: Vec<(std::ops::Range, HighlightStyle)> = syntax_highlights + .as_ref() + .map(|sh| { + sh.iter() + .filter_map(|(range, id)| { + id.style(&syntax_theme).map(|style| (range.clone(), style)) + }) + .collect() + }) + .unwrap_or_default(); + + for range in match_positions.iter() { + let match_style = HighlightStyle { + font_weight: Some(gpui::FontWeight::BOLD), + ..Default::default() + }; + highlights.push((range.clone(), match_style)); + } ListItem::new(ix) .inset(true) @@ -1094,7 +1277,7 @@ impl QuickSearchDelegate { let preview_data = { let modal = qs.read(cx); let delegate = &modal.picker.read(cx).delegate; - delegate.actual_index(visible_ix).and_then(|idx| { + delegate.actual_index(ix).and_then(|idx| { match delegate.items.get(idx) { Some(QuickSearchItem::LineMatch { project_path, @@ -1110,7 +1293,7 @@ impl QuickSearchDelegate { qs.update(cx, |modal, cx| { window.focus(&modal.picker.focus_handle(cx)); modal.picker.update(cx, |picker, cx| { - picker.delegate.selected_index = visible_ix; + picker.delegate.selected_index = ix; cx.notify(); }); modal.schedule_preview_update(preview_data, window, cx); @@ -1125,12 +1308,14 @@ impl QuickSearchDelegate { .pl(px(20.)) .justify_between() .child( - div().flex_1().min_w_0().overflow_hidden().child( - Label::new(preview_text) - .size(ui::LabelSize::Small) - .color(Color::Default) - .truncate(), - ), + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .text_ui_sm(cx) + .child(StyledText::new(preview_text).with_highlights(highlights)), ) .child( Label::new(line_label) @@ -1210,6 +1395,72 @@ impl PickerDelegate for QuickSearchDelegate { "Search in project...".into() } + fn render_editor( + &self, + editor: &Entity, + _window: &mut Window, + cx: &mut Context>, + ) -> Div { + let search_options = self.search_options; + let focus_handle = self.focus_handle.clone(); + + let render_option_button_fn = |option: SearchOption, cx: &mut Context>| { + let is_active = search_options.contains(option.as_options()); + let action = option.to_toggle_action(); + let label = option.label(); + let fh = focus_handle.clone(); + let options = option.as_options(); + + IconButton::new(label, option.icon()) + .on_click(cx.listener(move |picker, _, window, cx| { + picker.delegate.toggle_search_option(options); + let query = picker.delegate.current_query.clone(); + picker.set_query(query, window, cx); + })) + .style(ButtonStyle::Subtle) + .shape(IconButtonShape::Square) + .toggle_state(is_active) + .when_some(fh, |this, fh| { + this.tooltip(move |_window, cx| Tooltip::for_action_in(label, action, &fh, cx)) + }) + }; + + v_flex() + .bg(cx.theme().colors().toolbar_background) + .child( + h_flex() + .overflow_hidden() + .flex_none() + .py_2() + .px_2() + .gap_2() + .child( + h_flex() + .flex_1() + .min_w_32() + .h_8() + .pl_2() + .pr_1() + .border_1() + .border_color(cx.theme().colors().border) + .rounded_md() + .child(editor.clone()) + .child( + h_flex() + .gap_1() + .child(render_option_button_fn(SearchOption::CaseSensitive, cx)) + .child(render_option_button_fn(SearchOption::WholeWord, cx)) + .child(render_option_button_fn(SearchOption::Regex, cx)) + .child(render_option_button_fn( + SearchOption::IncludeIgnored, + cx, + )), + ), + ), + ) + .child(Divider::horizontal()) + } + fn update_matches( &mut self, query: String, @@ -1219,14 +1470,7 @@ impl PickerDelegate for QuickSearchDelegate { self.current_query = query.clone(); if query.is_empty() { - self.items.clear(); - self.visible_indices.clear(); - self.pending_search_id = 0; - self.match_count = 0; - self.file_count = 0; - self.is_limited = false; - self.is_searching = false; - self.regex_error = None; + self.clear_search_state(); let quick_search = self.quick_search.clone(); cx.defer_in(window, move |_, _window, cx| { if let Some(quick_search) = quick_search.upgrade() { @@ -1294,97 +1538,160 @@ impl PickerDelegate for QuickSearchDelegate { return; }; - let mut results = SearchResults { - items: Vec::with_capacity(MAX_LINE_MATCHES + MAX_LINE_MATCHES / 10), - line_match_count: 0, - file_count: 0, - is_limited: false, + picker + .update(cx, |picker, cx| { + if picker.delegate.pending_search_id == search_id { + picker.delegate.reset_for_new_search(); + cx.notify(); + } + }) + .log_err(); + + let mut counters = BatchCounters { + total_files: 0, + total_line_matches: 0, + search_limited: false, }; + let mut is_first_batch = true; - let mut project_search_results = pin!(project_search_results); - while let Some(result) = project_search_results.next().await { - match result { - project::search::SearchResult::Buffer { buffer, ranges } => { - if ranges.is_empty() { - continue; - } + let mut results_stream = pin!(project_search_results.ready_chunks(STREAM_CHUNK_SIZE)); + while let Some(results) = results_stream.next().await { + let mut batch = SearchResults { + items: Vec::with_capacity(results.len() * 2), + buffers: HashMap::default(), + }; - let file_result = cx - .read_entity(&buffer, |buf, cx| extract_file_matches(buf, &ranges, cx)) - .ok() - .flatten(); + for result in results { + match result { + project::search::SearchResult::Buffer { buffer, ranges } => { + if ranges.is_empty() { + continue; + } - let Some(file_result) = file_result else { - continue; - }; + let file_result = cx + .read_entity(&buffer, |buf, cx| { + extract_file_matches(buf, &ranges, cx) + }) + .log_err() + .flatten(); - results.items.push(QuickSearchItem::FileHeader { - file_name: file_result.file_name, - parent_path: file_result.parent_path, - file_key: file_result.file_key, - }); - results.file_count += 1; - - for match_data in file_result.matches { - results.items.push(QuickSearchItem::LineMatch { - project_path: match_data.project_path, - file_key: match_data.file_key, - line: match_data.line, - line_label: match_data.line_label, - preview_text: match_data.preview_text, - match_ranges: match_data.match_ranges, - }); + let Some(file_result) = file_result else { + continue; + }; + + process_buffer_into_batch( + file_result, + buffer, + &mut batch, + &mut counters, + ); - results.line_match_count += 1; - if results.line_match_count >= MAX_LINE_MATCHES { - results.is_limited = true; + if counters.search_limited { break; } } - - if results.line_match_count >= MAX_LINE_MATCHES { + project::search::SearchResult::LimitReached => { + counters.search_limited = true; break; } } - project::search::SearchResult::LimitReached => { - results.is_limited = true; - break; + } + + if !batch.items.is_empty() { + let first_line_match = if is_first_batch { + batch.first_line_match() + } else { + None + }; + let quick_search_clone = quick_search.clone(); + let is_first = is_first_batch; + let total_line_matches = counters.total_line_matches; + let total_files = counters.total_files; + + let preview_data = picker + .update_in(cx, |picker, _window, cx| { + if picker.delegate.pending_search_id != search_id { + return None; + } + + picker.delegate.items.extend(batch.items); + + for (project_path, buffer) in batch.buffers { + if picker + .delegate + .buffer_subscriptions + .contains_key(&project_path) + { + picker.delegate.buffer_cache.insert(project_path, buffer); + continue; + } + + let pp = project_path.clone(); + let subscription = + cx.subscribe(&buffer, move |picker, _buffer, event, cx| { + if matches!(event, BufferEvent::Reparsed) { + picker + .delegate + .update_syntax_highlights_for_buffer(&pp, cx); + cx.notify(); + } + }); + picker + .delegate + .buffer_subscriptions + .insert(project_path.clone(), subscription); + picker.delegate.buffer_cache.insert(project_path, buffer); + } + + picker.delegate.update_visible_indices(); + picker.delegate.match_count = total_line_matches; + picker.delegate.file_count = total_files; + + if is_first { + let first_selectable = picker + .delegate + .visible_indices + .iter() + .position(|&actual_idx| { + matches!( + picker.delegate.items.get(actual_idx), + Some(QuickSearchItem::LineMatch { .. }) + ) + }) + .unwrap_or(0); + picker.delegate.selected_index = first_selectable; + } + cx.notify(); + + first_line_match + }) + .ok() + .flatten(); + + if let Some(first_match) = preview_data { + if let Some(quick_search) = quick_search_clone.upgrade() { + quick_search + .update_in(cx, |qs, window, cx| { + qs.update_preview(Some(first_match), window, cx); + }) + .log_err(); + } } + + is_first_batch = false; } - } - let first_line_match = results.first_line_match(); + if counters.search_limited { + break; + } + } picker - .update_in(cx, |picker, window, cx| { + .update(cx, |picker, cx| { if picker.delegate.pending_search_id == search_id { - picker.delegate.items = results.items; - picker.delegate.update_visible_indices(); - - let first_selectable = picker - .delegate - .visible_indices - .iter() - .position(|&actual_idx| { - matches!( - picker.delegate.items.get(actual_idx), - Some(QuickSearchItem::LineMatch { .. }) - ) - }) - .unwrap_or(0); - - picker.delegate.selected_index = first_selectable; - picker.delegate.match_count = results.line_match_count; - picker.delegate.file_count = results.file_count; - picker.delegate.is_limited = results.is_limited; + picker.delegate.is_limited = counters.search_limited; picker.delegate.is_searching = false; cx.notify(); - - if let Some(quick_search) = quick_search.upgrade() { - quick_search.update(cx, |qs, cx| { - qs.update_preview(first_line_match, window, cx); - }); - } } }) .log_err(); @@ -1456,8 +1763,18 @@ impl PickerDelegate for QuickSearchDelegate { QuickSearchItem::LineMatch { line_label, preview_text, + match_positions, + syntax_highlights, .. - } => Some(self.render_line_match(ix, selected, line_label, preview_text)), + } => Some(self.render_line_match( + ix, + selected, + line_label, + preview_text, + match_positions, + syntax_highlights, + cx, + )), } } @@ -1481,10 +1798,8 @@ impl PickerDelegate for QuickSearchDelegate { ); } - if self.match_count > 0 || self.is_searching { - let results_text = if self.is_searching { - "Searching…".to_string() - } else if self.is_limited { + if self.match_count > 0 { + let results_text = if self.is_limited { format!("{}+ results (limited)", self.match_count) } else { let result_word = if self.match_count == 1 { @@ -1708,6 +2023,9 @@ mod tests { line_label: SharedString::from((line + 1).to_string()), preview_text: SharedString::from(preview.to_string()), match_ranges: Arc::new(Vec::new()), + match_positions: Arc::new(Vec::new()), + trim_start: 0, + syntax_highlights: None, } } From 32d222efa619a084949d6143fe4a030d17ff3e23 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Thu, 18 Dec 2025 20:09:53 +0100 Subject: [PATCH 22/35] Adds quick search cancellation support Implements cancellation support for the quick search feature to prevent unnecessary work when the query changes rapidly. Shares an atomic boolean between the main thread and worker threads to signal cancellation. Adds background processing of results to keep the UI responsive. --- crates/project/src/project_search.rs | 32 ++++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/crates/project/src/project_search.rs b/crates/project/src/project_search.rs index 2efe0b73688a09..852b8e5ff007ac 100644 --- a/crates/project/src/project_search.rs +++ b/crates/project/src/project_search.rs @@ -5,7 +5,10 @@ use std::{ ops::Range, path::{Path, PathBuf}, pin::pin, - sync::Arc, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, }; use anyhow::Context; @@ -347,8 +350,12 @@ impl Search { }; let ensure_matches_are_reported_in_order = if should_find_all_matches { Some( - Self::ensure_matched_ranges_are_reported_in_order(sorted_matches_rx, tx) - .boxed_local(), + Self::ensure_matched_ranges_are_reported_in_order( + sorted_matches_rx, + tx, + &cancelled, + ) + .boxed_local(), ) } else { drop(tx); @@ -522,12 +529,17 @@ impl Search { async fn ensure_matched_ranges_are_reported_in_order( rx: Receiver, Vec>)>>, tx: Sender, + cancelled: &AtomicBool, ) { use postage::stream::Stream; _ = maybe!(async move { let mut matched_buffers = 0; let mut matches = 0; while let Ok(mut next_buffer_matches) = rx.recv().await { + if cancelled.load(Ordering::Relaxed) { + break; + } + let Some((buffer, ranges)) = next_buffer_matches.recv().await else { continue; }; @@ -541,7 +553,14 @@ impl Search { matched_buffers += 1; matches += ranges.len(); - _ = tx.send(SearchResult::Buffer { buffer, ranges }).await?; + if tx + .send(SearchResult::Buffer { buffer, ranges }) + .await + .is_err() + { + cancelled.store(true, Ordering::Relaxed); + break; + } } anyhow::Ok(()) }) @@ -599,6 +618,7 @@ struct Worker { BufferSnapshot, oneshot::Sender<(Entity, Vec>)>, )>, + cancelled: &'search AtomicBool, } impl Worker { @@ -631,6 +651,10 @@ impl Worker { let mut scan_path = pin!(input_paths_rx.fuse()); loop { + if self.cancelled.load(Ordering::Relaxed) { + break; + } + let handler = RequestHandler { query: &self.query, open_entries: &self.open_buffers, From 65c71aee198188ad8cc1026ad0eb6d3c98d06d9a Mon Sep 17 00:00:00 2001 From: David Bonan Date: Thu, 18 Dec 2025 20:11:57 +0100 Subject: [PATCH 23/35] Improves quick search performance Reduces UI latency by processing search results in batches, prioritizing initial results for faster display and preview. Introduces a cancellation mechanism to prevent processing stale searches, and optimizes text preview generation by safely handling character boundaries. Also computes search debounce based on project file count to improve responsiveness. --- crates/search/src/quick_search.rs | 975 +++++++++++++++++++----------- 1 file changed, 627 insertions(+), 348 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 7653d36c526e2d..7eb9f44da6a31d 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -10,7 +10,15 @@ use gpui::{ use language::{Buffer, BufferEvent, HighlightId}; use picker::{Picker, PickerDelegate}; use project::{Project, ProjectPath, search::SearchQuery}; -use std::{path::Path, pin::pin, sync::Arc, time::Duration}; +use std::{ + path::Path, + pin::pin, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; use text::{ToOffset as _, ToPoint as _}; use ui::{ Button, ButtonStyle, Color, Divider, Icon, IconButton, IconButtonShape, IconName, KeyBinding, @@ -28,98 +36,67 @@ use crate::{ type AnchorRange = std::ops::Range; -const MODAL_HEIGHT: Pixels = px(800.); -const MODAL_WIDTH: Pixels = px(1400.); -const LEFT_PANEL_WIDTH: Pixels = px(400.); -const MAX_PREVIEW_BYTES: usize = 200; -const SEARCH_DEBOUNCE_MS: u64 = 100; -const PREVIEW_DEBOUNCE_MS: u64 = 50; -const EDIT_OPEN_DELAY_MS: u64 = 200; -const STREAM_CHUNK_SIZE: usize = 1024; -const MAX_LINES_PER_FILE: usize = 800; -const MAX_SEARCH_RESULT_FILES: usize = 5_000; -const MAX_SEARCH_RESULT_RANGES: usize = 10_000; - -actions!(search, [QuickSearch]); - -struct LineMatchData { - project_path: ProjectPath, - file_key: SharedString, - line: u32, - line_label: SharedString, - preview_text: SharedString, - match_ranges: Arc>, - match_positions: Arc>>, - trim_start: usize, - syntax_highlights: Option, HighlightId)>>>, -} - -enum QuickSearchHighlights {} - -struct FileMatchResult { - file_name: SharedString, - parent_path: SharedString, - file_key: SharedString, - matches: Vec, -} - -fn truncate_preview(text: &str, max_bytes: usize) -> SharedString { - let trimmed = text.trim(); - if trimmed.len() <= max_bytes { - return trimmed.to_string().into(); +fn find_safe_char_boundaries(text: &str, start: usize, end: usize) -> Option<(usize, usize)> { + let mut safe_start = start.min(text.len()); + while safe_start > 0 && !text.is_char_boundary(safe_start) { + safe_start -= 1; } - let mut end = max_bytes; - while end > 0 && !trimmed.is_char_boundary(end) { - end -= 1; + let mut safe_end = end.min(text.len()); + while safe_end < text.len() && !text.is_char_boundary(safe_end) { + safe_end += 1; } - let mut result = trimmed[..end].to_string(); - result.push('…'); - result.into() + if safe_start < safe_end { + Some((safe_start, safe_end)) + } else { + None + } } -#[inline] -fn preview_content_len(preview_text: &str) -> usize { - preview_text - .len() - .saturating_sub(if preview_text.ends_with('…') { - '…'.len_utf8() - } else { - 0 - }) +struct BufferExtractData { + worktree_id: project::WorktreeId, + path: Arc, + snapshot: language::BufferSnapshot, + ranges: Vec, } -fn extract_file_matches(buf: &Buffer, ranges: &[AnchorRange], cx: &App) -> Option { +fn extract_buffer_data( + buf: &Buffer, + ranges: Vec, + cx: &App, +) -> Option { let file = buf.file()?; - let project_path = ProjectPath { + Some(BufferExtractData { worktree_id: file.worktree_id(cx), path: file.path().clone(), - }; - - let (file_name, parent_path) = extract_path_parts(&project_path.path); - let file_key = format_file_key(&parent_path, &file_name); - - let snapshot = buf.snapshot(); + snapshot: buf.snapshot(), + ranges, + }) +} - struct LineInfo { - line_start_offset: usize, - line_text: String, - trim_start: usize, - match_ranges: Vec, - } +struct LineInfo { + line_start_offset: usize, + line_text: String, + trim_start: usize, + match_ranges: Vec, +} +fn group_ranges_by_line( + ranges: &[AnchorRange], + snapshot: &language::BufferSnapshot, +) -> (HashMap, Vec) { let estimated_lines = ranges.len().min(MAX_LINES_PER_FILE); let mut lines_data: HashMap = HashMap::default(); lines_data.reserve(estimated_lines); let mut line_order = Vec::with_capacity(estimated_lines); for range in ranges { - let start_point = range.start.to_point(&snapshot); + let start_point = range.start.to_point(snapshot); let line = start_point.row; - if let Some(data) = lines_data.get_mut(&line) { - data.match_ranges.push(range.clone()); + if let Some(line_data) = lines_data.get_mut(&line) { + line_data.match_ranges.push(range.clone()); } else { let line_start = snapshot.point_to_offset(text::Point::new(line, 0)); let line_end_col = snapshot.line_len(line); @@ -145,6 +122,68 @@ fn extract_file_matches(buf: &Buffer, ranges: &[AnchorRange], cx: &App) -> Optio } } + (lines_data, line_order) +} + +fn create_line_match_data( + line: u32, + info: LineInfo, + snapshot: &language::BufferSnapshot, + project_path: &ProjectPath, + file_key: &SharedString, +) -> LineMatchData { + let preview_text = truncate_preview(&info.line_text, MAX_PREVIEW_BYTES); + let preview_len = preview_content_len(&preview_text); + let line_label: SharedString = (line + 1).to_string().into(); + + let mut match_positions = Vec::new(); + let preview_str: &str = preview_text.as_ref(); + for range in &info.match_ranges { + let match_start_offset = range.start.to_offset(snapshot); + let match_end_offset = range.end.to_offset(snapshot); + + let start_in_line = match_start_offset.saturating_sub(info.line_start_offset); + let end_in_line = match_end_offset.saturating_sub(info.line_start_offset); + + let start_in_preview = start_in_line.saturating_sub(info.trim_start); + let end_in_preview = end_in_line.saturating_sub(info.trim_start); + + if start_in_preview < preview_len && end_in_preview > 0 { + let clamped_start = start_in_preview.min(preview_len); + let clamped_end = end_in_preview.min(preview_len); + if let Some((safe_start, safe_end)) = + find_safe_char_boundaries(preview_str, clamped_start, clamped_end) + { + match_positions.push(safe_start..safe_end); + } + } + } + + LineMatchData { + project_path: project_path.clone(), + file_key: file_key.clone(), + line, + line_label, + preview_text, + match_ranges: Arc::new(info.match_ranges), + match_positions: Arc::new(match_positions), + trim_start: info.trim_start, + syntax_highlights: None, + } +} + +fn process_file_matches(data: BufferExtractData) -> Option { + let project_path = ProjectPath { + worktree_id: data.worktree_id, + path: data.path.clone(), + }; + + let (file_name, parent_path) = extract_path_parts(&data.path); + let file_key = format_file_key(&parent_path, &file_name); + let snapshot = &data.snapshot; + + let (mut lines_data, line_order) = group_ranges_by_line(&data.ranges, snapshot); + if line_order.is_empty() { return None; } @@ -153,60 +192,13 @@ fn extract_file_matches(buf: &Buffer, ranges: &[AnchorRange], cx: &App) -> Optio .into_iter() .filter_map(|line| { let info = lines_data.remove(&line)?; - let preview_text = truncate_preview(&info.line_text, MAX_PREVIEW_BYTES); - let preview_len = preview_content_len(&preview_text); - let line_label: SharedString = (line + 1).to_string().into(); - - let mut match_positions = Vec::new(); - let preview_str: &str = preview_text.as_ref(); - for range in &info.match_ranges { - let match_start_offset = range.start.to_offset(&snapshot); - let match_end_offset = range.end.to_offset(&snapshot); - - let start_in_line = match_start_offset.saturating_sub(info.line_start_offset); - let end_in_line = match_end_offset.saturating_sub(info.line_start_offset); - - let start_in_preview = start_in_line.saturating_sub(info.trim_start); - let end_in_preview = end_in_line.saturating_sub(info.trim_start); - - if start_in_preview < preview_len && end_in_preview > 0 { - let clamped_start = start_in_preview.min(preview_len); - let clamped_end = end_in_preview.min(preview_len); - if clamped_start < clamped_end { - let mut safe_start = clamped_start.min(preview_str.len()); - while safe_start > 0 && !preview_str.is_char_boundary(safe_start) { - safe_start -= 1; - } - - let mut safe_end = clamped_end.min(preview_str.len()); - while safe_end < preview_str.len() - && !preview_str.is_char_boundary(safe_end) - { - safe_end += 1; - } - - if safe_start < safe_end { - match_positions.push(safe_start..safe_end); - } - } - } - } - - let syntax_highlights = - extract_syntax_highlights_for_line(&snapshot, line, info.trim_start, preview_len) - .map(Arc::new); - - Some(LineMatchData { - project_path: project_path.clone(), - file_key: file_key.clone(), + Some(create_line_match_data( line, - line_label, - preview_text, - match_ranges: Arc::new(info.match_ranges), - match_positions: Arc::new(match_positions), - trim_start: info.trim_start, - syntax_highlights, - }) + info, + snapshot, + &project_path, + &file_key, + )) }) .collect(); @@ -218,12 +210,97 @@ fn extract_file_matches(buf: &Buffer, ranges: &[AnchorRange], cx: &App) -> Optio }) } +const MODAL_HEIGHT: Pixels = px(800.); +const MODAL_WIDTH: Pixels = px(1400.); +const LEFT_PANEL_WIDTH: Pixels = px(400.); +const MAX_PREVIEW_BYTES: usize = 200; +const PREVIEW_DEBOUNCE_MS: u64 = 50; +const EDIT_OPEN_DELAY_MS: u64 = 200; +const STREAM_CHUNK_SIZE: usize = 64; +const FIRST_BATCH_THRESHOLD: usize = 16; +const BACKGROUND_BATCH_THRESHOLD: usize = 128; +const MAX_LINES_PER_FILE: usize = 800; +const MAX_SEARCH_RESULT_FILES: usize = 5_000; +const MAX_SEARCH_RESULT_RANGES: usize = 10_000; + +fn compute_search_debounce_ms(file_count: usize) -> u64 { + match file_count { + 0..100 => 0, + 100..1_000 => 50, + 1_000..10_000 => 100, + 10_000..50_000 => 150, + _ => 200, + } +} + +fn get_project_file_count(project: &Project, cx: &App) -> usize { + project + .worktrees(cx) + .map(|worktree| worktree.read(cx).snapshot().file_count()) + .sum() +} + +actions!(search, [QuickSearch]); + +struct LineMatchData { + project_path: ProjectPath, + file_key: SharedString, + line: u32, + line_label: SharedString, + preview_text: SharedString, + match_ranges: Arc>, + match_positions: Arc>>, + trim_start: usize, + syntax_highlights: Option, HighlightId)>>>, +} + +enum QuickSearchHighlights {} + +struct FileMatchResult { + file_name: SharedString, + parent_path: SharedString, + file_key: SharedString, + matches: Vec, +} + +fn truncate_preview(text: &str, max_bytes: usize) -> SharedString { + let trimmed = text.trim(); + if trimmed.len() <= max_bytes { + return trimmed.to_string().into(); + } + + let mut end = max_bytes; + while end > 0 && !trimmed.is_char_boundary(end) { + end -= 1; + } + + let mut result = trimmed[..end].to_string(); + result.push('…'); + result.into() +} + +#[inline] +fn preview_content_len(preview_text: &str) -> usize { + preview_text + .len() + .saturating_sub(if preview_text.ends_with('…') { + '…'.len_utf8() + } else { + 0 + }) +} + fn extract_syntax_highlights_for_line( snapshot: &language::BufferSnapshot, + preview_text: &str, line: u32, trim_start: usize, - preview_len: usize, ) -> Option, HighlightId)>> { + let preview_len = preview_content_len(preview_text); + if preview_len == 0 { + return None; + } + let line_start = snapshot.point_to_offset(text::Point::new(line, 0)); let line_len = snapshot.line_len(line); let line_end = snapshot.point_to_offset(text::Point::new(line, line_len)); @@ -245,8 +322,10 @@ fn extract_syntax_highlights_for_line( let clamped_start = rel_start.min(preview_len); let clamped_end = rel_end.min(preview_len); - if clamped_start < clamped_end { - highlights.push((clamped_start..clamped_end, highlight_id)); + if let Some((safe_start, safe_end)) = + find_safe_char_boundaries(preview_text, clamped_start, clamped_end) + { + highlights.push((safe_start..safe_end, highlight_id)); } } } @@ -293,17 +372,7 @@ enum QuickSearchItem { parent_path: SharedString, file_key: SharedString, }, - LineMatch { - project_path: ProjectPath, - file_key: SharedString, - line: u32, - line_label: SharedString, - preview_text: SharedString, - match_ranges: Arc>, - match_positions: Arc>>, - trim_start: usize, - syntax_highlights: Option, HighlightId)>>>, - }, + LineMatch(LineMatchData), } pub struct QuickSearchDelegate { @@ -315,7 +384,7 @@ pub struct QuickSearchDelegate { visible_line_match_indices: Vec, collapsed_files: HashSet, selected_index: usize, - pending_search_id: usize, + search_cancelled: Option>, quick_search: WeakEntity, match_count: usize, file_count: usize, @@ -404,7 +473,7 @@ impl Render for QuickSearchModal { .overflow_hidden() .bg(cx.theme().colors().editor_background) .on_click(move |_, window, cx| { - window.focus(&picker.focus_handle(cx)); + window.focus(&picker.focus_handle(cx), cx); }) .on_action({ move |_: &Save, window, cx| { @@ -538,7 +607,7 @@ impl QuickSearchModal { visible_line_match_indices: Vec::new(), collapsed_files: HashSet::default(), selected_index: 0, - pending_search_id: 0, + search_cancelled: None, quick_search: weak_self, match_count: 0, file_count: 0, @@ -623,6 +692,9 @@ impl QuickSearchModal { return; }; + // Delay before opening the file in the workspace to handle focus transitions: + // when the file opens in the workspace, it steals focus from the Quick Search. + // After opening, we restore focus to the preview editor so the user can continue editing. self._open_in_workspace_task = Some(cx.spawn_in(window, async move |this, cx| { cx.background_executor() .timer(Duration::from_millis(EDIT_OPEN_DELAY_MS)) @@ -646,7 +718,7 @@ impl QuickSearchModal { cx.spawn_in(window, async move |_, cx| { let _ = open_task.await; cx.update(|window, cx| { - window.focus(&preview_editor.focus_handle(cx)); + window.focus(&preview_editor.focus_handle(cx), cx); }) .log_err(); }) @@ -876,17 +948,7 @@ fn process_buffer_into_batch( counters.total_files += 1; for match_data in file_result.matches { - batch.items.push(QuickSearchItem::LineMatch { - project_path: match_data.project_path, - file_key: match_data.file_key, - line: match_data.line, - line_label: match_data.line_label, - preview_text: match_data.preview_text, - match_ranges: match_data.match_ranges, - match_positions: match_data.match_positions, - trim_start: match_data.trim_start, - syntax_highlights: match_data.syntax_highlights, - }); + batch.items.push(QuickSearchItem::LineMatch(match_data)); counters.total_line_matches += 1; } @@ -897,21 +959,194 @@ fn process_buffer_into_batch( } } -impl SearchResults { - fn first_line_match(&self) -> Option<(ProjectPath, u32, Arc>)> { - self.items.iter().find_map(|item| { - if let QuickSearchItem::LineMatch { - project_path, - line, - match_ranges, - .. - } = item - { - Some((project_path.clone(), *line, match_ranges.clone())) +fn process_results_in_background( + buffer_data_list: Vec<(BufferExtractData, Entity)>, +) -> Vec<(FileMatchResult, Entity)> { + buffer_data_list + .into_iter() + .filter_map(|(data, buffer)| process_file_matches(data).map(|result| (result, buffer))) + .collect() +} + +fn apply_batch_to_picker( + delegate: &mut QuickSearchDelegate, + batch: SearchResults, + total_line_matches: usize, + total_files: usize, + is_first: bool, + cx: &mut Context>, +) -> Option<(ProjectPath, u32, Arc>)> { + let prev_items_len = delegate.items.len(); + delegate.items.extend(batch.items); + + for (project_path, buffer) in batch.buffers { + if delegate.buffer_subscriptions.contains_key(&project_path) { + delegate.buffer_cache.insert(project_path, buffer); + continue; + } + + let pp = project_path.clone(); + let subscription = cx.subscribe(&buffer, move |picker, _buffer, event, cx| { + if matches!(event, BufferEvent::Reparsed) { + picker.delegate.update_syntax_highlights_for_buffer(&pp, cx); + cx.notify(); + } + }); + delegate + .buffer_subscriptions + .insert(project_path.clone(), subscription); + delegate.buffer_cache.insert(project_path, buffer); + } + + delegate.update_visible_indices_from(prev_items_len); + delegate.match_count = total_line_matches; + delegate.file_count = total_files; + + if is_first { + let first_selectable = delegate + .visible_indices + .iter() + .position(|&actual_idx| { + matches!( + delegate.items.get(actual_idx), + Some(QuickSearchItem::LineMatch(_)) + ) + }) + .unwrap_or(0); + delegate.selected_index = first_selectable; + } + + cx.notify(); + + if is_first { + delegate.items.iter().find_map(|item| { + if let QuickSearchItem::LineMatch(data) = item { + Some((data.project_path.clone(), data.line, data.match_ranges.clone())) } else { None } }) + } else { + None + } +} + +fn trigger_preview_update( + quick_search: &WeakEntity, + preview_data: Option<(ProjectPath, u32, Arc>)>, + cx: &mut gpui::AsyncWindowContext, +) { + if let Some(first_match) = preview_data { + if let Some(quick_search) = quick_search.upgrade() { + quick_search + .update_in(cx, |qs, window, cx| { + qs.update_preview(Some(first_match), window, cx); + }) + .log_err(); + } + } +} + +async fn process_and_apply_batch( + buffer_data: Vec<(BufferExtractData, Entity)>, + counters: &mut BatchCounters, + is_first_batch: &mut bool, + limit_reached: bool, + picker: &WeakEntity>, + quick_search: &WeakEntity, + cancelled: &AtomicBool, + cx: &mut gpui::AsyncWindowContext, +) { + let processed_results = cx + .background_executor() + .spawn(async move { process_results_in_background(buffer_data) }) + .await; + + if cancelled.load(Ordering::Relaxed) { + return; + } + + let mut batch = SearchResults { + items: Vec::with_capacity(processed_results.len() * 2), + buffers: HashMap::default(), + }; + + for (file_result, buffer) in processed_results { + process_buffer_into_batch(file_result, buffer, &mut batch, counters); + if counters.search_limited { + break; + } + } + + if limit_reached { + counters.search_limited = true; + } + + if !batch.items.is_empty() { + let is_first = *is_first_batch; + let total_line_matches = counters.total_line_matches; + let total_files = counters.total_files; + + let preview_data = picker + .update_in(cx, |picker, _window, cx| { + if cancelled.load(Ordering::Relaxed) { + return None; + } + apply_batch_to_picker( + &mut picker.delegate, + batch, + total_line_matches, + total_files, + is_first, + cx, + ) + }) + .ok() + .flatten(); + + trigger_preview_update(quick_search, preview_data, cx); + *is_first_batch = false; + } +} + +struct PendingBufferData { + list: Vec<(BufferExtractData, Entity)>, + limit_reached: bool, + first_batch_sent: bool, +} + +impl PendingBufferData { + fn new() -> Self { + Self { + list: Vec::with_capacity(BACKGROUND_BATCH_THRESHOLD), + limit_reached: false, + first_batch_sent: false, + } + } + + fn len(&self) -> usize { + self.list.len() + } + + fn should_process(&self) -> bool { + if self.limit_reached { + return true; + } + let threshold = if self.first_batch_sent { + BACKGROUND_BATCH_THRESHOLD + } else { + FIRST_BATCH_THRESHOLD + }; + self.len() >= threshold + } + + fn take(&mut self) -> Vec<(BufferExtractData, Entity)> { + self.first_batch_sent = true; + std::mem::take(&mut self.list) + } + + fn is_empty(&self) -> bool { + self.list.is_empty() } } @@ -948,7 +1183,9 @@ impl QuickSearchDelegate { self.items.clear(); self.visible_indices.clear(); self.visible_line_match_indices.clear(); - self.pending_search_id = 0; + if let Some(cancelled) = self.search_cancelled.take() { + cancelled.store(true, Ordering::Relaxed); + } self.match_count = 0; self.file_count = 0; self.is_limited = false; @@ -970,18 +1207,24 @@ impl QuickSearchDelegate { } fn update_visible_indices(&mut self) { - self.visible_indices.clear(); - self.visible_indices.reserve(self.items.len()); - self.visible_line_match_indices.clear(); - self.visible_line_match_indices.reserve(self.items.len()); + self.update_visible_indices_from(0); + } + + fn update_visible_indices_from(&mut self, start_index: usize) { + if start_index == 0 { + self.visible_indices.clear(); + self.visible_indices.reserve(self.items.len()); + self.visible_line_match_indices.clear(); + self.visible_line_match_indices.reserve(self.items.len()); + } - for (idx, item) in self.items.iter().enumerate() { + for (idx, item) in self.items.iter().enumerate().skip(start_index) { match item { QuickSearchItem::FileHeader { .. } => { self.visible_indices.push(idx); } - QuickSearchItem::LineMatch { file_key, .. } => { - if !self.collapsed_files.contains(file_key) { + QuickSearchItem::LineMatch(data) => { + if !self.collapsed_files.contains(&data.file_key) { let visible_idx = self.visible_indices.len(); self.visible_indices.push(idx); self.visible_line_match_indices.push(visible_idx); @@ -1022,11 +1265,10 @@ impl QuickSearchDelegate { let mut indices_to_remove = Vec::new(); for (visible_idx, &actual_idx) in self.visible_indices.iter().enumerate() { - if matches!( - self.items.get(actual_idx), - Some(QuickSearchItem::LineMatch { file_key: fk, .. }) if fk == file_key - ) { - indices_to_remove.push(visible_idx); + if let Some(QuickSearchItem::LineMatch(data)) = self.items.get(actual_idx) { + if &data.file_key == file_key { + indices_to_remove.push(visible_idx); + } } } @@ -1057,7 +1299,7 @@ impl QuickSearchDelegate { .enumerate() .skip(header_actual_idx + 1) .take_while(|(_, item)| { - matches!(item, QuickSearchItem::LineMatch { file_key: fk, .. } if fk == file_key) + matches!(item, QuickSearchItem::LineMatch(data) if &data.file_key == file_key) }) .map(|(idx, _)| idx) .collect(); @@ -1076,7 +1318,7 @@ impl QuickSearchDelegate { for (visible_idx, &actual_idx) in self.visible_indices.iter().enumerate() { if matches!( self.items.get(actual_idx), - Some(QuickSearchItem::LineMatch { .. }) + Some(QuickSearchItem::LineMatch(_)) ) { self.visible_line_match_indices.push(visible_idx); } @@ -1095,22 +1337,13 @@ impl QuickSearchDelegate { let snapshot = buffer.read(cx).snapshot(); for item in &mut self.items { - if let QuickSearchItem::LineMatch { - project_path: pp, - line, - trim_start, - preview_text, - syntax_highlights, - .. - } = item - { - if pp == project_path && syntax_highlights.is_none() { - let preview_len = preview_content_len(preview_text); - *syntax_highlights = extract_syntax_highlights_for_line( + if let QuickSearchItem::LineMatch(data) = item { + if &data.project_path == project_path && data.syntax_highlights.is_none() { + data.syntax_highlights = extract_syntax_highlights_for_line( &snapshot, - *line, - *trim_start, - preview_len, + &data.preview_text, + data.line, + data.trim_start, ) .map(Arc::new); } @@ -1129,7 +1362,7 @@ impl QuickSearchDelegate { .get(visible_index) .and_then(|&actual_idx| self.items.get(actual_idx)) .map_or(false, |item| { - matches!(item, QuickSearchItem::LineMatch { .. }) + matches!(item, QuickSearchItem::LineMatch(_)) }) } @@ -1203,7 +1436,7 @@ impl QuickSearchDelegate { cx.stop_propagation(); if let Some(qs) = quick_search.upgrade() { qs.update(cx, |qs, cx| { - window.focus(&qs.picker.focus_handle(cx)); + window.focus(&qs.picker.focus_handle(cx), cx); qs.picker.update(cx, |picker, cx| { if event.modifiers().alt { picker.delegate.toggle_all_files_collapsed(&file_key); @@ -1243,6 +1476,14 @@ impl QuickSearchDelegate { cx: &App, ) -> ListItem { let quick_search = self.quick_search.clone(); + let preview_str: &str = preview_text.as_ref(); + + let is_valid_range = |range: &std::ops::Range| -> bool { + range.start < range.end + && range.end <= preview_str.len() + && preview_str.is_char_boundary(range.start) + && preview_str.is_char_boundary(range.end) + }; let syntax_theme = cx.theme().syntax(); let mut highlights: Vec<(std::ops::Range, HighlightStyle)> = syntax_highlights @@ -1250,6 +1491,9 @@ impl QuickSearchDelegate { .map(|sh| { sh.iter() .filter_map(|(range, id)| { + if !is_valid_range(range) { + return None; + } id.style(&syntax_theme).map(|style| (range.clone(), style)) }) .collect() @@ -1257,6 +1501,9 @@ impl QuickSearchDelegate { .unwrap_or_default(); for range in match_positions.iter() { + if !is_valid_range(range) { + continue; + } let match_style = HighlightStyle { font_weight: Some(gpui::FontWeight::BOLD), ..Default::default() @@ -1279,19 +1526,18 @@ impl QuickSearchDelegate { let delegate = &modal.picker.read(cx).delegate; delegate.actual_index(ix).and_then(|idx| { match delegate.items.get(idx) { - Some(QuickSearchItem::LineMatch { - project_path, - line, - match_ranges, - .. - }) => Some((project_path.clone(), *line, match_ranges.clone())), + Some(QuickSearchItem::LineMatch(data)) => Some(( + data.project_path.clone(), + data.line, + data.match_ranges.clone(), + )), _ => None, } }) }; qs.update(cx, |modal, cx| { - window.focus(&modal.picker.focus_handle(cx)); + window.focus(&modal.picker.focus_handle(cx), cx); modal.picker.update(cx, |picker, cx| { picker.delegate.selected_index = ix; cx.notify(); @@ -1373,12 +1619,9 @@ impl PickerDelegate for QuickSearchDelegate { let quick_search = self.quick_search.clone(); let actual_index = self.actual_index(self.selected_index); let preview_data = actual_index.and_then(|idx| match self.items.get(idx) { - Some(QuickSearchItem::LineMatch { - project_path, - line, - match_ranges, - .. - }) => Some((project_path.clone(), *line, match_ranges.clone())), + Some(QuickSearchItem::LineMatch(data)) => { + Some((data.project_path.clone(), data.line, data.match_ranges.clone())) + } _ => None, }); @@ -1487,21 +1730,27 @@ impl PickerDelegate for QuickSearchDelegate { self.is_searching = true; - self.pending_search_id += 1; - let search_id = self.pending_search_id; + if let Some(prev_cancelled) = self.search_cancelled.take() { + prev_cancelled.store(true, Ordering::Relaxed); + } + let cancelled = Arc::new(AtomicBool::new(false)); + self.search_cancelled = Some(cancelled.clone()); + + let file_count = get_project_file_count(self.project.read(cx), cx); + let debounce_ms = compute_search_debounce_ms(file_count); + let project = self.project.clone(); let search_options = self.search_options; let quick_search = self.quick_search.clone(); cx.spawn_in(window, async move |picker, cx| { - smol::Timer::after(Duration::from_millis(SEARCH_DEBOUNCE_MS)).await; + if debounce_ms > 0 { + cx.background_executor() + .timer(Duration::from_millis(debounce_ms)) + .await; + } - let is_stale = picker - .update(cx, |picker, _| { - picker.delegate.pending_search_id != search_id - }) - .unwrap_or(true); - if is_stale { + if cancelled.load(Ordering::Relaxed) { return; } @@ -1540,7 +1789,7 @@ impl PickerDelegate for QuickSearchDelegate { picker .update(cx, |picker, cx| { - if picker.delegate.pending_search_id == search_id { + if !cancelled.load(Ordering::Relaxed) { picker.delegate.reset_for_new_search(); cx.notify(); } @@ -1553,13 +1802,13 @@ impl PickerDelegate for QuickSearchDelegate { search_limited: false, }; let mut is_first_batch = true; + let mut pending = PendingBufferData::new(); let mut results_stream = pin!(project_search_results.ready_chunks(STREAM_CHUNK_SIZE)); while let Some(results) = results_stream.next().await { - let mut batch = SearchResults { - items: Vec::with_capacity(results.len() * 2), - buffers: HashMap::default(), - }; + if cancelled.load(Ordering::Relaxed) { + return; + } for result in results { match result { @@ -1568,127 +1817,68 @@ impl PickerDelegate for QuickSearchDelegate { continue; } - let file_result = cx + let extract_data = cx .read_entity(&buffer, |buf, cx| { - extract_file_matches(buf, &ranges, cx) + extract_buffer_data(buf, ranges, cx) }) .log_err() .flatten(); - let Some(file_result) = file_result else { - continue; - }; - - process_buffer_into_batch( - file_result, - buffer, - &mut batch, - &mut counters, - ); - - if counters.search_limited { - break; + if let Some(data) = extract_data { + pending.list.push((data, buffer)); } } project::search::SearchResult::LimitReached => { - counters.search_limited = true; + pending.limit_reached = true; break; } } } - if !batch.items.is_empty() { - let first_line_match = if is_first_batch { - batch.first_line_match() - } else { - None - }; - let quick_search_clone = quick_search.clone(); - let is_first = is_first_batch; - let total_line_matches = counters.total_line_matches; - let total_files = counters.total_files; - - let preview_data = picker - .update_in(cx, |picker, _window, cx| { - if picker.delegate.pending_search_id != search_id { - return None; - } - - picker.delegate.items.extend(batch.items); - - for (project_path, buffer) in batch.buffers { - if picker - .delegate - .buffer_subscriptions - .contains_key(&project_path) - { - picker.delegate.buffer_cache.insert(project_path, buffer); - continue; - } - - let pp = project_path.clone(); - let subscription = - cx.subscribe(&buffer, move |picker, _buffer, event, cx| { - if matches!(event, BufferEvent::Reparsed) { - picker - .delegate - .update_syntax_highlights_for_buffer(&pp, cx); - cx.notify(); - } - }); - picker - .delegate - .buffer_subscriptions - .insert(project_path.clone(), subscription); - picker.delegate.buffer_cache.insert(project_path, buffer); - } - - picker.delegate.update_visible_indices(); - picker.delegate.match_count = total_line_matches; - picker.delegate.file_count = total_files; - - if is_first { - let first_selectable = picker - .delegate - .visible_indices - .iter() - .position(|&actual_idx| { - matches!( - picker.delegate.items.get(actual_idx), - Some(QuickSearchItem::LineMatch { .. }) - ) - }) - .unwrap_or(0); - picker.delegate.selected_index = first_selectable; - } - cx.notify(); - - first_line_match - }) - .ok() - .flatten(); - - if let Some(first_match) = preview_data { - if let Some(quick_search) = quick_search_clone.upgrade() { - quick_search - .update_in(cx, |qs, window, cx| { - qs.update_preview(Some(first_match), window, cx); - }) - .log_err(); - } - } - - is_first_batch = false; + if !pending.should_process() { + continue; } + let buffer_data_to_process = pending.take(); + let limit_reached = pending.limit_reached; + + process_and_apply_batch( + buffer_data_to_process, + &mut counters, + &mut is_first_batch, + limit_reached, + &picker, + &quick_search, + &cancelled, + cx, + ) + .await; + if counters.search_limited { break; } } + if !pending.is_empty() && !cancelled.load(Ordering::Relaxed) { + let buffer_data_to_process = pending.take(); + let limit_reached = pending.limit_reached; + + process_and_apply_batch( + buffer_data_to_process, + &mut counters, + &mut is_first_batch, + limit_reached, + &picker, + &quick_search, + &cancelled, + cx, + ) + .await; + } + picker .update(cx, |picker, cx| { - if picker.delegate.pending_search_id == search_id { + if !cancelled.load(Ordering::Relaxed) { picker.delegate.is_limited = counters.search_limited; picker.delegate.is_searching = false; cx.notify(); @@ -1704,15 +1894,12 @@ impl PickerDelegate for QuickSearchDelegate { None => return, }; - let Some(QuickSearchItem::LineMatch { - project_path, line, .. - }) = self.items.get(actual_index) - else { + let Some(QuickSearchItem::LineMatch(data)) = self.items.get(actual_index) else { return; }; - let project_path = project_path.clone(); - let line = *line; + let project_path = data.project_path.clone(); + let line = data.line; if let Some(workspace) = self.workspace.upgrade() { workspace.update(cx, |workspace, cx| { @@ -1760,19 +1947,13 @@ impl PickerDelegate for QuickSearchDelegate { parent_path, file_key, } => Some(self.render_file_header(ix, file_name, parent_path, file_key, cx)), - QuickSearchItem::LineMatch { - line_label, - preview_text, - match_positions, - syntax_highlights, - .. - } => Some(self.render_line_match( + QuickSearchItem::LineMatch(data) => Some(self.render_line_match( ix, selected, - line_label, - preview_text, - match_positions, - syntax_highlights, + &data.line_label, + &data.preview_text, + &data.match_positions, + &data.syntax_highlights, cx, )), } @@ -2013,7 +2194,7 @@ mod tests { } fn line_match(file_key: &str, line: u32, preview: &str) -> QuickSearchItem { - QuickSearchItem::LineMatch { + QuickSearchItem::LineMatch(LineMatchData { project_path: ProjectPath { worktree_id: project::WorktreeId::from_usize(0), path: util::rel_path::rel_path(file_key).into(), @@ -2026,7 +2207,7 @@ mod tests { match_positions: Arc::new(Vec::new()), trim_start: 0, syntax_highlights: None, - } + }) } #[gpui::test] @@ -2045,12 +2226,12 @@ mod tests { let mut fixture = TestFixture::new(cx, json!({"file.rs": "fn test() {}\n"})).await; fixture.set_query("test"); - assert_eq!(fixture.delegate(|d| d.pending_search_id), 1); + assert!(fixture.delegate(|d| d.search_cancelled.is_some())); fixture.search("").await; fixture.delegate(|d| { assert_eq!(d.items.len(), 0); - assert_eq!(d.pending_search_id, 0); + assert!(d.search_cancelled.is_none()); }); } @@ -2063,17 +2244,22 @@ mod tests { } #[gpui::test] - async fn test_quick_search_query_updates_search_id(cx: &mut TestAppContext) { + async fn test_quick_search_query_sets_cancellation_flag(cx: &mut TestAppContext) { let mut fixture = TestFixture::new(cx, json!({"file.rs": "fn hello() {}\nfn world() {}\n"})).await; - assert_eq!(fixture.delegate(|d| d.pending_search_id), 0); + assert!(fixture.delegate(|d| d.search_cancelled.is_none())); fixture.set_query("hello"); - assert_eq!(fixture.delegate(|d| d.pending_search_id), 1); + let first_cancelled = fixture.delegate(|d| d.search_cancelled.clone()); + assert!(first_cancelled.is_some()); + assert!(!first_cancelled.as_ref().unwrap().load(Ordering::Relaxed)); fixture.set_query("world"); - assert_eq!(fixture.delegate(|d| d.pending_search_id), 2); + let second_cancelled = fixture.delegate(|d| d.search_cancelled.clone()); + assert!(second_cancelled.is_some()); + assert!(first_cancelled.as_ref().unwrap().load(Ordering::Relaxed)); + assert!(!second_cancelled.as_ref().unwrap().load(Ordering::Relaxed)); } #[gpui::test] @@ -2311,4 +2497,97 @@ mod tests { fixture.search("hello").await; assert!(fixture.delegate(|d| d.match_count) > 0); } + + #[gpui::test] + async fn test_quick_search_many_matches(cx: &mut TestAppContext) { + let content = (0..500) + .map(|i| format!("fn test_function_{}() {{}}", i)) + .collect::>() + .join("\n"); + + let mut fixture = TestFixture::new(cx, json!({ "large_file.rs": content })).await; + + fixture.search("test_function").await; + + let (match_count, file_count) = + fixture.delegate(|d| (d.match_count, d.file_count)); + + assert_eq!(match_count, 500); + assert_eq!(file_count, 1); + } + + #[gpui::test] + async fn test_quick_search_rapid_query_updates(cx: &mut TestAppContext) { + let mut fixture = TestFixture::new(cx, json!({"file.rs": "fn main() {}\n"})).await; + + fixture.set_query("fn"); + fixture.set_query("fn m"); + fixture.set_query("fn ma"); + fixture.set_query("fn mai"); + fixture.set_query("fn main"); + + fixture.quick_search.update(&mut fixture.cx, |modal, cx| { + modal.picker.update(cx, |picker, cx| { + picker.delegate.is_searching = false; + cx.notify(); + }); + }); + + fixture.search("fn main").await; + + let match_count = fixture.delegate(|d| d.match_count); + assert!(match_count > 0); + } + + #[gpui::test] + async fn test_quick_search_unicode_query(cx: &mut TestAppContext) { + let mut fixture = TestFixture::new( + cx, + json!({ + "unicode.rs": "// 日本語コメント\nfn main() { println!(\"こんにちは\"); }\n", + "emoji.rs": "// 🎉 celebration\nfn party() {}\n" + }), + ) + .await; + + fixture.search("日本語").await; + assert!(fixture.delegate(|d| d.match_count) > 0); + + fixture.search("🎉").await; + assert!(fixture.delegate(|d| d.match_count) > 0); + } + + #[gpui::test] + async fn test_quick_search_special_regex_chars(cx: &mut TestAppContext) { + let mut fixture = TestFixture::new( + cx, + json!({ + "file.rs": "let x = a + b;\nlet y = (a * b);\n" + }), + ) + .await; + + fixture.search("(a * b)").await; + let match_count = fixture.delegate(|d| d.match_count); + assert!(match_count > 0); + } + + #[gpui::test] + async fn test_quick_search_empty_file(cx: &mut TestAppContext) { + let mut fixture = TestFixture::new( + cx, + json!({ + "empty.rs": "", + "nonempty.rs": "fn main() {}" + }), + ) + .await; + + fixture.search("fn main").await; + let (match_count, file_count) = + fixture.delegate(|d| (d.match_count, d.file_count)); + + assert_eq!(match_count, 1); + assert_eq!(file_count, 1); + } } From 9d9ae3870bad7aed858cfc4eb561721088057068 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Thu, 18 Dec 2025 20:40:36 +0100 Subject: [PATCH 24/35] Fix syntax highlighting on buffer insert Ensures syntax highlighting is updated when a new buffer is added to the delegate's cache. --- crates/search/src/quick_search.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 7eb9f44da6a31d..6a2c37774b8ed1 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -995,7 +995,8 @@ fn apply_batch_to_picker( delegate .buffer_subscriptions .insert(project_path.clone(), subscription); - delegate.buffer_cache.insert(project_path, buffer); + delegate.buffer_cache.insert(project_path.clone(), buffer); + delegate.update_syntax_highlights_for_buffer(&project_path, cx); } delegate.update_visible_indices_from(prev_items_len); From 34bbb85c4f988492b8692f1f298d96d7638fbe92 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Thu, 18 Dec 2025 20:41:41 +0100 Subject: [PATCH 25/35] Fix format --- crates/search/src/quick_search.rs | 24 +++++++++++++----------- 1 file changed, 13 insertions(+), 11 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 6a2c37774b8ed1..5ece592811dc14 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -1022,7 +1022,11 @@ fn apply_batch_to_picker( if is_first { delegate.items.iter().find_map(|item| { if let QuickSearchItem::LineMatch(data) = item { - Some((data.project_path.clone(), data.line, data.match_ranges.clone())) + Some(( + data.project_path.clone(), + data.line, + data.match_ranges.clone(), + )) } else { None } @@ -1362,9 +1366,7 @@ impl QuickSearchDelegate { self.visible_indices .get(visible_index) .and_then(|&actual_idx| self.items.get(actual_idx)) - .map_or(false, |item| { - matches!(item, QuickSearchItem::LineMatch(_)) - }) + .map_or(false, |item| matches!(item, QuickSearchItem::LineMatch(_))) } #[inline] @@ -1620,9 +1622,11 @@ impl PickerDelegate for QuickSearchDelegate { let quick_search = self.quick_search.clone(); let actual_index = self.actual_index(self.selected_index); let preview_data = actual_index.and_then(|idx| match self.items.get(idx) { - Some(QuickSearchItem::LineMatch(data)) => { - Some((data.project_path.clone(), data.line, data.match_ranges.clone())) - } + Some(QuickSearchItem::LineMatch(data)) => Some(( + data.project_path.clone(), + data.line, + data.match_ranges.clone(), + )), _ => None, }); @@ -2510,8 +2514,7 @@ mod tests { fixture.search("test_function").await; - let (match_count, file_count) = - fixture.delegate(|d| (d.match_count, d.file_count)); + let (match_count, file_count) = fixture.delegate(|d| (d.match_count, d.file_count)); assert_eq!(match_count, 500); assert_eq!(file_count, 1); @@ -2585,8 +2588,7 @@ mod tests { .await; fixture.search("fn main").await; - let (match_count, file_count) = - fixture.delegate(|d| (d.match_count, d.file_count)); + let (match_count, file_count) = fixture.delegate(|d| (d.match_count, d.file_count)); assert_eq!(match_count, 1); assert_eq!(file_count, 1); From e0e64e6d5609b2f00bc605b167e6eab5fd0b1e38 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Thu, 18 Dec 2025 20:53:45 +0100 Subject: [PATCH 26/35] Fix panic --- crates/search/src/quick_search.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 5ece592811dc14..f9e668e64f81dd 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -43,8 +43,8 @@ fn find_safe_char_boundaries(text: &str, start: usize, end: usize) -> Option<(us } let mut safe_end = end.min(text.len()); - while safe_end < text.len() && !text.is_char_boundary(safe_end) { - safe_end += 1; + while safe_end > 0 && !text.is_char_boundary(safe_end) { + safe_end -= 1; } if safe_start < safe_end { From f671d595a40384451b0cecac2041f2c323025c14 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Sat, 20 Dec 2025 22:11:55 +0100 Subject: [PATCH 27/35] Improves quick search modal sizing Updates the quick search modal to use dynamic sizing based on viewport dimensions and minimum constraints. This ensures the modal adapts better to different screen sizes and avoids being too small on larger screens. It also replaces fixed pixel values with a ratio for the left panel width. --- crates/search/src/quick_search.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index f9e668e64f81dd..21e1be5c1faa30 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -210,9 +210,10 @@ fn process_file_matches(data: BufferExtractData) -> Option { }) } -const MODAL_HEIGHT: Pixels = px(800.); -const MODAL_WIDTH: Pixels = px(1400.); -const LEFT_PANEL_WIDTH: Pixels = px(400.); +const MIN_MODAL_WIDTH: Pixels = px(800.); +const MIN_MODAL_HEIGHT: Pixels = px(500.); +const LEFT_PANEL_RATIO: f32 = 0.30; +const MIN_LEFT_PANEL_WIDTH: Pixels = px(350.); const MAX_PREVIEW_BYTES: usize = 200; const PREVIEW_DEBOUNCE_MS: u64 = 50; const EDIT_OPEN_DELAY_MS: u64 = 200; @@ -428,10 +429,9 @@ impl Render for QuickSearchModal { let picker = self.picker.clone(); let viewport_size = window.viewport_size(); - let max_width = viewport_size.width * 0.9; - let max_height = viewport_size.height * 0.8; - let modal_width = MODAL_WIDTH.min(max_width); - let modal_height = MODAL_HEIGHT.min(max_height); + let modal_width = (viewport_size.width * 0.8).max(MIN_MODAL_WIDTH); + let modal_height = (viewport_size.height * 0.8).max(MIN_MODAL_HEIGHT); + let left_panel_width = (modal_width * LEFT_PANEL_RATIO).max(MIN_LEFT_PANEL_WIDTH); div() .id("quick-search-modal") @@ -453,7 +453,7 @@ impl Render for QuickSearchModal { .overflow_hidden() .child( v_flex() - .w(LEFT_PANEL_WIDTH) + .w(left_panel_width) .flex_shrink_0() .h_full() .min_h_0() From 35e1fdbad8e3427d5c26c733add1420ffe179264 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Sun, 21 Dec 2025 16:44:18 +0100 Subject: [PATCH 28/35] Improves quick search modal layout Adapts the quick search modal layout to use a vertical layout when the viewport width is below a certain threshold, improving usability on smaller screens. This change also refactors the rendering logic for better readability and maintainability. --- crates/search/src/quick_search.rs | 154 +++++++++++++++++------------- 1 file changed, 85 insertions(+), 69 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 21e1be5c1faa30..32373a088ca938 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -210,10 +210,9 @@ fn process_file_matches(data: BufferExtractData) -> Option { }) } -const MIN_MODAL_WIDTH: Pixels = px(800.); -const MIN_MODAL_HEIGHT: Pixels = px(500.); +const MIN_WIDTH_FOR_HORIZONTAL_LAYOUT: Pixels = px(950.); const LEFT_PANEL_RATIO: f32 = 0.30; -const MIN_LEFT_PANEL_WIDTH: Pixels = px(350.); +const VERTICAL_RESULTS_RATIO: f32 = 0.40; const MAX_PREVIEW_BYTES: usize = 200; const PREVIEW_DEBOUNCE_MS: u64 = 50; const EDIT_OPEN_DELAY_MS: u64 = 200; @@ -427,11 +426,89 @@ impl Render for QuickSearchModal { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let preview_editor = self.preview_editor.clone(); let picker = self.picker.clone(); + let project = self.project.clone(); let viewport_size = window.viewport_size(); - let modal_width = (viewport_size.width * 0.8).max(MIN_MODAL_WIDTH); - let modal_height = (viewport_size.height * 0.8).max(MIN_MODAL_HEIGHT); - let left_panel_width = (modal_width * LEFT_PANEL_RATIO).max(MIN_LEFT_PANEL_WIDTH); + let use_vertical_layout = viewport_size.width < MIN_WIDTH_FOR_HORIZONTAL_LAYOUT; + + let modal_width = (viewport_size.width * 0.9).min(viewport_size.width); + let modal_height = (viewport_size.height * 0.8).min(viewport_size.height); + + let border_color = cx.theme().colors().border; + + let results_panel = v_flex() + .flex_shrink_0() + .min_h_0() + .overflow_hidden() + .child(self.picker.clone()); + + let save_preview_editor = preview_editor.clone(); + let preview_panel = v_flex() + .id("quick-search-preview") + .relative() + .flex_1() + .overflow_hidden() + .bg(cx.theme().colors().editor_background) + .on_click(move |_, window, cx| { + window.focus(&picker.focus_handle(cx), cx); + }) + .on_action({ + let project = project.clone(); + move |_: &Save, window, cx| { + if let Some(editor) = save_preview_editor.clone() { + editor.update(cx, |editor, cx| { + editor + .save(SaveOptions::default(), project.clone(), window, cx) + .detach_and_log_err(cx); + }); + } + } + }) + .when_some(preview_editor, |this, editor| this.child(editor)) + .when(self.preview_editor.is_none(), |this| { + this.child( + div() + .size_full() + .flex() + .items_center() + .justify_center() + .child(Label::new("Select a result to preview").color(Color::Muted)), + ) + }); + + let content = if use_vertical_layout { + let results_height = modal_height * VERTICAL_RESULTS_RATIO; + v_flex() + .w_full() + .flex_1() + .min_h_0() + .overflow_hidden() + .child( + results_panel + .h(results_height) + .w_full() + .border_b_1() + .border_color(border_color), + ) + .child(preview_panel.w_full()) + .into_any_element() + } else { + let left_panel_width = modal_width * LEFT_PANEL_RATIO; + h_flex() + .w_full() + .flex_1() + .min_h_0() + .overflow_hidden() + .child( + results_panel + .w(left_panel_width) + .h_full() + .border_r_1() + .border_color(border_color), + ) + .child(preview_panel.h_full()) + .into_any_element() + }; div() .id("quick-search-modal") @@ -444,69 +521,8 @@ impl Render for QuickSearchModal { .size_full() .overflow_hidden() .border_1() - .border_color(cx.theme().colors().border) - .child( - h_flex() - .w_full() - .flex_1() - .min_h_0() - .overflow_hidden() - .child( - v_flex() - .w(left_panel_width) - .flex_shrink_0() - .h_full() - .min_h_0() - .overflow_hidden() - .border_r_1() - .border_color(cx.theme().colors().border) - .child(self.picker.clone()), - ) - .child({ - let project = self.project.clone(); - let save_preview_editor = preview_editor.clone(); - v_flex() - .id("quick-search-preview") - .relative() - .flex_1() - .h_full() - .overflow_hidden() - .bg(cx.theme().colors().editor_background) - .on_click(move |_, window, cx| { - window.focus(&picker.focus_handle(cx), cx); - }) - .on_action({ - move |_: &Save, window, cx| { - if let Some(editor) = save_preview_editor.clone() { - editor.update(cx, |editor, cx| { - editor - .save( - SaveOptions::default(), - project.clone(), - window, - cx, - ) - .detach_and_log_err(cx); - }); - } - } - }) - .when_some(preview_editor, |this, editor| this.child(editor)) - .when(self.preview_editor.is_none(), |this| { - this.child( - div() - .size_full() - .flex() - .items_center() - .justify_center() - .child( - Label::new("Select a result to preview") - .color(Color::Muted), - ), - ) - }) - }), - ), + .border_color(border_color) + .child(content), ) } } From 4045a7182b1b8dd17c9adbcd25df9ac86447adbc Mon Sep 17 00:00:00 2001 From: David Bonan Date: Sun, 21 Dec 2025 20:05:40 +0100 Subject: [PATCH 29/35] Improves quick search navigation. Enhances quick search navigation by automatically expanding collapsed file headers when navigating through the list. --- crates/search/src/quick_search.rs | 286 +++++++++++++++++++++++++++++- 1 file changed, 284 insertions(+), 2 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 32373a088ca938..33fc19ffc03c30 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -1417,6 +1417,86 @@ impl QuickSearchDelegate { } } + fn expand_and_select( + &mut self, + file_key: &SharedString, + header_visible_idx: usize, + going_down: bool, + ) { + self.toggle_file_collapsed(file_key); + + let target_idx = if going_down { + self.find_first_match_of_file(file_key, header_visible_idx) + } else { + self.find_last_match_of_file(file_key, header_visible_idx) + }; + + if let Some(idx) = target_idx { + self.selected_index = idx; + } + } + + fn find_first_match_of_file( + &self, + file_key: &SharedString, + header_visible_idx: usize, + ) -> Option { + let next_idx = header_visible_idx + 1; + if next_idx < self.visible_indices.len() { + let actual_idx = *self.visible_indices.get(next_idx)?; + if let Some(QuickSearchItem::LineMatch(data)) = self.items.get(actual_idx) { + if &data.file_key == file_key { + return Some(next_idx); + } + } + } + None + } + + fn find_last_match_of_file( + &self, + file_key: &SharedString, + header_visible_idx: usize, + ) -> Option { + let mut last_match_idx = None; + for check_idx in (header_visible_idx + 1)..self.visible_indices.len() { + let actual_idx = *self.visible_indices.get(check_idx)?; + match self.items.get(actual_idx) { + Some(QuickSearchItem::LineMatch(data)) if &data.file_key == file_key => { + last_match_idx = Some(check_idx); + } + Some(QuickSearchItem::FileHeader { .. }) => break, + _ => {} + } + } + last_match_idx.or_else(|| self.find_first_match_of_file(file_key, header_visible_idx)) + } + + fn find_collapsed_file_in_direction( + &self, + from_visible_idx: usize, + going_down: bool, + ) -> Option<(usize, SharedString)> { + let range: Box> = if going_down { + Box::new((from_visible_idx + 1)..self.visible_indices.len()) + } else { + Box::new((0..from_visible_idx).rev()) + }; + + for scan_idx in range { + if let Some(&actual_idx) = self.visible_indices.get(scan_idx) { + if let Some(QuickSearchItem::FileHeader { file_key, .. }) = + self.items.get(actual_idx) + { + if self.collapsed_files.contains(file_key) { + return Some((scan_idx, file_key.clone())); + } + } + } + } + None + } + fn render_file_header( &self, ix: usize, @@ -1622,10 +1702,56 @@ impl PickerDelegate for QuickSearchDelegate { let going_down = ix >= self.selected_index; + let collapsed_file_at_ix = self.visible_indices.get(ix).and_then(|&actual_idx| { + if let Some(QuickSearchItem::FileHeader { file_key, .. }) = self.items.get(actual_idx) { + if self.collapsed_files.contains(file_key) { + Some(file_key.clone()) + } else { + None + } + } else { + None + } + }); + + if let Some(file_key) = collapsed_file_at_ix { + self.expand_and_select(&file_key, ix, going_down); + return; + } + if let Some(found) = self.find_nearest_line_match(ix, going_down) { + let scan_range: Box> = if going_down { + Box::new((ix + 1)..found) + } else { + Box::new(((found + 1)..ix).rev()) + }; + + for scan_idx in scan_range { + if let Some(&actual_idx) = self.visible_indices.get(scan_idx) { + if let Some(QuickSearchItem::FileHeader { file_key, .. }) = + self.items.get(actual_idx) + { + if self.collapsed_files.contains(file_key) { + let file_key = file_key.clone(); + self.expand_and_select(&file_key, scan_idx, going_down); + return; + } + } + } + } + self.selected_index = found; - } else if let Some(found) = self.find_nearest_line_match(ix, !going_down) { - self.selected_index = found; + } else { + if let Some((collapsed_idx, file_key)) = + self.find_collapsed_file_in_direction(ix, going_down) + { + self.expand_and_select(&file_key, collapsed_idx, going_down); + return; + } + + if let Some(found) = self.find_nearest_line_match(ix, !going_down) { + self.selected_index = found; + } } } @@ -2464,6 +2590,162 @@ mod tests { }); } + #[gpui::test] + async fn test_quick_search_navigation_down_expands_collapsed_file(cx: &mut TestAppContext) { + let mut fixture = TestFixture::new(cx, json!({"file.rs": ""})).await; + + fixture.set_items(vec![ + file_header("test.rs", "src"), + line_match("src/test.rs", 0, "fn test()"), + line_match("src/test.rs", 1, "fn other()"), + file_header("lib.rs", "src"), + line_match("src/lib.rs", 0, "pub fn lib()"), + ]); + + let file_key: SharedString = "src/lib.rs".into(); + fixture.toggle_file_collapsed(&file_key); + + fixture.delegate(|d| { + assert!(d.collapsed_files.contains(&file_key)); + assert_eq!(d.visible_indices.len(), 4); + }); + + fixture + .quick_search + .update_in(&mut fixture.cx, |modal, window, cx| { + modal.picker.update(cx, |picker, cx| { + picker.delegate.selected_index = 2; + picker.delegate.set_selected_index(3, window, cx); + }); + }); + + fixture.delegate(|d| { + assert!(!d.collapsed_files.contains(&file_key)); + assert_eq!(d.visible_indices.len(), 5); + assert_eq!(d.selected_index, 4); + }); + } + + #[gpui::test] + async fn test_quick_search_navigation_up_expands_collapsed_file(cx: &mut TestAppContext) { + let mut fixture = TestFixture::new(cx, json!({"file.rs": ""})).await; + + fixture.set_items(vec![ + file_header("test.rs", "src"), + line_match("src/test.rs", 0, "fn test()"), + line_match("src/test.rs", 1, "fn other()"), + file_header("lib.rs", "src"), + line_match("src/lib.rs", 0, "pub fn lib()"), + line_match("src/lib.rs", 1, "pub fn lib2()"), + ]); + + let file_key: SharedString = "src/test.rs".into(); + fixture.toggle_file_collapsed(&file_key); + + fixture.delegate(|d| { + assert!(d.collapsed_files.contains(&file_key)); + assert_eq!(d.visible_indices.len(), 4); + }); + + fixture + .quick_search + .update_in(&mut fixture.cx, |modal, window, cx| { + modal.picker.update(cx, |picker, cx| { + picker.delegate.selected_index = 2; + picker.delegate.set_selected_index(1, window, cx); + }); + }); + + fixture.delegate(|d| { + assert!(!d.collapsed_files.contains(&file_key)); + assert_eq!(d.visible_indices.len(), 6); + assert_eq!(d.selected_index, 2); + }); + } + + #[gpui::test] + async fn test_quick_search_navigation_up_skipping_collapsed_file(cx: &mut TestAppContext) { + let mut fixture = TestFixture::new(cx, json!({"file.rs": ""})).await; + + fixture.set_items(vec![ + file_header("a.rs", "src"), + line_match("src/a.rs", 0, "fn a()"), + file_header("b.rs", "src"), + line_match("src/b.rs", 0, "fn b()"), + file_header("c.rs", "src"), + line_match("src/c.rs", 0, "fn c()"), + ]); + + let file_key_b: SharedString = "src/b.rs".into(); + fixture.toggle_file_collapsed(&file_key_b); + + fixture.delegate(|d| { + assert!(d.collapsed_files.contains(&file_key_b)); + assert_eq!(d.visible_indices.len(), 5); + }); + + fixture + .quick_search + .update_in(&mut fixture.cx, |modal, window, cx| { + modal.picker.update(cx, |picker, cx| { + picker.delegate.selected_index = 4; + picker.delegate.set_selected_index(3, window, cx); + }); + }); + + fixture.delegate(|d| { + assert!(!d.collapsed_files.contains(&file_key_b)); + assert_eq!(d.visible_indices.len(), 6); + assert_eq!(d.selected_index, 3); + }); + } + + #[gpui::test] + async fn test_quick_search_navigation_up_multiple_collapsed_expands_nearest( + cx: &mut TestAppContext, + ) { + let mut fixture = TestFixture::new(cx, json!({"file.rs": ""})).await; + + fixture.set_items(vec![ + file_header("a.rs", "src"), + line_match("src/a.rs", 0, "fn a()"), + file_header("b.rs", "src"), + line_match("src/b.rs", 0, "fn b()"), + file_header("c.rs", "src"), + line_match("src/c.rs", 0, "fn c()"), + file_header("d.rs", "src"), + line_match("src/d.rs", 0, "fn d()"), + ]); + + let file_key_a: SharedString = "src/a.rs".into(); + let file_key_b: SharedString = "src/b.rs".into(); + let file_key_c: SharedString = "src/c.rs".into(); + fixture.toggle_file_collapsed(&file_key_a); + fixture.toggle_file_collapsed(&file_key_b); + fixture.toggle_file_collapsed(&file_key_c); + + fixture.delegate(|d| { + assert_eq!(d.collapsed_files.len(), 3); + assert_eq!(d.visible_indices.len(), 5); + }); + + fixture + .quick_search + .update_in(&mut fixture.cx, |modal, window, cx| { + modal.picker.update(cx, |picker, cx| { + picker.delegate.selected_index = 4; + picker.delegate.set_selected_index(3, window, cx); + }); + }); + + fixture.delegate(|d| { + assert!(d.collapsed_files.contains(&file_key_a)); + assert!(d.collapsed_files.contains(&file_key_b)); + assert!(!d.collapsed_files.contains(&file_key_c)); + assert_eq!(d.collapsed_files.len(), 2); + }); + } + #[gpui::test] fn test_truncate_preview() { assert_eq!( From 46964c9a81448c00a3cf738138cedf8f1f892f77 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Sun, 21 Dec 2025 22:36:50 +0100 Subject: [PATCH 30/35] Navigates on double click in quick search This change addresses an issue where double-clicking an item in quick search wasn't correctly triggering the navigation. It ensures that the selected index is properly updated and the confirmation action is executed, leading to the intended navigation. --- crates/search/src/quick_search.rs | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 33fc19ffc03c30..a1007287648a6b 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -1617,9 +1617,17 @@ impl QuickSearchDelegate { .on_click({ move |event, window, cx| { cx.stop_propagation(); + let Some(qs) = quick_search.upgrade() else { + return; + }; if event.click_count() >= 2 { - window.dispatch_action(menu::Confirm.boxed_clone(), cx); - } else if let Some(qs) = quick_search.upgrade() { + qs.update(cx, |modal, cx| { + modal.picker.update(cx, |picker, cx| { + picker.delegate.selected_index = ix; + picker.delegate.confirm(false, window, cx); + }); + }); + } else { let preview_data = { let modal = qs.read(cx); let delegate = &modal.picker.read(cx).delegate; From f9d6fe10e563fc079e21b00dbf9b41d0242a255b Mon Sep 17 00:00:00 2001 From: David Bonan Date: Sun, 21 Dec 2025 22:39:34 +0100 Subject: [PATCH 31/35] Fix clippy --- crates/search/src/quick_search.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index a1007287648a6b..7d2f1ac0277ad8 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -453,7 +453,6 @@ impl Render for QuickSearchModal { window.focus(&picker.focus_handle(cx), cx); }) .on_action({ - let project = project.clone(); move |_: &Save, window, cx| { if let Some(editor) = save_preview_editor.clone() { editor.update(cx, |editor, cx| { From 23e67153ffdec7a817d49a72a2b3fd07f394ae8b Mon Sep 17 00:00:00 2001 From: David Bonan Date: Wed, 31 Dec 2025 17:25:44 +0100 Subject: [PATCH 32/35] Removes cancellation mechanism --- crates/project/src/project_search.rs | 33 ++---------- crates/search/src/quick_search.rs | 79 ++++------------------------ 2 files changed, 13 insertions(+), 99 deletions(-) diff --git a/crates/project/src/project_search.rs b/crates/project/src/project_search.rs index 852b8e5ff007ac..4236de039df9ab 100644 --- a/crates/project/src/project_search.rs +++ b/crates/project/src/project_search.rs @@ -5,10 +5,7 @@ use std::{ ops::Range, path::{Path, PathBuf}, pin::pin, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, + sync::Arc, }; use anyhow::Context; @@ -350,12 +347,8 @@ impl Search { }; let ensure_matches_are_reported_in_order = if should_find_all_matches { Some( - Self::ensure_matched_ranges_are_reported_in_order( - sorted_matches_rx, - tx, - &cancelled, - ) - .boxed_local(), + Self::ensure_matched_ranges_are_reported_in_order(sorted_matches_rx, tx) + .boxed_local(), ) } else { drop(tx); @@ -529,17 +522,12 @@ impl Search { async fn ensure_matched_ranges_are_reported_in_order( rx: Receiver, Vec>)>>, tx: Sender, - cancelled: &AtomicBool, ) { use postage::stream::Stream; _ = maybe!(async move { let mut matched_buffers = 0; let mut matches = 0; while let Ok(mut next_buffer_matches) = rx.recv().await { - if cancelled.load(Ordering::Relaxed) { - break; - } - let Some((buffer, ranges)) = next_buffer_matches.recv().await else { continue; }; @@ -552,15 +540,7 @@ impl Search { } matched_buffers += 1; matches += ranges.len(); - - if tx - .send(SearchResult::Buffer { buffer, ranges }) - .await - .is_err() - { - cancelled.store(true, Ordering::Relaxed); - break; - } + _ = tx.send(SearchResult::Buffer { buffer, ranges }).await?; } anyhow::Ok(()) }) @@ -618,7 +598,6 @@ struct Worker { BufferSnapshot, oneshot::Sender<(Entity, Vec>)>, )>, - cancelled: &'search AtomicBool, } impl Worker { @@ -651,10 +630,6 @@ impl Worker { let mut scan_path = pin!(input_paths_rx.fuse()); loop { - if self.cancelled.load(Ordering::Relaxed) { - break; - } - let handler = RequestHandler { query: &self.query, open_entries: &self.open_buffers, diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 7d2f1ac0277ad8..8a392a1e71aa8b 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -10,15 +10,7 @@ use gpui::{ use language::{Buffer, BufferEvent, HighlightId}; use picker::{Picker, PickerDelegate}; use project::{Project, ProjectPath, search::SearchQuery}; -use std::{ - path::Path, - pin::pin, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, - time::Duration, -}; +use std::{path::Path, pin::pin, sync::Arc, time::Duration}; use text::{ToOffset as _, ToPoint as _}; use ui::{ Button, ButtonStyle, Color, Divider, Icon, IconButton, IconButtonShape, IconName, KeyBinding, @@ -384,7 +376,6 @@ pub struct QuickSearchDelegate { visible_line_match_indices: Vec, collapsed_files: HashSet, selected_index: usize, - search_cancelled: Option>, quick_search: WeakEntity, match_count: usize, file_count: usize, @@ -622,7 +613,6 @@ impl QuickSearchModal { visible_line_match_indices: Vec::new(), collapsed_files: HashSet::default(), selected_index: 0, - search_cancelled: None, quick_search: weak_self, match_count: 0, file_count: 0, @@ -1074,7 +1064,6 @@ async fn process_and_apply_batch( limit_reached: bool, picker: &WeakEntity>, quick_search: &WeakEntity, - cancelled: &AtomicBool, cx: &mut gpui::AsyncWindowContext, ) { let processed_results = cx @@ -1082,10 +1071,6 @@ async fn process_and_apply_batch( .spawn(async move { process_results_in_background(buffer_data) }) .await; - if cancelled.load(Ordering::Relaxed) { - return; - } - let mut batch = SearchResults { items: Vec::with_capacity(processed_results.len() * 2), buffers: HashMap::default(), @@ -1109,9 +1094,6 @@ async fn process_and_apply_batch( let preview_data = picker .update_in(cx, |picker, _window, cx| { - if cancelled.load(Ordering::Relaxed) { - return None; - } apply_batch_to_picker( &mut picker.delegate, batch, @@ -1203,9 +1185,6 @@ impl QuickSearchDelegate { self.items.clear(); self.visible_indices.clear(); self.visible_line_match_indices.clear(); - if let Some(cancelled) = self.search_cancelled.take() { - cancelled.store(true, Ordering::Relaxed); - } self.match_count = 0; self.file_count = 0; self.is_limited = false; @@ -1884,12 +1863,6 @@ impl PickerDelegate for QuickSearchDelegate { self.is_searching = true; - if let Some(prev_cancelled) = self.search_cancelled.take() { - prev_cancelled.store(true, Ordering::Relaxed); - } - let cancelled = Arc::new(AtomicBool::new(false)); - self.search_cancelled = Some(cancelled.clone()); - let file_count = get_project_file_count(self.project.read(cx), cx); let debounce_ms = compute_search_debounce_ms(file_count); @@ -1904,10 +1877,6 @@ impl PickerDelegate for QuickSearchDelegate { .await; } - if cancelled.load(Ordering::Relaxed) { - return; - } - let search_query = match build_search_query(&query, search_options) { Ok(q) => { picker @@ -1943,10 +1912,8 @@ impl PickerDelegate for QuickSearchDelegate { picker .update(cx, |picker, cx| { - if !cancelled.load(Ordering::Relaxed) { - picker.delegate.reset_for_new_search(); - cx.notify(); - } + picker.delegate.reset_for_new_search(); + cx.notify(); }) .log_err(); @@ -1960,10 +1927,6 @@ impl PickerDelegate for QuickSearchDelegate { let mut results_stream = pin!(project_search_results.ready_chunks(STREAM_CHUNK_SIZE)); while let Some(results) = results_stream.next().await { - if cancelled.load(Ordering::Relaxed) { - return; - } - for result in results { match result { project::search::SearchResult::Buffer { buffer, ranges } => { @@ -2003,7 +1966,6 @@ impl PickerDelegate for QuickSearchDelegate { limit_reached, &picker, &quick_search, - &cancelled, cx, ) .await; @@ -2013,7 +1975,7 @@ impl PickerDelegate for QuickSearchDelegate { } } - if !pending.is_empty() && !cancelled.load(Ordering::Relaxed) { + if !pending.is_empty() { let buffer_data_to_process = pending.take(); let limit_reached = pending.limit_reached; @@ -2024,7 +1986,6 @@ impl PickerDelegate for QuickSearchDelegate { limit_reached, &picker, &quick_search, - &cancelled, cx, ) .await; @@ -2032,11 +1993,9 @@ impl PickerDelegate for QuickSearchDelegate { picker .update(cx, |picker, cx| { - if !cancelled.load(Ordering::Relaxed) { - picker.delegate.is_limited = counters.search_limited; - picker.delegate.is_searching = false; - cx.notify(); - } + picker.delegate.is_limited = counters.search_limited; + picker.delegate.is_searching = false; + cx.notify(); }) .log_err(); }) @@ -2379,13 +2338,12 @@ mod tests { async fn test_quick_search_empty_query_clears_results(cx: &mut TestAppContext) { let mut fixture = TestFixture::new(cx, json!({"file.rs": "fn test() {}\n"})).await; - fixture.set_query("test"); - assert!(fixture.delegate(|d| d.search_cancelled.is_some())); + fixture.search("test").await; + assert!(fixture.delegate(|d| d.items.len()) > 0); fixture.search("").await; fixture.delegate(|d| { assert_eq!(d.items.len(), 0); - assert!(d.search_cancelled.is_none()); }); } @@ -2397,25 +2355,6 @@ mod tests { assert_eq!(fixture.delegate(|d| d.items.len()), 0); } - #[gpui::test] - async fn test_quick_search_query_sets_cancellation_flag(cx: &mut TestAppContext) { - let mut fixture = - TestFixture::new(cx, json!({"file.rs": "fn hello() {}\nfn world() {}\n"})).await; - - assert!(fixture.delegate(|d| d.search_cancelled.is_none())); - - fixture.set_query("hello"); - let first_cancelled = fixture.delegate(|d| d.search_cancelled.clone()); - assert!(first_cancelled.is_some()); - assert!(!first_cancelled.as_ref().unwrap().load(Ordering::Relaxed)); - - fixture.set_query("world"); - let second_cancelled = fixture.delegate(|d| d.search_cancelled.clone()); - assert!(second_cancelled.is_some()); - assert!(first_cancelled.as_ref().unwrap().load(Ordering::Relaxed)); - assert!(!second_cancelled.as_ref().unwrap().load(Ordering::Relaxed)); - } - #[gpui::test] async fn test_quick_search_finds_matches(cx: &mut TestAppContext) { let mut fixture = TestFixture::new( From 8e4656ebed10ada55e371a801c32140b2f96bf27 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Wed, 31 Dec 2025 18:24:24 +0100 Subject: [PATCH 33/35] Uses the search results receiver directly Updates the quick search functionality to directly consume the search results receiver (`rx`) instead of relying on the `project` object, streamlining the data flow and improving efficiency. --- crates/search/src/quick_search.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 8a392a1e71aa8b..74cabb5d7f88fa 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -1903,7 +1903,7 @@ impl PickerDelegate for QuickSearchDelegate { } }; - let Some(project_search_results) = project + let Some(project::SearchResults { rx: results_rx, _task_handle }) = project .update(cx, |project, cx| project.search(search_query, cx)) .log_err() else { @@ -1925,7 +1925,7 @@ impl PickerDelegate for QuickSearchDelegate { let mut is_first_batch = true; let mut pending = PendingBufferData::new(); - let mut results_stream = pin!(project_search_results.ready_chunks(STREAM_CHUNK_SIZE)); + let mut results_stream = pin!(results_rx.ready_chunks(STREAM_CHUNK_SIZE)); while let Some(results) = results_stream.next().await { for result in results { match result { From d4321e0215e32c7026172bc8148b6d6e3d456800 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Wed, 31 Dec 2025 18:40:03 +0100 Subject: [PATCH 34/35] Yields to UI thread during quick search. Allows other UI tasks to run between search result batches to improve responsiveness. --- crates/search/src/quick_search.rs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index 74cabb5d7f88fa..f6e0ef948f09c5 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -1903,7 +1903,10 @@ impl PickerDelegate for QuickSearchDelegate { } }; - let Some(project::SearchResults { rx: results_rx, _task_handle }) = project + let Some(project::SearchResults { + rx: results_rx, + _task_handle, + }) = project .update(cx, |project, cx| project.search(search_query, cx)) .log_err() else { @@ -1970,6 +1973,9 @@ impl PickerDelegate for QuickSearchDelegate { ) .await; + // Yield to allow other UI tasks to run between batches + smol::future::yield_now().await; + if counters.search_limited { break; } From edfab4a6d0bcad3be3335f9b23c67f0ac8a34d80 Mon Sep 17 00:00:00 2001 From: David Bonan Date: Wed, 31 Dec 2025 20:06:41 +0100 Subject: [PATCH 35/35] Displays a "Searching..." indicator in Quick Search Adds a spinner and text label to indicate when Quick Search is actively searching for results. This provides visual feedback to the user that the search is in progress. --- crates/search/src/quick_search.rs | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/crates/search/src/quick_search.rs b/crates/search/src/quick_search.rs index f6e0ef948f09c5..5c98a93f4eec6e 100644 --- a/crates/search/src/quick_search.rs +++ b/crates/search/src/quick_search.rs @@ -14,7 +14,7 @@ use std::{path::Path, pin::pin, sync::Arc, time::Duration}; use text::{ToOffset as _, ToPoint as _}; use ui::{ Button, ButtonStyle, Color, Divider, Icon, IconButton, IconButtonShape, IconName, KeyBinding, - Label, ListItem, ListItemSpacing, Tooltip, prelude::*, rems_from_px, + Label, LabelSize, ListItem, ListItemSpacing, SpinnerLabel, Tooltip, prelude::*, rems_from_px, }; use util::{ResultExt, paths::PathMatcher}; use workspace::{ @@ -2098,6 +2098,23 @@ impl PickerDelegate for QuickSearchDelegate { ); } + if self.is_searching { + return Some( + h_flex() + .w_full() + .px_3() + .py_1() + .gap_2() + .child(SpinnerLabel::new().size(LabelSize::Small)) + .child( + Label::new("Searching...") + .size(LabelSize::Small) + .color(Color::Muted), + ) + .into_any(), + ); + } + if self.match_count > 0 { let results_text = if self.is_limited { format!("{}+ results (limited)", self.match_count)