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, PathBuf},
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<FoundPath>,
29 matches: Matches,
30 selected_index: Option<usize>,
31 cancel_flag: Arc<AtomicBool>,
32 history_items: Vec<FoundPath>,
33}
34
35#[derive(Debug)]
36enum Matches {
37 History(Vec<FoundPath>),
38 Search(Vec<PathMatch>),
39}
40
41#[derive(Debug)]
42enum Match<'a> {
43 History(&'a FoundPath),
44 Search(&'a PathMatch),
45}
46
47impl Matches {
48 fn len(&self) -> usize {
49 match self {
50 Self::History(items) => items.len(),
51 Self::Search(items) => items.len(),
52 }
53 }
54
55 fn get(&self, index: usize) -> Option<Match<'_>> {
56 match self {
57 Self::History(items) => items.get(index).map(Match::History),
58 Self::Search(items) => items.get(index).map(Match::Search),
59 }
60 }
61}
62
63impl Default for Matches {
64 fn default() -> Self {
65 Self::History(Vec::new())
66 }
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
70struct FoundPath {
71 project: ProjectPath,
72 absolute: Option<PathBuf>,
73}
74
75impl FoundPath {
76 fn new(project: ProjectPath, absolute: Option<PathBuf>) -> Self {
77 Self { project, absolute }
78 }
79}
80
81actions!(file_finder, [Toggle]);
82
83pub fn init(cx: &mut AppContext) {
84 cx.add_action(toggle_file_finder);
85 FileFinder::init(cx);
86}
87
88const MAX_RECENT_SELECTIONS: usize = 20;
89
90fn toggle_file_finder(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
91 workspace.toggle_modal(cx, |workspace, cx| {
92 let project = workspace.project().read(cx);
93
94 let currently_opened_path = workspace
95 .active_item(cx)
96 .and_then(|item| item.project_path(cx))
97 .map(|project_path| {
98 let abs_path = project
99 .worktree_for_id(project_path.worktree_id, cx)
100 .map(|worktree| worktree.read(cx).abs_path().join(&project_path.path));
101 FoundPath::new(project_path, abs_path)
102 });
103
104 // if exists, bubble the currently opened path to the top
105 let history_items = currently_opened_path
106 .clone()
107 .into_iter()
108 .chain(
109 workspace
110 .recent_navigation_history(Some(MAX_RECENT_SELECTIONS), cx)
111 .into_iter()
112 .filter(|(history_path, _)| {
113 Some(history_path)
114 != currently_opened_path
115 .as_ref()
116 .map(|found_path| &found_path.project)
117 })
118 .filter(|(_, history_abs_path)| {
119 history_abs_path.as_ref()
120 != currently_opened_path
121 .as_ref()
122 .and_then(|found_path| found_path.absolute.as_ref())
123 })
124 .map(|(history_path, abs_path)| FoundPath::new(history_path, abs_path)),
125 )
126 .collect();
127
128 let project = workspace.project().clone();
129 let workspace = cx.handle().downgrade();
130 let finder = cx.add_view(|cx| {
131 Picker::new(
132 FileFinderDelegate::new(
133 workspace,
134 project,
135 currently_opened_path,
136 history_items,
137 cx,
138 ),
139 cx,
140 )
141 });
142 finder
143 });
144}
145
146pub enum Event {
147 Selected(ProjectPath),
148 Dismissed,
149}
150
151#[derive(Debug, Clone)]
152struct FileSearchQuery {
153 raw_query: String,
154 file_query_end: Option<usize>,
155}
156
157impl FileSearchQuery {
158 fn path_query(&self) -> &str {
159 match self.file_query_end {
160 Some(file_path_end) => &self.raw_query[..file_path_end],
161 None => &self.raw_query,
162 }
163 }
164}
165
166impl FileFinderDelegate {
167 fn new(
168 workspace: WeakViewHandle<Workspace>,
169 project: ModelHandle<Project>,
170 currently_opened_path: Option<FoundPath>,
171 history_items: Vec<FoundPath>,
172 cx: &mut ViewContext<FileFinder>,
173 ) -> Self {
174 cx.observe(&project, |picker, _, cx| {
175 picker.update_matches(picker.query(cx), cx);
176 })
177 .detach();
178 Self {
179 workspace,
180 project,
181 search_count: 0,
182 latest_search_id: 0,
183 latest_search_did_cancel: false,
184 latest_search_query: None,
185 currently_opened_path,
186 matches: Matches::default(),
187 selected_index: None,
188 cancel_flag: Arc::new(AtomicBool::new(false)),
189 history_items,
190 }
191 }
192
193 fn spawn_search(
194 &mut self,
195 query: PathLikeWithPosition<FileSearchQuery>,
196 cx: &mut ViewContext<FileFinder>,
197 ) -> Task<()> {
198 let relative_to = self
199 .currently_opened_path
200 .as_ref()
201 .map(|found_path| Arc::clone(&found_path.project.path));
202 let worktrees = self
203 .project
204 .read(cx)
205 .visible_worktrees(cx)
206 .collect::<Vec<_>>();
207 let include_root_name = worktrees.len() > 1;
208 let candidate_sets = worktrees
209 .into_iter()
210 .map(|worktree| {
211 let worktree = worktree.read(cx);
212 PathMatchCandidateSet {
213 snapshot: worktree.snapshot(),
214 include_ignored: worktree
215 .root_entry()
216 .map_or(false, |entry| entry.is_ignored),
217 include_root_name,
218 }
219 })
220 .collect::<Vec<_>>();
221
222 let search_id = util::post_inc(&mut self.search_count);
223 self.cancel_flag.store(true, atomic::Ordering::Relaxed);
224 self.cancel_flag = Arc::new(AtomicBool::new(false));
225 let cancel_flag = self.cancel_flag.clone();
226 cx.spawn(|picker, mut cx| async move {
227 let matches = fuzzy::match_path_sets(
228 candidate_sets.as_slice(),
229 query.path_like.path_query(),
230 relative_to,
231 false,
232 100,
233 &cancel_flag,
234 cx.background(),
235 )
236 .await;
237 let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
238 picker
239 .update(&mut cx, |picker, cx| {
240 picker
241 .delegate_mut()
242 .set_search_matches(search_id, did_cancel, query, matches, cx)
243 })
244 .log_err();
245 })
246 }
247
248 fn set_search_matches(
249 &mut self,
250 search_id: usize,
251 did_cancel: bool,
252 query: PathLikeWithPosition<FileSearchQuery>,
253 matches: Vec<PathMatch>,
254 cx: &mut ViewContext<FileFinder>,
255 ) {
256 if search_id >= self.latest_search_id {
257 self.latest_search_id = search_id;
258 if self.latest_search_did_cancel
259 && Some(query.path_like.path_query())
260 == self
261 .latest_search_query
262 .as_ref()
263 .map(|query| query.path_like.path_query())
264 {
265 match &mut self.matches {
266 Matches::History(_) => self.matches = Matches::Search(matches),
267 Matches::Search(search_matches) => {
268 util::extend_sorted(search_matches, matches.into_iter(), 100, |a, b| {
269 b.cmp(a)
270 })
271 }
272 }
273 } else {
274 self.matches = Matches::Search(matches);
275 }
276 self.latest_search_query = Some(query);
277 self.latest_search_did_cancel = did_cancel;
278 cx.notify();
279 }
280 }
281
282 fn labels_for_match(
283 &self,
284 path_match: Match,
285 cx: &AppContext,
286 ix: usize,
287 ) -> (String, Vec<usize>, String, Vec<usize>) {
288 let (file_name, file_name_positions, full_path, full_path_positions) = match path_match {
289 Match::History(found_path) => {
290 let worktree_id = found_path.project.worktree_id;
291 let project_relative_path = &found_path.project.path;
292 let has_worktree = self
293 .project
294 .read(cx)
295 .worktree_for_id(worktree_id, cx)
296 .is_some();
297
298 if !has_worktree {
299 if let Some(absolute_path) = &found_path.absolute {
300 return (
301 absolute_path
302 .file_name()
303 .map_or_else(
304 || project_relative_path.to_string_lossy(),
305 |file_name| file_name.to_string_lossy(),
306 )
307 .to_string(),
308 Vec::new(),
309 absolute_path.to_string_lossy().to_string(),
310 Vec::new(),
311 );
312 }
313 }
314
315 let mut path = Arc::clone(project_relative_path);
316 if project_relative_path.as_ref() == Path::new("") {
317 if let Some(absolute_path) = &found_path.absolute {
318 path = Arc::from(absolute_path.as_path());
319 }
320 }
321 self.labels_for_path_match(&PathMatch {
322 score: ix as f64,
323 positions: Vec::new(),
324 worktree_id: worktree_id.to_usize(),
325 path,
326 path_prefix: "".into(),
327 distance_to_relative_ancestor: usize::MAX,
328 })
329 }
330 Match::Search(path_match) => self.labels_for_path_match(path_match),
331 };
332
333 if file_name_positions.is_empty() {
334 if let Some(user_home_path) = std::env::var("HOME").ok() {
335 let user_home_path = user_home_path.trim();
336 if !user_home_path.is_empty() {
337 if (&full_path).starts_with(user_home_path) {
338 return (
339 file_name,
340 file_name_positions,
341 full_path.replace(user_home_path, "~"),
342 full_path_positions,
343 );
344 }
345 }
346 }
347 }
348
349 (
350 file_name,
351 file_name_positions,
352 full_path,
353 full_path_positions,
354 )
355 }
356
357 fn labels_for_path_match(
358 &self,
359 path_match: &PathMatch,
360 ) -> (String, Vec<usize>, String, Vec<usize>) {
361 let path = &path_match.path;
362 let path_string = path.to_string_lossy();
363 let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
364 let path_positions = path_match.positions.clone();
365
366 let file_name = path.file_name().map_or_else(
367 || path_match.path_prefix.to_string(),
368 |file_name| file_name.to_string_lossy().to_string(),
369 );
370 let file_name_start = path_match.path_prefix.chars().count() + path_string.chars().count()
371 - file_name.chars().count();
372 let file_name_positions = path_positions
373 .iter()
374 .filter_map(|pos| {
375 if pos >= &file_name_start {
376 Some(pos - file_name_start)
377 } else {
378 None
379 }
380 })
381 .collect();
382
383 (file_name, file_name_positions, full_path, path_positions)
384 }
385}
386
387impl PickerDelegate for FileFinderDelegate {
388 fn placeholder_text(&self) -> Arc<str> {
389 "Search project files...".into()
390 }
391
392 fn match_count(&self) -> usize {
393 self.matches.len()
394 }
395
396 fn selected_index(&self) -> usize {
397 self.selected_index.unwrap_or(0)
398 }
399
400 fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<FileFinder>) {
401 self.selected_index = Some(ix);
402 cx.notify();
403 }
404
405 fn update_matches(&mut self, raw_query: String, cx: &mut ViewContext<FileFinder>) -> Task<()> {
406 if raw_query.is_empty() {
407 let project = self.project.read(cx);
408 self.latest_search_id = post_inc(&mut self.search_count);
409 self.matches = Matches::History(
410 self.history_items
411 .iter()
412 .filter(|history_item| {
413 project
414 .worktree_for_id(history_item.project.worktree_id, cx)
415 .is_some()
416 || (project.is_local()
417 && history_item
418 .absolute
419 .as_ref()
420 .filter(|abs_path| abs_path.exists())
421 .is_some())
422 })
423 .cloned()
424 .collect(),
425 );
426 cx.notify();
427 Task::ready(())
428 } else {
429 let raw_query = &raw_query;
430 let query = PathLikeWithPosition::parse_str(raw_query, |path_like_str| {
431 Ok::<_, std::convert::Infallible>(FileSearchQuery {
432 raw_query: raw_query.to_owned(),
433 file_query_end: if path_like_str == raw_query {
434 None
435 } else {
436 Some(path_like_str.len())
437 },
438 })
439 })
440 .expect("infallible");
441 self.spawn_search(query, cx)
442 }
443 }
444
445 fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<FileFinder>) {
446 if let Some(m) = self.matches.get(self.selected_index()) {
447 if let Some(workspace) = self.workspace.upgrade(cx) {
448 let open_task = workspace.update(cx, move |workspace, cx| {
449 let split_or_open = |workspace: &mut Workspace, project_path, cx| {
450 if secondary {
451 workspace.split_path(project_path, cx)
452 } else {
453 workspace.open_path(project_path, None, true, cx)
454 }
455 };
456 match m {
457 Match::History(history_match) => {
458 let worktree_id = history_match.project.worktree_id;
459 if workspace
460 .project()
461 .read(cx)
462 .worktree_for_id(worktree_id, cx)
463 .is_some()
464 {
465 split_or_open(
466 workspace,
467 ProjectPath {
468 worktree_id,
469 path: Arc::clone(&history_match.project.path),
470 },
471 cx,
472 )
473 } else {
474 match history_match.absolute.as_ref() {
475 Some(abs_path) => {
476 if secondary {
477 workspace.split_abs_path(
478 abs_path.to_path_buf(),
479 false,
480 cx,
481 )
482 } else {
483 workspace.open_abs_path(
484 abs_path.to_path_buf(),
485 false,
486 cx,
487 )
488 }
489 }
490 None => split_or_open(
491 workspace,
492 ProjectPath {
493 worktree_id,
494 path: Arc::clone(&history_match.project.path),
495 },
496 cx,
497 ),
498 }
499 }
500 }
501 Match::Search(m) => split_or_open(
502 workspace,
503 ProjectPath {
504 worktree_id: WorktreeId::from_usize(m.worktree_id),
505 path: m.path.clone(),
506 },
507 cx,
508 ),
509 }
510 });
511
512 let row = self
513 .latest_search_query
514 .as_ref()
515 .and_then(|query| query.row)
516 .map(|row| row.saturating_sub(1));
517 let col = self
518 .latest_search_query
519 .as_ref()
520 .and_then(|query| query.column)
521 .unwrap_or(0)
522 .saturating_sub(1);
523 cx.spawn(|_, mut cx| async move {
524 let item = open_task.await.log_err()?;
525 if let Some(row) = row {
526 if let Some(active_editor) = item.downcast::<Editor>() {
527 active_editor
528 .downgrade()
529 .update(&mut cx, |editor, cx| {
530 let snapshot = editor.snapshot(cx).display_snapshot;
531 let point = snapshot
532 .buffer_snapshot
533 .clip_point(Point::new(row, col), Bias::Left);
534 editor.change_selections(Some(Autoscroll::center()), cx, |s| {
535 s.select_ranges([point..point])
536 });
537 })
538 .log_err();
539 }
540 }
541 workspace
542 .downgrade()
543 .update(&mut cx, |workspace, cx| workspace.dismiss_modal(cx))
544 .log_err();
545
546 Some(())
547 })
548 .detach();
549 }
550 }
551 }
552
553 fn dismissed(&mut self, _: &mut ViewContext<FileFinder>) {}
554
555 fn render_match(
556 &self,
557 ix: usize,
558 mouse_state: &mut MouseState,
559 selected: bool,
560 cx: &AppContext,
561 ) -> AnyElement<Picker<Self>> {
562 let path_match = self
563 .matches
564 .get(ix)
565 .expect("Invalid matches state: no element for index {ix}");
566 let theme = theme::current(cx);
567 let style = theme.picker.item.in_state(selected).style_for(mouse_state);
568 let (file_name, file_name_positions, full_path, full_path_positions) =
569 self.labels_for_match(path_match, cx, ix);
570 Flex::column()
571 .with_child(
572 Label::new(file_name, style.label.clone()).with_highlights(file_name_positions),
573 )
574 .with_child(
575 Label::new(full_path, style.label.clone()).with_highlights(full_path_positions),
576 )
577 .flex(1., false)
578 .contained()
579 .with_style(style.container)
580 .into_any_named("match")
581 }
582}
583
584#[cfg(test)]
585mod tests {
586 use std::{assert_eq, collections::HashMap, path::Path, time::Duration};
587
588 use super::*;
589 use editor::Editor;
590 use gpui::{TestAppContext, ViewHandle};
591 use menu::{Confirm, SelectNext};
592 use serde_json::json;
593 use workspace::{AppState, Workspace};
594
595 #[ctor::ctor]
596 fn init_logger() {
597 if std::env::var("RUST_LOG").is_ok() {
598 env_logger::init();
599 }
600 }
601
602 #[gpui::test]
603 async fn test_matching_paths(cx: &mut TestAppContext) {
604 let app_state = init_test(cx);
605 app_state
606 .fs
607 .as_fake()
608 .insert_tree(
609 "/root",
610 json!({
611 "a": {
612 "banana": "",
613 "bandana": "",
614 }
615 }),
616 )
617 .await;
618
619 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
620 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
621 let workspace = window.root(cx);
622 cx.dispatch_action(window.into(), Toggle);
623
624 let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
625 finder
626 .update(cx, |finder, cx| {
627 finder.delegate_mut().update_matches("bna".to_string(), cx)
628 })
629 .await;
630 finder.read_with(cx, |finder, _| {
631 assert_eq!(finder.delegate().matches.len(), 2);
632 });
633
634 let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
635 cx.dispatch_action(window.into(), SelectNext);
636 cx.dispatch_action(window.into(), Confirm);
637 active_pane
638 .condition(cx, |pane, _| pane.active_item().is_some())
639 .await;
640 cx.read(|cx| {
641 let active_item = active_pane.read(cx).active_item().unwrap();
642 assert_eq!(
643 active_item
644 .as_any()
645 .downcast_ref::<Editor>()
646 .unwrap()
647 .read(cx)
648 .title(cx),
649 "bandana"
650 );
651 });
652 }
653
654 #[gpui::test]
655 async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
656 let app_state = init_test(cx);
657
658 let first_file_name = "first.rs";
659 let first_file_contents = "// First Rust file";
660 app_state
661 .fs
662 .as_fake()
663 .insert_tree(
664 "/src",
665 json!({
666 "test": {
667 first_file_name: first_file_contents,
668 "second.rs": "// Second Rust file",
669 }
670 }),
671 )
672 .await;
673
674 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
675 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
676 let workspace = window.root(cx);
677 cx.dispatch_action(window.into(), Toggle);
678 let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
679
680 let file_query = &first_file_name[..3];
681 let file_row = 1;
682 let file_column = 3;
683 assert!(file_column <= first_file_contents.len());
684 let query_inside_file = format!("{file_query}:{file_row}:{file_column}");
685 finder
686 .update(cx, |finder, cx| {
687 finder
688 .delegate_mut()
689 .update_matches(query_inside_file.to_string(), cx)
690 })
691 .await;
692 finder.read_with(cx, |finder, _| {
693 let finder = finder.delegate();
694 assert_eq!(finder.matches.len(), 1);
695 let latest_search_query = finder
696 .latest_search_query
697 .as_ref()
698 .expect("Finder should have a query after the update_matches call");
699 assert_eq!(latest_search_query.path_like.raw_query, query_inside_file);
700 assert_eq!(
701 latest_search_query.path_like.file_query_end,
702 Some(file_query.len())
703 );
704 assert_eq!(latest_search_query.row, Some(file_row));
705 assert_eq!(latest_search_query.column, Some(file_column as u32));
706 });
707
708 let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
709 cx.dispatch_action(window.into(), SelectNext);
710 cx.dispatch_action(window.into(), Confirm);
711 active_pane
712 .condition(cx, |pane, _| pane.active_item().is_some())
713 .await;
714 let editor = cx.update(|cx| {
715 let active_item = active_pane.read(cx).active_item().unwrap();
716 active_item.downcast::<Editor>().unwrap()
717 });
718 cx.foreground().advance_clock(Duration::from_secs(2));
719 cx.foreground().start_waiting();
720 cx.foreground().finish_waiting();
721 editor.update(cx, |editor, cx| {
722 let all_selections = editor.selections.all_adjusted(cx);
723 assert_eq!(
724 all_selections.len(),
725 1,
726 "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
727 );
728 let caret_selection = all_selections.into_iter().next().unwrap();
729 assert_eq!(caret_selection.start, caret_selection.end,
730 "Caret selection should have its start and end at the same position");
731 assert_eq!(file_row, caret_selection.start.row + 1,
732 "Query inside file should get caret with the same focus row");
733 assert_eq!(file_column, caret_selection.start.column as usize + 1,
734 "Query inside file should get caret with the same focus column");
735 });
736 }
737
738 #[gpui::test]
739 async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
740 let app_state = init_test(cx);
741
742 let first_file_name = "first.rs";
743 let first_file_contents = "// First Rust file";
744 app_state
745 .fs
746 .as_fake()
747 .insert_tree(
748 "/src",
749 json!({
750 "test": {
751 first_file_name: first_file_contents,
752 "second.rs": "// Second Rust file",
753 }
754 }),
755 )
756 .await;
757
758 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
759 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
760 let workspace = window.root(cx);
761 cx.dispatch_action(window.into(), Toggle);
762 let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
763
764 let file_query = &first_file_name[..3];
765 let file_row = 200;
766 let file_column = 300;
767 assert!(file_column > first_file_contents.len());
768 let query_outside_file = format!("{file_query}:{file_row}:{file_column}");
769 finder
770 .update(cx, |finder, cx| {
771 finder
772 .delegate_mut()
773 .update_matches(query_outside_file.to_string(), cx)
774 })
775 .await;
776 finder.read_with(cx, |finder, _| {
777 let finder = finder.delegate();
778 assert_eq!(finder.matches.len(), 1);
779 let latest_search_query = finder
780 .latest_search_query
781 .as_ref()
782 .expect("Finder should have a query after the update_matches call");
783 assert_eq!(latest_search_query.path_like.raw_query, query_outside_file);
784 assert_eq!(
785 latest_search_query.path_like.file_query_end,
786 Some(file_query.len())
787 );
788 assert_eq!(latest_search_query.row, Some(file_row));
789 assert_eq!(latest_search_query.column, Some(file_column as u32));
790 });
791
792 let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
793 cx.dispatch_action(window.into(), SelectNext);
794 cx.dispatch_action(window.into(), Confirm);
795 active_pane
796 .condition(cx, |pane, _| pane.active_item().is_some())
797 .await;
798 let editor = cx.update(|cx| {
799 let active_item = active_pane.read(cx).active_item().unwrap();
800 active_item.downcast::<Editor>().unwrap()
801 });
802 cx.foreground().advance_clock(Duration::from_secs(2));
803 cx.foreground().start_waiting();
804 cx.foreground().finish_waiting();
805 editor.update(cx, |editor, cx| {
806 let all_selections = editor.selections.all_adjusted(cx);
807 assert_eq!(
808 all_selections.len(),
809 1,
810 "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
811 );
812 let caret_selection = all_selections.into_iter().next().unwrap();
813 assert_eq!(caret_selection.start, caret_selection.end,
814 "Caret selection should have its start and end at the same position");
815 assert_eq!(0, caret_selection.start.row,
816 "Excessive rows (as in query outside file borders) should get trimmed to last file row");
817 assert_eq!(first_file_contents.len(), caret_selection.start.column as usize,
818 "Excessive columns (as in query outside file borders) should get trimmed to selected row's last column");
819 });
820 }
821
822 #[gpui::test]
823 async fn test_matching_cancellation(cx: &mut TestAppContext) {
824 let app_state = init_test(cx);
825 app_state
826 .fs
827 .as_fake()
828 .insert_tree(
829 "/dir",
830 json!({
831 "hello": "",
832 "goodbye": "",
833 "halogen-light": "",
834 "happiness": "",
835 "height": "",
836 "hi": "",
837 "hiccup": "",
838 }),
839 )
840 .await;
841
842 let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await;
843 let workspace = cx
844 .add_window(|cx| Workspace::test_new(project, cx))
845 .root(cx);
846 let finder = cx
847 .add_window(|cx| {
848 Picker::new(
849 FileFinderDelegate::new(
850 workspace.downgrade(),
851 workspace.read(cx).project().clone(),
852 None,
853 Vec::new(),
854 cx,
855 ),
856 cx,
857 )
858 })
859 .root(cx);
860
861 let query = test_path_like("hi");
862 finder
863 .update(cx, |f, cx| f.delegate_mut().spawn_search(query.clone(), cx))
864 .await;
865 finder.read_with(cx, |f, _| assert_eq!(f.delegate().matches.len(), 5));
866
867 finder.update(cx, |finder, cx| {
868 let delegate = finder.delegate_mut();
869 let matches = match &delegate.matches {
870 Matches::Search(path_matches) => path_matches,
871 _ => panic!("Search matches expected"),
872 }
873 .clone();
874
875 // Simulate a search being cancelled after the time limit,
876 // returning only a subset of the matches that would have been found.
877 drop(delegate.spawn_search(query.clone(), cx));
878 delegate.set_search_matches(
879 delegate.latest_search_id,
880 true, // did-cancel
881 query.clone(),
882 vec![matches[1].clone(), matches[3].clone()],
883 cx,
884 );
885
886 // Simulate another cancellation.
887 drop(delegate.spawn_search(query.clone(), cx));
888 delegate.set_search_matches(
889 delegate.latest_search_id,
890 true, // did-cancel
891 query.clone(),
892 vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
893 cx,
894 );
895
896 match &delegate.matches {
897 Matches::Search(new_matches) => {
898 assert_eq!(new_matches.as_slice(), &matches[0..4])
899 }
900 _ => panic!("Search matches expected"),
901 };
902 });
903 }
904
905 #[gpui::test]
906 async fn test_ignored_files(cx: &mut TestAppContext) {
907 let app_state = init_test(cx);
908 app_state
909 .fs
910 .as_fake()
911 .insert_tree(
912 "/ancestor",
913 json!({
914 ".gitignore": "ignored-root",
915 "ignored-root": {
916 "happiness": "",
917 "height": "",
918 "hi": "",
919 "hiccup": "",
920 },
921 "tracked-root": {
922 ".gitignore": "height",
923 "happiness": "",
924 "height": "",
925 "hi": "",
926 "hiccup": "",
927 },
928 }),
929 )
930 .await;
931
932 let project = Project::test(
933 app_state.fs.clone(),
934 [
935 "/ancestor/tracked-root".as_ref(),
936 "/ancestor/ignored-root".as_ref(),
937 ],
938 cx,
939 )
940 .await;
941 let workspace = cx
942 .add_window(|cx| Workspace::test_new(project, cx))
943 .root(cx);
944 let finder = cx
945 .add_window(|cx| {
946 Picker::new(
947 FileFinderDelegate::new(
948 workspace.downgrade(),
949 workspace.read(cx).project().clone(),
950 None,
951 Vec::new(),
952 cx,
953 ),
954 cx,
955 )
956 })
957 .root(cx);
958 finder
959 .update(cx, |f, cx| {
960 f.delegate_mut().spawn_search(test_path_like("hi"), cx)
961 })
962 .await;
963 finder.read_with(cx, |f, _| assert_eq!(f.delegate().matches.len(), 7));
964 }
965
966 #[gpui::test]
967 async fn test_single_file_worktrees(cx: &mut TestAppContext) {
968 let app_state = init_test(cx);
969 app_state
970 .fs
971 .as_fake()
972 .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
973 .await;
974
975 let project = Project::test(
976 app_state.fs.clone(),
977 ["/root/the-parent-dir/the-file".as_ref()],
978 cx,
979 )
980 .await;
981 let workspace = cx
982 .add_window(|cx| Workspace::test_new(project, cx))
983 .root(cx);
984 let finder = cx
985 .add_window(|cx| {
986 Picker::new(
987 FileFinderDelegate::new(
988 workspace.downgrade(),
989 workspace.read(cx).project().clone(),
990 None,
991 Vec::new(),
992 cx,
993 ),
994 cx,
995 )
996 })
997 .root(cx);
998
999 // Even though there is only one worktree, that worktree's filename
1000 // is included in the matching, because the worktree is a single file.
1001 finder
1002 .update(cx, |f, cx| {
1003 f.delegate_mut().spawn_search(test_path_like("thf"), cx)
1004 })
1005 .await;
1006 cx.read(|cx| {
1007 let finder = finder.read(cx);
1008 let delegate = finder.delegate();
1009 let matches = match &delegate.matches {
1010 Matches::Search(path_matches) => path_matches,
1011 _ => panic!("Search matches expected"),
1012 };
1013 assert_eq!(matches.len(), 1);
1014
1015 let (file_name, file_name_positions, full_path, full_path_positions) =
1016 delegate.labels_for_path_match(&matches[0]);
1017 assert_eq!(file_name, "the-file");
1018 assert_eq!(file_name_positions, &[0, 1, 4]);
1019 assert_eq!(full_path, "the-file");
1020 assert_eq!(full_path_positions, &[0, 1, 4]);
1021 });
1022
1023 // Since the worktree root is a file, searching for its name followed by a slash does
1024 // not match anything.
1025 finder
1026 .update(cx, |f, cx| {
1027 f.delegate_mut().spawn_search(test_path_like("thf/"), cx)
1028 })
1029 .await;
1030 finder.read_with(cx, |f, _| assert_eq!(f.delegate().matches.len(), 0));
1031 }
1032
1033 #[gpui::test]
1034 async fn test_path_distance_ordering(cx: &mut TestAppContext) {
1035 let app_state = init_test(cx);
1036 app_state
1037 .fs
1038 .as_fake()
1039 .insert_tree(
1040 "/root",
1041 json!({
1042 "dir1": { "a.txt": "" },
1043 "dir2": {
1044 "a.txt": "",
1045 "b.txt": ""
1046 }
1047 }),
1048 )
1049 .await;
1050
1051 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1052 let workspace = cx
1053 .add_window(|cx| Workspace::test_new(project, cx))
1054 .root(cx);
1055 let worktree_id = cx.read(|cx| {
1056 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1057 assert_eq!(worktrees.len(), 1);
1058 WorktreeId::from_usize(worktrees[0].id())
1059 });
1060
1061 // When workspace has an active item, sort items which are closer to that item
1062 // first when they have the same name. In this case, b.txt is closer to dir2's a.txt
1063 // so that one should be sorted earlier
1064 let b_path = Some(dummy_found_path(ProjectPath {
1065 worktree_id,
1066 path: Arc::from(Path::new("/root/dir2/b.txt")),
1067 }));
1068 let finder = cx
1069 .add_window(|cx| {
1070 Picker::new(
1071 FileFinderDelegate::new(
1072 workspace.downgrade(),
1073 workspace.read(cx).project().clone(),
1074 b_path,
1075 Vec::new(),
1076 cx,
1077 ),
1078 cx,
1079 )
1080 })
1081 .root(cx);
1082
1083 finder
1084 .update(cx, |f, cx| {
1085 f.delegate_mut().spawn_search(test_path_like("a.txt"), cx)
1086 })
1087 .await;
1088
1089 finder.read_with(cx, |f, _| {
1090 let delegate = f.delegate();
1091 let matches = match &delegate.matches {
1092 Matches::Search(path_matches) => path_matches,
1093 _ => panic!("Search matches expected"),
1094 };
1095 assert_eq!(matches[0].path.as_ref(), Path::new("dir2/a.txt"));
1096 assert_eq!(matches[1].path.as_ref(), Path::new("dir1/a.txt"));
1097 });
1098 }
1099
1100 #[gpui::test]
1101 async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
1102 let app_state = init_test(cx);
1103 app_state
1104 .fs
1105 .as_fake()
1106 .insert_tree(
1107 "/root",
1108 json!({
1109 "dir1": {},
1110 "dir2": {
1111 "dir3": {}
1112 }
1113 }),
1114 )
1115 .await;
1116
1117 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1118 let workspace = cx
1119 .add_window(|cx| Workspace::test_new(project, cx))
1120 .root(cx);
1121 let finder = cx
1122 .add_window(|cx| {
1123 Picker::new(
1124 FileFinderDelegate::new(
1125 workspace.downgrade(),
1126 workspace.read(cx).project().clone(),
1127 None,
1128 Vec::new(),
1129 cx,
1130 ),
1131 cx,
1132 )
1133 })
1134 .root(cx);
1135 finder
1136 .update(cx, |f, cx| {
1137 f.delegate_mut().spawn_search(test_path_like("dir"), cx)
1138 })
1139 .await;
1140 cx.read(|cx| {
1141 let finder = finder.read(cx);
1142 assert_eq!(finder.delegate().matches.len(), 0);
1143 });
1144 }
1145
1146 #[gpui::test]
1147 async fn test_query_history(
1148 deterministic: Arc<gpui::executor::Deterministic>,
1149 cx: &mut gpui::TestAppContext,
1150 ) {
1151 let app_state = init_test(cx);
1152
1153 app_state
1154 .fs
1155 .as_fake()
1156 .insert_tree(
1157 "/src",
1158 json!({
1159 "test": {
1160 "first.rs": "// First Rust file",
1161 "second.rs": "// Second Rust file",
1162 "third.rs": "// Third Rust file",
1163 }
1164 }),
1165 )
1166 .await;
1167
1168 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1169 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
1170 let workspace = window.root(cx);
1171 let worktree_id = cx.read(|cx| {
1172 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1173 assert_eq!(worktrees.len(), 1);
1174 WorktreeId::from_usize(worktrees[0].id())
1175 });
1176
1177 // Open and close panels, getting their history items afterwards.
1178 // Ensure history items get populated with opened items, and items are kept in a certain order.
1179 // The history lags one opened buffer behind, since it's updated in the search panel only on its reopen.
1180 //
1181 // TODO: without closing, the opened items do not propagate their history changes for some reason
1182 // it does work in real app though, only tests do not propagate.
1183
1184 let initial_history = open_close_queried_buffer(
1185 "fir",
1186 1,
1187 "first.rs",
1188 window.into(),
1189 &workspace,
1190 &deterministic,
1191 cx,
1192 )
1193 .await;
1194 assert!(
1195 initial_history.is_empty(),
1196 "Should have no history before opening any files"
1197 );
1198
1199 let history_after_first = open_close_queried_buffer(
1200 "sec",
1201 1,
1202 "second.rs",
1203 window.into(),
1204 &workspace,
1205 &deterministic,
1206 cx,
1207 )
1208 .await;
1209 assert_eq!(
1210 history_after_first,
1211 vec![FoundPath::new(
1212 ProjectPath {
1213 worktree_id,
1214 path: Arc::from(Path::new("test/first.rs")),
1215 },
1216 Some(PathBuf::from("/src/test/first.rs"))
1217 )],
1218 "Should show 1st opened item in the history when opening the 2nd item"
1219 );
1220
1221 let history_after_second = open_close_queried_buffer(
1222 "thi",
1223 1,
1224 "third.rs",
1225 window.into(),
1226 &workspace,
1227 &deterministic,
1228 cx,
1229 )
1230 .await;
1231 assert_eq!(
1232 history_after_second,
1233 vec![
1234 FoundPath::new(
1235 ProjectPath {
1236 worktree_id,
1237 path: Arc::from(Path::new("test/second.rs")),
1238 },
1239 Some(PathBuf::from("/src/test/second.rs"))
1240 ),
1241 FoundPath::new(
1242 ProjectPath {
1243 worktree_id,
1244 path: Arc::from(Path::new("test/first.rs")),
1245 },
1246 Some(PathBuf::from("/src/test/first.rs"))
1247 ),
1248 ],
1249 "Should show 1st and 2nd opened items in the history when opening the 3rd item. \
12502nd item should be the first in the history, as the last opened."
1251 );
1252
1253 let history_after_third = open_close_queried_buffer(
1254 "sec",
1255 1,
1256 "second.rs",
1257 window.into(),
1258 &workspace,
1259 &deterministic,
1260 cx,
1261 )
1262 .await;
1263 assert_eq!(
1264 history_after_third,
1265 vec![
1266 FoundPath::new(
1267 ProjectPath {
1268 worktree_id,
1269 path: Arc::from(Path::new("test/third.rs")),
1270 },
1271 Some(PathBuf::from("/src/test/third.rs"))
1272 ),
1273 FoundPath::new(
1274 ProjectPath {
1275 worktree_id,
1276 path: Arc::from(Path::new("test/second.rs")),
1277 },
1278 Some(PathBuf::from("/src/test/second.rs"))
1279 ),
1280 FoundPath::new(
1281 ProjectPath {
1282 worktree_id,
1283 path: Arc::from(Path::new("test/first.rs")),
1284 },
1285 Some(PathBuf::from("/src/test/first.rs"))
1286 ),
1287 ],
1288 "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \
12893rd item should be the first in the history, as the last opened."
1290 );
1291
1292 let history_after_second_again = open_close_queried_buffer(
1293 "thi",
1294 1,
1295 "third.rs",
1296 window.into(),
1297 &workspace,
1298 &deterministic,
1299 cx,
1300 )
1301 .await;
1302 assert_eq!(
1303 history_after_second_again,
1304 vec![
1305 FoundPath::new(
1306 ProjectPath {
1307 worktree_id,
1308 path: Arc::from(Path::new("test/second.rs")),
1309 },
1310 Some(PathBuf::from("/src/test/second.rs"))
1311 ),
1312 FoundPath::new(
1313 ProjectPath {
1314 worktree_id,
1315 path: Arc::from(Path::new("test/third.rs")),
1316 },
1317 Some(PathBuf::from("/src/test/third.rs"))
1318 ),
1319 FoundPath::new(
1320 ProjectPath {
1321 worktree_id,
1322 path: Arc::from(Path::new("test/first.rs")),
1323 },
1324 Some(PathBuf::from("/src/test/first.rs"))
1325 ),
1326 ],
1327 "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \
13282nd item, as the last opened, 3rd item should go next as it was opened right before."
1329 );
1330 }
1331
1332 #[gpui::test]
1333 async fn test_external_files_history(
1334 deterministic: Arc<gpui::executor::Deterministic>,
1335 cx: &mut gpui::TestAppContext,
1336 ) {
1337 let app_state = init_test(cx);
1338
1339 app_state
1340 .fs
1341 .as_fake()
1342 .insert_tree(
1343 "/src",
1344 json!({
1345 "test": {
1346 "first.rs": "// First Rust file",
1347 "second.rs": "// Second Rust file",
1348 }
1349 }),
1350 )
1351 .await;
1352
1353 app_state
1354 .fs
1355 .as_fake()
1356 .insert_tree(
1357 "/external-src",
1358 json!({
1359 "test": {
1360 "third.rs": "// Third Rust file",
1361 "fourth.rs": "// Fourth Rust file",
1362 }
1363 }),
1364 )
1365 .await;
1366
1367 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1368 cx.update(|cx| {
1369 project.update(cx, |project, cx| {
1370 project.find_or_create_local_worktree("/external-src", false, cx)
1371 })
1372 })
1373 .detach();
1374 deterministic.run_until_parked();
1375
1376 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
1377 let workspace = window.root(cx);
1378 let worktree_id = cx.read(|cx| {
1379 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1380 assert_eq!(worktrees.len(), 1,);
1381
1382 WorktreeId::from_usize(worktrees[0].id())
1383 });
1384 workspace
1385 .update(cx, |workspace, cx| {
1386 workspace.open_abs_path(PathBuf::from("/external-src/test/third.rs"), false, cx)
1387 })
1388 .detach();
1389 deterministic.run_until_parked();
1390 let external_worktree_id = cx.read(|cx| {
1391 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1392 assert_eq!(
1393 worktrees.len(),
1394 2,
1395 "External file should get opened in a new worktree"
1396 );
1397
1398 WorktreeId::from_usize(
1399 worktrees
1400 .into_iter()
1401 .find(|worktree| worktree.id() != worktree_id.to_usize())
1402 .expect("New worktree should have a different id")
1403 .id(),
1404 )
1405 });
1406 close_active_item(&workspace, &deterministic, cx).await;
1407
1408 let initial_history_items = open_close_queried_buffer(
1409 "sec",
1410 1,
1411 "second.rs",
1412 window.into(),
1413 &workspace,
1414 &deterministic,
1415 cx,
1416 )
1417 .await;
1418 assert_eq!(
1419 initial_history_items,
1420 vec![FoundPath::new(
1421 ProjectPath {
1422 worktree_id: external_worktree_id,
1423 path: Arc::from(Path::new("")),
1424 },
1425 Some(PathBuf::from("/external-src/test/third.rs"))
1426 )],
1427 "Should show external file with its full path in the history after it was open"
1428 );
1429
1430 let updated_history_items = open_close_queried_buffer(
1431 "fir",
1432 1,
1433 "first.rs",
1434 window.into(),
1435 &workspace,
1436 &deterministic,
1437 cx,
1438 )
1439 .await;
1440 assert_eq!(
1441 updated_history_items,
1442 vec![
1443 FoundPath::new(
1444 ProjectPath {
1445 worktree_id,
1446 path: Arc::from(Path::new("test/second.rs")),
1447 },
1448 Some(PathBuf::from("/src/test/second.rs"))
1449 ),
1450 FoundPath::new(
1451 ProjectPath {
1452 worktree_id: external_worktree_id,
1453 path: Arc::from(Path::new("")),
1454 },
1455 Some(PathBuf::from("/external-src/test/third.rs"))
1456 ),
1457 ],
1458 "Should keep external file with history updates",
1459 );
1460 }
1461
1462 async fn open_close_queried_buffer(
1463 input: &str,
1464 expected_matches: usize,
1465 expected_editor_title: &str,
1466 window: gpui::AnyWindowHandle,
1467 workspace: &ViewHandle<Workspace>,
1468 deterministic: &gpui::executor::Deterministic,
1469 cx: &mut gpui::TestAppContext,
1470 ) -> Vec<FoundPath> {
1471 cx.dispatch_action(window, Toggle);
1472 let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
1473 finder
1474 .update(cx, |finder, cx| {
1475 finder.delegate_mut().update_matches(input.to_string(), cx)
1476 })
1477 .await;
1478 let history_items = finder.read_with(cx, |finder, _| {
1479 assert_eq!(
1480 finder.delegate().matches.len(),
1481 expected_matches,
1482 "Unexpected number of matches found for query {input}"
1483 );
1484 finder.delegate().history_items.clone()
1485 });
1486
1487 let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
1488 cx.dispatch_action(window, SelectNext);
1489 cx.dispatch_action(window, Confirm);
1490 deterministic.run_until_parked();
1491 active_pane
1492 .condition(cx, |pane, _| pane.active_item().is_some())
1493 .await;
1494 cx.read(|cx| {
1495 let active_item = active_pane.read(cx).active_item().unwrap();
1496 let active_editor_title = active_item
1497 .as_any()
1498 .downcast_ref::<Editor>()
1499 .unwrap()
1500 .read(cx)
1501 .title(cx);
1502 assert_eq!(
1503 expected_editor_title, active_editor_title,
1504 "Unexpected editor title for query {input}"
1505 );
1506 });
1507
1508 close_active_item(workspace, deterministic, cx).await;
1509
1510 history_items
1511 }
1512
1513 async fn close_active_item(
1514 workspace: &ViewHandle<Workspace>,
1515 deterministic: &gpui::executor::Deterministic,
1516 cx: &mut TestAppContext,
1517 ) {
1518 let mut original_items = HashMap::new();
1519 cx.read(|cx| {
1520 for pane in workspace.read(cx).panes() {
1521 let pane_id = pane.id();
1522 let pane = pane.read(cx);
1523 let insertion_result = original_items.insert(pane_id, pane.items().count());
1524 assert!(insertion_result.is_none(), "Pane id {pane_id} collision");
1525 }
1526 });
1527
1528 let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
1529 active_pane
1530 .update(cx, |pane, cx| {
1531 pane.close_active_item(&workspace::CloseActiveItem { save_intent: None }, cx)
1532 .unwrap()
1533 })
1534 .await
1535 .unwrap();
1536 deterministic.run_until_parked();
1537 cx.read(|cx| {
1538 for pane in workspace.read(cx).panes() {
1539 let pane_id = pane.id();
1540 let pane = pane.read(cx);
1541 match original_items.remove(&pane_id) {
1542 Some(original_items) => {
1543 assert_eq!(
1544 pane.items().count(),
1545 original_items.saturating_sub(1),
1546 "Pane id {pane_id} should have item closed"
1547 );
1548 }
1549 None => panic!("Pane id {pane_id} not found in original items"),
1550 }
1551 }
1552 });
1553 assert!(
1554 original_items.len() <= 1,
1555 "At most one panel should got closed"
1556 );
1557 }
1558
1559 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1560 cx.foreground().forbid_parking();
1561 cx.update(|cx| {
1562 let state = AppState::test(cx);
1563 theme::init((), cx);
1564 language::init(cx);
1565 super::init(cx);
1566 editor::init(cx);
1567 workspace::init_settings(cx);
1568 Project::init_settings(cx);
1569 state
1570 })
1571 }
1572
1573 fn test_path_like(test_str: &str) -> PathLikeWithPosition<FileSearchQuery> {
1574 PathLikeWithPosition::parse_str(test_str, |path_like_str| {
1575 Ok::<_, std::convert::Infallible>(FileSearchQuery {
1576 raw_query: test_str.to_owned(),
1577 file_query_end: if path_like_str == test_str {
1578 None
1579 } else {
1580 Some(path_like_str.len())
1581 },
1582 })
1583 })
1584 .unwrap()
1585 }
1586
1587 fn dummy_found_path(project_path: ProjectPath) -> FoundPath {
1588 FoundPath {
1589 project: project_path,
1590 absolute: None,
1591 }
1592 }
1593}