diff --git a/Cargo.lock b/Cargo.lock index 8c1ade1714c9dd..95b6810d89c4c6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12879,6 +12879,51 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick_search" +version = "0.1.0" +dependencies = [ + "any_vec", + "anyhow", + "async-channel 2.5.0", + "bitflags 2.9.4", + "buffer_diff", + "collections", + "editor", + "file_icons", + "futures 0.3.31", + "fuzzy", + "git", + "git2", + "git_ui", + "gpui", + "indexmap", + "itertools 0.14.0", + "language", + "log", + "markdown", + "menu", + "multi_buffer", + "picker", + "project", + "schemars", + "search", + "serde", + "serde_json", + "settings", + "smol", + "text", + "theme", + "time", + "time_format", + "tracing", + "ui", + "util", + "util_macros", + "workspace", + "ztracing", +] + [[package]] name = "quinn" version = "0.11.9" @@ -14459,16 +14504,25 @@ version = "0.1.0" dependencies = [ "any_vec", "anyhow", + "async-channel 2.5.0", "bitflags 2.9.4", + "buffer_diff", "client", "collections", "editor", + "file_icons", "futures 0.3.31", + "fuzzy", + "git", "gpui", + "indexmap", "itertools 0.14.0", "language", + "log", "lsp", "menu", + "multi_buffer", + "picker", "pretty_assertions", "project", "schemars", @@ -14476,6 +14530,7 @@ dependencies = [ "serde_json", "settings", "smol", + "text", "theme", "tracing", "ui", @@ -18144,6 +18199,7 @@ dependencies = [ "picker", "project", "project_panel", + "quick_search", "regex", "release_channel", "schemars", @@ -20596,6 +20652,7 @@ dependencies = [ "project_symbols", "prompt_store", "proto", + "quick_search", "rayon", "recent_projects", "release_channel", diff --git a/Cargo.toml b/Cargo.toml index f3a5fefc7168c5..24c311d1e4f246 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -148,6 +148,7 @@ members = [ "crates/rules_library", "crates/schema_generator", "crates/search", + "crates/quick_search", "crates/session", "crates/settings", "crates/settings_json", @@ -375,6 +376,7 @@ rope = { path = "crates/rope" } rpc = { path = "crates/rpc" } rules_library = { path = "crates/rules_library" } search = { path = "crates/search" } +quick_search = { path = "crates/quick_search" } session = { path = "crates/session" } settings = { path = "crates/settings" } settings_json = { path = "crates/settings_json" } diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index aac9dcf7068567..1ea0349541a7aa 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -611,6 +611,7 @@ "shift-find": "pane::DeploySearch", "ctrl-shift-f": "pane::DeploySearch", "ctrl-shift-h": ["pane::DeploySearch", { "replace_enabled": true }], + "ctrl-alt-k": "quick_search::Toggle", "ctrl-shift-t": "pane::ReopenClosedItem", "ctrl-k ctrl-s": "zed::OpenKeymap", "ctrl-k ctrl-t": "theme_selector::Toggle", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 224f6755465d63..366631e4462776 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -678,6 +678,7 @@ "ctrl-alt-+": ["workspace::IncreaseOpenDocksSize", { "px": 0 }], "cmd-shift-f": "pane::DeploySearch", "cmd-shift-h": ["pane::DeploySearch", { "replace_enabled": true }], + "cmd-alt-k": "quick_search::Toggle", "cmd-shift-t": "pane::ReopenClosedItem", "cmd-k cmd-s": "zed::OpenKeymap", "cmd-k cmd-t": "theme_selector::Toggle", diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index 5626309ecb2e17..7e5b322c48464b 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -608,6 +608,7 @@ "shift-alt-0": "workspace::ResetOpenDocksSize", "ctrl-shift-f": "pane::DeploySearch", "ctrl-shift-h": ["pane::DeploySearch", { "replace_enabled": true }], + "ctrl-alt-k": "quick_search::Toggle", "ctrl-shift-t": "pane::ReopenClosedItem", "ctrl-k ctrl-s": "zed::OpenKeymap", "ctrl-k ctrl-t": "theme_selector::Toggle", diff --git a/crates/quick_search/Cargo.toml b/crates/quick_search/Cargo.toml new file mode 100644 index 00000000000000..0dc7be44d9cc9f --- /dev/null +++ b/crates/quick_search/Cargo.toml @@ -0,0 +1,58 @@ +[package] +name = "quick_search" +version = "0.1.0" +edition.workspace = true +publish.workspace = true +license = "GPL-3.0-or-later" + +[lints] +workspace = true + +[lib] +name = "quick_search" +path = "src/quick_search.rs" +doctest = false + +[dependencies] +anyhow.workspace = true +any_vec.workspace = true +async-channel = "2.5" +bitflags.workspace = true +buffer_diff.workspace = true +collections.workspace = true +editor.workspace = true +file_icons.workspace = true +futures.workspace = true +fuzzy.workspace = true +git.workspace = true +git_ui.workspace = true +git2.workspace = true +gpui.workspace = true +time.workspace = true +time_format.workspace = true +indexmap.workspace = true +itertools.workspace = true +language.workspace = true +log.workspace = true +markdown.workspace = true +menu.workspace = true +multi_buffer.workspace = true +picker.workspace = true +project.workspace = true +schemars.workspace = true +search.workspace = true +serde.workspace = true +serde_json.workspace = true +settings.workspace = true +smol.workspace = true +text.workspace = true +theme.workspace = true +tracing.workspace = true +ui.workspace = true +util.workspace = true +util_macros.workspace = true +workspace.workspace = true +ztracing.workspace = true + +[package.metadata.cargo-machete] +ignored = ["tracing"] diff --git a/crates/quick_search/src/core.rs b/crates/quick_search/src/core.rs new file mode 100644 index 00000000000000..d9d39a16400912 --- /dev/null +++ b/crates/quick_search/src/core.rs @@ -0,0 +1,780 @@ +use std::{ + cmp::Ordering, + collections::HashMap, + ops::Range, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, AtomicU64}, + }, +}; + +use futures::future::LocalBoxFuture; +use gpui::{AnyView, App, AppContext, AsyncApp, Context, Entity, WeakEntity, Window}; +use language::Buffer; +use log::debug; +use project::Project; +use project::search::SearchResult; +use search::SearchOptions; +use text::Point; +use ui::IconName; +use util::paths::PathStyle; + +use crate::PickerHandle; +use crate::preview::{PreviewKey, PreviewRequest}; +use crate::types::QuickMatch; +use crate::types::{MatchAction, QuickMatchKind}; +use crate::types::{MatchKey, QuickMatchPatch}; + +pub type SearchUiContext<'a> = Context<'a, PickerHandle>; + +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub struct SourceId(pub Arc); + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ListPresentation { + Flat, + Grouped, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[allow(dead_code)] +pub enum SortPolicy { + StreamOrder, + FinalSort, +} + +#[derive(Clone, Debug)] +pub struct SourceSpecCore { + pub supported_options: SearchOptions, + pub min_query_len: usize, + pub sort_policy: SortPolicy, +} + +#[derive(Clone, Debug)] +pub struct SourceSpecUi { + pub title: Arc, + pub icon: IconName, + pub placeholder: Arc, + pub list_presentation: ListPresentation, + pub use_diff_preview: bool, +} + +#[derive(Clone, Debug)] +pub struct SourceSpec { + pub id: SourceId, + pub core: SourceSpecCore, + pub ui: SourceSpecUi, +} + +#[derive(Clone)] +pub struct SearchCancellation { + flag: Arc, +} + +impl SearchCancellation { + pub fn new(flag: Arc) -> Self { + Self { flag } + } + + pub fn is_cancelled(&self) -> bool { + self.flag.load(std::sync::atomic::Ordering::Relaxed) + } + + pub fn cancel(&self) { + self.flag.store(true, std::sync::atomic::Ordering::SeqCst); + } + + pub fn flag(&self) -> Arc { + self.flag.clone() + } +} + +#[derive(Clone)] +pub struct FooterCancellation { + session: SearchCancellation, + local: SearchCancellation, +} + +impl FooterCancellation { + pub fn new(session: SearchCancellation, local: SearchCancellation) -> Self { + Self { session, local } + } + + pub fn is_cancelled(&self) -> bool { + self.session.is_cancelled() || self.local.is_cancelled() + } +} + +#[derive(Clone, Default)] +pub struct PreviewFooterHostState { + pub has_content: bool, + pub loading: bool, + pub loading_label: Option>, +} + +#[derive(Clone)] +pub struct PreviewFooterHost { + state: Entity, +} + +impl PreviewFooterHost { + pub fn new(cx: &mut App) -> Self { + Self { + state: cx.new(|_cx| PreviewFooterHostState::default()), + } + } + + pub fn state_entity(&self) -> &Entity { + &self.state + } + + pub fn set_loading(&self, loading: bool, cx: &mut App) { + self.state.update(cx, |state, cx| { + if state.loading == loading { + return; + } + state.loading = loading; + if !loading { + state.loading_label = None; + } + cx.notify(); + }); + } + + pub fn set_has_content(&self, has_content: bool, cx: &mut App) { + self.state.update(cx, |state, cx| { + if state.has_content == has_content { + return; + } + state.has_content = has_content; + cx.notify(); + }); + } + + pub fn set_loading_label(&self, label: Option>, cx: &mut App) { + self.state.update(cx, |state, cx| { + if state.loading_label == label { + return; + } + state.loading_label = label; + cx.notify(); + }); + } + + pub fn snapshot(&self, cx: &App) -> PreviewFooterHostState { + self.state.read(cx).clone() + } +} + +#[derive(Clone)] +pub struct FooterSpec { + pub title: Arc, + pub toggleable: bool, + pub default_open: bool, +} + +#[derive(Clone)] +pub struct FooterContext { + pub project: Entity, + #[allow(dead_code)] + pub query: Arc, + pub selected: Option, + pub preview_buffer: Option>, + pub cancellation: FooterCancellation, +} + +#[derive(Clone)] +pub enum FooterEvent { + OpenChanged(bool), + ContextChanged(FooterContext), +} + +pub struct FooterInstance { + pub spec: FooterSpec, + pub host: PreviewFooterHost, + pub view: AnyView, + pub handle_event: Arc, +} + +#[derive(Clone)] +pub struct SearchContext { + project: Entity, + query: Arc, + search_options: SearchOptions, + path_style: PathStyle, + language_registry: Arc, + background_executor: gpui::BackgroundExecutor, + cancellation: SearchCancellation, + match_arena: Arc, +} + +impl SearchContext { + pub fn new( + project: Entity, + query: Arc, + search_options: SearchOptions, + path_style: PathStyle, + language_registry: Arc, + cancellation: SearchCancellation, + background_executor: gpui::BackgroundExecutor, + match_arena: Arc, + ) -> Self { + Self { + project, + query, + search_options, + path_style, + language_registry, + background_executor, + cancellation, + match_arena, + } + } + + pub fn cancellation(&self) -> &SearchCancellation { + &self.cancellation + } + + pub fn project(&self) -> &Entity { + &self.project + } + + pub fn query(&self) -> &Arc { + &self.query + } + + pub fn search_options(&self) -> SearchOptions { + self.search_options + } + + pub fn path_style(&self) -> PathStyle { + self.path_style + } + + pub fn language_registry(&self) -> &Arc { + &self.language_registry + } + + pub fn background_executor(&self) -> &gpui::BackgroundExecutor { + &self.background_executor + } + + pub fn match_arena(&self) -> &Arc { + &self.match_arena + } +} + +#[derive(Clone)] +pub struct SearchSink { + picker: WeakEntity, + generation: usize, + cancellation: SearchCancellation, + finished: Arc, +} + +impl SearchSink { + pub fn new( + picker: WeakEntity, + generation: usize, + cancellation: SearchCancellation, + ) -> Self { + Self { + picker, + generation, + cancellation, + finished: Arc::new(AtomicBool::new(false)), + } + } + + pub fn is_cancelled(&self) -> bool { + self.cancellation.is_cancelled() + } + + pub fn is_finished(&self) -> bool { + self.finished.load(std::sync::atomic::Ordering::Relaxed) + } + + fn mark_finished(&self) { + self.finished + .store(true, std::sync::atomic::Ordering::SeqCst); + } + + pub fn record_error(&self, message: String, app: &mut AsyncApp) { + if self.is_cancelled() { + return; + } + self.mark_finished(); + crate::record_error(self.picker.clone(), self.generation, message, app); + } + + pub fn finish_stream(&self, app: &mut AsyncApp) { + if self.is_cancelled() { + return; + } + self.mark_finished(); + crate::finish_stream(self.picker.clone(), self.generation, app); + } + + pub fn flush_batch_ids( + &self, + batch: &mut Vec, + arena: &Arc, + app: &mut AsyncApp, + ) { + if self.is_cancelled() { + return; + } + crate::flush_batch_ids( + self.picker.clone(), + self.generation, + batch, + arena.clone(), + app, + ); + } + + pub fn apply_patches_by_key( + &self, + patches: Vec<(MatchKey, QuickMatchPatch)>, + app: &mut AsyncApp, + ) { + if self.is_cancelled() { + return; + } + crate::apply_patches_by_key(self.picker.clone(), self.generation, patches, app); + } + + pub fn set_query_notice(&self, notice: Option, app: &mut AsyncApp) { + if self.is_cancelled() { + return; + } + + let Some(picker_entity) = self.picker.upgrade() else { + return; + }; + if let Err(err) = app.update_entity(&picker_entity, |picker, cx| { + if picker.delegate.search_engine.generation() != self.generation { + return; + } + picker.delegate.query_notice = notice.clone(); + cx.notify(); + }) { + debug!("quick_search: failed to set query notice: {:?}", err); + } + } + + pub fn set_inflight_results( + &self, + rx: async_channel::Receiver, + app: &mut AsyncApp, + ) { + if self.is_cancelled() { + return; + } + + let Some(picker_entity) = self.picker.upgrade() else { + return; + }; + if let Err(err) = app.update_entity(&picker_entity, |picker, _cx| { + if picker.delegate.search_engine.generation() != self.generation { + return; + } + picker + .delegate + .search_engine + .set_inflight_results(rx.clone()); + }) { + debug!("quick_search: failed to store inflight results: {:?}", err); + } + } +} + +pub fn spawn_source_task(cx: &mut SearchUiContext<'_>, sink: SearchSink, f: F) +where + F: 'static + for<'a> FnOnce(&'a mut AsyncApp, SearchSink) -> LocalBoxFuture<'a, ()>, +{ + cx.spawn(move |_, app: &mut AsyncApp| { + let mut app = app.clone(); + let sink = sink.clone(); + async move { + if sink.is_cancelled() { + return; + } + + f(&mut app, sink.clone()).await; + + if sink.is_cancelled() { + return; + } + if !sink.is_finished() { + sink.finish_stream(&mut app); + } + } + }) + .detach(); +} + +pub trait QuickSearchSource { + fn spec(&self) -> &'static SourceSpec; + + fn cmp_matches(&self, _a: &QuickMatch, _b: &QuickMatch) -> Ordering { + Ordering::Equal + } + + fn create_preview_footer(&self, _window: &mut Window, _cx: &mut App) -> Option { + None + } + + fn start_search(&self, ctx: SearchContext, sink: SearchSink, cx: &mut SearchUiContext<'_>); +} + +#[derive(Clone)] +pub struct SourceRegistry { + sources: Arc<[Arc]>, + indices_by_id: Arc>, +} + +pub struct SourceRegistryBuilder { + sources: Vec>, +} + +impl SourceRegistryBuilder { + pub fn new() -> Self { + Self { + sources: Vec::new(), + } + } + + pub fn with_source(mut self, source: T) -> Self { + self.sources.push(Arc::new(source)); + self + } + + pub fn build(self) -> SourceRegistry { + let mut indices_by_id = HashMap::::new(); + for (index, source) in self.sources.iter().enumerate() { + indices_by_id.insert(source.spec().id.clone(), index); + } + SourceRegistry { + sources: Arc::from(self.sources), + indices_by_id: Arc::new(indices_by_id), + } + } +} + +pub struct MatchBatcher { + batch_ids: Vec, + arena: Arc, +} + +impl MatchBatcher { + pub fn new(arena: Arc) -> Self { + Self { + batch_ids: Vec::with_capacity(crate::RESULTS_BATCH_SIZE), + arena, + } + } + + pub fn push(&mut self, match_item: QuickMatch, sink: &SearchSink, app: &mut AsyncApp) { + let id = self.arena.insert(match_item); + self.batch_ids.push(id); + if self.batch_ids.len() >= crate::RESULTS_BATCH_SIZE { + sink.flush_batch_ids(&mut self.batch_ids, &self.arena, app); + } + } + + pub fn flush(&mut self, sink: &SearchSink, app: &mut AsyncApp) { + sink.flush_batch_ids(&mut self.batch_ids, &self.arena, app); + } + + pub fn finish(mut self, sink: &SearchSink, app: &mut AsyncApp) { + self.flush(sink, app); + sink.finish_stream(app); + } +} + +pub struct MatchArena { + next_id: AtomicU64, + matches: Mutex>, +} + +impl MatchArena { + pub fn new() -> Self { + Self { + next_id: AtomicU64::new(1), + matches: Mutex::new(Vec::new()), + } + } + + pub fn insert(&self, mut match_item: QuickMatch) -> crate::types::MatchId { + let id = self + .next_id + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + match_item.id = id; + if match_item.key.0 == 0 { + match_item.key = crate::types::compute_match_key(&match_item); + } + let mut lock = match self.matches.lock() { + Ok(guard) => guard, + Err(poison) => poison.into_inner(), + }; + lock.push(match_item); + id + } + + pub fn get_cloned(&self, id: crate::types::MatchId) -> Option { + let lock = match self.matches.lock() { + Ok(guard) => guard, + Err(poison) => poison.into_inner(), + }; + let idx = id.checked_sub(1)? as usize; + lock.get(idx).cloned() + } +} + +#[derive(Clone)] +pub enum ConfirmOutcome { + OpenProjectPath { + project_path: project::ProjectPath, + point_range: Option>, + }, + OpenGitCommit { + repo_workdir: Arc, + sha: Arc, + }, + Dismiss, +} + +#[derive(Clone)] +pub struct GitCommitPreviewMeta { + pub sha: Arc, + pub subject: Arc, + pub author: Arc, + pub commit_timestamp: i64, + pub repo_label: Arc, + pub remote: Option<::git::GitRemote>, + pub github_url: Option>, +} + +#[derive(Clone)] +pub enum PreviewPanelUi { + GitCommit { + meta: GitCommitPreviewMeta, + }, + Standard { + path_text: Arc, + highlights: Vec, + }, +} + +impl SourceRegistry { + pub fn builder() -> SourceRegistryBuilder { + SourceRegistryBuilder::new() + } + + pub fn default_builtin() -> Self { + Self::builder() + .with_source(crate::sources::files::FilesSource) + .with_source(crate::sources::text_grep::TextGrepSource) + .with_source(crate::sources::commits::CommitsSource) + .build() + } + + pub fn available_sources(&self) -> &[Arc] { + &self.sources + } + + pub fn spec_for_id(&self, id: &SourceId) -> Option<&'static SourceSpec> { + let index = self.indices_by_id.get(id).copied()?; + self.sources.get(index).map(|source| source.spec()) + } + + pub fn source_for_id(&self, id: &SourceId) -> Option<&dyn QuickSearchSource> { + let index = self.indices_by_id.get(id).copied()?; + self.sources.get(index).map(|source| source.as_ref()) + } + + pub fn preview_request_for_match( + &self, + selected: &QuickMatch, + search_generation: usize, + weak_ranges: Vec>, + use_diff_preview: bool, + query: &str, + project: &Entity, + cx: &App, + ) -> PreviewRequest { + let key = PreviewKey(((search_generation as u64) << 32) | (selected.id & 0xFFFF_FFFF)); + match &selected.kind { + QuickMatchKind::Buffer { + buffer_id, ranges, .. + } => { + let buffer = project.read(cx).buffer_for_id(*buffer_id, cx); + let Some(buffer) = buffer else { + return match &selected.action { + MatchAction::OpenProjectPath { project_path, .. } => { + PreviewRequest::ProjectPath { + key, + project_path: project_path.clone(), + strong_ranges: ranges.clone(), + weak_ranges, + use_diff_preview, + } + } + _ => PreviewRequest::Empty, + }; + }; + + let snapshot = buffer.read(cx).snapshot(); + let mut strong_ranges = Vec::with_capacity(ranges.len()); + for range in ranges { + strong_ranges.push(crate::types::point_range_to_anchor_range( + range.clone(), + &snapshot.text, + )); + } + + let mut weak_anchor_ranges = Vec::with_capacity(weak_ranges.len()); + for range in weak_ranges { + weak_anchor_ranges.push(crate::types::point_range_to_anchor_range( + range, + &snapshot.text, + )); + } + + PreviewRequest::Buffer { + key, + buffer, + strong_ranges, + weak_ranges: weak_anchor_ranges, + use_diff_preview, + } + } + QuickMatchKind::ProjectPath { project_path } => PreviewRequest::ProjectPath { + key, + project_path: project_path.clone(), + strong_ranges: Vec::new(), + weak_ranges: Vec::new(), + use_diff_preview, + }, + QuickMatchKind::GitCommit { + repo_workdir, sha, .. + } => PreviewRequest::GitCommit { + key, + repo_workdir: repo_workdir.clone(), + sha: sha.clone(), + query: Arc::::from(query.to_string()), + }, + } + } + + pub fn confirm_outcome_for_match(&self, selected: &QuickMatch, _cx: &App) -> ConfirmOutcome { + match &selected.action { + MatchAction::OpenGitCommit { repo_workdir, sha } => ConfirmOutcome::OpenGitCommit { + repo_workdir: repo_workdir.clone(), + sha: sha.clone(), + }, + MatchAction::OpenProjectPath { + project_path, + point_range, + } => { + let mut point_range = point_range.clone(); + if point_range.is_none() { + if let QuickMatchKind::Buffer { ranges, .. } = &selected.kind { + point_range = ranges.first().cloned(); + } + } + ConfirmOutcome::OpenProjectPath { + project_path: project_path.clone(), + point_range, + } + } + MatchAction::Dismiss => ConfirmOutcome::Dismiss, + } + } + + pub fn preview_panel_ui_for_match( + &self, + selected: &QuickMatch, + project: &Entity, + cx: &mut App, + ) -> PreviewPanelUi { + match &selected.kind { + QuickMatchKind::GitCommit { + repo_workdir, + sha, + subject, + author, + repo_label, + commit_timestamp, + .. + } => PreviewPanelUi::GitCommit { + meta: { + let remote = resolve_git_remote_for_workdir(repo_workdir, project, cx); + let github_url = remote.as_ref().map(|remote| { + Arc::::from(format!( + "{}/{}/{}/commit/{}", + remote.host.base_url(), + remote.owner, + remote.repo, + sha, + )) + }); + GitCommitPreviewMeta { + sha: sha.clone(), + subject: subject.clone(), + author: author.clone(), + commit_timestamp: *commit_timestamp, + repo_label: repo_label.clone(), + remote, + github_url, + } + }, + }, + _ => PreviewPanelUi::Standard { + path_text: selected.display_path.clone(), + highlights: selected + .display_path_positions + .as_deref() + .map(|positions| positions.to_vec()) + .unwrap_or_default(), + }, + } + } +} + +fn resolve_git_remote_for_workdir( + repo_workdir: &Arc, + project: &Entity, + cx: &mut App, +) -> Option<::git::GitRemote> { + let git_store = project.read(cx).git_store().read(cx); + let repo = git_store + .repositories() + .values() + .find(|repo| repo.read(cx).work_directory_abs_path.as_ref() == repo_workdir.as_ref())?; + + let snapshot = repo.read(cx).snapshot(); + let remote_url = snapshot + .remote_upstream_url + .as_ref() + .or(snapshot.remote_origin_url.as_ref())?; + + let provider_registry = ::git::GitHostingProviderRegistry::default_global(cx); + let (host, parsed) = ::git::parse_git_remote_url(provider_registry, remote_url)?; + Some(::git::GitRemote { + host, + owner: parsed.owner.into(), + repo: parsed.repo.into(), + }) +} + +impl Default for SourceRegistry { + fn default() -> Self { + Self::default_builtin() + } +} + +pub fn default_source_id() -> SourceId { + SourceId(Arc::from("grep")) +} diff --git a/crates/quick_search/src/grouped_list.rs b/crates/quick_search/src/grouped_list.rs new file mode 100644 index 00000000000000..fe1e5fecb21dd2 --- /dev/null +++ b/crates/quick_search/src/grouped_list.rs @@ -0,0 +1,185 @@ +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, +}; + +use gpui::{App, Entity}; +use project::{Project, ProjectPath}; +use util::rel_path::RelPath; + +use crate::match_list::MatchList; +use crate::types::{GroupHeader, GroupKey, MatchId}; + +#[derive(Clone)] +pub struct GroupedFileHeader { + pub key: GroupKey, + pub header: GroupHeader, + pub match_count: usize, + pub worktree_name: Option>, + pub emphasize_worktree: bool, +} + +#[derive(Clone)] +pub enum GroupedRow { + FileHeader(GroupedFileHeader), + LineMatch { match_id: MatchId }, +} + +#[derive(Default)] +pub struct GroupedListState { + pub rows: Vec, + pub collapsed_groups: HashSet, + pub multi_worktree: bool, +} + +impl GroupedListState { + pub fn clear(&mut self) { + self.rows.clear(); + self.collapsed_groups.clear(); + self.multi_worktree = false; + } + + pub fn rebuild( + &mut self, + match_list: &mut MatchList, + selection: Option, + project: &Entity, + cx: &App, + ) -> Option { + let visible_matches = match_list.match_count(); + self.multi_worktree = project.read(cx).visible_worktrees(cx).count() > 1; + if visible_matches == 0 { + self.rows.clear(); + return None; + } + + let mut per_group: HashMap> = HashMap::default(); + let mut group_order: Vec = Vec::new(); + let mut group_headers: HashMap = HashMap::default(); + let mut group_project_paths: HashMap = HashMap::default(); + let mut path_occurrences: HashMap, usize> = HashMap::default(); + + for match_index in 0..visible_matches { + let Some(match_item) = match_list.item(match_index) else { + continue; + }; + let Some(group) = match_item.group.as_deref() else { + continue; + }; + let key = group.key; + + let entry = per_group.entry(key).or_insert_with(|| { + group_order.push(key); + group_headers.insert(key, group.header.clone()); + if let Some(project_path) = match_item.project_path().cloned() { + path_occurrences + .entry(project_path.path.clone()) + .and_modify(|count| *count = count.saturating_add(1)) + .or_insert(1); + group_project_paths.insert(key, project_path); + } + Vec::new() + }); + entry.push(match_item.id); + } + + let mut rows: Vec = Vec::with_capacity(visible_matches + per_group.len()); + for key in group_order { + let Some(match_indices) = per_group.get(&key) else { + continue; + }; + let match_count = match_indices.len(); + + let header = match group_headers.get(&key) { + Some(h) => h.clone(), + None => continue, + }; + let project_path = group_project_paths.get(&key); + + let worktree_name = if self.multi_worktree { + project_path.and_then(|project_path| { + project + .read(cx) + .worktree_for_id(project_path.worktree_id, cx) + .map(|worktree| { + Arc::::from(worktree.read(cx).root_name_str().to_string()) + }) + }) + } else { + None + }; + + let emphasize_worktree = self.multi_worktree + && project_path.is_some_and(|project_path| { + path_occurrences + .get(&project_path.path) + .copied() + .unwrap_or(0) + > 1 + }); + + rows.push(GroupedRow::FileHeader(GroupedFileHeader { + key, + header, + match_count, + worktree_name, + emphasize_worktree, + })); + + if self.collapsed_groups.contains(&key) { + continue; + } + for &match_id in match_indices { + rows.push(GroupedRow::LineMatch { match_id }); + } + } + + self.rows = rows; + + selection.and_then(|id| self.row_index_for_match_id(id)) + } + + pub fn row_index_for_match_id(&self, id: MatchId) -> Option { + self.rows + .iter() + .position(|row| matches!(row, GroupedRow::LineMatch { match_id } if *match_id == id)) + } + + pub fn toggle_group_collapsed( + &mut self, + match_list: &mut MatchList, + selection: Option, + project: &Entity, + key: GroupKey, + cx: &App, + ) -> Option { + if self.collapsed_groups.contains(&key) { + self.collapsed_groups.remove(&key); + } else { + self.collapsed_groups.insert(key); + } + self.rebuild(match_list, selection, project, cx) + } + + pub fn toggle_all_groups_collapsed( + &mut self, + match_list: &mut MatchList, + selection: Option, + project: &Entity, + clicked: GroupKey, + cx: &App, + ) -> Option { + let clicked_is_collapsed = self.collapsed_groups.contains(&clicked); + if clicked_is_collapsed { + self.collapsed_groups.clear(); + } else { + self.collapsed_groups.clear(); + for row in &self.rows { + if let GroupedRow::FileHeader(header) = row { + self.collapsed_groups.insert(header.key); + } + } + } + self.rebuild(match_list, selection, project, cx) + } +} diff --git a/crates/quick_search/src/match_list.rs b/crates/quick_search/src/match_list.rs new file mode 100644 index 00000000000000..14e0d9baa71752 --- /dev/null +++ b/crates/quick_search/src/match_list.rs @@ -0,0 +1,132 @@ +use crate::types::{MatchId, MatchKey, QuickMatch, QuickMatchPatch}; +use collections::HashMap; +use std::cmp::Ordering; + +pub struct MatchList { + items: Vec, + id_index: HashMap, + key_index: HashMap, + pending_patches_by_key: HashMap>, + max_results: usize, + truncated: bool, +} + +impl MatchList { + pub fn new(max_results: usize) -> Self { + Self { + items: Vec::new(), + id_index: HashMap::default(), + key_index: HashMap::default(), + pending_patches_by_key: HashMap::default(), + max_results, + truncated: false, + } + } + + pub fn clear(&mut self) { + self.items.clear(); + self.id_index.clear(); + self.key_index.clear(); + self.pending_patches_by_key.clear(); + self.truncated = false; + } + + pub fn extend(&mut self, batch: Vec) -> bool { + for mut match_item in batch { + if self.items.len() >= self.max_results { + self.truncated = true; + break; + } + if self.id_index.contains_key(&match_item.id) { + continue; + } + if self.key_index.contains_key(&match_item.key) { + continue; + } + if let Some(patches) = self.pending_patches_by_key.remove(&match_item.key) { + for patch in patches { + match_item.apply_patch(patch); + } + } + let index = self.items.len(); + let id = match_item.id; + let key = match_item.key; + self.items.push(match_item); + self.id_index.insert(id, index); + self.key_index.insert(key, id); + } + self.truncated + } + + pub fn match_count(&self) -> usize { + self.items.len() + } + + pub fn total_results(&self) -> usize { + self.items.len() + } + + pub fn is_truncated(&self) -> bool { + self.truncated + } + + pub fn item(&self, index: usize) -> Option<&QuickMatch> { + self.items.get(index) + } + + pub fn item_by_id(&self, id: MatchId) -> Option<&QuickMatch> { + let index = self.id_index.get(&id).copied()?; + self.items.get(index) + } + + pub fn index_by_id(&self, id: MatchId) -> Option { + self.id_index.get(&id).copied() + } + + pub fn id_by_key(&self, key: MatchKey) -> Option { + self.key_index.get(&key).copied() + } + + pub fn key_by_id(&self, id: MatchId) -> Option { + self.item_by_id(id).map(|match_item| match_item.key) + } + + pub fn update_by_id(&mut self, id: MatchId, patch: QuickMatchPatch) -> bool { + let Some(&index) = self.id_index.get(&id) else { + return false; + }; + if let Some(item) = self.items.get_mut(index) { + let changed = item.apply_patch(patch); + return changed; + } + false + } + + pub fn update_by_key_or_queue(&mut self, key: MatchKey, patch: QuickMatchPatch) -> bool { + let Some(id) = self.id_by_key(key) else { + if self.truncated { + return false; + } + self.pending_patches_by_key + .entry(key) + .or_default() + .push(patch); + return false; + }; + self.update_by_id(id, patch) + } + + pub fn sort_by(&mut self, mut compare: F) + where + F: FnMut(&QuickMatch, &QuickMatch) -> Ordering, + { + self.items + .sort_by(|a, b| compare(a, b).then_with(|| a.id.cmp(&b.id))); + self.id_index.clear(); + self.key_index.clear(); + for (index, match_item) in self.items.iter().enumerate() { + self.id_index.insert(match_item.id, index); + self.key_index.insert(match_item.key, match_item.id); + } + } +} diff --git a/crates/quick_search/src/quick_search.rs b/crates/quick_search/src/quick_search.rs new file mode 100644 index 00000000000000..bad2850861030f --- /dev/null +++ b/crates/quick_search/src/quick_search.rs @@ -0,0 +1,3380 @@ +pub(crate) mod core; +mod grouped_list; +pub(crate) mod match_list; +pub(crate) mod sources; +pub(crate) mod types; + +#[path = "quick_search_preview.rs"] +mod preview; + +use crate::{ + grouped_list::{GroupedFileHeader, GroupedListState, GroupedRow}, + match_list::MatchList, + preview::PreviewRequest, + preview::PreviewState, + types::{MatchId, MatchKey, PatchValue, QuickMatch}, +}; +use async_channel::Receiver; +use file_icons::FileIcons; +use gpui::AsyncApp; +use project::search::SearchResult; +use std::collections::HashMap; +use std::path::Path; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::{sync::Arc, time::Duration}; + +use ::git::GitRemote; +use editor::{Editor, EditorSettings, SelectionEffects, scroll::Autoscroll}; +use git_ui::commit_tooltip::CommitAvatar; +use gpui::SharedString; +use gpui::{ + Action, App, Context, DismissEvent, Entity, EntityInputHandler, FocusHandle, FocusOutEvent, + Focusable, HighlightStyle, InteractiveElement, Render, StatefulInteractiveElement, Styled, + StyledText, Task, WeakEntity, Window, +}; +use language::Buffer; +use log::debug; +use picker::{Picker, PickerDelegate}; +use project::{Project, debounced_delay::DebouncedDelay}; +use schemars::JsonSchema; +use search::{ + SearchOptions, ToggleCaseSensitive, ToggleIncludeIgnored, ToggleRegex, ToggleWholeWord, + search_bar::{input_base_styles, render_text_input}, +}; +use serde::Deserialize; +use settings::{Settings, SettingsStore}; +use text::Point; +use theme::ThemeSettings; +use ui::{ + Button, ButtonStyle, Chip, Color, DiffStat, Divider, DividerColor, HighlightedLabel, Icon, + IconButton, IconButtonShape, IconName, IconPosition, IconSize, KeyBinding, Label, LabelCommon, + LabelLike, LabelSize, ListItem, ListItemSpacing, SpinnerLabel, Tooltip, prelude::*, + rems_from_px, +}; +use workspace::{ActivatePane, ModalView, Workspace, item::PreviewTabsSettings, pane}; + +pub(crate) const MIN_QUERY_LEN: usize = 2; +const RESULTS_BATCH_SIZE: usize = 4096; + +const MODAL_SIZE_FRAC: f32 = 0.75; + +const STACK_BREAKPOINT_PX: f32 = 800.; + +const H_LIST_FRAC: f32 = 0.35; + +const PREVIEW_MIN_WIDTH_REM: f32 = 30.; +const PREVIEW_MIN_HEIGHT_REM: f32 = 12.; +const MAX_SNIPPET_BYTES: usize = 240; +const MAX_RESULTS: usize = 20_000; +const QUERY_DEBOUNCE_MS: u64 = 80; + +fn snippet_shared_for_entry(entry: &QuickMatch) -> SharedString { + let snippet_arc: Arc = entry + .first_line_snippet + .clone() + .or_else(|| { + entry.snippet.as_ref().and_then(|s| { + s.lines() + .next() + .map(|line| Arc::::from(line.to_string())) + }) + }) + .unwrap_or_else(|| Arc::::from("")); + SharedString::new(snippet_arc) +} + +/// Toggles the preview-side footer (source-provided details panel). +#[derive(Default, PartialEq, Eq, Clone, Deserialize, JsonSchema, Action)] +#[action(namespace = quick_search, name = "TogglePreviewFooter")] +#[serde(deny_unknown_fields)] +struct TogglePreviewFooter; + +fn syntax_and_match_snippet_element( + entry: &QuickMatch, + snippet_shared: &SharedString, + cx: &App, +) -> Option { + let mut syntax_highlights: Vec<(std::ops::Range, HighlightStyle)> = Vec::new(); + let mut match_highlights: Vec<(std::ops::Range, HighlightStyle)> = Vec::new(); + + if let Some(syntax_runs) = entry.snippet_syntax_highlights.as_deref() { + let syntax_theme = cx.theme().syntax(); + for (range, highlight_id) in syntax_runs.iter() { + if let Some(style) = highlight_id.style(syntax_theme) { + syntax_highlights.push((range.clone(), style)); + } + } + } + + if let Some(match_ranges) = entry.snippet_match_positions.as_deref() { + let style = HighlightStyle { + background_color: Some(cx.theme().colors().search_match_background.opacity(0.35)), + ..Default::default() + }; + match_highlights.extend(match_ranges.iter().cloned().map(|r| (r, style))); + } + + if syntax_highlights.is_empty() && match_highlights.is_empty() { + return None; + } + + let highlights = gpui::combine_highlights(syntax_highlights, match_highlights); + Some( + LabelLike::new() + .size(LabelSize::Small) + .single_line() + .truncate() + .child(StyledText::new(snippet_shared.clone()).with_highlights(highlights)) + .into_any_element(), + ) +} + +pub fn init(cx: &mut App) { + cx.observe_new(QuickSearch::register).detach(); +} + +pub struct QuickSearch { + picker: Entity>, + preview: PreviewState, + source_registry: core::SourceRegistry, + preview_footer: PreviewFooterState, + focus_handle: FocusHandle, +} + +impl QuickSearch { + fn register( + workspace: &mut Workspace, + _window: Option<&mut Window>, + cx: &mut Context, + ) { + let workspace_handle = cx.entity().downgrade(); + workspace.register_action( + move |workspace, _: &workspace::ToggleQuickSearch, window, cx| { + let selected_text = workspace + .active_item(cx) + .and_then(|item| item.act_as::(cx)) + .and_then(|editor| { + editor.update(cx, |editor, cx| { + let selection = editor.selected_text_range(true, window, cx)?; + if selection.range.is_empty() { + return None; + } + let mut adjusted = None; + let text = editor.text_for_range( + selection.range, + &mut adjusted, + window, + cx, + )?; + if text.contains('\n') { + return None; + } + let trimmed = text.trim().to_string(); + (!trimmed.is_empty()).then_some(trimmed) + }) + }); + let project = workspace.project().clone(); + let workspace_handle = workspace_handle.clone(); + workspace.toggle_modal(window, cx, move |window, cx| { + QuickSearch::new(workspace_handle.clone(), project, selected_text, window, cx) + }) + }, + ); + + workspace.register_action(move |workspace, _: &ToggleRegex, window, cx| { + if let Some(active) = workspace.active_modal::(cx) { + active.update(cx, |qs, cx| { + qs.toggle_search_option(SearchOptions::REGEX, window, cx) + }); + } else { + cx.propagate(); + } + }); + workspace.register_action(move |workspace, _: &ToggleCaseSensitive, window, cx| { + if let Some(active) = workspace.active_modal::(cx) { + active.update(cx, |qs, cx| { + qs.toggle_search_option(SearchOptions::CASE_SENSITIVE, window, cx) + }); + } else { + cx.propagate(); + } + }); + workspace.register_action(move |workspace, _: &ToggleWholeWord, window, cx| { + if let Some(active) = workspace.active_modal::(cx) { + active.update(cx, |qs, cx| { + qs.toggle_search_option(SearchOptions::WHOLE_WORD, window, cx) + }); + } else { + cx.propagate(); + } + }); + workspace.register_action(move |workspace, _: &ToggleIncludeIgnored, window, cx| { + if let Some(active) = workspace.active_modal::(cx) { + active.update(cx, |qs, cx| { + qs.toggle_search_option(SearchOptions::INCLUDE_IGNORED, window, cx) + }); + } else { + cx.propagate(); + } + }); + workspace.register_action(move |workspace, action: &pane::ActivateItem, window, cx| { + if let Some(active) = workspace.active_modal::(cx) { + active.update(cx, |qs, cx| qs.handle_activate_item(action, window, cx)); + } else { + cx.propagate(); + } + }); + workspace.register_action( + move |workspace, action: &pane::ActivateNextItem, window, cx| { + if let Some(active) = workspace.active_modal::(cx) { + active.update(cx, |qs, cx| { + qs.handle_activate_next_item(action, window, cx) + }); + } else { + cx.propagate(); + } + }, + ); + + workspace.register_action( + move |workspace, action: &pane::ActivatePreviousItem, window, cx| { + if let Some(active) = workspace.active_modal::(cx) { + active.update(cx, |qs, cx| { + qs.handle_activate_previous_item(action, window, cx) + }); + } else { + cx.propagate(); + } + }, + ); + + workspace.register_action(move |workspace, action: &ActivatePane, window, cx| { + if let Some(active) = workspace.active_modal::(cx) { + active.update(cx, |qs, cx| qs.handle_activate_pane(action, window, cx)); + } else { + cx.propagate(); + } + }); + } + + fn new( + workspace: WeakEntity, + project: Entity, + initial_query: Option, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let focus_handle = cx.focus_handle(); + cx.on_focus_out(&focus_handle, window, Self::handle_focus_out) + .detach(); + + let preview_project = project.clone(); + let initial_buffer = cx.new(|cx| Buffer::local("", cx)); + let editor_settings = EditorSettings::get_global(cx); + let search_options = SearchOptions::from_settings(&editor_settings.search); + let preview = PreviewState::new(preview_project, initial_buffer, window, cx); + let source_registry = core::SourceRegistry::default(); + let mut preview_footer = PreviewFooterState::new(&source_registry, window, cx); + let initial_source = core::default_source_id(); + let mut delegate = QuickSearchDelegate::new( + cx.entity().downgrade(), + workspace, + window.window_handle(), + project, + search_options, + source_registry.clone(), + ); + delegate + .search_engine + .set_active_source(initial_source.clone()); + preview_footer.set_active_source(initial_source, window, cx); + let picker = cx.new(|cx| { + let picker = Picker::uniform_list(delegate, window, cx) + .modal(false) + .show_scrollbar(true) + .max_height(None); + if let Some(query) = initial_query { + picker.set_query(query, window, cx); + } + picker + }); + + Self { + picker, + preview, + source_registry, + preview_footer, + focus_handle, + } + } + + fn handle_focus_out( + &mut self, + _event: FocusOutEvent, + window: &mut Window, + cx: &mut Context, + ) { + // Defer so we don't dismiss during transient focus transitions. + let owner = cx.entity().downgrade(); + window.defer(cx, move |window, cx| { + let Some(qs) = owner.upgrade() else { + return; + }; + qs.update(cx, |qs, cx| { + if qs.focus_handle.contains_focused(window, cx) { + return; + } + cx.emit(DismissEvent); + }); + }); + } + + fn toggle_search_option( + &mut self, + option: SearchOptions, + window: &mut Window, + cx: &mut Context, + ) { + self.picker.update(cx, |picker, cx| { + picker.delegate.search_engine.search_options.toggle(option); + let options = picker.delegate.search_engine.search_options; + let mut settings = EditorSettings::get_global(cx).clone(); + settings.search.case_sensitive = options.contains(SearchOptions::CASE_SENSITIVE); + settings.search.whole_word = options.contains(SearchOptions::WHOLE_WORD); + settings.search.include_ignored = options.contains(SearchOptions::INCLUDE_IGNORED); + settings.search.regex = options.contains(SearchOptions::REGEX); + SettingsStore::update(cx, |store, _| { + store.override_global(settings); + }); + picker.refresh(window, cx); + }); + } + + fn set_search_source( + &mut self, + source_id: core::SourceId, + window: &mut Window, + cx: &mut Context, + ) { + let owner = cx.entity().downgrade(); + let session_cancellation = self.picker.read(cx).delegate.search_engine.cancellation(); + self.preview.request_preview( + PreviewRequest::Empty, + session_cancellation, + &owner, + window, + cx, + ); + self.preview_footer + .set_active_source(source_id.clone(), window, cx); + self.picker.update(cx, |picker, cx| { + picker + .delegate + .search_engine + .set_active_source(source_id.clone()); + picker.delegate.match_list.clear(); + picker.delegate.selection = None; + picker.delegate.reset_scroll = true; + picker.delegate.total_results = 0; + picker.delegate.is_streaming = false; + picker.delegate.stream_finished = true; + picker.delegate.clear_grouped_list_state(); + picker.delegate.rebuild_rows_after_match_list_changed(); + picker.refresh_placeholder(window, cx); + let query = picker.query(cx); + let _scheduled = picker.delegate.search_engine.schedule_search_with_delay( + query, + Duration::from_millis(0), + cx, + ); + picker.refresh(window, cx); + }); + } + + fn cycle_search_source(&mut self, delta: isize, window: &mut Window, cx: &mut Context) { + let sources = self.source_registry.available_sources(); + if sources.is_empty() { + return; + } + + let active_source = self + .picker + .read(cx) + .delegate + .search_engine + .active_source + .clone(); + let len = sources.len() as isize; + let active_ix = sources + .iter() + .position(|s| s.spec().id == active_source) + .unwrap_or(0) as isize; + let next_ix = (active_ix + delta).rem_euclid(len) as usize; + let next_source = sources[next_ix].spec().id.clone(); + self.set_search_source(next_source, window, cx); + } + + fn handle_activate_item( + &mut self, + action: &pane::ActivateItem, + window: &mut Window, + cx: &mut Context, + ) { + let sources = self.source_registry.available_sources(); + let index = action.0; + if index >= sources.len() { + return; + } + let source_id = sources[index].spec().id.clone(); + self.set_search_source(source_id, window, cx); + } + + fn handle_activate_next_item( + &mut self, + _: &pane::ActivateNextItem, + window: &mut Window, + cx: &mut Context, + ) { + self.cycle_search_source(1, window, cx); + } + + fn handle_activate_previous_item( + &mut self, + _: &pane::ActivatePreviousItem, + window: &mut Window, + cx: &mut Context, + ) { + self.cycle_search_source(-1, window, cx); + } + + fn handle_activate_pane( + &mut self, + action: &ActivatePane, + window: &mut Window, + cx: &mut Context, + ) { + let sources = self.source_registry.available_sources(); + let index = action.0; + if index >= sources.len() { + return; + } + let source_id = sources[index].spec().id.clone(); + self.set_search_source(source_id, window, cx); + } +} + +impl ModalView for QuickSearch { + fn on_before_dismiss( + &mut self, + _window: &mut Window, + _cx: &mut Context, + ) -> workspace::DismissDecision { + // Guard hook for future focus-stealing popovers/menus. + workspace::DismissDecision::Dismiss(true) + } +} + +impl gpui::EventEmitter for QuickSearch {} + +impl Focusable for QuickSearch { + fn focus_handle(&self, cx: &App) -> FocusHandle { + self.picker.focus_handle(cx) + } +} + +struct Layout { + modal_width: Pixels, + modal_height: Pixels, + content_height: Pixels, + is_horizontal: bool, + show_preview: bool, +} + +impl Layout { + fn compute(window: &Window) -> Self { + let viewport = window.viewport_size(); + let rem_size = window.rem_size(); + + let modal_width = viewport.width * MODAL_SIZE_FRAC; + let modal_height = viewport.height * MODAL_SIZE_FRAC; + + let content_height = modal_height; + + let preview_min_width_px = rems(PREVIEW_MIN_WIDTH_REM).to_pixels(rem_size); + let preview_min_height_px = rems(PREVIEW_MIN_HEIGHT_REM).to_pixels(rem_size); + + let preview_width_in_horiz = modal_width * (1.0 - H_LIST_FRAC); + let is_horizontal = viewport.width > px(STACK_BREAKPOINT_PX) + && preview_width_in_horiz >= preview_min_width_px; + + let show_preview = if is_horizontal { + modal_height >= preview_min_height_px + } else { + modal_height * 0.5 >= preview_min_height_px + }; + + Self { + modal_width, + modal_height, + content_height, + is_horizontal, + show_preview, + } + } +} + +struct PreviewFooterState { + preview_visible: bool, + active_source: core::SourceId, + instances: HashMap, + open_by_source: HashMap, + cancellation_by_source: HashMap, + last_context_by_source: HashMap, + last_selected_by_source: HashMap, +} + +#[derive(Clone, PartialEq, Eq)] +struct FooterContextKey { + query: Arc, + selected_key: Option, +} + +impl PreviewFooterState { + fn new( + source_registry: &core::SourceRegistry, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let mut instances = HashMap::::new(); + let mut open_by_source = HashMap::::new(); + + for source in source_registry.available_sources() { + let Some(instance) = source.create_preview_footer(window, cx) else { + continue; + }; + let source_id = source.spec().id.clone(); + open_by_source.insert(source_id.clone(), instance.spec.default_open); + + cx.observe(instance.host.state_entity(), |this, _, cx| { + this.picker.update(cx, |_picker, cx| cx.notify()); + cx.notify(); + }) + .detach(); + + instances.insert(source_id, instance); + } + + Self { + preview_visible: true, + active_source: core::default_source_id(), + instances, + open_by_source, + cancellation_by_source: HashMap::new(), + last_context_by_source: HashMap::new(), + last_selected_by_source: HashMap::new(), + } + } + + fn set_preview_visible(&mut self, preview_visible: bool) { + if self.preview_visible && !preview_visible { + for cancellation in self.cancellation_by_source.values() { + cancellation.cancel(); + } + } + self.preview_visible = preview_visible; + } + + fn set_active_source(&mut self, source_id: core::SourceId, _window: &mut Window, cx: &mut App) { + if self.active_source == source_id { + return; + } + + if let Some(cancellation) = self.cancellation_by_source.get(&self.active_source) { + cancellation.cancel(); + } + + self.active_source = source_id; + if let Some(instance) = self.instances.get(&self.active_source) { + instance.host.set_loading(false, cx); + instance.host.set_has_content(false, cx); + } + } + + fn update_active_context( + &mut self, + selected: Option, + query: Arc, + preview_buffer: Option>, + session_cancellation: core::SearchCancellation, + project: Entity, + window: &mut Window, + cx: &mut App, + ) { + let Some(instance) = self.instances.get(&self.active_source) else { + return; + }; + + if !self.preview_visible || query.is_empty() { + if let Some(prev) = self.cancellation_by_source.get(&self.active_source) { + prev.cancel(); + } + instance.host.set_loading(false, cx); + instance.host.set_has_content(false, cx); + self.last_selected_by_source.remove(&self.active_source); + self.last_context_by_source.remove(&self.active_source); + return; + } + + if let Some(selected) = selected.as_ref() { + self.last_selected_by_source + .insert(self.active_source.clone(), selected.clone()); + } + + let context_key = FooterContextKey { + query: query.clone(), + selected_key: selected.as_ref().map(|selected| selected.key), + }; + + if selected.is_none() { + if self + .last_context_by_source + .get(&self.active_source) + .is_some_and(|prev| prev.query == query && prev.selected_key.is_some()) + { + return; + } + } + + if self + .last_context_by_source + .get(&self.active_source) + .is_some_and(|prev| prev == &context_key) + { + return; + } + + self.last_context_by_source + .insert(self.active_source.clone(), context_key); + + if let Some(prev) = self.cancellation_by_source.get(&self.active_source) { + prev.cancel(); + } + + let local_cancellation = core::SearchCancellation::new(Arc::new(AtomicBool::new(false))); + self.cancellation_by_source + .insert(self.active_source.clone(), local_cancellation.clone()); + + let open = self + .open_by_source + .get(&self.active_source) + .copied() + .unwrap_or(instance.spec.default_open); + (instance.handle_event)(core::FooterEvent::OpenChanged(open), window, cx); + (instance.handle_event)( + core::FooterEvent::ContextChanged(core::FooterContext { + project, + query, + selected: selected.or_else(|| { + self.last_selected_by_source + .get(&self.active_source) + .cloned() + }), + preview_buffer, + cancellation: core::FooterCancellation::new( + session_cancellation, + local_cancellation, + ), + }), + window, + cx, + ); + } + + fn toggle_active_open(&mut self, has_selected: bool, window: &mut Window, cx: &mut App) { + let Some(instance) = self.instances.get(&self.active_source) else { + return; + }; + if !instance.spec.toggleable { + return; + } + if !self.preview_visible { + return; + } + if !has_selected { + return; + } + let state = instance.host.snapshot(cx); + if !state.has_content && !state.loading { + return; + } + + let current = self + .open_by_source + .get(&self.active_source) + .copied() + .unwrap_or(instance.spec.default_open); + let next = !current; + self.open_by_source.insert(self.active_source.clone(), next); + (instance.handle_event)(core::FooterEvent::OpenChanged(next), window, cx); + } + + fn active_toggle_button( + &self, + selected: Option<&QuickMatch>, + cx: &App, + ) -> Option<(Arc, bool)> { + if !self.preview_visible { + return None; + } + if selected.is_none() { + return None; + } + + let instance = self.instances.get(&self.active_source)?; + if !instance.spec.toggleable { + return None; + } + let state = instance.host.snapshot(cx); + if !state.has_content && !state.loading { + return None; + } + + let open = self + .open_by_source + .get(&self.active_source) + .copied() + .unwrap_or(instance.spec.default_open); + Some((instance.spec.title.clone(), open)) + } + + fn render_footer_for_active_source( + &self, + max_height: gpui::Pixels, + active_source: &core::SourceId, + selected: Option<&QuickMatch>, + _window: &mut Window, + cx: &mut App, + ) -> Option { + if !self.preview_visible { + return None; + } + if *active_source != self.active_source { + return None; + } + let selected = selected.or_else(|| self.last_selected_by_source.get(active_source)); + if selected.is_none() { + return None; + } + + let instance = self.instances.get(active_source)?; + let state = instance.host.snapshot(cx); + if !state.has_content && !state.loading { + return None; + } + + let open = self + .open_by_source + .get(active_source) + .copied() + .unwrap_or(instance.spec.default_open); + if !open { + return None; + } + + let buffer_font_size = ThemeSettings::get_global(cx).buffer_font_size(cx); + Some( + div() + .id("quick_search-preview-footer") + .flex_shrink_0() + .min_h_0() + .max_h(max_height) + .overflow_y_scroll() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .bg(cx.theme().colors().panel_background) + .text_size(buffer_font_size) + .child(instance.view.clone()) + .into_any_element(), + ) + } +} + +impl Render for QuickSearch { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let layout = Layout::compute(window); + self.preview_footer.set_preview_visible(layout.show_preview); + + div() + .w(layout.modal_width) + .h(layout.modal_height) + .overflow_hidden() + .elevation_3(cx) + .track_focus(&self.focus_handle) + .key_context("QuickSearch") + .on_action(cx.listener(Self::handle_activate_item)) + .on_action(cx.listener(Self::handle_activate_next_item)) + .on_action(cx.listener(Self::handle_activate_previous_item)) + .on_action(cx.listener(Self::handle_activate_pane)) + .on_action(cx.listener(Self::handle_toggle_preview_footer)) + .bg(cx.theme().colors().panel_background) + .border_1() + .border_color(cx.theme().colors().border) + .rounded_lg() + .child(self.render_content(&layout, window, cx)) + } +} + +impl QuickSearch { + fn handle_toggle_preview_footer( + &mut self, + _: &TogglePreviewFooter, + window: &mut Window, + cx: &mut Context, + ) { + let has_selected = self.picker.read(cx).delegate.selected_match().is_some(); + self.preview_footer + .toggle_active_open(has_selected, window, cx); + self.preview.needs_preview_scroll = true; + let owner = cx.entity().downgrade(); + window.defer(cx, move |window, cx| { + let Some(qs) = owner.upgrade() else { + return; + }; + qs.update(cx, |qs, cx| { + qs.preview.apply_preview_selection(window, cx); + }); + }); + self.picker.update(cx, |_picker, cx| cx.notify()); + cx.notify(); + } + + fn render_content( + &mut self, + layout: &Layout, + window: &mut Window, + cx: &mut Context, + ) -> impl IntoElement { + let list = self.render_list_panel(layout, cx); + let preview = self.render_preview_panel(layout, window, cx); + + if layout.is_horizontal { + h_flex() + .w_full() + .h(layout.content_height) + .overflow_hidden() + .child(list) + .child(Divider::vertical().color(DividerColor::Border)) + .child(preview) + } else { + v_flex() + .w_full() + .h(layout.content_height) + .overflow_hidden() + .child(list) + .child(Divider::horizontal().color(DividerColor::Border)) + .child(preview) + } + } + + fn render_list_panel(&self, layout: &Layout, _cx: &mut Context) -> Div { + let list_content = v_flex() + .h_full() + .overflow_hidden() + .child( + div().flex_none().px_2().pt_2().pb_1().child( + Label::new("Quick Search") + .size(LabelSize::Default) + .color(Color::Muted), + ), + ) + .child( + div() + .flex_1() + .min_h_0() + .overflow_hidden() + .px_1() + .child(self.picker.clone()), + ); + if layout.is_horizontal { + let list_width = layout.modal_width * H_LIST_FRAC; + v_flex() + .w(list_width) + .h(layout.content_height) + .flex_shrink_0() + .overflow_hidden() + .child(list_content) + } else { + let list_height = layout.content_height * 0.4; + v_flex() + .w_full() + .h(list_height) + .overflow_hidden() + .child(list_content) + } + } + + fn render_preview_panel( + &mut self, + layout: &Layout, + window: &mut Window, + cx: &mut Context, + ) -> Div { + let selected = self.picker.read(cx).delegate.selected_match().cloned(); + let content = if layout.show_preview { + self.render_preview_content(selected.clone(), window, cx) + } else { + Self::render_placeholder("Preview hidden (window too small)") + }; + + let active_source = self + .picker + .read(cx) + .delegate + .search_engine + .active_source + .clone(); + let footer = if layout.show_preview { + let preview_height = if layout.is_horizontal { + layout.content_height + } else { + layout.content_height * 0.55 + }; + self.preview_footer.render_footer_for_active_source( + preview_height * 0.35, + &active_source, + selected.as_ref(), + window, + cx, + ) + } else { + None + }; + + let base = v_flex() + .min_w_0() + .overflow_hidden() + .child(div().flex_1().min_h_0().overflow_hidden().child(content)) + .when_some(footer, |this, footer| this.child(footer)); + + if layout.is_horizontal { + base.flex_1().h(layout.content_height) + } else { + let preview_height = layout.content_height * 0.55; + base.w_full().h(preview_height) + } + } + + fn render_git_commit_avatar( + sha: &SharedString, + remote: Option<&GitRemote>, + size: impl Into, + window: &mut Window, + cx: &mut App, + ) -> AnyElement { + let size = size.into(); + let avatar = CommitAvatar::new(sha, remote); + + v_flex() + .w(size) + .h(size) + .border_1() + .border_color(cx.theme().colors().border) + .rounded_full() + .justify_center() + .items_center() + .child( + avatar + .avatar(window, cx) + .map(|a| a.size(size).into_any_element()) + .unwrap_or_else(|| { + Icon::new(IconName::Person) + .color(Color::Muted) + .size(IconSize::Medium) + .into_any_element() + }), + ) + .into_any() + } + + fn render_preview_content( + &self, + selected: Option, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + if let Some(error) = &self.preview.error_message { + return Self::render_error(error); + } + + let Some(selected) = selected else { + return Self::render_placeholder("Select a match to preview"); + }; + + if selected.is_likely_binary() { + return Self::render_non_text_placeholder(&selected.file_name); + } + + let project = self.preview.project(); + match self + .source_registry + .preview_panel_ui_for_match(&selected, &project, cx) + { + core::PreviewPanelUi::GitCommit { meta } => { + self.render_git_commit_preview(meta, &selected, window, cx) + } + core::PreviewPanelUi::Standard { + path_text, + highlights, + } => self.render_standard_preview(path_text, highlights, &selected, window, cx), + } + } + + fn render_standard_preview( + &self, + path_text: Arc, + highlights: Vec, + selected: &QuickMatch, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let meta_line = selected + .blame + .as_ref() + .map(|b| b.as_ref()) + .unwrap_or("Context not available yet"); + + v_flex() + .size_full() + .overflow_hidden() + .child( + v_flex() + .flex_shrink_0() + .px_2() + .py_1() + .gap_0p5() + .bg(cx.theme().colors().panel_background) + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child(Self::render_preview_header(path_text, selected, highlights)) + .child( + Label::new(meta_line.to_string()) + .size(LabelSize::XSmall) + .color(Color::Muted) + .single_line() + .truncate(), + ), + ) + .child(self.render_preview_editor(selected, None, false, window, cx)) + .into_any_element() + } + + fn render_git_commit_preview( + &self, + meta: core::GitCommitPreviewMeta, + selected: &QuickMatch, + window: &mut Window, + cx: &mut Context, + ) -> AnyElement { + let subject = meta + .subject + .as_ref() + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + let author = meta.author.as_ref().trim().to_string(); + let full_sha = meta.sha.as_ref().to_string(); + + let commit_date = time::OffsetDateTime::from_unix_timestamp(meta.commit_timestamp) + .unwrap_or_else(|_| time::OffsetDateTime::now_utc()); + let local_offset = time::UtcOffset::current_local_offset().unwrap_or(time::UtcOffset::UTC); + let date_string = time_format::format_localized_timestamp( + commit_date, + time::OffsetDateTime::now_utc(), + local_offset, + time_format::TimestampFormat::MediumAbsolute, + ); + + let remote = meta.remote.clone(); + let github_url = meta.github_url.clone(); + + let sha_shared = SharedString::from(full_sha.clone()); + let avatar = Self::render_git_commit_avatar( + &sha_shared, + remote.as_ref(), + rems_from_px(48.), + window, + cx, + ); + + let commit_diff_stat = self.commit_diff_stat_for_preview(cx); + + let header = v_flex() + .gap_1p5() + .child( + h_flex() + .gap_2() + .items_center() + .child( + div() + .w(rems_from_px(48.)) + .h(rems_from_px(48.)) + .rounded_full() + .border_1() + .border_color(cx.theme().colors().border) + .items_center() + .justify_center() + .child(avatar), + ) + .child( + v_flex() + .overflow_hidden() + .child( + Label::new(subject) + .size(LabelSize::Small) + .single_line() + .truncate(), + ) + .child( + h_flex() + .gap_1() + .child(Label::new(author).color(Color::Default)) + .child( + Label::new(format!("Commit:{}", full_sha)) + .color(Color::Muted) + .size(LabelSize::Small) + .truncate() + .buffer_font(cx), + ), + ), + ), + ) + .child( + h_flex() + .gap_1p5() + .child( + Label::new(date_string) + .color(Color::Muted) + .size(LabelSize::Small), + ) + .child(Label::new("•").color(Color::Ignored).size(LabelSize::Small)) + .children(commit_diff_stat) + .when(!meta.repo_label.trim().is_empty(), |this| { + this.child( + Label::new(meta.repo_label.as_ref().to_string()) + .size(LabelSize::Small) + .color(Color::Muted) + .single_line() + .truncate(), + ) + }) + .when_some(github_url.as_ref(), |this, url| { + this.flex_1().justify_end().child( + Button::new("quick-search-view-on-github", "View on GitHub") + .icon(IconName::Github) + .icon_color(Color::Muted) + .icon_size(IconSize::Small) + .icon_position(IconPosition::Start) + .on_click({ + let url = url.to_string(); + move |_, _, cx| cx.open_url(&url) + }), + ) + }), + ); + + v_flex() + .size_full() + .overflow_hidden() + .child( + div() + .flex_shrink_0() + .px_2() + .py_2() + .bg(cx.theme().colors().panel_background) + .border_b_1() + .border_color(cx.theme().colors().border_variant) + .child(header), + ) + .child(self.render_preview_editor(selected, None, false, window, cx)) + .into_any_element() + } + + fn commit_diff_stat_for_preview(&self, cx: &App) -> Option { + let editor = self.preview.preview_editor(); + let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx); + let mut total_additions = 0u32; + let mut total_deletions = 0u32; + + let mut seen_buffers = std::collections::HashSet::new(); + for (_, buffer, _) in snapshot.excerpts() { + let buffer_id = buffer.remote_id(); + if !seen_buffers.insert(buffer_id) { + continue; + } + + let Some(diff) = snapshot.diff_for_buffer_id(buffer_id) else { + continue; + }; + let base_text = diff.base_text(); + for hunk in + diff.hunks_intersecting_range(language::Anchor::MIN..language::Anchor::MAX, buffer) + { + let added_rows = hunk.range.end.row.saturating_sub(hunk.range.start.row); + total_additions += added_rows; + + let base_start = base_text + .offset_to_point(hunk.diff_base_byte_range.start) + .row; + let base_end = base_text.offset_to_point(hunk.diff_base_byte_range.end).row; + let deleted_rows = base_end.saturating_sub(base_start); + total_deletions += deleted_rows; + } + } + + if total_additions == 0 && total_deletions == 0 { + return None; + } + + Some(DiffStat::new( + "quick-search-commit-diff-stat", + total_additions as usize, + total_deletions as usize, + )) + } + + fn render_placeholder(message: &str) -> AnyElement { + div() + .size_full() + .flex() + .items_center() + .justify_center() + .child(Label::new(message.to_string()).color(Color::Muted)) + .into_any_element() + } + + fn render_non_text_placeholder(file_name: &str) -> AnyElement { + div() + .size_full() + .flex() + .items_center() + .justify_center() + .child( + v_flex() + .gap_2() + .items_center() + .child( + Icon::new(IconName::File) + .color(Color::Muted) + .size(ui::IconSize::Medium), + ) + .child( + Label::new(format!("Preview not available for {}", file_name)) + .color(Color::Muted) + .size(LabelSize::Small), + ), + ) + .into_any_element() + } + + fn render_error(message: &str) -> AnyElement { + div() + .size_full() + .flex() + .items_center() + .justify_center() + .child( + v_flex() + .gap_2() + .items_center() + .child( + Icon::new(IconName::Warning) + .color(Color::Warning) + .size(ui::IconSize::Medium), + ) + .child( + Label::new(message.to_string()) + .color(Color::Warning) + .size(LabelSize::Small), + ), + ) + .into_any_element() + } + + fn render_preview_header( + path_text: Arc, + selected: &QuickMatch, + highlights: Vec, + ) -> Div { + h_flex() + .gap_1() + .items_center() + .flex_shrink_0() + .child( + HighlightedLabel::new(path_text, highlights) + .size(LabelSize::XSmall) + .color(Color::Muted) + .truncate() + .single_line(), + ) + .when_some(selected.location_label.as_ref(), |this, location| { + this.child( + Label::new(location) + .size(LabelSize::XSmall) + .color(Color::Muted) + .single_line(), + ) + }) + } + + fn render_preview_editor( + &self, + _selected: &QuickMatch, + _blame: Option>, + _show_meta: bool, + _window: &mut Window, + cx: &mut Context, + ) -> Div { + div() + .flex_1() + .min_h_0() + .size_full() + .bg(cx.theme().colors().editor_background) + .overflow_hidden() + .child(self.preview.preview_editor()) + } +} + +struct QuickSearchDelegate { + match_list: MatchList, + selection: Option, + grouped_rows_dirty: bool, + quick_search: WeakEntity, + workspace: WeakEntity, + window_handle: gpui::AnyWindowHandle, + project: Entity, + query_error: Option, + query_notice: Option, + search_engine: SearchEngine, + source_registry: core::SourceRegistry, + current_query: String, + reset_scroll: bool, + is_streaming: bool, + total_results: usize, + notify_pending: bool, + notify_debouncer: DebouncedDelay>, + notify_scheduled: bool, + notify_interval_ms: u64, + next_match_id: MatchId, + stream_finished: bool, + grouped_list: GroupedListState, +} + +impl QuickSearchDelegate { + fn new( + quick_search: WeakEntity, + workspace: WeakEntity, + window_handle: gpui::AnyWindowHandle, + project: Entity, + search_options: SearchOptions, + source_registry: core::SourceRegistry, + ) -> Self { + Self { + match_list: MatchList::new(MAX_RESULTS), + selection: None, + grouped_rows_dirty: false, + quick_search, + workspace, + window_handle, + project, + query_error: None, + query_notice: None, + search_engine: SearchEngine::new(search_options, QUERY_DEBOUNCE_MS), + source_registry, + current_query: String::new(), + reset_scroll: false, + is_streaming: false, + total_results: 0, + notify_pending: false, + notify_debouncer: DebouncedDelay::new(), + notify_scheduled: false, + notify_interval_ms: 32, + next_match_id: 0, + stream_finished: false, + grouped_list: Default::default(), + } + } + + fn reset_notify_throttle(&mut self) { + self.notify_pending = false; + self.notify_scheduled = false; + self.notify_debouncer = DebouncedDelay::new(); + } + + fn schedule_notify_if_needed(&mut self, generation: usize, cx: &mut Context) { + if !self.notify_pending || self.notify_scheduled { + return; + } + + self.notify_scheduled = true; + let interval_ms = self.notify_interval_ms; + self.notify_debouncer.fire_new( + Duration::from_millis(interval_ms), + cx, + move |picker, cx| { + if picker.delegate.search_engine.generation() != generation { + picker.delegate.reset_notify_throttle(); + return Task::ready(()); + } + + picker.delegate.ensure_grouped_rows_built(cx); + if picker.delegate.notify_pending { + cx.notify(); + } + picker.delegate.notify_pending = false; + picker.delegate.notify_scheduled = false; + Task::ready(()) + }, + ); + } + + fn request_notify( + &mut self, + generation: usize, + immediate: bool, + cx: &mut Context, + ) { + if self.search_engine.generation() != generation { + self.reset_notify_throttle(); + return; + } + + if immediate { + self.ensure_grouped_rows_built(cx); + cx.notify(); + self.notify_pending = false; + self.notify_scheduled = false; + return; + } + + self.schedule_notify_if_needed(generation, cx); + } + + fn selected_match(&self) -> Option<&QuickMatch> { + let key = self.selection?; + let id = self.match_list.id_by_key(key)?; + self.match_list.item_by_id(id) + } + + fn selected_match_index(&self) -> Option { + let key = self.selection?; + let id = self.match_list.id_by_key(key)?; + self.match_list.index_by_id(id) + } + + fn selected_row_index(&self) -> usize { + if self.is_grouped_list_active() { + let Some(key) = self.selection else { + return self + .grouped_list + .rows + .iter() + .position(|row| matches!(row, GroupedRow::LineMatch { .. })) + .unwrap_or(0); + }; + let Some(id) = self.match_list.id_by_key(key) else { + return 0; + }; + self.grouped_list + .row_index_for_match_id(id) + .or_else(|| { + self.grouped_list + .rows + .iter() + .position(|row| matches!(row, GroupedRow::LineMatch { .. })) + }) + .unwrap_or(0) + } else { + self.selected_match_index().unwrap_or(0) + } + } + + fn weak_preview_ranges_for_selected( + &self, + selected: &QuickMatch, + ) -> Vec> { + if self.current_query.len() < 3 { + return Vec::new(); + } + if selected.source_id.as_ref() != "grep" { + return Vec::new(); + } + let Some(selected_buffer_id) = selected.buffer_id() else { + return Vec::new(); + }; + + const WINDOW: usize = 120; + const MAX_RANGES: usize = 600; + + let selected_index = self.selected_match_index().unwrap_or(0); + let match_count = self.match_list.match_count(); + if match_count == 0 { + return Vec::new(); + } + + let start = selected_index.saturating_sub(WINDOW); + let end = (selected_index + WINDOW).min(match_count.saturating_sub(1)); + + let mut weak = Vec::new(); + for index in start..=end { + let Some(m) = self.match_list.item(index) else { + continue; + }; + if m.id == selected.id { + continue; + } + if m.buffer_id() != Some(selected_buffer_id) { + continue; + } + let Some(ranges) = m.ranges() else { + continue; + }; + for range in ranges { + weak.push(range.clone()); + if weak.len() >= MAX_RANGES { + return weak; + } + } + } + + weak + } + + fn is_grouped_list_active(&self) -> bool { + let Some(spec) = self + .source_registry + .spec_for_id(&self.search_engine.active_source) + else { + return false; + }; + match spec.ui.list_presentation { + core::ListPresentation::Grouped => { + self.current_query.trim().len() >= spec.core.min_query_len + } + core::ListPresentation::Flat => false, + } + } + + fn clear_grouped_list_state(&mut self) { + self.grouped_list.clear(); + self.grouped_rows_dirty = false; + } + + fn ensure_grouped_rows_built(&mut self, cx: &App) { + if !self.is_grouped_list_active() || !self.grouped_rows_dirty { + return; + } + let selected_id = self.selection.and_then(|k| self.match_list.id_by_key(k)); + let selected_row = + self.grouped_list + .rebuild(&mut self.match_list, selected_id, &self.project, cx); + if selected_row.is_none() { + self.selection = self.grouped_list.rows.iter().find_map(|row| match row { + GroupedRow::LineMatch { match_id } => self.match_list.key_by_id(*match_id), + _ => None, + }); + } + self.grouped_rows_dirty = false; + } + + fn rebuild_rows_after_match_list_changed(&mut self) { + if self.is_grouped_list_active() { + self.grouped_rows_dirty = true; + } else { + self.clear_grouped_list_state(); + } + } + + fn toggle_group_collapsed(&mut self, key: types::GroupKey, cx: &App) { + let selected_id = self.selection.and_then(|k| self.match_list.id_by_key(k)); + let selected_row = self.grouped_list.toggle_group_collapsed( + &mut self.match_list, + selected_id, + &self.project, + key, + cx, + ); + if selected_row.is_none() { + self.selection = self.grouped_list.rows.iter().find_map(|row| match row { + GroupedRow::LineMatch { match_id } => self.match_list.key_by_id(*match_id), + _ => None, + }); + } + } + + fn toggle_all_groups_collapsed(&mut self, clicked: types::GroupKey, cx: &App) { + let selected_id = self.selection.and_then(|k| self.match_list.id_by_key(k)); + let selected_row = self.grouped_list.toggle_all_groups_collapsed( + &mut self.match_list, + selected_id, + &self.project, + clicked, + cx, + ); + if selected_row.is_none() { + self.selection = self.grouped_list.rows.iter().find_map(|row| match row { + GroupedRow::LineMatch { match_id } => self.match_list.key_by_id(*match_id), + _ => None, + }); + } + } +} + +pub(crate) fn highlight_indices(text: &str, query: &str, case_sensitive: bool) -> Vec { + if query.is_empty() { + return Vec::new(); + } + if !case_sensitive { + if query.is_ascii() && text.is_ascii() { + let mut positions = Vec::new(); + let needle = query.as_bytes(); + let hay = text.as_bytes(); + let mut i = 0; + while i + needle.len() <= hay.len() { + if hay[i..i + needle.len()] + .iter() + .zip(needle.iter()) + .all(|(h, n)| h.eq_ignore_ascii_case(n)) + { + positions.extend(i..i + needle.len()); + } + i += 1; + } + positions.sort_unstable(); + positions.dedup(); + return positions; + } else { + return find_case_insensitive_unicode(text, query); + } + } + + let mut positions = Vec::new(); + let mut start = 0; + while let Some(pos) = text[start..].find(query) { + let abs = start + pos; + let end = abs + query.len(); + positions.extend(text[abs..end].char_indices().map(|(ix, _)| abs + ix)); + + let step = text[abs..] + .chars() + .next() + .map(|c| c.len_utf8()) + .unwrap_or(1); + start = abs + step; + } + positions.sort_unstable(); + positions.dedup(); + positions +} + +fn find_case_insensitive_unicode(text: &str, query: &str) -> Vec { + if query.is_empty() { + return Vec::new(); + } + + let mut folded_chars: Vec = Vec::new(); + let mut folded_to_orig_byte: Vec = Vec::new(); + for (orig_byte, ch) in text.char_indices() { + for lower_ch in ch.to_lowercase() { + folded_chars.push(lower_ch); + folded_to_orig_byte.push(orig_byte); + } + } + + let needle: Vec = query.chars().flat_map(|c| c.to_lowercase()).collect(); + if needle.is_empty() { + return Vec::new(); + } + let mut positions = Vec::new(); + let mut start = 0; + while start + needle.len() <= folded_chars.len() { + if folded_chars[start..start + needle.len()] + .iter() + .zip(needle.iter()) + .all(|(a, b)| a == b) + { + positions.extend( + folded_to_orig_byte[start..start + needle.len()] + .iter() + .copied(), + ); + } + start += 1; + } + positions.sort_unstable(); + positions.dedup(); + positions +} + +fn stable_source_button_id(id: &core::SourceId) -> u32 { + let mut hash: u32 = 0x811c_9dc5; + for b in id.0.as_bytes() { + hash ^= *b as u32; + hash = hash.wrapping_mul(0x0100_0193); + } + hash +} + +impl PickerDelegate for QuickSearchDelegate { + type ListItem = ListItem; + + fn match_count(&self) -> usize { + if self.is_grouped_list_active() { + if self.grouped_rows_dirty { + self.match_list.match_count() + } else { + self.grouped_list.rows.len() + } + } else { + self.match_list.match_count() + } + } + + fn selected_index(&self) -> usize { + if self.is_grouped_list_active() { + if self.grouped_rows_dirty { + 0 + } else { + self.selected_row_index() + } + } else { + self.selected_match_index().unwrap_or(0) + } + } + + fn can_select( + &mut self, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, + ) -> bool { + if !self.is_grouped_list_active() { + return true; + } + + matches!( + self.grouped_list.rows.get(ix), + Some(GroupedRow::LineMatch { .. }) + ) + } + + fn set_selected_index(&mut self, ix: usize, _: &mut Window, cx: &mut Context>) { + if self.is_grouped_list_active() { + set_selected_index_grouped(self, ix, cx); + } else { + let ix = ix.min(self.match_list.match_count().saturating_sub(1)); + self.selection = self.match_list.item(ix).map(|m| m.key); + cx.notify(); + } + } + + fn selected_index_changed( + &self, + _ix: usize, + _window: &mut Window, + cx: &mut Context>, + ) -> Option> { + let selected = self.selected_match().cloned(); + if let Some(selected) = selected.as_ref() { + self.schedule_enrichment_for(selected.clone(), cx); + } + let weak_preview_ranges = selected + .as_ref() + .map(|selected| self.weak_preview_ranges_for_selected(selected)) + .unwrap_or_default(); + let use_diff_preview = self + .source_registry + .spec_for_id(&self.search_engine.active_source) + .map(|spec| spec.ui.use_diff_preview) + .unwrap_or(false); + let request = selected.as_ref().map_or(PreviewRequest::Empty, |selected| { + self.source_registry.preview_request_for_match( + selected, + self.search_engine.generation(), + weak_preview_ranges.clone(), + use_diff_preview, + &self.current_query, + &self.project, + cx, + ) + }); + let footer_preview_buffer = match &request { + PreviewRequest::Buffer { buffer, .. } => Some(buffer.clone()), + _ => None, + }; + let footer_selected = selected; + let footer_query: Arc = Arc::from(self.current_query.clone()); + let footer_project = self.project.clone(); + let session_cancellation = self.search_engine.cancellation(); + let quick_search = self.quick_search.clone(); + + Some(Box::new(move |window, cx| { + let session_cancellation = session_cancellation.clone(); + let request = request.clone(); + let footer_selected = footer_selected.clone(); + let footer_query = footer_query.clone(); + let footer_preview_buffer = footer_preview_buffer.clone(); + let footer_project = footer_project.clone(); + let Some(quick_search) = quick_search.upgrade() else { + return; + }; + + quick_search.update(cx, move |quick_search, cx| { + let owner = cx.entity().downgrade(); + quick_search.preview.request_preview( + request.clone(), + session_cancellation.clone(), + &owner, + window, + cx, + ); + quick_search.preview_footer.update_active_context( + footer_selected.clone(), + footer_query.clone(), + footer_preview_buffer.clone(), + session_cancellation, + footer_project.clone(), + window, + cx, + ); + }); + })) + } + + fn separators_after_indices(&self) -> Vec { + Vec::new() + } + + fn placeholder_text(&self, _: &mut Window, _: &mut App) -> Arc { + self.source_registry + .spec_for_id(&self.search_engine.active_source) + .map(|spec| spec.ui.placeholder.clone()) + .unwrap_or_else(|| Arc::from("Search...")) + } + + fn render_editor( + &self, + editor: &Entity, + _window: &mut Window, + cx: &mut Context>, + ) -> Div { + let search_options = self.search_engine.search_options; + let query_error = self.query_error.clone(); + let active_source = self.search_engine.active_source.clone(); + let active_spec = self + .source_registry + .spec_for_id(&active_source) + .or_else(|| { + self.source_registry + .available_sources() + .first() + .map(|s| s.spec()) + }); + + let toggle_button = |icon: IconName, + active: bool, + tooltip: Arc, + option: SearchOptions| + -> IconButton { + IconButton::new(("quick-search-toggle", icon as u32), icon) + .shape(IconButtonShape::Square) + .style(ButtonStyle::Subtle) + .toggle_state(active) + .on_click({ + let qs = self.quick_search.clone(); + move |_, window, cx| { + if let Some(qs) = qs.upgrade() { + qs.update(cx, |qs, cx| qs.toggle_search_option(option, window, cx)); + } + } + }) + .tooltip(Tooltip::text(tooltip)) + }; + + let source_button = |id: u32, + spec: &'static core::SourceSpec, + active: bool, + source_id: core::SourceId| + -> IconButton { + IconButton::new(("quick-search-source", id), spec.ui.icon) + .shape(IconButtonShape::Square) + .style(ButtonStyle::Subtle) + .toggle_state(active) + .on_click({ + let qs = self.quick_search.clone(); + move |_, window, cx| { + if let Some(qs) = qs.upgrade() { + qs.update(cx, |qs, cx| { + qs.set_search_source(source_id.clone(), window, cx) + }); + } + } + }) + .tooltip(Tooltip::text(spec.ui.title.to_string())) + }; + + let mut toggles = h_flex().gap_1(); + let supported = active_spec + .map(|spec| spec.core.supported_options) + .unwrap_or_else(SearchOptions::empty); + if supported.contains(SearchOptions::REGEX) { + toggles = toggles.child(toggle_button( + IconName::Regex, + search_options.contains(SearchOptions::REGEX), + Arc::from("Use Regular Expressions"), + SearchOptions::REGEX, + )); + } + if supported.contains(SearchOptions::CASE_SENSITIVE) { + toggles = toggles.child(toggle_button( + IconName::CaseSensitive, + search_options.contains(SearchOptions::CASE_SENSITIVE), + Arc::from("Match Case Sensitivity"), + SearchOptions::CASE_SENSITIVE, + )); + } + if supported.contains(SearchOptions::WHOLE_WORD) { + toggles = toggles.child(toggle_button( + IconName::WholeWord, + search_options.contains(SearchOptions::WHOLE_WORD), + Arc::from("Match Whole Words"), + SearchOptions::WHOLE_WORD, + )); + } + if supported.contains(SearchOptions::INCLUDE_IGNORED) { + toggles = toggles.child(toggle_button( + IconName::Sliders, + search_options.contains(SearchOptions::INCLUDE_IGNORED), + Arc::from("Include ignored files"), + SearchOptions::INCLUDE_IGNORED, + )); + } + + let mut sources = h_flex().gap_1(); + for source in self.source_registry.available_sources().iter() { + let spec = source.spec(); + let id = stable_source_button_id(&spec.id); + sources = sources.child(source_button( + id, + spec, + active_source == spec.id, + spec.id.clone(), + )); + } + + let controls = h_flex().gap_2().child(sources).child(toggles); + + let border_color = if query_error.is_some() { + cx.theme().status().error + } else { + cx.theme().colors().border_variant + }; + + let bar = input_base_styles(border_color, |container| { + container + .h_10() + .items_center() + .px_2() + .gap_2() + .bg(cx.theme().colors().toolbar_background) + .child(div().flex_1().child(render_text_input(editor, None, cx))) + .child(controls) + }); + + v_flex() + .gap_1() + .child(bar) + .when_some(query_error.as_ref(), |this, error| { + this.child( + Label::new(error.clone()) + .size(LabelSize::Small) + .color(Color::Error) + .ml_1(), + ) + }) + .when_some(self.query_notice.as_ref(), |this, notice| { + this.child( + Label::new(notice.clone()) + .size(LabelSize::XSmall) + .color(Color::Muted) + .ml_1() + .single_line() + .truncate(), + ) + }) + } + + fn update_matches( + &mut self, + query: String, + window: &mut Window, + cx: &mut Context>, + ) -> gpui::Task<()> { + let query = query.trim().to_string(); + let session_cancellation = self.search_engine.cancellation(); + if let Some(qs) = self.quick_search.upgrade() { + let qs = qs.downgrade(); + window.defer(cx, move |window, cx| { + let Some(qs) = qs.upgrade() else { + return; + }; + qs.update(cx, |qs, cx| { + let owner = cx.entity().downgrade(); + qs.preview.request_preview( + PreviewRequest::Empty, + session_cancellation, + &owner, + window, + cx, + ); + }); + }); + } + + let min_len = self + .source_registry + .spec_for_id(&self.search_engine.active_source) + .map(|spec| spec.core.min_query_len) + .unwrap_or(crate::MIN_QUERY_LEN); + if query.len() < min_len { + self.reset_scroll = false; + self.is_streaming = false; + self.total_results = 0; + self.stream_finished = true; + self.query_error = None; + self.query_notice = None; + self.match_list.clear(); + self.selection = None; + self.clear_grouped_list_state(); + let _cancel_task = self.search_engine.cancel_pending_debounced_search(cx); + cx.notify(); + return Task::ready(()); + } + + self.is_streaming = true; + self.stream_finished = false; + self.search_engine.schedule_search(query, cx) + } + + fn confirm(&mut self, secondary: bool, window: &mut Window, cx: &mut Context>) { + let Some(selected) = self.selected_match().cloned() else { + if let Some(quick_search) = self.quick_search.upgrade() { + quick_search.update(cx, |_, cx| cx.emit(DismissEvent)); + } else { + cx.emit(DismissEvent); + } + return; + }; + + let outcome = self + .source_registry + .confirm_outcome_for_match(&selected, cx); + + let Some(workspace) = self.workspace.upgrade() else { + cx.emit(DismissEvent); + return; + }; + + let window_handle = window.window_handle().downcast::(); + let (project_path, point_range) = match outcome { + core::ConfirmOutcome::OpenProjectPath { + project_path, + point_range, + } => (Some(project_path), point_range), + core::ConfirmOutcome::OpenGitCommit { repo_workdir, sha } => { + let project = self.project.clone(); + let workspace = workspace.downgrade(); + window.defer(cx, move |window, cx| { + let repository = project + .read(cx) + .git_store() + .read(cx) + .repositories() + .values() + .find(|repo| { + repo.read(cx).work_directory_abs_path.as_ref() == repo_workdir.as_ref() + }) + .cloned(); + let Some(repository) = repository else { + return; + }; + + git_ui::commit_view::CommitView::open( + sha.to_string(), + repository.downgrade(), + workspace, + None, + None, + window, + cx, + ); + }); + (None, None) + } + core::ConfirmOutcome::Dismiss => (None, None), + }; + + if let Some(project_path) = project_path { + let open_task = workspace.update(cx, |workspace, cx| { + let allow_preview = + PreviewTabsSettings::get_global(cx).enable_preview_from_file_finder; + if secondary { + workspace.split_path_preview( + project_path.clone(), + allow_preview, + None, + window, + cx, + ) + } else { + workspace.open_path_preview( + project_path.clone(), + None, + true, + allow_preview, + true, + window, + cx, + ) + } + }); + + if let Some(window_handle) = window_handle { + cx.spawn(move |_, app: &mut gpui::AsyncApp| { + let mut app = app.clone(); + async move { + let Ok(item) = open_task.await else { + return; + }; + let Some(editor) = item.downcast::() else { + return; + }; + if let Err(update_err) = window_handle.update( + &mut app, + |_workspace, window, cx| { + editor.update(cx, |editor, cx| { + if let Some(point_range) = &point_range { + editor.unfold_ranges( + std::slice::from_ref(point_range), + false, + true, + cx, + ); + editor.change_selections( + SelectionEffects::scroll(Autoscroll::center()), + window, + cx, + |selections| selections.select_ranges([point_range.clone()]), + ); + } + }); + }, + ) + { + debug!( + "quick_search: window handle dropped before selection highlight: {:?}", + update_err + ); + } + } + }) + .detach(); + } + } + + if let Some(quick_search) = self.quick_search.upgrade() { + quick_search.update(cx, |_, cx| cx.emit(DismissEvent)); + } else { + cx.emit(DismissEvent); + } + } + + fn confirm_input( + &mut self, + secondary: bool, + window: &mut Window, + cx: &mut Context>, + ) { + self.confirm(secondary, window, cx); + } + + fn confirm_completion( + &mut self, + _query: String, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option { + None + } + + fn confirm_update_query( + &mut self, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option { + None + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + if self.is_grouped_list_active() { + let row = self.grouped_list.rows.get(ix)?.clone(); + match row { + GroupedRow::FileHeader(header) => { + return render_grouped_file_header(self, header, ix, _window, cx); + } + GroupedRow::LineMatch { match_id, .. } => { + return render_grouped_match_row(self, match_id, ix, selected, _window, cx); + } + } + } + render_flat_match_row(self, ix, selected, _window, cx) + } + + fn render_header( + &self, + _window: &mut Window, + _cx: &mut Context>, + ) -> Option { + if let Some(err) = &self.query_error { + return Some( + h_flex() + .w_full() + .px_2() + .py_1() + .child( + Label::new(err.clone()) + .size(LabelSize::Small) + .color(Color::Error), + ) + .into_any(), + ); + } + + let truncated = self.match_list.is_truncated(); + let suffix = if truncated { " (truncated)" } else { "" }; + let (results, files) = if self.is_grouped_list_active() { + let file_count = self + .grouped_list + .rows + .iter() + .filter(|r| matches!(r, GroupedRow::FileHeader(_))) + .count(); + (self.total_results, Some(file_count)) + } else { + (self.total_results, None) + }; + + if results == 0 && !self.is_streaming { + return None; + } + + let mut label = if let Some(files) = files { + let result_word = if results == 1 { "result" } else { "results" }; + let file_word = if files == 1 { "file" } else { "files" }; + format!("{results} {result_word} in {files} {file_word}{suffix}") + } else { + format!("{results} results{suffix}") + }; + if self.is_streaming { + label.push_str(" (searching)"); + } + + Some( + h_flex() + .w_full() + .px_2() + .py_1() + .gap_2() + .items_center() + .child(Label::new(label).size(LabelSize::Small).color(Color::Muted)) + .when(self.is_streaming, |this| { + this.child( + SpinnerLabel::new() + .size(LabelSize::Small) + .color(Color::Muted), + ) + }) + .into_any(), + ) + } + + fn render_footer( + &self, + _window: &mut Window, + cx: &mut Context>, + ) -> Option { + let footer_toggle = self.quick_search.upgrade().and_then(|qs| { + qs.read(cx) + .preview_footer + .active_toggle_button(self.selected_match(), cx) + }); + + Some( + h_flex() + .w_full() + .p_1p5() + .gap_1() + .justify_end() + .border_t_1() + .border_color(cx.theme().colors().border_variant) + .when_some(footer_toggle, |this, (label, open)| { + this.child( + Button::new("quick-search-toggle-preview-footer", label.to_string()) + .toggle_state(open) + .key_binding( + KeyBinding::for_action(&TogglePreviewFooter, cx) + .size(rems_from_px(12.)), + ) + .on_click(|_, window, cx| { + window.dispatch_action(TogglePreviewFooter.boxed_clone(), cx); + }), + ) + }) + .child( + Button::new("quick-search-open-split", "Open in Split") + .key_binding( + KeyBinding::for_action(&menu::SecondaryConfirm, cx) + .size(rems_from_px(12.)), + ) + .on_click(|_, window, cx| { + window.dispatch_action(menu::SecondaryConfirm.boxed_clone(), cx); + }), + ) + .child( + Button::new("quick-search-open", "Open") + .key_binding( + KeyBinding::for_action(&menu::Confirm, cx).size(rems_from_px(12.)), + ) + .on_click(|_, window, cx| { + window.dispatch_action(menu::Confirm.boxed_clone(), cx); + }), + ) + .into_any(), + ) + } + + fn dismissed(&mut self, _window: &mut Window, cx: &mut Context>) { + self.search_engine.cancel(); + self.is_streaming = false; + self.next_match_id = 0; + self.clear_grouped_list_state(); + self.reset_notify_throttle(); + if let Some(quick_search) = self.quick_search.upgrade() { + quick_search.update(cx, |_, cx| { + cx.emit(DismissEvent); + }); + } else { + cx.emit(DismissEvent); + } + } +} + +pub(crate) type PickerHandle = picker::Picker; + +#[derive(Clone)] +pub(crate) struct GenerationGuard { + generation: usize, + cancel_flag: Arc, +} + +impl GenerationGuard { + pub(crate) fn new() -> Self { + Self { + generation: 0, + cancel_flag: Arc::new(AtomicBool::new(false)), + } + } + + pub(crate) fn generation(&self) -> usize { + self.generation + } + + pub(crate) fn cancel_flag(&self) -> Arc { + self.cancel_flag.clone() + } + + pub(crate) fn cancel(&self) { + self.cancel_flag.store(true, Ordering::SeqCst); + } + + pub(crate) fn begin_request(&mut self) -> (usize, Arc) { + self.cancel_flag.store(true, Ordering::SeqCst); + self.cancel_flag = Arc::new(AtomicBool::new(false)); + self.generation = self.generation.saturating_add(1); + (self.generation, self.cancel_flag.clone()) + } +} + +pub struct SearchEngine { + pub search_options: SearchOptions, + pub active_source: crate::core::SourceId, + generation_guard: GenerationGuard, + inflight_results: Option>, + debouncer: DebouncedDelay, + debounce_ms: u64, +} + +pub(crate) enum SourceEvent { + AppendMatchIds(Vec, Arc), + ApplyPatches(Vec<(MatchId, types::QuickMatchPatch)>), + ApplyPatchesByKey(Vec<(MatchKey, types::QuickMatchPatch)>), + Error(String), + FinishStream, +} + +pub(crate) fn apply_source_event( + picker: WeakEntity, + generation: usize, + update: SourceEvent, + app: &mut AsyncApp, +) { + let Some(picker_entity) = picker.upgrade() else { + return; + }; + + let mut reset_window_handle: Option = None; + + if let Err(err) = app.update_entity(&picker_entity, |picker, cx| { + if picker.delegate.search_engine.generation() != generation { + return; + } + + match update { + SourceEvent::Error(message) => { + picker.delegate.query_error = Some(message); + picker.delegate.query_notice = None; + picker.delegate.match_list.clear(); + picker.delegate.selection = None; + picker.delegate.clear_grouped_list_state(); + picker.delegate.is_streaming = false; + picker.delegate.total_results = 0; + picker.delegate.search_engine.inflight_results = None; + picker.delegate.stream_finished = true; + picker.delegate.reset_notify_throttle(); + cx.notify(); + } + SourceEvent::FinishStream => { + if picker.delegate.stream_finished { + return; + } + + if let Some(spec) = picker + .delegate + .source_registry + .spec_for_id(&picker.delegate.search_engine.active_source) + { + if matches!(spec.core.sort_policy, crate::core::SortPolicy::FinalSort) { + if let Some(source) = picker + .delegate + .source_registry + .source_for_id(&picker.delegate.search_engine.active_source) + { + picker + .delegate + .match_list + .sort_by(|a, b| source.cmp_matches(a, b)); + picker.delegate.rebuild_rows_after_match_list_changed(); + } + } + } + + picker.delegate.stream_finished = true; + picker.delegate.is_streaming = false; + picker.delegate.search_engine.inflight_results = None; + picker.delegate.reset_notify_throttle(); + cx.notify(); + } + SourceEvent::AppendMatchIds(ids, arena) => { + if picker.delegate.stream_finished { + return; + } + + let previous_render_rows = picker.delegate.match_count(); + let previous_total = picker.delegate.total_results; + + let mut matches: Vec = Vec::with_capacity(ids.len()); + for id in ids { + if let Some(m) = arena.get_cloned(id) { + matches.push(m); + } + } + + let reached_cap = picker.delegate.match_list.extend(matches); + picker.delegate.total_results = picker.delegate.match_list.total_results(); + picker.delegate.rebuild_rows_after_match_list_changed(); + + let new_render_rows = picker.delegate.match_count(); + let need_notify = new_render_rows != previous_render_rows + || picker.delegate.total_results != previous_total; + picker.delegate.notify_pending = need_notify; + + if picker.delegate.reset_scroll && picker.delegate.match_count() > 0 { + reset_window_handle = Some(picker.delegate.window_handle); + } + + if reached_cap { + picker.delegate.search_engine.cancel(); + if let Some(rx) = picker.delegate.search_engine.inflight_results.take() { + rx.close(); + } + } + + if picker.delegate.notify_pending { + let immediate = previous_render_rows == 0 && new_render_rows > 0; + picker.delegate.request_notify(generation, immediate, cx); + } + } + SourceEvent::ApplyPatches(patches) => { + if patches.is_empty() { + return; + } + + let mut changed = false; + for (id, patch) in patches { + if picker.delegate.match_list.update_by_id(id, patch) { + changed = true; + } + } + + if changed { + picker.delegate.notify_pending = true; + picker.delegate.request_notify(generation, false, cx); + } + } + SourceEvent::ApplyPatchesByKey(patches) => { + if patches.is_empty() { + return; + } + + let mut changed = false; + for (key, patch) in patches { + if picker + .delegate + .match_list + .update_by_key_or_queue(key, patch) + { + changed = true; + } + } + + if changed { + picker.delegate.notify_pending = true; + picker.delegate.request_notify(generation, false, cx); + } + } + } + }) { + debug!("quick_search: apply_ui_update failed: {:?}", err); + return; + } + + let Some(window_handle) = reset_window_handle else { + return; + }; + let picker_for_reset = picker_entity.clone(); + if let Err(err) = app.update_window(window_handle, move |_, window, cx| { + picker_for_reset.update(cx, |picker, cx| { + if picker.delegate.search_engine.generation() != generation { + return; + } + if !picker.delegate.reset_scroll || picker.delegate.match_count() == 0 { + return; + } + picker.delegate.reset_scroll = false; + let previous_index = picker.delegate.selected_index(); + picker.set_selected_index(0, Some(picker::Direction::Down), true, window, cx); + let current_index = picker.delegate.selected_index(); + if previous_index == current_index { + if let Some(action) = + picker + .delegate + .selected_index_changed(current_index, window, cx) + { + action(window, cx); + } + } + }); + }) { + debug!("quick_search: reset scroll window update failed: {:?}", err); + } +} + +impl SearchEngine { + pub fn new(search_options: SearchOptions, debounce_ms: u64) -> Self { + Self { + search_options, + active_source: crate::core::default_source_id(), + generation_guard: GenerationGuard::new(), + inflight_results: None, + debouncer: DebouncedDelay::new(), + debounce_ms, + } + } + + pub fn cancel(&mut self) { + self.generation_guard.cancel(); + if let Some(rx) = self.inflight_results.take() { + rx.close(); + } + } + + pub(crate) fn generation(&self) -> usize { + self.generation_guard.generation() + } + + pub(crate) fn cancellation(&self) -> core::SearchCancellation { + core::SearchCancellation::new(self.generation_guard.cancel_flag()) + } + + pub(crate) fn begin_request(&mut self) -> (usize, Arc) { + self.cancel(); + self.generation_guard.begin_request() + } + + pub(crate) fn set_inflight_results(&mut self, rx: Receiver) { + self.inflight_results = Some(rx); + } + + pub(crate) fn schedule_search( + &mut self, + query: String, + cx: &mut Context, + ) -> Task<()> { + self.schedule_search_with_delay(query, Duration::from_millis(self.debounce_ms), cx) + } + + pub(crate) fn schedule_search_with_delay( + &mut self, + query: String, + delay: Duration, + cx: &mut Context, + ) -> Task<()> { + let query = query.trim().to_string(); + let (generation, cancel_flag) = self.begin_request(); + let cancellation = core::SearchCancellation::new(cancel_flag); + let query_to_run = query; + self.debouncer.fire_new(delay, cx, move |picker, cx| { + picker + .delegate + .start_search_with_request(query_to_run, generation, cancellation, cx); + Task::ready(()) + }); + Task::ready(()) + } + + pub(crate) fn cancel_pending_debounced_search( + &mut self, + cx: &mut Context, + ) -> Task<()> { + self.cancel(); + self.debouncer + .fire_new(Duration::from_millis(0), cx, |_picker, _cx| Task::ready(())); + Task::ready(()) + } + + #[allow(dead_code)] + pub fn set_active_source(&mut self, source: crate::core::SourceId) { + self.active_source = source; + } +} + +impl QuickSearchDelegate { + pub fn schedule_enrichment_for(&self, m: QuickMatch, cx: &mut Context) { + if m.blame.is_some() { + return; + } + let Some(buffer_id) = m.buffer_id() else { + return; + }; + let Some(buffer) = self.project.read(cx).buffer_for_id(buffer_id, cx) else { + return; + }; + let picker = cx.entity().downgrade(); + let generation = self.search_engine.generation(); + let project = self.project.clone(); + let position_for_cache = m.position(); + let cancellation = self.search_engine.cancellation(); + let match_id = m.id; + cx.spawn(move |_, app: &mut gpui::AsyncApp| { + let mut app = app.clone(); + async move { + if cancellation.is_cancelled() { + return; + } + if let Some((row, _col)) = position_for_cache { + let blame = enrich_blame(&mut app, &project, &buffer, row) + .await + .map(Arc::from); + + if cancellation.is_cancelled() { + return; + } + + let Some(blame_text) = blame else { + return; + }; + + let patch = types::QuickMatchPatch { + blame: PatchValue::SetTo(blame_text), + ..Default::default() + }; + apply_patch_to_match(picker, generation, match_id, patch, &mut app); + } + } + }) + .detach(); + } + + pub fn start_search_with_request( + &mut self, + trimmed: String, + generation: usize, + cancellation: core::SearchCancellation, + cx: &mut Context, + ) { + if self.search_engine.generation() != generation { + return; + } + if cancellation.is_cancelled() { + return; + } + + self.next_match_id = 0; + self.current_query = trimmed.clone(); + self.query_error = None; + self.query_notice = None; + self.match_list.clear(); + self.selection = None; + self.clear_grouped_list_state(); + self.reset_notify_throttle(); + self.reset_scroll = true; + self.is_streaming = false; + self.total_results = 0; + self.stream_finished = true; + cx.notify(); + + if trimmed.is_empty() { + self.is_streaming = false; + self.search_engine.inflight_results = None; + self.total_results = 0; + self.stream_finished = true; + self.selection = None; + cx.notify(); + return; + } + + let min_len = self + .source_registry + .spec_for_id(&self.search_engine.active_source) + .map(|s| s.core.min_query_len) + .unwrap_or(crate::MIN_QUERY_LEN); + if trimmed.len() < min_len { + self.is_streaming = false; + self.search_engine.inflight_results = None; + self.total_results = 0; + self.stream_finished = true; + self.query_error = None; + self.match_list.clear(); + self.selection = None; + self.clear_grouped_list_state(); + cx.notify(); + return; + } + + self.is_streaming = true; + self.stream_finished = false; + cx.notify(); + + let picker = cx.entity().downgrade(); + let source_id = self.search_engine.active_source.clone(); + + debug!( + "quick_search: start search source={} query_len={}", + source_id.0, + trimmed.len() + ); + let Some(source) = self.source_registry.source_for_id(&source_id) else { + self.query_error = Some(format!("Unknown source: {}", source_id.0)); + self.query_notice = None; + self.match_list.clear(); + self.selection = None; + self.clear_grouped_list_state(); + self.is_streaming = false; + self.total_results = 0; + self.search_engine.inflight_results = None; + self.stream_finished = true; + self.reset_notify_throttle(); + cx.notify(); + return; + }; + let path_style = self.project.read(cx).path_style(cx); + let language_registry = self.project.read(cx).languages().clone(); + let match_arena = Arc::new(core::MatchArena::new()); + let search_context = core::SearchContext::new( + self.project.clone(), + Arc::::from(trimmed), + self.search_engine.search_options, + path_style, + language_registry, + cancellation.clone(), + cx.background_executor().clone(), + match_arena, + ); + let sink = core::SearchSink::new(picker, generation, cancellation); + source.start_search(search_context, sink, cx); + } +} + +pub(crate) fn record_error( + picker: WeakEntity, + generation: usize, + message: String, + app: &mut AsyncApp, +) { + apply_source_event(picker, generation, SourceEvent::Error(message), app); +} + +pub(crate) fn finish_stream( + picker: WeakEntity, + generation: usize, + app: &mut AsyncApp, +) { + apply_source_event(picker, generation, SourceEvent::FinishStream, app); +} + +pub(crate) fn flush_batch_ids( + picker: WeakEntity, + generation: usize, + batch_ids: &mut Vec, + arena: Arc, + app: &mut AsyncApp, +) { + if batch_ids.is_empty() { + return; + } + let drained_ids = std::mem::take(batch_ids); + apply_source_event( + picker, + generation, + SourceEvent::AppendMatchIds(drained_ids, arena), + app, + ); +} + +async fn enrich_blame( + app: &mut AsyncApp, + project: &Entity, + buffer: &Entity, + row: u32, +) -> Option { + let blame_task = app.update_entity(project, |project, cx| { + project.blame_buffer(buffer, None, cx) + }); + let Ok(blame_task) = blame_task else { + return None; + }; + let blame = match blame_task.await { + Ok(Some(blame)) => blame, + _ => return None, + }; + let entry = blame + .entries + .iter() + .find(|entry| entry.range.contains(&row))?; + Some(format_blame_entry( + entry.author.as_deref(), + entry.summary.as_deref(), + &entry.sha.to_string(), + entry.original_line_number, + )) +} + +fn format_blame_entry( + author: Option<&str>, + summary: Option<&str>, + sha: &str, + original_line: u32, +) -> String { + let author = author.unwrap_or("unknown"); + let summary = summary.unwrap_or(""); + let short_sha = sha.get(..8).unwrap_or(sha); + if summary.is_empty() { + format!("{author} · {short_sha} · L{original_line}") + } else { + format!("{author} · {summary} · {short_sha} · L{original_line}") + } +} + +fn apply_patch_to_match( + picker: WeakEntity, + generation: usize, + id: MatchId, + patch: types::QuickMatchPatch, + app: &mut AsyncApp, +) { + apply_source_event( + picker, + generation, + SourceEvent::ApplyPatches(vec![(id, patch)]), + app, + ); +} + +pub(crate) fn apply_patches_by_key( + picker: WeakEntity, + generation: usize, + patches: Vec<(MatchKey, types::QuickMatchPatch)>, + app: &mut AsyncApp, +) { + apply_source_event( + picker, + generation, + SourceEvent::ApplyPatchesByKey(patches), + app, + ); +} + +fn set_selected_index_grouped( + delegate: &mut QuickSearchDelegate, + ix: usize, + cx: &mut Context>, +) { + if delegate.grouped_list.rows.is_empty() { + delegate.selection = None; + cx.notify(); + return; + } + + let ix = ix.min(delegate.grouped_list.rows.len().saturating_sub(1)); + let previous_row = delegate.selected_row_index(); + let going_down = ix >= previous_row; + + let mut chosen_row = None; + if matches!( + delegate.grouped_list.rows.get(ix), + Some(GroupedRow::LineMatch { .. }) + ) { + chosen_row = Some(ix); + } else { + if going_down { + for i in ix..delegate.grouped_list.rows.len() { + if matches!(delegate.grouped_list.rows[i], GroupedRow::LineMatch { .. }) { + chosen_row = Some(i); + break; + } + } + } else { + for i in (0..=ix).rev() { + if matches!(delegate.grouped_list.rows[i], GroupedRow::LineMatch { .. }) { + chosen_row = Some(i); + break; + } + } + } + } + + let Some(row_ix) = chosen_row else { + cx.notify(); + return; + }; + + let match_id = match &delegate.grouped_list.rows[row_ix] { + GroupedRow::LineMatch { match_id, .. } => *match_id, + _ => return, + }; + + delegate.selection = delegate.match_list.key_by_id(match_id); + cx.notify(); +} + +fn render_grouped_file_header( + delegate: &QuickSearchDelegate, + header: GroupedFileHeader, + ix: usize, + _window: &mut Window, + _cx: &mut Context>, +) -> Option { + let is_collapsed = delegate.grouped_list.collapsed_groups.contains(&header.key); + let chevron_icon = if is_collapsed { + IconName::ChevronRight + } else { + IconName::ChevronDown + }; + + let file_icon = header + .header + .icon_path + .clone() + .map(Icon::from_path) + .unwrap_or_else(|| Icon::new(header.header.icon_name)); + + let quick_search = delegate.quick_search.clone(); + let key = header.key; + + let right = h_flex() + .gap_1() + .items_center() + .child( + Label::new(format!("{}", header.match_count)) + .size(LabelSize::XSmall) + .color(Color::Muted), + ) + .when_some(header.worktree_name.clone(), |this, name| { + let label_color = if header.emphasize_worktree { + Color::Accent + } else { + Color::Muted + }; + this.child(Chip::new(name).label_color(label_color)) + }); + + Some( + ListItem::new(("quick-search-group-header", ix)) + .inset(true) + .spacing(ListItemSpacing::Sparse) + .child( + h_flex() + .w_full() + .min_w_0() + .justify_between() + .items_center() + .child( + h_flex() + .min_w_0() + .gap_1() + .items_center() + .cursor_pointer() + .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(header.header.title.clone()) + .size(LabelSize::Small) + .single_line() + .truncate(), + ) + .when_some(header.header.subtitle.as_ref(), |this, subtitle| { + this.child( + Label::new(subtitle.clone()) + .size(LabelSize::Small) + .color(Color::Muted) + .single_line() + .truncate(), + ) + }), + ) + .child(right), + ) + .on_click(move |event, _window, cx| { + cx.stop_propagation(); + if let Some(qs) = quick_search.upgrade() { + qs.update(cx, |qs, cx| { + qs.picker.update(cx, |picker, cx| { + if event.modifiers().alt { + picker.delegate.toggle_all_groups_collapsed(key, cx); + } else { + picker.delegate.toggle_group_collapsed(key, cx); + } + cx.notify(); + }); + }); + } + }), + ) +} + +fn render_grouped_match_row( + delegate: &QuickSearchDelegate, + match_id: MatchId, + ix: usize, + selected: bool, + _window: &mut Window, + cx: &mut Context>, +) -> Option { + let entry = delegate.match_list.item_by_id(match_id)?; + let line_label: String = entry + .position() + .map(|(row, _)| format!("{}", row + 1)) + .unwrap_or_default(); + + let snippet_known = entry.location_label.is_some() + || entry.first_line_snippet.is_some() + || entry.snippet.is_some(); + let snippet_shared = snippet_shared_for_entry(entry); + let snippet_text = snippet_shared.as_ref(); + let is_blank_line = snippet_known && snippet_text.trim().is_empty(); + + let case_sensitive = delegate + .search_engine + .search_options + .contains(SearchOptions::CASE_SENSITIVE); + let do_highlights = delegate.current_query.len() >= 3 + && !delegate + .search_engine + .search_options + .contains(SearchOptions::REGEX); + let snippet_element = if snippet_known && !is_blank_line { + syntax_and_match_snippet_element(entry, &snippet_shared, cx) + } else { + None + }; + + let content = h_flex() + .gap_2() + .items_center() + .min_w_0() + .child( + div().w(rems(3.0)).flex_shrink_0().child( + Label::new(line_label) + .size(LabelSize::XSmall) + .color(Color::Muted) + .single_line(), + ), + ) + .child(if !snippet_known { + Label::new("(loading…)") + .size(LabelSize::Small) + .color(Color::Muted) + .single_line() + .into_any_element() + } else if is_blank_line { + Label::new("(blank line)") + .size(LabelSize::Small) + .color(Color::Muted) + .single_line() + .into_any_element() + } else { + snippet_element.unwrap_or_else(|| { + let snippet_highlights = if do_highlights && !is_blank_line { + highlight_indices(snippet_text, &delegate.current_query, case_sensitive) + } else { + Vec::new() + }; + HighlightedLabel::new(snippet_shared.clone(), snippet_highlights) + .size(LabelSize::Small) + .single_line() + .truncate() + .into_any_element() + }) + }); + + Some( + ListItem::new(("quick-search-group-match", ix)) + .spacing(ListItemSpacing::Sparse) + .inset(true) + .toggle_state(selected) + .child(content) + .on_click( + cx.listener(move |picker, event: &gpui::ClickEvent, window, cx| { + cx.stop_propagation(); + window.prevent_default(); + picker.set_selected_index(ix, None, false, window, cx); + if event.click_count() >= 2 { + window.dispatch_action(menu::Confirm.boxed_clone(), cx); + } + }), + ), + ) +} + +fn render_flat_match_row( + delegate: &QuickSearchDelegate, + ix: usize, + selected: bool, + _window: &mut Window, + cx: &mut Context>, +) -> Option { + let entry = delegate.match_list.item(ix)?; + + let case_sensitive = delegate + .search_engine + .search_options + .contains(SearchOptions::CASE_SENSITIVE); + let do_highlights = delegate.current_query.len() >= 3 + && !delegate + .search_engine + .search_options + .contains(SearchOptions::REGEX); + + let (start_icon, content) = match &entry.kind { + types::QuickMatchKind::ProjectPath { .. } => { + let icon = FileIcons::get_icon(Path::new(&*entry.file_name), cx) + .map(|icon_path| Icon::from_path(icon_path).color(Color::Muted)); + + let file_name_positions = entry + .file_name_positions + .as_deref() + .map(|p| p.to_vec()) + .unwrap_or_default(); + let dir_positions = entry + .display_path_positions + .as_deref() + .map(|p| p.to_vec()) + .unwrap_or_default(); + + let content = v_flex() + .gap_1() + .overflow_hidden() + .child( + HighlightedLabel::new(entry.file_name.clone(), file_name_positions) + .size(LabelSize::Small) + .single_line() + .truncate(), + ) + .child( + HighlightedLabel::new(entry.display_path.clone(), dir_positions) + .size(LabelSize::XSmall) + .color(Color::Muted) + .single_line() + .truncate(), + ); + (icon, content.into_any_element()) + } + types::QuickMatchKind::Buffer { .. } => { + let icon = FileIcons::get_icon(Path::new(&*entry.file_name), cx) + .map(|icon_path| Icon::from_path(icon_path).color(Color::Muted)); + + let snippet_shared = snippet_shared_for_entry(entry); + let snippet_text = snippet_shared.as_ref(); + let snippet_element = { + syntax_and_match_snippet_element(entry, &snippet_shared, cx).unwrap_or_else(|| { + let snippet_highlights = if do_highlights { + highlight_indices(snippet_text, &delegate.current_query, case_sensitive) + } else { + Vec::new() + }; + HighlightedLabel::new(snippet_shared.clone(), snippet_highlights) + .size(LabelSize::Small) + .single_line() + .truncate() + .into_any_element() + }) + }; + + let content = v_flex() + .gap_1() + .overflow_hidden() + .child( + h_flex() + .gap_1() + .items_center() + .overflow_hidden() + .child( + Label::new(entry.file_name.clone()) + .size(LabelSize::XSmall) + .color(Color::Muted) + .single_line() + .truncate(), + ) + .when_some(entry.location_label.as_ref(), |this, location| { + this.child( + Label::new(location.clone()) + .size(LabelSize::XSmall) + .color(Color::Muted) + .single_line(), + ) + }) + .child( + Label::new(entry.path_label.clone()) + .size(LabelSize::XSmall) + .color(Color::Muted) + .single_line() + .truncate(), + ), + ) + .child(snippet_element); + (icon, content.into_any_element()) + } + types::QuickMatchKind::GitCommit { + branch, + subject, + author, + repo_label, + .. + } => { + let icon = Some(Icon::new(IconName::GitBranchAlt).color(Color::Muted)); + let subject = subject.clone(); + let subject_highlights = if do_highlights { + highlight_indices(&subject, &delegate.current_query, case_sensitive) + } else { + Vec::new() + }; + + let author = author.clone(); + let sha = entry.file_name.clone(); + let repo = repo_label.clone(); + + let mut meta_parts = Vec::::new(); + if let Some(branch) = branch.as_ref().filter(|b| !b.is_empty()) { + meta_parts.push(branch.to_string()); + } + if !author.is_empty() { + meta_parts.push(author.to_string()); + } + if !sha.is_empty() { + meta_parts.push(sha.to_string()); + } + if !repo.is_empty() { + meta_parts.push(repo.to_string()); + } + let meta = meta_parts.join(" · "); + let content = v_flex() + .gap_1() + .overflow_hidden() + .child( + HighlightedLabel::new(subject, subject_highlights) + .size(LabelSize::Small) + .single_line() + .truncate(), + ) + .child( + Label::new(meta) + .size(LabelSize::XSmall) + .color(Color::Muted) + .single_line() + .truncate(), + ); + + (icon, content.into_any_element()) + } + }; + + Some( + ListItem::new(("quick-search-item", ix)) + .spacing(ListItemSpacing::Sparse) + .inset(true) + .start_slot::(start_icon) + .toggle_state(selected) + .child(content) + .on_click( + cx.listener(move |picker, event: &gpui::ClickEvent, window, cx| { + cx.stop_propagation(); + window.prevent_default(); + picker.set_selected_index(ix, None, false, window, cx); + if event.click_count() >= 2 { + window.dispatch_action(menu::Confirm.boxed_clone(), cx); + } + }), + ), + ) +} diff --git a/crates/quick_search/src/quick_search_preview.rs b/crates/quick_search/src/quick_search_preview.rs new file mode 100644 index 00000000000000..f857dac8a14343 --- /dev/null +++ b/crates/quick_search/src/quick_search_preview.rs @@ -0,0 +1,1318 @@ +use super::QuickSearch; +use anyhow::{Context as AnyhowContext, Result}; +use buffer_diff::{BufferDiff, BufferDiffSnapshot}; +use editor::{ + Addon, Anchor as MultiBufferAnchor, Editor, EditorMode, MultiBuffer, SelectionEffects, + SizingBehavior, scroll::Autoscroll, +}; +use gpui::Entity; +use gpui::{AppContext, Context, IntoElement, Subscription, WeakEntity, Window}; +use language::language_settings::SoftWrap; +use language::{ + Buffer, Capability, DiskState, File, LanguageRegistry, LineEnding, ReplicaId, Rope, TextBuffer, +}; +use log::debug; +use multi_buffer::{ExcerptRange, PathKey}; +use project::Project; +use project::WorktreeId; +use std::{ + any::Any, + ops::Range, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; +use text::{Anchor as TextAnchor, BufferId, Point, ToPoint}; +use ui::LabelCommon; +use util::ResultExt; +use util::paths::PathStyle; +use util::rel_path::RelPath; + +use crate::GenerationGuard; +use crate::core::SearchCancellation; +use project::ProjectPath; +use project::debounced_delay::DebouncedDelay; + +#[derive(Clone, PartialEq, Eq, Hash, Debug)] +pub(crate) struct PreviewKey(pub u64); + +#[derive(Clone)] +pub(crate) enum PreviewRequest { + Empty, + Buffer { + key: PreviewKey, + buffer: Entity, + strong_ranges: Vec>, + weak_ranges: Vec>, + use_diff_preview: bool, + }, + ProjectPath { + key: PreviewKey, + project_path: ProjectPath, + strong_ranges: Vec>, + weak_ranges: Vec>, + use_diff_preview: bool, + }, + GitCommit { + key: PreviewKey, + repo_workdir: Arc, + sha: Arc, + query: Arc, + }, +} + +struct PreviewManager { + generation_guard: GenerationGuard, + debounce: DebouncedDelay, +} + +impl PreviewManager { + fn new() -> Self { + Self { + generation_guard: GenerationGuard::new(), + debounce: DebouncedDelay::new(), + } + } + + fn generation(&self) -> usize { + self.generation_guard.generation() + } + + fn begin_request(&mut self) -> (usize, Arc) { + self.generation_guard.begin_request() + } + + fn debounce_mut(&mut self) -> &mut DebouncedDelay { + &mut self.debounce + } +} + +pub struct PreviewState { + project: Entity, + text_preview_multi: Entity, + text_preview_editor: Entity, + diff_preview_multi: Entity, + diff_preview_editor: Entity, + use_diff_preview: bool, + manager: PreviewManager, + pub current_preview: Option, + pub current_preview_anchors: Option>>, + pub current_weak_preview_anchors: Option>>, + pub needs_preview_scroll: bool, + pub error_message: Option, + _text_scroll_subscription: Subscription, + _diff_scroll_subscription: Subscription, +} + +enum QuickSearchPreviewStrongHighlights {} +enum QuickSearchPreviewWeakHighlights {} + +impl PreviewState { + pub fn new( + project: Entity, + initial_buffer: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let project_for_text = project.clone(); + let (text_preview_multi, text_preview_editor) = + build_preview_editor(initial_buffer.clone(), project_for_text, false, window, cx); + let project_for_diff = project.clone(); + let (diff_preview_multi, diff_preview_editor) = + build_preview_editor(initial_buffer, project_for_diff, true, window, cx); + + let text_preview_editor_handle = text_preview_editor.clone(); + let text_sub = cx.subscribe_in( + &text_preview_editor_handle, + window, + |this: &mut QuickSearch, _editor, event, _window, _cx| { + if let editor::EditorEvent::ScrollPositionChanged { + autoscroll: false, .. + } = event + { + this.preview.needs_preview_scroll = false; + } + }, + ); + + let diff_preview_editor_handle = diff_preview_editor.clone(); + let diff_sub = cx.subscribe_in( + &diff_preview_editor_handle, + window, + |this: &mut QuickSearch, _editor, event, _window, _cx| { + if let editor::EditorEvent::ScrollPositionChanged { + autoscroll: false, .. + } = event + { + this.preview.needs_preview_scroll = false; + } + }, + ); + + Self { + project, + text_preview_multi, + text_preview_editor, + diff_preview_multi, + diff_preview_editor, + use_diff_preview: false, + manager: PreviewManager::new(), + current_preview: None, + current_preview_anchors: None, + current_weak_preview_anchors: None, + needs_preview_scroll: false, + error_message: None, + _text_scroll_subscription: text_sub, + _diff_scroll_subscription: diff_sub, + } + } + + fn active_preview_multi(&self) -> &Entity { + if self.use_diff_preview { + &self.diff_preview_multi + } else { + &self.text_preview_multi + } + } + + fn active_preview_editor(&self) -> &Entity { + if self.use_diff_preview { + &self.diff_preview_editor + } else { + &self.text_preview_editor + } + } + + pub fn preview_editor(&self) -> Entity { + self.active_preview_editor().clone() + } + + pub(super) fn project(&self) -> Entity { + self.project.clone() + } + + pub fn set_error(&mut self, message: impl Into) { + self.error_message = Some(message.into()); + } + + pub fn clear_error(&mut self) { + self.error_message = None; + } + + fn replace_preview(&mut self, buffer: Entity, cx: &mut Context) { + let buffer_id = buffer.read(cx).remote_id(); + self.active_preview_editor().update(cx, |editor, cx| { + editor.disable_header_for_buffer(buffer_id, cx); + }); + self.active_preview_multi().update(cx, |multi, cx| { + multi.clear(cx); + multi.push_excerpts( + buffer, + [ExcerptRange::new(text::Anchor::min_max_range_for_buffer( + buffer_id, + ))], + cx, + ); + }); + self.needs_preview_scroll = false; + } + + pub fn request_preview( + &mut self, + request: PreviewRequest, + session_cancellation: SearchCancellation, + owner: &WeakEntity, + window: &mut Window, + cx: &mut Context, + ) { + self.maybe_update_preview(request, session_cancellation, owner, window, cx); + } + + pub fn maybe_update_preview( + &mut self, + request: PreviewRequest, + session_cancellation: SearchCancellation, + owner: &WeakEntity, + window: &mut Window, + cx: &mut Context, + ) { + if session_cancellation.is_cancelled() { + return; + } + + let (preview_key, strong_anchors, weak_anchors, use_diff_preview) = match &request { + PreviewRequest::Empty => (None, None, None, false), + PreviewRequest::Buffer { + key, + strong_ranges, + weak_ranges, + use_diff_preview, + .. + } => ( + Some(key.clone()), + Some(strong_ranges.clone()), + Some(weak_ranges.clone()), + *use_diff_preview, + ), + PreviewRequest::ProjectPath { + key, + use_diff_preview, + .. + } => (Some(key.clone()), None, None, *use_diff_preview), + PreviewRequest::GitCommit { key, .. } => (Some(key.clone()), None, None, true), + }; + + if matches!(request, PreviewRequest::Empty) { + self.manager.begin_request(); + self.current_preview = None; + self.current_preview_anchors = None; + self.current_weak_preview_anchors = None; + self.needs_preview_scroll = false; + self.use_diff_preview = false; + self.apply_preview_highlights(cx); + return; + } + + let Some(preview_key) = preview_key else { + return; + }; + + let same_preview = self.current_preview.as_ref() == Some(&preview_key); + let same_anchors = self.current_preview_anchors.as_ref() == strong_anchors.as_ref(); + let same_weak = self.current_weak_preview_anchors.as_ref() == weak_anchors.as_ref(); + let same_presentation = self.use_diff_preview == use_diff_preview; + + if same_preview && same_anchors && same_weak && same_presentation { + if self.needs_preview_scroll { + self.apply_preview_selection(window, cx); + } + return; + } + + if same_preview && same_anchors && !same_weak && same_presentation { + self.current_weak_preview_anchors = weak_anchors; + self.apply_preview_highlights(cx); + return; + } + + self.current_preview_anchors = strong_anchors; + self.current_weak_preview_anchors = weak_anchors; + self.needs_preview_scroll = false; + + let (preview_generation, cancel_flag) = self.manager.begin_request(); + + let quick_search = owner.clone(); + let project_for_task = self.project.clone(); + let request_for_task = request.clone(); + let preview_key_for_task = preview_key.clone(); + self.current_preview = Some(preview_key); + self.use_diff_preview = use_diff_preview; + + if let PreviewRequest::Buffer { buffer, .. } = &request { + let same_buffer = self + .active_preview_multi() + .read(cx) + .as_singleton() + .map(|b| b == *buffer) + .unwrap_or(false); + + if !same_buffer { + self.replace_preview(buffer.clone(), cx); + } + self.needs_preview_scroll = true; + self.apply_preview_highlights(cx); + self.apply_preview_selection(window, cx); + return; + } + + let session_cancellation_for_task = session_cancellation; + + let window_handle = window.window_handle(); + self.manager + .debounce_mut() + .fire_new(Duration::from_millis(24), cx, move |_, cx| { + cx.spawn(move |_, app: &mut gpui::AsyncApp| { + let mut app = app.clone(); + async move { + if cancel_flag.load(Ordering::SeqCst) || session_cancellation_for_task.is_cancelled() { + return; + } + + if let PreviewRequest::GitCommit { repo_workdir, sha, .. } = &request_for_task + { + let query_for_commit = match &request_for_task { + PreviewRequest::GitCommit { query, .. } => query.clone(), + _ => Arc::::from(""), + }; + + let placeholder = format!("Loading commit {sha}…\n"); + if let Some(qs) = quick_search.upgrade() { + if let Err(err) = app.update_entity(&qs, |qs, cx| { + if session_cancellation_for_task.is_cancelled() { + return; + } + if qs.preview.current_preview.as_ref() != Some(&preview_key_for_task) + || qs.preview.manager.generation() != preview_generation + { + return; + } + let buffer = cx.new(|cx| language::Buffer::local(&placeholder, cx)); + qs.preview.replace_preview(buffer, cx); + qs.preview.needs_preview_scroll = false; + qs.preview.apply_preview_highlights(cx); + cx.notify(); + }) { + debug!("quick_search: failed to set git preview placeholder: {:?}", err); + } + } + + let repository = app + .read_entity(&project_for_task, |project, cx| { + project + .git_store() + .read(cx) + .repositories() + .values() + .find(|repo| { + repo.read(cx).work_directory_abs_path.as_ref() + == repo_workdir.as_ref() + }) + .cloned() + }) + .unwrap_or_else(|err| { + debug!( + "quick_search: failed to resolve repository for commit preview: {:?}", + err + ); + None + }); + + let Some(repository) = repository else { + let text = format!( + "Failed to load commit:\nNo repository found for {}\n", + repo_workdir.display() + ); + if let Some(qs) = quick_search.upgrade() { + if let Err(err) = app.update_entity(&qs, |qs, cx| { + if session_cancellation_for_task.is_cancelled() { + return; + } + if qs.preview.current_preview.as_ref() != Some(&preview_key_for_task) + || qs.preview.manager.generation() != preview_generation + { + return; + } + let buffer = + cx.new(|cx| language::Buffer::local(text.clone(), cx)); + qs.preview.replace_preview(buffer, cx); + qs.preview.needs_preview_scroll = false; + qs.preview.apply_preview_highlights(cx); + cx.notify(); + }) { + debug!("quick_search: failed to show repo missing preview: {:?}", err); + } + } + return; + }; + + let Ok(language_registry) = + app.read_entity(&project_for_task, |project, _| project.languages().clone()) + else { + return; + }; + + let first_worktree_id = app + .read_entity(&project_for_task, |project, cx| { + project + .worktrees(cx) + .next() + .map(|worktree| worktree.read(cx).id()) + }) + .unwrap_or_else(|err| { + debug!( + "quick_search: failed to resolve worktree id for commit preview: {:?}", + err + ); + None + }); + + let commit_diff_rx = app.update_entity(&repository, |repo, _| { + repo.load_commit_diff(sha.to_string()) + }); + let commit_diff_rx = match commit_diff_rx { + Ok(rx) => rx, + Err(err) => { + let text = format!("Failed to start commit diff load:\n{err:?}\n"); + if let Some(qs) = quick_search.upgrade() { + if let Err(err) = app.update_entity(&qs, |qs, cx| { + if session_cancellation_for_task.is_cancelled() { + return; + } + if qs.preview.current_preview.as_ref() + != Some(&preview_key_for_task) + || qs.preview.manager.generation() != preview_generation + { + return; + } + let buffer = + cx.new(|cx| language::Buffer::local(text.clone(), cx)); + qs.preview.replace_preview(buffer, cx); + qs.preview.needs_preview_scroll = false; + qs.preview.apply_preview_highlights(cx); + cx.notify(); + }) { + debug!( + "quick_search: failed to show commit diff start error: {:?}", + err + ); + } + } + return; + } + }; + + let commit_diff = match commit_diff_rx.await { + Ok(Ok(d)) => d, + Ok(Err(err)) => { + let text = format!("Failed to load commit diff:\n{err:?}\n"); + if let Some(qs) = quick_search.upgrade() { + if let Err(err) = app.update_entity(&qs, |qs, cx| { + if session_cancellation_for_task.is_cancelled() { + return; + } + if qs.preview.current_preview.as_ref() != Some(&preview_key_for_task) + || qs.preview.manager.generation() != preview_generation + { + return; + } + let buffer = + cx.new(|cx| language::Buffer::local(text.clone(), cx)); + qs.preview.replace_preview(buffer, cx); + qs.preview.needs_preview_scroll = false; + qs.preview.apply_preview_highlights(cx); + cx.notify(); + }) { + debug!("quick_search: failed to show commit diff error: {:?}", err); + } + } + return; + } + Err(err) => { + let text = format!("Failed to load commit diff:\n{err:?}\n"); + if let Some(qs) = quick_search.upgrade() { + if let Err(err) = app.update_entity(&qs, |qs, cx| { + if session_cancellation_for_task.is_cancelled() { + return; + } + if qs.preview.current_preview.as_ref() != Some(&preview_key_for_task) + || qs.preview.manager.generation() != preview_generation + { + return; + } + let buffer = + cx.new(|cx| language::Buffer::local(text.clone(), cx)); + qs.preview.replace_preview(buffer, cx); + qs.preview.needs_preview_scroll = false; + qs.preview.apply_preview_highlights(cx); + cx.notify(); + }) { + debug!("quick_search: failed to show commit diff error: {:?}", err); + } + } + return; + } + }; + + if cancel_flag.load(Ordering::SeqCst) || session_cancellation_for_task.is_cancelled() { + return; + } + + let mut built: Vec<(Entity, Entity)> = Vec::new(); + for file in commit_diff.files { + if cancel_flag.load(Ordering::SeqCst) || session_cancellation_for_task.is_cancelled() { + return; + } + + let is_deleted = file.new_text.is_none(); + let new_text = file.new_text.unwrap_or_default(); + let old_text = file.old_text; + + let worktree_id = match app.update_entity(&repository, |repo, cx| { + repo.repo_path_to_project_path(&file.path, cx) + .map(|p| p.worktree_id) + .or(first_worktree_id) + }) { + Ok(Some(id)) => id, + _ => continue, + }; + + let display_name: Arc = Arc::from( + file.path + .display(PathStyle::Posix) + .to_string() + .into_boxed_str(), + ); + + let file = Arc::new(GitBlob { + path: file.path.clone(), + worktree_id, + is_deleted, + display_name, + }) as Arc; + + let buffer = match build_commit_file_buffer( + new_text, + file, + &language_registry, + &mut app, + ) + .await + { + Ok(buffer) => buffer, + Err(err) => { + debug!("quick_search: failed to build commit file buffer: {err:?}"); + continue; + } + }; + + let buffer_diff = match build_commit_file_diff( + old_text, + &buffer, + &language_registry, + &mut app, + ) + .await + { + Ok(diff) => diff, + Err(err) => { + debug!("quick_search: failed to build commit file diff: {err:?}"); + continue; + } + }; + + built.push((buffer, buffer_diff)); + } + + if cancel_flag.load(Ordering::SeqCst) || session_cancellation_for_task.is_cancelled() { + return; + } + + if let Some(qs) = quick_search.upgrade() { + let preview_id = preview_key_for_task.clone(); + let mut should_apply_selection = false; + let update_result = app.update_entity(&qs, |qs, cx| { + if session_cancellation_for_task.is_cancelled() { + return; + } + if qs.preview.current_preview.as_ref() != Some(&preview_key_for_task) + || qs.preview.manager.generation() != preview_generation + { + return; + } + + fn find_query_anchor_in_buffer( + snapshot: &language::BufferSnapshot, + query: &str, + ) -> Option { + let query = query.trim(); + if query.is_empty() { + return None; + } + + let query_lower = query.to_ascii_lowercase(); + for row in 0..=snapshot.text.max_point().row { + let line: String = snapshot + .text + .text_for_range( + Point::new(row, 0) + ..Point::new(row, snapshot.text.line_len(row)), + ) + .collect(); + let hay_lower = line.to_ascii_lowercase(); + let Some(byte_ix) = hay_lower.find(&query_lower) else { + continue; + }; + let col = line[..byte_ix].chars().count() as u32; + return Some( + snapshot.text.anchor_after(Point::new(row, col)), + ); + } + None + } + + let mut focus_range: Option> = None; + if !query_for_commit.trim().is_empty() { + for (buffer, _buffer_diff) in &built { + let snapshot = buffer.read(cx).snapshot(); + if let Some(anchor) = + find_query_anchor_in_buffer(&snapshot, &query_for_commit) + { + focus_range = Some(anchor..anchor); + break; + } + } + } + + if focus_range.is_none() { + for (buffer, buffer_diff) in &built { + let snapshot = buffer.read(cx).snapshot(); + let first_hunk = buffer_diff + .read(cx) + .hunks(&snapshot.text, cx) + .next(); + if let Some(hunk) = first_hunk { + let anchor = hunk.buffer_range.start; + focus_range = Some(anchor..anchor); + break; + } + } + } + + qs.preview.use_diff_preview = true; + qs.preview.diff_preview_multi.update(cx, |multibuffer, cx| { + multibuffer.clear(cx); + for (buffer, buffer_diff) in &built { + let snapshot = buffer.read(cx).snapshot(); + let Some(path) = + snapshot.file().map(|file| file.path().clone()) + else { + continue; + }; + + let excerpt_ranges = + vec![language::Point::zero()..snapshot.max_point()]; + + let (_preview_anchors, _new_excerpts) = + multibuffer.set_excerpts_for_path( + PathKey::with_sort_prefix(FILE_NAMESPACE_SORT_PREFIX, path), + buffer.clone(), + excerpt_ranges, + 0, + cx, + ); + multibuffer.add_diff(buffer_diff.clone(), cx); + } + }); + + qs.preview.current_preview_anchors = + focus_range.map(|range| vec![range]); + qs.preview.needs_preview_scroll = true; + qs.preview.apply_preview_highlights(cx); + should_apply_selection = true; + cx.notify(); + }); + if let Err(err) = update_result { + debug!("quick_search: failed to apply commit preview: {:?}", err); + } else if should_apply_selection { + let quick_search = quick_search.clone(); + if let Err(err) = app.update_window(window_handle, move |_, window, cx| { + let Some(qs) = quick_search.upgrade() else { + return; + }; + qs.update(cx, |qs, cx| { + if qs.preview.current_preview.as_ref() != Some(&preview_id) + || qs.preview.manager.generation() != preview_generation + { + return; + } + qs.preview.apply_preview_selection(window, cx); + }); + }) { + debug!("quick_search: window update failed: {:?}", err); + } + } + } + return; + } + + let buffer_for_preview = match &request_for_task { + PreviewRequest::Buffer { buffer, .. } => buffer.clone(), + PreviewRequest::ProjectPath { project_path, .. } => { + let open_task = match app.update_entity( + &project_for_task, + |project, cx| project.open_buffer(project_path.clone(), cx), + ) { + Ok(task) => task, + Err(err) => { + debug!("quick_search: failed to start open_buffer: {:?}", err); + if let Some(qs) = quick_search.upgrade() { + if let Err(update_err) = app.update_entity(&qs, |qs, _cx| { + qs.preview.set_error(format!("Failed to open file: {err}")); + }) { + debug!( + "quick_search: failed to record preview error: {:?}", + update_err + ); + } + } + return; + } + }; + let buffer = match open_task.await { + Ok(buffer) => buffer, + Err(err) => { + debug!("quick_search: failed to open buffer: {:?}", err); + if let Some(qs) = quick_search.upgrade() { + if let Err(update_err) = app.update_entity(&qs, |qs, _cx| { + qs.preview.set_error(format!("Failed to open file: {err}")); + }) { + debug!( + "quick_search: failed to record preview error: {:?}", + update_err + ); + } + } + return; + } + }; + buffer + } + _ => return, + }; + + if let Some(qs) = quick_search.upgrade() { + let preview_id = preview_key_for_task.clone(); + let (strong_point_ranges, weak_point_ranges) = match &request_for_task { + PreviewRequest::ProjectPath { + strong_ranges, + weak_ranges, + .. + } => (strong_ranges.clone(), weak_ranges.clone()), + _ => (Vec::new(), Vec::new()), + }; + let mut should_apply_selection = false; + let update_result = app.update_entity(&qs, |qs, cx| { + if session_cancellation_for_task.is_cancelled() { + return; + } + if qs.preview.current_preview.as_ref() != Some(&preview_id) + || qs.preview.manager.generation() != preview_generation + { + return; + } + let buffer_changed = qs + .preview + .active_preview_multi() + .read(cx) + .as_singleton() + .map(|b| b != buffer_for_preview) + .unwrap_or(true); + if buffer_changed { + qs.preview.replace_preview(buffer_for_preview.clone(), cx); + } + + if !strong_point_ranges.is_empty() || !weak_point_ranges.is_empty() { + let snapshot = buffer_for_preview.read(cx).snapshot(); + qs.preview.current_preview_anchors = Some( + strong_point_ranges + .iter() + .cloned() + .map(|range| { + crate::types::point_range_to_anchor_range( + range, + &snapshot.text, + ) + }) + .collect(), + ); + qs.preview.current_weak_preview_anchors = Some( + weak_point_ranges + .iter() + .cloned() + .map(|range| { + crate::types::point_range_to_anchor_range( + range, + &snapshot.text, + ) + }) + .collect(), + ); + } else { + qs.preview.current_preview_anchors = None; + qs.preview.current_weak_preview_anchors = None; + } + qs.preview.clear_error(); + qs.preview.needs_preview_scroll = true; + qs.preview.apply_preview_highlights(cx); + should_apply_selection = true; + cx.notify(); + }); + if let Err(err) = update_result { + debug!( + "quick_search: quick search dropped before preview applied: {:?}", + err + ); + } else if should_apply_selection { + let quick_search = quick_search.clone(); + let preview_id = preview_key_for_task.clone(); + if let Err(err) = app.update_window(window_handle, move |_, window, cx| { + let Some(qs) = quick_search.upgrade() else { + return; + }; + qs.update(cx, |qs, cx| { + if qs.preview.current_preview.as_ref() != Some(&preview_id) + || qs.preview.manager.generation() != preview_generation + { + return; + } + qs.preview.apply_preview_selection(window, cx); + }); + }) { + debug!("quick_search: window update failed: {:?}", err); + } + } + } + } + }) + }); + } + + fn apply_preview_highlights(&mut self, cx: &mut Context) { + let strong = self.current_preview_anchors.clone().unwrap_or_default(); + let weak = self + .current_weak_preview_anchors + .clone() + .unwrap_or_default(); + let use_diff_preview = self.use_diff_preview; + + self.active_preview_editor().update(cx, |editor, cx| { + let multi_buffer = editor.buffer().read(cx); + let snapshot = multi_buffer.snapshot(cx); + let excerpt_buffers: std::collections::HashMap< + multi_buffer::ExcerptId, + &language::BufferSnapshot, + > = snapshot + .excerpts() + .map(|(excerpt_id, buffer, _)| (excerpt_id, buffer)) + .collect(); + + let mut excerpt_ids_by_buffer = + std::collections::HashMap::::new(); + let mut fallback_excerpt_id: Option = None; + if use_diff_preview { + for (excerpt_id, buffer, _range) in snapshot.excerpts() { + fallback_excerpt_id.get_or_insert(excerpt_id); + excerpt_ids_by_buffer + .entry(buffer.remote_id()) + .or_insert(excerpt_id); + } + } else { + fallback_excerpt_id = snapshot.excerpts().next().map(|(id, _, _)| id); + } + + let Some(fallback_excerpt_id) = fallback_excerpt_id else { + editor.highlight_background::( + &[], + |_, theme| theme.colors().search_match_background.opacity(0.35), + cx, + ); + editor.highlight_background::( + &[], + |_, theme| theme.colors().search_active_match_background, + cx, + ); + return; + }; + + let convert_range = |range: &Range| { + let excerpt_id = if use_diff_preview { + range + .start + .buffer_id + .and_then(|buffer_id| excerpt_ids_by_buffer.get(&buffer_id).copied()) + .unwrap_or(fallback_excerpt_id) + } else { + fallback_excerpt_id + }; + let converted = MultiBufferAnchor::range_in_buffer(excerpt_id, range.clone()); + is_safe_anchor_range(&converted, &excerpt_buffers).then_some(converted) + }; + + let weak_ranges: Vec<_> = weak.iter().filter_map(convert_range).collect(); + let strong_ranges: Vec<_> = strong.iter().filter_map(convert_range).collect(); + + editor.highlight_background::( + &weak_ranges, + |_, theme| theme.colors().search_match_background.opacity(0.35), + cx, + ); + editor.highlight_background::( + &strong_ranges, + |_, theme| theme.colors().search_active_match_background, + cx, + ); + }); + } + + pub fn apply_preview_selection(&mut self, window: &mut Window, cx: &mut Context) { + if !self.needs_preview_scroll { + return; + } + if self.use_diff_preview { + let strong = self.current_preview_anchors.clone().unwrap_or_default(); + self.active_preview_editor().update(cx, |editor, cx| { + let multi_buffer = editor.buffer().read(cx); + let snapshot = multi_buffer.snapshot(cx); + let mut excerpt_ids_by_buffer = + std::collections::HashMap::::new(); + let mut fallback_excerpt_id: Option = None; + for (excerpt_id, buffer, _range) in snapshot.excerpts() { + fallback_excerpt_id.get_or_insert(excerpt_id); + excerpt_ids_by_buffer + .entry(buffer.remote_id()) + .or_insert(excerpt_id); + } + let Some(fallback_excerpt_id) = fallback_excerpt_id else { + return; + }; + + let excerpt_buffers: std::collections::HashMap< + multi_buffer::ExcerptId, + &language::BufferSnapshot, + > = snapshot + .excerpts() + .map(|(excerpt_id, buffer, _)| (excerpt_id, buffer)) + .collect(); + + let mut anchor_ranges: Vec> = strong + .iter() + .filter_map(|range| { + let excerpt_id = range + .start + .buffer_id + .and_then(|buffer_id| excerpt_ids_by_buffer.get(&buffer_id).copied()) + .unwrap_or(fallback_excerpt_id); + let converted = + MultiBufferAnchor::range_in_buffer(excerpt_id, range.clone()); + is_safe_anchor_range(&converted, &excerpt_buffers).then_some(converted) + }) + .collect(); + if anchor_ranges.is_empty() { + anchor_ranges.push(MultiBufferAnchor::min()..MultiBufferAnchor::min()); + } + + let effects = SelectionEffects::scroll(Autoscroll::center()); + editor.change_selections(effects, window, cx, move |selections| { + selections.clear_disjoint(); + selections.select_anchor_ranges(anchor_ranges); + }); + }); + } else { + let strong = self.current_preview_anchors.clone().unwrap_or_default(); + self.active_preview_editor().update(cx, |editor, cx| { + let multi_buffer = editor.buffer().read(cx); + let snapshot = multi_buffer.snapshot(cx); + let Some((excerpt_id, _buffer, _range)) = snapshot.excerpts().next() else { + return; + }; + + let excerpt_buffers: std::collections::HashMap< + multi_buffer::ExcerptId, + &language::BufferSnapshot, + > = snapshot + .excerpts() + .map(|(excerpt_id, buffer, _)| (excerpt_id, buffer)) + .collect(); + + let mut anchor_ranges: Vec> = strong + .iter() + .filter_map(|range| { + let converted = + MultiBufferAnchor::range_in_buffer(excerpt_id, range.clone()); + is_safe_anchor_range(&converted, &excerpt_buffers).then_some(converted) + }) + .collect(); + if anchor_ranges.is_empty() { + anchor_ranges.push(MultiBufferAnchor::min()..MultiBufferAnchor::min()); + } + + let effects = SelectionEffects::scroll(Autoscroll::center()); + editor.change_selections(effects, window, cx, move |selections| { + selections.clear_disjoint(); + selections.select_anchor_ranges(anchor_ranges); + }); + }); + } + self.needs_preview_scroll = false; + } +} + +fn is_safe_anchor_range( + range: &Range, + excerpt_buffers: &std::collections::HashMap, +) -> bool { + is_safe_anchor(&range.start, excerpt_buffers) && is_safe_anchor(&range.end, excerpt_buffers) +} + +fn is_safe_anchor( + anchor: &MultiBufferAnchor, + excerpt_buffers: &std::collections::HashMap, +) -> bool { + if anchor.is_min() || anchor.is_max() { + return true; + } + + let Some(buffer) = excerpt_buffers.get(&anchor.excerpt_id) else { + return false; + }; + if !buffer.can_resolve(&anchor.text_anchor) { + return false; + } + + let point = anchor.text_anchor.to_point(buffer); + let max_point = buffer.text.max_point(); + if point.row > max_point.row { + return false; + } + let max_col = buffer.text.line_len(point.row); + point.column <= max_col +} + +fn build_preview_editor( + buffer: Entity, + project: Entity, + include_commit_addon: bool, + window: &mut Window, + cx: &mut Context, +) -> (Entity, Entity) { + let buffer_id = buffer.read(cx).remote_id(); + let preview_multi = cx.new(|cx| { + let mut multi = if include_commit_addon { + MultiBuffer::new(Capability::ReadOnly) + } else { + MultiBuffer::without_headers(Capability::ReadOnly) + }; + multi.push_excerpts( + buffer, + [ExcerptRange::new(TextAnchor::min_max_range_for_buffer( + buffer_id, + ))], + cx, + ); + multi + }); + let preview_editor = cx.new(|cx| { + let mut editor = Editor::new( + EditorMode::Full { + scale_ui_elements_with_buffer_font_size: true, + show_active_line_background: true, + + sizing_behavior: SizingBehavior::ExcludeOverscrollMargin, + }, + preview_multi.clone(), + Some(project.clone()), + window, + cx, + ); + editor.set_read_only(true); + editor.set_searchable(false); + editor.set_in_project_search(true); + editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); + if include_commit_addon { + editor.set_expand_all_diff_hunks(cx); + editor.register_addon(CommitPreviewAddon { + multibuffer: preview_multi.downgrade(), + }); + } + editor.set_show_line_numbers(true, cx); + editor.set_show_wrap_guides(false, cx); + editor.set_show_runnables(false, cx); + editor.set_show_breakpoints(false, cx); + editor.set_show_gutter(true, cx); + editor.set_show_scrollbars(true, cx); + editor.disable_expand_excerpt_buttons(cx); + if !include_commit_addon { + editor.disable_header_for_buffer(buffer_id, cx); + } + editor + }); + (preview_multi, preview_editor) +} + +pub(super) const FILE_NAMESPACE_SORT_PREFIX: u64 = 1; + +pub(super) struct CommitPreviewAddon { + pub(super) multibuffer: WeakEntity, +} + +impl Addon for CommitPreviewAddon { + fn render_buffer_header_controls( + &self, + excerpt: &multi_buffer::ExcerptInfo, + _window: &Window, + cx: &gpui::App, + ) -> Option { + let multibuffer = self.multibuffer.upgrade()?; + let snapshot = multibuffer.read(cx).snapshot(cx); + let excerpts = snapshot.excerpts().collect::>(); + let current_idx = excerpts.iter().position(|(id, _, _)| *id == excerpt.id)?; + let (_, _, current_range) = &excerpts[current_idx]; + + let start_row = current_range.context.start.to_point(&excerpt.buffer).row; + + let prev_end_row = if current_idx > 0 { + let (_, prev_buffer, prev_range) = &excerpts[current_idx - 1]; + if prev_buffer.remote_id() == excerpt.buffer_id { + prev_range.context.end.to_point(&excerpt.buffer).row + } else { + 0 + } + } else { + 0 + }; + + let skipped_lines = start_row.saturating_sub(prev_end_row); + if skipped_lines > 0 { + Some( + ui::Label::new(format!("{skipped_lines} unchanged lines")) + .color(ui::Color::Muted) + .size(ui::LabelSize::Small) + .into_any_element(), + ) + } else { + None + } + } + + fn to_any(&self) -> &dyn Any { + self + } +} + +#[derive(Clone)] +pub(super) struct GitBlob { + pub(super) path: git::repository::RepoPath, + pub(super) worktree_id: WorktreeId, + pub(super) is_deleted: bool, + pub(super) display_name: Arc, +} + +impl File for GitBlob { + fn as_local(&self) -> Option<&dyn language::LocalFile> { + None + } + + fn disk_state(&self) -> DiskState { + if self.is_deleted { + DiskState::Deleted + } else { + DiskState::New + } + } + + fn path_style(&self, _: &gpui::App) -> PathStyle { + PathStyle::Posix + } + + fn path(&self) -> &Arc { + self.path.as_ref() + } + + fn full_path(&self, _: &gpui::App) -> std::path::PathBuf { + self.path.as_std_path().to_path_buf() + } + + fn file_name<'a>(&'a self, _: &'a gpui::App) -> &'a str { + self.display_name.as_ref() + } + + fn worktree_id(&self, _: &gpui::App) -> WorktreeId { + self.worktree_id + } + + fn to_proto(&self, _cx: &gpui::App) -> language::proto::File { + language::proto::File { + worktree_id: self.worktree_id.to_proto(), + entry_id: None, + path: self.path.as_ref().as_unix_str().to_string(), + mtime: None, + is_deleted: self.is_deleted, + } + } + + fn is_private(&self) -> bool { + false + } +} + +pub(super) async fn build_commit_file_buffer( + mut text: String, + file: Arc, + language_registry: &Arc, + cx: &mut gpui::AsyncApp, +) -> Result> { + let line_ending = LineEnding::detect(&text); + LineEnding::normalize(&mut text); + let text = Rope::from(text); + + let language = cx.update(|cx| language_registry.language_for_file(&file, Some(&text), cx))?; + let language = if let Some(language) = language { + language_registry + .load_language(&language) + .await + .ok() + .and_then(|e| e.log_err()) + } else { + None + }; + + let buffer = cx + .new(|cx| { + let buffer = TextBuffer::new_normalized( + ReplicaId::LOCAL, + cx.entity_id().as_non_zero_u64().into(), + line_ending, + text, + ); + let mut buffer = Buffer::build(buffer, Some(file), Capability::ReadWrite); + buffer.set_language_async(language, cx); + buffer + }) + .context("creating commit preview buffer")?; + + Ok(buffer) +} + +pub(super) async fn build_commit_file_diff( + mut old_text: Option, + buffer: &Entity, + language_registry: &Arc, + cx: &mut gpui::AsyncApp, +) -> Result> { + if let Some(old_text) = &mut old_text { + LineEnding::normalize(old_text); + } + + let buffer_snapshot = cx.update(|cx| buffer.read(cx).snapshot())?; + + let base_buffer = cx + .update(|cx| { + Buffer::build_snapshot( + old_text.as_deref().unwrap_or("").into(), + buffer_snapshot.language().cloned(), + Some(language_registry.clone()), + cx, + ) + })? + .await; + + let diff_snapshot = cx + .update(|cx| { + BufferDiffSnapshot::new_with_base_buffer( + buffer_snapshot.text.clone(), + old_text.map(Arc::new), + base_buffer, + cx, + ) + })? + .await; + + cx.new(|cx| { + let mut diff = BufferDiff::new(&buffer_snapshot.text, cx); + diff.set_snapshot(diff_snapshot, &buffer_snapshot.text, cx); + diff + }) + .context("creating commit preview diff") +} diff --git a/crates/quick_search/src/sources.rs b/crates/quick_search/src/sources.rs new file mode 100644 index 00000000000000..668c2d2fc8cc90 --- /dev/null +++ b/crates/quick_search/src/sources.rs @@ -0,0 +1,3 @@ +pub mod commits; +pub mod files; +pub mod text_grep; diff --git a/crates/quick_search/src/sources/commits.rs b/crates/quick_search/src/sources/commits.rs new file mode 100644 index 00000000000000..85095f0638b70a --- /dev/null +++ b/crates/quick_search/src/sources/commits.rs @@ -0,0 +1,368 @@ +use std::sync::{Arc, OnceLock}; + +use gpui::AppContext; +use search::SearchOptions; +use ui::IconName; + +use crate::types::QuickMatchBuilder; +use crate::types::QuickMatchKind; +use anyhow::{Context as AnyhowContext, Result}; +use futures::FutureExt as _; +use fuzzy::StringMatchCandidate; +use git2::Sort; +use log::debug; + +use crate::core::{ + ListPresentation, MatchBatcher, QuickSearchSource, SearchContext, SearchSink, SearchUiContext, + SortPolicy, SourceId, SourceSpec, SourceSpecCore, SourceSpecUi, +}; + +pub struct CommitsSource; + +#[derive(Clone)] +pub struct GitCommitEntry { + pub repo_workdir: Arc, + pub sha: Arc, + pub subject: Arc, + pub commit_timestamp: i64, + pub author_name: Arc, + pub branch: Option>, +} + +pub fn list_commits_local( + repo_workdir: Arc, + limit: usize, +) -> Result> { + let repo = git2::Repository::open(repo_workdir.as_ref()).context("opening git repository")?; + + let branch: Option> = match repo.head() { + Ok(head) => head.shorthand().map(|s| s.to_string()).and_then(|name| { + let name = name.trim(); + (!name.is_empty() && name != "HEAD").then(|| Arc::::from(name.to_string())) + }), + Err(err) => { + debug!("quick_search: failed to read git HEAD: {:?}", err); + None + } + }; + + let mut revwalk = repo.revwalk().context("creating git revwalk")?; + revwalk.push_head().context("pushing HEAD to revwalk")?; + + revwalk + .set_sorting(Sort::TIME) + .context("setting revwalk sorting")?; + + let mut commits = Vec::new(); + for oid in revwalk.take(limit) { + let oid = match oid { + Ok(oid) => oid, + Err(_) => continue, + }; + let commit = match repo.find_commit(oid) { + Ok(c) => c, + Err(_) => continue, + }; + + let sha: Arc = Arc::from(oid.to_string()); + let subject: Arc = Arc::from(commit.summary().unwrap_or("").trim().to_string()); + let commit_timestamp = commit.time().seconds(); + let author_name: Arc = Arc::from( + commit + .author() + .name() + .unwrap_or("unknown") + .trim() + .to_string(), + ); + + commits.push(GitCommitEntry { + repo_workdir: repo_workdir.clone(), + sha, + subject, + commit_timestamp, + author_name, + branch: branch.clone(), + }); + } + + Ok(commits) +} + +impl CommitsSource { + fn spec_static() -> &'static SourceSpec { + static SPEC: OnceLock = OnceLock::new(); + SPEC.get_or_init(|| SourceSpec { + id: SourceId(Arc::from("commits")), + core: SourceSpecCore { + supported_options: SearchOptions::empty(), + min_query_len: 1, + sort_policy: SortPolicy::StreamOrder, + }, + ui: SourceSpecUi { + title: Arc::from("Commits"), + icon: IconName::GitBranchAlt, + placeholder: Arc::from("Search commits..."), + list_presentation: ListPresentation::Flat, + use_diff_preview: true, + }, + }) + } +} + +impl QuickSearchSource for CommitsSource { + fn spec(&self) -> &'static SourceSpec { + Self::spec_static() + } + + fn start_search(&self, ctx: SearchContext, sink: SearchSink, cx: &mut SearchUiContext<'_>) { + let repos = ctx + .project() + .read(cx) + .git_store() + .read(cx) + .repositories() + .values() + .cloned() + .collect::>(); + + let repos = repos + .into_iter() + .map(|repo| { + let repo_workdir = repo.read(cx).work_directory_abs_path.clone(); + (repo_workdir, repo) + }) + .collect::>(); + + if repos.is_empty() { + let message = "No Git repositories found in this project.".to_string(); + crate::core::spawn_source_task(cx, sink, move |app, sink| { + async move { + sink.record_error(message, app); + } + .boxed_local() + }); + return; + } + + let query = ctx.query().clone(); + let executor = ctx.background_executor().clone(); + let source_id = self.spec().id.0.clone(); + let cancellation = ctx.cancellation().clone(); + let cancel_flag = cancellation.flag(); + let match_arena = ctx.match_arena().clone(); + crate::core::spawn_source_task(cx, sink, move |app, sink| { + async move { + if cancellation.is_cancelled() { + return; + } + + let mut commits = Vec::new(); + let mut used_fallback = false; + for (repo_workdir, repo_entity) in repos { + if cancellation.is_cancelled() { + return; + } + + let local_workdir = repo_workdir.clone(); + let local_task = + executor.spawn(async move { list_commits_local(local_workdir, 500) }); + match local_task.await { + Ok(mut entries) => commits.append(&mut entries), + Err(err) => { + debug!( + "quick_search: local commit listing failed (falling back): {:?}", + err + ); + used_fallback = true; + + let branches_rx = + match app.update_entity(&repo_entity, |repo, _| repo.branches()) { + Ok(rx) => rx, + Err(err) => { + debug!( + "quick_search: failed to get branches from git store (skipping repo): {:?}", + err + ); + continue; + } + }; + + let branches = match branches_rx.await { + Ok(Ok(branches)) => branches, + Ok(Err(err)) => { + debug!( + "quick_search: failed to list branches from git store: {:?}", + err + ); + Vec::new() + } + Err(err) => { + debug!( + "quick_search: branch listing task failed (falling back to empty): {:?}", + err + ); + Vec::new() + } + }; + + let mut seen = std::collections::HashSet::::new(); + for b in branches { + let branch_name: Arc = Arc::from(b.name().to_string()); + let Some(summary) = b.most_recent_commit else { + continue; + }; + let sha = summary.sha.to_string(); + if !seen.insert(sha.clone()) { + continue; + } + commits.push(GitCommitEntry { + repo_workdir: repo_workdir.clone(), + sha: Arc::::from(sha), + subject: Arc::::from(summary.subject.to_string()), + commit_timestamp: summary.commit_timestamp, + author_name: Arc::::from(summary.author_name.to_string()), + branch: Some(branch_name.clone()), + }); + } + + let head_commit = match app + .update_entity(&repo_entity, |repo, _| repo.snapshot().head_commit) + { + Ok(head_commit) => head_commit, + Err(err) => { + debug!( + "quick_search: failed to read head commit from git store: {:?}", + err + ); + None + } + }; + if let Some(head) = head_commit { + let sha = head.sha.to_string(); + let subject = head + .message + .lines() + .next() + .unwrap_or("") + .trim() + .to_string(); + if !sha.is_empty() { + commits.push(GitCommitEntry { + repo_workdir: repo_workdir.clone(), + sha: Arc::::from(sha), + subject: Arc::::from(subject), + commit_timestamp: head.commit_timestamp, + author_name: Arc::::from(head.author_name.to_string()), + branch: None, + }); + } + } + } + } + } + + let notice = used_fallback.then_some( + "Some repositories are remote; showing branch-tip commits (full history unavailable)." + .to_string(), + ); + sink.set_query_notice(notice, app); + + if commits.is_empty() { + sink.record_error("No commits found.".to_string(), app); + return; + } + + let candidates = commits + .iter() + .enumerate() + .map(|(id, c)| { + let s = format!("{} {} {}", c.sha, c.subject, c.author_name); + StringMatchCandidate::new(id, &s) + }) + .collect::>(); + + let mut matches = fuzzy::match_strings( + candidates.as_slice(), + query.as_ref(), + true, + true, + 1_000, + &cancel_flag, + executor, + ) + .await; + + if cancellation.is_cancelled() { + return; + } + + matches.sort_by(|a, b| { + b.score + .partial_cmp(&a.score) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| { + let at = commits + .get(a.candidate_id) + .map(|c| c.commit_timestamp) + .unwrap_or(0); + let bt = commits + .get(b.candidate_id) + .map(|c| c.commit_timestamp) + .unwrap_or(0); + bt.cmp(&at) + }) + }); + + let mut batcher = MatchBatcher::new(match_arena.clone()); + for m in matches { + let Some(commit) = commits.get(m.candidate_id) else { + continue; + }; + + let sha_short: Arc = + Arc::from(commit.sha.get(..8).unwrap_or(&commit.sha).to_string()); + let subject = commit.subject.clone(); + let author = commit.author_name.clone(); + + let repo_label: Arc = commit + .repo_workdir + .file_name() + .and_then(|s| s.to_str()) + .filter(|s| !s.is_empty()) + .map(|s| Arc::::from(s.to_string())) + .unwrap_or_else(|| { + Arc::::from(commit.repo_workdir.to_string_lossy().to_string()) + }); + + batcher.push( + QuickMatchBuilder::new( + source_id.clone(), + QuickMatchKind::GitCommit { + repo_workdir: commit.repo_workdir.clone(), + sha: commit.sha.clone(), + subject, + author, + repo_label: repo_label.clone(), + branch: commit.branch.clone(), + commit_timestamp: commit.commit_timestamp, + }, + ) + .file_name(sha_short) + .path_label(repo_label.clone()) + .display_path(repo_label) + .path_segments_from_label() + .build(), + &sink, + app, + ); + } + + if !cancellation.is_cancelled() { + batcher.finish(&sink, app); + } + } + .boxed_local() + }); + } +} diff --git a/crates/quick_search/src/sources/files.rs b/crates/quick_search/src/sources/files.rs new file mode 100644 index 00000000000000..d2f84e518fb4fd --- /dev/null +++ b/crates/quick_search/src/sources/files.rs @@ -0,0 +1,797 @@ +use std::{ + path::PathBuf, + sync::{Arc, OnceLock}, + time::Duration, +}; + +use file_icons::FileIcons; +use futures::FutureExt as _; +use gpui::{AnyView, App, AppContext, AsyncApp, Context, Entity, Render, Window}; +use search::SearchOptions; +use settings::Settings; +use smol::fs; +use smol::io::AsyncReadExt as _; +use ui::IconName; +use ui::prelude::*; +use ui::{Color, Icon, IconSize, Label, LabelSize, div, h_flex, v_flex}; + +use crate::types::QuickMatch; +use crate::types::{QuickMatchBuilder, QuickMatchKind}; +use project::{PathMatchCandidateSet, ProjectPath, WorktreeId}; +use util::rel_path::RelPath; +use util::size::format_file_size; + +use crate::core::{ + ListPresentation, MatchBatcher, QuickSearchSource, SearchContext, SearchSink, SearchUiContext, + SortPolicy, SourceId, SourceSpec, SourceSpecCore, SourceSpecUi, +}; +use log::debug; +use theme::ThemeSettings; + +pub struct FilesSource; + +struct FilesDetailsFooter { + host_state: Entity, + open: bool, + project: Option>, + project_path: Option, + selected_key: Option, + cancellation: Option, + abs_path_buf: Option, + loaded_for_key: Option, + abs_path: Arc, + file_type: Arc, + encoding: Arc, + line_endings: Arc, + file_size: Arc, + lines: Arc, + shows_loc: bool, + loading_overlay_visible: bool, + loading_overlay_nonce: u64, + last_loading: bool, + _subscription: gpui::Subscription, +} + +impl FilesDetailsFooter { + fn clear(&mut self) { + self.project = None; + self.project_path = None; + self.selected_key = None; + self.cancellation = None; + self.abs_path_buf = None; + self.loaded_for_key = None; + self.abs_path = Arc::from("-"); + self.file_type = Arc::from("File"); + self.encoding = Arc::from("-"); + self.line_endings = Arc::from("-"); + self.file_size = Arc::from("-"); + self.lines = Arc::from("-"); + self.shows_loc = false; + } + + fn set_context( + &mut self, + project: Entity, + project_path: ProjectPath, + abs_path: Option, + cancellation: crate::core::FooterCancellation, + selected: &QuickMatch, + ) { + self.project = Some(project); + self.project_path = Some(project_path); + self.abs_path_buf = abs_path; + self.cancellation = Some(cancellation); + self.selected_key = Some(selected.key); + self.loaded_for_key = None; + } +} + +impl Render for FilesDetailsFooter { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl ui::IntoElement { + let host_state = self.host_state.read(cx); + let show_overlay = host_state.loading && self.loading_overlay_visible; + let buffer_font_size = ThemeSettings::get_global(cx).buffer_font_size(cx); + + let label_width = rems_from_px(72.); + let kv_row = |label: &'static str, value: Arc| { + h_flex() + .gap_3() + .items_baseline() + .child( + div().w(label_width).child( + Label::new(label) + .size(LabelSize::Small) + .color(Color::Muted) + .buffer_font(cx), + ), + ) + .child( + div().flex_1().min_w_0().child( + Label::new(value) + .size(LabelSize::Small) + .color(Color::Default) + .truncate() + .buffer_font(cx), + ), + ) + }; + + let icon_path = self.abs_path_buf.as_deref().or_else(|| { + self.project_path + .as_ref() + .map(|project_path| project_path.path.as_std_path()) + }); + let file_icon = icon_path + .and_then(|path| FileIcons::get_icon(path, cx)) + .map(|icon_path| Icon::from_path(icon_path).color(Color::Muted)) + .unwrap_or_else(|| Icon::new(IconName::File).color(Color::Muted)); + + div() + .relative() + .w_full() + .text_size(buffer_font_size) + .child({ + let lines_label = if self.shows_loc { "LOC" } else { "Lines" }; + v_flex() + .gap_1p5() + .child(kv_row("Path", self.abs_path.clone())) + .child( + h_flex() + .gap_6() + .child( + v_flex() + .gap_1() + .child( + h_flex() + .gap_3() + .items_baseline() + .child( + div().w(label_width).child( + Label::new("Type") + .size(LabelSize::Small) + .color(Color::Muted) + .buffer_font(cx), + ), + ) + .child( + div().flex_1().min_w_0().child( + h_flex() + .gap_2() + .items_center() + .child(file_icon.size(IconSize::Small)) + .child( + Label::new(self.file_type.clone()) + .size(LabelSize::Small) + .color(Color::Default) + .truncate() + .buffer_font(cx), + ), + ), + ), + ) + .child(kv_row("Encoding", self.encoding.clone())) + .child(kv_row("Endings", self.line_endings.clone())), + ) + .child( + v_flex() + .gap_1() + .child(kv_row("Size", self.file_size.clone())) + .child(kv_row(lines_label, self.lines.clone())), + ), + ) + .p_2() + }) + .when(show_overlay, |this| { + this.child( + div() + .absolute() + .top(rems_from_px(8.)) + .right(rems_from_px(8.)) + .child( + ui::SpinnerLabel::new() + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + }) + } +} + +async fn detect_encoding_label(abs_path: &std::path::Path) -> Option> { + let mut file = match fs::File::open(abs_path).await { + Ok(file) => file, + Err(err) => { + debug!( + "quick_search: failed to open file for encoding detection: {:?}", + err + ); + return None; + } + }; + let mut buf = [0u8; 4]; + let read_len = match file.read(&mut buf).await { + Ok(read_len) => read_len, + Err(err) => { + debug!( + "quick_search: failed to read file for encoding detection: {:?}", + err + ); + return None; + } + }; + + let buf = &buf[..read_len]; + let label = if buf.starts_with(&[0x00, 0x00, 0xFE, 0xFF]) { + "UTF-32 BE" + } else if buf.starts_with(&[0xFF, 0xFE, 0x00, 0x00]) { + "UTF-32 LE" + } else if buf.starts_with(&[0xEF, 0xBB, 0xBF]) { + "UTF-8 (BOM)" + } else if buf.starts_with(&[0xFE, 0xFF]) { + "UTF-16 BE" + } else if buf.starts_with(&[0xFF, 0xFE]) { + "UTF-16 LE" + } else { + "UTF-8" + }; + + Some(Arc::from(label)) +} + +impl FilesSource { + fn spec_static() -> &'static SourceSpec { + static SPEC: OnceLock = OnceLock::new(); + SPEC.get_or_init(|| SourceSpec { + id: SourceId(Arc::from("files")), + core: SourceSpecCore { + supported_options: SearchOptions::INCLUDE_IGNORED, + min_query_len: 1, + sort_policy: SortPolicy::StreamOrder, + }, + ui: SourceSpecUi { + title: Arc::from("Files"), + icon: IconName::File, + placeholder: Arc::from("Find files..."), + list_presentation: ListPresentation::Flat, + use_diff_preview: false, + }, + }) + } +} + +impl QuickSearchSource for FilesSource { + fn spec(&self) -> &'static SourceSpec { + Self::spec_static() + } + + fn create_preview_footer( + &self, + _window: &mut Window, + cx: &mut App, + ) -> Option { + fn spawn_task( + footer: gpui::WeakEntity, + host_state: Entity, + project: Entity, + project_path: ProjectPath, + abs_path: Option, + cancellation: crate::core::FooterCancellation, + selected_key: crate::types::MatchKey, + window: &mut Window, + cx: &mut App, + ) { + window + .spawn(cx, async move |cx| { + let set_loading = + |loading: bool, label: Option>, cx: &mut gpui::AsyncWindowContext| { + if let Err(err) = cx.update_entity(&host_state, |state, cx| { + state.loading = loading; + state.loading_label = label; + cx.notify(); + }) { + debug!( + "quick_search: failed to update files footer host state: {:?}", + err + ); + } + }; + + set_loading(true, Some(Arc::from("Loading details.")), cx); + + if cancellation.is_cancelled() { + set_loading(false, None, cx); + return; + } + + let (file_size, encoding) = if let Some(abs_path) = abs_path.as_ref() { + let file_size = match fs::metadata(abs_path).await { + Ok(meta) => Arc::::from(format_file_size(meta.len(), false)), + Err(_) => Arc::::from("-"), + }; + let encoding = detect_encoding_label(abs_path) + .await + .unwrap_or_else(|| Arc::::from("-")); + (file_size, encoding) + } else { + (Arc::::from("-"), Arc::::from("-")) + }; + + if cancellation.is_cancelled() { + set_loading(false, None, cx); + return; + } + + if let Err(err) = footer.update(cx, |footer, cx| { + if footer.selected_key != Some(selected_key) { + return; + } + footer.file_size = file_size.clone(); + footer.encoding = encoding.clone(); + cx.notify(); + }) { + debug!( + "quick_search: failed to update files footer disk metadata: {:?}", + err + ); + } + + set_loading(true, Some(Arc::from("Opening file.")), cx); + + let open_task = match cx.update_entity(&project, |project, cx| { + project.open_buffer(project_path.clone(), cx) + }) { + Ok(task) => task, + Err(err) => { + debug!( + "quick_search: failed to start open_buffer for files footer: {:?}", + err + ); + set_loading(false, None, cx); + return; + } + }; + + let buffer = match open_task.await { + Ok(buffer) => buffer, + Err(err) => { + debug!( + "quick_search: failed to open buffer for files footer: {:?}", + err + ); + set_loading(false, None, cx); + return; + } + }; + + if cancellation.is_cancelled() { + set_loading(false, None, cx); + return; + } + + let extension = project_path + .path + .as_std_path() + .extension() + .and_then(|ext| ext.to_str()) + .map(|s| Arc::::from(s.to_string())); + + let (language_name, has_language, line_endings, line_count, loc_count) = cx + .read_entity(&buffer, |buffer, _| { + let snapshot = buffer.snapshot(); + let line_endings = match snapshot.text.line_ending() { + text::LineEnding::Unix => Arc::::from("LF"), + text::LineEnding::Windows => Arc::::from("CRLF"), + }; + + let line_count = snapshot.text.row_count(); + let language_name = buffer.language().map(|lang| lang.name()); + let has_language = language_name.is_some(); + + let mut loc_count = 0u32; + if has_language { + let mut lines = snapshot.text.as_rope().chunks().lines(); + while let Some(line) = lines.next() { + let mut has_non_ws = false; + for ch in line.chars() { + if !ch.is_whitespace() { + has_non_ws = true; + break; + } + } + if has_non_ws { + loc_count = loc_count.saturating_add(1); + } + } + } + + (language_name, has_language, line_endings, line_count, loc_count) + }) + .unwrap_or_else(|err| { + debug!( + "quick_search: failed to read buffer snapshot for files footer: {:?}", + err + ); + (None, false, Arc::::from("-"), 0, 0) + }); + + if cancellation.is_cancelled() { + set_loading(false, None, cx); + return; + } + + let file_type = match (language_name, extension) { + (Some(name), _) => Arc::::from(name.to_string()), + (None, Some(ext)) => Arc::::from(ext.to_string()), + (None, None) => Arc::::from("File"), + }; + + let lines_value = if has_language { + Arc::::from(loc_count.to_string()) + } else { + Arc::::from(line_count.to_string()) + }; + + if let Err(err) = footer.update(cx, |footer, cx| { + if footer.selected_key != Some(selected_key) { + return; + } + footer.file_type = file_type; + footer.line_endings = line_endings; + footer.lines = lines_value; + footer.shows_loc = has_language; + footer.loaded_for_key = Some(selected_key); + cx.notify(); + }) { + debug!("quick_search: failed to update files footer view: {:?}", err); + } + + set_loading(false, None, cx); + }) + .detach(); + } + + let host = crate::core::PreviewFooterHost::new(cx); + let host_state = host.state_entity().clone(); + let footer = cx.new(|cx| { + let subscription = + cx.observe(&host_state, move |this: &mut FilesDetailsFooter, state, cx| { + let loading = state.read(cx).loading; + if loading && !this.last_loading { + this.last_loading = true; + this.loading_overlay_visible = false; + this.loading_overlay_nonce = this.loading_overlay_nonce.wrapping_add(1); + let nonce = this.loading_overlay_nonce; + let footer = cx.entity().downgrade(); + cx.spawn(move |_, app: &mut AsyncApp| { + let mut app = app.clone(); + async move { + smol::Timer::after(Duration::from_millis(75)).await; + let Some(footer) = footer.upgrade() else { + return; + }; + if let Err(err) = footer.update(&mut app, |footer, cx| { + if !footer.last_loading || footer.loading_overlay_nonce != nonce { + return; + } + footer.loading_overlay_visible = true; + cx.notify(); + }) { + debug!( + "quick_search: failed to show files footer loading overlay: {:?}", + err + ); + } + } + }) + .detach(); + } else if !loading && this.last_loading { + this.last_loading = false; + this.loading_overlay_visible = false; + } + + cx.notify(); + }); + FilesDetailsFooter { + host_state, + open: false, + project: None, + project_path: None, + selected_key: None, + cancellation: None, + abs_path_buf: None, + loaded_for_key: None, + abs_path: Arc::from("-"), + file_type: Arc::from("File"), + encoding: Arc::from("-"), + line_endings: Arc::from("-"), + file_size: Arc::from("-"), + lines: Arc::from("-"), + shows_loc: false, + loading_overlay_visible: false, + loading_overlay_nonce: 0, + last_loading: false, + _subscription: subscription, + } + }); + let footer_view = AnyView::from(footer.clone()); + let footer_weak = footer.downgrade(); + + Some(crate::core::FooterInstance { + spec: crate::core::FooterSpec { + title: Arc::from("Details"), + toggleable: true, + default_open: false, + }, + host: host.clone(), + view: footer_view, + handle_event: Arc::new(move |event, window, cx| match event { + crate::core::FooterEvent::OpenChanged(open) => { + let params = match footer_weak.update(cx, |footer, cx| { + footer.open = open; + cx.notify(); + + if !open { + return None; + } + + let selected_key = footer.selected_key?; + if footer.loaded_for_key == Some(selected_key) { + return None; + } + + Some(( + footer.project.clone()?, + footer.project_path.clone()?, + footer.abs_path_buf.clone(), + footer.cancellation.clone()?, + selected_key, + )) + }) { + Ok(params) => params, + Err(err) => { + debug!( + "quick_search: failed to update files footer state: {:?}", + err + ); + None + } + }; + + let Some((project, project_path, abs_path, cancellation, selected_key)) = + params + else { + return; + }; + if cancellation.is_cancelled() { + return; + } + + spawn_task( + footer_weak.clone(), + host.state_entity().clone(), + project, + project_path, + abs_path, + cancellation, + selected_key, + window, + cx, + ); + } + crate::core::FooterEvent::ContextChanged(ctx) => { + host.set_loading(false, cx); + host.set_loading_label(None, cx); + + let has_content = ctx + .selected + .as_ref() + .and_then(|selected| selected.project_path()) + .is_some(); + host.set_has_content(has_content, cx); + + let params = match footer_weak.update(cx, |footer, cx| { + let Some(selected) = ctx.selected.as_ref() else { + footer.clear(); + cx.notify(); + return None; + }; + let Some(project_path) = selected.project_path().cloned() else { + footer.clear(); + cx.notify(); + return None; + }; + + let abs_path_buf = ctx.project.read(cx).absolute_path(&project_path, cx); + footer.abs_path_buf = abs_path_buf.clone(); + footer.abs_path = abs_path_buf + .as_ref() + .map(|p| Arc::::from(p.to_string_lossy().to_string())) + .unwrap_or_else(|| Arc::::from("-")); + + footer.file_type = Arc::from("File"); + footer.encoding = Arc::from("-"); + footer.line_endings = Arc::from("-"); + footer.file_size = Arc::from("-"); + footer.lines = Arc::from("-"); + footer.shows_loc = false; + + footer.set_context( + ctx.project.clone(), + project_path.clone(), + abs_path_buf.clone(), + ctx.cancellation.clone(), + selected, + ); + + cx.notify(); + if !footer.open { + return None; + } + + Some(( + ctx.project.clone(), + project_path, + abs_path_buf, + ctx.cancellation.clone(), + selected.key, + )) + }) { + Ok(params) => params, + Err(err) => { + debug!( + "quick_search: failed to update files footer context: {:?}", + err + ); + None + } + }; + + let Some((project, project_path, abs_path, cancellation, selected_key)) = + params + else { + return; + }; + if cancellation.is_cancelled() { + return; + } + + spawn_task( + footer_weak.clone(), + host.state_entity().clone(), + project, + project_path, + abs_path, + cancellation, + selected_key, + window, + cx, + ); + } + }), + }) + } + + fn start_search(&self, ctx: SearchContext, sink: SearchSink, cx: &mut SearchUiContext<'_>) { + let include_ignored = ctx + .search_options() + .contains(SearchOptions::INCLUDE_IGNORED); + let path_style = ctx.path_style(); + let worktrees = ctx + .project() + .read(cx) + .worktree_store() + .read(cx) + .visible_worktrees_and_single_files(cx) + .collect::>(); + let include_root_name = worktrees.len() > 1; + + let mut set_id_to_worktree_id = std::collections::HashMap::::new(); + let candidate_sets = worktrees + .into_iter() + .map(|worktree| { + let worktree = worktree.read(cx); + let snapshot = worktree.snapshot(); + set_id_to_worktree_id.insert(snapshot.id().to_usize(), worktree.id()); + PathMatchCandidateSet { + snapshot, + include_ignored, + include_root_name, + candidates: project::Candidates::Files, + } + }) + .collect::>(); + + let executor = ctx.background_executor().clone(); + let source_id = self.spec().id.0.clone(); + let query = ctx.query().clone(); + let cancellation = ctx.cancellation().clone(); + let cancel_flag = cancellation.flag(); + let match_arena = ctx.match_arena().clone(); + crate::core::spawn_source_task(cx, sink, move |app, sink| { + async move { + if cancellation.is_cancelled() { + return; + } + + let relative_to: Option> = None; + let path_matches = fuzzy::match_path_sets( + candidate_sets.as_slice(), + query.as_ref(), + &relative_to, + false, + 2_000, + &cancel_flag, + executor, + ) + .await; + + if cancellation.is_cancelled() { + return; + } + + let mut batcher = MatchBatcher::new(match_arena.clone()); + for pm in path_matches { + let Some(worktree_id) = set_id_to_worktree_id.get(&pm.worktree_id).copied() + else { + continue; + }; + + let project_path = ProjectPath { + worktree_id, + path: pm.path.clone(), + }; + + let full_path = pm.path_prefix.join(&pm.path); + let file_name_str = full_path.file_name().unwrap_or(""); + let file_name_start = full_path + .as_unix_str() + .len() + .saturating_sub(file_name_str.len()); + let mut dir_positions = pm.positions.clone(); + let file_name_positions = dir_positions + .iter() + .filter_map(|pos| pos.checked_sub(file_name_start)) + .collect::>(); + + let display_path_string = full_path + .display(path_style) + .trim_end_matches(file_name_str) + .to_string(); + dir_positions.retain(|idx| *idx < display_path_string.len()); + + let mut path_label_string = display_path_string.clone(); + path_label_string.push_str(file_name_str); + let path_label: Arc = Arc::from(path_label_string); + let display_path: Arc = Arc::from(display_path_string); + + let file_name: Arc = if file_name_str.is_empty() { + path_label.clone() + } else { + Arc::from(file_name_str.to_string()) + }; + + batcher.push( + QuickMatchBuilder::new( + source_id.clone(), + QuickMatchKind::ProjectPath { project_path }, + ) + .path_label(path_label) + .display_path(display_path) + .display_path_positions(Some(Arc::<[usize]>::from(dir_positions))) + .path_segments_from_label() + .file_name(file_name) + .file_name_positions(Some(Arc::<[usize]>::from(file_name_positions))) + .build(), + &sink, + app, + ); + } + + if !cancellation.is_cancelled() { + batcher.finish(&sink, app); + } + } + .boxed_local() + }); + } +} diff --git a/crates/quick_search/src/sources/text_grep.rs b/crates/quick_search/src/sources/text_grep.rs new file mode 100644 index 00000000000000..44fab67b2cd482 --- /dev/null +++ b/crates/quick_search/src/sources/text_grep.rs @@ -0,0 +1,1760 @@ +use std::{ + collections::HashMap, + ops::Range, + path, + path::Path, + sync::{Arc, OnceLock}, + time::{Duration, Instant}, +}; + +use collections::FxHashMap; + +use file_icons::FileIcons; +use futures::FutureExt as _; +use gpui::{AnyView, App, AppContext, AsyncApp, Context, Entity, IntoElement, Render, Window, div}; +use language::{Buffer, HighlightId, LanguageRegistry}; +use markdown::{Markdown, MarkdownElement}; +use search::SearchOptions; +use settings::Settings; +use text::{Anchor as TextAnchor, BufferId, Point, ToOffset, ToPoint}; +use theme::ThemeSettings; +use ui::prelude::*; +use ui::{Color, IconName, LabelSize, SpinnerLabel}; + +use crate::types::{GroupHeader, GroupInfo, MatchKey, QuickMatch, QuickMatchBuilder}; +use log::debug; +use project::search::{SearchQuery, SearchResult}; +use project::{HoverBlock, HoverBlockKind, ProjectPath}; +use smol::future::yield_now; +use util::paths::{PathMatcher, PathStyle}; + +use crate::core::{ + ListPresentation, MatchBatcher, QuickSearchSource, SearchContext, SearchSink, SearchUiContext, + SortPolicy, SourceId, SourceSpec, SourceSpecCore, SourceSpecUi, +}; +use editor::hover_popover::hover_markdown_style; +use editor::hover_popover::open_markdown_url; + +pub struct TextGrepSource; + +struct GrepHoverFooter { + host_state: Entity, + markdown: Option>, + message: Option>, + selected_key: Option, + loading_overlay_visible: bool, + loading_overlay_nonce: u64, + last_loading: bool, + _subscription: gpui::Subscription, +} + +impl GrepHoverFooter { + fn clear(&mut self) { + self.markdown = None; + self.message = None; + self.selected_key = None; + } + + fn set_markdown(&mut self, markdown: Entity) { + self.markdown = Some(markdown); + self.message = None; + } + + fn set_message(&mut self, message: Arc) { + self.markdown = None; + self.message = Some(message); + } +} + +impl Render for GrepHoverFooter { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let host_state = self.host_state.read(cx); + let show_overlay = host_state.loading && self.loading_overlay_visible; + let buffer_font_size = ThemeSettings::get_global(cx).buffer_font_size(cx); + div() + .relative() + .w_full() + .when_some(self.markdown.clone(), |this, markdown| { + let mut style = hover_markdown_style(window, cx); + style.base_text_style.refine(&gpui::TextStyleRefinement { + font_size: Some(buffer_font_size.into()), + ..Default::default() + }); + this.child( + MarkdownElement::new(markdown, style) + .code_block_renderer(markdown::CodeBlockRenderer::Default { + copy_button: false, + copy_button_on_hover: false, + border: false, + }) + .on_url_click(open_markdown_url) + .p_2(), + ) + }) + .when(self.markdown.is_none() && self.message.is_some(), |this| { + let message = self + .message + .clone() + .unwrap_or_else(|| Arc::::from("No details available")); + this.child( + div() + .text_size(buffer_font_size) + .text_color(Color::Muted.color(cx)) + .child(message.to_string()), + ) + .p_2() + }) + .when( + !host_state.loading && self.markdown.is_none() && self.message.is_none(), + |this| { + this.child( + div() + .text_size(buffer_font_size) + .text_color(Color::Muted.color(cx)) + .child("No details available"), + ) + .p_2() + }, + ) + .when(show_overlay, |this| { + this.child( + div() + .absolute() + .top(rems_from_px(8.)) + .right(rems_from_px(8.)) + .child( + SpinnerLabel::new() + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + }) + } +} + +fn hover_blocks_to_markdown(blocks: &[HoverBlock]) -> String { + let mut out = String::new(); + for (index, block) in blocks.iter().enumerate() { + if index > 0 { + out.push_str("\n\n"); + } + match &block.kind { + HoverBlockKind::PlainText | HoverBlockKind::Markdown => { + out.push_str(block.text.trim()); + } + HoverBlockKind::Code { language } => { + out.push_str("```"); + out.push_str(language); + out.push('\n'); + out.push_str(block.text.trim()); + out.push_str("\n```"); + } + } + } + out +} + +fn rightmost_token_probe_offset(line: &str, start: usize, end: usize) -> Option { + fn is_token_char(ch: char) -> bool { + ch == '_' || ch.is_alphanumeric() + } + + let mut start = start.min(line.len()); + let mut end = end.min(line.len()); + while start > 0 && !line.is_char_boundary(start) { + start = start.saturating_sub(1); + } + while end > 0 && !line.is_char_boundary(end) { + end = end.saturating_sub(1); + } + if start >= end { + return None; + } + + let slice = &line[start..end]; + let mut index = slice.len(); + while index > 0 { + let Some(ch) = slice[..index].chars().next_back() else { + break; + }; + let ch_len = ch.len_utf8(); + let ch_start = index.saturating_sub(ch_len); + if is_token_char(ch) { + let mut run_start = ch_start; + while run_start > 0 { + let Some(prev) = slice[..run_start].chars().next_back() else { + break; + }; + if is_token_char(prev) { + run_start = run_start.saturating_sub(prev.len_utf8()); + } else { + break; + } + } + + let mut probe = run_start + (index - run_start) / 2; + while probe > run_start && !slice.is_char_boundary(probe) { + probe = probe.saturating_sub(1); + } + return Some(start + probe); + } + index = ch_start; + } + + None +} + +#[derive(Clone)] +struct SyntaxEnrichItem { + key: crate::types::MatchKey, + row: u32, + snippet_len: usize, +} + +impl TextGrepSource { + fn spec_static() -> &'static SourceSpec { + static SPEC: OnceLock = OnceLock::new(); + SPEC.get_or_init(|| SourceSpec { + id: SourceId(Arc::from("grep")), + core: SourceSpecCore { + supported_options: SearchOptions::REGEX + | SearchOptions::CASE_SENSITIVE + | SearchOptions::WHOLE_WORD + | SearchOptions::INCLUDE_IGNORED, + min_query_len: crate::MIN_QUERY_LEN, + sort_policy: SortPolicy::StreamOrder, + }, + ui: SourceSpecUi { + title: Arc::from("Text"), + icon: IconName::MagnifyingGlass, + placeholder: Arc::from("Live grep..."), + list_presentation: ListPresentation::Grouped, + use_diff_preview: false, + }, + }) + } +} + +impl QuickSearchSource for TextGrepSource { + fn spec(&self) -> &'static SourceSpec { + Self::spec_static() + } + + fn create_preview_footer( + &self, + _window: &mut Window, + cx: &mut App, + ) -> Option { + let host = crate::core::PreviewFooterHost::new(cx); + let host_state = host.state_entity().clone(); + let footer = cx.new(|cx| { + let subscription = cx.observe(&host_state, move |this: &mut GrepHoverFooter, state, cx| { + let loading = state.read(cx).loading; + if loading && !this.last_loading { + this.last_loading = true; + this.loading_overlay_visible = false; + this.loading_overlay_nonce = this.loading_overlay_nonce.wrapping_add(1); + let nonce = this.loading_overlay_nonce; + let footer = cx.entity().downgrade(); + cx.spawn(move |_, app: &mut AsyncApp| { + let mut app = app.clone(); + async move { + smol::Timer::after(Duration::from_millis(75)).await; + let Some(footer) = footer.upgrade() else { + return; + }; + if let Err(err) = footer.update(&mut app, |footer, cx| { + if !footer.last_loading || footer.loading_overlay_nonce != nonce { + return; + } + footer.loading_overlay_visible = true; + cx.notify(); + }) { + debug!( + "quick_search: failed to show grep footer loading overlay: {:?}", + err + ); + } + } + }) + .detach(); + } else if !loading && this.last_loading { + this.last_loading = false; + this.loading_overlay_visible = false; + } + + cx.notify(); + }); + GrepHoverFooter { + host_state, + markdown: None, + message: None, + selected_key: None, + loading_overlay_visible: false, + loading_overlay_nonce: 0, + last_loading: false, + _subscription: subscription, + } + }); + let footer_view = AnyView::from(footer.clone()); + let footer_weak = footer.downgrade(); + let host_for_events = host.clone(); + let host_state_for_tasks = host.state_entity().clone(); + + Some(crate::core::FooterInstance { + spec: crate::core::FooterSpec { + title: Arc::from("Details"), + toggleable: true, + default_open: true, + }, + host, + view: footer_view, + handle_event: Arc::new(move |event, window, cx| match event { + crate::core::FooterEvent::OpenChanged(_open) => {} + crate::core::FooterEvent::ContextChanged(ctx) => { + host_for_events.set_loading(false, cx); + host_for_events.set_loading_label(None, cx); + + let Some(selected) = ctx.selected else { + host_for_events.set_has_content(false, cx); + if let Err(err) = footer_weak.update(cx, |footer, cx| { + footer.clear(); + cx.notify(); + }) { + debug!("quick_search: failed to clear grep footer view: {:?}", err); + } + return; + }; + let Some(buffer_id) = selected.buffer_id() else { + host_for_events.set_has_content(false, cx); + if let Err(err) = footer_weak.update(cx, |footer, cx| { + footer.clear(); + cx.notify(); + }) { + debug!("quick_search: failed to clear grep footer view: {:?}", err); + } + return; + }; + let Some(match_range) = + selected.ranges().and_then(|ranges| ranges.first()).cloned() + else { + host_for_events.set_has_content(false, cx); + if let Err(err) = footer_weak.update(cx, |footer, cx| { + footer.clear(); + cx.notify(); + }) { + debug!("quick_search: failed to clear grep footer view: {:?}", err); + } + return; + }; + + host_for_events.set_has_content(true, cx); + if let Err(err) = footer_weak.update(cx, |footer, cx| { + footer.selected_key = Some(selected.key); + cx.notify(); + }) { + debug!( + "quick_search: failed to update grep footer selection key: {:?}", + err + ); + } + let selected_key = selected.key; + let project_path = selected.project_path().cloned(); + let worktree_id = project_path.as_ref().map(|path| path.worktree_id); + + let project = ctx.project.clone(); + let preview_buffer = ctx.preview_buffer.clone(); + let cancellation = ctx.cancellation.clone(); + + host_for_events.set_loading(true, cx); + host_for_events.set_loading_label(Some(Arc::from("Preparing…")), cx); + let footer_for_task = footer_weak.clone(); + let host_state = host_state_for_tasks.clone(); + window + .spawn(cx, async move |cx| { + let set_loading = |loading: bool, cx: &mut gpui::AsyncWindowContext| { + if let Err(err) = cx.update_entity(&host_state, |state, cx| { + if state.loading == loading { + return; + } + state.loading = loading; + cx.notify(); + }) { + debug!( + "quick_search: failed to update grep footer loading state: {:?}", + err + ); + } + }; + + let set_has_content = + |has_content: bool, cx: &mut gpui::AsyncWindowContext| { + if let Err(err) = + cx.update_entity(&host_state, |state, cx| { + if state.has_content == has_content { + return; + } + state.has_content = has_content; + cx.notify(); + }) + { + debug!( + "quick_search: failed to update grep footer content state: {:?}", + err + ); + } + }; + + let set_loading_label = |label: Option>, + cx: &mut gpui::AsyncWindowContext| { + if let Err(err) = cx.update_entity(&host_state, |state, cx| { + if state.loading_label == label { + return; + } + state.loading_label = label; + cx.notify(); + }) { + debug!( + "quick_search: failed to update grep footer loading label: {:?}", + err + ); + } + }; + + cx.background_executor() + .timer(std::time::Duration::from_millis(50)) + .await; + if cancellation.is_cancelled() { + set_loading(false, cx); + return; + } + + let buffer = preview_buffer + .and_then(|buffer| { + let matches = cx + .read_entity(&buffer, |buffer, _| buffer.remote_id()) + .map(|id| id == buffer_id) + .unwrap_or_else(|err| { + debug!( + "quick_search: failed to read preview buffer id for hover footer: {:?}", + err + ); + false + }); + matches.then_some(buffer) + }) + .map(Some) + .unwrap_or_else(|| { + cx.read_entity(&project, |project, cx| { + project.buffer_for_id(buffer_id, cx) + }) + .unwrap_or_else(|err| { + debug!( + "quick_search: failed to read project buffer for grep footer: {:?}", + err + ); + None + }) + }); + + set_loading_label(Some(Arc::from("Opening file…")), cx); + let buffer = if let Some(buffer) = buffer { + buffer + } else if let Some(project_path) = &project_path { + let open_task = match cx.update_entity(&project, |project, cx| { + project.open_buffer(project_path.clone(), cx) + }) { + Ok(task) => task, + Err(err) => { + debug!( + "quick_search: failed to start open_buffer for hover footer: {:?}", + err + ); + set_loading(false, cx); + set_has_content(false, cx); + return; + } + }; + match open_task.await { + Ok(buffer) => buffer, + Err(err) => { + debug!( + "quick_search: failed to open buffer for hover footer: {:?}", + err + ); + set_loading(false, cx); + set_has_content(false, cx); + return; + } + } + } else { + set_loading(false, cx); + set_has_content(false, cx); + set_loading_label(None, cx); + return; + }; + + if cancellation.is_cancelled() { + set_loading(false, cx); + set_loading_label(None, cx); + return; + } + + let hover_points = cx + .read_entity(&buffer, |buffer, _| { + let snapshot = buffer.snapshot(); + let max_row = snapshot.text.max_point().row; + let row = match_range.start.row.min(max_row); + let line_start = Point::new(row, 0); + let line_end = Point::new(row, snapshot.text.line_len(row)); + + let line_start_offset = snapshot.text.point_to_offset(line_start); + let line_end_offset = snapshot.text.point_to_offset(line_end); + + let match_start_offset = + snapshot.text.point_to_offset(match_range.start); + let match_end_offset = snapshot.text.point_to_offset(match_range.end); + + let line_text: String = snapshot + .text + .text_for_range(line_start_offset..line_end_offset) + .collect(); + + let rel_start = match_start_offset + .saturating_sub(line_start_offset) + .min(line_text.len()); + let rel_end = match_end_offset + .min(line_end_offset) + .saturating_sub(line_start_offset) + .min(line_text.len()); + + let mut points = Vec::with_capacity(3); + if let Some(probe) = + rightmost_token_probe_offset(&line_text, rel_start, rel_end) + { + points.push( + snapshot + .text + .offset_to_point(line_start_offset.saturating_add(probe)), + ); + } + + let end_point = if match_range.end.column > 0 { + Point::new( + match_range.end.row, + match_range.end.column.saturating_sub(1), + ) + } else { + match_range.end + }; + points.push(end_point); + points.push(match_range.start); + + let mut unique = Vec::with_capacity(points.len()); + for point in points { + if !unique.iter().any(|p| p == &point) { + unique.push(point); + } + } + unique + }) + .unwrap_or_else(|err| { + debug!("quick_search: failed to compute hover probe points: {:?}", err); + vec![match_range.start] + }); + + let (language_name, has_relevant_adapters) = + cx.read_entity(&project, |project, cx| { + let Some(language) = buffer.read(cx).language().cloned() else { + return (None, false); + }; + let language_name = Some(language.name()); + let has_relevant_adapters = !project + .languages() + .lsp_adapters(&language.name()) + .is_empty(); + (language_name, has_relevant_adapters) + }) + .unwrap_or_else(|err| { + debug!( + "quick_search: failed to read language info for footer: {:?}", + err + ); + (None, false) + }); + + if language_name.is_none() { + if let Err(err) = footer_for_task.update(cx, |footer, cx| { + if footer.selected_key != Some(selected_key) { + return; + } + footer.set_message(Arc::from("No language detected for this file.")); + cx.notify(); + }) { + debug!("quick_search: failed to update grep footer view: {:?}", err); + } + set_loading(false, cx); + set_has_content(true, cx); + set_loading_label(None, cx); + return; + } + + if !has_relevant_adapters { + let language_name = language_name + .map(|name| name.0.to_string()) + .unwrap_or_else(|| "this language".to_string()); + let label = + format!("No language server configured for {language_name}."); + if let Err(err) = footer_for_task.update(cx, |footer, cx| { + if footer.selected_key != Some(selected_key) { + return; + } + footer.set_message(Arc::from(label.clone())); + cx.notify(); + }) { + debug!("quick_search: failed to update grep footer view: {:?}", err); + } + set_loading(false, cx); + set_has_content(true, cx); + set_loading_label(None, cx); + return; + } + + let server_status_for_buffer = |cx: &gpui::AsyncWindowContext| { + cx.read_entity(&project, |project, cx| { + let Some(language) = buffer.read(cx).language().cloned() else { + return (false, false, None); + }; + let relevant = project + .languages() + .lsp_adapters(&language.name()) + .into_iter() + .map(|adapter| adapter.name()) + .collect::>(); + if relevant.is_empty() { + return (false, false, None); + } + + let mut has_running_relevant = false; + let mut has_pending_diagnostic_updates = false; + let mut best_progress: Option<(std::time::Instant, Arc)> = + None; + + for (_id, status) in project.language_server_statuses(cx) { + if !relevant.contains(&status.name) { + continue; + } + if let Some(worktree_id) = worktree_id { + if let Some(status_worktree_id) = status.worktree { + if status_worktree_id != worktree_id { + continue; + } + } + } + + has_running_relevant = true; + has_pending_diagnostic_updates |= + status.has_pending_diagnostic_updates; + + let Some(progress) = status + .pending_work + .values() + .max_by_key(|progress| progress.last_update_at) + else { + continue; + }; + + let label = if let Some(title) = + progress.title.as_ref().filter(|s| !s.trim().is_empty()) + { + if let Some(pct) = progress.percentage { + Arc::::from(format!("{title} ({pct}%)")) + } else { + Arc::::from(title.to_string()) + } + } else if let Some(message) = + progress.message.as_ref().filter(|s| !s.trim().is_empty()) + { + if let Some(pct) = progress.percentage { + Arc::::from(format!("{message} ({pct}%)")) + } else { + Arc::::from(message.to_string()) + } + } else { + Arc::::from("busy") + }; + + match best_progress.as_ref() { + Some((at, _)) if *at >= progress.last_update_at => {} + _ => { + best_progress = Some((progress.last_update_at, label)); + } + } + } + + let hint = if let Some((_at, label)) = best_progress { + Some(label) + } else if has_pending_diagnostic_updates { + Some(Arc::::from("updating diagnostics")) + } else { + None + }; + + let busy = hint.is_some(); + (has_running_relevant, busy, hint) + }) + .unwrap_or_else(|err| { + debug!( + "quick_search: failed to read language server statuses for footer: {:?}", + err + ); + (false, false, None) + }) + }; + + let slow_after = + std::time::Instant::now() + std::time::Duration::from_secs(30); + let started_at = std::time::Instant::now(); + let min_time_before_no_hover = std::time::Duration::from_secs(1); + let mut consecutive_idle_empty_responses: usize = 0; + + loop { + if cancellation.is_cancelled() { + set_loading(false, cx); + set_loading_label(None, cx); + return; + } + + let poll_interval = if std::time::Instant::now() > slow_after { + std::time::Duration::from_millis(750) + } else { + std::time::Duration::from_millis(250) + }; + + let (running, _busy, hint) = server_status_for_buffer(cx); + if !running { + consecutive_idle_empty_responses = 0; + set_loading_label(Some(Arc::from("Starting language server…")), cx); + if let Err(err) = footer_for_task.update(cx, |footer, cx| { + if footer.selected_key != Some(selected_key) { + return; + } + footer.set_message(Arc::::from( + "Language server still starting…", + )); + cx.notify(); + }) { + debug!( + "quick_search: failed to update grep footer view: {:?}", + err + ); + } + set_has_content(true, cx); + cx.background_executor() + .timer(poll_interval) + .await; + continue; + } + + let label = if let Some(hint) = hint { + Arc::::from(format!("Requesting hover… ({hint})")) + } else { + Arc::::from("Requesting hover…") + }; + set_loading_label(Some(label), cx); + + let mut hovers: Option> = None; + let mut saw_not_ready = false; + let mut saw_response = false; + for probe_point in hover_points.iter().cloned() { + if cancellation.is_cancelled() { + set_loading(false, cx); + set_loading_label(None, cx); + return; + } + + let hover_task = cx.update_entity(&project, |project, cx| { + project.hover(&buffer, probe_point, cx) + }); + let hover_task = match hover_task { + Ok(task) => task, + Err(err) => { + debug!( + "quick_search: hover request failed: {:?}", + err + ); + set_loading(false, cx); + set_has_content(true, cx); + set_loading_label(None, cx); + if let Err(err) = + footer_for_task.update(cx, |footer, cx| { + if footer.selected_key != Some(selected_key) { + return; + } + footer.set_message(Arc::from( + "Failed to request hover.", + )); + cx.notify(); + }) + { + debug!( + "quick_search: failed to update grep footer view: {:?}", + err + ); + } + return; + } + }; + + let hover_task = hover_task.fuse(); + futures::pin_mut!(hover_task); + + let result = loop { + if cancellation.is_cancelled() { + set_loading(false, cx); + set_loading_label(None, cx); + return; + } + + let poll_timer = cx + .background_executor() + .timer(poll_interval) + .fuse(); + futures::pin_mut!(poll_timer); + + futures::select_biased! { + hovers = hover_task => break hovers, + _ = poll_timer => { + let (running, _busy, hint) = server_status_for_buffer(cx); + let label = if !running { + Arc::::from("Starting language server…") + } else if let Some(hint) = hint { + Arc::::from(format!("Requesting hover… ({hint})")) + } else { + Arc::::from("Requesting hover…") + }; + set_loading_label(Some(label), cx); + } + } + }; + + match result { + None => { + saw_not_ready = true; + } + Some(result) => { + saw_response = true; + let has_content = result + .iter() + .any(|hover| !hover.is_empty()); + hovers = Some(result); + if has_content { + break; + } + } + } + } + + let mut blocks: Vec = Vec::new(); + let mut hover_language_name: Option = None; + if let Some(hovers) = hovers { + for hover in hovers { + if hover.is_empty() { + continue; + } + if hover_language_name.is_none() { + hover_language_name = hover + .language + .as_ref() + .map(|language| language.name()); + } + blocks.extend(hover.contents); + } + } + + let text = hover_blocks_to_markdown(&blocks); + if text.trim().is_empty() { + let (running, busy_now, hint) = server_status_for_buffer(cx); + + if !running { + consecutive_idle_empty_responses = 0; + } + + if !saw_response && saw_not_ready { + consecutive_idle_empty_responses = 0; + let message = if !running { + Arc::::from("Language server still starting…") + } else if let Some(hint) = hint { + Arc::::from(format!( + "Language server busy… ({hint}). Waiting for hover…" + )) + } else { + Arc::::from("Waiting for language server hover…") + }; + if let Err(err) = footer_for_task.update(cx, |footer, cx| { + if footer.selected_key != Some(selected_key) { + return; + } + footer.set_message(message); + cx.notify(); + }) { + debug!( + "quick_search: failed to update grep footer view: {:?}", + err + ); + } + set_has_content(true, cx); + cx.background_executor() + .timer(poll_interval) + .await; + continue; + } + + if busy_now { + consecutive_idle_empty_responses = 0; + let message = if let Some(hint) = hint { + Arc::::from(format!( + "Language server busy… ({hint}). Waiting for hover…" + )) + } else { + Arc::::from("Language server busy… Waiting for hover…") + }; + if let Err(err) = footer_for_task.update(cx, |footer, cx| { + if footer.selected_key != Some(selected_key) { + return; + } + footer.set_message(message); + cx.notify(); + }) { + debug!( + "quick_search: failed to update grep footer view: {:?}", + err + ); + } + set_has_content(true, cx); + cx.background_executor() + .timer(poll_interval) + .await; + continue; + } + + consecutive_idle_empty_responses = + consecutive_idle_empty_responses.saturating_add(1); + if consecutive_idle_empty_responses < 2 { + if let Err(err) = footer_for_task.update(cx, |footer, cx| { + if footer.selected_key != Some(selected_key) { + return; + } + footer.set_message(Arc::::from( + "Waiting for hover information…", + )); + cx.notify(); + }) { + debug!( + "quick_search: failed to update grep footer view: {:?}", + err + ); + } + set_has_content(true, cx); + cx.background_executor() + .timer(poll_interval) + .await; + continue; + } + + let elapsed = std::time::Instant::now() + .saturating_duration_since(started_at); + if elapsed < min_time_before_no_hover { + if let Err(err) = footer_for_task.update(cx, |footer, cx| { + if footer.selected_key != Some(selected_key) { + return; + } + footer.set_message(Arc::::from( + "Waiting for hover information…", + )); + cx.notify(); + }) { + debug!( + "quick_search: failed to update grep footer view: {:?}", + err + ); + } + set_has_content(true, cx); + cx.background_executor() + .timer(poll_interval) + .await; + continue; + } + + if let Err(err) = footer_for_task.update(cx, |footer, cx| { + if footer.selected_key != Some(selected_key) { + return; + } + footer.set_message(Arc::::from( + "No hover information at this position.", + )); + cx.notify(); + }) { + debug!( + "quick_search: failed to update grep footer view: {:?}", + err + ); + } + set_has_content(true, cx); + set_loading(false, cx); + set_loading_label(None, cx); + return; + } + + if text.contains("{unknown}") { + consecutive_idle_empty_responses = 0; + let (_running, _busy_now, hint) = server_status_for_buffer(cx); + let message = if let Some(hint) = hint { + Arc::::from(format!( + "Hover incomplete (server returned {{unknown}}). Waiting… ({hint})" + )) + } else { + Arc::::from( + "Hover incomplete (server returned {unknown}). Waiting…", + ) + }; + if let Err(err) = footer_for_task.update(cx, |footer, cx| { + if footer.selected_key != Some(selected_key) { + return; + } + footer.set_message(message); + cx.notify(); + }) { + debug!( + "quick_search: failed to update grep footer view: {:?}", + err + ); + } + set_has_content(true, cx); + cx.background_executor() + .timer(poll_interval) + .await; + continue; + } + + let language_registry = cx + .read_entity(&project, |project, _| project.languages().clone()) + .map(Some) + .unwrap_or_else(|err| { + debug!( + "quick_search: failed to read language registry for hover footer: {:?}", + err + ); + None + }); + + let markdown = match cx.new(|cx| { + Markdown::new( + text.into(), + language_registry, + hover_language_name, + cx, + ) + }) { + Ok(markdown) => markdown, + Err(err) => { + debug!( + "quick_search: failed to build hover markdown: {:?}", + err + ); + set_loading(false, cx); + set_has_content(false, cx); + set_loading_label(None, cx); + return; + } + }; + + if let Err(err) = footer_for_task.update(cx, |footer, cx| { + if footer.selected_key != Some(selected_key) { + return; + } + footer.set_markdown(markdown); + cx.notify(); + }) { + debug!( + "quick_search: failed to update grep footer view: {:?}", + err + ); + set_loading(false, cx); + set_has_content(false, cx); + set_loading_label(None, cx); + return; + } + + set_loading(false, cx); + set_has_content(true, cx); + set_loading_label(None, cx); + return; + } + }) + .detach(); + } + }), + }) + } + + fn start_search(&self, ctx: SearchContext, sink: SearchSink, cx: &mut SearchUiContext<'_>) { + let project = ctx.project().clone(); + let search_options = ctx.search_options(); + let source_id = self.spec().id.0.clone(); + let path_style = ctx.path_style(); + let language_registry = ctx.language_registry().clone(); + let query = ctx.query().clone(); + let match_arena = ctx.match_arena().clone(); + + crate::core::spawn_source_task(cx, sink, move |app, sink| { + async move { + let search_query = match app.update_entity(&project, |_project, _| { + let include = PathMatcher::default(); + let exclude = PathMatcher::default(); + if search_options.contains(SearchOptions::REGEX) { + SearchQuery::regex( + query.as_ref(), + search_options.contains(SearchOptions::WHOLE_WORD), + search_options.contains(SearchOptions::CASE_SENSITIVE), + search_options.contains(SearchOptions::INCLUDE_IGNORED), + false, + include, + exclude, + false, + None, + ) + } else { + SearchQuery::text( + query.as_ref(), + search_options.contains(SearchOptions::WHOLE_WORD), + search_options.contains(SearchOptions::CASE_SENSITIVE), + search_options.contains(SearchOptions::INCLUDE_IGNORED), + include, + exclude, + false, + None, + ) + } + }) { + Ok(Ok(query)) => query, + Ok(Err(err)) => { + sink.record_error(err.to_string(), app); + return; + } + Err(err) => { + sink.record_error(err.to_string(), app); + return; + } + }; + + let receiver = + match app.update_entity(&project, |project, cx| project.search(search_query, cx)) { + Ok(receiver) => receiver, + Err(err) => { + sink.record_error(err.to_string(), app); + return; + } + }; + + sink.set_inflight_results(receiver.clone(), app); + + let mut batcher = MatchBatcher::new(match_arena.clone()); + let mut syntax_workers: HashMap> = + HashMap::new(); + const YIELD_MAX_ITEMS: usize = 128; + const YIELD_MAX_INTERVAL: Duration = Duration::from_millis(4); + let mut since_yield = 0usize; + let mut last_yield = Instant::now(); + loop { + let result = match receiver.recv().await { + Ok(r) => r, + Err(_) => break, + }; + if sink.is_cancelled() { + break; + } + + match result { + SearchResult::Buffer { buffer, ranges } => { + if let Some(out) = build_matches_for_buffer( + app, + &buffer, + ranges, + &path_style, + &source_id, + ) { + if !out.pending_syntax.is_empty() { + ensure_syntax_worker( + app, + &mut syntax_workers, + out.buffer_id, + buffer.clone(), + sink.clone(), + language_registry.clone(), + ); + if let Some(sender) = syntax_workers.get(&out.buffer_id) { + for item in out.pending_syntax { + if let Err(err) = sender.try_send(item) { + debug!( + "quick_search: failed to queue syntax enrich item: {:?}", + err + ); + break; + } + } + } + } + + for match_item in out.matches { + batcher.push(match_item, &sink, app); + } + } + if sink.is_cancelled() { + break; + } + } + SearchResult::LimitReached => { + batcher.flush(&sink, app); + if sink.is_cancelled() { + break; + } + break; + } + } + + since_yield = since_yield.saturating_add(1); + if since_yield >= YIELD_MAX_ITEMS || last_yield.elapsed() >= YIELD_MAX_INTERVAL { + yield_now().await; + since_yield = 0; + last_yield = Instant::now(); + } + } + + drop(syntax_workers); + if !sink.is_cancelled() { + batcher.finish(&sink, app); + } + } + .boxed_local() + }); + } +} + +fn elide_path(segments: &[Arc]) -> Arc { + const MAX_SEGMENTS: usize = 5; + let Some(head) = segments.first() else { + return Arc::::from(""); + }; + if segments.len() <= MAX_SEGMENTS { + return Arc::::from(segments.join("/")); + } + + let tail_count = MAX_SEGMENTS.saturating_sub(1); + let tail_start = segments.len().saturating_sub(tail_count); + let mut parts = Vec::with_capacity(2 + tail_count); + parts.push(head.clone()); + parts.push(Arc::::from("…")); + parts.extend_from_slice(&segments[tail_start..]); + Arc::::from(parts.join("/")) +} + +fn clip_snippet_into(text: &str, out: &mut String) -> usize { + out.clear(); + if text.len() <= crate::MAX_SNIPPET_BYTES { + out.push_str(text); + return text.len(); + } + + let suffix = "."; + let max_content_bytes = crate::MAX_SNIPPET_BYTES.saturating_sub(suffix.len()); + let mut end = max_content_bytes.min(text.len()); + while end > 0 && !text.is_char_boundary(end) { + end -= 1; + } + + out.reserve(end + suffix.len()); + out.push_str(&text[..end]); + out.push_str(suffix); + end +} + +fn coalesce_syntax_runs(runs: &mut Vec<(Range, HighlightId)>) { + if runs.len() <= 1 { + return; + } + runs.sort_by_key(|(range, _)| (range.start, range.end)); + let mut out: Vec<(Range, HighlightId)> = Vec::with_capacity(runs.len()); + for (range, id) in runs.drain(..) { + if let Some((last_range, last_id)) = out.last_mut() { + if *last_id == id && last_range.end == range.start { + last_range.end = range.end; + continue; + } + } + out.push((range, id)); + } + *runs = out; +} + +struct BuildMatchesOutput { + matches: Vec, + pending_syntax: Vec, + buffer_id: BufferId, +} + +fn ensure_syntax_worker( + app: &mut AsyncApp, + workers: &mut HashMap>, + buffer_id: BufferId, + buffer: gpui::Entity, + sink: SearchSink, + language_registry: Arc, +) { + if workers.contains_key(&buffer_id) { + return; + } + + let (sender, receiver) = async_channel::unbounded(); + workers.insert(buffer_id, sender); + + app.spawn(async move |app| { + let mut language_attempted = false; + let mut queued: Vec = Vec::new(); + + loop { + let first = match receiver.recv().await { + Ok(item) => item, + Err(_) => break, + }; + queued.push(first); + while let Ok(item) = receiver.try_recv() { + queued.push(item); + } + + if sink.is_cancelled() { + break; + } + + let snapshot = match app.read_entity(&buffer, |b, _| b.snapshot()) { + Ok(s) => s, + Err(_) => break, + }; + + if snapshot.language().is_none() && !language_attempted { + language_attempted = true; + let file = match app.read_entity(&buffer, |b, _| b.file().cloned()) { + Ok(file) => file, + Err(err) => { + debug!( + "quick_search: failed to read file for syntax enrich worker: {:?}", + err + ); + None + } + }; + if let Some(file) = file { + let available = match app.update({ + let language_registry = language_registry.clone(); + let file = file.clone(); + move |cx| language_registry.language_for_file(&file, None, cx) + }) { + Ok(available) => available, + Err(err) => { + debug!( + "quick_search: failed to detect language for syntax enrich worker: {:?}", + err + ); + None + } + }; + if let Some(available) = available { + let language_receiver = language_registry.load_language(&available); + if let Ok(Ok(language)) = language_receiver.await { + if let Err(err) = app.update_entity(&buffer, |b, cx| { + b.set_language_registry(language_registry.clone()); + b.set_language_async(Some(language.clone()), cx); + }) { + debug!( + "quick_search: failed to set language for syntax enrich worker: {:?}", + err + ); + } + } + } + } + } + + let parsing_idle = app.read_entity(&buffer, |b, _| b.parsing_idle()); + if let Ok(idle) = parsing_idle { + idle.await; + } + + while let Ok(item) = receiver.try_recv() { + queued.push(item); + } + + if sink.is_cancelled() { + break; + } + + let snapshot = match app.read_entity(&buffer, |b, _| b.snapshot()) { + Ok(s) => s, + Err(_) => break, + }; + if snapshot.language().is_none() { + queued.clear(); + continue; + } + + let mut patches: Vec<(crate::types::MatchKey, crate::types::QuickMatchPatch)> = + Vec::new(); + + for item in queued.drain(..) { + let snippet_len = item.snippet_len; + if snippet_len == 0 { + continue; + } + + let max_row = snapshot.text.max_point().row; + let row = item.row.min(max_row); + let line_start = Point::new(row, 0); + let line_end = Point::new(row, snapshot.text.line_len(row)); + let line_start_offset = snapshot.text.point_to_offset(line_start); + let line_end_offset = snapshot.text.point_to_offset(line_end); + + // Limit work to the snippet window to avoid scanning long lines. + let snippet_abs_start = line_start_offset; + let snippet_abs_end = + (line_start_offset + snippet_len + 512).min(line_end_offset); + + let snippet_text: String = snapshot + .text_for_range(snippet_abs_start..snippet_abs_end) + .collect(); + let snippet_trimmed_end = snippet_text.trim_end(); + let trim_start = snippet_trimmed_end.len() - snippet_trimmed_end.trim_start().len(); + let snippet_end_abs = (trim_start + snippet_len).min(snippet_trimmed_end.len()); + if trim_start >= snippet_end_abs { + continue; + } + + let mut highlight_ids: Vec<(Range, HighlightId)> = Vec::new(); + let mut current_offset = 0usize; + let mut chunks = snapshot.chunks(snippet_abs_start..snippet_abs_end, true); + for chunk in chunks.by_ref() { + 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 < snippet_len { + let clamped_start = rel_start.min(snippet_len); + let clamped_end = rel_end.min(snippet_len); + if clamped_start < clamped_end { + highlight_ids.push((clamped_start..clamped_end, highlight_id)); + } + } + } + + current_offset += chunk_len; + if current_offset >= snippet_len + trim_start { + break; + } + } + + if highlight_ids.is_empty() { + continue; + } + coalesce_syntax_runs(&mut highlight_ids); + + patches.push(( + item.key, + crate::types::QuickMatchPatch { + snippet_syntax_highlights: crate::types::PatchValue::SetTo(Arc::from( + highlight_ids.into_boxed_slice(), + )), + ..Default::default() + }, + )); + } + + if !patches.is_empty() { + sink.apply_patches_by_key(patches, app); + } + } + }) + .detach(); +} + +fn build_matches_for_buffer( + app: &mut AsyncApp, + buffer: &gpui::Entity, + ranges: Vec>, + path_style: &PathStyle, + source_id: &Arc, +) -> Option { + struct PreparedRange { + start_col: u32, + start_point: Point, + end_point: Point, + start_offset: usize, + end_offset: usize, + } + + let snapshot = match app.read_entity(buffer, |b, _| b.snapshot()) { + Ok(s) => s, + Err(_) => return None, + }; + let buffer_id = snapshot.text.remote_id(); + + let (project_path, path_label): (Option, Arc) = app + .read_entity(buffer, |b, cx| { + let Some(file) = b.file() else { + return (None, Arc::::from("")); + }; + let project_path = ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path().clone(), + }; + let path_label: Arc = + Arc::::from(file.path().display(*path_style).to_string()); + (Some(project_path), path_label) + }) + .unwrap_or((None, Arc::::from(""))); + + let file_name: Arc = path_label + .rsplit_once(path::MAIN_SEPARATOR) + .map(|(_, name)| Arc::::from(name)) + .or_else(|| { + path_label + .rsplit_once('/') + .map(|(_, name)| Arc::::from(name)) + }) + .unwrap_or_else(|| path_label.clone()); + + let path_segments = crate::types::split_path_segments(&path_label); + let display_path: Arc = elide_path(&path_segments); + + let group: Option> = project_path.as_ref().map(|project_path| { + let title: Arc = project_path + .path + .file_name() + .map(|name| Arc::::from(name.to_string())) + .unwrap_or_else(|| Arc::::from(project_path.path.as_unix_str().to_string())); + let subtitle: Option> = project_path.path.parent().and_then(|path| { + let s = path.as_unix_str().to_string(); + (!s.is_empty()).then(|| Arc::::from(s)) + }); + let icon_path = app + .update({ + let file_name = file_name.clone(); + move |cx| FileIcons::get_icon(Path::new(file_name.as_ref()), cx) + }) + .unwrap_or_else(|err| { + debug!("quick_search: failed to get icon for grep group: {:?}", err); + None + }); + + Arc::new(GroupInfo { + key: crate::types::compute_group_key_for_project_path(source_id, project_path), + header: GroupHeader { + icon_name: IconName::File, + icon_path, + title, + subtitle, + }, + }) + }); + + let mut per_line: FxHashMap> = FxHashMap::default(); + let mut line_order: Vec = Vec::new(); + for range in ranges { + let start_point = range.start.to_point(&snapshot.text); + let end_point = range.end.to_point(&snapshot.text); + let start_offset = range.start.to_offset(&snapshot.text); + let end_offset = range.end.to_offset(&snapshot.text); + let row = start_point.row; + + if !per_line.contains_key(&row) { + line_order.push(row); + } + per_line + .entry(row) + .or_insert_with(Vec::new) + .push(PreparedRange { + start_col: start_point.column, + start_point, + end_point, + start_offset, + end_offset, + }); + } + + let mut matches = Vec::with_capacity(line_order.len()); + let mut pending_syntax: Vec = Vec::new(); + let mut line_buf = String::new(); + let mut snippet_buf = String::new(); + let mut snippet_match_positions: Vec> = Vec::new(); + let mut snippet_syntax_highlights: Vec<(Range, HighlightId)> = Vec::new(); + for row in line_order { + let mut items = match per_line.remove(&row) { + Some(v) => v, + None => continue, + }; + if items.len() > 1 { + items.sort_by_key(|item| item.start_col); + } + + let mut ranges_for_line = Vec::with_capacity(items.len()); + for item in &items { + ranges_for_line.push(item.start_point..item.end_point); + } + + let Some(first_range) = items.first() else { + continue; + }; + let first_col = first_range.start_col; + let start_point = first_range.start_point; + let location_label: Option> = + Some(format!(":{}:{}", row + 1, first_col + 1).into()); + + let max_row = snapshot.text.max_point().row; + let row = row.min(max_row); + let line_start = Point::new(row, 0); + let line_end = Point::new(row, snapshot.text.line_len(row)); + let line_start_offset = snapshot.text.point_to_offset(line_start); + let line_end_offset = snapshot.text.point_to_offset(line_end); + let line_read_end = + (line_start_offset + crate::MAX_SNIPPET_BYTES + 512).min(line_end_offset); + + line_buf.clear(); + line_buf.extend(snapshot.text_for_range(line_start_offset..line_read_end)); + + let line_trimmed_end = line_buf.trim_end(); + let trim_start = line_trimmed_end.len() - line_trimmed_end.trim_start().len(); + let line_trimmed = &line_trimmed_end[trim_start..]; + + let snippet_content_len = clip_snippet_into(line_trimmed, &mut snippet_buf); + let snippet: Arc = Arc::::from(snippet_buf.as_str()); + + snippet_match_positions.clear(); + for r in &items { + let match_start_offset = r.start_offset; + let match_end_offset = r.end_offset; + + let start_in_line = match_start_offset.saturating_sub(line_start_offset); + let end_in_line = match_end_offset.saturating_sub(line_start_offset); + + let start_in_preview = start_in_line.saturating_sub(trim_start); + let end_in_preview = end_in_line.saturating_sub(trim_start); + + if start_in_preview >= snippet_content_len || end_in_preview == 0 { + continue; + } + + let clamped_start = start_in_preview.min(snippet_content_len); + let clamped_end = end_in_preview.min(snippet_content_len); + if clamped_start >= clamped_end { + continue; + } + + let snippet_str = snippet.as_ref(); + let mut safe_start = clamped_start.min(snippet_str.len()); + while safe_start > 0 && !snippet_str.is_char_boundary(safe_start) { + safe_start -= 1; + } + let mut safe_end = clamped_end.min(snippet_str.len()); + while safe_end < snippet_str.len() && !snippet_str.is_char_boundary(safe_end) { + safe_end += 1; + } + + if safe_start < safe_end { + snippet_match_positions.push(safe_start..safe_end); + } + } + if snippet_match_positions.len() > 1 { + snippet_match_positions.sort_by_key(|r| (r.start, r.end)); + snippet_match_positions.dedup(); + } + + snippet_syntax_highlights.clear(); + if snippet_content_len > 0 && snapshot.language().is_some() { + let mut rel_offset = 0usize; + let snippet_abs_start = line_start_offset + trim_start; + let snippet_abs_end = snippet_abs_start + snippet_content_len; + let mut chunks = snapshot.chunks(snippet_abs_start..snippet_abs_end, true); + for chunk in chunks.by_ref() { + let chunk_len = chunk.text.len(); + let chunk_start = rel_offset; + let chunk_end = rel_offset + chunk_len; + rel_offset = chunk_end; + + if let Some(id) = chunk.syntax_highlight_id { + let start_rel = chunk_start.min(snippet_content_len); + let end_rel = chunk_end.min(snippet_content_len); + if start_rel < end_rel { + snippet_syntax_highlights.push((start_rel..end_rel, id)); + } + } + + if rel_offset >= snippet_content_len { + break; + } + } + if snippet_syntax_highlights.len() > 1 { + coalesce_syntax_runs(&mut snippet_syntax_highlights); + } + } + + let ranges_for_line_points = ranges_for_line; + let first_point_range = ranges_for_line_points.first().cloned(); + let kind = crate::types::QuickMatchKind::Buffer { + buffer_id, + ranges: ranges_for_line_points.clone(), + position: Some((row, start_point.column)), + }; + let snippet_for_match = snippet.clone(); + let snippet_match_positions_arc = (!snippet_match_positions.is_empty()) + .then(|| Arc::<[Range]>::from(snippet_match_positions.as_slice())); + let snippet_syntax_highlights_arc = (!snippet_syntax_highlights.is_empty()).then(|| { + Arc::<[(Range, HighlightId)]>::from(snippet_syntax_highlights.as_slice()) + }); + + let mut match_item = QuickMatchBuilder::new(source_id.clone(), kind) + .action(match project_path.clone() { + Some(project_path) => crate::types::MatchAction::OpenProjectPath { + project_path, + point_range: first_point_range, + }, + None => crate::types::MatchAction::Dismiss, + }) + .group(group.clone()) + .path_label(path_label.clone()) + .display_path(display_path.clone()) + .path_segments(path_segments.clone()) + .file_name(file_name.clone()) + .location_label(location_label) + .snippet(Some(snippet_for_match)) + .first_line_snippet(Some(snippet)) + .snippet_match_positions(snippet_match_positions_arc) + .snippet_syntax_highlights(snippet_syntax_highlights_arc) + .build(); + match_item.key = crate::types::compute_match_key(&match_item); + if match_item.snippet_syntax_highlights.is_none() && snippet_content_len > 0 { + pending_syntax.push(SyntaxEnrichItem { + key: match_item.key, + row, + snippet_len: snippet_content_len, + }); + } + matches.push(match_item); + } + + Some(BuildMatchesOutput { + matches, + pending_syntax, + buffer_id, + }) +} diff --git a/crates/quick_search/src/types.rs b/crates/quick_search/src/types.rs new file mode 100644 index 00000000000000..848eeffefadd4e --- /dev/null +++ b/crates/quick_search/src/types.rs @@ -0,0 +1,468 @@ +use collections::FxHasher; +use gpui::{Img, SharedString}; +use language::HighlightId; +use project::ProjectPath; +use std::{ + hash::{Hash, Hasher}, + ops::Range, + path, + path::Path, + sync::Arc, +}; +use text::Point; +use text::ToOffset; +use text::{Anchor as TextAnchor, BufferId}; +use ui::IconName; + +pub type MatchId = u64; + +#[repr(transparent)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub struct MatchKey(pub u64); + +#[repr(transparent)] +#[derive(Clone, Copy, Debug, Hash, PartialEq, Eq)] +pub struct GroupKey(pub u64); + +#[derive(Clone)] +pub struct GroupHeader { + pub icon_name: IconName, + pub icon_path: Option, + pub title: Arc, + pub subtitle: Option>, +} + +#[derive(Clone)] +pub struct GroupInfo { + pub key: GroupKey, + pub header: GroupHeader, +} + +#[derive(Clone, Debug, Default)] +pub enum PatchValue { + #[default] + Unchanged, + #[allow(dead_code)] + Clear, + SetTo(T), +} + +#[derive(Clone, Default)] +pub struct QuickMatchPatch { + pub snippet: PatchValue>, + pub snippet_syntax_highlights: PatchValue, HighlightId)]>>, + pub blame: PatchValue>, + pub location_label: PatchValue>, + pub path_label: PatchValue>, + pub path_segments: PatchValue]>>, + pub file_name: PatchValue>, +} + +#[derive(Clone)] +pub enum QuickMatchKind { + Buffer { + buffer_id: BufferId, + ranges: Vec>, + position: Option<(u32, u32)>, + }, + ProjectPath { + project_path: ProjectPath, + }, + GitCommit { + repo_workdir: Arc, + sha: Arc, + subject: Arc, + author: Arc, + repo_label: Arc, + branch: Option>, + commit_timestamp: i64, + }, +} + +#[derive(Clone)] +pub enum MatchAction { + OpenProjectPath { + project_path: ProjectPath, + point_range: Option>, + }, + OpenGitCommit { + repo_workdir: Arc, + sha: Arc, + }, + Dismiss, +} + +#[derive(Clone)] +pub struct QuickMatch { + pub id: MatchId, + pub key: MatchKey, + pub source_id: Arc, + pub action: MatchAction, + pub group: Option>, + pub path_label: Arc, + pub display_path: Arc, + pub display_path_positions: Option>, + pub path_segments: Arc<[Arc]>, + pub file_name: Arc, + pub file_name_positions: Option>, + pub location_label: Option>, + pub snippet: Option>, + pub first_line_snippet: Option>, + pub snippet_match_positions: Option]>>, + pub snippet_syntax_highlights: Option, HighlightId)]>>, + pub blame: Option>, + pub kind: QuickMatchKind, +} + +pub struct QuickMatchBuilder { + match_item: QuickMatch, +} + +impl QuickMatchBuilder { + pub fn new(source_id: Arc, kind: QuickMatchKind) -> Self { + let action = match &kind { + QuickMatchKind::ProjectPath { project_path } => MatchAction::OpenProjectPath { + project_path: project_path.clone(), + point_range: None, + }, + QuickMatchKind::Buffer { .. } => MatchAction::Dismiss, + QuickMatchKind::GitCommit { + repo_workdir, sha, .. + } => MatchAction::OpenGitCommit { + repo_workdir: repo_workdir.clone(), + sha: sha.clone(), + }, + }; + Self { + match_item: QuickMatch { + id: 0, + key: MatchKey(0), + source_id, + action, + group: None, + path_label: Arc::::from(""), + display_path: Arc::::from(""), + display_path_positions: None, + path_segments: Arc::from(Box::<[Arc]>::default()), + file_name: Arc::::from(""), + file_name_positions: None, + location_label: None, + snippet: None, + first_line_snippet: None, + snippet_match_positions: None, + snippet_syntax_highlights: None, + blame: None, + kind, + }, + } + } + + pub fn action(mut self, action: MatchAction) -> Self { + self.match_item.action = action; + self + } + + pub fn group(mut self, group: Option>) -> Self { + self.match_item.group = group; + self + } + + pub fn path_label(mut self, path_label: Arc) -> Self { + self.match_item.path_label = path_label; + self + } + + pub fn display_path(mut self, display_path: Arc) -> Self { + self.match_item.display_path = display_path; + self + } + + pub fn display_path_positions(mut self, positions: Option>) -> Self { + self.match_item.display_path_positions = positions; + self + } + + pub fn path_segments(mut self, path_segments: Arc<[Arc]>) -> Self { + self.match_item.path_segments = path_segments; + self + } + + pub fn path_segments_from_label(mut self) -> Self { + self.match_item.path_segments = split_path_segments(&self.match_item.path_label); + self + } + + pub fn file_name(mut self, file_name: Arc) -> Self { + self.match_item.file_name = file_name; + self + } + + pub fn file_name_positions(mut self, positions: Option>) -> Self { + self.match_item.file_name_positions = positions; + self + } + + pub fn location_label(mut self, label: Option>) -> Self { + self.match_item.location_label = label; + self + } + + pub fn snippet(mut self, snippet: Option>) -> Self { + self.match_item.first_line_snippet = snippet + .as_deref() + .and_then(|snippet| snippet.lines().next()) + .map(Arc::::from); + self.match_item.snippet = snippet; + self + } + + pub fn first_line_snippet(mut self, first_line_snippet: Option>) -> Self { + self.match_item.first_line_snippet = first_line_snippet; + self + } + + pub fn snippet_match_positions(mut self, positions: Option]>>) -> Self { + self.match_item.snippet_match_positions = positions; + self + } + + pub fn snippet_syntax_highlights( + mut self, + highlights: Option, HighlightId)]>>, + ) -> Self { + self.match_item.snippet_syntax_highlights = highlights; + self + } + + pub fn build(self) -> QuickMatch { + self.match_item + } +} + +fn hash_part(hasher: &mut H, value: &T) { + value.hash(hasher); + 0u8.hash(hasher); +} + +pub fn compute_match_key(quick_match: &QuickMatch) -> MatchKey { + let mut hasher = FxHasher::default(); + hash_part(&mut hasher, &quick_match.source_id); + + match &quick_match.kind { + QuickMatchKind::ProjectPath { project_path } => { + hash_part(&mut hasher, b"path"); + hash_part(&mut hasher, &project_path.worktree_id.to_proto()); + hash_part(&mut hasher, &project_path.path.as_unix_str()); + } + QuickMatchKind::Buffer { + buffer_id, + position, + .. + } => { + hash_part(&mut hasher, b"buf"); + let id_u64: u64 = (*buffer_id).into(); + hash_part(&mut hasher, &id_u64); + let row: u32 = position.map(|(row, _)| row).unwrap_or(0); + hash_part(&mut hasher, &row); + } + QuickMatchKind::GitCommit { + repo_workdir, sha, .. + } => { + hash_part(&mut hasher, b"commit"); + hash_part(&mut hasher, &repo_workdir.to_string_lossy()); + hash_part(&mut hasher, sha); + } + } + + MatchKey(hasher.finish()) +} + +pub fn compute_group_key_for_project_path( + source_id: &Arc, + project_path: &ProjectPath, +) -> GroupKey { + let mut hasher = FxHasher::default(); + hash_part(&mut hasher, source_id); + hash_part(&mut hasher, b"group"); + hash_part(&mut hasher, &project_path.worktree_id.to_proto()); + hash_part(&mut hasher, &project_path.path.as_unix_str()); + GroupKey(hasher.finish()) +} + +impl QuickMatch { + pub fn ranges(&self) -> Option<&[Range]> { + match &self.kind { + QuickMatchKind::Buffer { ranges, .. } => Some(ranges), + _ => None, + } + } + + pub fn buffer_id(&self) -> Option { + match &self.kind { + QuickMatchKind::Buffer { buffer_id, .. } => Some(*buffer_id), + _ => None, + } + } + + pub fn position(&self) -> Option<(u32, u32)> { + match &self.kind { + QuickMatchKind::Buffer { position, .. } => *position, + _ => None, + } + } + + pub fn project_path(&self) -> Option<&ProjectPath> { + match &self.action { + MatchAction::OpenProjectPath { project_path, .. } => Some(project_path), + _ => None, + } + } + + pub fn is_likely_binary(&self) -> bool { + let extension = self + .file_name + .rsplit('.') + .next() + .unwrap_or("") + .to_lowercase(); + + Img::extensions().contains(&extension.as_str()) && !extension.contains("svg") + } + + pub fn apply_patch(&mut self, patch: QuickMatchPatch) -> bool { + let mut changed = false; + + match patch.snippet { + PatchValue::Unchanged => {} + PatchValue::Clear => { + if self.snippet.is_some() { + self.snippet = None; + self.first_line_snippet = None; + self.snippet_match_positions = None; + self.snippet_syntax_highlights = None; + changed = true; + } + } + PatchValue::SetTo(value) => { + if self.snippet.as_ref() != Some(&value) { + self.first_line_snippet = value.lines().next().map(Arc::::from); + self.snippet = Some(value); + self.snippet_match_positions = None; + self.snippet_syntax_highlights = None; + changed = true; + } + } + } + + match patch.snippet_syntax_highlights { + PatchValue::Unchanged => {} + PatchValue::Clear => { + if self.snippet_syntax_highlights.is_some() { + self.snippet_syntax_highlights = None; + changed = true; + } + } + PatchValue::SetTo(value) => { + if self.snippet_syntax_highlights.as_ref() != Some(&value) { + self.snippet_syntax_highlights = Some(value); + changed = true; + } + } + } + + match patch.blame { + PatchValue::Unchanged => {} + PatchValue::Clear => { + if self.blame.is_some() { + self.blame = None; + changed = true; + } + } + PatchValue::SetTo(value) => { + if self.blame.as_ref() != Some(&value) { + self.blame = Some(value); + changed = true; + } + } + } + + match patch.location_label { + PatchValue::Unchanged => {} + PatchValue::Clear => { + if self.location_label.is_some() { + self.location_label = None; + changed = true; + } + } + PatchValue::SetTo(value) => { + if self.location_label.as_ref() != Some(&value) { + self.location_label = Some(value); + changed = true; + } + } + } + + match patch.path_label { + PatchValue::Unchanged => {} + PatchValue::Clear => {} + PatchValue::SetTo(value) => { + if self.path_label != value { + self.path_label = value; + changed = true; + } + } + } + + match patch.path_segments { + PatchValue::Unchanged => {} + PatchValue::Clear => {} + PatchValue::SetTo(value) => { + if self.path_segments != value { + self.path_segments = value; + changed = true; + } + } + } + + match patch.file_name { + PatchValue::Unchanged => {} + PatchValue::Clear => {} + PatchValue::SetTo(value) => { + if self.file_name != value { + self.file_name = value; + changed = true; + } + } + } + + changed + } +} + +pub fn split_path_segments(path_label: &str) -> Arc<[Arc]> { + if path_label.is_empty() { + return Arc::from(Box::<[Arc]>::default()); + } + let mut segments: Vec> = path_label + .split(|c| c == '/' || c == path::MAIN_SEPARATOR) + .filter(|part| !part.is_empty()) + .map(Arc::::from) + .collect(); + if segments.is_empty() { + segments.push(Arc::::from(path_label)); + } + Arc::from(segments.into_boxed_slice()) +} + +pub fn point_range_to_anchor_range( + range: Range, + buffer: &text::BufferSnapshot, +) -> Range { + let start_offset = range.start.to_offset(buffer); + let end_offset = range.end.to_offset(buffer); + if start_offset == end_offset { + buffer.anchor_before(start_offset)..buffer.anchor_before(end_offset) + } else { + buffer.anchor_after(range.start)..buffer.anchor_before(range.end) + } +} diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index 02eb611fc22570..bab504d2800c95 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -31,11 +31,16 @@ gpui.workspace = true language.workspace = true menu.workspace = true project.workspace = true +picker.workspace = true +fuzzy.workspace = true +log.workspace = true +multi_buffer.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 @@ -43,6 +48,11 @@ util_macros.workspace = true workspace.workspace = true zed_actions.workspace = true itertools.workspace = true +file_icons.workspace = true +async-channel = "2.5" +indexmap.workspace = true +buffer_diff.workspace = true +git.workspace = true ztracing.workspace = true tracing.workspace = true @@ -58,4 +68,3 @@ workspace = { workspace = true, features = ["test-support"] } [package.metadata.cargo-machete] ignored = ["tracing"] - diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index 6663f8c3184aba..5003f5de168559 100644 --- a/crates/search/src/search.rs +++ b/crates/search/src/search.rs @@ -15,7 +15,7 @@ use crate::project_search::ProjectSearchBar; pub mod buffer_search; pub mod project_search; -pub(crate) mod search_bar; +pub mod search_bar; pub mod search_status_button; pub fn init(cx: &mut App) { diff --git a/crates/search/src/search_bar.rs b/crates/search/src/search_bar.rs index 13b4df9574aa6b..b14cf000e46243 100644 --- a/crates/search/src/search_bar.rs +++ b/crates/search/src/search_bar.rs @@ -39,7 +39,7 @@ pub(super) fn render_action_button( }) } -pub(crate) fn input_base_styles(border_color: Hsla, map: impl FnOnce(Div) -> Div) -> Div { +pub fn input_base_styles(border_color: Hsla, map: impl FnOnce(Div) -> Div) -> Div { h_flex() .map(map) .min_w_32() @@ -51,7 +51,7 @@ pub(crate) fn input_base_styles(border_color: Hsla, map: impl FnOnce(Div) -> Div .rounded_md() } -pub(crate) fn render_text_input( +pub fn render_text_input( editor: &Entity, color_override: Option, app: &App, diff --git a/crates/vim/Cargo.toml b/crates/vim/Cargo.toml index 2db1b51e72fcd8..7b37d759719f01 100644 --- a/crates/vim/Cargo.toml +++ b/crates/vim/Cargo.toml @@ -58,6 +58,7 @@ assets.workspace = true command_palette = { workspace = true, features = ["test-support"] } editor = { workspace = true, features = ["test-support"] } git_ui.workspace = true +quick_search.workspace = true gpui = { workspace = true, features = ["test-support"] } indoc.workspace = true language = { workspace = true, features = ["test-support"] } diff --git a/crates/vim/src/test/vim_test_context.rs b/crates/vim/src/test/vim_test_context.rs index 2d5ed4227dcc26..575b1b1ebb782b 100644 --- a/crates/vim/src/test/vim_test_context.rs +++ b/crates/vim/src/test/vim_test_context.rs @@ -27,6 +27,7 @@ impl VimTestContext { git_ui::init(cx); crate::init(cx); search::init(cx); + quick_search::init(cx); theme::init(theme::LoadThemes::JustBase, cx); settings_ui::init(cx); markdown_preview::init(cx); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 7dfa5d634c73ee..67155d89bbd220 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -389,6 +389,12 @@ pub struct ToggleFileFinder { pub separate_history: bool, } +/// Toggles the Quick Search modal. +#[derive(Default, PartialEq, Eq, Clone, Deserialize, JsonSchema, Action)] +#[action(namespace = quick_search, name = "Toggle")] +#[serde(deny_unknown_fields)] +pub struct ToggleQuickSearch; + /// Increases size of a currently focused dock by a given amount of pixels. #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] #[action(namespace = workspace)] diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 141de1139fb571..97210e50adb819 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -125,6 +125,7 @@ reqwest.workspace = true reqwest_client.workspace = true rope.workspace = true search.workspace = true +quick_search.workspace = true serde.workspace = true serde_json.workspace = true session.workspace = true diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 6d94a15a666c66..48487e725e1224 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -623,6 +623,7 @@ pub fn main() { snippets_ui::init(cx); channel::init(&app_state.client.clone(), app_state.user_store.clone(), cx); search::init(cx); + quick_search::init(cx); vim::init(cx); terminal_view::init(cx); journal::init(app_state.clone(), cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index a51e38bfe48976..6f44afc13d14a1 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -5003,6 +5003,7 @@ mod tests { debugger_ui::init(cx); initialize_workspace(app_state.clone(), prompt_builder, cx); search::init(cx); + quick_search::init(cx); app_state }) } diff --git a/crates/zed/src/zed/app_menus.rs b/crates/zed/src/zed/app_menus.rs index a7961ac6d4cb66..943cfe13b9f50c 100644 --- a/crates/zed/src/zed/app_menus.rs +++ b/crates/zed/src/zed/app_menus.rs @@ -169,7 +169,8 @@ pub fn app_menus(cx: &mut App) -> Vec { MenuItem::os_action("Paste", editor::actions::Paste, OsAction::Paste), MenuItem::separator(), MenuItem::action("Find", search::buffer_search::Deploy::find()), - MenuItem::action("Find in Project", workspace::DeploySearch::find()), + MenuItem::action("Find In Project", workspace::DeploySearch::find()), + MenuItem::action("Quick Search...", workspace::ToggleQuickSearch), MenuItem::separator(), MenuItem::action( "Toggle Line Comment", diff --git a/docs/src/key-bindings.md b/docs/src/key-bindings.md index f0f1e472c75e7e..784efe51e0abd4 100644 --- a/docs/src/key-bindings.md +++ b/docs/src/key-bindings.md @@ -18,6 +18,8 @@ We currently support: This setting can also be changed via the command palette through the `zed: toggle base keymap selector` action. +The Quick Search overlay is bound by default to `Ctrl+Alt+K` on Windows/Linux and `Cmd+Alt+K` on macOS; you can remap it in the keymap editor or `keymap.json`. + You can also enable `vim_mode` or `helix_mode`, which add modal bindings. For more information, see the documentation for [Vim mode](./vim.md) and [Helix mode](./helix.md).