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 .unwrap()
743 })
744 })
745 .await
746 .unwrap();
747 cx.run_until_parked();
748
749 picker
750 .update_in(cx, |picker, window, cx| {
751 picker.delegate.include_ignored = None;
752 picker
753 .delegate
754 .spawn_search(test_path_position("hi"), window, cx)
755 })
756 .await;
757 picker.update(cx, |picker, _| {
758 let matches = collect_search_matches(picker);
759 assert_eq!(matches.history.len(), 0);
760 assert_eq!(
761 matches.search,
762 vec![
763 PathBuf::from("ignored-root/hi"),
764 PathBuf::from("tracked-root/hi"),
765 PathBuf::from("ignored-root/hiccup"),
766 PathBuf::from("tracked-root/hiccup"),
767 PathBuf::from("ignored-root/height"),
768 PathBuf::from("ignored-root/happiness"),
769 PathBuf::from("tracked-root/happiness"),
770 ],
771 "Only for the worktree with the ignored root, all indexed ignored files are found in the auto ignored mode"
772 );
773 });
774
775 picker
776 .update_in(cx, |picker, window, cx| {
777 picker.delegate.include_ignored = Some(true);
778 picker
779 .delegate
780 .spawn_search(test_path_position("hi"), window, cx)
781 })
782 .await;
783 picker.update(cx, |picker, _| {
784 let matches = collect_search_matches(picker);
785 assert_eq!(matches.history.len(), 0);
786 assert_eq!(
787 matches.search,
788 vec![
789 PathBuf::from("ignored-root/hi"),
790 PathBuf::from("tracked-root/hi"),
791 PathBuf::from("ignored-root/hiccup"),
792 PathBuf::from("tracked-root/hiccup"),
793 PathBuf::from("ignored-root/height"),
794 PathBuf::from("tracked-root/height"),
795 PathBuf::from("tracked-root/heights/height_1"),
796 PathBuf::from("tracked-root/heights/height_2"),
797 PathBuf::from("ignored-root/happiness"),
798 PathBuf::from("tracked-root/happiness"),
799 ],
800 "All ignored files that were indexed are found in the turned on ignored mode"
801 );
802 });
803
804 picker
805 .update_in(cx, |picker, window, cx| {
806 picker.delegate.include_ignored = Some(false);
807 picker
808 .delegate
809 .spawn_search(test_path_position("hi"), window, cx)
810 })
811 .await;
812 picker.update(cx, |picker, _| {
813 let matches = collect_search_matches(picker);
814 assert_eq!(matches.history.len(), 0);
815 assert_eq!(
816 matches.search,
817 vec![
818 PathBuf::from("tracked-root/hi"),
819 PathBuf::from("tracked-root/hiccup"),
820 PathBuf::from("tracked-root/happiness"),
821 ],
822 "Only non-ignored files should be found for the turned off ignored mode"
823 );
824 });
825}
826
827#[gpui::test]
828async fn test_single_file_worktrees(cx: &mut TestAppContext) {
829 let app_state = init_test(cx);
830 app_state
831 .fs
832 .as_fake()
833 .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
834 .await;
835
836 let project = Project::test(
837 app_state.fs.clone(),
838 ["/root/the-parent-dir/the-file".as_ref()],
839 cx,
840 )
841 .await;
842
843 let (picker, _, cx) = build_find_picker(project, cx);
844
845 // Even though there is only one worktree, that worktree's filename
846 // is included in the matching, because the worktree is a single file.
847 picker
848 .update_in(cx, |picker, window, cx| {
849 picker
850 .delegate
851 .spawn_search(test_path_position("thf"), window, cx)
852 })
853 .await;
854 cx.read(|cx| {
855 let picker = picker.read(cx);
856 let delegate = &picker.delegate;
857 let matches = collect_search_matches(picker).search_matches_only();
858 assert_eq!(matches.len(), 1);
859
860 let (file_name, file_name_positions, full_path, full_path_positions) =
861 delegate.labels_for_path_match(&matches[0]);
862 assert_eq!(file_name, "the-file");
863 assert_eq!(file_name_positions, &[0, 1, 4]);
864 assert_eq!(full_path, "");
865 assert_eq!(full_path_positions, &[0; 0]);
866 });
867
868 // Since the worktree root is a file, searching for its name followed by a slash does
869 // not match anything.
870 picker
871 .update_in(cx, |picker, window, cx| {
872 picker
873 .delegate
874 .spawn_search(test_path_position("thf/"), window, cx)
875 })
876 .await;
877 picker.update(cx, |f, _| assert_eq!(f.delegate.matches.len(), 0));
878}
879
880#[gpui::test]
881async fn test_path_distance_ordering(cx: &mut TestAppContext) {
882 let app_state = init_test(cx);
883 app_state
884 .fs
885 .as_fake()
886 .insert_tree(
887 path!("/root"),
888 json!({
889 "dir1": { "a.txt": "" },
890 "dir2": {
891 "a.txt": "",
892 "b.txt": ""
893 }
894 }),
895 )
896 .await;
897
898 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
899 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
900
901 let worktree_id = cx.read(|cx| {
902 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
903 assert_eq!(worktrees.len(), 1);
904 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
905 });
906
907 // When workspace has an active item, sort items which are closer to that item
908 // first when they have the same name. In this case, b.txt is closer to dir2's a.txt
909 // so that one should be sorted earlier
910 let b_path = ProjectPath {
911 worktree_id,
912 path: Arc::from(Path::new("dir2/b.txt")),
913 };
914 workspace
915 .update_in(cx, |workspace, window, cx| {
916 workspace.open_path(b_path, None, true, window, cx)
917 })
918 .await
919 .unwrap();
920 let finder = open_file_picker(&workspace, cx);
921 finder
922 .update_in(cx, |f, window, cx| {
923 f.delegate
924 .spawn_search(test_path_position("a.txt"), window, cx)
925 })
926 .await;
927
928 finder.update(cx, |picker, _| {
929 let matches = collect_search_matches(picker).search_paths_only();
930 assert_eq!(matches[0].as_path(), Path::new("dir2/a.txt"));
931 assert_eq!(matches[1].as_path(), Path::new("dir1/a.txt"));
932 });
933}
934
935#[gpui::test]
936async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
937 let app_state = init_test(cx);
938 app_state
939 .fs
940 .as_fake()
941 .insert_tree(
942 "/root",
943 json!({
944 "dir1": {},
945 "dir2": {
946 "dir3": {}
947 }
948 }),
949 )
950 .await;
951
952 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
953 let (picker, _workspace, cx) = build_find_picker(project, cx);
954
955 picker
956 .update_in(cx, |f, window, cx| {
957 f.delegate
958 .spawn_search(test_path_position("dir"), window, cx)
959 })
960 .await;
961 cx.read(|cx| {
962 let finder = picker.read(cx);
963 assert_eq!(finder.delegate.matches.len(), 0);
964 });
965}
966
967#[gpui::test]
968async fn test_query_history(cx: &mut gpui::TestAppContext) {
969 let app_state = init_test(cx);
970
971 app_state
972 .fs
973 .as_fake()
974 .insert_tree(
975 path!("/src"),
976 json!({
977 "test": {
978 "first.rs": "// First Rust file",
979 "second.rs": "// Second Rust file",
980 "third.rs": "// Third Rust file",
981 }
982 }),
983 )
984 .await;
985
986 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
987 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
988 let worktree_id = cx.read(|cx| {
989 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
990 assert_eq!(worktrees.len(), 1);
991 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
992 });
993
994 // Open and close panels, getting their history items afterwards.
995 // Ensure history items get populated with opened items, and items are kept in a certain order.
996 // The history lags one opened buffer behind, since it's updated in the search panel only on its reopen.
997 //
998 // TODO: without closing, the opened items do not propagate their history changes for some reason
999 // it does work in real app though, only tests do not propagate.
1000 workspace.update_in(cx, |_workspace, window, cx| window.focused(cx));
1001
1002 let initial_history = open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1003 assert!(
1004 initial_history.is_empty(),
1005 "Should have no history before opening any files"
1006 );
1007
1008 let history_after_first =
1009 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1010 assert_eq!(
1011 history_after_first,
1012 vec![FoundPath::new(
1013 ProjectPath {
1014 worktree_id,
1015 path: Arc::from(Path::new("test/first.rs")),
1016 },
1017 Some(PathBuf::from(path!("/src/test/first.rs")))
1018 )],
1019 "Should show 1st opened item in the history when opening the 2nd item"
1020 );
1021
1022 let history_after_second =
1023 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1024 assert_eq!(
1025 history_after_second,
1026 vec![
1027 FoundPath::new(
1028 ProjectPath {
1029 worktree_id,
1030 path: Arc::from(Path::new("test/second.rs")),
1031 },
1032 Some(PathBuf::from(path!("/src/test/second.rs")))
1033 ),
1034 FoundPath::new(
1035 ProjectPath {
1036 worktree_id,
1037 path: Arc::from(Path::new("test/first.rs")),
1038 },
1039 Some(PathBuf::from(path!("/src/test/first.rs")))
1040 ),
1041 ],
1042 "Should show 1st and 2nd opened items in the history when opening the 3rd item. \
1043 2nd item should be the first in the history, as the last opened."
1044 );
1045
1046 let history_after_third =
1047 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1048 assert_eq!(
1049 history_after_third,
1050 vec![
1051 FoundPath::new(
1052 ProjectPath {
1053 worktree_id,
1054 path: Arc::from(Path::new("test/third.rs")),
1055 },
1056 Some(PathBuf::from(path!("/src/test/third.rs")))
1057 ),
1058 FoundPath::new(
1059 ProjectPath {
1060 worktree_id,
1061 path: Arc::from(Path::new("test/second.rs")),
1062 },
1063 Some(PathBuf::from(path!("/src/test/second.rs")))
1064 ),
1065 FoundPath::new(
1066 ProjectPath {
1067 worktree_id,
1068 path: Arc::from(Path::new("test/first.rs")),
1069 },
1070 Some(PathBuf::from(path!("/src/test/first.rs")))
1071 ),
1072 ],
1073 "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \
1074 3rd item should be the first in the history, as the last opened."
1075 );
1076
1077 let history_after_second_again =
1078 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1079 assert_eq!(
1080 history_after_second_again,
1081 vec![
1082 FoundPath::new(
1083 ProjectPath {
1084 worktree_id,
1085 path: Arc::from(Path::new("test/second.rs")),
1086 },
1087 Some(PathBuf::from(path!("/src/test/second.rs")))
1088 ),
1089 FoundPath::new(
1090 ProjectPath {
1091 worktree_id,
1092 path: Arc::from(Path::new("test/third.rs")),
1093 },
1094 Some(PathBuf::from(path!("/src/test/third.rs")))
1095 ),
1096 FoundPath::new(
1097 ProjectPath {
1098 worktree_id,
1099 path: Arc::from(Path::new("test/first.rs")),
1100 },
1101 Some(PathBuf::from(path!("/src/test/first.rs")))
1102 ),
1103 ],
1104 "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \
1105 2nd item, as the last opened, 3rd item should go next as it was opened right before."
1106 );
1107}
1108
1109#[gpui::test]
1110async fn test_external_files_history(cx: &mut gpui::TestAppContext) {
1111 let app_state = init_test(cx);
1112
1113 app_state
1114 .fs
1115 .as_fake()
1116 .insert_tree(
1117 path!("/src"),
1118 json!({
1119 "test": {
1120 "first.rs": "// First Rust file",
1121 "second.rs": "// Second Rust file",
1122 }
1123 }),
1124 )
1125 .await;
1126
1127 app_state
1128 .fs
1129 .as_fake()
1130 .insert_tree(
1131 path!("/external-src"),
1132 json!({
1133 "test": {
1134 "third.rs": "// Third Rust file",
1135 "fourth.rs": "// Fourth Rust file",
1136 }
1137 }),
1138 )
1139 .await;
1140
1141 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1142 cx.update(|cx| {
1143 project.update(cx, |project, cx| {
1144 project.find_or_create_worktree(path!("/external-src"), false, cx)
1145 })
1146 })
1147 .detach();
1148 cx.background_executor.run_until_parked();
1149
1150 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1151 let worktree_id = cx.read(|cx| {
1152 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1153 assert_eq!(worktrees.len(), 1,);
1154
1155 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1156 });
1157 workspace
1158 .update_in(cx, |workspace, window, cx| {
1159 workspace.open_abs_path(
1160 PathBuf::from(path!("/external-src/test/third.rs")),
1161 OpenOptions {
1162 visible: Some(OpenVisible::None),
1163 ..Default::default()
1164 },
1165 window,
1166 cx,
1167 )
1168 })
1169 .detach();
1170 cx.background_executor.run_until_parked();
1171 let external_worktree_id = cx.read(|cx| {
1172 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1173 assert_eq!(
1174 worktrees.len(),
1175 2,
1176 "External file should get opened in a new worktree"
1177 );
1178
1179 WorktreeId::from_usize(
1180 worktrees
1181 .into_iter()
1182 .find(|worktree| worktree.entity_id().as_u64() as usize != worktree_id.to_usize())
1183 .expect("New worktree should have a different id")
1184 .entity_id()
1185 .as_u64() as usize,
1186 )
1187 });
1188 cx.dispatch_action(workspace::CloseActiveItem {
1189 save_intent: None,
1190 close_pinned: false,
1191 });
1192
1193 let initial_history_items =
1194 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1195 assert_eq!(
1196 initial_history_items,
1197 vec![FoundPath::new(
1198 ProjectPath {
1199 worktree_id: external_worktree_id,
1200 path: Arc::from(Path::new("")),
1201 },
1202 Some(PathBuf::from(path!("/external-src/test/third.rs")))
1203 )],
1204 "Should show external file with its full path in the history after it was open"
1205 );
1206
1207 let updated_history_items =
1208 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1209 assert_eq!(
1210 updated_history_items,
1211 vec![
1212 FoundPath::new(
1213 ProjectPath {
1214 worktree_id,
1215 path: Arc::from(Path::new("test/second.rs")),
1216 },
1217 Some(PathBuf::from(path!("/src/test/second.rs")))
1218 ),
1219 FoundPath::new(
1220 ProjectPath {
1221 worktree_id: external_worktree_id,
1222 path: Arc::from(Path::new("")),
1223 },
1224 Some(PathBuf::from(path!("/external-src/test/third.rs")))
1225 ),
1226 ],
1227 "Should keep external file with history updates",
1228 );
1229}
1230
1231#[gpui::test]
1232async fn test_toggle_panel_new_selections(cx: &mut gpui::TestAppContext) {
1233 let app_state = init_test(cx);
1234
1235 app_state
1236 .fs
1237 .as_fake()
1238 .insert_tree(
1239 path!("/src"),
1240 json!({
1241 "test": {
1242 "first.rs": "// First Rust file",
1243 "second.rs": "// Second Rust file",
1244 "third.rs": "// Third Rust file",
1245 }
1246 }),
1247 )
1248 .await;
1249
1250 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1251 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1252
1253 // generate some history to select from
1254 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1255 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1256 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1257 let current_history = open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1258
1259 for expected_selected_index in 0..current_history.len() {
1260 cx.dispatch_action(ToggleFileFinder::default());
1261 let picker = active_file_picker(&workspace, cx);
1262 let selected_index = picker.update(cx, |picker, _| picker.delegate.selected_index());
1263 assert_eq!(
1264 selected_index, expected_selected_index,
1265 "Should select the next item in the history"
1266 );
1267 }
1268
1269 cx.dispatch_action(ToggleFileFinder::default());
1270 let selected_index = workspace.update(cx, |workspace, cx| {
1271 workspace
1272 .active_modal::<FileFinder>(cx)
1273 .unwrap()
1274 .read(cx)
1275 .picker
1276 .read(cx)
1277 .delegate
1278 .selected_index()
1279 });
1280 assert_eq!(
1281 selected_index, 0,
1282 "Should wrap around the history and start all over"
1283 );
1284}
1285
1286#[gpui::test]
1287async fn test_search_preserves_history_items(cx: &mut gpui::TestAppContext) {
1288 let app_state = init_test(cx);
1289
1290 app_state
1291 .fs
1292 .as_fake()
1293 .insert_tree(
1294 path!("/src"),
1295 json!({
1296 "test": {
1297 "first.rs": "// First Rust file",
1298 "second.rs": "// Second Rust file",
1299 "third.rs": "// Third Rust file",
1300 "fourth.rs": "// Fourth Rust file",
1301 }
1302 }),
1303 )
1304 .await;
1305
1306 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1307 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1308 let worktree_id = cx.read(|cx| {
1309 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1310 assert_eq!(worktrees.len(), 1,);
1311
1312 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1313 });
1314
1315 // generate some history to select from
1316 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1317 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1318 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1319 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1320
1321 let finder = open_file_picker(&workspace, cx);
1322 let first_query = "f";
1323 finder
1324 .update_in(cx, |finder, window, cx| {
1325 finder
1326 .delegate
1327 .update_matches(first_query.to_string(), window, cx)
1328 })
1329 .await;
1330 finder.update(cx, |picker, _| {
1331 let matches = collect_search_matches(picker);
1332 assert_eq!(matches.history.len(), 1, "Only one history item contains {first_query}, it should be present and others should be filtered out");
1333 let history_match = matches.history_found_paths.first().expect("Should have path matches for history items after querying");
1334 assert_eq!(history_match, &FoundPath::new(
1335 ProjectPath {
1336 worktree_id,
1337 path: Arc::from(Path::new("test/first.rs")),
1338 },
1339 Some(PathBuf::from(path!("/src/test/first.rs")))
1340 ));
1341 assert_eq!(matches.search.len(), 1, "Only one non-history item contains {first_query}, it should be present");
1342 assert_eq!(matches.search.first().unwrap(), Path::new("test/fourth.rs"));
1343 });
1344
1345 let second_query = "fsdasdsa";
1346 let finder = active_file_picker(&workspace, cx);
1347 finder
1348 .update_in(cx, |finder, window, cx| {
1349 finder
1350 .delegate
1351 .update_matches(second_query.to_string(), window, cx)
1352 })
1353 .await;
1354 finder.update(cx, |picker, _| {
1355 assert!(
1356 collect_search_matches(picker)
1357 .search_paths_only()
1358 .is_empty(),
1359 "No search entries should match {second_query}"
1360 );
1361 });
1362
1363 let first_query_again = first_query;
1364
1365 let finder = active_file_picker(&workspace, cx);
1366 finder
1367 .update_in(cx, |finder, window, cx| {
1368 finder
1369 .delegate
1370 .update_matches(first_query_again.to_string(), window, cx)
1371 })
1372 .await;
1373 finder.update(cx, |picker, _| {
1374 let matches = collect_search_matches(picker);
1375 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");
1376 let history_match = matches.history_found_paths.first().expect("Should have path matches for history items after querying");
1377 assert_eq!(history_match, &FoundPath::new(
1378 ProjectPath {
1379 worktree_id,
1380 path: Arc::from(Path::new("test/first.rs")),
1381 },
1382 Some(PathBuf::from(path!("/src/test/first.rs")))
1383 ));
1384 assert_eq!(matches.search.len(), 1, "Only one non-history item contains {first_query_again}, it should be present, even after non-matching query");
1385 assert_eq!(matches.search.first().unwrap(), Path::new("test/fourth.rs"));
1386 });
1387}
1388
1389#[gpui::test]
1390async fn test_search_sorts_history_items(cx: &mut gpui::TestAppContext) {
1391 let app_state = init_test(cx);
1392
1393 app_state
1394 .fs
1395 .as_fake()
1396 .insert_tree(
1397 path!("/root"),
1398 json!({
1399 "test": {
1400 "1_qw": "// First file that matches the query",
1401 "2_second": "// Second file",
1402 "3_third": "// Third file",
1403 "4_fourth": "// Fourth file",
1404 "5_qwqwqw": "// A file with 3 more matches than the first one",
1405 "6_qwqwqw": "// Same query matches as above, but closer to the end of the list due to the name",
1406 "7_qwqwqw": "// One more, same amount of query matches as above",
1407 }
1408 }),
1409 )
1410 .await;
1411
1412 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
1413 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1414 // generate some history to select from
1415 open_close_queried_buffer("1", 1, "1_qw", &workspace, cx).await;
1416 open_close_queried_buffer("2", 1, "2_second", &workspace, cx).await;
1417 open_close_queried_buffer("3", 1, "3_third", &workspace, cx).await;
1418 open_close_queried_buffer("2", 1, "2_second", &workspace, cx).await;
1419 open_close_queried_buffer("6", 1, "6_qwqwqw", &workspace, cx).await;
1420
1421 let finder = open_file_picker(&workspace, cx);
1422 let query = "qw";
1423 finder
1424 .update_in(cx, |finder, window, cx| {
1425 finder
1426 .delegate
1427 .update_matches(query.to_string(), window, cx)
1428 })
1429 .await;
1430 finder.update(cx, |finder, _| {
1431 let search_matches = collect_search_matches(finder);
1432 assert_eq!(
1433 search_matches.history,
1434 vec![PathBuf::from("test/1_qw"), PathBuf::from("test/6_qwqwqw"),],
1435 );
1436 assert_eq!(
1437 search_matches.search,
1438 vec![
1439 PathBuf::from("test/5_qwqwqw"),
1440 PathBuf::from("test/7_qwqwqw"),
1441 ],
1442 );
1443 });
1444}
1445
1446#[gpui::test]
1447async fn test_select_current_open_file_when_no_history(cx: &mut gpui::TestAppContext) {
1448 let app_state = init_test(cx);
1449
1450 app_state
1451 .fs
1452 .as_fake()
1453 .insert_tree(
1454 path!("/root"),
1455 json!({
1456 "test": {
1457 "1_qw": "",
1458 }
1459 }),
1460 )
1461 .await;
1462
1463 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
1464 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1465 // Open new buffer
1466 open_queried_buffer("1", 1, "1_qw", &workspace, cx).await;
1467
1468 let picker = open_file_picker(&workspace, cx);
1469 picker.update(cx, |finder, _| {
1470 assert_match_selection(&finder, 0, "1_qw");
1471 });
1472}
1473
1474#[gpui::test]
1475async fn test_keep_opened_file_on_top_of_search_results_and_select_next_one(
1476 cx: &mut TestAppContext,
1477) {
1478 let app_state = init_test(cx);
1479
1480 app_state
1481 .fs
1482 .as_fake()
1483 .insert_tree(
1484 path!("/src"),
1485 json!({
1486 "test": {
1487 "bar.rs": "// Bar file",
1488 "lib.rs": "// Lib file",
1489 "maaa.rs": "// Maaaaaaa",
1490 "main.rs": "// Main file",
1491 "moo.rs": "// Moooooo",
1492 }
1493 }),
1494 )
1495 .await;
1496
1497 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1498 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1499
1500 open_close_queried_buffer("bar", 1, "bar.rs", &workspace, cx).await;
1501 open_close_queried_buffer("lib", 1, "lib.rs", &workspace, cx).await;
1502 open_queried_buffer("main", 1, "main.rs", &workspace, cx).await;
1503
1504 // main.rs is on top, previously used is selected
1505 let picker = open_file_picker(&workspace, cx);
1506 picker.update(cx, |finder, _| {
1507 assert_eq!(finder.delegate.matches.len(), 3);
1508 assert_match_selection(finder, 0, "main.rs");
1509 assert_match_at_position(finder, 1, "lib.rs");
1510 assert_match_at_position(finder, 2, "bar.rs");
1511 });
1512
1513 // all files match, main.rs is still on top, but the second item is selected
1514 picker
1515 .update_in(cx, |finder, window, cx| {
1516 finder
1517 .delegate
1518 .update_matches(".rs".to_string(), window, cx)
1519 })
1520 .await;
1521 picker.update(cx, |finder, _| {
1522 assert_eq!(finder.delegate.matches.len(), 5);
1523 assert_match_at_position(finder, 0, "main.rs");
1524 assert_match_selection(finder, 1, "bar.rs");
1525 assert_match_at_position(finder, 2, "lib.rs");
1526 assert_match_at_position(finder, 3, "moo.rs");
1527 assert_match_at_position(finder, 4, "maaa.rs");
1528 });
1529
1530 // main.rs is not among matches, select top item
1531 picker
1532 .update_in(cx, |finder, window, cx| {
1533 finder.delegate.update_matches("b".to_string(), window, cx)
1534 })
1535 .await;
1536 picker.update(cx, |finder, _| {
1537 assert_eq!(finder.delegate.matches.len(), 2);
1538 assert_match_at_position(finder, 0, "bar.rs");
1539 assert_match_at_position(finder, 1, "lib.rs");
1540 });
1541
1542 // main.rs is back, put it on top and select next item
1543 picker
1544 .update_in(cx, |finder, window, cx| {
1545 finder.delegate.update_matches("m".to_string(), window, cx)
1546 })
1547 .await;
1548 picker.update(cx, |finder, _| {
1549 assert_eq!(finder.delegate.matches.len(), 3);
1550 assert_match_at_position(finder, 0, "main.rs");
1551 assert_match_selection(finder, 1, "moo.rs");
1552 assert_match_at_position(finder, 2, "maaa.rs");
1553 });
1554
1555 // get back to the initial state
1556 picker
1557 .update_in(cx, |finder, window, cx| {
1558 finder.delegate.update_matches("".to_string(), window, cx)
1559 })
1560 .await;
1561 picker.update(cx, |finder, _| {
1562 assert_eq!(finder.delegate.matches.len(), 3);
1563 assert_match_selection(finder, 0, "main.rs");
1564 assert_match_at_position(finder, 1, "lib.rs");
1565 assert_match_at_position(finder, 2, "bar.rs");
1566 });
1567}
1568
1569#[gpui::test]
1570async fn test_setting_auto_select_first_and_select_active_file(cx: &mut TestAppContext) {
1571 let app_state = init_test(cx);
1572
1573 cx.update(|cx| {
1574 let settings = *FileFinderSettings::get_global(cx);
1575
1576 FileFinderSettings::override_global(
1577 FileFinderSettings {
1578 skip_focus_for_active_in_search: false,
1579 ..settings
1580 },
1581 cx,
1582 );
1583 });
1584
1585 app_state
1586 .fs
1587 .as_fake()
1588 .insert_tree(
1589 path!("/src"),
1590 json!({
1591 "test": {
1592 "bar.rs": "// Bar file",
1593 "lib.rs": "// Lib file",
1594 "maaa.rs": "// Maaaaaaa",
1595 "main.rs": "// Main file",
1596 "moo.rs": "// Moooooo",
1597 }
1598 }),
1599 )
1600 .await;
1601
1602 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1603 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1604
1605 open_close_queried_buffer("bar", 1, "bar.rs", &workspace, cx).await;
1606 open_close_queried_buffer("lib", 1, "lib.rs", &workspace, cx).await;
1607 open_queried_buffer("main", 1, "main.rs", &workspace, cx).await;
1608
1609 // main.rs is on top, previously used is selected
1610 let picker = open_file_picker(&workspace, cx);
1611 picker.update(cx, |finder, _| {
1612 assert_eq!(finder.delegate.matches.len(), 3);
1613 assert_match_selection(finder, 0, "main.rs");
1614 assert_match_at_position(finder, 1, "lib.rs");
1615 assert_match_at_position(finder, 2, "bar.rs");
1616 });
1617
1618 // all files match, main.rs is on top, and is selected
1619 picker
1620 .update_in(cx, |finder, window, cx| {
1621 finder
1622 .delegate
1623 .update_matches(".rs".to_string(), window, cx)
1624 })
1625 .await;
1626 picker.update(cx, |finder, _| {
1627 assert_eq!(finder.delegate.matches.len(), 5);
1628 assert_match_selection(finder, 0, "main.rs");
1629 assert_match_at_position(finder, 1, "bar.rs");
1630 assert_match_at_position(finder, 2, "lib.rs");
1631 assert_match_at_position(finder, 3, "moo.rs");
1632 assert_match_at_position(finder, 4, "maaa.rs");
1633 });
1634}
1635
1636#[gpui::test]
1637async fn test_non_separate_history_items(cx: &mut TestAppContext) {
1638 let app_state = init_test(cx);
1639
1640 app_state
1641 .fs
1642 .as_fake()
1643 .insert_tree(
1644 path!("/src"),
1645 json!({
1646 "test": {
1647 "bar.rs": "// Bar file",
1648 "lib.rs": "// Lib file",
1649 "maaa.rs": "// Maaaaaaa",
1650 "main.rs": "// Main file",
1651 "moo.rs": "// Moooooo",
1652 }
1653 }),
1654 )
1655 .await;
1656
1657 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1658 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1659
1660 open_close_queried_buffer("bar", 1, "bar.rs", &workspace, cx).await;
1661 open_close_queried_buffer("lib", 1, "lib.rs", &workspace, cx).await;
1662 open_queried_buffer("main", 1, "main.rs", &workspace, cx).await;
1663
1664 cx.dispatch_action(ToggleFileFinder::default());
1665 let picker = active_file_picker(&workspace, cx);
1666 // main.rs is on top, previously used is selected
1667 picker.update(cx, |finder, _| {
1668 assert_eq!(finder.delegate.matches.len(), 3);
1669 assert_match_selection(finder, 0, "main.rs");
1670 assert_match_at_position(finder, 1, "lib.rs");
1671 assert_match_at_position(finder, 2, "bar.rs");
1672 });
1673
1674 // all files match, main.rs is still on top, but the second item is selected
1675 picker
1676 .update_in(cx, |finder, window, cx| {
1677 finder
1678 .delegate
1679 .update_matches(".rs".to_string(), window, cx)
1680 })
1681 .await;
1682 picker.update(cx, |finder, _| {
1683 assert_eq!(finder.delegate.matches.len(), 5);
1684 assert_match_at_position(finder, 0, "main.rs");
1685 assert_match_selection(finder, 1, "moo.rs");
1686 assert_match_at_position(finder, 2, "bar.rs");
1687 assert_match_at_position(finder, 3, "lib.rs");
1688 assert_match_at_position(finder, 4, "maaa.rs");
1689 });
1690
1691 // main.rs is not among matches, select top item
1692 picker
1693 .update_in(cx, |finder, window, cx| {
1694 finder.delegate.update_matches("b".to_string(), window, cx)
1695 })
1696 .await;
1697 picker.update(cx, |finder, _| {
1698 assert_eq!(finder.delegate.matches.len(), 2);
1699 assert_match_at_position(finder, 0, "bar.rs");
1700 assert_match_at_position(finder, 1, "lib.rs");
1701 });
1702
1703 // main.rs is back, put it on top and select next item
1704 picker
1705 .update_in(cx, |finder, window, cx| {
1706 finder.delegate.update_matches("m".to_string(), window, cx)
1707 })
1708 .await;
1709 picker.update(cx, |finder, _| {
1710 assert_eq!(finder.delegate.matches.len(), 3);
1711 assert_match_at_position(finder, 0, "main.rs");
1712 assert_match_selection(finder, 1, "moo.rs");
1713 assert_match_at_position(finder, 2, "maaa.rs");
1714 });
1715
1716 // get back to the initial state
1717 picker
1718 .update_in(cx, |finder, window, cx| {
1719 finder.delegate.update_matches("".to_string(), window, cx)
1720 })
1721 .await;
1722 picker.update(cx, |finder, _| {
1723 assert_eq!(finder.delegate.matches.len(), 3);
1724 assert_match_selection(finder, 0, "main.rs");
1725 assert_match_at_position(finder, 1, "lib.rs");
1726 assert_match_at_position(finder, 2, "bar.rs");
1727 });
1728}
1729
1730#[gpui::test]
1731async fn test_history_items_shown_in_order_of_open(cx: &mut TestAppContext) {
1732 let app_state = init_test(cx);
1733
1734 app_state
1735 .fs
1736 .as_fake()
1737 .insert_tree(
1738 path!("/test"),
1739 json!({
1740 "test": {
1741 "1.txt": "// One",
1742 "2.txt": "// Two",
1743 "3.txt": "// Three",
1744 }
1745 }),
1746 )
1747 .await;
1748
1749 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
1750 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1751
1752 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
1753 open_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
1754 open_queried_buffer("3", 1, "3.txt", &workspace, cx).await;
1755
1756 let picker = open_file_picker(&workspace, cx);
1757 picker.update(cx, |finder, _| {
1758 assert_eq!(finder.delegate.matches.len(), 3);
1759 assert_match_selection(finder, 0, "3.txt");
1760 assert_match_at_position(finder, 1, "2.txt");
1761 assert_match_at_position(finder, 2, "1.txt");
1762 });
1763
1764 cx.dispatch_action(SelectNext);
1765 cx.dispatch_action(Confirm); // Open 2.txt
1766
1767 let picker = open_file_picker(&workspace, cx);
1768 picker.update(cx, |finder, _| {
1769 assert_eq!(finder.delegate.matches.len(), 3);
1770 assert_match_selection(finder, 0, "2.txt");
1771 assert_match_at_position(finder, 1, "3.txt");
1772 assert_match_at_position(finder, 2, "1.txt");
1773 });
1774
1775 cx.dispatch_action(SelectNext);
1776 cx.dispatch_action(SelectNext);
1777 cx.dispatch_action(Confirm); // Open 1.txt
1778
1779 let picker = open_file_picker(&workspace, cx);
1780 picker.update(cx, |finder, _| {
1781 assert_eq!(finder.delegate.matches.len(), 3);
1782 assert_match_selection(finder, 0, "1.txt");
1783 assert_match_at_position(finder, 1, "2.txt");
1784 assert_match_at_position(finder, 2, "3.txt");
1785 });
1786}
1787
1788#[gpui::test]
1789async fn test_selected_history_item_stays_selected_on_worktree_updated(cx: &mut TestAppContext) {
1790 let app_state = init_test(cx);
1791
1792 app_state
1793 .fs
1794 .as_fake()
1795 .insert_tree(
1796 path!("/test"),
1797 json!({
1798 "test": {
1799 "1.txt": "// One",
1800 "2.txt": "// Two",
1801 "3.txt": "// Three",
1802 }
1803 }),
1804 )
1805 .await;
1806
1807 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
1808 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1809
1810 open_close_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
1811 open_close_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
1812 open_close_queried_buffer("3", 1, "3.txt", &workspace, cx).await;
1813
1814 let picker = open_file_picker(&workspace, cx);
1815 picker.update(cx, |finder, _| {
1816 assert_eq!(finder.delegate.matches.len(), 3);
1817 assert_match_selection(finder, 0, "3.txt");
1818 assert_match_at_position(finder, 1, "2.txt");
1819 assert_match_at_position(finder, 2, "1.txt");
1820 });
1821
1822 cx.dispatch_action(SelectNext);
1823
1824 // Add more files to the worktree to trigger update matches
1825 for i in 0..5 {
1826 let filename = if cfg!(windows) {
1827 format!("C:/test/{}.txt", 4 + i)
1828 } else {
1829 format!("/test/{}.txt", 4 + i)
1830 };
1831 app_state
1832 .fs
1833 .create_file(Path::new(&filename), Default::default())
1834 .await
1835 .expect("unable to create file");
1836 }
1837
1838 cx.executor().advance_clock(FS_WATCH_LATENCY);
1839
1840 picker.update(cx, |finder, _| {
1841 assert_eq!(finder.delegate.matches.len(), 3);
1842 assert_match_at_position(finder, 0, "3.txt");
1843 assert_match_selection(finder, 1, "2.txt");
1844 assert_match_at_position(finder, 2, "1.txt");
1845 });
1846}
1847
1848#[gpui::test]
1849async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppContext) {
1850 let app_state = init_test(cx);
1851
1852 app_state
1853 .fs
1854 .as_fake()
1855 .insert_tree(
1856 path!("/src"),
1857 json!({
1858 "collab_ui": {
1859 "first.rs": "// First Rust file",
1860 "second.rs": "// Second Rust file",
1861 "third.rs": "// Third Rust file",
1862 "collab_ui.rs": "// Fourth Rust file",
1863 }
1864 }),
1865 )
1866 .await;
1867
1868 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1869 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1870 // generate some history to select from
1871 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1872 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1873 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1874 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1875
1876 let finder = open_file_picker(&workspace, cx);
1877 let query = "collab_ui";
1878 cx.simulate_input(query);
1879 finder.update(cx, |picker, _| {
1880 let search_entries = collect_search_matches(picker).search_paths_only();
1881 assert_eq!(
1882 search_entries,
1883 vec![
1884 PathBuf::from("collab_ui/collab_ui.rs"),
1885 PathBuf::from("collab_ui/first.rs"),
1886 PathBuf::from("collab_ui/third.rs"),
1887 PathBuf::from("collab_ui/second.rs"),
1888 ],
1889 "Despite all search results having the same directory name, the most matching one should be on top"
1890 );
1891 });
1892}
1893
1894#[gpui::test]
1895async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) {
1896 let app_state = init_test(cx);
1897
1898 app_state
1899 .fs
1900 .as_fake()
1901 .insert_tree(
1902 path!("/src"),
1903 json!({
1904 "test": {
1905 "first.rs": "// First Rust file",
1906 "nonexistent.rs": "// Second Rust file",
1907 "third.rs": "// Third Rust file",
1908 }
1909 }),
1910 )
1911 .await;
1912
1913 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1914 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); // generate some history to select from
1915 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1916 open_close_queried_buffer("non", 1, "nonexistent.rs", &workspace, cx).await;
1917 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1918 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1919 app_state
1920 .fs
1921 .remove_file(
1922 Path::new(path!("/src/test/nonexistent.rs")),
1923 RemoveOptions::default(),
1924 )
1925 .await
1926 .unwrap();
1927 cx.run_until_parked();
1928
1929 let picker = open_file_picker(&workspace, cx);
1930 cx.simulate_input("rs");
1931
1932 picker.update(cx, |picker, _| {
1933 assert_eq!(
1934 collect_search_matches(picker).history,
1935 vec![
1936 PathBuf::from("test/first.rs"),
1937 PathBuf::from("test/third.rs"),
1938 ],
1939 "Should have all opened files in the history, except the ones that do not exist on disk"
1940 );
1941 });
1942}
1943
1944#[gpui::test]
1945async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAppContext) {
1946 let app_state = init_test(cx);
1947
1948 app_state
1949 .fs
1950 .as_fake()
1951 .insert_tree(
1952 "/src",
1953 json!({
1954 "lib.rs": "// Lib file",
1955 "main.rs": "// Bar file",
1956 "read.me": "// Readme file",
1957 }),
1958 )
1959 .await;
1960
1961 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1962 let (workspace, cx) =
1963 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1964
1965 // Initial state
1966 let picker = open_file_picker(&workspace, cx);
1967 cx.simulate_input("rs");
1968 picker.update(cx, |finder, _| {
1969 assert_eq!(finder.delegate.matches.len(), 2);
1970 assert_match_at_position(finder, 0, "lib.rs");
1971 assert_match_at_position(finder, 1, "main.rs");
1972 });
1973
1974 // Delete main.rs
1975 app_state
1976 .fs
1977 .remove_file("/src/main.rs".as_ref(), Default::default())
1978 .await
1979 .expect("unable to remove file");
1980 cx.executor().advance_clock(FS_WATCH_LATENCY);
1981
1982 // main.rs is in not among search results anymore
1983 picker.update(cx, |finder, _| {
1984 assert_eq!(finder.delegate.matches.len(), 1);
1985 assert_match_at_position(finder, 0, "lib.rs");
1986 });
1987
1988 // Create util.rs
1989 app_state
1990 .fs
1991 .create_file("/src/util.rs".as_ref(), Default::default())
1992 .await
1993 .expect("unable to create file");
1994 cx.executor().advance_clock(FS_WATCH_LATENCY);
1995
1996 // util.rs is among search results
1997 picker.update(cx, |finder, _| {
1998 assert_eq!(finder.delegate.matches.len(), 2);
1999 assert_match_at_position(finder, 0, "lib.rs");
2000 assert_match_at_position(finder, 1, "util.rs");
2001 });
2002}
2003
2004#[gpui::test]
2005async fn test_search_results_refreshed_on_adding_and_removing_worktrees(
2006 cx: &mut gpui::TestAppContext,
2007) {
2008 let app_state = init_test(cx);
2009
2010 app_state
2011 .fs
2012 .as_fake()
2013 .insert_tree(
2014 "/test",
2015 json!({
2016 "project_1": {
2017 "bar.rs": "// Bar file",
2018 "lib.rs": "// Lib file",
2019 },
2020 "project_2": {
2021 "Cargo.toml": "// Cargo file",
2022 "main.rs": "// Main file",
2023 }
2024 }),
2025 )
2026 .await;
2027
2028 let project = Project::test(app_state.fs.clone(), ["/test/project_1".as_ref()], cx).await;
2029 let (workspace, cx) =
2030 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2031 let worktree_1_id = project.update(cx, |project, cx| {
2032 let worktree = project.worktrees(cx).last().expect("worktree not found");
2033 worktree.read(cx).id()
2034 });
2035
2036 // Initial state
2037 let picker = open_file_picker(&workspace, cx);
2038 cx.simulate_input("rs");
2039 picker.update(cx, |finder, _| {
2040 assert_eq!(finder.delegate.matches.len(), 2);
2041 assert_match_at_position(finder, 0, "bar.rs");
2042 assert_match_at_position(finder, 1, "lib.rs");
2043 });
2044
2045 // Add new worktree
2046 project
2047 .update(cx, |project, cx| {
2048 project
2049 .find_or_create_worktree("/test/project_2", true, cx)
2050 .into_future()
2051 })
2052 .await
2053 .expect("unable to create workdir");
2054 cx.executor().advance_clock(FS_WATCH_LATENCY);
2055
2056 // main.rs is among search results
2057 picker.update(cx, |finder, _| {
2058 assert_eq!(finder.delegate.matches.len(), 3);
2059 assert_match_at_position(finder, 0, "bar.rs");
2060 assert_match_at_position(finder, 1, "lib.rs");
2061 assert_match_at_position(finder, 2, "main.rs");
2062 });
2063
2064 // Remove the first worktree
2065 project.update(cx, |project, cx| {
2066 project.remove_worktree(worktree_1_id, cx);
2067 });
2068 cx.executor().advance_clock(FS_WATCH_LATENCY);
2069
2070 // Files from the first worktree are not in the search results anymore
2071 picker.update(cx, |finder, _| {
2072 assert_eq!(finder.delegate.matches.len(), 1);
2073 assert_match_at_position(finder, 0, "main.rs");
2074 });
2075}
2076
2077#[gpui::test]
2078async fn test_selected_match_stays_selected_after_matches_refreshed(cx: &mut gpui::TestAppContext) {
2079 let app_state = init_test(cx);
2080
2081 app_state.fs.as_fake().insert_tree("/src", json!({})).await;
2082
2083 app_state
2084 .fs
2085 .create_dir("/src/even".as_ref())
2086 .await
2087 .expect("unable to create dir");
2088
2089 let initial_files_num = 5;
2090 for i in 0..initial_files_num {
2091 let filename = format!("/src/even/file_{}.txt", 10 + i);
2092 app_state
2093 .fs
2094 .create_file(Path::new(&filename), Default::default())
2095 .await
2096 .expect("unable to create file");
2097 }
2098
2099 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
2100 let (workspace, cx) =
2101 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2102
2103 // Initial state
2104 let picker = open_file_picker(&workspace, cx);
2105 cx.simulate_input("file");
2106 let selected_index = 3;
2107 // Checking only the filename, not the whole path
2108 let selected_file = format!("file_{}.txt", 10 + selected_index);
2109 // Select even/file_13.txt
2110 for _ in 0..selected_index {
2111 cx.dispatch_action(SelectNext);
2112 }
2113
2114 picker.update(cx, |finder, _| {
2115 assert_match_selection(finder, selected_index, &selected_file)
2116 });
2117
2118 // Add more matches to the search results
2119 let files_to_add = 10;
2120 for i in 0..files_to_add {
2121 let filename = format!("/src/file_{}.txt", 20 + i);
2122 app_state
2123 .fs
2124 .create_file(Path::new(&filename), Default::default())
2125 .await
2126 .expect("unable to create file");
2127 }
2128 cx.executor().advance_clock(FS_WATCH_LATENCY);
2129
2130 // file_13.txt is still selected
2131 picker.update(cx, |finder, _| {
2132 let expected_selected_index = selected_index + files_to_add;
2133 assert_match_selection(finder, expected_selected_index, &selected_file);
2134 });
2135}
2136
2137#[gpui::test]
2138async fn test_first_match_selected_if_previous_one_is_not_in_the_match_list(
2139 cx: &mut gpui::TestAppContext,
2140) {
2141 let app_state = init_test(cx);
2142
2143 app_state
2144 .fs
2145 .as_fake()
2146 .insert_tree(
2147 "/src",
2148 json!({
2149 "file_1.txt": "// file_1",
2150 "file_2.txt": "// file_2",
2151 "file_3.txt": "// file_3",
2152 }),
2153 )
2154 .await;
2155
2156 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
2157 let (workspace, cx) =
2158 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2159
2160 // Initial state
2161 let picker = open_file_picker(&workspace, cx);
2162 cx.simulate_input("file");
2163 // Select even/file_2.txt
2164 cx.dispatch_action(SelectNext);
2165
2166 // Remove the selected entry
2167 app_state
2168 .fs
2169 .remove_file("/src/file_2.txt".as_ref(), Default::default())
2170 .await
2171 .expect("unable to remove file");
2172 cx.executor().advance_clock(FS_WATCH_LATENCY);
2173
2174 // file_1.txt is now selected
2175 picker.update(cx, |finder, _| {
2176 assert_match_selection(finder, 0, "file_1.txt");
2177 });
2178}
2179
2180#[gpui::test]
2181async fn test_keeps_file_finder_open_after_modifier_keys_release(cx: &mut gpui::TestAppContext) {
2182 let app_state = init_test(cx);
2183
2184 app_state
2185 .fs
2186 .as_fake()
2187 .insert_tree(
2188 path!("/test"),
2189 json!({
2190 "1.txt": "// One",
2191 }),
2192 )
2193 .await;
2194
2195 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
2196 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2197
2198 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
2199
2200 cx.simulate_modifiers_change(Modifiers::secondary_key());
2201 open_file_picker(&workspace, cx);
2202
2203 cx.simulate_modifiers_change(Modifiers::none());
2204 active_file_picker(&workspace, cx);
2205}
2206
2207#[gpui::test]
2208async fn test_opens_file_on_modifier_keys_release(cx: &mut gpui::TestAppContext) {
2209 let app_state = init_test(cx);
2210
2211 app_state
2212 .fs
2213 .as_fake()
2214 .insert_tree(
2215 path!("/test"),
2216 json!({
2217 "1.txt": "// One",
2218 "2.txt": "// Two",
2219 }),
2220 )
2221 .await;
2222
2223 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
2224 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2225
2226 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
2227 open_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
2228
2229 cx.simulate_modifiers_change(Modifiers::secondary_key());
2230 let picker = open_file_picker(&workspace, cx);
2231 picker.update(cx, |finder, _| {
2232 assert_eq!(finder.delegate.matches.len(), 2);
2233 assert_match_selection(finder, 0, "2.txt");
2234 assert_match_at_position(finder, 1, "1.txt");
2235 });
2236
2237 cx.dispatch_action(SelectNext);
2238 cx.simulate_modifiers_change(Modifiers::none());
2239 cx.read(|cx| {
2240 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
2241 assert_eq!(active_editor.read(cx).title(cx), "1.txt");
2242 });
2243}
2244
2245#[gpui::test]
2246async fn test_switches_between_release_norelease_modes_on_forward_nav(
2247 cx: &mut gpui::TestAppContext,
2248) {
2249 let app_state = init_test(cx);
2250
2251 app_state
2252 .fs
2253 .as_fake()
2254 .insert_tree(
2255 path!("/test"),
2256 json!({
2257 "1.txt": "// One",
2258 "2.txt": "// Two",
2259 }),
2260 )
2261 .await;
2262
2263 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
2264 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2265
2266 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
2267 open_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
2268
2269 // Open with a shortcut
2270 cx.simulate_modifiers_change(Modifiers::secondary_key());
2271 let picker = open_file_picker(&workspace, cx);
2272 picker.update(cx, |finder, _| {
2273 assert_eq!(finder.delegate.matches.len(), 2);
2274 assert_match_selection(finder, 0, "2.txt");
2275 assert_match_at_position(finder, 1, "1.txt");
2276 });
2277
2278 // Switch to navigating with other shortcuts
2279 // Don't open file on modifiers release
2280 cx.simulate_modifiers_change(Modifiers::control());
2281 cx.dispatch_action(SelectNext);
2282 cx.simulate_modifiers_change(Modifiers::none());
2283 picker.update(cx, |finder, _| {
2284 assert_eq!(finder.delegate.matches.len(), 2);
2285 assert_match_at_position(finder, 0, "2.txt");
2286 assert_match_selection(finder, 1, "1.txt");
2287 });
2288
2289 // Back to navigation with initial shortcut
2290 // Open file on modifiers release
2291 cx.simulate_modifiers_change(Modifiers::secondary_key());
2292 cx.dispatch_action(ToggleFileFinder::default());
2293 cx.simulate_modifiers_change(Modifiers::none());
2294 cx.read(|cx| {
2295 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
2296 assert_eq!(active_editor.read(cx).title(cx), "2.txt");
2297 });
2298}
2299
2300#[gpui::test]
2301async fn test_switches_between_release_norelease_modes_on_backward_nav(
2302 cx: &mut gpui::TestAppContext,
2303) {
2304 let app_state = init_test(cx);
2305
2306 app_state
2307 .fs
2308 .as_fake()
2309 .insert_tree(
2310 path!("/test"),
2311 json!({
2312 "1.txt": "// One",
2313 "2.txt": "// Two",
2314 "3.txt": "// Three"
2315 }),
2316 )
2317 .await;
2318
2319 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
2320 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2321
2322 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
2323 open_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
2324 open_queried_buffer("3", 1, "3.txt", &workspace, cx).await;
2325
2326 // Open with a shortcut
2327 cx.simulate_modifiers_change(Modifiers::secondary_key());
2328 let picker = open_file_picker(&workspace, cx);
2329 picker.update(cx, |finder, _| {
2330 assert_eq!(finder.delegate.matches.len(), 3);
2331 assert_match_selection(finder, 0, "3.txt");
2332 assert_match_at_position(finder, 1, "2.txt");
2333 assert_match_at_position(finder, 2, "1.txt");
2334 });
2335
2336 // Switch to navigating with other shortcuts
2337 // Don't open file on modifiers release
2338 cx.simulate_modifiers_change(Modifiers::control());
2339 cx.dispatch_action(menu::SelectPrevious);
2340 cx.simulate_modifiers_change(Modifiers::none());
2341 picker.update(cx, |finder, _| {
2342 assert_eq!(finder.delegate.matches.len(), 3);
2343 assert_match_at_position(finder, 0, "3.txt");
2344 assert_match_at_position(finder, 1, "2.txt");
2345 assert_match_selection(finder, 2, "1.txt");
2346 });
2347
2348 // Back to navigation with initial shortcut
2349 // Open file on modifiers release
2350 cx.simulate_modifiers_change(Modifiers::secondary_key());
2351 cx.dispatch_action(SelectPrevious); // <-- File Finder's SelectPrevious, not menu's
2352 cx.simulate_modifiers_change(Modifiers::none());
2353 cx.read(|cx| {
2354 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
2355 assert_eq!(active_editor.read(cx).title(cx), "3.txt");
2356 });
2357}
2358
2359#[gpui::test]
2360async fn test_extending_modifiers_does_not_confirm_selection(cx: &mut gpui::TestAppContext) {
2361 let app_state = init_test(cx);
2362
2363 app_state
2364 .fs
2365 .as_fake()
2366 .insert_tree(
2367 path!("/test"),
2368 json!({
2369 "1.txt": "// One",
2370 }),
2371 )
2372 .await;
2373
2374 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
2375 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2376
2377 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
2378
2379 cx.simulate_modifiers_change(Modifiers::secondary_key());
2380 open_file_picker(&workspace, cx);
2381
2382 cx.simulate_modifiers_change(Modifiers::command_shift());
2383 active_file_picker(&workspace, cx);
2384}
2385
2386#[gpui::test]
2387async fn test_repeat_toggle_action(cx: &mut gpui::TestAppContext) {
2388 let app_state = init_test(cx);
2389 app_state
2390 .fs
2391 .as_fake()
2392 .insert_tree(
2393 "/test",
2394 json!({
2395 "00.txt": "",
2396 "01.txt": "",
2397 "02.txt": "",
2398 "03.txt": "",
2399 "04.txt": "",
2400 "05.txt": "",
2401 }),
2402 )
2403 .await;
2404
2405 let project = Project::test(app_state.fs.clone(), ["/test".as_ref()], cx).await;
2406 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2407
2408 cx.dispatch_action(ToggleFileFinder::default());
2409 let picker = active_file_picker(&workspace, cx);
2410
2411 picker.update_in(cx, |picker, window, cx| {
2412 picker.update_matches(".txt".to_string(), window, cx)
2413 });
2414
2415 cx.run_until_parked();
2416
2417 picker.update(cx, |picker, _| {
2418 assert_eq!(picker.delegate.matches.len(), 6);
2419 assert_eq!(picker.delegate.selected_index, 0);
2420 });
2421
2422 // When toggling repeatedly, the picker scrolls to reveal the selected item.
2423 cx.dispatch_action(ToggleFileFinder::default());
2424 cx.dispatch_action(ToggleFileFinder::default());
2425 cx.dispatch_action(ToggleFileFinder::default());
2426
2427 cx.run_until_parked();
2428
2429 picker.update(cx, |picker, _| {
2430 assert_eq!(picker.delegate.matches.len(), 6);
2431 assert_eq!(picker.delegate.selected_index, 3);
2432 });
2433}
2434
2435async fn open_close_queried_buffer(
2436 input: &str,
2437 expected_matches: usize,
2438 expected_editor_title: &str,
2439 workspace: &Entity<Workspace>,
2440 cx: &mut gpui::VisualTestContext,
2441) -> Vec<FoundPath> {
2442 let history_items = open_queried_buffer(
2443 input,
2444 expected_matches,
2445 expected_editor_title,
2446 workspace,
2447 cx,
2448 )
2449 .await;
2450
2451 cx.dispatch_action(workspace::CloseActiveItem {
2452 save_intent: None,
2453 close_pinned: false,
2454 });
2455
2456 history_items
2457}
2458
2459async fn open_queried_buffer(
2460 input: &str,
2461 expected_matches: usize,
2462 expected_editor_title: &str,
2463 workspace: &Entity<Workspace>,
2464 cx: &mut gpui::VisualTestContext,
2465) -> Vec<FoundPath> {
2466 let picker = open_file_picker(&workspace, cx);
2467 cx.simulate_input(input);
2468
2469 let history_items = picker.update(cx, |finder, _| {
2470 assert_eq!(
2471 finder.delegate.matches.len(),
2472 expected_matches,
2473 "Unexpected number of matches found for query `{input}`, matches: {:?}",
2474 finder.delegate.matches
2475 );
2476 finder.delegate.history_items.clone()
2477 });
2478
2479 cx.dispatch_action(Confirm);
2480
2481 cx.read(|cx| {
2482 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
2483 let active_editor_title = active_editor.read(cx).title(cx);
2484 assert_eq!(
2485 expected_editor_title, active_editor_title,
2486 "Unexpected editor title for query `{input}`"
2487 );
2488 });
2489
2490 history_items
2491}
2492
2493fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
2494 cx.update(|cx| {
2495 let state = AppState::test(cx);
2496 theme::init(theme::LoadThemes::JustBase, cx);
2497 language::init(cx);
2498 super::init(cx);
2499 editor::init(cx);
2500 workspace::init_settings(cx);
2501 Project::init_settings(cx);
2502 state
2503 })
2504}
2505
2506fn test_path_position(test_str: &str) -> FileSearchQuery {
2507 let path_position = PathWithPosition::parse_str(test_str);
2508
2509 FileSearchQuery {
2510 raw_query: test_str.to_owned(),
2511 file_query_end: if path_position.path.to_str().unwrap() == test_str {
2512 None
2513 } else {
2514 Some(path_position.path.to_str().unwrap().len())
2515 },
2516 path_position,
2517 }
2518}
2519
2520fn build_find_picker(
2521 project: Entity<Project>,
2522 cx: &mut TestAppContext,
2523) -> (
2524 Entity<Picker<FileFinderDelegate>>,
2525 Entity<Workspace>,
2526 &mut VisualTestContext,
2527) {
2528 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2529 let picker = open_file_picker(&workspace, cx);
2530 (picker, workspace, cx)
2531}
2532
2533#[track_caller]
2534fn open_file_picker(
2535 workspace: &Entity<Workspace>,
2536 cx: &mut VisualTestContext,
2537) -> Entity<Picker<FileFinderDelegate>> {
2538 cx.dispatch_action(ToggleFileFinder {
2539 separate_history: true,
2540 });
2541 active_file_picker(workspace, cx)
2542}
2543
2544#[track_caller]
2545fn active_file_picker(
2546 workspace: &Entity<Workspace>,
2547 cx: &mut VisualTestContext,
2548) -> Entity<Picker<FileFinderDelegate>> {
2549 workspace.update(cx, |workspace, cx| {
2550 workspace
2551 .active_modal::<FileFinder>(cx)
2552 .expect("file finder is not open")
2553 .read(cx)
2554 .picker
2555 .clone()
2556 })
2557}
2558
2559#[derive(Debug, Default)]
2560struct SearchEntries {
2561 history: Vec<PathBuf>,
2562 history_found_paths: Vec<FoundPath>,
2563 search: Vec<PathBuf>,
2564 search_matches: Vec<PathMatch>,
2565}
2566
2567impl SearchEntries {
2568 #[track_caller]
2569 fn search_paths_only(self) -> Vec<PathBuf> {
2570 assert!(
2571 self.history.is_empty(),
2572 "Should have no history matches, but got: {:?}",
2573 self.history
2574 );
2575 self.search
2576 }
2577
2578 #[track_caller]
2579 fn search_matches_only(self) -> Vec<PathMatch> {
2580 assert!(
2581 self.history.is_empty(),
2582 "Should have no history matches, but got: {:?}",
2583 self.history
2584 );
2585 self.search_matches
2586 }
2587}
2588
2589fn collect_search_matches(picker: &Picker<FileFinderDelegate>) -> SearchEntries {
2590 let mut search_entries = SearchEntries::default();
2591 for m in &picker.delegate.matches.matches {
2592 match &m {
2593 Match::History {
2594 path: history_path,
2595 panel_match: path_match,
2596 } => {
2597 search_entries.history.push(
2598 path_match
2599 .as_ref()
2600 .map(|path_match| {
2601 Path::new(path_match.0.path_prefix.as_ref()).join(&path_match.0.path)
2602 })
2603 .unwrap_or_else(|| {
2604 history_path
2605 .absolute
2606 .as_deref()
2607 .unwrap_or_else(|| &history_path.project.path)
2608 .to_path_buf()
2609 }),
2610 );
2611 search_entries
2612 .history_found_paths
2613 .push(history_path.clone());
2614 }
2615 Match::Search(path_match) => {
2616 search_entries
2617 .search
2618 .push(Path::new(path_match.0.path_prefix.as_ref()).join(&path_match.0.path));
2619 search_entries.search_matches.push(path_match.0.clone());
2620 }
2621 }
2622 }
2623 search_entries
2624}
2625
2626#[track_caller]
2627fn assert_match_selection(
2628 finder: &Picker<FileFinderDelegate>,
2629 expected_selection_index: usize,
2630 expected_file_name: &str,
2631) {
2632 assert_eq!(
2633 finder.delegate.selected_index(),
2634 expected_selection_index,
2635 "Match is not selected"
2636 );
2637 assert_match_at_position(finder, expected_selection_index, expected_file_name);
2638}
2639
2640#[track_caller]
2641fn assert_match_at_position(
2642 finder: &Picker<FileFinderDelegate>,
2643 match_index: usize,
2644 expected_file_name: &str,
2645) {
2646 let match_item = finder
2647 .delegate
2648 .matches
2649 .get(match_index)
2650 .unwrap_or_else(|| panic!("Finder has no match for index {match_index}"));
2651 let match_file_name = match &match_item {
2652 Match::History { path, .. } => path.absolute.as_deref().unwrap().file_name(),
2653 Match::Search(path_match) => path_match.0.path.file_name(),
2654 }
2655 .unwrap()
2656 .to_string_lossy();
2657 assert_eq!(match_file_name, expected_file_name);
2658}
2659
2660#[gpui::test]
2661async fn test_filename_precedence(cx: &mut TestAppContext) {
2662 let app_state = init_test(cx);
2663
2664 app_state
2665 .fs
2666 .as_fake()
2667 .insert_tree(
2668 path!("/src"),
2669 json!({
2670 "layout": {
2671 "app.css": "",
2672 "app.d.ts": "",
2673 "app.html": "",
2674 "+page.svelte": "",
2675 },
2676 "routes": {
2677 "+layout.svelte": "",
2678 }
2679 }),
2680 )
2681 .await;
2682
2683 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
2684 let (picker, _, cx) = build_find_picker(project, cx);
2685
2686 cx.simulate_input("layout");
2687
2688 picker.update(cx, |finder, _| {
2689 let search_matches = collect_search_matches(finder).search_paths_only();
2690
2691 assert_eq!(
2692 search_matches,
2693 vec![
2694 PathBuf::from("routes/+layout.svelte"),
2695 PathBuf::from("layout/app.css"),
2696 PathBuf::from("layout/app.d.ts"),
2697 PathBuf::from("layout/app.html"),
2698 PathBuf::from("layout/+page.svelte"),
2699 ],
2700 "File with 'layout' in filename should be prioritized over files in 'layout' directory"
2701 );
2702 });
2703}