1use std::{future::IntoFuture, path::Path, time::Duration};
2
3use super::*;
4use editor::Editor;
5use gpui::{Entity, TestAppContext, VisualTestContext};
6use menu::{Confirm, SelectNext, SelectPrevious};
7use pretty_assertions::assert_eq;
8use project::{FS_WATCH_LATENCY, RemoveOptions};
9use serde_json::json;
10use util::path;
11use workspace::{AppState, CloseActiveItem, OpenOptions, ToggleFileFinder, Workspace};
12
13#[ctor::ctor]
14fn init_logger() {
15 zlog::init_test();
16}
17
18#[test]
19fn test_path_elision() {
20 #[track_caller]
21 fn check(path: &str, budget: usize, matches: impl IntoIterator<Item = usize>, expected: &str) {
22 let mut path = path.to_owned();
23 let slice = PathComponentSlice::new(&path);
24 let matches = Vec::from_iter(matches);
25 if let Some(range) = slice.elision_range(budget - 1, &matches) {
26 path.replace_range(range, "…");
27 }
28 assert_eq!(path, expected);
29 }
30
31 // Simple cases, mostly to check that different path shapes are handled gracefully.
32 check("p/a/b/c/d/", 6, [], "p/…/d/");
33 check("p/a/b/c/d/", 1, [2, 4, 6], "p/a/b/c/d/");
34 check("p/a/b/c/d/", 10, [2, 6], "p/a/…/c/d/");
35 check("p/a/b/c/d/", 8, [6], "p/…/c/d/");
36
37 check("p/a/b/c/d", 5, [], "p/…/d");
38 check("p/a/b/c/d", 9, [2, 4, 6], "p/a/b/c/d");
39 check("p/a/b/c/d", 9, [2, 6], "p/a/…/c/d");
40 check("p/a/b/c/d", 7, [6], "p/…/c/d");
41
42 check("/p/a/b/c/d/", 7, [], "/p/…/d/");
43 check("/p/a/b/c/d/", 11, [3, 5, 7], "/p/a/b/c/d/");
44 check("/p/a/b/c/d/", 11, [3, 7], "/p/a/…/c/d/");
45 check("/p/a/b/c/d/", 9, [7], "/p/…/c/d/");
46
47 // If the budget can't be met, no elision is done.
48 check(
49 "project/dir/child/grandchild",
50 5,
51 [],
52 "project/dir/child/grandchild",
53 );
54
55 // The longest unmatched segment is picked for elision.
56 check(
57 "project/one/two/X/three/sub",
58 21,
59 [16],
60 "project/…/X/three/sub",
61 );
62
63 // Elision stops when the budget is met, even though there are more components in the chosen segment.
64 // It proceeds from the end of the unmatched segment that is closer to the midpoint of the path.
65 check(
66 "project/one/two/three/X/sub",
67 21,
68 [22],
69 "project/…/three/X/sub",
70 )
71}
72
73#[test]
74fn test_custom_project_search_ordering_in_file_finder() {
75 let mut file_finder_sorted_output = vec![
76 ProjectPanelOrdMatch(PathMatch {
77 score: 0.5,
78 positions: Vec::new(),
79 worktree_id: 0,
80 path: Arc::from(Path::new("b0.5")),
81 path_prefix: Arc::default(),
82 distance_to_relative_ancestor: 0,
83 is_dir: false,
84 }),
85 ProjectPanelOrdMatch(PathMatch {
86 score: 1.0,
87 positions: Vec::new(),
88 worktree_id: 0,
89 path: Arc::from(Path::new("c1.0")),
90 path_prefix: Arc::default(),
91 distance_to_relative_ancestor: 0,
92 is_dir: false,
93 }),
94 ProjectPanelOrdMatch(PathMatch {
95 score: 1.0,
96 positions: Vec::new(),
97 worktree_id: 0,
98 path: Arc::from(Path::new("a1.0")),
99 path_prefix: Arc::default(),
100 distance_to_relative_ancestor: 0,
101 is_dir: false,
102 }),
103 ProjectPanelOrdMatch(PathMatch {
104 score: 0.5,
105 positions: Vec::new(),
106 worktree_id: 0,
107 path: Arc::from(Path::new("a0.5")),
108 path_prefix: Arc::default(),
109 distance_to_relative_ancestor: 0,
110 is_dir: false,
111 }),
112 ProjectPanelOrdMatch(PathMatch {
113 score: 1.0,
114 positions: Vec::new(),
115 worktree_id: 0,
116 path: Arc::from(Path::new("b1.0")),
117 path_prefix: Arc::default(),
118 distance_to_relative_ancestor: 0,
119 is_dir: false,
120 }),
121 ];
122 file_finder_sorted_output.sort_by(|a, b| b.cmp(a));
123
124 assert_eq!(
125 file_finder_sorted_output,
126 vec![
127 ProjectPanelOrdMatch(PathMatch {
128 score: 1.0,
129 positions: Vec::new(),
130 worktree_id: 0,
131 path: Arc::from(Path::new("a1.0")),
132 path_prefix: Arc::default(),
133 distance_to_relative_ancestor: 0,
134 is_dir: false,
135 }),
136 ProjectPanelOrdMatch(PathMatch {
137 score: 1.0,
138 positions: Vec::new(),
139 worktree_id: 0,
140 path: Arc::from(Path::new("b1.0")),
141 path_prefix: Arc::default(),
142 distance_to_relative_ancestor: 0,
143 is_dir: false,
144 }),
145 ProjectPanelOrdMatch(PathMatch {
146 score: 1.0,
147 positions: Vec::new(),
148 worktree_id: 0,
149 path: Arc::from(Path::new("c1.0")),
150 path_prefix: Arc::default(),
151 distance_to_relative_ancestor: 0,
152 is_dir: false,
153 }),
154 ProjectPanelOrdMatch(PathMatch {
155 score: 0.5,
156 positions: Vec::new(),
157 worktree_id: 0,
158 path: Arc::from(Path::new("a0.5")),
159 path_prefix: Arc::default(),
160 distance_to_relative_ancestor: 0,
161 is_dir: false,
162 }),
163 ProjectPanelOrdMatch(PathMatch {
164 score: 0.5,
165 positions: Vec::new(),
166 worktree_id: 0,
167 path: Arc::from(Path::new("b0.5")),
168 path_prefix: Arc::default(),
169 distance_to_relative_ancestor: 0,
170 is_dir: false,
171 }),
172 ]
173 );
174}
175
176#[gpui::test]
177async fn test_matching_paths(cx: &mut TestAppContext) {
178 let app_state = init_test(cx);
179 app_state
180 .fs
181 .as_fake()
182 .insert_tree(
183 path!("/root"),
184 json!({
185 "a": {
186 "banana": "",
187 "bandana": "",
188 }
189 }),
190 )
191 .await;
192
193 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
194
195 let (picker, workspace, cx) = build_find_picker(project, cx);
196
197 cx.simulate_input("bna");
198 picker.update(cx, |picker, _| {
199 assert_eq!(picker.delegate.matches.len(), 2);
200 });
201 cx.dispatch_action(SelectNext);
202 cx.dispatch_action(Confirm);
203 cx.read(|cx| {
204 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
205 assert_eq!(active_editor.read(cx).title(cx), "bandana");
206 });
207
208 for bandana_query in [
209 "bandana",
210 "./bandana",
211 ".\\bandana",
212 util::separator!("a/bandana"),
213 "b/bandana",
214 "b\\bandana",
215 " bandana",
216 "bandana ",
217 " bandana ",
218 " ndan ",
219 " band ",
220 "a bandana",
221 ] {
222 picker
223 .update_in(cx, |picker, window, cx| {
224 picker
225 .delegate
226 .update_matches(bandana_query.to_string(), window, cx)
227 })
228 .await;
229 picker.update(cx, |picker, _| {
230 assert_eq!(
231 picker.delegate.matches.len(),
232 1,
233 "Wrong number of matches for bandana query '{bandana_query}'. Matches: {:?}",
234 picker.delegate.matches
235 );
236 });
237 cx.dispatch_action(SelectNext);
238 cx.dispatch_action(Confirm);
239 cx.read(|cx| {
240 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
241 assert_eq!(
242 active_editor.read(cx).title(cx),
243 "bandana",
244 "Wrong match for bandana query '{bandana_query}'"
245 );
246 });
247 }
248}
249
250#[gpui::test]
251async fn test_unicode_paths(cx: &mut TestAppContext) {
252 let app_state = init_test(cx);
253 app_state
254 .fs
255 .as_fake()
256 .insert_tree(
257 path!("/root"),
258 json!({
259 "a": {
260 "İg": " ",
261 }
262 }),
263 )
264 .await;
265
266 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
267
268 let (picker, workspace, cx) = build_find_picker(project, cx);
269
270 cx.simulate_input("g");
271 picker.update(cx, |picker, _| {
272 assert_eq!(picker.delegate.matches.len(), 1);
273 });
274 cx.dispatch_action(SelectNext);
275 cx.dispatch_action(Confirm);
276 cx.read(|cx| {
277 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
278 assert_eq!(active_editor.read(cx).title(cx), "İg");
279 });
280}
281
282#[gpui::test]
283async fn test_absolute_paths(cx: &mut TestAppContext) {
284 let app_state = init_test(cx);
285 app_state
286 .fs
287 .as_fake()
288 .insert_tree(
289 path!("/root"),
290 json!({
291 "a": {
292 "file1.txt": "",
293 "b": {
294 "file2.txt": "",
295 },
296 }
297 }),
298 )
299 .await;
300
301 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
302
303 let (picker, workspace, cx) = build_find_picker(project, cx);
304
305 let matching_abs_path = path!("/root/a/b/file2.txt").to_string();
306 picker
307 .update_in(cx, |picker, window, cx| {
308 picker
309 .delegate
310 .update_matches(matching_abs_path, window, cx)
311 })
312 .await;
313 picker.update(cx, |picker, _| {
314 assert_eq!(
315 collect_search_matches(picker).search_paths_only(),
316 vec![PathBuf::from("a/b/file2.txt")],
317 "Matching abs path should be the only match"
318 )
319 });
320 cx.dispatch_action(SelectNext);
321 cx.dispatch_action(Confirm);
322 cx.read(|cx| {
323 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
324 assert_eq!(active_editor.read(cx).title(cx), "file2.txt");
325 });
326
327 let mismatching_abs_path = path!("/root/a/b/file1.txt").to_string();
328 picker
329 .update_in(cx, |picker, window, cx| {
330 picker
331 .delegate
332 .update_matches(mismatching_abs_path, window, cx)
333 })
334 .await;
335 picker.update(cx, |picker, _| {
336 assert_eq!(
337 collect_search_matches(picker).search_paths_only(),
338 Vec::<PathBuf>::new(),
339 "Mismatching abs path should produce no matches"
340 )
341 });
342}
343
344#[gpui::test]
345async fn test_complex_path(cx: &mut TestAppContext) {
346 let app_state = init_test(cx);
347 app_state
348 .fs
349 .as_fake()
350 .insert_tree(
351 path!("/root"),
352 json!({
353 "其他": {
354 "S数据表格": {
355 "task.xlsx": "some content",
356 },
357 }
358 }),
359 )
360 .await;
361
362 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
363
364 let (picker, workspace, cx) = build_find_picker(project, cx);
365
366 cx.simulate_input("t");
367 picker.update(cx, |picker, _| {
368 assert_eq!(picker.delegate.matches.len(), 1);
369 assert_eq!(
370 collect_search_matches(picker).search_paths_only(),
371 vec![PathBuf::from("其他/S数据表格/task.xlsx")],
372 )
373 });
374 cx.dispatch_action(SelectNext);
375 cx.dispatch_action(Confirm);
376 cx.read(|cx| {
377 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
378 assert_eq!(active_editor.read(cx).title(cx), "task.xlsx");
379 });
380}
381
382#[gpui::test]
383async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
384 let app_state = init_test(cx);
385
386 let first_file_name = "first.rs";
387 let first_file_contents = "// First Rust file";
388 app_state
389 .fs
390 .as_fake()
391 .insert_tree(
392 path!("/src"),
393 json!({
394 "test": {
395 first_file_name: first_file_contents,
396 "second.rs": "// Second Rust file",
397 }
398 }),
399 )
400 .await;
401
402 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
403
404 let (picker, workspace, cx) = build_find_picker(project, cx);
405
406 let file_query = &first_file_name[..3];
407 let file_row = 1;
408 let file_column = 3;
409 assert!(file_column <= first_file_contents.len());
410 let query_inside_file = format!("{file_query}:{file_row}:{file_column}");
411 picker
412 .update_in(cx, |finder, window, cx| {
413 finder
414 .delegate
415 .update_matches(query_inside_file.to_string(), window, cx)
416 })
417 .await;
418 picker.update(cx, |finder, _| {
419 let finder = &finder.delegate;
420 assert_eq!(finder.matches.len(), 1);
421 let latest_search_query = finder
422 .latest_search_query
423 .as_ref()
424 .expect("Finder should have a query after the update_matches call");
425 assert_eq!(latest_search_query.raw_query, query_inside_file);
426 assert_eq!(latest_search_query.file_query_end, Some(file_query.len()));
427 assert_eq!(latest_search_query.path_position.row, Some(file_row));
428 assert_eq!(
429 latest_search_query.path_position.column,
430 Some(file_column as u32)
431 );
432 });
433
434 cx.dispatch_action(SelectNext);
435 cx.dispatch_action(Confirm);
436
437 let editor = cx.update(|_, cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
438 cx.executor().advance_clock(Duration::from_secs(2));
439
440 editor.update(cx, |editor, cx| {
441 let all_selections = editor.selections.all_adjusted(cx);
442 assert_eq!(
443 all_selections.len(),
444 1,
445 "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
446 );
447 let caret_selection = all_selections.into_iter().next().unwrap();
448 assert_eq!(caret_selection.start, caret_selection.end,
449 "Caret selection should have its start and end at the same position");
450 assert_eq!(file_row, caret_selection.start.row + 1,
451 "Query inside file should get caret with the same focus row");
452 assert_eq!(file_column, caret_selection.start.column as usize + 1,
453 "Query inside file should get caret with the same focus column");
454 });
455}
456
457#[gpui::test]
458async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
459 let app_state = init_test(cx);
460
461 let first_file_name = "first.rs";
462 let first_file_contents = "// First Rust file";
463 app_state
464 .fs
465 .as_fake()
466 .insert_tree(
467 path!("/src"),
468 json!({
469 "test": {
470 first_file_name: first_file_contents,
471 "second.rs": "// Second Rust file",
472 }
473 }),
474 )
475 .await;
476
477 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
478
479 let (picker, workspace, cx) = build_find_picker(project, cx);
480
481 let file_query = &first_file_name[..3];
482 let file_row = 200;
483 let file_column = 300;
484 assert!(file_column > first_file_contents.len());
485 let query_outside_file = format!("{file_query}:{file_row}:{file_column}");
486 picker
487 .update_in(cx, |picker, window, cx| {
488 picker
489 .delegate
490 .update_matches(query_outside_file.to_string(), window, cx)
491 })
492 .await;
493 picker.update(cx, |finder, _| {
494 let delegate = &finder.delegate;
495 assert_eq!(delegate.matches.len(), 1);
496 let latest_search_query = delegate
497 .latest_search_query
498 .as_ref()
499 .expect("Finder should have a query after the update_matches call");
500 assert_eq!(latest_search_query.raw_query, query_outside_file);
501 assert_eq!(latest_search_query.file_query_end, Some(file_query.len()));
502 assert_eq!(latest_search_query.path_position.row, Some(file_row));
503 assert_eq!(
504 latest_search_query.path_position.column,
505 Some(file_column as u32)
506 );
507 });
508
509 cx.dispatch_action(SelectNext);
510 cx.dispatch_action(Confirm);
511
512 let editor = cx.update(|_, cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
513 cx.executor().advance_clock(Duration::from_secs(2));
514
515 editor.update(cx, |editor, cx| {
516 let all_selections = editor.selections.all_adjusted(cx);
517 assert_eq!(
518 all_selections.len(),
519 1,
520 "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
521 );
522 let caret_selection = all_selections.into_iter().next().unwrap();
523 assert_eq!(caret_selection.start, caret_selection.end,
524 "Caret selection should have its start and end at the same position");
525 assert_eq!(0, caret_selection.start.row,
526 "Excessive rows (as in query outside file borders) should get trimmed to last file row");
527 assert_eq!(first_file_contents.len(), caret_selection.start.column as usize,
528 "Excessive columns (as in query outside file borders) should get trimmed to selected row's last column");
529 });
530}
531
532#[gpui::test]
533async fn test_matching_cancellation(cx: &mut TestAppContext) {
534 let app_state = init_test(cx);
535 app_state
536 .fs
537 .as_fake()
538 .insert_tree(
539 "/dir",
540 json!({
541 "hello": "",
542 "goodbye": "",
543 "halogen-light": "",
544 "happiness": "",
545 "height": "",
546 "hi": "",
547 "hiccup": "",
548 }),
549 )
550 .await;
551
552 let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await;
553
554 let (picker, _, cx) = build_find_picker(project, cx);
555
556 let query = test_path_position("hi");
557 picker
558 .update_in(cx, |picker, window, cx| {
559 picker.delegate.spawn_search(query.clone(), window, cx)
560 })
561 .await;
562
563 picker.update(cx, |picker, _cx| {
564 assert_eq!(picker.delegate.matches.len(), 5)
565 });
566
567 picker.update_in(cx, |picker, window, cx| {
568 let matches = collect_search_matches(picker).search_matches_only();
569 let delegate = &mut picker.delegate;
570
571 // Simulate a search being cancelled after the time limit,
572 // returning only a subset of the matches that would have been found.
573 drop(delegate.spawn_search(query.clone(), window, cx));
574 delegate.set_search_matches(
575 delegate.latest_search_id,
576 true, // did-cancel
577 query.clone(),
578 vec![
579 ProjectPanelOrdMatch(matches[1].clone()),
580 ProjectPanelOrdMatch(matches[3].clone()),
581 ],
582 cx,
583 );
584
585 // Simulate another cancellation.
586 drop(delegate.spawn_search(query.clone(), window, cx));
587 delegate.set_search_matches(
588 delegate.latest_search_id,
589 true, // did-cancel
590 query.clone(),
591 vec![
592 ProjectPanelOrdMatch(matches[0].clone()),
593 ProjectPanelOrdMatch(matches[2].clone()),
594 ProjectPanelOrdMatch(matches[3].clone()),
595 ],
596 cx,
597 );
598
599 assert_eq!(
600 collect_search_matches(picker)
601 .search_matches_only()
602 .as_slice(),
603 &matches[0..4]
604 );
605 });
606}
607
608#[gpui::test]
609async fn test_ignored_root(cx: &mut TestAppContext) {
610 let app_state = init_test(cx);
611 app_state
612 .fs
613 .as_fake()
614 .insert_tree(
615 "/ancestor",
616 json!({
617 ".gitignore": "ignored-root",
618 "ignored-root": {
619 "happiness": "",
620 "height": "",
621 "hi": "",
622 "hiccup": "",
623 },
624 "tracked-root": {
625 ".gitignore": "height*",
626 "happiness": "",
627 "height": "",
628 "heights": {
629 "height_1": "",
630 "height_2": "",
631 },
632 "hi": "",
633 "hiccup": "",
634 },
635 }),
636 )
637 .await;
638
639 let project = Project::test(
640 app_state.fs.clone(),
641 [
642 Path::new(path!("/ancestor/tracked-root")),
643 Path::new(path!("/ancestor/ignored-root")),
644 ],
645 cx,
646 )
647 .await;
648 let (picker, workspace, cx) = build_find_picker(project, cx);
649
650 picker
651 .update_in(cx, |picker, window, cx| {
652 picker
653 .delegate
654 .spawn_search(test_path_position("hi"), window, cx)
655 })
656 .await;
657 picker.update(cx, |picker, _| {
658 let matches = collect_search_matches(picker);
659 assert_eq!(matches.history.len(), 0);
660 assert_eq!(
661 matches.search,
662 vec![
663 PathBuf::from("ignored-root/hi"),
664 PathBuf::from("tracked-root/hi"),
665 PathBuf::from("ignored-root/hiccup"),
666 PathBuf::from("tracked-root/hiccup"),
667 PathBuf::from("ignored-root/height"),
668 PathBuf::from("ignored-root/happiness"),
669 PathBuf::from("tracked-root/happiness"),
670 ],
671 "All ignored files that were indexed are found for default ignored mode"
672 );
673 });
674 cx.dispatch_action(ToggleIncludeIgnored);
675 picker
676 .update_in(cx, |picker, window, cx| {
677 picker
678 .delegate
679 .spawn_search(test_path_position("hi"), window, cx)
680 })
681 .await;
682 picker.update(cx, |picker, _| {
683 let matches = collect_search_matches(picker);
684 assert_eq!(matches.history.len(), 0);
685 assert_eq!(
686 matches.search,
687 vec![
688 PathBuf::from("ignored-root/hi"),
689 PathBuf::from("tracked-root/hi"),
690 PathBuf::from("ignored-root/hiccup"),
691 PathBuf::from("tracked-root/hiccup"),
692 PathBuf::from("ignored-root/height"),
693 PathBuf::from("tracked-root/height"),
694 PathBuf::from("ignored-root/happiness"),
695 PathBuf::from("tracked-root/happiness"),
696 ],
697 "All ignored files should be found, for the toggled on ignored mode"
698 );
699 });
700
701 picker
702 .update_in(cx, |picker, window, cx| {
703 picker.delegate.include_ignored = Some(false);
704 picker
705 .delegate
706 .spawn_search(test_path_position("hi"), window, cx)
707 })
708 .await;
709 picker.update(cx, |picker, _| {
710 let matches = collect_search_matches(picker);
711 assert_eq!(matches.history.len(), 0);
712 assert_eq!(
713 matches.search,
714 vec![
715 PathBuf::from("tracked-root/hi"),
716 PathBuf::from("tracked-root/hiccup"),
717 PathBuf::from("tracked-root/happiness"),
718 ],
719 "Only non-ignored files should be found for the turned off ignored mode"
720 );
721 });
722
723 workspace
724 .update_in(cx, |workspace, window, cx| {
725 workspace.open_abs_path(
726 PathBuf::from(path!("/ancestor/tracked-root/heights/height_1")),
727 OpenOptions {
728 visible: Some(OpenVisible::None),
729 ..OpenOptions::default()
730 },
731 window,
732 cx,
733 )
734 })
735 .await
736 .unwrap();
737 cx.run_until_parked();
738 workspace
739 .update_in(cx, |workspace, window, cx| {
740 workspace.active_pane().update(cx, |pane, cx| {
741 pane.close_active_item(&CloseActiveItem::default(), window, cx)
742 })
743 })
744 .await
745 .unwrap();
746 cx.run_until_parked();
747
748 picker
749 .update_in(cx, |picker, window, cx| {
750 picker.delegate.include_ignored = None;
751 picker
752 .delegate
753 .spawn_search(test_path_position("hi"), window, cx)
754 })
755 .await;
756 picker.update(cx, |picker, _| {
757 let matches = collect_search_matches(picker);
758 assert_eq!(matches.history.len(), 0);
759 assert_eq!(
760 matches.search,
761 vec![
762 PathBuf::from("ignored-root/hi"),
763 PathBuf::from("tracked-root/hi"),
764 PathBuf::from("ignored-root/hiccup"),
765 PathBuf::from("tracked-root/hiccup"),
766 PathBuf::from("ignored-root/height"),
767 PathBuf::from("ignored-root/happiness"),
768 PathBuf::from("tracked-root/happiness"),
769 ],
770 "Only for the worktree with the ignored root, all indexed ignored files are found in the auto ignored mode"
771 );
772 });
773
774 picker
775 .update_in(cx, |picker, window, cx| {
776 picker.delegate.include_ignored = Some(true);
777 picker
778 .delegate
779 .spawn_search(test_path_position("hi"), window, cx)
780 })
781 .await;
782 picker.update(cx, |picker, _| {
783 let matches = collect_search_matches(picker);
784 assert_eq!(matches.history.len(), 0);
785 assert_eq!(
786 matches.search,
787 vec![
788 PathBuf::from("ignored-root/hi"),
789 PathBuf::from("tracked-root/hi"),
790 PathBuf::from("ignored-root/hiccup"),
791 PathBuf::from("tracked-root/hiccup"),
792 PathBuf::from("ignored-root/height"),
793 PathBuf::from("tracked-root/height"),
794 PathBuf::from("tracked-root/heights/height_1"),
795 PathBuf::from("tracked-root/heights/height_2"),
796 PathBuf::from("ignored-root/happiness"),
797 PathBuf::from("tracked-root/happiness"),
798 ],
799 "All ignored files that were indexed are found in the turned on ignored mode"
800 );
801 });
802
803 picker
804 .update_in(cx, |picker, window, cx| {
805 picker.delegate.include_ignored = Some(false);
806 picker
807 .delegate
808 .spawn_search(test_path_position("hi"), window, cx)
809 })
810 .await;
811 picker.update(cx, |picker, _| {
812 let matches = collect_search_matches(picker);
813 assert_eq!(matches.history.len(), 0);
814 assert_eq!(
815 matches.search,
816 vec![
817 PathBuf::from("tracked-root/hi"),
818 PathBuf::from("tracked-root/hiccup"),
819 PathBuf::from("tracked-root/happiness"),
820 ],
821 "Only non-ignored files should be found for the turned off ignored mode"
822 );
823 });
824}
825
826#[gpui::test]
827async fn test_single_file_worktrees(cx: &mut TestAppContext) {
828 let app_state = init_test(cx);
829 app_state
830 .fs
831 .as_fake()
832 .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
833 .await;
834
835 let project = Project::test(
836 app_state.fs.clone(),
837 ["/root/the-parent-dir/the-file".as_ref()],
838 cx,
839 )
840 .await;
841
842 let (picker, _, cx) = build_find_picker(project, cx);
843
844 // Even though there is only one worktree, that worktree's filename
845 // is included in the matching, because the worktree is a single file.
846 picker
847 .update_in(cx, |picker, window, cx| {
848 picker
849 .delegate
850 .spawn_search(test_path_position("thf"), window, cx)
851 })
852 .await;
853 cx.read(|cx| {
854 let picker = picker.read(cx);
855 let delegate = &picker.delegate;
856 let matches = collect_search_matches(picker).search_matches_only();
857 assert_eq!(matches.len(), 1);
858
859 let (file_name, file_name_positions, full_path, full_path_positions) =
860 delegate.labels_for_path_match(&matches[0]);
861 assert_eq!(file_name, "the-file");
862 assert_eq!(file_name_positions, &[0, 1, 4]);
863 assert_eq!(full_path, "");
864 assert_eq!(full_path_positions, &[0; 0]);
865 });
866
867 // Since the worktree root is a file, searching for its name followed by a slash does
868 // not match anything.
869 picker
870 .update_in(cx, |picker, window, cx| {
871 picker
872 .delegate
873 .spawn_search(test_path_position("thf/"), window, cx)
874 })
875 .await;
876 picker.update(cx, |f, _| assert_eq!(f.delegate.matches.len(), 0));
877}
878
879#[gpui::test]
880async fn test_path_distance_ordering(cx: &mut TestAppContext) {
881 let app_state = init_test(cx);
882 app_state
883 .fs
884 .as_fake()
885 .insert_tree(
886 path!("/root"),
887 json!({
888 "dir1": { "a.txt": "" },
889 "dir2": {
890 "a.txt": "",
891 "b.txt": ""
892 }
893 }),
894 )
895 .await;
896
897 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
898 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
899
900 let worktree_id = cx.read(|cx| {
901 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
902 assert_eq!(worktrees.len(), 1);
903 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
904 });
905
906 // When workspace has an active item, sort items which are closer to that item
907 // first when they have the same name. In this case, b.txt is closer to dir2's a.txt
908 // so that one should be sorted earlier
909 let b_path = ProjectPath {
910 worktree_id,
911 path: Arc::from(Path::new("dir2/b.txt")),
912 };
913 workspace
914 .update_in(cx, |workspace, window, cx| {
915 workspace.open_path(b_path, None, true, window, cx)
916 })
917 .await
918 .unwrap();
919 let finder = open_file_picker(&workspace, cx);
920 finder
921 .update_in(cx, |f, window, cx| {
922 f.delegate
923 .spawn_search(test_path_position("a.txt"), window, cx)
924 })
925 .await;
926
927 finder.update(cx, |picker, _| {
928 let matches = collect_search_matches(picker).search_paths_only();
929 assert_eq!(matches[0].as_path(), Path::new("dir2/a.txt"));
930 assert_eq!(matches[1].as_path(), Path::new("dir1/a.txt"));
931 });
932}
933
934#[gpui::test]
935async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
936 let app_state = init_test(cx);
937 app_state
938 .fs
939 .as_fake()
940 .insert_tree(
941 "/root",
942 json!({
943 "dir1": {},
944 "dir2": {
945 "dir3": {}
946 }
947 }),
948 )
949 .await;
950
951 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
952 let (picker, _workspace, cx) = build_find_picker(project, cx);
953
954 picker
955 .update_in(cx, |f, window, cx| {
956 f.delegate
957 .spawn_search(test_path_position("dir"), window, cx)
958 })
959 .await;
960 cx.read(|cx| {
961 let finder = picker.read(cx);
962 assert_eq!(finder.delegate.matches.len(), 0);
963 });
964}
965
966#[gpui::test]
967async fn test_query_history(cx: &mut gpui::TestAppContext) {
968 let app_state = init_test(cx);
969
970 app_state
971 .fs
972 .as_fake()
973 .insert_tree(
974 path!("/src"),
975 json!({
976 "test": {
977 "first.rs": "// First Rust file",
978 "second.rs": "// Second Rust file",
979 "third.rs": "// Third Rust file",
980 }
981 }),
982 )
983 .await;
984
985 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
986 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
987 let worktree_id = cx.read(|cx| {
988 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
989 assert_eq!(worktrees.len(), 1);
990 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
991 });
992
993 // Open and close panels, getting their history items afterwards.
994 // Ensure history items get populated with opened items, and items are kept in a certain order.
995 // The history lags one opened buffer behind, since it's updated in the search panel only on its reopen.
996 //
997 // TODO: without closing, the opened items do not propagate their history changes for some reason
998 // it does work in real app though, only tests do not propagate.
999 workspace.update_in(cx, |_workspace, window, cx| window.focused(cx));
1000
1001 let initial_history = open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1002 assert!(
1003 initial_history.is_empty(),
1004 "Should have no history before opening any files"
1005 );
1006
1007 let history_after_first =
1008 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1009 assert_eq!(
1010 history_after_first,
1011 vec![FoundPath::new(
1012 ProjectPath {
1013 worktree_id,
1014 path: Arc::from(Path::new("test/first.rs")),
1015 },
1016 Some(PathBuf::from(path!("/src/test/first.rs")))
1017 )],
1018 "Should show 1st opened item in the history when opening the 2nd item"
1019 );
1020
1021 let history_after_second =
1022 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1023 assert_eq!(
1024 history_after_second,
1025 vec![
1026 FoundPath::new(
1027 ProjectPath {
1028 worktree_id,
1029 path: Arc::from(Path::new("test/second.rs")),
1030 },
1031 Some(PathBuf::from(path!("/src/test/second.rs")))
1032 ),
1033 FoundPath::new(
1034 ProjectPath {
1035 worktree_id,
1036 path: Arc::from(Path::new("test/first.rs")),
1037 },
1038 Some(PathBuf::from(path!("/src/test/first.rs")))
1039 ),
1040 ],
1041 "Should show 1st and 2nd opened items in the history when opening the 3rd item. \
1042 2nd item should be the first in the history, as the last opened."
1043 );
1044
1045 let history_after_third =
1046 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1047 assert_eq!(
1048 history_after_third,
1049 vec![
1050 FoundPath::new(
1051 ProjectPath {
1052 worktree_id,
1053 path: Arc::from(Path::new("test/third.rs")),
1054 },
1055 Some(PathBuf::from(path!("/src/test/third.rs")))
1056 ),
1057 FoundPath::new(
1058 ProjectPath {
1059 worktree_id,
1060 path: Arc::from(Path::new("test/second.rs")),
1061 },
1062 Some(PathBuf::from(path!("/src/test/second.rs")))
1063 ),
1064 FoundPath::new(
1065 ProjectPath {
1066 worktree_id,
1067 path: Arc::from(Path::new("test/first.rs")),
1068 },
1069 Some(PathBuf::from(path!("/src/test/first.rs")))
1070 ),
1071 ],
1072 "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \
1073 3rd item should be the first in the history, as the last opened."
1074 );
1075
1076 let history_after_second_again =
1077 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1078 assert_eq!(
1079 history_after_second_again,
1080 vec![
1081 FoundPath::new(
1082 ProjectPath {
1083 worktree_id,
1084 path: Arc::from(Path::new("test/second.rs")),
1085 },
1086 Some(PathBuf::from(path!("/src/test/second.rs")))
1087 ),
1088 FoundPath::new(
1089 ProjectPath {
1090 worktree_id,
1091 path: Arc::from(Path::new("test/third.rs")),
1092 },
1093 Some(PathBuf::from(path!("/src/test/third.rs")))
1094 ),
1095 FoundPath::new(
1096 ProjectPath {
1097 worktree_id,
1098 path: Arc::from(Path::new("test/first.rs")),
1099 },
1100 Some(PathBuf::from(path!("/src/test/first.rs")))
1101 ),
1102 ],
1103 "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \
1104 2nd item, as the last opened, 3rd item should go next as it was opened right before."
1105 );
1106}
1107
1108#[gpui::test]
1109async fn test_external_files_history(cx: &mut gpui::TestAppContext) {
1110 let app_state = init_test(cx);
1111
1112 app_state
1113 .fs
1114 .as_fake()
1115 .insert_tree(
1116 path!("/src"),
1117 json!({
1118 "test": {
1119 "first.rs": "// First Rust file",
1120 "second.rs": "// Second Rust file",
1121 }
1122 }),
1123 )
1124 .await;
1125
1126 app_state
1127 .fs
1128 .as_fake()
1129 .insert_tree(
1130 path!("/external-src"),
1131 json!({
1132 "test": {
1133 "third.rs": "// Third Rust file",
1134 "fourth.rs": "// Fourth Rust file",
1135 }
1136 }),
1137 )
1138 .await;
1139
1140 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1141 cx.update(|cx| {
1142 project.update(cx, |project, cx| {
1143 project.find_or_create_worktree(path!("/external-src"), false, cx)
1144 })
1145 })
1146 .detach();
1147 cx.background_executor.run_until_parked();
1148
1149 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1150 let worktree_id = cx.read(|cx| {
1151 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1152 assert_eq!(worktrees.len(), 1,);
1153
1154 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1155 });
1156 workspace
1157 .update_in(cx, |workspace, window, cx| {
1158 workspace.open_abs_path(
1159 PathBuf::from(path!("/external-src/test/third.rs")),
1160 OpenOptions {
1161 visible: Some(OpenVisible::None),
1162 ..Default::default()
1163 },
1164 window,
1165 cx,
1166 )
1167 })
1168 .detach();
1169 cx.background_executor.run_until_parked();
1170 let external_worktree_id = cx.read(|cx| {
1171 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1172 assert_eq!(
1173 worktrees.len(),
1174 2,
1175 "External file should get opened in a new worktree"
1176 );
1177
1178 WorktreeId::from_usize(
1179 worktrees
1180 .into_iter()
1181 .find(|worktree| worktree.entity_id().as_u64() as usize != worktree_id.to_usize())
1182 .expect("New worktree should have a different id")
1183 .entity_id()
1184 .as_u64() as usize,
1185 )
1186 });
1187 cx.dispatch_action(workspace::CloseActiveItem {
1188 save_intent: None,
1189 close_pinned: false,
1190 });
1191
1192 let initial_history_items =
1193 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1194 assert_eq!(
1195 initial_history_items,
1196 vec![FoundPath::new(
1197 ProjectPath {
1198 worktree_id: external_worktree_id,
1199 path: Arc::from(Path::new("")),
1200 },
1201 Some(PathBuf::from(path!("/external-src/test/third.rs")))
1202 )],
1203 "Should show external file with its full path in the history after it was open"
1204 );
1205
1206 let updated_history_items =
1207 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1208 assert_eq!(
1209 updated_history_items,
1210 vec![
1211 FoundPath::new(
1212 ProjectPath {
1213 worktree_id,
1214 path: Arc::from(Path::new("test/second.rs")),
1215 },
1216 Some(PathBuf::from(path!("/src/test/second.rs")))
1217 ),
1218 FoundPath::new(
1219 ProjectPath {
1220 worktree_id: external_worktree_id,
1221 path: Arc::from(Path::new("")),
1222 },
1223 Some(PathBuf::from(path!("/external-src/test/third.rs")))
1224 ),
1225 ],
1226 "Should keep external file with history updates",
1227 );
1228}
1229
1230#[gpui::test]
1231async fn test_toggle_panel_new_selections(cx: &mut gpui::TestAppContext) {
1232 let app_state = init_test(cx);
1233
1234 app_state
1235 .fs
1236 .as_fake()
1237 .insert_tree(
1238 path!("/src"),
1239 json!({
1240 "test": {
1241 "first.rs": "// First Rust file",
1242 "second.rs": "// Second Rust file",
1243 "third.rs": "// Third Rust file",
1244 }
1245 }),
1246 )
1247 .await;
1248
1249 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1250 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1251
1252 // generate some history to select from
1253 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1254 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1255 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1256 let current_history = open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1257
1258 for expected_selected_index in 0..current_history.len() {
1259 cx.dispatch_action(ToggleFileFinder::default());
1260 let picker = active_file_picker(&workspace, cx);
1261 let selected_index = picker.update(cx, |picker, _| picker.delegate.selected_index());
1262 assert_eq!(
1263 selected_index, expected_selected_index,
1264 "Should select the next item in the history"
1265 );
1266 }
1267
1268 cx.dispatch_action(ToggleFileFinder::default());
1269 let selected_index = workspace.update(cx, |workspace, cx| {
1270 workspace
1271 .active_modal::<FileFinder>(cx)
1272 .unwrap()
1273 .read(cx)
1274 .picker
1275 .read(cx)
1276 .delegate
1277 .selected_index()
1278 });
1279 assert_eq!(
1280 selected_index, 0,
1281 "Should wrap around the history and start all over"
1282 );
1283}
1284
1285#[gpui::test]
1286async fn test_search_preserves_history_items(cx: &mut gpui::TestAppContext) {
1287 let app_state = init_test(cx);
1288
1289 app_state
1290 .fs
1291 .as_fake()
1292 .insert_tree(
1293 path!("/src"),
1294 json!({
1295 "test": {
1296 "first.rs": "// First Rust file",
1297 "second.rs": "// Second Rust file",
1298 "third.rs": "// Third Rust file",
1299 "fourth.rs": "// Fourth Rust file",
1300 }
1301 }),
1302 )
1303 .await;
1304
1305 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1306 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1307 let worktree_id = cx.read(|cx| {
1308 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1309 assert_eq!(worktrees.len(), 1,);
1310
1311 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1312 });
1313
1314 // generate some history to select from
1315 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1316 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1317 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1318 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1319
1320 let finder = open_file_picker(&workspace, cx);
1321 let first_query = "f";
1322 finder
1323 .update_in(cx, |finder, window, cx| {
1324 finder
1325 .delegate
1326 .update_matches(first_query.to_string(), window, cx)
1327 })
1328 .await;
1329 finder.update(cx, |picker, _| {
1330 let matches = collect_search_matches(picker);
1331 assert_eq!(matches.history.len(), 1, "Only one history item contains {first_query}, it should be present and others should be filtered out");
1332 let history_match = matches.history_found_paths.first().expect("Should have path matches for history items after querying");
1333 assert_eq!(history_match, &FoundPath::new(
1334 ProjectPath {
1335 worktree_id,
1336 path: Arc::from(Path::new("test/first.rs")),
1337 },
1338 Some(PathBuf::from(path!("/src/test/first.rs")))
1339 ));
1340 assert_eq!(matches.search.len(), 1, "Only one non-history item contains {first_query}, it should be present");
1341 assert_eq!(matches.search.first().unwrap(), Path::new("test/fourth.rs"));
1342 });
1343
1344 let second_query = "fsdasdsa";
1345 let finder = active_file_picker(&workspace, cx);
1346 finder
1347 .update_in(cx, |finder, window, cx| {
1348 finder
1349 .delegate
1350 .update_matches(second_query.to_string(), window, cx)
1351 })
1352 .await;
1353 finder.update(cx, |picker, _| {
1354 assert!(
1355 collect_search_matches(picker)
1356 .search_paths_only()
1357 .is_empty(),
1358 "No search entries should match {second_query}"
1359 );
1360 });
1361
1362 let first_query_again = first_query;
1363
1364 let finder = active_file_picker(&workspace, cx);
1365 finder
1366 .update_in(cx, |finder, window, cx| {
1367 finder
1368 .delegate
1369 .update_matches(first_query_again.to_string(), window, cx)
1370 })
1371 .await;
1372 finder.update(cx, |picker, _| {
1373 let matches = collect_search_matches(picker);
1374 assert_eq!(matches.history.len(), 1, "Only one history item contains {first_query_again}, it should be present and others should be filtered out, even after non-matching query");
1375 let history_match = matches.history_found_paths.first().expect("Should have path matches for history items after querying");
1376 assert_eq!(history_match, &FoundPath::new(
1377 ProjectPath {
1378 worktree_id,
1379 path: Arc::from(Path::new("test/first.rs")),
1380 },
1381 Some(PathBuf::from(path!("/src/test/first.rs")))
1382 ));
1383 assert_eq!(matches.search.len(), 1, "Only one non-history item contains {first_query_again}, it should be present, even after non-matching query");
1384 assert_eq!(matches.search.first().unwrap(), Path::new("test/fourth.rs"));
1385 });
1386}
1387
1388#[gpui::test]
1389async fn test_search_sorts_history_items(cx: &mut gpui::TestAppContext) {
1390 let app_state = init_test(cx);
1391
1392 app_state
1393 .fs
1394 .as_fake()
1395 .insert_tree(
1396 path!("/root"),
1397 json!({
1398 "test": {
1399 "1_qw": "// First file that matches the query",
1400 "2_second": "// Second file",
1401 "3_third": "// Third file",
1402 "4_fourth": "// Fourth file",
1403 "5_qwqwqw": "// A file with 3 more matches than the first one",
1404 "6_qwqwqw": "// Same query matches as above, but closer to the end of the list due to the name",
1405 "7_qwqwqw": "// One more, same amount of query matches as above",
1406 }
1407 }),
1408 )
1409 .await;
1410
1411 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
1412 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1413 // generate some history to select from
1414 open_close_queried_buffer("1", 1, "1_qw", &workspace, cx).await;
1415 open_close_queried_buffer("2", 1, "2_second", &workspace, cx).await;
1416 open_close_queried_buffer("3", 1, "3_third", &workspace, cx).await;
1417 open_close_queried_buffer("2", 1, "2_second", &workspace, cx).await;
1418 open_close_queried_buffer("6", 1, "6_qwqwqw", &workspace, cx).await;
1419
1420 let finder = open_file_picker(&workspace, cx);
1421 let query = "qw";
1422 finder
1423 .update_in(cx, |finder, window, cx| {
1424 finder
1425 .delegate
1426 .update_matches(query.to_string(), window, cx)
1427 })
1428 .await;
1429 finder.update(cx, |finder, _| {
1430 let search_matches = collect_search_matches(finder);
1431 assert_eq!(
1432 search_matches.history,
1433 vec![PathBuf::from("test/1_qw"), PathBuf::from("test/6_qwqwqw"),],
1434 );
1435 assert_eq!(
1436 search_matches.search,
1437 vec![
1438 PathBuf::from("test/5_qwqwqw"),
1439 PathBuf::from("test/7_qwqwqw"),
1440 ],
1441 );
1442 });
1443}
1444
1445#[gpui::test]
1446async fn test_select_current_open_file_when_no_history(cx: &mut gpui::TestAppContext) {
1447 let app_state = init_test(cx);
1448
1449 app_state
1450 .fs
1451 .as_fake()
1452 .insert_tree(
1453 path!("/root"),
1454 json!({
1455 "test": {
1456 "1_qw": "",
1457 }
1458 }),
1459 )
1460 .await;
1461
1462 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
1463 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1464 // Open new buffer
1465 open_queried_buffer("1", 1, "1_qw", &workspace, cx).await;
1466
1467 let picker = open_file_picker(&workspace, cx);
1468 picker.update(cx, |finder, _| {
1469 assert_match_selection(&finder, 0, "1_qw");
1470 });
1471}
1472
1473#[gpui::test]
1474async fn test_keep_opened_file_on_top_of_search_results_and_select_next_one(
1475 cx: &mut TestAppContext,
1476) {
1477 let app_state = init_test(cx);
1478
1479 app_state
1480 .fs
1481 .as_fake()
1482 .insert_tree(
1483 path!("/src"),
1484 json!({
1485 "test": {
1486 "bar.rs": "// Bar file",
1487 "lib.rs": "// Lib file",
1488 "maaa.rs": "// Maaaaaaa",
1489 "main.rs": "// Main file",
1490 "moo.rs": "// Moooooo",
1491 }
1492 }),
1493 )
1494 .await;
1495
1496 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1497 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1498
1499 open_close_queried_buffer("bar", 1, "bar.rs", &workspace, cx).await;
1500 open_close_queried_buffer("lib", 1, "lib.rs", &workspace, cx).await;
1501 open_queried_buffer("main", 1, "main.rs", &workspace, cx).await;
1502
1503 // main.rs is on top, previously used is selected
1504 let picker = open_file_picker(&workspace, cx);
1505 picker.update(cx, |finder, _| {
1506 assert_eq!(finder.delegate.matches.len(), 3);
1507 assert_match_selection(finder, 0, "main.rs");
1508 assert_match_at_position(finder, 1, "lib.rs");
1509 assert_match_at_position(finder, 2, "bar.rs");
1510 });
1511
1512 // all files match, main.rs is still on top, but the second item is selected
1513 picker
1514 .update_in(cx, |finder, window, cx| {
1515 finder
1516 .delegate
1517 .update_matches(".rs".to_string(), window, cx)
1518 })
1519 .await;
1520 picker.update(cx, |finder, _| {
1521 assert_eq!(finder.delegate.matches.len(), 5);
1522 assert_match_at_position(finder, 0, "main.rs");
1523 assert_match_selection(finder, 1, "bar.rs");
1524 assert_match_at_position(finder, 2, "lib.rs");
1525 assert_match_at_position(finder, 3, "moo.rs");
1526 assert_match_at_position(finder, 4, "maaa.rs");
1527 });
1528
1529 // main.rs is not among matches, select top item
1530 picker
1531 .update_in(cx, |finder, window, cx| {
1532 finder.delegate.update_matches("b".to_string(), window, cx)
1533 })
1534 .await;
1535 picker.update(cx, |finder, _| {
1536 assert_eq!(finder.delegate.matches.len(), 2);
1537 assert_match_at_position(finder, 0, "bar.rs");
1538 assert_match_at_position(finder, 1, "lib.rs");
1539 });
1540
1541 // main.rs is back, put it on top and select next item
1542 picker
1543 .update_in(cx, |finder, window, cx| {
1544 finder.delegate.update_matches("m".to_string(), window, cx)
1545 })
1546 .await;
1547 picker.update(cx, |finder, _| {
1548 assert_eq!(finder.delegate.matches.len(), 3);
1549 assert_match_at_position(finder, 0, "main.rs");
1550 assert_match_selection(finder, 1, "moo.rs");
1551 assert_match_at_position(finder, 2, "maaa.rs");
1552 });
1553
1554 // get back to the initial state
1555 picker
1556 .update_in(cx, |finder, window, cx| {
1557 finder.delegate.update_matches("".to_string(), window, cx)
1558 })
1559 .await;
1560 picker.update(cx, |finder, _| {
1561 assert_eq!(finder.delegate.matches.len(), 3);
1562 assert_match_selection(finder, 0, "main.rs");
1563 assert_match_at_position(finder, 1, "lib.rs");
1564 assert_match_at_position(finder, 2, "bar.rs");
1565 });
1566}
1567
1568#[gpui::test]
1569async fn test_setting_auto_select_first_and_select_active_file(cx: &mut TestAppContext) {
1570 let app_state = init_test(cx);
1571
1572 cx.update(|cx| {
1573 let settings = *FileFinderSettings::get_global(cx);
1574
1575 FileFinderSettings::override_global(
1576 FileFinderSettings {
1577 skip_focus_for_active_in_search: false,
1578 ..settings
1579 },
1580 cx,
1581 );
1582 });
1583
1584 app_state
1585 .fs
1586 .as_fake()
1587 .insert_tree(
1588 path!("/src"),
1589 json!({
1590 "test": {
1591 "bar.rs": "// Bar file",
1592 "lib.rs": "// Lib file",
1593 "maaa.rs": "// Maaaaaaa",
1594 "main.rs": "// Main file",
1595 "moo.rs": "// Moooooo",
1596 }
1597 }),
1598 )
1599 .await;
1600
1601 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1602 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1603
1604 open_close_queried_buffer("bar", 1, "bar.rs", &workspace, cx).await;
1605 open_close_queried_buffer("lib", 1, "lib.rs", &workspace, cx).await;
1606 open_queried_buffer("main", 1, "main.rs", &workspace, cx).await;
1607
1608 // main.rs is on top, previously used is selected
1609 let picker = open_file_picker(&workspace, cx);
1610 picker.update(cx, |finder, _| {
1611 assert_eq!(finder.delegate.matches.len(), 3);
1612 assert_match_selection(finder, 0, "main.rs");
1613 assert_match_at_position(finder, 1, "lib.rs");
1614 assert_match_at_position(finder, 2, "bar.rs");
1615 });
1616
1617 // all files match, main.rs is on top, and is selected
1618 picker
1619 .update_in(cx, |finder, window, cx| {
1620 finder
1621 .delegate
1622 .update_matches(".rs".to_string(), window, cx)
1623 })
1624 .await;
1625 picker.update(cx, |finder, _| {
1626 assert_eq!(finder.delegate.matches.len(), 5);
1627 assert_match_selection(finder, 0, "main.rs");
1628 assert_match_at_position(finder, 1, "bar.rs");
1629 assert_match_at_position(finder, 2, "lib.rs");
1630 assert_match_at_position(finder, 3, "moo.rs");
1631 assert_match_at_position(finder, 4, "maaa.rs");
1632 });
1633}
1634
1635#[gpui::test]
1636async fn test_non_separate_history_items(cx: &mut TestAppContext) {
1637 let app_state = init_test(cx);
1638
1639 app_state
1640 .fs
1641 .as_fake()
1642 .insert_tree(
1643 path!("/src"),
1644 json!({
1645 "test": {
1646 "bar.rs": "// Bar file",
1647 "lib.rs": "// Lib file",
1648 "maaa.rs": "// Maaaaaaa",
1649 "main.rs": "// Main file",
1650 "moo.rs": "// Moooooo",
1651 }
1652 }),
1653 )
1654 .await;
1655
1656 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1657 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1658
1659 open_close_queried_buffer("bar", 1, "bar.rs", &workspace, cx).await;
1660 open_close_queried_buffer("lib", 1, "lib.rs", &workspace, cx).await;
1661 open_queried_buffer("main", 1, "main.rs", &workspace, cx).await;
1662
1663 cx.dispatch_action(ToggleFileFinder::default());
1664 let picker = active_file_picker(&workspace, cx);
1665 // main.rs is on top, previously used is selected
1666 picker.update(cx, |finder, _| {
1667 assert_eq!(finder.delegate.matches.len(), 3);
1668 assert_match_selection(finder, 0, "main.rs");
1669 assert_match_at_position(finder, 1, "lib.rs");
1670 assert_match_at_position(finder, 2, "bar.rs");
1671 });
1672
1673 // all files match, main.rs is still on top, but the second item is selected
1674 picker
1675 .update_in(cx, |finder, window, cx| {
1676 finder
1677 .delegate
1678 .update_matches(".rs".to_string(), window, cx)
1679 })
1680 .await;
1681 picker.update(cx, |finder, _| {
1682 assert_eq!(finder.delegate.matches.len(), 5);
1683 assert_match_at_position(finder, 0, "main.rs");
1684 assert_match_selection(finder, 1, "moo.rs");
1685 assert_match_at_position(finder, 2, "bar.rs");
1686 assert_match_at_position(finder, 3, "lib.rs");
1687 assert_match_at_position(finder, 4, "maaa.rs");
1688 });
1689
1690 // main.rs is not among matches, select top item
1691 picker
1692 .update_in(cx, |finder, window, cx| {
1693 finder.delegate.update_matches("b".to_string(), window, cx)
1694 })
1695 .await;
1696 picker.update(cx, |finder, _| {
1697 assert_eq!(finder.delegate.matches.len(), 2);
1698 assert_match_at_position(finder, 0, "bar.rs");
1699 assert_match_at_position(finder, 1, "lib.rs");
1700 });
1701
1702 // main.rs is back, put it on top and select next item
1703 picker
1704 .update_in(cx, |finder, window, cx| {
1705 finder.delegate.update_matches("m".to_string(), window, cx)
1706 })
1707 .await;
1708 picker.update(cx, |finder, _| {
1709 assert_eq!(finder.delegate.matches.len(), 3);
1710 assert_match_at_position(finder, 0, "main.rs");
1711 assert_match_selection(finder, 1, "moo.rs");
1712 assert_match_at_position(finder, 2, "maaa.rs");
1713 });
1714
1715 // get back to the initial state
1716 picker
1717 .update_in(cx, |finder, window, cx| {
1718 finder.delegate.update_matches("".to_string(), window, cx)
1719 })
1720 .await;
1721 picker.update(cx, |finder, _| {
1722 assert_eq!(finder.delegate.matches.len(), 3);
1723 assert_match_selection(finder, 0, "main.rs");
1724 assert_match_at_position(finder, 1, "lib.rs");
1725 assert_match_at_position(finder, 2, "bar.rs");
1726 });
1727}
1728
1729#[gpui::test]
1730async fn test_history_items_shown_in_order_of_open(cx: &mut TestAppContext) {
1731 let app_state = init_test(cx);
1732
1733 app_state
1734 .fs
1735 .as_fake()
1736 .insert_tree(
1737 path!("/test"),
1738 json!({
1739 "test": {
1740 "1.txt": "// One",
1741 "2.txt": "// Two",
1742 "3.txt": "// Three",
1743 }
1744 }),
1745 )
1746 .await;
1747
1748 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
1749 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1750
1751 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
1752 open_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
1753 open_queried_buffer("3", 1, "3.txt", &workspace, cx).await;
1754
1755 let picker = open_file_picker(&workspace, cx);
1756 picker.update(cx, |finder, _| {
1757 assert_eq!(finder.delegate.matches.len(), 3);
1758 assert_match_selection(finder, 0, "3.txt");
1759 assert_match_at_position(finder, 1, "2.txt");
1760 assert_match_at_position(finder, 2, "1.txt");
1761 });
1762
1763 cx.dispatch_action(SelectNext);
1764 cx.dispatch_action(Confirm); // Open 2.txt
1765
1766 let picker = open_file_picker(&workspace, cx);
1767 picker.update(cx, |finder, _| {
1768 assert_eq!(finder.delegate.matches.len(), 3);
1769 assert_match_selection(finder, 0, "2.txt");
1770 assert_match_at_position(finder, 1, "3.txt");
1771 assert_match_at_position(finder, 2, "1.txt");
1772 });
1773
1774 cx.dispatch_action(SelectNext);
1775 cx.dispatch_action(SelectNext);
1776 cx.dispatch_action(Confirm); // Open 1.txt
1777
1778 let picker = open_file_picker(&workspace, cx);
1779 picker.update(cx, |finder, _| {
1780 assert_eq!(finder.delegate.matches.len(), 3);
1781 assert_match_selection(finder, 0, "1.txt");
1782 assert_match_at_position(finder, 1, "2.txt");
1783 assert_match_at_position(finder, 2, "3.txt");
1784 });
1785}
1786
1787#[gpui::test]
1788async fn test_selected_history_item_stays_selected_on_worktree_updated(cx: &mut TestAppContext) {
1789 let app_state = init_test(cx);
1790
1791 app_state
1792 .fs
1793 .as_fake()
1794 .insert_tree(
1795 path!("/test"),
1796 json!({
1797 "test": {
1798 "1.txt": "// One",
1799 "2.txt": "// Two",
1800 "3.txt": "// Three",
1801 }
1802 }),
1803 )
1804 .await;
1805
1806 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
1807 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1808
1809 open_close_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
1810 open_close_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
1811 open_close_queried_buffer("3", 1, "3.txt", &workspace, cx).await;
1812
1813 let picker = open_file_picker(&workspace, cx);
1814 picker.update(cx, |finder, _| {
1815 assert_eq!(finder.delegate.matches.len(), 3);
1816 assert_match_selection(finder, 0, "3.txt");
1817 assert_match_at_position(finder, 1, "2.txt");
1818 assert_match_at_position(finder, 2, "1.txt");
1819 });
1820
1821 cx.dispatch_action(SelectNext);
1822
1823 // Add more files to the worktree to trigger update matches
1824 for i in 0..5 {
1825 let filename = if cfg!(windows) {
1826 format!("C:/test/{}.txt", 4 + i)
1827 } else {
1828 format!("/test/{}.txt", 4 + i)
1829 };
1830 app_state
1831 .fs
1832 .create_file(Path::new(&filename), Default::default())
1833 .await
1834 .expect("unable to create file");
1835 }
1836
1837 cx.executor().advance_clock(FS_WATCH_LATENCY);
1838
1839 picker.update(cx, |finder, _| {
1840 assert_eq!(finder.delegate.matches.len(), 3);
1841 assert_match_at_position(finder, 0, "3.txt");
1842 assert_match_selection(finder, 1, "2.txt");
1843 assert_match_at_position(finder, 2, "1.txt");
1844 });
1845}
1846
1847#[gpui::test]
1848async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppContext) {
1849 let app_state = init_test(cx);
1850
1851 app_state
1852 .fs
1853 .as_fake()
1854 .insert_tree(
1855 path!("/src"),
1856 json!({
1857 "collab_ui": {
1858 "first.rs": "// First Rust file",
1859 "second.rs": "// Second Rust file",
1860 "third.rs": "// Third Rust file",
1861 "collab_ui.rs": "// Fourth Rust file",
1862 }
1863 }),
1864 )
1865 .await;
1866
1867 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1868 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1869 // generate some history to select from
1870 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1871 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1872 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1873 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1874
1875 let finder = open_file_picker(&workspace, cx);
1876 let query = "collab_ui";
1877 cx.simulate_input(query);
1878 finder.update(cx, |picker, _| {
1879 let search_entries = collect_search_matches(picker).search_paths_only();
1880 assert_eq!(
1881 search_entries,
1882 vec![
1883 PathBuf::from("collab_ui/collab_ui.rs"),
1884 PathBuf::from("collab_ui/first.rs"),
1885 PathBuf::from("collab_ui/third.rs"),
1886 PathBuf::from("collab_ui/second.rs"),
1887 ],
1888 "Despite all search results having the same directory name, the most matching one should be on top"
1889 );
1890 });
1891}
1892
1893#[gpui::test]
1894async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) {
1895 let app_state = init_test(cx);
1896
1897 app_state
1898 .fs
1899 .as_fake()
1900 .insert_tree(
1901 path!("/src"),
1902 json!({
1903 "test": {
1904 "first.rs": "// First Rust file",
1905 "nonexistent.rs": "// Second Rust file",
1906 "third.rs": "// Third Rust file",
1907 }
1908 }),
1909 )
1910 .await;
1911
1912 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1913 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); // generate some history to select from
1914 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1915 open_close_queried_buffer("non", 1, "nonexistent.rs", &workspace, cx).await;
1916 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1917 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1918 app_state
1919 .fs
1920 .remove_file(
1921 Path::new(path!("/src/test/nonexistent.rs")),
1922 RemoveOptions::default(),
1923 )
1924 .await
1925 .unwrap();
1926 cx.run_until_parked();
1927
1928 let picker = open_file_picker(&workspace, cx);
1929 cx.simulate_input("rs");
1930
1931 picker.update(cx, |picker, _| {
1932 assert_eq!(
1933 collect_search_matches(picker).history,
1934 vec![
1935 PathBuf::from("test/first.rs"),
1936 PathBuf::from("test/third.rs"),
1937 ],
1938 "Should have all opened files in the history, except the ones that do not exist on disk"
1939 );
1940 });
1941}
1942
1943#[gpui::test]
1944async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAppContext) {
1945 let app_state = init_test(cx);
1946
1947 app_state
1948 .fs
1949 .as_fake()
1950 .insert_tree(
1951 "/src",
1952 json!({
1953 "lib.rs": "// Lib file",
1954 "main.rs": "// Bar file",
1955 "read.me": "// Readme file",
1956 }),
1957 )
1958 .await;
1959
1960 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1961 let (workspace, cx) =
1962 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1963
1964 // Initial state
1965 let picker = open_file_picker(&workspace, cx);
1966 cx.simulate_input("rs");
1967 picker.update(cx, |finder, _| {
1968 assert_eq!(finder.delegate.matches.len(), 2);
1969 assert_match_at_position(finder, 0, "lib.rs");
1970 assert_match_at_position(finder, 1, "main.rs");
1971 });
1972
1973 // Delete main.rs
1974 app_state
1975 .fs
1976 .remove_file("/src/main.rs".as_ref(), Default::default())
1977 .await
1978 .expect("unable to remove file");
1979 cx.executor().advance_clock(FS_WATCH_LATENCY);
1980
1981 // main.rs is in not among search results anymore
1982 picker.update(cx, |finder, _| {
1983 assert_eq!(finder.delegate.matches.len(), 1);
1984 assert_match_at_position(finder, 0, "lib.rs");
1985 });
1986
1987 // Create util.rs
1988 app_state
1989 .fs
1990 .create_file("/src/util.rs".as_ref(), Default::default())
1991 .await
1992 .expect("unable to create file");
1993 cx.executor().advance_clock(FS_WATCH_LATENCY);
1994
1995 // util.rs is among search results
1996 picker.update(cx, |finder, _| {
1997 assert_eq!(finder.delegate.matches.len(), 2);
1998 assert_match_at_position(finder, 0, "lib.rs");
1999 assert_match_at_position(finder, 1, "util.rs");
2000 });
2001}
2002
2003#[gpui::test]
2004async fn test_search_results_refreshed_on_adding_and_removing_worktrees(
2005 cx: &mut gpui::TestAppContext,
2006) {
2007 let app_state = init_test(cx);
2008
2009 app_state
2010 .fs
2011 .as_fake()
2012 .insert_tree(
2013 "/test",
2014 json!({
2015 "project_1": {
2016 "bar.rs": "// Bar file",
2017 "lib.rs": "// Lib file",
2018 },
2019 "project_2": {
2020 "Cargo.toml": "// Cargo file",
2021 "main.rs": "// Main file",
2022 }
2023 }),
2024 )
2025 .await;
2026
2027 let project = Project::test(app_state.fs.clone(), ["/test/project_1".as_ref()], cx).await;
2028 let (workspace, cx) =
2029 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2030 let worktree_1_id = project.update(cx, |project, cx| {
2031 let worktree = project.worktrees(cx).last().expect("worktree not found");
2032 worktree.read(cx).id()
2033 });
2034
2035 // Initial state
2036 let picker = open_file_picker(&workspace, cx);
2037 cx.simulate_input("rs");
2038 picker.update(cx, |finder, _| {
2039 assert_eq!(finder.delegate.matches.len(), 2);
2040 assert_match_at_position(finder, 0, "bar.rs");
2041 assert_match_at_position(finder, 1, "lib.rs");
2042 });
2043
2044 // Add new worktree
2045 project
2046 .update(cx, |project, cx| {
2047 project
2048 .find_or_create_worktree("/test/project_2", true, cx)
2049 .into_future()
2050 })
2051 .await
2052 .expect("unable to create workdir");
2053 cx.executor().advance_clock(FS_WATCH_LATENCY);
2054
2055 // main.rs is among search results
2056 picker.update(cx, |finder, _| {
2057 assert_eq!(finder.delegate.matches.len(), 3);
2058 assert_match_at_position(finder, 0, "bar.rs");
2059 assert_match_at_position(finder, 1, "lib.rs");
2060 assert_match_at_position(finder, 2, "main.rs");
2061 });
2062
2063 // Remove the first worktree
2064 project.update(cx, |project, cx| {
2065 project.remove_worktree(worktree_1_id, cx);
2066 });
2067 cx.executor().advance_clock(FS_WATCH_LATENCY);
2068
2069 // Files from the first worktree are not in the search results anymore
2070 picker.update(cx, |finder, _| {
2071 assert_eq!(finder.delegate.matches.len(), 1);
2072 assert_match_at_position(finder, 0, "main.rs");
2073 });
2074}
2075
2076#[gpui::test]
2077async fn test_selected_match_stays_selected_after_matches_refreshed(cx: &mut gpui::TestAppContext) {
2078 let app_state = init_test(cx);
2079
2080 app_state.fs.as_fake().insert_tree("/src", json!({})).await;
2081
2082 app_state
2083 .fs
2084 .create_dir("/src/even".as_ref())
2085 .await
2086 .expect("unable to create dir");
2087
2088 let initial_files_num = 5;
2089 for i in 0..initial_files_num {
2090 let filename = format!("/src/even/file_{}.txt", 10 + i);
2091 app_state
2092 .fs
2093 .create_file(Path::new(&filename), Default::default())
2094 .await
2095 .expect("unable to create file");
2096 }
2097
2098 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
2099 let (workspace, cx) =
2100 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2101
2102 // Initial state
2103 let picker = open_file_picker(&workspace, cx);
2104 cx.simulate_input("file");
2105 let selected_index = 3;
2106 // Checking only the filename, not the whole path
2107 let selected_file = format!("file_{}.txt", 10 + selected_index);
2108 // Select even/file_13.txt
2109 for _ in 0..selected_index {
2110 cx.dispatch_action(SelectNext);
2111 }
2112
2113 picker.update(cx, |finder, _| {
2114 assert_match_selection(finder, selected_index, &selected_file)
2115 });
2116
2117 // Add more matches to the search results
2118 let files_to_add = 10;
2119 for i in 0..files_to_add {
2120 let filename = format!("/src/file_{}.txt", 20 + i);
2121 app_state
2122 .fs
2123 .create_file(Path::new(&filename), Default::default())
2124 .await
2125 .expect("unable to create file");
2126 }
2127 cx.executor().advance_clock(FS_WATCH_LATENCY);
2128
2129 // file_13.txt is still selected
2130 picker.update(cx, |finder, _| {
2131 let expected_selected_index = selected_index + files_to_add;
2132 assert_match_selection(finder, expected_selected_index, &selected_file);
2133 });
2134}
2135
2136#[gpui::test]
2137async fn test_first_match_selected_if_previous_one_is_not_in_the_match_list(
2138 cx: &mut gpui::TestAppContext,
2139) {
2140 let app_state = init_test(cx);
2141
2142 app_state
2143 .fs
2144 .as_fake()
2145 .insert_tree(
2146 "/src",
2147 json!({
2148 "file_1.txt": "// file_1",
2149 "file_2.txt": "// file_2",
2150 "file_3.txt": "// file_3",
2151 }),
2152 )
2153 .await;
2154
2155 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
2156 let (workspace, cx) =
2157 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2158
2159 // Initial state
2160 let picker = open_file_picker(&workspace, cx);
2161 cx.simulate_input("file");
2162 // Select even/file_2.txt
2163 cx.dispatch_action(SelectNext);
2164
2165 // Remove the selected entry
2166 app_state
2167 .fs
2168 .remove_file("/src/file_2.txt".as_ref(), Default::default())
2169 .await
2170 .expect("unable to remove file");
2171 cx.executor().advance_clock(FS_WATCH_LATENCY);
2172
2173 // file_1.txt is now selected
2174 picker.update(cx, |finder, _| {
2175 assert_match_selection(finder, 0, "file_1.txt");
2176 });
2177}
2178
2179#[gpui::test]
2180async fn test_keeps_file_finder_open_after_modifier_keys_release(cx: &mut gpui::TestAppContext) {
2181 let app_state = init_test(cx);
2182
2183 app_state
2184 .fs
2185 .as_fake()
2186 .insert_tree(
2187 path!("/test"),
2188 json!({
2189 "1.txt": "// One",
2190 }),
2191 )
2192 .await;
2193
2194 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
2195 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2196
2197 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
2198
2199 cx.simulate_modifiers_change(Modifiers::secondary_key());
2200 open_file_picker(&workspace, cx);
2201
2202 cx.simulate_modifiers_change(Modifiers::none());
2203 active_file_picker(&workspace, cx);
2204}
2205
2206#[gpui::test]
2207async fn test_opens_file_on_modifier_keys_release(cx: &mut gpui::TestAppContext) {
2208 let app_state = init_test(cx);
2209
2210 app_state
2211 .fs
2212 .as_fake()
2213 .insert_tree(
2214 path!("/test"),
2215 json!({
2216 "1.txt": "// One",
2217 "2.txt": "// Two",
2218 }),
2219 )
2220 .await;
2221
2222 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
2223 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2224
2225 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
2226 open_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
2227
2228 cx.simulate_modifiers_change(Modifiers::secondary_key());
2229 let picker = open_file_picker(&workspace, cx);
2230 picker.update(cx, |finder, _| {
2231 assert_eq!(finder.delegate.matches.len(), 2);
2232 assert_match_selection(finder, 0, "2.txt");
2233 assert_match_at_position(finder, 1, "1.txt");
2234 });
2235
2236 cx.dispatch_action(SelectNext);
2237 cx.simulate_modifiers_change(Modifiers::none());
2238 cx.read(|cx| {
2239 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
2240 assert_eq!(active_editor.read(cx).title(cx), "1.txt");
2241 });
2242}
2243
2244#[gpui::test]
2245async fn test_switches_between_release_norelease_modes_on_forward_nav(
2246 cx: &mut gpui::TestAppContext,
2247) {
2248 let app_state = init_test(cx);
2249
2250 app_state
2251 .fs
2252 .as_fake()
2253 .insert_tree(
2254 path!("/test"),
2255 json!({
2256 "1.txt": "// One",
2257 "2.txt": "// Two",
2258 }),
2259 )
2260 .await;
2261
2262 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
2263 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2264
2265 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
2266 open_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
2267
2268 // Open with a shortcut
2269 cx.simulate_modifiers_change(Modifiers::secondary_key());
2270 let picker = open_file_picker(&workspace, cx);
2271 picker.update(cx, |finder, _| {
2272 assert_eq!(finder.delegate.matches.len(), 2);
2273 assert_match_selection(finder, 0, "2.txt");
2274 assert_match_at_position(finder, 1, "1.txt");
2275 });
2276
2277 // Switch to navigating with other shortcuts
2278 // Don't open file on modifiers release
2279 cx.simulate_modifiers_change(Modifiers::control());
2280 cx.dispatch_action(SelectNext);
2281 cx.simulate_modifiers_change(Modifiers::none());
2282 picker.update(cx, |finder, _| {
2283 assert_eq!(finder.delegate.matches.len(), 2);
2284 assert_match_at_position(finder, 0, "2.txt");
2285 assert_match_selection(finder, 1, "1.txt");
2286 });
2287
2288 // Back to navigation with initial shortcut
2289 // Open file on modifiers release
2290 cx.simulate_modifiers_change(Modifiers::secondary_key());
2291 cx.dispatch_action(ToggleFileFinder::default());
2292 cx.simulate_modifiers_change(Modifiers::none());
2293 cx.read(|cx| {
2294 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
2295 assert_eq!(active_editor.read(cx).title(cx), "2.txt");
2296 });
2297}
2298
2299#[gpui::test]
2300async fn test_switches_between_release_norelease_modes_on_backward_nav(
2301 cx: &mut gpui::TestAppContext,
2302) {
2303 let app_state = init_test(cx);
2304
2305 app_state
2306 .fs
2307 .as_fake()
2308 .insert_tree(
2309 path!("/test"),
2310 json!({
2311 "1.txt": "// One",
2312 "2.txt": "// Two",
2313 "3.txt": "// Three"
2314 }),
2315 )
2316 .await;
2317
2318 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
2319 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2320
2321 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
2322 open_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
2323 open_queried_buffer("3", 1, "3.txt", &workspace, cx).await;
2324
2325 // Open with a shortcut
2326 cx.simulate_modifiers_change(Modifiers::secondary_key());
2327 let picker = open_file_picker(&workspace, cx);
2328 picker.update(cx, |finder, _| {
2329 assert_eq!(finder.delegate.matches.len(), 3);
2330 assert_match_selection(finder, 0, "3.txt");
2331 assert_match_at_position(finder, 1, "2.txt");
2332 assert_match_at_position(finder, 2, "1.txt");
2333 });
2334
2335 // Switch to navigating with other shortcuts
2336 // Don't open file on modifiers release
2337 cx.simulate_modifiers_change(Modifiers::control());
2338 cx.dispatch_action(menu::SelectPrevious);
2339 cx.simulate_modifiers_change(Modifiers::none());
2340 picker.update(cx, |finder, _| {
2341 assert_eq!(finder.delegate.matches.len(), 3);
2342 assert_match_at_position(finder, 0, "3.txt");
2343 assert_match_at_position(finder, 1, "2.txt");
2344 assert_match_selection(finder, 2, "1.txt");
2345 });
2346
2347 // Back to navigation with initial shortcut
2348 // Open file on modifiers release
2349 cx.simulate_modifiers_change(Modifiers::secondary_key());
2350 cx.dispatch_action(SelectPrevious); // <-- File Finder's SelectPrevious, not menu's
2351 cx.simulate_modifiers_change(Modifiers::none());
2352 cx.read(|cx| {
2353 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
2354 assert_eq!(active_editor.read(cx).title(cx), "3.txt");
2355 });
2356}
2357
2358#[gpui::test]
2359async fn test_extending_modifiers_does_not_confirm_selection(cx: &mut gpui::TestAppContext) {
2360 let app_state = init_test(cx);
2361
2362 app_state
2363 .fs
2364 .as_fake()
2365 .insert_tree(
2366 path!("/test"),
2367 json!({
2368 "1.txt": "// One",
2369 }),
2370 )
2371 .await;
2372
2373 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
2374 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2375
2376 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
2377
2378 cx.simulate_modifiers_change(Modifiers::secondary_key());
2379 open_file_picker(&workspace, cx);
2380
2381 cx.simulate_modifiers_change(Modifiers::command_shift());
2382 active_file_picker(&workspace, cx);
2383}
2384
2385#[gpui::test]
2386async fn test_repeat_toggle_action(cx: &mut gpui::TestAppContext) {
2387 let app_state = init_test(cx);
2388 app_state
2389 .fs
2390 .as_fake()
2391 .insert_tree(
2392 "/test",
2393 json!({
2394 "00.txt": "",
2395 "01.txt": "",
2396 "02.txt": "",
2397 "03.txt": "",
2398 "04.txt": "",
2399 "05.txt": "",
2400 }),
2401 )
2402 .await;
2403
2404 let project = Project::test(app_state.fs.clone(), ["/test".as_ref()], cx).await;
2405 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2406
2407 cx.dispatch_action(ToggleFileFinder::default());
2408 let picker = active_file_picker(&workspace, cx);
2409
2410 picker.update_in(cx, |picker, window, cx| {
2411 picker.update_matches(".txt".to_string(), window, cx)
2412 });
2413
2414 cx.run_until_parked();
2415
2416 picker.update(cx, |picker, _| {
2417 assert_eq!(picker.delegate.matches.len(), 6);
2418 assert_eq!(picker.delegate.selected_index, 0);
2419 });
2420
2421 // When toggling repeatedly, the picker scrolls to reveal the selected item.
2422 cx.dispatch_action(ToggleFileFinder::default());
2423 cx.dispatch_action(ToggleFileFinder::default());
2424 cx.dispatch_action(ToggleFileFinder::default());
2425
2426 cx.run_until_parked();
2427
2428 picker.update(cx, |picker, _| {
2429 assert_eq!(picker.delegate.matches.len(), 6);
2430 assert_eq!(picker.delegate.selected_index, 3);
2431 });
2432}
2433
2434async fn open_close_queried_buffer(
2435 input: &str,
2436 expected_matches: usize,
2437 expected_editor_title: &str,
2438 workspace: &Entity<Workspace>,
2439 cx: &mut gpui::VisualTestContext,
2440) -> Vec<FoundPath> {
2441 let history_items = open_queried_buffer(
2442 input,
2443 expected_matches,
2444 expected_editor_title,
2445 workspace,
2446 cx,
2447 )
2448 .await;
2449
2450 cx.dispatch_action(workspace::CloseActiveItem {
2451 save_intent: None,
2452 close_pinned: false,
2453 });
2454
2455 history_items
2456}
2457
2458async fn open_queried_buffer(
2459 input: &str,
2460 expected_matches: usize,
2461 expected_editor_title: &str,
2462 workspace: &Entity<Workspace>,
2463 cx: &mut gpui::VisualTestContext,
2464) -> Vec<FoundPath> {
2465 let picker = open_file_picker(&workspace, cx);
2466 cx.simulate_input(input);
2467
2468 let history_items = picker.update(cx, |finder, _| {
2469 assert_eq!(
2470 finder.delegate.matches.len(),
2471 expected_matches,
2472 "Unexpected number of matches found for query `{input}`, matches: {:?}",
2473 finder.delegate.matches
2474 );
2475 finder.delegate.history_items.clone()
2476 });
2477
2478 cx.dispatch_action(Confirm);
2479
2480 cx.read(|cx| {
2481 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
2482 let active_editor_title = active_editor.read(cx).title(cx);
2483 assert_eq!(
2484 expected_editor_title, active_editor_title,
2485 "Unexpected editor title for query `{input}`"
2486 );
2487 });
2488
2489 history_items
2490}
2491
2492fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
2493 cx.update(|cx| {
2494 let state = AppState::test(cx);
2495 theme::init(theme::LoadThemes::JustBase, cx);
2496 language::init(cx);
2497 super::init(cx);
2498 editor::init(cx);
2499 workspace::init_settings(cx);
2500 Project::init_settings(cx);
2501 state
2502 })
2503}
2504
2505fn test_path_position(test_str: &str) -> FileSearchQuery {
2506 let path_position = PathWithPosition::parse_str(test_str);
2507
2508 FileSearchQuery {
2509 raw_query: test_str.to_owned(),
2510 file_query_end: if path_position.path.to_str().unwrap() == test_str {
2511 None
2512 } else {
2513 Some(path_position.path.to_str().unwrap().len())
2514 },
2515 path_position,
2516 }
2517}
2518
2519fn build_find_picker(
2520 project: Entity<Project>,
2521 cx: &mut TestAppContext,
2522) -> (
2523 Entity<Picker<FileFinderDelegate>>,
2524 Entity<Workspace>,
2525 &mut VisualTestContext,
2526) {
2527 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2528 let picker = open_file_picker(&workspace, cx);
2529 (picker, workspace, cx)
2530}
2531
2532#[track_caller]
2533fn open_file_picker(
2534 workspace: &Entity<Workspace>,
2535 cx: &mut VisualTestContext,
2536) -> Entity<Picker<FileFinderDelegate>> {
2537 cx.dispatch_action(ToggleFileFinder {
2538 separate_history: true,
2539 });
2540 active_file_picker(workspace, cx)
2541}
2542
2543#[track_caller]
2544fn active_file_picker(
2545 workspace: &Entity<Workspace>,
2546 cx: &mut VisualTestContext,
2547) -> Entity<Picker<FileFinderDelegate>> {
2548 workspace.update(cx, |workspace, cx| {
2549 workspace
2550 .active_modal::<FileFinder>(cx)
2551 .expect("file finder is not open")
2552 .read(cx)
2553 .picker
2554 .clone()
2555 })
2556}
2557
2558#[derive(Debug, Default)]
2559struct SearchEntries {
2560 history: Vec<PathBuf>,
2561 history_found_paths: Vec<FoundPath>,
2562 search: Vec<PathBuf>,
2563 search_matches: Vec<PathMatch>,
2564}
2565
2566impl SearchEntries {
2567 #[track_caller]
2568 fn search_paths_only(self) -> Vec<PathBuf> {
2569 assert!(
2570 self.history.is_empty(),
2571 "Should have no history matches, but got: {:?}",
2572 self.history
2573 );
2574 self.search
2575 }
2576
2577 #[track_caller]
2578 fn search_matches_only(self) -> Vec<PathMatch> {
2579 assert!(
2580 self.history.is_empty(),
2581 "Should have no history matches, but got: {:?}",
2582 self.history
2583 );
2584 self.search_matches
2585 }
2586}
2587
2588fn collect_search_matches(picker: &Picker<FileFinderDelegate>) -> SearchEntries {
2589 let mut search_entries = SearchEntries::default();
2590 for m in &picker.delegate.matches.matches {
2591 match &m {
2592 Match::History {
2593 path: history_path,
2594 panel_match: path_match,
2595 } => {
2596 search_entries.history.push(
2597 path_match
2598 .as_ref()
2599 .map(|path_match| {
2600 Path::new(path_match.0.path_prefix.as_ref()).join(&path_match.0.path)
2601 })
2602 .unwrap_or_else(|| {
2603 history_path
2604 .absolute
2605 .as_deref()
2606 .unwrap_or_else(|| &history_path.project.path)
2607 .to_path_buf()
2608 }),
2609 );
2610 search_entries
2611 .history_found_paths
2612 .push(history_path.clone());
2613 }
2614 Match::Search(path_match) => {
2615 search_entries
2616 .search
2617 .push(Path::new(path_match.0.path_prefix.as_ref()).join(&path_match.0.path));
2618 search_entries.search_matches.push(path_match.0.clone());
2619 }
2620 }
2621 }
2622 search_entries
2623}
2624
2625#[track_caller]
2626fn assert_match_selection(
2627 finder: &Picker<FileFinderDelegate>,
2628 expected_selection_index: usize,
2629 expected_file_name: &str,
2630) {
2631 assert_eq!(
2632 finder.delegate.selected_index(),
2633 expected_selection_index,
2634 "Match is not selected"
2635 );
2636 assert_match_at_position(finder, expected_selection_index, expected_file_name);
2637}
2638
2639#[track_caller]
2640fn assert_match_at_position(
2641 finder: &Picker<FileFinderDelegate>,
2642 match_index: usize,
2643 expected_file_name: &str,
2644) {
2645 let match_item = finder
2646 .delegate
2647 .matches
2648 .get(match_index)
2649 .unwrap_or_else(|| panic!("Finder has no match for index {match_index}"));
2650 let match_file_name = match &match_item {
2651 Match::History { path, .. } => path.absolute.as_deref().unwrap().file_name(),
2652 Match::Search(path_match) => path_match.0.path.file_name(),
2653 }
2654 .unwrap()
2655 .to_string_lossy();
2656 assert_eq!(match_file_name, expected_file_name);
2657}
2658
2659#[gpui::test]
2660async fn test_filename_precedence(cx: &mut TestAppContext) {
2661 let app_state = init_test(cx);
2662
2663 app_state
2664 .fs
2665 .as_fake()
2666 .insert_tree(
2667 path!("/src"),
2668 json!({
2669 "layout": {
2670 "app.css": "",
2671 "app.d.ts": "",
2672 "app.html": "",
2673 "+page.svelte": "",
2674 },
2675 "routes": {
2676 "+layout.svelte": "",
2677 }
2678 }),
2679 )
2680 .await;
2681
2682 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
2683 let (picker, _, cx) = build_find_picker(project, cx);
2684
2685 cx.simulate_input("layout");
2686
2687 picker.update(cx, |finder, _| {
2688 let search_matches = collect_search_matches(finder).search_paths_only();
2689
2690 assert_eq!(
2691 search_matches,
2692 vec![
2693 PathBuf::from("routes/+layout.svelte"),
2694 PathBuf::from("layout/app.css"),
2695 PathBuf::from("layout/app.d.ts"),
2696 PathBuf::from("layout/app.html"),
2697 PathBuf::from("layout/+page.svelte"),
2698 ],
2699 "File with 'layout' in filename should be prioritized over files in 'layout' directory"
2700 );
2701 });
2702}