Skip to repository content1605 lines · 59.1 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:10:36.480Z 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
delegate.rs
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