Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T06:24:00.619Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

text_finder.rs

573 lines · 20.0 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.