Skip to repository content573 lines · 20.0 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T06:24:00.619Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
text_finder.rs
1use std::{ops::Range, sync::atomic::Ordering};
2
3use db::{
4 query,
5 sqlez::{domain::Domain, thread_safe_connection::ThreadSafeConnection},
6 sqlez_macros::sql,
7};
8use gpui::{
9 App, AppContext, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
10 Modifiers, Subscription, Task, WeakEntity, actions,
11};
12use language::Buffer;
13use picker::Picker;
14
15use project::ProjectPath;
16use settings::SeedQuerySetting;
17use text::Anchor;
18use ui::Window;
19use workspace::{DismissDecision, ItemHandle, ModalView, Workspace, WorkspaceDb, WorkspaceId};
20
21mod delegate;
22mod render;
23use delegate::{Delegate, matches_to_multibuffer};
24use util::ResultExt as _;
25
26use crate::{ProjectSearchView, SearchOptions, text_finder::delegate::PopulateProjectSearch};
27
28actions!(text_finder, [ToProjectSearch, Fold, Unfold, ToggleFoldAll]);
29
30pub struct TextFinder {
31 picker: Entity<Picker<Delegate>>,
32 init_modifiers: Option<Modifiers>,
33 workspace_id: Option<WorkspaceId>,
34 _subscription: Subscription,
35}
36
37/// Persists the query and active filters of the just-closed Text Finder in the per-project
38/// database, keyed by workspace so the search is restored only for the project it was run in
39/// (mirrors JetBrains' per-project find history). The row is removed automatically when its
40/// workspace is deleted, via the `ON DELETE CASCADE` foreign key. Workspaces without a database
41/// id (not yet persisted) don't participate.
42pub struct TextFinderDb(ThreadSafeConnection);
43
44impl Domain for TextFinderDb {
45 const NAME: &str = stringify!(TextFinderDb);
46
47 const MIGRATIONS: &[&str] = &[sql!(
48 CREATE TABLE text_finder_queries (
49 workspace_id INTEGER PRIMARY KEY,
50 query TEXT NOT NULL,
51 search_options INTEGER NOT NULL DEFAULT 0,
52 FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id)
53 ON DELETE CASCADE
54 ) STRICT;
55 )];
56}
57
58db::static_connection!(TextFinderDb, [WorkspaceDb]);
59
60impl TextFinderDb {
61 query! {
62 pub async fn set_last_search(workspace_id: WorkspaceId, query: String, search_options: i64) -> Result<()> {
63 INSERT INTO text_finder_queries (workspace_id, query, search_options)
64 VALUES (?1, ?2, ?3)
65 ON CONFLICT(workspace_id) DO UPDATE SET query = ?2, search_options = ?3
66 }
67 }
68
69 query! {
70 pub fn last_search(workspace_id: WorkspaceId) -> Result<Option<(String, i64)>> {
71 SELECT query, search_options
72 FROM text_finder_queries
73 WHERE workspace_id = ?1
74 }
75 }
76}
77
78/// A query to pre-populate the Text Finder with, plus the search filters to restore alongside
79/// it. `options` carries the workspace's last-used filters when there are any persisted; it is
80/// `None` only the first time the finder is used in a workspace, leaving the filters at their
81/// setting-derived defaults.
82pub(crate) struct SearchSeed {
83 query: String,
84 options: Option<SearchOptions>,
85}
86
87fn store_last_search(
88 workspace_id: Option<WorkspaceId>,
89 query: String,
90 options: SearchOptions,
91 cx: &App,
92) {
93 let Some(workspace_id) = workspace_id else {
94 return;
95 };
96 let db = TextFinderDb::global(cx);
97 let search_options = options.bits() as i64;
98 db::write_and_log(cx, move || async move {
99 db.set_last_search(workspace_id, query, search_options)
100 .await
101 });
102}
103
104fn load_last_search(workspace_id: Option<WorkspaceId>, cx: &App) -> Option<SearchSeed> {
105 let (query, search_options) = TextFinderDb::global(cx)
106 .last_search(workspace_id?)
107 .log_err()
108 .flatten()?;
109 if query.is_empty() {
110 return None;
111 }
112 Some(SearchSeed {
113 query,
114 options: Some(SearchOptions::from_bits_truncate(search_options as u8)),
115 })
116}
117
118pub fn init(cx: &mut App) {
119 cx.observe_new(TextFinder::register).detach();
120}
121
122impl TextFinder {
123 fn register(
124 workspace: &mut Workspace,
125 _window: Option<&mut Window>,
126 _: &mut Context<Workspace>,
127 ) {
128 pub use zed_actions::text_finder::Toggle;
129 workspace.register_action(|workspace, _: &Toggle, window, cx| {
130 let Some(text_picker) = workspace.active_modal::<Self>(cx) else {
131 let seed_query = Self::seed_query(workspace, window, cx);
132 Self::open(seed_query, window, cx).detach();
133 return;
134 };
135
136 text_picker.update(cx, |text_picker, cx| {
137 text_picker.init_modifiers = Some(window.modifiers());
138 text_picker.picker.update(cx, |picker, cx| {
139 picker.cycle_selection(window, cx);
140 });
141 })
142 });
143 }
144
145 pub fn open_from_project_search<T: 'static>(
146 project_search_view: Entity<ProjectSearchView>,
147 window: &mut Window,
148 cx: &mut Context<T>,
149 ) -> Task<()> {
150 let project_search_item_id = project_search_view.entity_id();
151 cx.spawn_in(window, async move |_, cx| {
152 let workspace =
153 project_search_view.read_with(cx, |view, _| WeakEntity::clone(&view.workspace));
154 let delegate = Delegate::new_from_project_search(project_search_view, cx).await;
155 workspace
156 .update_in(cx, |workspace, window, cx| {
157 remove_project_search_tab(project_search_item_id, workspace, window, cx);
158 let workspace_id = workspace.database_id();
159 workspace.toggle_modal(window, cx, |window, cx| {
160 Self::new(delegate, None, workspace_id, window, cx)
161 });
162 })
163 .ok();
164 })
165 }
166
167 /// Transition this text finder into a project search tab, carrying over the
168 /// current results (and any in-progress search stream) instead of re-running
169 /// the search. Inverse of [`Self::open_from_project_search`].
170 fn to_project_search(
171 &mut self,
172 _: &ToProjectSearch,
173 window: &mut Window,
174 cx: &mut Context<Self>,
175 ) {
176 let picker = Entity::clone(&self.picker);
177 let workspace = self.weak_workspace(cx);
178
179 let connected_task = self.take_search_task(cx);
180 let project_search_view = self.project_search_view(cx);
181 let query = picker.read(cx).delegate.active_query.clone();
182 let search_options = picker.read(cx).delegate.search_options;
183
184 cx.spawn_in(window, async move |this, cx| {
185 let search_stream = connected_task.unwrap_or(gpui::Task::ready(None)).await;
186 let matches =
187 picker.update(cx, |picker, _| std::mem::take(&mut picker.delegate.matches));
188
189 project_search_view
190 .update_in(cx, |view, window, cx| {
191 view.adopt_text_finder_state(search_options, query, window, cx);
192 })
193 .log_err();
194
195 this.update(cx, |_, cx| cx.emit(DismissEvent)).log_err();
196 workspace
197 .update_in(cx, |workspace, window, cx| {
198 workspace.add_item_to_active_pane(
199 Box::new(project_search_view.clone()),
200 None,
201 true, // focus item
202 window,
203 cx,
204 );
205 })
206 .log_err();
207
208 if let PopulateProjectSearch::SupersededByNewSearch =
209 matches_to_multibuffer(&project_search_view, &matches, cx).await
210 {
211 return;
212 }
213
214 if let Some(stream) = search_stream {
215 project_search_view.update(cx, |view, cx| {
216 view.entity
217 .update(cx, |search, cx| search.hook_up_ongoing_search(stream, cx));
218 });
219 }
220 })
221 .detach();
222 }
223
224 fn split_left(
225 &mut self,
226 _: &workspace::pane::SplitLeft,
227 window: &mut Window,
228 cx: &mut Context<Self>,
229 ) {
230 self.open_in_split(workspace::SplitDirection::Left, window, cx);
231 }
232
233 fn split_right(
234 &mut self,
235 _: &workspace::pane::SplitRight,
236 window: &mut Window,
237 cx: &mut Context<Self>,
238 ) {
239 self.open_in_split(workspace::SplitDirection::Right, window, cx);
240 }
241
242 fn split_up(
243 &mut self,
244 _: &workspace::pane::SplitUp,
245 window: &mut Window,
246 cx: &mut Context<Self>,
247 ) {
248 self.open_in_split(workspace::SplitDirection::Up, window, cx);
249 }
250
251 fn split_down(
252 &mut self,
253 _: &workspace::pane::SplitDown,
254 window: &mut Window,
255 cx: &mut Context<Self>,
256 ) {
257 self.open_in_split(workspace::SplitDirection::Down, window, cx);
258 }
259
260 fn fold(&mut self, _: &Fold, _window: &mut Window, cx: &mut Context<Self>) {
261 self.picker.update(cx, |picker, cx| {
262 picker.delegate.set_selected_group_collapsed(true, cx);
263 });
264 }
265
266 fn unfold(&mut self, _: &Unfold, _window: &mut Window, cx: &mut Context<Self>) {
267 self.picker.update(cx, |picker, cx| {
268 picker.delegate.set_selected_group_collapsed(false, cx);
269 });
270 }
271
272 fn toggle_fold_all(&mut self, _: &ToggleFoldAll, _window: &mut Window, cx: &mut Context<Self>) {
273 self.picker.update(cx, |picker, cx| {
274 picker.delegate.toggle_all_collapsed(cx);
275 });
276 }
277
278 fn open_in_split(
279 &mut self,
280 direction: workspace::SplitDirection,
281 window: &mut Window,
282 cx: &mut Context<Self>,
283 ) {
284 self.picker.update(cx, |picker, cx| {
285 picker.delegate.open_in_split(direction, window, cx);
286 });
287 }
288
289 fn weak_workspace(&self, cx: &App) -> WeakEntity<Workspace> {
290 let workspace = WeakEntity::clone(
291 &self
292 .picker
293 .read(cx)
294 .delegate
295 .project_search_view
296 .read(cx)
297 .workspace,
298 );
299 workspace
300 }
301
302 fn take_search_task(
303 &self,
304 cx: &mut App,
305 ) -> Option<Task<Option<project::SearchResults<project::search::SearchResult>>>> {
306 self.picker
307 .read(cx)
308 .delegate
309 .text_finder_turning_into_project_search
310 .store(true, Ordering::Relaxed);
311 self.picker
312 .update(cx, |p, _| p.delegate.in_progress_search.take_connected())
313 }
314
315 /// Guess the query the user probably wants for pre-populating the search input.
316 fn seed_query(
317 workspace: &mut Workspace,
318 window: &mut Window,
319 cx: &mut Context<Workspace>,
320 ) -> Option<SearchSeed> {
321 let last_search = load_last_search(workspace.database_id(), cx);
322 let options = last_search.as_ref().and_then(|seed| seed.options);
323
324 let query = Self::active_item_query(workspace, window, cx)
325 .or_else(|| last_search.map(|seed| seed.query))?;
326
327 Some(SearchSeed { query, options })
328 }
329
330 /// The query to seed from the focused or active item, if any.
331 ///
332 /// The focused pane's item is consulted before the active center pane's, so invoking the
333 /// finder from a dock (e.g. with a selection in the terminal) seeds from that item even when
334 /// an editor with its own selection is active in the center — the selection the user made
335 /// last is the one next to the focus. When the focused item has nothing to offer (say, a
336 /// focused terminal without a selection), the center item is tried so an editor selection
337 /// still seeds.
338 fn active_item_query(
339 workspace: &mut Workspace,
340 window: &mut Window,
341 cx: &mut Context<Workspace>,
342 ) -> Option<String> {
343 let focused_item = workspace.focused_pane(window, cx).read(cx).active_item();
344 let active_item = workspace
345 .active_item(cx)
346 .filter(|active| match &focused_item {
347 Some(focused) => focused.item_id() != active.item_id(),
348 None => true,
349 });
350
351 focused_item
352 .into_iter()
353 .chain(active_item)
354 .find_map(|item| Self::item_query(workspace, item.as_ref(), window, cx))
355 }
356
357 /// The query to seed from one item, if any.
358 ///
359 /// Only an explicit selection seeds from the item; the bare word under the cursor is
360 /// ignored. Confirming a match jumps to (and places the cursor on) it, so seeding from the
361 /// cursor on reopen would clobber the search you were in the middle of, whereas a deliberate
362 /// selection (e.g. a double-click) is a clear signal to search for that text.
363 fn item_query(
364 workspace: &mut Workspace,
365 item: &dyn ItemHandle,
366 window: &mut Window,
367 cx: &mut Context<Workspace>,
368 ) -> Option<String> {
369 if let Some(project_search) = item.downcast::<ProjectSearchView>() {
370 let query = project_search.read(cx).search_query_text(cx);
371 if !query.is_empty() {
372 return Some(query);
373 }
374 }
375
376 if let Some(query) = crate::project_search::buffer_search_query(workspace, item, cx) {
377 return Some(query);
378 }
379
380 if let Some(searchable_item) = item.to_searchable_item_handle(cx) {
381 let query =
382 searchable_item.query_suggestion(Some(SeedQuerySetting::Selection), window, cx);
383 if !query.is_empty() {
384 return Some(query);
385 }
386 }
387
388 None
389 }
390
391 pub(crate) fn open(
392 seed_query: Option<SearchSeed>,
393 window: &mut Window,
394 cx: &mut Context<Workspace>,
395 ) -> Task<()> {
396 cx.spawn_in(window, async move |workspace, cx| {
397 let Ok(delegate_task) = workspace.update_in(cx, |workspace, window, cx| {
398 Delegate::new(workspace, window, cx)
399 }) else {
400 return;
401 };
402
403 let delegate = delegate_task.await;
404 workspace
405 .update_in(cx, |workspace, window, cx| {
406 let workspace_id = workspace.database_id();
407 workspace.toggle_modal(window, cx, |window, cx| {
408 Self::new(delegate, seed_query, workspace_id, window, cx)
409 });
410 })
411 .ok();
412 })
413 }
414
415 fn new(
416 delegate: Delegate,
417 seed_query: Option<SearchSeed>,
418 workspace_id: Option<WorkspaceId>,
419 window: &mut Window,
420 cx: &mut Context<Self>,
421 ) -> Self {
422 let project = delegate.project(cx).clone();
423 let preview = picker_preview::editor_preview(project, window, cx);
424 let picker = cx.new(|cx| Picker::list_with_preview(delegate, preview, window, cx));
425 let picker_weak = picker.downgrade();
426 let picker_focus_handle = picker.focus_handle(cx);
427 picker.update(cx, |picker, cx| {
428 picker.delegate.focus_handle = picker_focus_handle.clone();
429 picker.delegate.hook_up_any_ongoing_search(picker_weak, cx);
430 if let Some(seed_query) = seed_query {
431 // Restore filters before seeding the query so the initial search runs with them.
432 if let Some(options) = seed_query.options {
433 picker.delegate.search_options = options;
434 }
435 picker.set_query(&seed_query.query, window, cx);
436 picker.select_query(window, cx);
437 }
438 });
439 let subscription = cx.subscribe(&picker, |_, _, _: &DismissEvent, cx| {
440 cx.emit(DismissEvent);
441 });
442
443 Self {
444 picker,
445 init_modifiers: window.modifiers().modified().then_some(window.modifiers()),
446 workspace_id,
447 _subscription: subscription,
448 }
449 }
450
451 fn project_search_view(&self, cx: &mut App) -> Entity<ProjectSearchView> {
452 Entity::clone(&self.picker.read(cx).delegate.project_search_view)
453 }
454}
455
456fn remove_project_search_tab(
457 project_search_item_id: gpui::EntityId,
458 workspace: &mut Workspace,
459 window: &mut Window,
460 cx: &mut Context<'_, Workspace>,
461) {
462 if let Some(pane) = workspace.pane_for_item_id(project_search_item_id) {
463 pane.update(cx, |pane, cx| {
464 pane.remove_item(project_search_item_id, false, false, window, cx);
465 });
466 }
467}
468
469impl ModalView for TextFinder {
470 fn on_before_dismiss(
471 &mut self,
472 _window: &mut Window,
473 cx: &mut Context<Self>,
474 ) -> DismissDecision {
475 let picker = self.picker.read(cx);
476 let query = picker.query(cx);
477 if !query.is_empty() {
478 let options = picker.delegate.search_options;
479 store_last_search(self.workspace_id, query, options, cx);
480 }
481 DismissDecision::Dismiss(true)
482 }
483}
484
485impl EventEmitter<DismissEvent> for TextFinder {}
486
487impl Focusable for TextFinder {
488 fn focus_handle(&self, cx: &App) -> FocusHandle {
489 self.picker.read(cx).focus_handle(cx)
490 }
491}
492
493#[derive(Clone)]
494pub struct SearchMatch {
495 pub path: ProjectPath,
496 pub buffer: Entity<Buffer>,
497 pub anchor_range: Range<Anchor>,
498 pub range: Range<usize>,
499 pub match_start_byte_column: u32,
500 pub line_number: u32,
501}
502
503#[cfg(test)]
504mod tests {
505 use gpui::{TestAppContext, VisualTestContext};
506 use project::{FakeFs, Project};
507 use serde_json::json;
508 use settings::SettingsStore;
509 use util::path;
510 use workspace::MultiWorkspace;
511
512 use super::*;
513
514 fn init_test(cx: &mut TestAppContext) {
515 cx.update(|cx| {
516 let settings = SettingsStore::test(cx);
517 cx.set_global(settings);
518
519 theme_settings::init(theme::LoadThemes::JustBase, cx);
520
521 editor::init(cx);
522 crate::init(cx);
523 });
524 }
525
526 /// Dismissal can be initiated from inside a workspace update: workspace-level
527 /// action handlers (e.g. buffer search's `SearchActionsRegistrar`) call
528 /// `Workspace::hide_modal` while the workspace entity is leased, which runs
529 /// `on_before_dismiss` synchronously under that lease. Reading the workspace
530 /// entity there panics with "cannot read workspace::Workspace while it is
531 /// already being updated", so this test dismisses the finder the same way.
532 #[gpui::test]
533 async fn test_dismiss_from_within_workspace_update(cx: &mut TestAppContext) {
534 init_test(cx);
535
536 let fs = FakeFs::new(cx.background_executor.clone());
537 fs.insert_tree(path!("/dir"), json!({"one.rs": "const ONE: usize = 1;"}))
538 .await;
539 let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
540 let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project, window, cx));
541 let workspace = window
542 .read_with(cx, |multi_workspace, _| multi_workspace.workspace().clone())
543 .unwrap();
544 let cx = &mut VisualTestContext::from_window(window.into(), cx);
545
546 // Seed a query: the last-search persistence in `on_before_dismiss` (the
547 // code path that read the workspace entity) only runs when the query is
548 // non-empty, which is the common case in practice since the finder seeds
549 // the previous query on open.
550 let seed_query = SearchSeed {
551 query: "ONE".to_string(),
552 options: None,
553 };
554 workspace
555 .update_in(cx, |_, window, cx| {
556 TextFinder::open(Some(seed_query), window, cx)
557 })
558 .await;
559
560 workspace.update(cx, |workspace, cx| {
561 assert!(workspace.active_modal::<TextFinder>(cx).is_some());
562 });
563
564 workspace.update_in(cx, |workspace, window, cx| {
565 assert!(workspace.hide_modal(window, cx));
566 });
567
568 workspace.update(cx, |workspace, cx| {
569 assert!(workspace.active_modal::<TextFinder>(cx).is_none());
570 });
571 }
572}
573