Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T05:15:01.618Z 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

delegate.rs

1605 lines · 59.1 KB · rust
1//! The text_finder is a minimal modal interface to the project_search. It is
2//! targeted towards search for exploration. It can also be used as a filter
3//! step to the project_search.
4//!
5//! Basic interaction:
6//!
7//! ```txt
8//! Open text finder --- Open file ---> File tab
9//!
10//!                     (text_finder action)
11//! Open text finder --- ToProjectSearch ---> Project search tab
12//!
13//! Can also have a little loop where the user uses the ProjectSearch filters etc
14//! to refine the search:
15//!
16//!                     (project search tab)
17//!                  (removes tab, opens modal)
18//! Project search tab --- ToTextFinder ---> Text finder modal
19//!                             ^                  |
20//!                             |             ToProjectSearch (adds tab,
21//!                             |                  |          closes modal)
22//!                             |                  V
23//!                             . --------  Project search tab
24//! ```
25use std::ops::ControlFlow;
26use std::sync::Arc;
27use std::sync::atomic::{AtomicBool, Ordering};
28use std::{ops::Range, time::Duration};
29
30use collections::{HashMap, HashSet};
31use editor::{MultiBufferSnapshot, PathKey, multibuffer_context_lines};
32use file_icons::FileIcons;
33use futures::StreamExt;
34use gpui::{
35    AnyElement, AppContext, AsyncApp, ClickEvent, DismissEvent, EntityId, HighlightStyle,
36    Modifiers, StyledText, Task, TextStyle, prelude::*,
37};
38use gpui::{Entity, FocusHandle, WeakEntity};
39use language::{Buffer, LanguageAwareStyling};
40use picker::{Picker, PickerDelegate};
41use project::{Project, ProjectPath, Search};
42use project::{SearchResults, search::SearchQuery, search::SearchResult};
43use settings::Settings;
44use smol::future::yield_now;
45use text::Anchor;
46use theme_settings::ThemeSettings;
47use ui::{
48    Disclosure, Divider, FluentBuilder, ListItem, ListItemSpacing, Toggleable, Tooltip, prelude::*,
49    text_for_keystroke,
50};
51use util::ResultExt;
52use workspace::SplitDirection;
53use workspace::Workspace;
54use workspace::item::ItemSettings;
55use workspace::pane::Pane;
56
57use super::{Fold, SearchMatch, Unfold};
58use crate::project_search::{ActiveSettings, ProjectSearch};
59use crate::{ProjectSearchView, SearchOption, SearchOptions};
60
61pub struct Delegate {
62    pub(crate) project_search_view: Entity<ProjectSearchView>,
63    pub(crate) focus_handle: FocusHandle,
64    /// Flat list of every match, in result order. This is the canonical list
65    /// handed off to the project search; [`Self::entries`] is a grouped view
66    /// derived from it for rendering.
67    pub(crate) matches: Vec<SearchMatch>,
68    /// Display rows derived from [`Self::matches`]: a non-selectable header per
69    /// file, its matches, and separators between groups. Rebuilt via
70    /// [`Delegate::rebuild_entries`] whenever `matches` changes. `selected_index`
71    /// indexes into this list.
72    pub(crate) entries: Vec<Entry>,
73    pub(crate) selected_index: usize,
74    pub(crate) cancel_flag: Arc<AtomicBool>,
75    pub(crate) text_finder_turning_into_project_search: Arc<AtomicBool>,
76    pub(crate) last_selection_change_time: Option<std::time::Instant>,
77    pub(crate) last_click: Option<(usize, std::time::Instant)>,
78    pub(crate) search_options: SearchOptions,
79    /// Kept around for switching to project search
80    pub(crate) active_query: Option<SearchQuery>,
81    pub(crate) imported_from_project_search: bool,
82    /// When `is_ready` there is not a search in progress
83    pub(crate) in_progress_search: InProgressSearch,
84    pub(crate) unique_files: HashSet<ProjectPath>,
85    /// Largest line number across [`Self::matches`], used to size the line-number
86    /// column so every row's number right-aligns to the widest one. Recomputed in
87    /// [`Delegate::rebuild_entries`].
88    pub(crate) max_line_number: u32,
89    pub(crate) selected_matches: Vec<SelectedMatch>,
90    pub(crate) collapsed_paths: HashSet<ProjectPath>,
91}
92
93/// Wrapper with Eq is path + range equality
94#[derive(Clone)]
95pub(crate) struct SelectedMatch(pub SearchMatch);
96
97impl PartialEq for SelectedMatch {
98    fn eq(&self, other: &Self) -> bool {
99        *self == other.0
100    }
101}
102
103impl Eq for SelectedMatch {}
104
105impl PartialEq<SearchMatch> for SelectedMatch {
106    fn eq(&self, other: &SearchMatch) -> bool {
107        self.0.path == other.path && self.0.range == other.range
108    }
109}
110
111pub(crate) enum Entry {
112    Header(ProjectPath),
113    Match(usize),
114    Separator,
115}
116
117async fn get_ongoing_search(
118    project_search_view: &Entity<ProjectSearchView>,
119    cx: &mut AsyncApp,
120) -> Option<SearchResults<SearchResult>> {
121    let ongoing_search = project_search_view.update(cx, |view, cx| {
122        view.entity.update(cx, |search, _| {
123            search.pending_search.take().inspect(|_| {
124                search
125                    .project_search_turning_into_text_finder
126                    .store(true, Ordering::Relaxed);
127            })
128        })
129    })?;
130
131    ongoing_search.await
132}
133
134fn multibuffer_ranges_to_search_matches<'a>(
135    match_ranges: &'a [Range<multi_buffer::Anchor>],
136    multi_buffer: &'a editor::MultiBuffer,
137    snapshot: MultiBufferSnapshot,
138    cx: &'a App,
139) -> impl Iterator<Item = SearchMatch> + 'a {
140    match_ranges.iter().cloned().filter_map(move |mb_range| {
141        let (buffer_snapshot, text_range) =
142            snapshot.anchor_range_to_buffer_anchor_range(mb_range)?;
143
144        let file = buffer_snapshot.file()?;
145        let path = ProjectPath {
146            worktree_id: file.worktree_id(cx),
147            path: Arc::clone(file.path()),
148        };
149        let buffer = multi_buffer.buffer(buffer_snapshot.remote_id())?;
150
151        let start_offset: usize = buffer_snapshot.summary_for_anchor(&text_range.start);
152        let end_offset: usize = buffer_snapshot.summary_for_anchor(&text_range.end);
153        let point = buffer_snapshot.offset_to_point(start_offset);
154
155        Some(SearchMatch {
156            path,
157            buffer,
158            anchor_range: text_range,
159            range: start_offset..end_offset,
160            match_start_byte_column: point.column,
161            line_number: point.row + 1,
162        })
163    })
164}
165
166/// Stream the matches already sitting in the project search's multibuffer into
167/// the picker, a chunk at a time. Inverse of [`matches_to_multibuffer`].
168async fn stream_plunder_to_picker(
169    project_search_view: Entity<ProjectSearchView>,
170    cancel_flag: Arc<AtomicBool>,
171    picker: gpui::WeakEntity<Picker<Delegate>>,
172    cx: &mut AsyncApp,
173) {
174    let chunk_size = 1000;
175    let mut n_read = 0;
176
177    loop {
178        if cancel_flag.load(Ordering::SeqCst) {
179            return; // user cancelled or changed the query
180        }
181
182        let res = picker.update(cx, |picker, cx| {
183            let new_matches: Vec<SearchMatch> = {
184                let ps = project_search_view.read(cx).entity.read(cx);
185                let len = ps.match_ranges.len();
186                if n_read >= len {
187                    return ControlFlow::Break(());
188                }
189                let end = (n_read + chunk_size).min(len);
190                let chunk = &ps.match_ranges[n_read..end];
191                let multi_buffer = ps.excerpts.read(cx);
192                let snapshot = multi_buffer.snapshot(cx);
193                let matches =
194                    multibuffer_ranges_to_search_matches(chunk, multi_buffer, snapshot, cx)
195                        .collect();
196                n_read = end;
197                matches
198            };
199
200            let delegate = &mut picker.delegate;
201            delegate
202                .unique_files
203                .extend(new_matches.iter().map(|m| m.path.clone()));
204            delegate.matches.extend(new_matches);
205            delegate.rebuild_entries();
206            cx.notify();
207            ControlFlow::Continue(())
208        });
209
210        match res {
211            Ok(ControlFlow::Continue(())) => {}
212            Ok(ControlFlow::Break(())) | Err(_) => break,
213        }
214
215        // Critical or the search transformation will hold the background thread for too long
216        yield_now().await;
217    }
218}
219
220pub(crate) enum InProgressSearch {
221    Connected(Task<Option<SearchResults<SearchResult>>>),
222    Disconnected(SearchResults<SearchResult>),
223    None,
224}
225
226impl InProgressSearch {
227    /// If this is in disconnected state set it to None and return the search results
228    fn take_disconnected(&mut self) -> Option<SearchResults<SearchResult>> {
229        if matches!(self, InProgressSearch::Disconnected(_)) {
230            let mut placeholder = InProgressSearch::None;
231            std::mem::swap(self, &mut placeholder);
232            match placeholder {
233                InProgressSearch::Disconnected(results_stream) => return Some(results_stream),
234                _ => unreachable!("guarded with matches! above"),
235            }
236        } else {
237            None
238        }
239    }
240
241    /// If a search is currently streaming into the picker, take its task so it
242    /// can be awaited to recover the underlying result stream.
243    pub(crate) fn take_connected(&mut self) -> Option<Task<Option<SearchResults<SearchResult>>>> {
244        if matches!(self, InProgressSearch::Connected(_)) {
245            match std::mem::replace(self, InProgressSearch::None) {
246                InProgressSearch::Connected(task) => Some(task),
247                _ => unreachable!("guarded with matches! above"),
248            }
249        } else {
250            None
251        }
252    }
253}
254
255impl Delegate {
256    pub fn hook_up_any_ongoing_search(
257        &mut self,
258        picker: gpui::WeakEntity<Picker<Delegate>>,
259        cx: &App,
260    ) {
261        let cancel_flag = Arc::clone(&self.cancel_flag);
262        let text_finder_turning_into_project_search =
263            Arc::clone(&self.text_finder_turning_into_project_search);
264        let project_search_view = self.project_search_view.clone();
265        let ongoing = self.in_progress_search.take_disconnected();
266
267        self.in_progress_search = InProgressSearch::Connected(cx.spawn(async move |cx| {
268            stream_plunder_to_picker(project_search_view, cancel_flag.clone(), picker.clone(), cx)
269                .await;
270
271            if let Some(results_stream) = ongoing {
272                return stream_results_to_picker(
273                    cancel_flag,
274                    text_finder_turning_into_project_search,
275                    picker,
276                    results_stream,
277                    ImportedMatches::Yes,
278                    cx,
279                )
280                .await;
281            }
282            None
283        }));
284    }
285
286    pub fn new_from_project_search(
287        project_search: Entity<ProjectSearchView>,
288        cx: &mut AsyncApp,
289    ) -> Task<Delegate> {
290        cx.spawn(async move |cx| {
291            let ongoing = get_ongoing_search(&project_search, cx).await;
292
293            let in_progress_search = if let Some(results_stream) = ongoing {
294                InProgressSearch::Disconnected(results_stream)
295            } else {
296                InProgressSearch::None
297            };
298
299            let (search_options, active_query, has_existing_matches) =
300                cx.read_entity(&project_search, |ps, cx| {
301                    let entity = ps.entity.read(cx);
302                    (
303                        ps.search_options,
304                        entity.active_query.clone(),
305                        !entity.match_ranges.is_empty(),
306                    )
307                });
308
309            let imported_from_project_search =
310                has_existing_matches || !matches!(in_progress_search, InProgressSearch::None);
311
312            let this = cx.update(move |cx| Self {
313                project_search_view: project_search,
314                focus_handle: cx.focus_handle(),
315                matches: Vec::new(),
316                entries: Vec::new(),
317                selected_index: 0,
318                cancel_flag: Arc::new(AtomicBool::new(false)),
319                text_finder_turning_into_project_search: Arc::new(AtomicBool::new(false)),
320                last_selection_change_time: None,
321                last_click: None,
322                search_options,
323                active_query,
324                imported_from_project_search,
325                in_progress_search,
326                unique_files: HashSet::default(),
327                max_line_number: 0,
328                selected_matches: Vec::new(),
329                collapsed_paths: HashSet::default(),
330            });
331
332            this
333        })
334    }
335
336    pub fn new(
337        workspace: &mut Workspace,
338        window: &mut Window,
339        cx: &mut Context<Workspace>,
340    ) -> Task<Self> {
341        let project = workspace.project().clone();
342        let weak_workspace = workspace.weak_handle();
343        let settings = cx
344            .global::<ActiveSettings>()
345            .0
346            .get(&project.downgrade())
347            .cloned();
348
349        let search = cx.new(|cx| ProjectSearch::new(project, cx));
350        let project_search =
351            cx.new(|cx| ProjectSearchView::new(weak_workspace, search, window, cx, settings));
352        cx.spawn(async move |_, cx| Self::new_from_project_search(project_search, cx).await)
353    }
354
355    pub(crate) fn project<'a>(&self, cx: &'a App) -> &'a Entity<Project> {
356        &self.project_search_view.read(cx).entity.read(cx).project
357    }
358
359    /// Rebuilds the grouped [`Self::entries`] display list from the flat
360    /// [`Self::matches`]. Matches arrive grouped per file (one search result
361    /// per buffer), so consecutive matches share a path; we emit one header per
362    /// group and a separator before every group after the first.
363    ///
364    /// Selection is preserved across rebuilds: if a match was selected it stays
365    /// selected at its new row, otherwise we snap to the first selectable row.
366    pub(crate) fn rebuild_entries(&mut self) {
367        let previously_selected_match = match self.entries.get(self.selected_index) {
368            Some(Entry::Match(match_index)) => Some(*match_index),
369            _ => None,
370        };
371
372        let mut entries = Vec::with_capacity(self.matches.len());
373        let mut last_path: Option<&ProjectPath> = None;
374        for (match_index, search_match) in self.matches.iter().enumerate() {
375            if last_path != Some(&search_match.path) {
376                if last_path.is_some() {
377                    entries.push(Entry::Separator);
378                }
379                entries.push(Entry::Header(search_match.path.clone()));
380                last_path = Some(&search_match.path);
381            }
382            if !self.collapsed_paths.contains(&search_match.path) {
383                entries.push(Entry::Match(match_index));
384            }
385        }
386        self.entries = entries;
387        self.max_line_number = self
388            .matches
389            .iter()
390            .map(|search_match| search_match.line_number)
391            .max()
392            .unwrap_or(0);
393
394        self.selected_index = previously_selected_match
395            .and_then(|match_index| {
396                self.entries
397                    .iter()
398                    .position(|entry| matches!(entry, Entry::Match(other) if *other == match_index))
399            })
400            .or_else(|| self.first_selectable_index())
401            .unwrap_or(0);
402    }
403
404    fn first_selectable_index(&self) -> Option<usize> {
405        self.entries
406            .iter()
407            .position(|entry| matches!(entry, Entry::Match(_)))
408    }
409
410    pub(crate) fn toggle_group_collapsed(&mut self, path: &ProjectPath) {
411        if !self.collapsed_paths.remove(path) {
412            self.collapsed_paths.insert(path.clone());
413        }
414        self.rebuild_entries();
415    }
416
417    pub(crate) fn set_selected_group_collapsed(
418        &mut self,
419        collapsed: bool,
420        cx: &mut Context<Picker<Self>>,
421    ) {
422        let path = match self.entries.get(self.selected_index) {
423            Some(Entry::Match(match_index)) => self
424                .matches
425                .get(*match_index)
426                .map(|search_match| search_match.path.clone()),
427            Some(Entry::Header(path)) => Some(path.clone()),
428            Some(Entry::Separator) | None => None,
429        };
430        let Some(path) = path else {
431            return;
432        };
433        if collapsed == self.collapsed_paths.contains(&path) {
434            return;
435        }
436
437        self.toggle_group_collapsed(&path);
438
439        if let Some(index) = self.entries.iter().position(|entry| match entry {
440            Entry::Header(header_path) => collapsed && *header_path == path,
441            Entry::Match(match_index) => {
442                !collapsed
443                    && self
444                        .matches
445                        .get(*match_index)
446                        .is_some_and(|search_match| search_match.path == path)
447            }
448            Entry::Separator => false,
449        }) {
450            self.selected_index = index;
451        }
452        cx.notify();
453    }
454
455    pub(crate) fn toggle_all_collapsed(&mut self, cx: &mut Context<Picker<Self>>) {
456        if self.collapsed_paths.is_empty() {
457            self.collapsed_paths = self
458                .matches
459                .iter()
460                .map(|search_match| search_match.path.clone())
461                .collect();
462        } else {
463            self.collapsed_paths.clear();
464        }
465        self.rebuild_entries();
466        cx.notify();
467    }
468
469    fn selected_search_match(&self) -> Option<&SearchMatch> {
470        self.search_match_for_entry(self.selected_index)
471    }
472
473    fn search_match_for_entry(&self, ix: usize) -> Option<&SearchMatch> {
474        match self.entries.get(ix)? {
475            Entry::Match(match_index) => self.matches.get(*match_index),
476            Entry::Header(_) | Entry::Separator => None,
477        }
478    }
479
480    pub(crate) fn prepend_selected_matches(&mut self) {
481        if self.selected_matches.is_empty() {
482            return;
483        }
484        self.matches
485            .retain(|m| !self.selected_matches.iter().any(|selected| selected == m));
486        let mut matches: Vec<SearchMatch> = self
487            .selected_matches
488            .iter()
489            .map(|selected| selected.0.clone())
490            .collect();
491        self.unique_files
492            .extend(matches.iter().map(|m| m.path.clone()));
493        matches.append(&mut self.matches);
494        self.matches = matches;
495    }
496
497    fn open_match(
498        &self,
499        search_match: &SearchMatch,
500        pane: Option<WeakEntity<Pane>>,
501        focus: bool,
502        window: &mut Window,
503        cx: &mut Context<Picker<Self>>,
504    ) {
505        let path = search_match.path.clone();
506        let row = search_match.line_number.saturating_sub(1);
507        let column = search_match.match_start_byte_column;
508        let Some(workspace) = self.project_search_view.read(cx).workspace.upgrade() else {
509            return;
510        };
511        let open_task = workspace.update(cx, |workspace, cx| {
512            workspace.open_path_preview(path, pane, focus, false, true, window, cx)
513        });
514        cx.spawn_in(window, async move |_, cx| {
515            let item = open_task.await.log_err()?;
516            if let Some(active_editor) = item.downcast::<editor::Editor>() {
517                active_editor
518                    .downgrade()
519                    .update_in(cx, |editor, window, cx| {
520                        editor.go_to_singleton_buffer_point(
521                            text::Point::new(row, column),
522                            window,
523                            cx,
524                        );
525                    })
526                    .log_err();
527            }
528            Some(())
529        })
530        .detach();
531    }
532
533    /// Opens the selected match in a new split in `direction`, then dismisses.
534    /// With a multi-selection, every selected match opens as a tab in a
535    /// single new split.
536    pub(crate) fn open_in_split(
537        &mut self,
538        direction: SplitDirection,
539        window: &mut Window,
540        cx: &mut Context<Picker<Self>>,
541    ) {
542        if !self.selected_matches.is_empty() {
543            let Some(workspace) = self.project_search_view.read(cx).workspace.upgrade() else {
544                return;
545            };
546            let selected = std::mem::take(&mut self.selected_matches);
547            let new_pane = workspace.update(cx, |workspace, cx| {
548                workspace.split_pane(workspace.active_pane().clone(), direction, window, cx)
549            });
550            let count = selected.len();
551            for (i, selected_match) in selected.iter().enumerate() {
552                let focus = i + 1 == count;
553                self.open_match(
554                    &selected_match.0,
555                    Some(new_pane.downgrade()),
556                    focus,
557                    window,
558                    cx,
559                );
560            }
561            cx.emit(DismissEvent);
562            return;
563        }
564        let Some(selected_match) = self.selected_search_match() else {
565            return;
566        };
567        let path = selected_match.path.clone();
568        let line_number = selected_match.line_number;
569        let column = selected_match.match_start_byte_column;
570        let Some(workspace) = self.project_search_view.read(cx).workspace.upgrade() else {
571            return;
572        };
573        let open_task = workspace.update(cx, |workspace, cx| {
574            workspace.split_path_preview(path, false, Some(direction), window, cx)
575        });
576        let row = line_number.saturating_sub(1);
577        cx.spawn_in(window, async move |_, cx| {
578            let item = open_task.await.log_err()?;
579            if let Some(active_editor) = item.downcast::<editor::Editor>() {
580                active_editor
581                    .downgrade()
582                    .update_in(cx, |editor, window, cx| {
583                        editor.go_to_singleton_buffer_point(
584                            text::Point::new(row, column),
585                            window,
586                            cx,
587                        );
588                    })
589                    .log_err();
590            }
591            Some(())
592        })
593        .detach();
594        cx.emit(DismissEvent);
595    }
596}
597
598pub(crate) enum PopulateProjectSearch {
599    Completed,
600    SupersededByNewSearch,
601}
602
603/// Convert the picker's list of matches into multibuffer. Inverse of
604/// [`plunder_multibuffer`].
605pub(crate) async fn matches_to_multibuffer(
606    project_search_view: &Entity<ProjectSearchView>,
607    matches: &[SearchMatch],
608    cx: &mut AsyncApp,
609) -> PopulateProjectSearch {
610    let mut buffer_order_in_text_finder: Vec<EntityId> = Vec::new();
611    let mut by_buffer: HashMap<_, (_, Vec<_>)> = HashMap::default();
612
613    for m in matches {
614        let buffer = Entity::clone(&m.buffer);
615        by_buffer
616            .entry(buffer.entity_id())
617            .and_modify(|(_, ranges)| ranges.push(m.anchor_range.clone()))
618            .or_insert_with(|| {
619                buffer_order_in_text_finder.push(buffer.entity_id());
620                (buffer, vec![m.anchor_range.clone()])
621            });
622    }
623
624    let excerpts =
625        project_search_view.read_with(cx, |view, cx| view.entity.read(cx).excerpts.clone());
626    excerpts.update(cx, |excerpts, cx| excerpts.clear(cx));
627
628    // Every await point is a place where the user could type a search
629    // query in which case we gotta abort. Store the search id so we
630    // can check if that happened.
631    let search_id = project_search_view.update(cx, |view, cx| {
632        view.entity.update(cx, |search, _| {
633            search.match_ranges.clear();
634            search.search_id
635        })
636    });
637
638    let context_lines = cx.update(|cx| multibuffer_context_lines(cx));
639
640    let still_current = |cx: &mut AsyncApp| {
641        project_search_view.update(cx, |view, cx| view.entity.read(cx).search_id == search_id)
642    };
643
644    let mut excerpts_added = 0;
645    for buffer_id in buffer_order_in_text_finder {
646        if !still_current(cx) {
647            return PopulateProjectSearch::SupersededByNewSearch;
648        }
649        let (buffer, ranges) = by_buffer.remove(&buffer_id).expect("just put them in");
650        excerpts_added += ranges.len();
651        let new_ranges = excerpts
652            .update(cx, |excerpts, cx| {
653                excerpts.set_anchored_excerpts_for_path(
654                    PathKey::for_buffer(&buffer, cx),
655                    buffer,
656                    ranges,
657                    context_lines,
658                    cx,
659                )
660            })
661            .await;
662
663        if !still_current(cx) {
664            return PopulateProjectSearch::SupersededByNewSearch;
665        }
666        project_search_view.update(cx, |view, cx| {
667            view.entity.update(cx, |search, cx| {
668                search.match_ranges.extend(new_ranges);
669                cx.notify();
670            })
671        });
672
673        // Adding items to the multibuffer can take time. Be sure to not hold
674        // the foreground hostage.
675        if excerpts_added > 100 {
676            yield_now().await;
677            excerpts_added = 0;
678        }
679    }
680    PopulateProjectSearch::Completed
681}
682
683const SEARCH_DEBOUNCE_MS: u64 = 100;
684const CLICK_THRESHOLD_MS: u128 = 50;
685const DOUBLE_CLICK_THRESHOLD_MS: u128 = 300;
686const SEARCH_RESULTS_BATCH_SIZE: usize = 256;
687const MAX_MATCH_CONTEXT_BYTES: usize = 512;
688
689impl PickerDelegate for Delegate {
690    type ListItem = AnyElement;
691
692    fn name() -> &'static str {
693        "text finder"
694    }
695
696    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
697        "Search all files…".into()
698    }
699
700    fn searchbar_trailer(
701        &self,
702        _window: &mut Window,
703        cx: &mut Context<Picker<Self>>,
704    ) -> Option<AnyElement> {
705        let active = self.search_options;
706        let focus_handle = self.focus_handle.clone();
707        let picker = cx.entity();
708
709        let filter_buttons = [
710            SearchOption::CaseSensitive,
711            SearchOption::WholeWord,
712            SearchOption::Regex,
713            SearchOption::IncludeIgnored,
714        ]
715        .into_iter()
716        .map(|option| {
717            let options = option.as_options();
718            let action = option.to_toggle_action();
719            let label = option.label();
720            let focus_handle = focus_handle.clone();
721            let picker = picker.clone();
722
723            IconButton::new(
724                ("text-finder-search-option", option as usize),
725                option.icon(),
726            )
727            .icon_size(IconSize::Small)
728            .toggle_state(active.contains(options))
729            .tooltip(move |_window, cx| Tooltip::for_action_in(label, action, &focus_handle, cx))
730            .on_click(move |_, window, cx| {
731                picker.update(cx, |picker, cx| {
732                    picker.delegate.search_options.toggle(options);
733                    picker.refresh(window, cx);
734                });
735            })
736        });
737
738        Some(
739            h_flex()
740                .gap_px()
741                .children(filter_buttons)
742                .child(Divider::vertical().ml_px().mr_0p5())
743                .children(picker::parts::project_scan_indicator(
744                    self.active_query.is_some(),
745                    self.project(cx),
746                    cx,
747                ))
748                .into_any_element(),
749        )
750    }
751
752    fn actions_menu(
753        &self,
754        _window: &mut Window,
755        _cx: &mut Context<Picker<Self>>,
756    ) -> Vec<picker::PickerAction> {
757        use gpui::Action as _;
758        vec![
759            picker::PickerAction::header("Split…"),
760            picker::PickerAction::button(
761                "Left",
762                workspace::pane::SplitLeft::default().boxed_clone(),
763            ),
764            picker::PickerAction::button(
765                "Right",
766                workspace::pane::SplitRight::default().boxed_clone(),
767            ),
768            picker::PickerAction::button("Up", workspace::pane::SplitUp::default().boxed_clone()),
769            picker::PickerAction::button(
770                "Down",
771                workspace::pane::SplitDown::default().boxed_clone(),
772            ),
773            picker::PickerAction::separator(),
774            picker::PickerAction::button(
775                if self.selected_matches.len() > 1 {
776                    "Open Multiple"
777                } else {
778                    "Open File"
779                },
780                menu::Confirm.boxed_clone(),
781            ),
782            picker::PickerAction::button("Open as Tab", super::ToProjectSearch.boxed_clone()),
783        ]
784    }
785
786    fn match_count(&self) -> usize {
787        self.entries.len()
788    }
789
790    fn can_select(&self, ix: usize, _window: &mut Window, _cx: &mut Context<Picker<Self>>) -> bool {
791        match self.entries.get(ix) {
792            Some(Entry::Match(_)) => true,
793            Some(Entry::Header(path)) => self.collapsed_paths.contains(path),
794            Some(Entry::Separator) | None => false,
795        }
796    }
797
798    fn selected_index(&self) -> usize {
799        self.selected_index
800    }
801
802    fn select_on_hover(&self) -> bool {
803        false
804    }
805
806    fn set_selected_index(
807        &mut self,
808        ix: usize,
809        _window: &mut Window,
810        _cx: &mut Context<Picker<Self>>,
811    ) {
812        self.selected_index = ix;
813        self.last_selection_change_time = Some(std::time::Instant::now());
814    }
815
816    fn update_matches(
817        &mut self,
818        query: String,
819        window: &mut Window,
820        cx: &mut Context<Picker<Self>>,
821    ) -> Task<()> {
822        self.cancel_flag
823            .store(true, std::sync::atomic::Ordering::SeqCst);
824        self.cancel_flag = Arc::new(AtomicBool::new(false));
825
826        let cancel_flag = Arc::clone(&self.cancel_flag);
827        let text_finder_turning_into_project_search =
828            Arc::clone(&self.text_finder_turning_into_project_search);
829
830        // The picker runs `update_matches("")` once on open. When the text
831        // finder was opened from an existing project search, the query editor is
832        // empty but we have already plundered that search's matches. Preserve
833        // them on that first call, otherwise the modal would show up empty.
834        let imported_from_project_search = std::mem::take(&mut self.imported_from_project_search);
835
836        let Some(search_query) = self.build_search_query(&query, cx) else {
837            if query.is_empty() && imported_from_project_search {
838                return Task::ready(());
839            }
840            self.matches.clear();
841            self.entries.clear();
842            self.unique_files.clear();
843            self.collapsed_paths.clear();
844            self.selected_index = 0;
845            self.active_query = None;
846            self.prepend_selected_matches();
847            self.rebuild_entries();
848            cx.notify();
849            return Task::ready(());
850        };
851
852        // Remember the exact query we are running so that a later switch to the
853        // project search hands over a query consistent with the results.
854        self.active_query = Some(search_query.clone());
855
856        let search_results = self.project_search_view.update(cx, |ps, cx| {
857            ps.entity.update(cx, |pr, cx| {
858                pr.project.update(cx, |p, cx| p.search(search_query, cx))
859            })
860        });
861
862        let (signal_done, match_updating_done) = futures::channel::oneshot::channel();
863        self.in_progress_search =
864            InProgressSearch::Connected(cx.spawn_in(window, async move |picker, cx| {
865                cx.background_executor()
866                    .timer(Duration::from_millis(SEARCH_DEBOUNCE_MS))
867                    .await;
868
869                if cancel_flag.load(std::sync::atomic::Ordering::SeqCst) {
870                    return None;
871                }
872
873                let res = stream_results_to_picker(
874                    cancel_flag,
875                    text_finder_turning_into_project_search,
876                    picker,
877                    search_results,
878                    ImportedMatches::No,
879                    cx,
880                )
881                .await;
882
883                // We must own the search task so we can take out the search
884                // result stream in case we are transforming into project
885                // search. The picker relies on the task returned
886                // `PickerDelegate::update_matches` to detect when we are done
887                // updating. So we have a placeholder task that completes when
888                // this signal is send.
889                let _ = signal_done.send(());
890                res
891            }));
892
893        cx.notify();
894        cx.spawn(async move |_, _| {
895            let _ = match_updating_done.await;
896        })
897    }
898
899    fn confirm(&mut self, _: bool, window: &mut Window, cx: &mut Context<Picker<Self>>) {
900        // Clicks (set_selected_index called immediately before confirm) require double-click.
901        // Enter key proceeds immediately.
902        let now = std::time::Instant::now();
903        let is_click = self
904            .last_selection_change_time
905            .map(|t| now.duration_since(t).as_millis() < CLICK_THRESHOLD_MS)
906            .unwrap_or(false);
907
908        if is_click {
909            let is_double_click = self
910                .last_click
911                .map(|(ix, t)| {
912                    ix == self.selected_index
913                        && now.duration_since(t).as_millis() < DOUBLE_CLICK_THRESHOLD_MS
914                })
915                .unwrap_or(false);
916            self.last_click = Some((self.selected_index, now));
917
918            if !is_double_click {
919                cx.focus_self(window);
920                return;
921            }
922        }
923
924        let Some(selected_match) = self.selected_search_match().cloned() else {
925            return;
926        };
927        self.open_match(&selected_match, None, true, window, cx);
928
929        cx.emit(DismissEvent);
930    }
931
932    fn supports_multi_select(&self) -> bool {
933        true
934    }
935
936    fn is_item_selected(&self, ix: usize) -> bool {
937        let Some(search_match) = self.search_match_for_entry(ix) else {
938            return false;
939        };
940        self.selected_matches
941            .iter()
942            .any(|selected| selected == search_match)
943    }
944
945    fn toggle_item_selected(
946        &mut self,
947        ix: usize,
948        _window: &mut Window,
949        cx: &mut Context<Picker<Self>>,
950    ) {
951        // Headers and separators cannot participate in multi-selection.
952        let Some(search_match) = self.search_match_for_entry(ix).cloned() else {
953            return;
954        };
955        let selected = SelectedMatch(search_match);
956        if let Some(position) = self
957            .selected_matches
958            .iter()
959            .position(|existing| *existing == selected)
960        {
961            self.selected_matches.remove(position);
962        } else {
963            self.selected_matches.push(selected);
964        }
965        cx.notify();
966    }
967
968    fn selected_item_count(&self) -> usize {
969        self.selected_matches.len()
970    }
971
972    fn clear_selection(&mut self, _cx: &mut Context<Picker<Self>>) {
973        self.selected_matches.clear();
974    }
975
976    fn confirm_multi(
977        &mut self,
978        _secondary: bool,
979        window: &mut Window,
980        cx: &mut Context<Picker<Self>>,
981    ) {
982        let selected = std::mem::take(&mut self.selected_matches);
983        if selected.is_empty() {
984            return;
985        }
986        let count = selected.len();
987        for (i, selected_match) in selected.iter().enumerate() {
988            let is_last = i + 1 == count;
989            self.open_match(&selected_match.0, None, is_last, window, cx);
990        }
991        cx.emit(DismissEvent);
992    }
993
994    fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
995        cx.emit(DismissEvent);
996    }
997
998    fn try_get_preview_data_for_match(&self, _cx: &App) -> Option<picker::PreviewUpdate> {
999        let m = self.selected_search_match()?;
1000        Some(picker::PreviewUpdate::from_buffer(
1001            m.buffer.clone(),
1002            picker::MatchLocation {
1003                anchor_range: m.anchor_range.clone(),
1004                range: m.range.clone(),
1005            },
1006        ))
1007    }
1008
1009    fn render_match(
1010        &self,
1011        ix: usize,
1012        selected: bool,
1013        window: &mut Window,
1014        cx: &mut Context<Picker<Self>>,
1015    ) -> Option<Self::ListItem> {
1016        self.render_match_impl(ix, selected, None, window, cx)
1017    }
1018
1019    fn render_match_with_checkbox(
1020        &self,
1021        ix: usize,
1022        selected: bool,
1023        checkbox: AnyElement,
1024        window: &mut Window,
1025        cx: &mut Context<Picker<Self>>,
1026    ) -> Option<Self::ListItem> {
1027        self.render_match_impl(ix, selected, Some(checkbox), window, cx)
1028    }
1029}
1030
1031impl Delegate {
1032    fn render_match_impl(
1033        &self,
1034        ix: usize,
1035        selected: bool,
1036        checkbox: Option<AnyElement>,
1037        _: &mut Window,
1038        cx: &mut Context<Picker<Self>>,
1039    ) -> Option<AnyElement> {
1040        match self.entries.get(ix)? {
1041            Entry::Separator => Some(
1042                div()
1043                    .py(DynamicSpacing::Base04.rems(cx))
1044                    .child(Divider::horizontal())
1045                    .into_any_element(),
1046            ),
1047            Entry::Header(path) => {
1048                let path_style = self.project(cx).read(cx).path_style(cx);
1049                let file_name = path
1050                    .path
1051                    .file_name()
1052                    .map(|name| name.to_string())
1053                    .unwrap_or_default();
1054                let directory = path
1055                    .path
1056                    .parent()
1057                    .map(|parent| parent.display(path_style))
1058                    .map(SharedString::new)
1059                    .unwrap_or_default();
1060                let file_icon = ItemSettings::get_global(cx)
1061                    .file_icons
1062                    .then(|| FileIcons::get_icon(path.path.as_std_path(), cx))
1063                    .flatten()
1064                    .map(|icon| {
1065                        Icon::from_path(icon)
1066                            .color(Color::Muted)
1067                            .size(IconSize::Small)
1068                    });
1069                let is_collapsed = self.collapsed_paths.contains(path);
1070                let toggle_path = path.clone();
1071                let tooltip_focus_handle = self.focus_handle.clone();
1072
1073                Some(
1074                    div()
1075                        .px_1()
1076                        .child(
1077                            h_flex()
1078                                .w_full()
1079                                .min_w_0()
1080                                .p_1()
1081                                .gap_1p5()
1082                                .rounded_sm()
1083                                .when(selected, |this| {
1084                                    this.bg(cx.theme().colors().ghost_element_selected)
1085                                })
1086                                .child(
1087                                    h_flex()
1088                                        .gap_1()
1089                                        .child(
1090                                            Disclosure::new(
1091                                                ("text-finder-fold", ix),
1092                                                !is_collapsed,
1093                                            )
1094                                            .tooltip(move |_window, cx| {
1095                                                let (label, action): (_, &dyn gpui::Action) =
1096                                                    if is_collapsed {
1097                                                        ("Unfold", &Unfold)
1098                                                    } else {
1099                                                        ("Fold", &Fold)
1100                                                    };
1101                                                Tooltip::with_meta_in(
1102                                                    label,
1103                                                    Some(action),
1104                                                    format!(
1105                                                        "{} to toggle all",
1106                                                        text_for_keystroke(
1107                                                            &Modifiers::alt(),
1108                                                            "click",
1109                                                            cx
1110                                                        )
1111                                                    ),
1112                                                    &tooltip_focus_handle,
1113                                                    cx,
1114                                                )
1115                                            })
1116                                            .on_click(
1117                                                cx.listener(
1118                                                    move |this, event: &ClickEvent, _window, cx| {
1119                                                        if event.modifiers().alt {
1120                                                            this.delegate.toggle_all_collapsed(cx);
1121                                                        } else {
1122                                                            this.delegate.toggle_group_collapsed(
1123                                                                &toggle_path,
1124                                                            );
1125                                                            cx.notify();
1126                                                        }
1127                                                    },
1128                                                ),
1129                                            ),
1130                                        )
1131                                        .children(file_icon),
1132                                )
1133                                .child(
1134                                    h_flex()
1135                                        .gap_1()
1136                                        .child(Label::new(file_name).size(LabelSize::Small))
1137                                        .when(!directory.is_empty(), |this| {
1138                                            this.child(
1139                                                Label::new(directory)
1140                                                    .size(LabelSize::Small)
1141                                                    .color(Color::Muted)
1142                                                    .truncate_start(),
1143                                            )
1144                                        }),
1145                                ),
1146                        )
1147                        .into_any_element(),
1148                )
1149            }
1150            Entry::Match(match_index) => {
1151                let search_match = self.matches.get(*match_index)?;
1152                Some(
1153                    ListItem::new(ix)
1154                        .spacing(ListItemSpacing::Sparse)
1155                        .inset(true)
1156                        .toggle_state(selected)
1157                        .when_some(checkbox, |this, checkbox| this.start_slot(checkbox))
1158                        .child(
1159                            h_flex()
1160                                .w_full()
1161                                .min_w_0()
1162                                .gap_2p5()
1163                                .text_sm()
1164                                .child(
1165                                    h_flex()
1166                                        .w(rems(
1167                                            (self.max_line_number.max(1).ilog10() + 1) as f32 * 0.5,
1168                                        ))
1169                                        .justify_end()
1170                                        .child(
1171                                            Label::new(search_match.line_number.to_string()).color(
1172                                                Color::Custom(
1173                                                    cx.theme().colors().text_muted.opacity(0.5),
1174                                                ),
1175                                            ),
1176                                        ),
1177                                )
1178                                .child(
1179                                    div()
1180                                        .flex_1()
1181                                        .min_w_0()
1182                                        .truncate()
1183                                        .child(render_matched_line(search_match, cx)),
1184                                ),
1185                        )
1186                        .into_any_element(),
1187                )
1188            }
1189        }
1190    }
1191}
1192
1193enum ImportedMatches {
1194    No,
1195    Yes,
1196}
1197
1198async fn stream_results_to_picker(
1199    cancel_flag: Arc<AtomicBool>,
1200    text_finder_turning_into_project_search: Arc<AtomicBool>,
1201    picker: gpui::WeakEntity<Picker<Delegate>>,
1202    search_results: SearchResults<SearchResult>,
1203    imported_matches: ImportedMatches,
1204    cx: &mut AsyncApp,
1205) -> Option<SearchResults<SearchResult>> {
1206    let mut results_stream = std::pin::pin!(
1207        search_results
1208            .rx
1209            .clone()
1210            .ready_chunks(SEARCH_RESULTS_BATCH_SIZE)
1211    );
1212
1213    // Project search enforces its ranges cap per file,
1214    // so one minified line slips through uncapped; cap it here.
1215    let cap = Search::MAX_SEARCH_RESULT_RANGES;
1216    let mut total_matches = 0;
1217
1218    let mut clear_existing = matches!(imported_matches, ImportedMatches::No);
1219    while let Some(results) = results_stream.next().await {
1220        if cancel_flag.load(std::sync::atomic::Ordering::SeqCst) {
1221            break;
1222        }
1223
1224        let mut batch_matches = Vec::new();
1225        let mut limit_reached = false;
1226
1227        for result in results {
1228            match result {
1229                SearchResult::Buffer { buffer, ranges } => {
1230                    let remaining = cap.saturating_sub(total_matches + batch_matches.len());
1231                    let capped = ranges.len().min(remaining);
1232                    let matches = Delegate::process_search_result(&buffer, &ranges[..capped], cx);
1233                    batch_matches.extend(matches);
1234                    if capped < ranges.len() {
1235                        limit_reached = true;
1236                        break;
1237                    }
1238                }
1239                SearchResult::LimitReached => {
1240                    limit_reached = true;
1241                }
1242                SearchResult::WaitingForScan | SearchResult::Searching => {}
1243            }
1244        }
1245
1246        total_matches += batch_matches.len();
1247
1248        picker
1249            .update(cx, |picker, cx| {
1250                let delegate = &mut picker.delegate;
1251
1252                if clear_existing {
1253                    delegate.matches.clear();
1254                    delegate.entries.clear();
1255                    delegate.unique_files.clear();
1256                    delegate.collapsed_paths.clear();
1257                    delegate.selected_index = 0;
1258                    clear_existing = false;
1259                }
1260
1261                delegate
1262                    .unique_files
1263                    .extend(batch_matches.iter().map(|m| &m.path).cloned());
1264                delegate.matches.extend(batch_matches);
1265                delegate.prepend_selected_matches();
1266                // Rebuild the grouped view and resnap the selection onto a
1267                // selectable row (the header/separator rows are not selectable).
1268                delegate.rebuild_entries();
1269
1270                cx.notify();
1271            })
1272            .log_err();
1273
1274        if limit_reached {
1275            break;
1276        }
1277
1278        // Note the difference with the cancel flag. We need the results to be
1279        // processed before taking out the search result stream. The cancel flag
1280        // just needs to stop the search.
1281        if text_finder_turning_into_project_search.load(Ordering::Relaxed) {
1282            return Some(search_results);
1283        }
1284
1285        smol::future::yield_now().await;
1286    }
1287    None
1288}
1289
1290/// Byte range around the match to render: a bounded slice of the matched line so rendering never scales with line length.
1291fn matched_line_window(
1292    snapshot: &language::BufferSnapshot,
1293    match_range: &Range<usize>,
1294    column: u32,
1295) -> Range<usize> {
1296    let line_start = match_range.start.saturating_sub(column as usize);
1297    let row = snapshot.offset_to_point(match_range.start).row;
1298    let line_end = snapshot.point_to_offset(text::Point::new(row, snapshot.line_len(row)));
1299    let start = snapshot.clip_offset(
1300        match_range
1301            .start
1302            .saturating_sub(MAX_MATCH_CONTEXT_BYTES)
1303            .max(line_start),
1304        text::Bias::Left,
1305    );
1306    let end = snapshot.clip_offset(
1307        (match_range.end + MAX_MATCH_CONTEXT_BYTES).min(line_end),
1308        text::Bias::Right,
1309    );
1310    start..end
1311}
1312
1313/// Renders the matched source line with syntax highlighting, overlaying the
1314/// search match with a highlighted background and bold weight.
1315fn render_matched_line(search_match: &SearchMatch, cx: &App) -> StyledText {
1316    let settings = ThemeSettings::get_global(cx);
1317    let text_style = TextStyle {
1318        color: cx.theme().colors().text,
1319        font_family: settings.buffer_font.family.clone(),
1320        font_features: settings.buffer_font.features.clone(),
1321        font_fallbacks: settings.buffer_font.fallbacks.clone(),
1322        font_size: settings.buffer_font_size(cx).into(),
1323        font_weight: settings.buffer_font.weight,
1324        line_height: relative(1.),
1325        ..Default::default()
1326    };
1327    let search_match_style = HighlightStyle {
1328        background_color: Some(cx.theme().colors().search_match_background),
1329        font_weight: Some(gpui::FontWeight::BOLD),
1330        ..Default::default()
1331    };
1332
1333    let snapshot = search_match.buffer.read(cx).snapshot();
1334
1335    // Render a bounded window around the match, not the whole line,
1336    // so a minified single-line file stays cheap.
1337    let line_start_abs = search_match
1338        .range
1339        .start
1340        .saturating_sub(search_match.match_start_byte_column as usize);
1341    let window = matched_line_window(
1342        &snapshot,
1343        &search_match.range,
1344        search_match.match_start_byte_column,
1345    );
1346    let window_text: String = snapshot.text_for_range(window.clone()).collect();
1347
1348    // Trim leading indentation only when the window starts at the line start;
1349    // a mid-line window already begins on content.
1350    let trim_offset = if window.start == line_start_abs {
1351        window_text.len() - window_text.trim_start().len()
1352    } else {
1353        0
1354    };
1355    let visible_start_abs = window.start + trim_offset;
1356    let visible_end_abs = window.end;
1357    let line_text = &window_text[trim_offset..];
1358
1359    // Syntax highlights for the visible (trimmed) portion of the line, with
1360    // ranges relative to the start of the rendered text.
1361    let syntax_theme = cx.theme().syntax();
1362    let mut syntax_highlights: Vec<(Range<usize>, HighlightStyle)> = Vec::new();
1363    let mut current_offset = 0;
1364    for chunk in snapshot.chunks(
1365        visible_start_abs..visible_end_abs,
1366        LanguageAwareStyling {
1367            tree_sitter: true,
1368            diagnostics: false,
1369        },
1370    ) {
1371        let chunk_len = chunk.text.len();
1372        if let Some(style) = chunk
1373            .syntax_highlight_id
1374            .and_then(|id| syntax_theme.get(id).copied())
1375        {
1376            syntax_highlights.push((current_offset..current_offset + chunk_len, style));
1377        }
1378        current_offset += chunk_len;
1379    }
1380
1381    // The search match range, clamped to the visible area and made relative to
1382    // the start of the rendered text.
1383    let match_start = search_match
1384        .range
1385        .start
1386        .clamp(visible_start_abs, visible_end_abs);
1387    let match_end = search_match
1388        .range
1389        .end
1390        .clamp(visible_start_abs, visible_end_abs);
1391    let match_highlight = (
1392        match_start - visible_start_abs..match_end - visible_start_abs,
1393        search_match_style,
1394    );
1395
1396    let highlights = gpui::combine_highlights(syntax_highlights, [match_highlight]);
1397
1398    StyledText::new(line_text.to_string()).with_default_highlights(&text_style, highlights)
1399}
1400
1401impl Delegate {
1402    pub(crate) fn build_search_query(
1403        &mut self,
1404        query: &str,
1405        cx: &mut Context<Picker<Self>>,
1406    ) -> Option<SearchQuery> {
1407        if query.is_empty() {
1408            return None;
1409        }
1410
1411        // Reuse the include/exclude filters configured on the shared project
1412        // search view so the text finder respects them too.
1413        let (files_to_include, files_to_exclude) =
1414            self.project_search_view.read(cx).file_path_filters(cx);
1415
1416        // If the project contains multiple visible worktrees, we match the
1417        // include/exclude patterns against full paths to allow them to be
1418        // disambiguated. For single worktree projects we use worktree relative
1419        // paths for convenience.
1420        let match_full_paths = self.project(cx).read(cx).visible_worktrees(cx).count() > 1;
1421        let open_buffers = None;
1422
1423        self.search_options
1424            .build_query(
1425                query,
1426                files_to_include,
1427                files_to_exclude,
1428                match_full_paths,
1429                open_buffers,
1430            )
1431            .log_err()
1432    }
1433
1434    /// Create things from MB
1435    pub(crate) fn process_search_result(
1436        buffer: &Entity<Buffer>,
1437        ranges: &[Range<Anchor>],
1438        cx: &AsyncApp,
1439    ) -> Vec<SearchMatch> {
1440        if ranges.is_empty() {
1441            return Vec::new();
1442        }
1443
1444        buffer.read_with(cx, |buf, cx| {
1445            let file = buf.file();
1446            let path = file.map(|f| ProjectPath {
1447                worktree_id: f.worktree_id(cx),
1448                path: f.path().clone(),
1449            });
1450            let mut matches = Vec::new();
1451            for anchor_range in ranges {
1452                let start_offset: usize = buf.summary_for_anchor(&anchor_range.start);
1453                let end_offset: usize = buf.summary_for_anchor(&anchor_range.end);
1454                let point = buf.offset_to_point(start_offset);
1455
1456                if let Some(path) = &path {
1457                    matches.push(SearchMatch {
1458                        path: path.clone(),
1459                        buffer: buffer.clone(),
1460                        anchor_range: anchor_range.clone(),
1461                        range: start_offset..end_offset,
1462                        match_start_byte_column: point.column,
1463                        line_number: point.row + 1,
1464                    });
1465                }
1466            }
1467            matches
1468        })
1469    }
1470}
1471
1472#[cfg(test)]
1473mod tests {
1474    use super::*;
1475    use gpui::{AppContext, TestAppContext};
1476    use project::search::{SearchQuery, SearchResult};
1477    use project::{FakeFs, Project};
1478    use serde_json::json;
1479    use settings::SettingsStore;
1480    use util::path;
1481    use util::paths::PathMatcher;
1482
1483    fn init_test(cx: &mut TestAppContext) {
1484        cx.update(|cx| {
1485            let settings = SettingsStore::test(cx);
1486            cx.set_global(settings);
1487            theme_settings::init(theme::LoadThemes::JustBase, cx);
1488            editor::init(cx);
1489            crate::init(cx);
1490        });
1491    }
1492
1493    async fn project_with_file(cx: &mut TestAppContext, contents: String) -> Entity<Project> {
1494        let fs = FakeFs::new(cx.background_executor.clone());
1495        fs.insert_tree(path!("/dir"), json!({ "sample.js": contents }))
1496            .await;
1497        Project::test(fs, [path!("/dir").as_ref()], cx).await
1498    }
1499
1500    #[gpui::test]
1501    async fn test_finder_caps_matches_on_long_line(cx: &mut TestAppContext) {
1502        use workspace::MultiWorkspace;
1503
1504        init_test(cx);
1505
1506        let line = "return ".repeat(Search::MAX_SEARCH_RESULT_RANGES * 2);
1507        let project = project_with_file(cx, line).await;
1508
1509        let window =
1510            cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
1511        let workspace = window
1512            .read_with(cx, |mw, _| mw.workspace().clone())
1513            .unwrap();
1514
1515        let delegate = window
1516            .update(cx, |_mw, window, cx| {
1517                workspace.update(cx, |workspace, cx| Delegate::new(workspace, window, cx))
1518            })
1519            .unwrap()
1520            .await;
1521        let picker = window
1522            .update(cx, |_mw, window, cx| {
1523                cx.new(|cx| Picker::list(delegate, window, cx))
1524            })
1525            .unwrap();
1526
1527        window
1528            .update(cx, |_mw, window, cx| {
1529                picker.update(cx, |picker, cx| picker.set_query("return", window, cx))
1530            })
1531            .unwrap();
1532
1533        // Search is debounced; advance past the debounce and let results stream in.
1534        cx.executor()
1535            .advance_clock(std::time::Duration::from_millis(SEARCH_DEBOUNCE_MS + 50));
1536        cx.run_until_parked();
1537
1538        picker.read_with(cx, |picker, _| {
1539            assert_eq!(
1540                picker.delegate.matches.len(),
1541                Search::MAX_SEARCH_RESULT_RANGES
1542            );
1543        });
1544    }
1545
1546    #[gpui::test]
1547    async fn test_builds_one_match_per_occurrence(cx: &mut TestAppContext) {
1548        init_test(cx);
1549
1550        let line = "return ".repeat(2_000);
1551        let expected_matches = line.matches("return").count();
1552        let project = project_with_file(cx, line).await;
1553
1554        let query = SearchQuery::text(
1555            "return",
1556            false,
1557            false,
1558            false,
1559            PathMatcher::default(),
1560            PathMatcher::default(),
1561            false,
1562            None,
1563        )
1564        .unwrap();
1565
1566        let search = project.update(cx, |project, cx| project.search(query, cx));
1567        let async_cx = cx.to_async();
1568        let mut matches = Vec::new();
1569        while let Ok(SearchResult::Buffer { buffer, ranges }) = search.rx.recv().await {
1570            matches.extend(Delegate::process_search_result(&buffer, &ranges, &async_cx));
1571        }
1572
1573        assert_eq!(matches.len(), expected_matches);
1574        assert!(matches.iter().all(|m| m.line_number == 1));
1575        assert!(
1576            matches
1577                .windows(2)
1578                .all(|pair| pair[0].match_start_byte_column < pair[1].match_start_byte_column)
1579        );
1580    }
1581
1582    #[gpui::test]
1583    fn test_matched_line_window_is_bounded(cx: &mut gpui::TestAppContext) {
1584        let long_line = "abcdefghij".repeat(1_000_000);
1585        let buffer = cx.new(|cx| language::Buffer::local(long_line, cx));
1586        buffer.read_with(cx, |buffer, _| {
1587            let snapshot = buffer.snapshot();
1588            let match_range = 5_000_000..5_000_003;
1589            let column = match_range.start as u32; // single line, so column equals the offset
1590            let window = matched_line_window(&snapshot, &match_range, column);
1591
1592            assert!(window.start <= match_range.start && window.end >= match_range.end);
1593            assert!(window.len() <= 2 * MAX_MATCH_CONTEXT_BYTES + match_range.len());
1594        });
1595
1596        let buffer = cx.new(|cx| language::Buffer::local("    let foo = bar;", cx));
1597        buffer.read_with(cx, |buffer, _| {
1598            let snapshot = buffer.snapshot();
1599            let match_range = 8..11; // "foo"
1600            let window = matched_line_window(&snapshot, &match_range, match_range.start as u32);
1601            assert_eq!(window, 0..snapshot.len());
1602        });
1603    }
1604}
1605
Served at tenant.openagents/omega Member data and write actions are omitted.