diff --git a/Cargo.lock b/Cargo.lock index 5ab20e6db9..d3be7e7882 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -16246,6 +16246,7 @@ dependencies = [ "anyhow", "bitflags 2.10.0", "collections", + "db", "editor", "file_icons", "fs", diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index 56d7e2b37d..af4c5e9837 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -25,6 +25,7 @@ anyhow.workspace = true any_vec.workspace = true bitflags.workspace = true collections.workspace = true +db.workspace = true editor.workspace = true file_icons.workspace = true fs.workspace = true diff --git a/crates/search/src/text_finder.rs b/crates/search/src/text_finder.rs index 040a45ec3e..707587658c 100644 --- a/crates/search/src/text_finder.rs +++ b/crates/search/src/text_finder.rs @@ -1,5 +1,10 @@ use std::{ops::Range, sync::atomic::Ordering}; +use db::{ + query, + sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection}, + sqlez_macros::sql, +}; use editor::Editor; use gpui::{ App, AppContext, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, @@ -9,16 +14,20 @@ use language::Buffer; use picker::Picker; use project::ProjectPath; +use settings::SeedQuerySetting; use text::Anchor; use ui::Window; -use workspace::{DismissDecision, ModalView, Workspace, searchable::SearchableItemHandle}; +use workspace::{ + DismissDecision, ModalView, Workspace, WorkspaceDb, WorkspaceId, + searchable::SearchableItemHandle, +}; mod delegate; mod render; use delegate::{Delegate, matches_to_multibuffer}; use util::ResultExt as _; -use crate::{ProjectSearchView, text_finder::delegate::PopulateProjectSearch}; +use crate::{ProjectSearchView, SearchOptions, text_finder::delegate::PopulateProjectSearch}; actions!(text_finder, [ToProjectSearch,]); @@ -28,6 +37,87 @@ pub struct TextFinder { _subscription: Subscription, } +/// Persists the query and active filters of the just-closed Text Finder in the per-project +/// database, keyed by workspace so the search is restored only for the project it was run in +/// (mirrors JetBrains' per-project find history). The row is removed automatically when its +/// workspace is deleted, via the `ON DELETE CASCADE` foreign key. Workspaces without a database +/// id (not yet persisted) don't participate. +pub struct TextFinderDb(ThreadSafeConnection); + +impl Domain for TextFinderDb { + const NAME: &str = stringify!(TextFinderDb); + + const MIGRATIONS: &[&str] = &[sql!( + CREATE TABLE text_finder_queries ( + workspace_id INTEGER PRIMARY KEY, + query TEXT NOT NULL, + search_options INTEGER NOT NULL DEFAULT 0, + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) + ON DELETE CASCADE + ) STRICT; + )]; +} + +db::static_connection!(TextFinderDb, [WorkspaceDb]); + +impl TextFinderDb { + query! { + pub async fn set_last_search(workspace_id: WorkspaceId, query: String, search_options: i64) -> Result<()> { + INSERT INTO text_finder_queries (workspace_id, query, search_options) + VALUES (?1, ?2, ?3) + ON CONFLICT(workspace_id) DO UPDATE SET query = ?2, search_options = ?3 + } + } + + query! { + pub fn last_search(workspace_id: WorkspaceId) -> Result> { + SELECT query, search_options + FROM text_finder_queries + WHERE workspace_id = ?1 + } + } +} + +/// A query to pre-populate the Text Finder with, plus the search filters to restore alongside +/// it. `options` carries the workspace's last-used filters when there are any persisted; it is +/// `None` only the first time the finder is used in a workspace, leaving the filters at their +/// setting-derived defaults. +pub(crate) struct SearchSeed { + query: String, + options: Option, +} + +fn store_last_search( + workspace_id: Option, + query: String, + options: SearchOptions, + cx: &App, +) { + let Some(workspace_id) = workspace_id else { + return; + }; + let db = TextFinderDb::global(cx); + let search_options = options.bits() as i64; + db::write_and_log(cx, move || async move { + db.set_last_search(workspace_id, query, search_options) + .await + }); +} + +fn load_last_search(workspace_id: Option, cx: &App) -> Option { + let (query, search_options) = TextFinderDb::global(cx) + .last_search(workspace_id?) + .log_err() + .flatten()?; + if query.is_empty() { + return None; + } + Some(SearchSeed { + query, + options: Some(SearchOptions::from_bits_truncate(search_options as u8)), + }) +} + pub fn init(cx: &mut App) { cx.observe_new(TextFinder::register).detach(); } @@ -206,14 +296,31 @@ impl TextFinder { .update(cx, |p, _| p.delegate.in_progress_search.take_connected()) } - /// The query to pre-populate the text finder with, sourced from the active - /// item in priority order, mirroring how project search seeds itself: an - /// active project search's query, then a focused buffer search bar's query, - /// then the word under the cursor (honoring `seed_search_query_from_cursor`). + /// Guess the query the user probably wants for pre-populating the search input. fn seed_query( workspace: &mut Workspace, window: &mut Window, cx: &mut Context, + ) -> Option { + let last_search = load_last_search(workspace.database_id(), cx); + let options = last_search.as_ref().and_then(|seed| seed.options); + + let query = Self::active_item_query(workspace, window, cx) + .or_else(|| last_search.map(|seed| seed.query))?; + + Some(SearchSeed { query, options }) + } + + /// The query to seed from the active item, if any. + /// + /// Only an explicit selection seeds from the editor; the bare word under the cursor is + /// ignored. Confirming a match jumps to (and places the cursor on) it, so seeding from the + /// cursor on reopen would clobber the search you were in the middle of, whereas a deliberate + /// selection (e.g. a double-click) is a clear signal to search for that text. + fn active_item_query( + workspace: &mut Workspace, + window: &mut Window, + cx: &mut Context, ) -> Option { let item = workspace.active_item(cx)?; @@ -230,13 +337,18 @@ impl TextFinder { return Some(query); } - let editor = item.act_as::(cx)?; - let query = editor.query_suggestion(None, window, cx); - (!query.is_empty()).then_some(query) + if let Some(editor) = item.act_as::(cx) { + let query = editor.query_suggestion(Some(SeedQuerySetting::Selection), window, cx); + if !query.is_empty() { + return Some(query); + } + } + + None } - pub fn open( - seed_query: Option, + pub(crate) fn open( + seed_query: Option, window: &mut Window, cx: &mut Context, ) -> Task<()> { @@ -260,7 +372,7 @@ impl TextFinder { fn new( delegate: Delegate, - seed_query: Option, + seed_query: Option, window: &mut Window, cx: &mut Context, ) -> Self { @@ -272,8 +384,12 @@ impl TextFinder { picker.update(cx, |picker, cx| { picker.delegate.focus_handle = picker_focus_handle.clone(); picker.delegate.hook_up_any_ongoing_search(picker_weak, cx); - if let Some(seed_query) = seed_query.as_deref() { - picker.set_query(seed_query, window, cx); + if let Some(seed_query) = seed_query { + // Restore filters before seeding the query so the initial search runs with them. + if let Some(options) = seed_query.options { + picker.delegate.search_options = options; + } + picker.set_query(&seed_query.query, window, cx); picker.select_query(window, cx); } }); @@ -310,8 +426,18 @@ impl ModalView for TextFinder { fn on_before_dismiss( &mut self, _window: &mut Window, - _cx: &mut Context, + cx: &mut Context, ) -> DismissDecision { + let picker = self.picker.read(cx); + let query = picker.query(cx); + if !query.is_empty() { + let options = picker.delegate.search_options; + let workspace_id = self + .weak_workspace(cx) + .upgrade() + .and_then(|workspace| workspace.read(cx).database_id()); + store_last_search(workspace_id, query, options, cx); + } DismissDecision::Dismiss(true) } }