1use editor::{scroll::autoscroll::Autoscroll, Bias, Editor};
2use fuzzy::PathMatch;
3use gpui::{
4 actions, elements::*, AppContext, ModelHandle, MouseState, Task, ViewContext, WeakViewHandle,
5};
6use picker::{Picker, PickerDelegate};
7use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
8use std::{
9 path::Path,
10 sync::{
11 atomic::{self, AtomicBool},
12 Arc,
13 },
14};
15use text::Point;
16use util::{paths::PathLikeWithPosition, post_inc, ResultExt};
17use workspace::Workspace;
18
19pub type FileFinder = Picker<FileFinderDelegate>;
20
21pub struct FileFinderDelegate {
22 workspace: WeakViewHandle<Workspace>,
23 project: ModelHandle<Project>,
24 search_count: usize,
25 latest_search_id: usize,
26 latest_search_did_cancel: bool,
27 latest_search_query: Option<PathLikeWithPosition<FileSearchQuery>>,
28 currently_opened_path: Option<ProjectPath>,
29 matches: Vec<PathMatch>,
30 selected: Option<(usize, Arc<Path>)>,
31 cancel_flag: Arc<AtomicBool>,
32 history_items: Vec<ProjectPath>,
33}
34
35actions!(file_finder, [Toggle]);
36
37pub fn init(cx: &mut AppContext) {
38 cx.add_action(toggle_file_finder);
39 FileFinder::init(cx);
40}
41
42const MAX_RECENT_SELECTIONS: usize = 20;
43
44fn toggle_file_finder(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
45 workspace.toggle_modal(cx, |workspace, cx| {
46 let history_items = workspace.recent_navigation_history(Some(MAX_RECENT_SELECTIONS), cx);
47 let currently_opened_path = workspace
48 .active_item(cx)
49 .and_then(|item| item.project_path(cx));
50
51 let project = workspace.project().clone();
52 let workspace = cx.handle().downgrade();
53 let finder = cx.add_view(|cx| {
54 Picker::new(
55 FileFinderDelegate::new(
56 workspace,
57 project,
58 currently_opened_path,
59 history_items,
60 cx,
61 ),
62 cx,
63 )
64 });
65 finder
66 });
67}
68
69pub enum Event {
70 Selected(ProjectPath),
71 Dismissed,
72}
73
74#[derive(Debug, Clone)]
75struct FileSearchQuery {
76 raw_query: String,
77 file_query_end: Option<usize>,
78}
79
80impl FileSearchQuery {
81 fn path_query(&self) -> &str {
82 match self.file_query_end {
83 Some(file_path_end) => &self.raw_query[..file_path_end],
84 None => &self.raw_query,
85 }
86 }
87}
88
89impl FileFinderDelegate {
90 fn labels_for_match(&self, path_match: &PathMatch) -> (String, Vec<usize>, String, Vec<usize>) {
91 let path = &path_match.path;
92 let path_string = path.to_string_lossy();
93 let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
94 let path_positions = path_match.positions.clone();
95
96 let file_name = path.file_name().map_or_else(
97 || path_match.path_prefix.to_string(),
98 |file_name| file_name.to_string_lossy().to_string(),
99 );
100 let file_name_start = path_match.path_prefix.chars().count() + path_string.chars().count()
101 - file_name.chars().count();
102 let file_name_positions = path_positions
103 .iter()
104 .filter_map(|pos| {
105 if pos >= &file_name_start {
106 Some(pos - file_name_start)
107 } else {
108 None
109 }
110 })
111 .collect();
112
113 (file_name, file_name_positions, full_path, path_positions)
114 }
115
116 pub fn new(
117 workspace: WeakViewHandle<Workspace>,
118 project: ModelHandle<Project>,
119 currently_opened_path: Option<ProjectPath>,
120 history_items: Vec<ProjectPath>,
121 cx: &mut ViewContext<FileFinder>,
122 ) -> Self {
123 cx.observe(&project, |picker, _, cx| {
124 picker.update_matches(picker.query(cx), cx);
125 })
126 .detach();
127 Self {
128 workspace,
129 project,
130 search_count: 0,
131 latest_search_id: 0,
132 latest_search_did_cancel: false,
133 latest_search_query: None,
134 currently_opened_path,
135 matches: Vec::new(),
136 selected: None,
137 cancel_flag: Arc::new(AtomicBool::new(false)),
138 history_items,
139 }
140 }
141
142 fn spawn_search(
143 &mut self,
144 query: PathLikeWithPosition<FileSearchQuery>,
145 cx: &mut ViewContext<FileFinder>,
146 ) -> Task<()> {
147 let relative_to = self
148 .currently_opened_path
149 .as_ref()
150 .map(|project_path| Arc::clone(&project_path.path));
151 let worktrees = self
152 .project
153 .read(cx)
154 .visible_worktrees(cx)
155 .collect::<Vec<_>>();
156 let include_root_name = worktrees.len() > 1;
157 let candidate_sets = worktrees
158 .into_iter()
159 .map(|worktree| {
160 let worktree = worktree.read(cx);
161 PathMatchCandidateSet {
162 snapshot: worktree.snapshot(),
163 include_ignored: worktree
164 .root_entry()
165 .map_or(false, |entry| entry.is_ignored),
166 include_root_name,
167 }
168 })
169 .collect::<Vec<_>>();
170
171 let search_id = util::post_inc(&mut self.search_count);
172 self.cancel_flag.store(true, atomic::Ordering::Relaxed);
173 self.cancel_flag = Arc::new(AtomicBool::new(false));
174 let cancel_flag = self.cancel_flag.clone();
175 cx.spawn(|picker, mut cx| async move {
176 let matches = fuzzy::match_path_sets(
177 candidate_sets.as_slice(),
178 query.path_like.path_query(),
179 relative_to,
180 false,
181 100,
182 &cancel_flag,
183 cx.background(),
184 )
185 .await;
186 let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
187 picker
188 .update(&mut cx, |picker, cx| {
189 picker
190 .delegate_mut()
191 .set_matches(search_id, did_cancel, query, matches, cx)
192 })
193 .log_err();
194 })
195 }
196
197 fn set_matches(
198 &mut self,
199 search_id: usize,
200 did_cancel: bool,
201 query: PathLikeWithPosition<FileSearchQuery>,
202 matches: Vec<PathMatch>,
203 cx: &mut ViewContext<FileFinder>,
204 ) {
205 if search_id >= self.latest_search_id {
206 self.latest_search_id = search_id;
207 if self.latest_search_did_cancel
208 && Some(query.path_like.path_query())
209 == self
210 .latest_search_query
211 .as_ref()
212 .map(|query| query.path_like.path_query())
213 {
214 util::extend_sorted(&mut self.matches, matches.into_iter(), 100, |a, b| b.cmp(a));
215 } else {
216 self.matches = matches;
217 }
218 self.latest_search_query = Some(query);
219 self.latest_search_did_cancel = did_cancel;
220 cx.notify();
221 }
222 }
223}
224
225impl PickerDelegate for FileFinderDelegate {
226 fn placeholder_text(&self) -> Arc<str> {
227 "Search project files...".into()
228 }
229
230 fn match_count(&self) -> usize {
231 self.matches.len()
232 }
233
234 fn selected_index(&self) -> usize {
235 if let Some(selected) = self.selected.as_ref() {
236 for (ix, path_match) in self.matches.iter().enumerate() {
237 if (path_match.worktree_id, path_match.path.as_ref())
238 == (selected.0, selected.1.as_ref())
239 {
240 return ix;
241 }
242 }
243 }
244 0
245 }
246
247 fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<FileFinder>) {
248 let mat = &self.matches[ix];
249 self.selected = Some((mat.worktree_id, mat.path.clone()));
250 cx.notify();
251 }
252
253 fn update_matches(&mut self, raw_query: String, cx: &mut ViewContext<FileFinder>) -> Task<()> {
254 if raw_query.is_empty() {
255 self.latest_search_id = post_inc(&mut self.search_count);
256 self.matches.clear();
257
258 self.matches = self
259 .currently_opened_path
260 .iter() // if exists, bubble the currently opened path to the top
261 .chain(self.history_items.iter().filter(|history_item| {
262 Some(*history_item) != self.currently_opened_path.as_ref()
263 }))
264 .enumerate()
265 .map(|(i, history_item)| PathMatch {
266 score: i as f64,
267 positions: Vec::new(),
268 worktree_id: history_item.worktree_id.0,
269 path: Arc::clone(&history_item.path),
270 path_prefix: "".into(),
271 distance_to_relative_ancestor: usize::MAX,
272 })
273 .collect();
274 cx.notify();
275 Task::ready(())
276 } else {
277 let raw_query = &raw_query;
278 let query = PathLikeWithPosition::parse_str(raw_query, |path_like_str| {
279 Ok::<_, std::convert::Infallible>(FileSearchQuery {
280 raw_query: raw_query.to_owned(),
281 file_query_end: if path_like_str == raw_query {
282 None
283 } else {
284 Some(path_like_str.len())
285 },
286 })
287 })
288 .expect("infallible");
289 self.spawn_search(query, cx)
290 }
291 }
292
293 fn confirm(&mut self, cx: &mut ViewContext<FileFinder>) {
294 if let Some(m) = self.matches.get(self.selected_index()) {
295 if let Some(workspace) = self.workspace.upgrade(cx) {
296 let project_path = ProjectPath {
297 worktree_id: WorktreeId::from_usize(m.worktree_id),
298 path: m.path.clone(),
299 };
300 let open_task = workspace.update(cx, |workspace, cx| {
301 workspace.open_path(project_path.clone(), None, true, cx)
302 });
303
304 let workspace = workspace.downgrade();
305
306 let row = self
307 .latest_search_query
308 .as_ref()
309 .and_then(|query| query.row)
310 .map(|row| row.saturating_sub(1));
311 let col = self
312 .latest_search_query
313 .as_ref()
314 .and_then(|query| query.column)
315 .unwrap_or(0)
316 .saturating_sub(1);
317 cx.spawn(|_, mut cx| async move {
318 let item = open_task.await.log_err()?;
319 if let Some(row) = row {
320 if let Some(active_editor) = item.downcast::<Editor>() {
321 active_editor
322 .downgrade()
323 .update(&mut cx, |editor, cx| {
324 let snapshot = editor.snapshot(cx).display_snapshot;
325 let point = snapshot
326 .buffer_snapshot
327 .clip_point(Point::new(row, col), Bias::Left);
328 editor.change_selections(Some(Autoscroll::center()), cx, |s| {
329 s.select_ranges([point..point])
330 });
331 })
332 .log_err();
333 }
334 }
335 workspace
336 .update(&mut cx, |workspace, cx| workspace.dismiss_modal(cx))
337 .log_err();
338
339 Some(())
340 })
341 .detach();
342 }
343 }
344 }
345
346 fn dismissed(&mut self, _: &mut ViewContext<FileFinder>) {}
347
348 fn render_match(
349 &self,
350 ix: usize,
351 mouse_state: &mut MouseState,
352 selected: bool,
353 cx: &AppContext,
354 ) -> AnyElement<Picker<Self>> {
355 let path_match = &self.matches[ix];
356 let theme = theme::current(cx);
357 let style = theme.picker.item.style_for(mouse_state, selected);
358 let (file_name, file_name_positions, full_path, full_path_positions) =
359 self.labels_for_match(path_match);
360 Flex::column()
361 .with_child(
362 Label::new(file_name, style.label.clone()).with_highlights(file_name_positions),
363 )
364 .with_child(
365 Label::new(full_path, style.label.clone()).with_highlights(full_path_positions),
366 )
367 .flex(1., false)
368 .contained()
369 .with_style(style.container)
370 .into_any_named("match")
371 }
372}
373
374#[cfg(test)]
375mod tests {
376 use std::{assert_eq, collections::HashMap, time::Duration};
377
378 use super::*;
379 use editor::Editor;
380 use gpui::{TestAppContext, ViewHandle};
381 use menu::{Confirm, SelectNext};
382 use serde_json::json;
383 use workspace::{AppState, Pane, Workspace};
384
385 #[ctor::ctor]
386 fn init_logger() {
387 if std::env::var("RUST_LOG").is_ok() {
388 env_logger::init();
389 }
390 }
391
392 #[gpui::test]
393 async fn test_matching_paths(cx: &mut TestAppContext) {
394 let app_state = init_test(cx);
395 app_state
396 .fs
397 .as_fake()
398 .insert_tree(
399 "/root",
400 json!({
401 "a": {
402 "banana": "",
403 "bandana": "",
404 }
405 }),
406 )
407 .await;
408
409 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
410 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
411 cx.dispatch_action(window_id, Toggle);
412
413 let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
414 finder
415 .update(cx, |finder, cx| {
416 finder.delegate_mut().update_matches("bna".to_string(), cx)
417 })
418 .await;
419 finder.read_with(cx, |finder, _| {
420 assert_eq!(finder.delegate().matches.len(), 2);
421 });
422
423 let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
424 cx.dispatch_action(window_id, SelectNext);
425 cx.dispatch_action(window_id, Confirm);
426 active_pane
427 .condition(cx, |pane, _| pane.active_item().is_some())
428 .await;
429 cx.read(|cx| {
430 let active_item = active_pane.read(cx).active_item().unwrap();
431 assert_eq!(
432 active_item
433 .as_any()
434 .downcast_ref::<Editor>()
435 .unwrap()
436 .read(cx)
437 .title(cx),
438 "bandana"
439 );
440 });
441 }
442
443 #[gpui::test]
444 async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
445 let app_state = init_test(cx);
446
447 let first_file_name = "first.rs";
448 let first_file_contents = "// First Rust file";
449 app_state
450 .fs
451 .as_fake()
452 .insert_tree(
453 "/src",
454 json!({
455 "test": {
456 first_file_name: first_file_contents,
457 "second.rs": "// Second Rust file",
458 }
459 }),
460 )
461 .await;
462
463 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
464 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
465 cx.dispatch_action(window_id, Toggle);
466 let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
467
468 let file_query = &first_file_name[..3];
469 let file_row = 1;
470 let file_column = 3;
471 assert!(file_column <= first_file_contents.len());
472 let query_inside_file = format!("{file_query}:{file_row}:{file_column}");
473 finder
474 .update(cx, |finder, cx| {
475 finder
476 .delegate_mut()
477 .update_matches(query_inside_file.to_string(), cx)
478 })
479 .await;
480 finder.read_with(cx, |finder, _| {
481 let finder = finder.delegate();
482 assert_eq!(finder.matches.len(), 1);
483 let latest_search_query = finder
484 .latest_search_query
485 .as_ref()
486 .expect("Finder should have a query after the update_matches call");
487 assert_eq!(latest_search_query.path_like.raw_query, query_inside_file);
488 assert_eq!(
489 latest_search_query.path_like.file_query_end,
490 Some(file_query.len())
491 );
492 assert_eq!(latest_search_query.row, Some(file_row));
493 assert_eq!(latest_search_query.column, Some(file_column as u32));
494 });
495
496 let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
497 cx.dispatch_action(window_id, SelectNext);
498 cx.dispatch_action(window_id, Confirm);
499 active_pane
500 .condition(cx, |pane, _| pane.active_item().is_some())
501 .await;
502 let editor = cx.update(|cx| {
503 let active_item = active_pane.read(cx).active_item().unwrap();
504 active_item.downcast::<Editor>().unwrap()
505 });
506 cx.foreground().advance_clock(Duration::from_secs(2));
507 cx.foreground().start_waiting();
508 cx.foreground().finish_waiting();
509 editor.update(cx, |editor, cx| {
510 let all_selections = editor.selections.all_adjusted(cx);
511 assert_eq!(
512 all_selections.len(),
513 1,
514 "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
515 );
516 let caret_selection = all_selections.into_iter().next().unwrap();
517 assert_eq!(caret_selection.start, caret_selection.end,
518 "Caret selection should have its start and end at the same position");
519 assert_eq!(file_row, caret_selection.start.row + 1,
520 "Query inside file should get caret with the same focus row");
521 assert_eq!(file_column, caret_selection.start.column as usize + 1,
522 "Query inside file should get caret with the same focus column");
523 });
524 }
525
526 #[gpui::test]
527 async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
528 let app_state = init_test(cx);
529
530 let first_file_name = "first.rs";
531 let first_file_contents = "// First Rust file";
532 app_state
533 .fs
534 .as_fake()
535 .insert_tree(
536 "/src",
537 json!({
538 "test": {
539 first_file_name: first_file_contents,
540 "second.rs": "// Second Rust file",
541 }
542 }),
543 )
544 .await;
545
546 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
547 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
548 cx.dispatch_action(window_id, Toggle);
549 let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
550
551 let file_query = &first_file_name[..3];
552 let file_row = 200;
553 let file_column = 300;
554 assert!(file_column > first_file_contents.len());
555 let query_outside_file = format!("{file_query}:{file_row}:{file_column}");
556 finder
557 .update(cx, |finder, cx| {
558 finder
559 .delegate_mut()
560 .update_matches(query_outside_file.to_string(), cx)
561 })
562 .await;
563 finder.read_with(cx, |finder, _| {
564 let finder = finder.delegate();
565 assert_eq!(finder.matches.len(), 1);
566 let latest_search_query = finder
567 .latest_search_query
568 .as_ref()
569 .expect("Finder should have a query after the update_matches call");
570 assert_eq!(latest_search_query.path_like.raw_query, query_outside_file);
571 assert_eq!(
572 latest_search_query.path_like.file_query_end,
573 Some(file_query.len())
574 );
575 assert_eq!(latest_search_query.row, Some(file_row));
576 assert_eq!(latest_search_query.column, Some(file_column as u32));
577 });
578
579 let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
580 cx.dispatch_action(window_id, SelectNext);
581 cx.dispatch_action(window_id, Confirm);
582 active_pane
583 .condition(cx, |pane, _| pane.active_item().is_some())
584 .await;
585 let editor = cx.update(|cx| {
586 let active_item = active_pane.read(cx).active_item().unwrap();
587 active_item.downcast::<Editor>().unwrap()
588 });
589 cx.foreground().advance_clock(Duration::from_secs(2));
590 cx.foreground().start_waiting();
591 cx.foreground().finish_waiting();
592 editor.update(cx, |editor, cx| {
593 let all_selections = editor.selections.all_adjusted(cx);
594 assert_eq!(
595 all_selections.len(),
596 1,
597 "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
598 );
599 let caret_selection = all_selections.into_iter().next().unwrap();
600 assert_eq!(caret_selection.start, caret_selection.end,
601 "Caret selection should have its start and end at the same position");
602 assert_eq!(0, caret_selection.start.row,
603 "Excessive rows (as in query outside file borders) should get trimmed to last file row");
604 assert_eq!(first_file_contents.len(), caret_selection.start.column as usize,
605 "Excessive columns (as in query outside file borders) should get trimmed to selected row's last column");
606 });
607 }
608
609 #[gpui::test]
610 async fn test_matching_cancellation(cx: &mut TestAppContext) {
611 let app_state = init_test(cx);
612 app_state
613 .fs
614 .as_fake()
615 .insert_tree(
616 "/dir",
617 json!({
618 "hello": "",
619 "goodbye": "",
620 "halogen-light": "",
621 "happiness": "",
622 "height": "",
623 "hi": "",
624 "hiccup": "",
625 }),
626 )
627 .await;
628
629 let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await;
630 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
631 let (_, finder) = cx.add_window(|cx| {
632 Picker::new(
633 FileFinderDelegate::new(
634 workspace.downgrade(),
635 workspace.read(cx).project().clone(),
636 None,
637 Vec::new(),
638 cx,
639 ),
640 cx,
641 )
642 });
643
644 let query = test_path_like("hi");
645 finder
646 .update(cx, |f, cx| f.delegate_mut().spawn_search(query.clone(), cx))
647 .await;
648 finder.read_with(cx, |f, _| assert_eq!(f.delegate().matches.len(), 5));
649
650 finder.update(cx, |finder, cx| {
651 let delegate = finder.delegate_mut();
652 let matches = delegate.matches.clone();
653
654 // Simulate a search being cancelled after the time limit,
655 // returning only a subset of the matches that would have been found.
656 drop(delegate.spawn_search(query.clone(), cx));
657 delegate.set_matches(
658 delegate.latest_search_id,
659 true, // did-cancel
660 query.clone(),
661 vec![matches[1].clone(), matches[3].clone()],
662 cx,
663 );
664
665 // Simulate another cancellation.
666 drop(delegate.spawn_search(query.clone(), cx));
667 delegate.set_matches(
668 delegate.latest_search_id,
669 true, // did-cancel
670 query.clone(),
671 vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
672 cx,
673 );
674
675 assert_eq!(delegate.matches, matches[0..4])
676 });
677 }
678
679 #[gpui::test]
680 async fn test_ignored_files(cx: &mut TestAppContext) {
681 let app_state = init_test(cx);
682 app_state
683 .fs
684 .as_fake()
685 .insert_tree(
686 "/ancestor",
687 json!({
688 ".gitignore": "ignored-root",
689 "ignored-root": {
690 "happiness": "",
691 "height": "",
692 "hi": "",
693 "hiccup": "",
694 },
695 "tracked-root": {
696 ".gitignore": "height",
697 "happiness": "",
698 "height": "",
699 "hi": "",
700 "hiccup": "",
701 },
702 }),
703 )
704 .await;
705
706 let project = Project::test(
707 app_state.fs.clone(),
708 [
709 "/ancestor/tracked-root".as_ref(),
710 "/ancestor/ignored-root".as_ref(),
711 ],
712 cx,
713 )
714 .await;
715 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
716 let (_, finder) = cx.add_window(|cx| {
717 Picker::new(
718 FileFinderDelegate::new(
719 workspace.downgrade(),
720 workspace.read(cx).project().clone(),
721 None,
722 Vec::new(),
723 cx,
724 ),
725 cx,
726 )
727 });
728 finder
729 .update(cx, |f, cx| {
730 f.delegate_mut().spawn_search(test_path_like("hi"), cx)
731 })
732 .await;
733 finder.read_with(cx, |f, _| assert_eq!(f.delegate().matches.len(), 7));
734 }
735
736 #[gpui::test]
737 async fn test_single_file_worktrees(cx: &mut TestAppContext) {
738 let app_state = init_test(cx);
739 app_state
740 .fs
741 .as_fake()
742 .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
743 .await;
744
745 let project = Project::test(
746 app_state.fs.clone(),
747 ["/root/the-parent-dir/the-file".as_ref()],
748 cx,
749 )
750 .await;
751 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
752 let (_, finder) = cx.add_window(|cx| {
753 Picker::new(
754 FileFinderDelegate::new(
755 workspace.downgrade(),
756 workspace.read(cx).project().clone(),
757 None,
758 Vec::new(),
759 cx,
760 ),
761 cx,
762 )
763 });
764
765 // Even though there is only one worktree, that worktree's filename
766 // is included in the matching, because the worktree is a single file.
767 finder
768 .update(cx, |f, cx| {
769 f.delegate_mut().spawn_search(test_path_like("thf"), cx)
770 })
771 .await;
772 cx.read(|cx| {
773 let finder = finder.read(cx);
774 let delegate = finder.delegate();
775 assert_eq!(delegate.matches.len(), 1);
776
777 let (file_name, file_name_positions, full_path, full_path_positions) =
778 delegate.labels_for_match(&delegate.matches[0]);
779 assert_eq!(file_name, "the-file");
780 assert_eq!(file_name_positions, &[0, 1, 4]);
781 assert_eq!(full_path, "the-file");
782 assert_eq!(full_path_positions, &[0, 1, 4]);
783 });
784
785 // Since the worktree root is a file, searching for its name followed by a slash does
786 // not match anything.
787 finder
788 .update(cx, |f, cx| {
789 f.delegate_mut().spawn_search(test_path_like("thf/"), cx)
790 })
791 .await;
792 finder.read_with(cx, |f, _| assert_eq!(f.delegate().matches.len(), 0));
793 }
794
795 #[gpui::test]
796 async fn test_multiple_matches_with_same_relative_path(cx: &mut TestAppContext) {
797 let app_state = init_test(cx);
798 app_state
799 .fs
800 .as_fake()
801 .insert_tree(
802 "/root",
803 json!({
804 "dir1": { "a.txt": "" },
805 "dir2": { "a.txt": "" }
806 }),
807 )
808 .await;
809
810 let project = Project::test(
811 app_state.fs.clone(),
812 ["/root/dir1".as_ref(), "/root/dir2".as_ref()],
813 cx,
814 )
815 .await;
816 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
817
818 let (_, finder) = cx.add_window(|cx| {
819 Picker::new(
820 FileFinderDelegate::new(
821 workspace.downgrade(),
822 workspace.read(cx).project().clone(),
823 None,
824 Vec::new(),
825 cx,
826 ),
827 cx,
828 )
829 });
830
831 // Run a search that matches two files with the same relative path.
832 finder
833 .update(cx, |f, cx| {
834 f.delegate_mut().spawn_search(test_path_like("a.t"), cx)
835 })
836 .await;
837
838 // Can switch between different matches with the same relative path.
839 finder.update(cx, |finder, cx| {
840 let delegate = finder.delegate_mut();
841 assert_eq!(delegate.matches.len(), 2);
842 assert_eq!(delegate.selected_index(), 0);
843 delegate.set_selected_index(1, cx);
844 assert_eq!(delegate.selected_index(), 1);
845 delegate.set_selected_index(0, cx);
846 assert_eq!(delegate.selected_index(), 0);
847 });
848 }
849
850 #[gpui::test]
851 async fn test_path_distance_ordering(cx: &mut TestAppContext) {
852 let app_state = init_test(cx);
853 app_state
854 .fs
855 .as_fake()
856 .insert_tree(
857 "/root",
858 json!({
859 "dir1": { "a.txt": "" },
860 "dir2": {
861 "a.txt": "",
862 "b.txt": ""
863 }
864 }),
865 )
866 .await;
867
868 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
869 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
870 let worktree_id = cx.read(|cx| {
871 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
872 assert_eq!(worktrees.len(), 1);
873 WorktreeId(worktrees[0].id())
874 });
875
876 // When workspace has an active item, sort items which are closer to that item
877 // first when they have the same name. In this case, b.txt is closer to dir2's a.txt
878 // so that one should be sorted earlier
879 let b_path = Some(ProjectPath {
880 worktree_id,
881 path: Arc::from(Path::new("/root/dir2/b.txt")),
882 });
883 let (_, finder) = cx.add_window(|cx| {
884 Picker::new(
885 FileFinderDelegate::new(
886 workspace.downgrade(),
887 workspace.read(cx).project().clone(),
888 b_path,
889 Vec::new(),
890 cx,
891 ),
892 cx,
893 )
894 });
895
896 finder
897 .update(cx, |f, cx| {
898 f.delegate_mut().spawn_search(test_path_like("a.txt"), cx)
899 })
900 .await;
901
902 finder.read_with(cx, |f, _| {
903 let delegate = f.delegate();
904 assert_eq!(delegate.matches[0].path.as_ref(), Path::new("dir2/a.txt"));
905 assert_eq!(delegate.matches[1].path.as_ref(), Path::new("dir1/a.txt"));
906 });
907 }
908
909 #[gpui::test]
910 async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
911 let app_state = init_test(cx);
912 app_state
913 .fs
914 .as_fake()
915 .insert_tree(
916 "/root",
917 json!({
918 "dir1": {},
919 "dir2": {
920 "dir3": {}
921 }
922 }),
923 )
924 .await;
925
926 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
927 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
928 let (_, finder) = cx.add_window(|cx| {
929 Picker::new(
930 FileFinderDelegate::new(
931 workspace.downgrade(),
932 workspace.read(cx).project().clone(),
933 None,
934 Vec::new(),
935 cx,
936 ),
937 cx,
938 )
939 });
940 finder
941 .update(cx, |f, cx| {
942 f.delegate_mut().spawn_search(test_path_like("dir"), cx)
943 })
944 .await;
945 cx.read(|cx| {
946 let finder = finder.read(cx);
947 assert_eq!(finder.delegate().matches.len(), 0);
948 });
949 }
950
951 #[gpui::test]
952 async fn test_query_history(
953 deterministic: Arc<gpui::executor::Deterministic>,
954 cx: &mut gpui::TestAppContext,
955 ) {
956 let app_state = init_test(cx);
957
958 app_state
959 .fs
960 .as_fake()
961 .insert_tree(
962 "/src",
963 json!({
964 "test": {
965 "first.rs": "// First Rust file",
966 "second.rs": "// Second Rust file",
967 "third.rs": "// Third Rust file",
968 }
969 }),
970 )
971 .await;
972
973 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
974 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
975 let worktree_id = cx.read(|cx| {
976 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
977 assert_eq!(worktrees.len(), 1);
978 WorktreeId(worktrees[0].id())
979 });
980
981 // Open and close panels, getting their history items afterwards.
982 // Ensure history items get populated with opened items, and items are kept in a certain order.
983 // The history lags one opened buffer behind, since it's updated in the search panel only on its reopen.
984 //
985 // TODO: without closing, the opened items do not propagate their history changes for some reason
986 // it does work in real app though, only tests do not propagate.
987
988 let initial_history = open_close_queried_buffer(
989 "fir",
990 1,
991 "first.rs",
992 window_id,
993 &workspace,
994 &deterministic,
995 cx,
996 )
997 .await;
998 assert!(
999 initial_history.is_empty(),
1000 "Should have no history before opening any files"
1001 );
1002
1003 let history_after_first = open_close_queried_buffer(
1004 "sec",
1005 1,
1006 "second.rs",
1007 window_id,
1008 &workspace,
1009 &deterministic,
1010 cx,
1011 )
1012 .await;
1013 assert_eq!(
1014 history_after_first,
1015 vec![ProjectPath {
1016 worktree_id,
1017 path: Arc::from(Path::new("test/first.rs")),
1018 }],
1019 "Should show 1st opened item in the history when opening the 2nd item"
1020 );
1021
1022 let history_after_second = open_close_queried_buffer(
1023 "thi",
1024 1,
1025 "third.rs",
1026 window_id,
1027 &workspace,
1028 &deterministic,
1029 cx,
1030 )
1031 .await;
1032 assert_eq!(
1033 history_after_second,
1034 vec![
1035 ProjectPath {
1036 worktree_id,
1037 path: Arc::from(Path::new("test/second.rs")),
1038 },
1039 ProjectPath {
1040 worktree_id,
1041 path: Arc::from(Path::new("test/first.rs")),
1042 },
1043 ],
1044 "Should show 1st and 2nd opened items in the history when opening the 3rd item. \
10452nd item should be the first in the history, as the last opened."
1046 );
1047
1048 let history_after_third = open_close_queried_buffer(
1049 "sec",
1050 1,
1051 "second.rs",
1052 window_id,
1053 &workspace,
1054 &deterministic,
1055 cx,
1056 )
1057 .await;
1058 assert_eq!(
1059 history_after_third,
1060 vec![
1061 ProjectPath {
1062 worktree_id,
1063 path: Arc::from(Path::new("test/third.rs")),
1064 },
1065 ProjectPath {
1066 worktree_id,
1067 path: Arc::from(Path::new("test/second.rs")),
1068 },
1069 ProjectPath {
1070 worktree_id,
1071 path: Arc::from(Path::new("test/first.rs")),
1072 },
1073 ],
1074 "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \
10753rd item should be the first in the history, as the last opened."
1076 );
1077
1078 let history_after_second_again = open_close_queried_buffer(
1079 "thi",
1080 1,
1081 "third.rs",
1082 window_id,
1083 &workspace,
1084 &deterministic,
1085 cx,
1086 )
1087 .await;
1088 assert_eq!(
1089 history_after_second_again,
1090 vec![
1091 ProjectPath {
1092 worktree_id,
1093 path: Arc::from(Path::new("test/second.rs")),
1094 },
1095 ProjectPath {
1096 worktree_id,
1097 path: Arc::from(Path::new("test/third.rs")),
1098 },
1099 ProjectPath {
1100 worktree_id,
1101 path: Arc::from(Path::new("test/first.rs")),
1102 },
1103 ],
1104 "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \
11052nd item, as the last opened, 3rd item should go next as it was opened right before."
1106 );
1107 }
1108
1109 async fn open_close_queried_buffer(
1110 input: &str,
1111 expected_matches: usize,
1112 expected_editor_title: &str,
1113 window_id: usize,
1114 workspace: &ViewHandle<Workspace>,
1115 deterministic: &gpui::executor::Deterministic,
1116 cx: &mut gpui::TestAppContext,
1117 ) -> Vec<ProjectPath> {
1118 cx.dispatch_action(window_id, Toggle);
1119 let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
1120 finder
1121 .update(cx, |finder, cx| {
1122 finder.delegate_mut().update_matches(input.to_string(), cx)
1123 })
1124 .await;
1125 let history_items = finder.read_with(cx, |finder, _| {
1126 assert_eq!(
1127 finder.delegate().matches.len(),
1128 expected_matches,
1129 "Unexpected number of matches found for query {input}"
1130 );
1131 finder.delegate().history_items.clone()
1132 });
1133
1134 let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
1135 cx.dispatch_action(window_id, SelectNext);
1136 cx.dispatch_action(window_id, Confirm);
1137 deterministic.run_until_parked();
1138 active_pane
1139 .condition(cx, |pane, _| pane.active_item().is_some())
1140 .await;
1141 cx.read(|cx| {
1142 let active_item = active_pane.read(cx).active_item().unwrap();
1143 let active_editor_title = active_item
1144 .as_any()
1145 .downcast_ref::<Editor>()
1146 .unwrap()
1147 .read(cx)
1148 .title(cx);
1149 assert_eq!(
1150 expected_editor_title, active_editor_title,
1151 "Unexpected editor title for query {input}"
1152 );
1153 });
1154
1155 let mut original_items = HashMap::new();
1156 cx.read(|cx| {
1157 for pane in workspace.read(cx).panes() {
1158 let pane_id = pane.id();
1159 let pane = pane.read(cx);
1160 let insertion_result = original_items.insert(pane_id, pane.items().count());
1161 assert!(insertion_result.is_none(), "Pane id {pane_id} collision");
1162 }
1163 });
1164 workspace.update(cx, |workspace, cx| {
1165 Pane::close_active_item(workspace, &workspace::CloseActiveItem, cx);
1166 });
1167 deterministic.run_until_parked();
1168 cx.read(|cx| {
1169 for pane in workspace.read(cx).panes() {
1170 let pane_id = pane.id();
1171 let pane = pane.read(cx);
1172 match original_items.remove(&pane_id) {
1173 Some(original_items) => {
1174 assert_eq!(
1175 pane.items().count(),
1176 original_items.saturating_sub(1),
1177 "Pane id {pane_id} should have item closed"
1178 );
1179 }
1180 None => panic!("Pane id {pane_id} not found in original items"),
1181 }
1182 }
1183 });
1184
1185 history_items
1186 }
1187
1188 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1189 cx.foreground().forbid_parking();
1190 cx.update(|cx| {
1191 let state = AppState::test(cx);
1192 theme::init((), cx);
1193 language::init(cx);
1194 super::init(cx);
1195 editor::init(cx);
1196 workspace::init_settings(cx);
1197 state
1198 })
1199 }
1200
1201 fn test_path_like(test_str: &str) -> PathLikeWithPosition<FileSearchQuery> {
1202 PathLikeWithPosition::parse_str(test_str, |path_like_str| {
1203 Ok::<_, std::convert::Infallible>(FileSearchQuery {
1204 raw_query: test_str.to_owned(),
1205 file_query_end: if path_like_str == test_str {
1206 None
1207 } else {
1208 Some(path_like_str.len())
1209 },
1210 })
1211 })
1212 .unwrap()
1213 }
1214}