text_finder: Seed last query and filters to make quick edits easier (#59849)

## Summary

It is incremental step to solve issue #59825.

This PR addresses the need for facilitating quick edits for matches
obtained by ‘text_finder’. This makes the text finder remember the last
query, so you can jump to a match, make a quick edit, and reopen the
finder to the same results instead of typing the same query again.


## Problem

A common flow is, open the text editor, then jump to first match, edit
that file, then reopen the finder to continue for next matches. But on
the reopen, the query was seeded from under the cursor. And after
editing, the cursor is usually sitting on an unrelated word, and
previous search was lost. The root cause is priority, the word under the
cursor was seeding the query. 



## Solution

Reorder query seeding so last query outranks the cursor word, then
cursor word can be dropped entirely, since last query is prioritized
over the word under cursor, the last query always wins, so checking the
cursor word afterwards is dead code. And JetBrains makes the same
choice, entirely ignores word on the cursor for seeding query. Explicit
selection still outranks the last query, since selecting text is usually
a deliberate choice.

### Before

1- Active project search query (if any)
2- Active buffer search query (if any)
3- Selected text or word under the cursor
4- Empty



### After
1- Active project search query (if any)
2- Active buffer search query (if any)
3- Selected text
4- Last query (of this project)
5- Empty



With updated order, this friction disappears (the same order is also
observed in JetBrains). To make the last query persistent, it is stored
per project in the database along with the active filters (case
sensitive, whole word, regex), so they also survive reopening the
project.


## Testing

- Manually verified the seed priority order between the options. 
- Verified the last query is seeded when project is reopened.
- Verified filters are restored regardless of this query order.

Release Notes:

- Improved the text finder to seed the last query and filters to make
quick edits easier.

---------

Co-authored-by: ozacod <ozacod@users.noreply.github.com>
Co-authored-by: Yara 🏳️‍⚧️ <git@yara.blue>
This commit is contained in:
authored and GitHub committed 2026-07-01 10:32:30 +00:00
1 parent 914cc66103
commit e4e2c9d3d5
3 files changed
+143 -15

No files matched your search

Generated
+1
View File
@@ -16246,6 +16246,7 @@ dependencies = [
"anyhow",
"bitflags 2.10.0",
"collections",
"db",
"editor",
"file_icons",
"fs",
+1
View File
@@ -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
+141 -15
View File
@@ -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<Option<(String, i64)>> {
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<SearchOptions>,
}
fn store_last_search(
workspace_id: Option<WorkspaceId>,
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<WorkspaceId>, cx: &App) -> Option<SearchSeed> {
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<Workspace>,
) -> Option<SearchSeed> {
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<Workspace>,
) -> Option<String> {
let item = workspace.active_item(cx)?;
@@ -230,13 +337,18 @@ impl TextFinder {
return Some(query);
}
let editor = item.act_as::<Editor>(cx)?;
let query = editor.query_suggestion(None, window, cx);
(!query.is_empty()).then_some(query)
if let Some(editor) = item.act_as::<Editor>(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<String>,
pub(crate) fn open(
seed_query: Option<SearchSeed>,
window: &mut Window,
cx: &mut Context<Workspace>,
) -> Task<()> {
@@ -260,7 +372,7 @@ impl TextFinder {
fn new(
delegate: Delegate,
seed_query: Option<String>,
seed_query: Option<SearchSeed>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Self>,
cx: &mut Context<Self>,
) -> 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)
}
}