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, assert_matches};
8use project::{FS_WATCH_LATENCY, RemoveOptions};
9use serde_json::json;
10use settings::SettingsStore;
11use util::{path, rel_path::rel_path};
12use workspace::{AppState, CloseActiveItem, OpenOptions, ToggleFileFinder, Workspace, open_paths};
13
14#[ctor::ctor]
15fn init_logger() {
16 zlog::init_test();
17}
18
19#[test]
20fn test_path_elision() {
21 #[track_caller]
22 fn check(path: &str, budget: usize, matches: impl IntoIterator<Item = usize>, expected: &str) {
23 let mut path = path.to_owned();
24 let slice = PathComponentSlice::new(&path);
25 let matches = Vec::from_iter(matches);
26 if let Some(range) = slice.elision_range(budget - 1, &matches) {
27 path.replace_range(range, "…");
28 }
29 assert_eq!(path, expected);
30 }
31
32 // Simple cases, mostly to check that different path shapes are handled gracefully.
33 check("p/a/b/c/d/", 6, [], "p/…/d/");
34 check("p/a/b/c/d/", 1, [2, 4, 6], "p/a/b/c/d/");
35 check("p/a/b/c/d/", 10, [2, 6], "p/a/…/c/d/");
36 check("p/a/b/c/d/", 8, [6], "p/…/c/d/");
37
38 check("p/a/b/c/d", 5, [], "p/…/d");
39 check("p/a/b/c/d", 9, [2, 4, 6], "p/a/b/c/d");
40 check("p/a/b/c/d", 9, [2, 6], "p/a/…/c/d");
41 check("p/a/b/c/d", 7, [6], "p/…/c/d");
42
43 check("/p/a/b/c/d/", 7, [], "/p/…/d/");
44 check("/p/a/b/c/d/", 11, [3, 5, 7], "/p/a/b/c/d/");
45 check("/p/a/b/c/d/", 11, [3, 7], "/p/a/…/c/d/");
46 check("/p/a/b/c/d/", 9, [7], "/p/…/c/d/");
47
48 // If the budget can't be met, no elision is done.
49 check(
50 "project/dir/child/grandchild",
51 5,
52 [],
53 "project/dir/child/grandchild",
54 );
55
56 // The longest unmatched segment is picked for elision.
57 check(
58 "project/one/two/X/three/sub",
59 21,
60 [16],
61 "project/…/X/three/sub",
62 );
63
64 // Elision stops when the budget is met, even though there are more components in the chosen segment.
65 // It proceeds from the end of the unmatched segment that is closer to the midpoint of the path.
66 check(
67 "project/one/two/three/X/sub",
68 21,
69 [22],
70 "project/…/three/X/sub",
71 )
72}
73
74#[test]
75fn test_custom_project_search_ordering_in_file_finder() {
76 let mut file_finder_sorted_output = vec![
77 ProjectPanelOrdMatch(PathMatch {
78 score: 0.5,
79 positions: Vec::new(),
80 worktree_id: 0,
81 path: rel_path("b0.5").into(),
82 path_prefix: rel_path("").into(),
83 distance_to_relative_ancestor: 0,
84 is_dir: false,
85 }),
86 ProjectPanelOrdMatch(PathMatch {
87 score: 1.0,
88 positions: Vec::new(),
89 worktree_id: 0,
90 path: rel_path("c1.0").into(),
91 path_prefix: rel_path("").into(),
92 distance_to_relative_ancestor: 0,
93 is_dir: false,
94 }),
95 ProjectPanelOrdMatch(PathMatch {
96 score: 1.0,
97 positions: Vec::new(),
98 worktree_id: 0,
99 path: rel_path("a1.0").into(),
100 path_prefix: rel_path("").into(),
101 distance_to_relative_ancestor: 0,
102 is_dir: false,
103 }),
104 ProjectPanelOrdMatch(PathMatch {
105 score: 0.5,
106 positions: Vec::new(),
107 worktree_id: 0,
108 path: rel_path("a0.5").into(),
109 path_prefix: rel_path("").into(),
110 distance_to_relative_ancestor: 0,
111 is_dir: false,
112 }),
113 ProjectPanelOrdMatch(PathMatch {
114 score: 1.0,
115 positions: Vec::new(),
116 worktree_id: 0,
117 path: rel_path("b1.0").into(),
118 path_prefix: rel_path("").into(),
119 distance_to_relative_ancestor: 0,
120 is_dir: false,
121 }),
122 ];
123 file_finder_sorted_output.sort_by(|a, b| b.cmp(a));
124
125 assert_eq!(
126 file_finder_sorted_output,
127 vec![
128 ProjectPanelOrdMatch(PathMatch {
129 score: 1.0,
130 positions: Vec::new(),
131 worktree_id: 0,
132 path: rel_path("a1.0").into(),
133 path_prefix: rel_path("").into(),
134 distance_to_relative_ancestor: 0,
135 is_dir: false,
136 }),
137 ProjectPanelOrdMatch(PathMatch {
138 score: 1.0,
139 positions: Vec::new(),
140 worktree_id: 0,
141 path: rel_path("b1.0").into(),
142 path_prefix: rel_path("").into(),
143 distance_to_relative_ancestor: 0,
144 is_dir: false,
145 }),
146 ProjectPanelOrdMatch(PathMatch {
147 score: 1.0,
148 positions: Vec::new(),
149 worktree_id: 0,
150 path: rel_path("c1.0").into(),
151 path_prefix: rel_path("").into(),
152 distance_to_relative_ancestor: 0,
153 is_dir: false,
154 }),
155 ProjectPanelOrdMatch(PathMatch {
156 score: 0.5,
157 positions: Vec::new(),
158 worktree_id: 0,
159 path: rel_path("a0.5").into(),
160 path_prefix: rel_path("").into(),
161 distance_to_relative_ancestor: 0,
162 is_dir: false,
163 }),
164 ProjectPanelOrdMatch(PathMatch {
165 score: 0.5,
166 positions: Vec::new(),
167 worktree_id: 0,
168 path: rel_path("b0.5").into(),
169 path_prefix: rel_path("").into(),
170 distance_to_relative_ancestor: 0,
171 is_dir: false,
172 }),
173 ]
174 );
175}
176
177#[gpui::test]
178async fn test_matching_paths(cx: &mut TestAppContext) {
179 let app_state = init_test(cx);
180 app_state
181 .fs
182 .as_fake()
183 .insert_tree(
184 path!("/root"),
185 json!({
186 "a": {
187 "banana": "",
188 "bandana": "",
189 }
190 }),
191 )
192 .await;
193
194 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
195
196 let (picker, workspace, cx) = build_find_picker(project, cx);
197
198 cx.simulate_input("bna");
199 picker.update(cx, |picker, _| {
200 assert_eq!(picker.delegate.matches.len(), 3);
201 });
202 cx.dispatch_action(SelectNext);
203 cx.dispatch_action(Confirm);
204 cx.read(|cx| {
205 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
206 assert_eq!(active_editor.read(cx).title(cx), "bandana");
207 });
208
209 for bandana_query in [
210 "bandana",
211 "./bandana",
212 ".\\bandana",
213 util::path!("a/bandana"),
214 "b/bandana",
215 "b\\bandana",
216 " bandana",
217 "bandana ",
218 " bandana ",
219 " ndan ",
220 " band ",
221 "a bandana",
222 "bandana:",
223 ] {
224 picker
225 .update_in(cx, |picker, window, cx| {
226 picker
227 .delegate
228 .update_matches(bandana_query.to_string(), window, cx)
229 })
230 .await;
231 picker.update(cx, |picker, _| {
232 assert_eq!(
233 picker.delegate.matches.len(),
234 // existence of CreateNew option depends on whether path already exists
235 if bandana_query == util::path!("a/bandana") {
236 1
237 } else {
238 2
239 },
240 "Wrong number of matches for bandana query '{bandana_query}'. Matches: {:?}",
241 picker.delegate.matches
242 );
243 });
244 cx.dispatch_action(SelectNext);
245 cx.dispatch_action(Confirm);
246 cx.read(|cx| {
247 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
248 assert_eq!(
249 active_editor.read(cx).title(cx),
250 "bandana",
251 "Wrong match for bandana query '{bandana_query}'"
252 );
253 });
254 }
255}
256
257#[gpui::test]
258async fn test_matching_paths_with_colon(cx: &mut TestAppContext) {
259 let app_state = init_test(cx);
260 app_state
261 .fs
262 .as_fake()
263 .insert_tree(
264 path!("/root"),
265 json!({
266 "a": {
267 "foo:bar.rs": "",
268 "foo.rs": "",
269 }
270 }),
271 )
272 .await;
273
274 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
275
276 let (picker, _, cx) = build_find_picker(project, cx);
277
278 // 'foo:' matches both files
279 cx.simulate_input("foo:");
280 picker.update(cx, |picker, _| {
281 assert_eq!(picker.delegate.matches.len(), 3);
282 assert_match_at_position(picker, 0, "foo.rs");
283 assert_match_at_position(picker, 1, "foo:bar.rs");
284 });
285
286 // 'foo:b' matches one of the files
287 cx.simulate_input("b");
288 picker.update(cx, |picker, _| {
289 assert_eq!(picker.delegate.matches.len(), 2);
290 assert_match_at_position(picker, 0, "foo:bar.rs");
291 });
292
293 cx.dispatch_action(editor::actions::Backspace);
294
295 // 'foo:1' matches both files, specifying which row to jump to
296 cx.simulate_input("1");
297 picker.update(cx, |picker, _| {
298 assert_eq!(picker.delegate.matches.len(), 3);
299 assert_match_at_position(picker, 0, "foo.rs");
300 assert_match_at_position(picker, 1, "foo:bar.rs");
301 });
302}
303
304#[gpui::test]
305async fn test_unicode_paths(cx: &mut TestAppContext) {
306 let app_state = init_test(cx);
307 app_state
308 .fs
309 .as_fake()
310 .insert_tree(
311 path!("/root"),
312 json!({
313 "a": {
314 "İg": " ",
315 }
316 }),
317 )
318 .await;
319
320 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
321
322 let (picker, workspace, cx) = build_find_picker(project, cx);
323
324 cx.simulate_input("g");
325 picker.update(cx, |picker, _| {
326 assert_eq!(picker.delegate.matches.len(), 2);
327 assert_match_at_position(picker, 1, "g");
328 });
329 cx.dispatch_action(Confirm);
330 cx.read(|cx| {
331 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
332 assert_eq!(active_editor.read(cx).title(cx), "İg");
333 });
334}
335
336#[gpui::test]
337async fn test_absolute_paths(cx: &mut TestAppContext) {
338 let app_state = init_test(cx);
339 app_state
340 .fs
341 .as_fake()
342 .insert_tree(
343 path!("/root"),
344 json!({
345 "a": {
346 "file1.txt": "",
347 "b": {
348 "file2.txt": "",
349 },
350 }
351 }),
352 )
353 .await;
354
355 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
356
357 let (picker, workspace, cx) = build_find_picker(project, cx);
358
359 let matching_abs_path = path!("/root/a/b/file2.txt").to_string();
360 picker
361 .update_in(cx, |picker, window, cx| {
362 picker
363 .delegate
364 .update_matches(matching_abs_path, window, cx)
365 })
366 .await;
367 picker.update(cx, |picker, _| {
368 assert_eq!(
369 collect_search_matches(picker).search_paths_only(),
370 vec![rel_path("a/b/file2.txt").into()],
371 "Matching abs path should be the only match"
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), "file2.txt");
379 });
380
381 let mismatching_abs_path = path!("/root/a/b/file1.txt").to_string();
382 picker
383 .update_in(cx, |picker, window, cx| {
384 picker
385 .delegate
386 .update_matches(mismatching_abs_path, window, cx)
387 })
388 .await;
389 picker.update(cx, |picker, _| {
390 assert_eq!(
391 collect_search_matches(picker).search_paths_only(),
392 Vec::new(),
393 "Mismatching abs path should produce no matches"
394 )
395 });
396}
397
398#[gpui::test]
399async fn test_complex_path(cx: &mut TestAppContext) {
400 let app_state = init_test(cx);
401 app_state
402 .fs
403 .as_fake()
404 .insert_tree(
405 path!("/root"),
406 json!({
407 "其他": {
408 "S数据表格": {
409 "task.xlsx": "some content",
410 },
411 }
412 }),
413 )
414 .await;
415
416 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
417
418 let (picker, workspace, cx) = build_find_picker(project, cx);
419
420 cx.simulate_input("t");
421 picker.update(cx, |picker, _| {
422 assert_eq!(picker.delegate.matches.len(), 2);
423 assert_eq!(
424 collect_search_matches(picker).search_paths_only(),
425 vec![rel_path("其他/S数据表格/task.xlsx").into()],
426 )
427 });
428 cx.dispatch_action(Confirm);
429 cx.read(|cx| {
430 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
431 assert_eq!(active_editor.read(cx).title(cx), "task.xlsx");
432 });
433}
434
435#[gpui::test]
436async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
437 let app_state = init_test(cx);
438
439 let first_file_name = "first.rs";
440 let first_file_contents = "// First Rust file";
441 app_state
442 .fs
443 .as_fake()
444 .insert_tree(
445 path!("/src"),
446 json!({
447 "test": {
448 first_file_name: first_file_contents,
449 "second.rs": "// Second Rust file",
450 }
451 }),
452 )
453 .await;
454
455 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
456
457 let (picker, workspace, cx) = build_find_picker(project, cx);
458
459 let file_query = &first_file_name[..3];
460 let file_row = 1;
461 let file_column = 3;
462 assert!(file_column <= first_file_contents.len());
463 let query_inside_file = format!("{file_query}:{file_row}:{file_column}");
464 picker
465 .update_in(cx, |finder, window, cx| {
466 finder
467 .delegate
468 .update_matches(query_inside_file.to_string(), window, cx)
469 })
470 .await;
471 picker.update(cx, |finder, _| {
472 assert_match_at_position(finder, 1, &query_inside_file.to_string());
473 let finder = &finder.delegate;
474 assert_eq!(finder.matches.len(), 2);
475 let latest_search_query = finder
476 .latest_search_query
477 .as_ref()
478 .expect("Finder should have a query after the update_matches call");
479 assert_eq!(latest_search_query.raw_query, query_inside_file);
480 assert_eq!(latest_search_query.file_query_end, Some(file_query.len()));
481 assert_eq!(latest_search_query.path_position.row, Some(file_row));
482 assert_eq!(
483 latest_search_query.path_position.column,
484 Some(file_column as u32)
485 );
486 });
487
488 cx.dispatch_action(Confirm);
489
490 let editor = cx.update(|_, cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
491 cx.executor().advance_clock(Duration::from_secs(2));
492
493 editor.update(cx, |editor, cx| {
494 let all_selections = editor.selections.all_adjusted(&editor.display_snapshot(cx));
495 assert_eq!(
496 all_selections.len(),
497 1,
498 "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
499 );
500 let caret_selection = all_selections.into_iter().next().unwrap();
501 assert_eq!(caret_selection.start, caret_selection.end,
502 "Caret selection should have its start and end at the same position");
503 assert_eq!(file_row, caret_selection.start.row + 1,
504 "Query inside file should get caret with the same focus row");
505 assert_eq!(file_column, caret_selection.start.column as usize + 1,
506 "Query inside file should get caret with the same focus column");
507 });
508}
509
510#[gpui::test]
511async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
512 let app_state = init_test(cx);
513
514 let first_file_name = "first.rs";
515 let first_file_contents = "// First Rust file";
516 app_state
517 .fs
518 .as_fake()
519 .insert_tree(
520 path!("/src"),
521 json!({
522 "test": {
523 first_file_name: first_file_contents,
524 "second.rs": "// Second Rust file",
525 }
526 }),
527 )
528 .await;
529
530 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
531
532 let (picker, workspace, cx) = build_find_picker(project, cx);
533
534 let file_query = &first_file_name[..3];
535 let file_row = 200;
536 let file_column = 300;
537 assert!(file_column > first_file_contents.len());
538 let query_outside_file = format!("{file_query}:{file_row}:{file_column}");
539 picker
540 .update_in(cx, |picker, window, cx| {
541 picker
542 .delegate
543 .update_matches(query_outside_file.to_string(), window, cx)
544 })
545 .await;
546 picker.update(cx, |finder, _| {
547 assert_match_at_position(finder, 1, &query_outside_file.to_string());
548 let delegate = &finder.delegate;
549 assert_eq!(delegate.matches.len(), 2);
550 let latest_search_query = delegate
551 .latest_search_query
552 .as_ref()
553 .expect("Finder should have a query after the update_matches call");
554 assert_eq!(latest_search_query.raw_query, query_outside_file);
555 assert_eq!(latest_search_query.file_query_end, Some(file_query.len()));
556 assert_eq!(latest_search_query.path_position.row, Some(file_row));
557 assert_eq!(
558 latest_search_query.path_position.column,
559 Some(file_column as u32)
560 );
561 });
562
563 cx.dispatch_action(Confirm);
564
565 let editor = cx.update(|_, cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
566 cx.executor().advance_clock(Duration::from_secs(2));
567
568 editor.update(cx, |editor, cx| {
569 let all_selections = editor.selections.all_adjusted(&editor.display_snapshot(cx));
570 assert_eq!(
571 all_selections.len(),
572 1,
573 "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
574 );
575 let caret_selection = all_selections.into_iter().next().unwrap();
576 assert_eq!(caret_selection.start, caret_selection.end,
577 "Caret selection should have its start and end at the same position");
578 assert_eq!(0, caret_selection.start.row,
579 "Excessive rows (as in query outside file borders) should get trimmed to last file row");
580 assert_eq!(first_file_contents.len(), caret_selection.start.column as usize,
581 "Excessive columns (as in query outside file borders) should get trimmed to selected row's last column");
582 });
583}
584
585#[gpui::test]
586async fn test_matching_cancellation(cx: &mut TestAppContext) {
587 let app_state = init_test(cx);
588 app_state
589 .fs
590 .as_fake()
591 .insert_tree(
592 "/dir",
593 json!({
594 "hello": "",
595 "goodbye": "",
596 "halogen-light": "",
597 "happiness": "",
598 "height": "",
599 "hi": "",
600 "hiccup": "",
601 }),
602 )
603 .await;
604
605 let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await;
606
607 let (picker, _, cx) = build_find_picker(project, cx);
608
609 let query = test_path_position("hi");
610 picker
611 .update_in(cx, |picker, window, cx| {
612 picker.delegate.spawn_search(query.clone(), window, cx)
613 })
614 .await;
615
616 picker.update(cx, |picker, _cx| {
617 // CreateNew option not shown in this case since file already exists
618 assert_eq!(picker.delegate.matches.len(), 5);
619 });
620
621 picker.update_in(cx, |picker, window, cx| {
622 let matches = collect_search_matches(picker).search_matches_only();
623 let delegate = &mut picker.delegate;
624
625 // Simulate a search being cancelled after the time limit,
626 // returning only a subset of the matches that would have been found.
627 drop(delegate.spawn_search(query.clone(), window, cx));
628 delegate.set_search_matches(
629 delegate.latest_search_id,
630 true, // did-cancel
631 query.clone(),
632 vec![
633 ProjectPanelOrdMatch(matches[1].clone()),
634 ProjectPanelOrdMatch(matches[3].clone()),
635 ],
636 cx,
637 );
638
639 // Simulate another cancellation.
640 drop(delegate.spawn_search(query.clone(), window, cx));
641 delegate.set_search_matches(
642 delegate.latest_search_id,
643 true, // did-cancel
644 query.clone(),
645 vec![
646 ProjectPanelOrdMatch(matches[0].clone()),
647 ProjectPanelOrdMatch(matches[2].clone()),
648 ProjectPanelOrdMatch(matches[3].clone()),
649 ],
650 cx,
651 );
652
653 assert_eq!(
654 collect_search_matches(picker)
655 .search_matches_only()
656 .as_slice(),
657 &matches[0..4]
658 );
659 });
660}
661
662#[gpui::test]
663async fn test_ignored_root_with_file_inclusions(cx: &mut TestAppContext) {
664 let app_state = init_test(cx);
665 cx.update(|cx| {
666 cx.update_global::<SettingsStore, _>(|store, cx| {
667 store.update_user_settings(cx, |settings| {
668 settings.project.worktree.file_scan_inclusions = Some(vec![
669 "height_demo/**/hi_bonjour".to_string(),
670 "**/height_1".to_string(),
671 ]);
672 });
673 })
674 });
675 app_state
676 .fs
677 .as_fake()
678 .insert_tree(
679 "/ancestor",
680 json!({
681 ".gitignore": "ignored-root",
682 "ignored-root": {
683 "happiness": "",
684 "height": "",
685 "hi": "",
686 "hiccup": "",
687 },
688 "tracked-root": {
689 ".gitignore": "height*",
690 "happiness": "",
691 "height": "",
692 "heights": {
693 "height_1": "",
694 "height_2": "",
695 },
696 "height_demo": {
697 "test_1": {
698 "hi_bonjour": "hi_bonjour",
699 "hi": "hello",
700 },
701 "hihi": "bye",
702 "test_2": {
703 "hoi": "nl"
704 }
705 },
706 "height_include": {
707 "height_1_include": "",
708 "height_2_include": "",
709 },
710 "hi": "",
711 "hiccup": "",
712 },
713 }),
714 )
715 .await;
716
717 let project = Project::test(
718 app_state.fs.clone(),
719 [
720 Path::new(path!("/ancestor/tracked-root")),
721 Path::new(path!("/ancestor/ignored-root")),
722 ],
723 cx,
724 )
725 .await;
726 let (picker, _workspace, cx) = build_find_picker(project, cx);
727
728 picker
729 .update_in(cx, |picker, window, cx| {
730 picker
731 .delegate
732 .spawn_search(test_path_position("hi"), window, cx)
733 })
734 .await;
735 picker.update(cx, |picker, _| {
736 let matches = collect_search_matches(picker);
737 assert_eq!(matches.history.len(), 0);
738 assert_eq!(
739 matches.search,
740 vec![
741 rel_path("ignored-root/hi").into(),
742 rel_path("tracked-root/hi").into(),
743 rel_path("ignored-root/hiccup").into(),
744 rel_path("tracked-root/hiccup").into(),
745 rel_path("tracked-root/height_demo/test_1/hi_bonjour").into(),
746 rel_path("ignored-root/height").into(),
747 rel_path("tracked-root/heights/height_1").into(),
748 rel_path("ignored-root/happiness").into(),
749 rel_path("tracked-root/happiness").into(),
750 ],
751 "All ignored files that were indexed are found for default ignored mode"
752 );
753 });
754}
755
756#[gpui::test]
757async fn test_ignored_root_with_file_inclusions_repro(cx: &mut TestAppContext) {
758 let app_state = init_test(cx);
759 cx.update(|cx| {
760 cx.update_global::<SettingsStore, _>(|store, cx| {
761 store.update_user_settings(cx, |settings| {
762 settings.project.worktree.file_scan_inclusions = Some(vec!["**/.env".to_string()]);
763 });
764 })
765 });
766 app_state
767 .fs
768 .as_fake()
769 .insert_tree(
770 "/src",
771 json!({
772 ".gitignore": "node_modules",
773 "node_modules": {
774 "package.json": "// package.json",
775 ".env": "BAR=FOO"
776 },
777 ".env": "FOO=BAR"
778 }),
779 )
780 .await;
781
782 let project = Project::test(app_state.fs.clone(), [Path::new(path!("/src"))], cx).await;
783 let (picker, _workspace, cx) = build_find_picker(project, cx);
784
785 picker
786 .update_in(cx, |picker, window, cx| {
787 picker
788 .delegate
789 .spawn_search(test_path_position("json"), window, cx)
790 })
791 .await;
792 picker.update(cx, |picker, _| {
793 let matches = collect_search_matches(picker);
794 assert_eq!(matches.history.len(), 0);
795 assert_eq!(
796 matches.search,
797 vec![],
798 "All ignored files that were indexed are found for default ignored mode"
799 );
800 });
801}
802
803#[gpui::test]
804async fn test_ignored_root(cx: &mut TestAppContext) {
805 let app_state = init_test(cx);
806 app_state
807 .fs
808 .as_fake()
809 .insert_tree(
810 "/ancestor",
811 json!({
812 ".gitignore": "ignored-root",
813 "ignored-root": {
814 "happiness": "",
815 "height": "",
816 "hi": "",
817 "hiccup": "",
818 },
819 "tracked-root": {
820 ".gitignore": "height*",
821 "happiness": "",
822 "height": "",
823 "heights": {
824 "height_1": "",
825 "height_2": "",
826 },
827 "hi": "",
828 "hiccup": "",
829 },
830 }),
831 )
832 .await;
833
834 let project = Project::test(
835 app_state.fs.clone(),
836 [
837 Path::new(path!("/ancestor/tracked-root")),
838 Path::new(path!("/ancestor/ignored-root")),
839 ],
840 cx,
841 )
842 .await;
843 let (picker, workspace, cx) = build_find_picker(project, cx);
844
845 picker
846 .update_in(cx, |picker, window, cx| {
847 picker
848 .delegate
849 .spawn_search(test_path_position("hi"), window, cx)
850 })
851 .await;
852 picker.update(cx, |picker, _| {
853 let matches = collect_search_matches(picker);
854 assert_eq!(matches.history.len(), 0);
855 assert_eq!(
856 matches.search,
857 vec![
858 rel_path("ignored-root/hi").into(),
859 rel_path("tracked-root/hi").into(),
860 rel_path("ignored-root/hiccup").into(),
861 rel_path("tracked-root/hiccup").into(),
862 rel_path("ignored-root/height").into(),
863 rel_path("ignored-root/happiness").into(),
864 rel_path("tracked-root/happiness").into(),
865 ],
866 "All ignored files that were indexed are found for default ignored mode"
867 );
868 });
869 cx.dispatch_action(ToggleIncludeIgnored);
870 picker
871 .update_in(cx, |picker, window, cx| {
872 picker
873 .delegate
874 .spawn_search(test_path_position("hi"), window, cx)
875 })
876 .await;
877 picker.update(cx, |picker, _| {
878 let matches = collect_search_matches(picker);
879 assert_eq!(matches.history.len(), 0);
880 assert_eq!(
881 matches.search,
882 vec![
883 rel_path("ignored-root/hi").into(),
884 rel_path("tracked-root/hi").into(),
885 rel_path("ignored-root/hiccup").into(),
886 rel_path("tracked-root/hiccup").into(),
887 rel_path("ignored-root/height").into(),
888 rel_path("tracked-root/height").into(),
889 rel_path("ignored-root/happiness").into(),
890 rel_path("tracked-root/happiness").into(),
891 ],
892 "All ignored files should be found, for the toggled on ignored mode"
893 );
894 });
895
896 picker
897 .update_in(cx, |picker, window, cx| {
898 picker.delegate.include_ignored = Some(false);
899 picker
900 .delegate
901 .spawn_search(test_path_position("hi"), window, cx)
902 })
903 .await;
904 picker.update(cx, |picker, _| {
905 let matches = collect_search_matches(picker);
906 assert_eq!(matches.history.len(), 0);
907 assert_eq!(
908 matches.search,
909 vec![
910 rel_path("tracked-root/hi").into(),
911 rel_path("tracked-root/hiccup").into(),
912 rel_path("tracked-root/happiness").into(),
913 ],
914 "Only non-ignored files should be found for the turned off ignored mode"
915 );
916 });
917
918 workspace
919 .update_in(cx, |workspace, window, cx| {
920 workspace.open_abs_path(
921 PathBuf::from(path!("/ancestor/tracked-root/heights/height_1")),
922 OpenOptions {
923 visible: Some(OpenVisible::None),
924 ..OpenOptions::default()
925 },
926 window,
927 cx,
928 )
929 })
930 .await
931 .unwrap();
932 cx.run_until_parked();
933 workspace
934 .update_in(cx, |workspace, window, cx| {
935 workspace.active_pane().update(cx, |pane, cx| {
936 pane.close_active_item(&CloseActiveItem::default(), window, cx)
937 })
938 })
939 .await
940 .unwrap();
941 cx.run_until_parked();
942
943 picker
944 .update_in(cx, |picker, window, cx| {
945 picker.delegate.include_ignored = None;
946 picker
947 .delegate
948 .spawn_search(test_path_position("hi"), window, cx)
949 })
950 .await;
951 picker.update(cx, |picker, _| {
952 let matches = collect_search_matches(picker);
953 assert_eq!(matches.history.len(), 0);
954 assert_eq!(
955 matches.search,
956 vec![
957 rel_path("ignored-root/hi").into(),
958 rel_path("tracked-root/hi").into(),
959 rel_path("ignored-root/hiccup").into(),
960 rel_path("tracked-root/hiccup").into(),
961 rel_path("ignored-root/height").into(),
962 rel_path("ignored-root/happiness").into(),
963 rel_path("tracked-root/happiness").into(),
964 ],
965 "Only for the worktree with the ignored root, all indexed ignored files are found in the auto ignored mode"
966 );
967 });
968
969 picker
970 .update_in(cx, |picker, window, cx| {
971 picker.delegate.include_ignored = Some(true);
972 picker
973 .delegate
974 .spawn_search(test_path_position("hi"), window, cx)
975 })
976 .await;
977 picker.update(cx, |picker, _| {
978 let matches = collect_search_matches(picker);
979 assert_eq!(matches.history.len(), 0);
980 assert_eq!(
981 matches.search,
982 vec![
983 rel_path("ignored-root/hi").into(),
984 rel_path("tracked-root/hi").into(),
985 rel_path("ignored-root/hiccup").into(),
986 rel_path("tracked-root/hiccup").into(),
987 rel_path("ignored-root/height").into(),
988 rel_path("tracked-root/height").into(),
989 rel_path("tracked-root/heights/height_1").into(),
990 rel_path("tracked-root/heights/height_2").into(),
991 rel_path("ignored-root/happiness").into(),
992 rel_path("tracked-root/happiness").into(),
993 ],
994 "All ignored files that were indexed are found in the turned on ignored mode"
995 );
996 });
997
998 picker
999 .update_in(cx, |picker, window, cx| {
1000 picker.delegate.include_ignored = Some(false);
1001 picker
1002 .delegate
1003 .spawn_search(test_path_position("hi"), window, cx)
1004 })
1005 .await;
1006 picker.update(cx, |picker, _| {
1007 let matches = collect_search_matches(picker);
1008 assert_eq!(matches.history.len(), 0);
1009 assert_eq!(
1010 matches.search,
1011 vec![
1012 rel_path("tracked-root/hi").into(),
1013 rel_path("tracked-root/hiccup").into(),
1014 rel_path("tracked-root/happiness").into(),
1015 ],
1016 "Only non-ignored files should be found for the turned off ignored mode"
1017 );
1018 });
1019}
1020
1021#[gpui::test]
1022async fn test_single_file_worktrees(cx: &mut TestAppContext) {
1023 let app_state = init_test(cx);
1024 app_state
1025 .fs
1026 .as_fake()
1027 .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
1028 .await;
1029
1030 let project = Project::test(
1031 app_state.fs.clone(),
1032 ["/root/the-parent-dir/the-file".as_ref()],
1033 cx,
1034 )
1035 .await;
1036
1037 let (picker, _, cx) = build_find_picker(project, cx);
1038
1039 // Even though there is only one worktree, that worktree's filename
1040 // is included in the matching, because the worktree is a single file.
1041 picker
1042 .update_in(cx, |picker, window, cx| {
1043 picker
1044 .delegate
1045 .spawn_search(test_path_position("thf"), window, cx)
1046 })
1047 .await;
1048 cx.read(|cx| {
1049 let picker = picker.read(cx);
1050 let delegate = &picker.delegate;
1051 let matches = collect_search_matches(picker).search_matches_only();
1052 assert_eq!(matches.len(), 1);
1053
1054 let (file_name, file_name_positions, full_path, full_path_positions) =
1055 delegate.labels_for_path_match(&matches[0], PathStyle::local());
1056 assert_eq!(file_name, "the-file");
1057 assert_eq!(file_name_positions, &[0, 1, 4]);
1058 assert_eq!(full_path, "");
1059 assert_eq!(full_path_positions, &[0; 0]);
1060 });
1061
1062 // Since the worktree root is a file, searching for its name followed by a slash does
1063 // not match anything.
1064 picker
1065 .update_in(cx, |picker, window, cx| {
1066 picker
1067 .delegate
1068 .spawn_search(test_path_position("thf/"), window, cx)
1069 })
1070 .await;
1071 picker.update(cx, |f, _| assert_eq!(f.delegate.matches.len(), 0));
1072}
1073
1074#[gpui::test]
1075async fn test_history_items_uniqueness_for_multiple_worktree(cx: &mut TestAppContext) {
1076 let app_state = init_test(cx);
1077 app_state
1078 .fs
1079 .as_fake()
1080 .insert_tree(
1081 path!("/repo1"),
1082 json!({
1083 "package.json": r#"{"name": "repo1"}"#,
1084 "src": {
1085 "index.js": "// Repo 1 index",
1086 }
1087 }),
1088 )
1089 .await;
1090
1091 app_state
1092 .fs
1093 .as_fake()
1094 .insert_tree(
1095 path!("/repo2"),
1096 json!({
1097 "package.json": r#"{"name": "repo2"}"#,
1098 "src": {
1099 "index.js": "// Repo 2 index",
1100 }
1101 }),
1102 )
1103 .await;
1104
1105 let project = Project::test(
1106 app_state.fs.clone(),
1107 [path!("/repo1").as_ref(), path!("/repo2").as_ref()],
1108 cx,
1109 )
1110 .await;
1111
1112 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1113 let (worktree_id1, worktree_id2) = cx.read(|cx| {
1114 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1115 (worktrees[0].read(cx).id(), worktrees[1].read(cx).id())
1116 });
1117
1118 workspace
1119 .update_in(cx, |workspace, window, cx| {
1120 workspace.open_path(
1121 ProjectPath {
1122 worktree_id: worktree_id1,
1123 path: rel_path("package.json").into(),
1124 },
1125 None,
1126 true,
1127 window,
1128 cx,
1129 )
1130 })
1131 .await
1132 .unwrap();
1133
1134 cx.dispatch_action(workspace::CloseActiveItem {
1135 save_intent: None,
1136 close_pinned: false,
1137 });
1138
1139 let picker = open_file_picker(&workspace, cx);
1140 cx.simulate_input("package.json");
1141
1142 picker.update(cx, |finder, _| {
1143 let matches = &finder.delegate.matches.matches;
1144
1145 assert_eq!(
1146 matches.len(),
1147 2,
1148 "Expected 1 history match + 1 search matches, but got {} matches: {:?}",
1149 matches.len(),
1150 matches
1151 );
1152
1153 assert_matches!(matches[0], Match::History { .. });
1154
1155 let search_matches = collect_search_matches(finder);
1156 assert_eq!(
1157 search_matches.history.len(),
1158 1,
1159 "Should have exactly 1 history match"
1160 );
1161 assert_eq!(
1162 search_matches.search.len(),
1163 1,
1164 "Should have exactly 1 search match (the other package.json)"
1165 );
1166
1167 if let Match::History { path, .. } = &matches[0] {
1168 assert_eq!(path.project.worktree_id, worktree_id1);
1169 assert_eq!(path.project.path.as_ref(), rel_path("package.json"));
1170 }
1171
1172 if let Match::Search(path_match) = &matches[1] {
1173 assert_eq!(
1174 WorktreeId::from_usize(path_match.0.worktree_id),
1175 worktree_id2
1176 );
1177 assert_eq!(path_match.0.path.as_ref(), rel_path("package.json"));
1178 }
1179 });
1180}
1181
1182#[gpui::test]
1183async fn test_create_file_for_multiple_worktrees(cx: &mut TestAppContext) {
1184 let app_state = init_test(cx);
1185 app_state
1186 .fs
1187 .as_fake()
1188 .insert_tree(
1189 path!("/roota"),
1190 json!({ "the-parent-dira": { "filea": "" } }),
1191 )
1192 .await;
1193
1194 app_state
1195 .fs
1196 .as_fake()
1197 .insert_tree(
1198 path!("/rootb"),
1199 json!({ "the-parent-dirb": { "fileb": "" } }),
1200 )
1201 .await;
1202
1203 let project = Project::test(
1204 app_state.fs.clone(),
1205 [path!("/roota").as_ref(), path!("/rootb").as_ref()],
1206 cx,
1207 )
1208 .await;
1209
1210 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1211 let (_worktree_id1, worktree_id2) = cx.read(|cx| {
1212 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1213 (
1214 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize),
1215 WorktreeId::from_usize(worktrees[1].entity_id().as_u64() as usize),
1216 )
1217 });
1218
1219 let b_path = ProjectPath {
1220 worktree_id: worktree_id2,
1221 path: rel_path("the-parent-dirb/fileb").into(),
1222 };
1223 workspace
1224 .update_in(cx, |workspace, window, cx| {
1225 workspace.open_path(b_path, None, true, window, cx)
1226 })
1227 .await
1228 .unwrap();
1229
1230 let finder = open_file_picker(&workspace, cx);
1231
1232 finder
1233 .update_in(cx, |f, window, cx| {
1234 f.delegate.spawn_search(
1235 test_path_position(path!("the-parent-dirb/filec")),
1236 window,
1237 cx,
1238 )
1239 })
1240 .await;
1241 cx.run_until_parked();
1242 finder.update_in(cx, |picker, window, cx| {
1243 assert_eq!(picker.delegate.matches.len(), 1);
1244 picker.delegate.confirm(false, window, cx)
1245 });
1246 cx.run_until_parked();
1247 cx.read(|cx| {
1248 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
1249 let project_path = active_editor.read(cx).project_path(cx);
1250 assert_eq!(
1251 project_path,
1252 Some(ProjectPath {
1253 worktree_id: worktree_id2,
1254 path: rel_path("the-parent-dirb/filec").into()
1255 })
1256 );
1257 });
1258}
1259
1260#[gpui::test]
1261async fn test_create_file_no_focused_with_multiple_worktrees(cx: &mut TestAppContext) {
1262 let app_state = init_test(cx);
1263 app_state
1264 .fs
1265 .as_fake()
1266 .insert_tree(
1267 path!("/roota"),
1268 json!({ "the-parent-dira": { "filea": "" } }),
1269 )
1270 .await;
1271
1272 app_state
1273 .fs
1274 .as_fake()
1275 .insert_tree(
1276 path!("/rootb"),
1277 json!({ "the-parent-dirb": { "fileb": "" } }),
1278 )
1279 .await;
1280
1281 let project = Project::test(
1282 app_state.fs.clone(),
1283 [path!("/roota").as_ref(), path!("/rootb").as_ref()],
1284 cx,
1285 )
1286 .await;
1287
1288 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1289 let (_worktree_id1, worktree_id2) = cx.read(|cx| {
1290 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1291 (worktrees[0].read(cx).id(), worktrees[1].read(cx).id())
1292 });
1293
1294 let finder = open_file_picker(&workspace, cx);
1295
1296 finder
1297 .update_in(cx, |f, window, cx| {
1298 f.delegate
1299 .spawn_search(test_path_position(path!("rootb/filec")), window, cx)
1300 })
1301 .await;
1302 cx.run_until_parked();
1303 finder.update_in(cx, |picker, window, cx| {
1304 assert_eq!(picker.delegate.matches.len(), 1);
1305 picker.delegate.confirm(false, window, cx)
1306 });
1307 cx.run_until_parked();
1308 cx.read(|cx| {
1309 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
1310 let project_path = active_editor.read(cx).project_path(cx);
1311 assert_eq!(
1312 project_path,
1313 Some(ProjectPath {
1314 worktree_id: worktree_id2,
1315 path: rel_path("filec").into()
1316 })
1317 );
1318 });
1319}
1320
1321#[gpui::test]
1322async fn test_path_distance_ordering(cx: &mut TestAppContext) {
1323 let app_state = init_test(cx);
1324 app_state
1325 .fs
1326 .as_fake()
1327 .insert_tree(
1328 path!("/root"),
1329 json!({
1330 "dir1": { "a.txt": "" },
1331 "dir2": {
1332 "a.txt": "",
1333 "b.txt": ""
1334 }
1335 }),
1336 )
1337 .await;
1338
1339 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
1340 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1341
1342 let worktree_id = cx.read(|cx| {
1343 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1344 assert_eq!(worktrees.len(), 1);
1345 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1346 });
1347
1348 // When workspace has an active item, sort items which are closer to that item
1349 // first when they have the same name. In this case, b.txt is closer to dir2's a.txt
1350 // so that one should be sorted earlier
1351 let b_path = ProjectPath {
1352 worktree_id,
1353 path: rel_path("dir2/b.txt").into(),
1354 };
1355 workspace
1356 .update_in(cx, |workspace, window, cx| {
1357 workspace.open_path(b_path, None, true, window, cx)
1358 })
1359 .await
1360 .unwrap();
1361 let finder = open_file_picker(&workspace, cx);
1362 finder
1363 .update_in(cx, |f, window, cx| {
1364 f.delegate
1365 .spawn_search(test_path_position("a.txt"), window, cx)
1366 })
1367 .await;
1368
1369 finder.update(cx, |picker, _| {
1370 let matches = collect_search_matches(picker).search_paths_only();
1371 assert_eq!(matches[0].as_ref(), rel_path("dir2/a.txt"));
1372 assert_eq!(matches[1].as_ref(), rel_path("dir1/a.txt"));
1373 });
1374}
1375
1376#[gpui::test]
1377async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
1378 let app_state = init_test(cx);
1379 app_state
1380 .fs
1381 .as_fake()
1382 .insert_tree(
1383 "/root",
1384 json!({
1385 "dir1": {},
1386 "dir2": {
1387 "dir3": {}
1388 }
1389 }),
1390 )
1391 .await;
1392
1393 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1394 let (picker, _workspace, cx) = build_find_picker(project, cx);
1395
1396 picker
1397 .update_in(cx, |f, window, cx| {
1398 f.delegate
1399 .spawn_search(test_path_position("dir"), window, cx)
1400 })
1401 .await;
1402 cx.read(|cx| {
1403 let finder = picker.read(cx);
1404 assert_eq!(finder.delegate.matches.len(), 1);
1405 assert_match_at_position(finder, 0, "dir");
1406 });
1407}
1408
1409#[gpui::test]
1410async fn test_query_history(cx: &mut gpui::TestAppContext) {
1411 let app_state = init_test(cx);
1412
1413 app_state
1414 .fs
1415 .as_fake()
1416 .insert_tree(
1417 path!("/src"),
1418 json!({
1419 "test": {
1420 "first.rs": "// First Rust file",
1421 "second.rs": "// Second Rust file",
1422 "third.rs": "// Third Rust file",
1423 }
1424 }),
1425 )
1426 .await;
1427
1428 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1429 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1430 let worktree_id = cx.read(|cx| {
1431 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1432 assert_eq!(worktrees.len(), 1);
1433 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1434 });
1435
1436 // Open and close panels, getting their history items afterwards.
1437 // Ensure history items get populated with opened items, and items are kept in a certain order.
1438 // The history lags one opened buffer behind, since it's updated in the search panel only on its reopen.
1439 //
1440 // TODO: without closing, the opened items do not propagate their history changes for some reason
1441 // it does work in real app though, only tests do not propagate.
1442 workspace.update_in(cx, |_workspace, window, cx| window.focused(cx));
1443
1444 let initial_history = open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1445 assert!(
1446 initial_history.is_empty(),
1447 "Should have no history before opening any files"
1448 );
1449
1450 let history_after_first =
1451 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1452 assert_eq!(
1453 history_after_first,
1454 vec![FoundPath::new(
1455 ProjectPath {
1456 worktree_id,
1457 path: rel_path("test/first.rs").into(),
1458 },
1459 PathBuf::from(path!("/src/test/first.rs"))
1460 )],
1461 "Should show 1st opened item in the history when opening the 2nd item"
1462 );
1463
1464 let history_after_second =
1465 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1466 assert_eq!(
1467 history_after_second,
1468 vec![
1469 FoundPath::new(
1470 ProjectPath {
1471 worktree_id,
1472 path: rel_path("test/second.rs").into(),
1473 },
1474 PathBuf::from(path!("/src/test/second.rs"))
1475 ),
1476 FoundPath::new(
1477 ProjectPath {
1478 worktree_id,
1479 path: rel_path("test/first.rs").into(),
1480 },
1481 PathBuf::from(path!("/src/test/first.rs"))
1482 ),
1483 ],
1484 "Should show 1st and 2nd opened items in the history when opening the 3rd item. \
1485 2nd item should be the first in the history, as the last opened."
1486 );
1487
1488 let history_after_third =
1489 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1490 assert_eq!(
1491 history_after_third,
1492 vec![
1493 FoundPath::new(
1494 ProjectPath {
1495 worktree_id,
1496 path: rel_path("test/third.rs").into(),
1497 },
1498 PathBuf::from(path!("/src/test/third.rs"))
1499 ),
1500 FoundPath::new(
1501 ProjectPath {
1502 worktree_id,
1503 path: rel_path("test/second.rs").into(),
1504 },
1505 PathBuf::from(path!("/src/test/second.rs"))
1506 ),
1507 FoundPath::new(
1508 ProjectPath {
1509 worktree_id,
1510 path: rel_path("test/first.rs").into(),
1511 },
1512 PathBuf::from(path!("/src/test/first.rs"))
1513 ),
1514 ],
1515 "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \
1516 3rd item should be the first in the history, as the last opened."
1517 );
1518
1519 let history_after_second_again =
1520 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1521 assert_eq!(
1522 history_after_second_again,
1523 vec![
1524 FoundPath::new(
1525 ProjectPath {
1526 worktree_id,
1527 path: rel_path("test/second.rs").into(),
1528 },
1529 PathBuf::from(path!("/src/test/second.rs"))
1530 ),
1531 FoundPath::new(
1532 ProjectPath {
1533 worktree_id,
1534 path: rel_path("test/third.rs").into(),
1535 },
1536 PathBuf::from(path!("/src/test/third.rs"))
1537 ),
1538 FoundPath::new(
1539 ProjectPath {
1540 worktree_id,
1541 path: rel_path("test/first.rs").into(),
1542 },
1543 PathBuf::from(path!("/src/test/first.rs"))
1544 ),
1545 ],
1546 "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \
1547 2nd item, as the last opened, 3rd item should go next as it was opened right before."
1548 );
1549}
1550
1551#[gpui::test]
1552async fn test_history_match_positions(cx: &mut gpui::TestAppContext) {
1553 let app_state = init_test(cx);
1554
1555 app_state
1556 .fs
1557 .as_fake()
1558 .insert_tree(
1559 path!("/src"),
1560 json!({
1561 "test": {
1562 "first.rs": "// First Rust file",
1563 "second.rs": "// Second Rust file",
1564 "third.rs": "// Third Rust file",
1565 }
1566 }),
1567 )
1568 .await;
1569
1570 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1571 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1572
1573 workspace.update_in(cx, |_workspace, window, cx| window.focused(cx));
1574
1575 open_close_queried_buffer("efir", 1, "first.rs", &workspace, cx).await;
1576 let history = open_close_queried_buffer("second", 1, "second.rs", &workspace, cx).await;
1577 assert_eq!(history.len(), 1);
1578
1579 let picker = open_file_picker(&workspace, cx);
1580 cx.simulate_input("fir");
1581 picker.update_in(cx, |finder, window, cx| {
1582 let matches = &finder.delegate.matches.matches;
1583 assert_matches!(
1584 matches.as_slice(),
1585 [Match::History { .. }, Match::CreateNew { .. }]
1586 );
1587 assert_eq!(
1588 matches[0].panel_match().unwrap().0.path.as_ref(),
1589 rel_path("test/first.rs")
1590 );
1591 assert_eq!(matches[0].panel_match().unwrap().0.positions, &[5, 6, 7]);
1592
1593 let (file_label, path_label) =
1594 finder
1595 .delegate
1596 .labels_for_match(&finder.delegate.matches.matches[0], window, cx);
1597 assert_eq!(file_label.text(), "first.rs");
1598 assert_eq!(file_label.highlight_indices(), &[0, 1, 2]);
1599 assert_eq!(
1600 path_label.text(),
1601 format!("test{}", PathStyle::local().separator())
1602 );
1603 assert_eq!(path_label.highlight_indices(), &[] as &[usize]);
1604 });
1605}
1606
1607#[gpui::test]
1608async fn test_external_files_history(cx: &mut gpui::TestAppContext) {
1609 let app_state = init_test(cx);
1610
1611 app_state
1612 .fs
1613 .as_fake()
1614 .insert_tree(
1615 path!("/src"),
1616 json!({
1617 "test": {
1618 "first.rs": "// First Rust file",
1619 "second.rs": "// Second Rust file",
1620 }
1621 }),
1622 )
1623 .await;
1624
1625 app_state
1626 .fs
1627 .as_fake()
1628 .insert_tree(
1629 path!("/external-src"),
1630 json!({
1631 "test": {
1632 "third.rs": "// Third Rust file",
1633 "fourth.rs": "// Fourth Rust file",
1634 }
1635 }),
1636 )
1637 .await;
1638
1639 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1640 cx.update(|cx| {
1641 project.update(cx, |project, cx| {
1642 project.find_or_create_worktree(path!("/external-src"), false, cx)
1643 })
1644 })
1645 .detach();
1646 cx.background_executor.run_until_parked();
1647
1648 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1649 let worktree_id = cx.read(|cx| {
1650 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1651 assert_eq!(worktrees.len(), 1,);
1652
1653 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1654 });
1655 workspace
1656 .update_in(cx, |workspace, window, cx| {
1657 workspace.open_abs_path(
1658 PathBuf::from(path!("/external-src/test/third.rs")),
1659 OpenOptions {
1660 visible: Some(OpenVisible::None),
1661 ..Default::default()
1662 },
1663 window,
1664 cx,
1665 )
1666 })
1667 .detach();
1668 cx.background_executor.run_until_parked();
1669 let external_worktree_id = cx.read(|cx| {
1670 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1671 assert_eq!(
1672 worktrees.len(),
1673 2,
1674 "External file should get opened in a new worktree"
1675 );
1676
1677 WorktreeId::from_usize(
1678 worktrees
1679 .into_iter()
1680 .find(|worktree| worktree.entity_id().as_u64() as usize != worktree_id.to_usize())
1681 .expect("New worktree should have a different id")
1682 .entity_id()
1683 .as_u64() as usize,
1684 )
1685 });
1686 cx.dispatch_action(workspace::CloseActiveItem {
1687 save_intent: None,
1688 close_pinned: false,
1689 });
1690
1691 let initial_history_items =
1692 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1693 assert_eq!(
1694 initial_history_items,
1695 vec![FoundPath::new(
1696 ProjectPath {
1697 worktree_id: external_worktree_id,
1698 path: rel_path("").into(),
1699 },
1700 PathBuf::from(path!("/external-src/test/third.rs"))
1701 )],
1702 "Should show external file with its full path in the history after it was open"
1703 );
1704
1705 let updated_history_items =
1706 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1707 assert_eq!(
1708 updated_history_items,
1709 vec![
1710 FoundPath::new(
1711 ProjectPath {
1712 worktree_id,
1713 path: rel_path("test/second.rs").into(),
1714 },
1715 PathBuf::from(path!("/src/test/second.rs"))
1716 ),
1717 FoundPath::new(
1718 ProjectPath {
1719 worktree_id: external_worktree_id,
1720 path: rel_path("").into(),
1721 },
1722 PathBuf::from(path!("/external-src/test/third.rs"))
1723 ),
1724 ],
1725 "Should keep external file with history updates",
1726 );
1727}
1728
1729#[gpui::test]
1730async fn test_toggle_panel_new_selections(cx: &mut gpui::TestAppContext) {
1731 let app_state = init_test(cx);
1732
1733 app_state
1734 .fs
1735 .as_fake()
1736 .insert_tree(
1737 path!("/src"),
1738 json!({
1739 "test": {
1740 "first.rs": "// First Rust file",
1741 "second.rs": "// Second Rust file",
1742 "third.rs": "// Third Rust file",
1743 }
1744 }),
1745 )
1746 .await;
1747
1748 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1749 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1750
1751 // generate some history to select from
1752 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1753 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1754 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1755 let current_history = open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1756
1757 for expected_selected_index in 0..current_history.len() {
1758 cx.dispatch_action(ToggleFileFinder::default());
1759 let picker = active_file_picker(&workspace, cx);
1760 let selected_index = picker.update(cx, |picker, _| picker.delegate.selected_index());
1761 assert_eq!(
1762 selected_index, expected_selected_index,
1763 "Should select the next item in the history"
1764 );
1765 }
1766
1767 cx.dispatch_action(ToggleFileFinder::default());
1768 let selected_index = workspace.update(cx, |workspace, cx| {
1769 workspace
1770 .active_modal::<FileFinder>(cx)
1771 .unwrap()
1772 .read(cx)
1773 .picker
1774 .read(cx)
1775 .delegate
1776 .selected_index()
1777 });
1778 assert_eq!(
1779 selected_index, 0,
1780 "Should wrap around the history and start all over"
1781 );
1782}
1783
1784#[gpui::test]
1785async fn test_search_preserves_history_items(cx: &mut gpui::TestAppContext) {
1786 let app_state = init_test(cx);
1787
1788 app_state
1789 .fs
1790 .as_fake()
1791 .insert_tree(
1792 path!("/src"),
1793 json!({
1794 "test": {
1795 "first.rs": "// First Rust file",
1796 "second.rs": "// Second Rust file",
1797 "third.rs": "// Third Rust file",
1798 "fourth.rs": "// Fourth Rust file",
1799 }
1800 }),
1801 )
1802 .await;
1803
1804 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1805 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1806 let worktree_id = cx.read(|cx| {
1807 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1808 assert_eq!(worktrees.len(), 1,);
1809
1810 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1811 });
1812
1813 // generate some history to select from
1814 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1815 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1816 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1817 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1818
1819 let finder = open_file_picker(&workspace, cx);
1820 let first_query = "f";
1821 finder
1822 .update_in(cx, |finder, window, cx| {
1823 finder
1824 .delegate
1825 .update_matches(first_query.to_string(), window, cx)
1826 })
1827 .await;
1828 finder.update(cx, |picker, _| {
1829 let matches = collect_search_matches(picker);
1830 assert_eq!(matches.history.len(), 1, "Only one history item contains {first_query}, it should be present and others should be filtered out");
1831 let history_match = matches.history_found_paths.first().expect("Should have path matches for history items after querying");
1832 assert_eq!(history_match, &FoundPath::new(
1833 ProjectPath {
1834 worktree_id,
1835 path: rel_path("test/first.rs").into(),
1836 },
1837 PathBuf::from(path!("/src/test/first.rs")),
1838 ));
1839 assert_eq!(matches.search.len(), 1, "Only one non-history item contains {first_query}, it should be present");
1840 assert_eq!(matches.search.first().unwrap().as_ref(), rel_path("test/fourth.rs"));
1841 });
1842
1843 let second_query = "fsdasdsa";
1844 let finder = active_file_picker(&workspace, cx);
1845 finder
1846 .update_in(cx, |finder, window, cx| {
1847 finder
1848 .delegate
1849 .update_matches(second_query.to_string(), window, cx)
1850 })
1851 .await;
1852 finder.update(cx, |picker, _| {
1853 assert!(
1854 collect_search_matches(picker)
1855 .search_paths_only()
1856 .is_empty(),
1857 "No search entries should match {second_query}"
1858 );
1859 });
1860
1861 let first_query_again = first_query;
1862
1863 let finder = active_file_picker(&workspace, cx);
1864 finder
1865 .update_in(cx, |finder, window, cx| {
1866 finder
1867 .delegate
1868 .update_matches(first_query_again.to_string(), window, cx)
1869 })
1870 .await;
1871 finder.update(cx, |picker, _| {
1872 let matches = collect_search_matches(picker);
1873 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");
1874 let history_match = matches.history_found_paths.first().expect("Should have path matches for history items after querying");
1875 assert_eq!(history_match, &FoundPath::new(
1876 ProjectPath {
1877 worktree_id,
1878 path: rel_path("test/first.rs").into(),
1879 },
1880 PathBuf::from(path!("/src/test/first.rs"))
1881 ));
1882 assert_eq!(matches.search.len(), 1, "Only one non-history item contains {first_query_again}, it should be present, even after non-matching query");
1883 assert_eq!(matches.search.first().unwrap().as_ref(), rel_path("test/fourth.rs"));
1884 });
1885}
1886
1887#[gpui::test]
1888async fn test_search_sorts_history_items(cx: &mut gpui::TestAppContext) {
1889 let app_state = init_test(cx);
1890
1891 app_state
1892 .fs
1893 .as_fake()
1894 .insert_tree(
1895 path!("/root"),
1896 json!({
1897 "test": {
1898 "1_qw": "// First file that matches the query",
1899 "2_second": "// Second file",
1900 "3_third": "// Third file",
1901 "4_fourth": "// Fourth file",
1902 "5_qwqwqw": "// A file with 3 more matches than the first one",
1903 "6_qwqwqw": "// Same query matches as above, but closer to the end of the list due to the name",
1904 "7_qwqwqw": "// One more, same amount of query matches as above",
1905 }
1906 }),
1907 )
1908 .await;
1909
1910 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
1911 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1912 // generate some history to select from
1913 open_close_queried_buffer("1", 1, "1_qw", &workspace, cx).await;
1914 open_close_queried_buffer("2", 1, "2_second", &workspace, cx).await;
1915 open_close_queried_buffer("3", 1, "3_third", &workspace, cx).await;
1916 open_close_queried_buffer("2", 1, "2_second", &workspace, cx).await;
1917 open_close_queried_buffer("6", 1, "6_qwqwqw", &workspace, cx).await;
1918
1919 let finder = open_file_picker(&workspace, cx);
1920 let query = "qw";
1921 finder
1922 .update_in(cx, |finder, window, cx| {
1923 finder
1924 .delegate
1925 .update_matches(query.to_string(), window, cx)
1926 })
1927 .await;
1928 finder.update(cx, |finder, _| {
1929 let search_matches = collect_search_matches(finder);
1930 assert_eq!(
1931 search_matches.history,
1932 vec![
1933 rel_path("test/1_qw").into(),
1934 rel_path("test/6_qwqwqw").into()
1935 ],
1936 );
1937 assert_eq!(
1938 search_matches.search,
1939 vec![
1940 rel_path("test/5_qwqwqw").into(),
1941 rel_path("test/7_qwqwqw").into()
1942 ],
1943 );
1944 });
1945}
1946
1947#[gpui::test]
1948async fn test_select_current_open_file_when_no_history(cx: &mut gpui::TestAppContext) {
1949 let app_state = init_test(cx);
1950
1951 app_state
1952 .fs
1953 .as_fake()
1954 .insert_tree(
1955 path!("/root"),
1956 json!({
1957 "test": {
1958 "1_qw": "",
1959 }
1960 }),
1961 )
1962 .await;
1963
1964 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
1965 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1966 // Open new buffer
1967 open_queried_buffer("1", 1, "1_qw", &workspace, cx).await;
1968
1969 let picker = open_file_picker(&workspace, cx);
1970 picker.update(cx, |finder, _| {
1971 assert_match_selection(finder, 0, "1_qw");
1972 });
1973}
1974
1975#[gpui::test]
1976async fn test_keep_opened_file_on_top_of_search_results_and_select_next_one(
1977 cx: &mut TestAppContext,
1978) {
1979 let app_state = init_test(cx);
1980
1981 app_state
1982 .fs
1983 .as_fake()
1984 .insert_tree(
1985 path!("/src"),
1986 json!({
1987 "test": {
1988 "bar.rs": "// Bar file",
1989 "lib.rs": "// Lib file",
1990 "maaa.rs": "// Maaaaaaa",
1991 "main.rs": "// Main file",
1992 "moo.rs": "// Moooooo",
1993 }
1994 }),
1995 )
1996 .await;
1997
1998 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1999 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2000
2001 open_close_queried_buffer("bar", 1, "bar.rs", &workspace, cx).await;
2002 open_close_queried_buffer("lib", 1, "lib.rs", &workspace, cx).await;
2003 open_queried_buffer("main", 1, "main.rs", &workspace, cx).await;
2004
2005 // main.rs is on top, previously used is selected
2006 let picker = open_file_picker(&workspace, cx);
2007 picker.update(cx, |finder, _| {
2008 assert_eq!(finder.delegate.matches.len(), 3);
2009 assert_match_selection(finder, 0, "main.rs");
2010 assert_match_at_position(finder, 1, "lib.rs");
2011 assert_match_at_position(finder, 2, "bar.rs");
2012 });
2013
2014 // all files match, main.rs is still on top, but the second item is selected
2015 picker
2016 .update_in(cx, |finder, window, cx| {
2017 finder
2018 .delegate
2019 .update_matches(".rs".to_string(), window, cx)
2020 })
2021 .await;
2022 picker.update(cx, |finder, _| {
2023 assert_eq!(finder.delegate.matches.len(), 6);
2024 assert_match_at_position(finder, 0, "main.rs");
2025 assert_match_selection(finder, 1, "bar.rs");
2026 assert_match_at_position(finder, 2, "lib.rs");
2027 assert_match_at_position(finder, 3, "moo.rs");
2028 assert_match_at_position(finder, 4, "maaa.rs");
2029 assert_match_at_position(finder, 5, ".rs");
2030 });
2031
2032 // main.rs is not among matches, select top item
2033 picker
2034 .update_in(cx, |finder, window, cx| {
2035 finder.delegate.update_matches("b".to_string(), window, cx)
2036 })
2037 .await;
2038 picker.update(cx, |finder, _| {
2039 assert_eq!(finder.delegate.matches.len(), 3);
2040 assert_match_at_position(finder, 0, "bar.rs");
2041 assert_match_at_position(finder, 1, "lib.rs");
2042 assert_match_at_position(finder, 2, "b");
2043 });
2044
2045 // main.rs is back, put it on top and select next item
2046 picker
2047 .update_in(cx, |finder, window, cx| {
2048 finder.delegate.update_matches("m".to_string(), window, cx)
2049 })
2050 .await;
2051 picker.update(cx, |finder, _| {
2052 assert_eq!(finder.delegate.matches.len(), 4);
2053 assert_match_at_position(finder, 0, "main.rs");
2054 assert_match_selection(finder, 1, "moo.rs");
2055 assert_match_at_position(finder, 2, "maaa.rs");
2056 assert_match_at_position(finder, 3, "m");
2057 });
2058
2059 // get back to the initial state
2060 picker
2061 .update_in(cx, |finder, window, cx| {
2062 finder.delegate.update_matches("".to_string(), window, cx)
2063 })
2064 .await;
2065 picker.update(cx, |finder, _| {
2066 assert_eq!(finder.delegate.matches.len(), 3);
2067 assert_match_selection(finder, 0, "main.rs");
2068 assert_match_at_position(finder, 1, "lib.rs");
2069 assert_match_at_position(finder, 2, "bar.rs");
2070 });
2071}
2072
2073#[gpui::test]
2074async fn test_setting_auto_select_first_and_select_active_file(cx: &mut TestAppContext) {
2075 let app_state = init_test(cx);
2076
2077 cx.update(|cx| {
2078 let settings = *FileFinderSettings::get_global(cx);
2079
2080 FileFinderSettings::override_global(
2081 FileFinderSettings {
2082 skip_focus_for_active_in_search: false,
2083 ..settings
2084 },
2085 cx,
2086 );
2087 });
2088
2089 app_state
2090 .fs
2091 .as_fake()
2092 .insert_tree(
2093 path!("/src"),
2094 json!({
2095 "test": {
2096 "bar.rs": "// Bar file",
2097 "lib.rs": "// Lib file",
2098 "maaa.rs": "// Maaaaaaa",
2099 "main.rs": "// Main file",
2100 "moo.rs": "// Moooooo",
2101 }
2102 }),
2103 )
2104 .await;
2105
2106 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
2107 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2108
2109 open_close_queried_buffer("bar", 1, "bar.rs", &workspace, cx).await;
2110 open_close_queried_buffer("lib", 1, "lib.rs", &workspace, cx).await;
2111 open_queried_buffer("main", 1, "main.rs", &workspace, cx).await;
2112
2113 // main.rs is on top, previously used is selected
2114 let picker = open_file_picker(&workspace, cx);
2115 picker.update(cx, |finder, _| {
2116 assert_eq!(finder.delegate.matches.len(), 3);
2117 assert_match_selection(finder, 0, "main.rs");
2118 assert_match_at_position(finder, 1, "lib.rs");
2119 assert_match_at_position(finder, 2, "bar.rs");
2120 });
2121
2122 // all files match, main.rs is on top, and is selected
2123 picker
2124 .update_in(cx, |finder, window, cx| {
2125 finder
2126 .delegate
2127 .update_matches(".rs".to_string(), window, cx)
2128 })
2129 .await;
2130 picker.update(cx, |finder, _| {
2131 assert_eq!(finder.delegate.matches.len(), 6);
2132 assert_match_selection(finder, 0, "main.rs");
2133 assert_match_at_position(finder, 1, "bar.rs");
2134 assert_match_at_position(finder, 2, "lib.rs");
2135 assert_match_at_position(finder, 3, "moo.rs");
2136 assert_match_at_position(finder, 4, "maaa.rs");
2137 assert_match_at_position(finder, 5, ".rs");
2138 });
2139}
2140
2141#[gpui::test]
2142async fn test_non_separate_history_items(cx: &mut TestAppContext) {
2143 let app_state = init_test(cx);
2144
2145 app_state
2146 .fs
2147 .as_fake()
2148 .insert_tree(
2149 path!("/src"),
2150 json!({
2151 "test": {
2152 "bar.rs": "// Bar file",
2153 "lib.rs": "// Lib file",
2154 "maaa.rs": "// Maaaaaaa",
2155 "main.rs": "// Main file",
2156 "moo.rs": "// Moooooo",
2157 }
2158 }),
2159 )
2160 .await;
2161
2162 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
2163 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2164
2165 open_close_queried_buffer("bar", 1, "bar.rs", &workspace, cx).await;
2166 open_close_queried_buffer("lib", 1, "lib.rs", &workspace, cx).await;
2167 open_queried_buffer("main", 1, "main.rs", &workspace, cx).await;
2168
2169 cx.dispatch_action(ToggleFileFinder::default());
2170 let picker = active_file_picker(&workspace, cx);
2171 // main.rs is on top, previously used is selected
2172 picker.update(cx, |finder, _| {
2173 assert_eq!(finder.delegate.matches.len(), 3);
2174 assert_match_selection(finder, 0, "main.rs");
2175 assert_match_at_position(finder, 1, "lib.rs");
2176 assert_match_at_position(finder, 2, "bar.rs");
2177 });
2178
2179 // all files match, main.rs is still on top, but the second item is selected
2180 picker
2181 .update_in(cx, |finder, window, cx| {
2182 finder
2183 .delegate
2184 .update_matches(".rs".to_string(), window, cx)
2185 })
2186 .await;
2187 picker.update(cx, |finder, _| {
2188 assert_eq!(finder.delegate.matches.len(), 6);
2189 assert_match_at_position(finder, 0, "main.rs");
2190 assert_match_selection(finder, 1, "moo.rs");
2191 assert_match_at_position(finder, 2, "bar.rs");
2192 assert_match_at_position(finder, 3, "lib.rs");
2193 assert_match_at_position(finder, 4, "maaa.rs");
2194 assert_match_at_position(finder, 5, ".rs");
2195 });
2196
2197 // main.rs is not among matches, select top item
2198 picker
2199 .update_in(cx, |finder, window, cx| {
2200 finder.delegate.update_matches("b".to_string(), window, cx)
2201 })
2202 .await;
2203 picker.update(cx, |finder, _| {
2204 assert_eq!(finder.delegate.matches.len(), 3);
2205 assert_match_at_position(finder, 0, "bar.rs");
2206 assert_match_at_position(finder, 1, "lib.rs");
2207 assert_match_at_position(finder, 2, "b");
2208 });
2209
2210 // main.rs is back, put it on top and select next item
2211 picker
2212 .update_in(cx, |finder, window, cx| {
2213 finder.delegate.update_matches("m".to_string(), window, cx)
2214 })
2215 .await;
2216 picker.update(cx, |finder, _| {
2217 assert_eq!(finder.delegate.matches.len(), 4);
2218 assert_match_at_position(finder, 0, "main.rs");
2219 assert_match_selection(finder, 1, "moo.rs");
2220 assert_match_at_position(finder, 2, "maaa.rs");
2221 assert_match_at_position(finder, 3, "m");
2222 });
2223
2224 // get back to the initial state
2225 picker
2226 .update_in(cx, |finder, window, cx| {
2227 finder.delegate.update_matches("".to_string(), window, cx)
2228 })
2229 .await;
2230 picker.update(cx, |finder, _| {
2231 assert_eq!(finder.delegate.matches.len(), 3);
2232 assert_match_selection(finder, 0, "main.rs");
2233 assert_match_at_position(finder, 1, "lib.rs");
2234 assert_match_at_position(finder, 2, "bar.rs");
2235 });
2236}
2237
2238#[gpui::test]
2239async fn test_history_items_shown_in_order_of_open(cx: &mut TestAppContext) {
2240 let app_state = init_test(cx);
2241
2242 app_state
2243 .fs
2244 .as_fake()
2245 .insert_tree(
2246 path!("/test"),
2247 json!({
2248 "test": {
2249 "1.txt": "// One",
2250 "2.txt": "// Two",
2251 "3.txt": "// Three",
2252 }
2253 }),
2254 )
2255 .await;
2256
2257 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
2258 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2259
2260 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
2261 open_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
2262 open_queried_buffer("3", 1, "3.txt", &workspace, cx).await;
2263
2264 let picker = open_file_picker(&workspace, cx);
2265 picker.update(cx, |finder, _| {
2266 assert_eq!(finder.delegate.matches.len(), 3);
2267 assert_match_selection(finder, 0, "3.txt");
2268 assert_match_at_position(finder, 1, "2.txt");
2269 assert_match_at_position(finder, 2, "1.txt");
2270 });
2271
2272 cx.dispatch_action(SelectNext);
2273 cx.dispatch_action(Confirm); // Open 2.txt
2274
2275 let picker = open_file_picker(&workspace, cx);
2276 picker.update(cx, |finder, _| {
2277 assert_eq!(finder.delegate.matches.len(), 3);
2278 assert_match_selection(finder, 0, "2.txt");
2279 assert_match_at_position(finder, 1, "3.txt");
2280 assert_match_at_position(finder, 2, "1.txt");
2281 });
2282
2283 cx.dispatch_action(SelectNext);
2284 cx.dispatch_action(SelectNext);
2285 cx.dispatch_action(Confirm); // Open 1.txt
2286
2287 let picker = open_file_picker(&workspace, cx);
2288 picker.update(cx, |finder, _| {
2289 assert_eq!(finder.delegate.matches.len(), 3);
2290 assert_match_selection(finder, 0, "1.txt");
2291 assert_match_at_position(finder, 1, "2.txt");
2292 assert_match_at_position(finder, 2, "3.txt");
2293 });
2294}
2295
2296#[gpui::test]
2297async fn test_selected_history_item_stays_selected_on_worktree_updated(cx: &mut TestAppContext) {
2298 let app_state = init_test(cx);
2299
2300 app_state
2301 .fs
2302 .as_fake()
2303 .insert_tree(
2304 path!("/test"),
2305 json!({
2306 "test": {
2307 "1.txt": "// One",
2308 "2.txt": "// Two",
2309 "3.txt": "// Three",
2310 }
2311 }),
2312 )
2313 .await;
2314
2315 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
2316 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2317
2318 open_close_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
2319 open_close_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
2320 open_close_queried_buffer("3", 1, "3.txt", &workspace, cx).await;
2321
2322 let picker = open_file_picker(&workspace, cx);
2323 picker.update(cx, |finder, _| {
2324 assert_eq!(finder.delegate.matches.len(), 3);
2325 assert_match_selection(finder, 0, "3.txt");
2326 assert_match_at_position(finder, 1, "2.txt");
2327 assert_match_at_position(finder, 2, "1.txt");
2328 });
2329
2330 cx.dispatch_action(SelectNext);
2331
2332 // Add more files to the worktree to trigger update matches
2333 for i in 0..5 {
2334 let filename = if cfg!(windows) {
2335 format!("C:/test/{}.txt", 4 + i)
2336 } else {
2337 format!("/test/{}.txt", 4 + i)
2338 };
2339 app_state
2340 .fs
2341 .create_file(Path::new(&filename), Default::default())
2342 .await
2343 .expect("unable to create file");
2344 }
2345
2346 cx.executor().advance_clock(FS_WATCH_LATENCY);
2347
2348 picker.update(cx, |finder, _| {
2349 assert_eq!(finder.delegate.matches.len(), 3);
2350 assert_match_at_position(finder, 0, "3.txt");
2351 assert_match_selection(finder, 1, "2.txt");
2352 assert_match_at_position(finder, 2, "1.txt");
2353 });
2354}
2355
2356#[gpui::test]
2357async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppContext) {
2358 let app_state = init_test(cx);
2359
2360 app_state
2361 .fs
2362 .as_fake()
2363 .insert_tree(
2364 path!("/src"),
2365 json!({
2366 "collab_ui": {
2367 "first.rs": "// First Rust file",
2368 "second.rs": "// Second Rust file",
2369 "third.rs": "// Third Rust file",
2370 "collab_ui.rs": "// Fourth Rust file",
2371 }
2372 }),
2373 )
2374 .await;
2375
2376 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
2377 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2378 // generate some history to select from
2379 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
2380 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
2381 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
2382 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
2383
2384 let finder = open_file_picker(&workspace, cx);
2385 let query = "collab_ui";
2386 cx.simulate_input(query);
2387 finder.update(cx, |picker, _| {
2388 let search_entries = collect_search_matches(picker).search_paths_only();
2389 assert_eq!(
2390 search_entries,
2391 vec![
2392 rel_path("collab_ui/collab_ui.rs").into(),
2393 rel_path("collab_ui/first.rs").into(),
2394 rel_path("collab_ui/third.rs").into(),
2395 rel_path("collab_ui/second.rs").into(),
2396 ],
2397 "Despite all search results having the same directory name, the most matching one should be on top"
2398 );
2399 });
2400}
2401
2402#[gpui::test]
2403async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) {
2404 let app_state = init_test(cx);
2405
2406 app_state
2407 .fs
2408 .as_fake()
2409 .insert_tree(
2410 path!("/src"),
2411 json!({
2412 "test": {
2413 "first.rs": "// First Rust file",
2414 "nonexistent.rs": "// Second Rust file",
2415 "third.rs": "// Third Rust file",
2416 }
2417 }),
2418 )
2419 .await;
2420
2421 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
2422 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); // generate some history to select from
2423 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
2424 open_close_queried_buffer("non", 1, "nonexistent.rs", &workspace, cx).await;
2425 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
2426 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
2427 app_state
2428 .fs
2429 .remove_file(
2430 Path::new(path!("/src/test/nonexistent.rs")),
2431 RemoveOptions::default(),
2432 )
2433 .await
2434 .unwrap();
2435 cx.run_until_parked();
2436
2437 let picker = open_file_picker(&workspace, cx);
2438 cx.simulate_input("rs");
2439
2440 picker.update(cx, |picker, _| {
2441 assert_eq!(
2442 collect_search_matches(picker).history,
2443 vec![
2444 rel_path("test/first.rs").into(),
2445 rel_path("test/third.rs").into()
2446 ],
2447 "Should have all opened files in the history, except the ones that do not exist on disk"
2448 );
2449 });
2450}
2451
2452#[gpui::test]
2453async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAppContext) {
2454 let app_state = init_test(cx);
2455
2456 app_state
2457 .fs
2458 .as_fake()
2459 .insert_tree(
2460 "/src",
2461 json!({
2462 "lib.rs": "// Lib file",
2463 "main.rs": "// Bar file",
2464 "read.me": "// Readme file",
2465 }),
2466 )
2467 .await;
2468
2469 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
2470 let (workspace, cx) =
2471 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2472
2473 // Initial state
2474 let picker = open_file_picker(&workspace, cx);
2475 cx.simulate_input("rs");
2476 picker.update(cx, |finder, _| {
2477 assert_eq!(finder.delegate.matches.len(), 3);
2478 assert_match_at_position(finder, 0, "lib.rs");
2479 assert_match_at_position(finder, 1, "main.rs");
2480 assert_match_at_position(finder, 2, "rs");
2481 });
2482 // Delete main.rs
2483 app_state
2484 .fs
2485 .remove_file("/src/main.rs".as_ref(), Default::default())
2486 .await
2487 .expect("unable to remove file");
2488 cx.executor().advance_clock(FS_WATCH_LATENCY);
2489
2490 // main.rs is in not among search results anymore
2491 picker.update(cx, |finder, _| {
2492 assert_eq!(finder.delegate.matches.len(), 2);
2493 assert_match_at_position(finder, 0, "lib.rs");
2494 assert_match_at_position(finder, 1, "rs");
2495 });
2496
2497 // Create util.rs
2498 app_state
2499 .fs
2500 .create_file("/src/util.rs".as_ref(), Default::default())
2501 .await
2502 .expect("unable to create file");
2503 cx.executor().advance_clock(FS_WATCH_LATENCY);
2504
2505 // util.rs is among search results
2506 picker.update(cx, |finder, _| {
2507 assert_eq!(finder.delegate.matches.len(), 3);
2508 assert_match_at_position(finder, 0, "lib.rs");
2509 assert_match_at_position(finder, 1, "util.rs");
2510 assert_match_at_position(finder, 2, "rs");
2511 });
2512}
2513
2514#[gpui::test]
2515async fn test_search_results_refreshed_on_standalone_file_creation(cx: &mut gpui::TestAppContext) {
2516 let app_state = init_test(cx);
2517
2518 app_state
2519 .fs
2520 .as_fake()
2521 .insert_tree(
2522 "/src",
2523 json!({
2524 "lib.rs": "// Lib file",
2525 "main.rs": "// Bar file",
2526 "read.me": "// Readme file",
2527 }),
2528 )
2529 .await;
2530 app_state
2531 .fs
2532 .as_fake()
2533 .insert_tree(
2534 "/test",
2535 json!({
2536 "new.rs": "// New file",
2537 }),
2538 )
2539 .await;
2540
2541 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
2542 let (workspace, cx) =
2543 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2544
2545 cx.update(|_, cx| {
2546 open_paths(
2547 &[PathBuf::from(path!("/test/new.rs"))],
2548 app_state,
2549 workspace::OpenOptions::default(),
2550 cx,
2551 )
2552 })
2553 .await
2554 .unwrap();
2555 assert_eq!(cx.update(|_, cx| cx.windows().len()), 1);
2556
2557 let initial_history = open_close_queried_buffer("new", 1, "new.rs", &workspace, cx).await;
2558 assert_eq!(
2559 initial_history.first().unwrap().absolute,
2560 PathBuf::from(path!("/test/new.rs")),
2561 "Should show 1st opened item in the history when opening the 2nd item"
2562 );
2563
2564 let history_after_first = open_close_queried_buffer("lib", 1, "lib.rs", &workspace, cx).await;
2565 assert_eq!(
2566 history_after_first.first().unwrap().absolute,
2567 PathBuf::from(path!("/test/new.rs")),
2568 "Should show 1st opened item in the history when opening the 2nd item"
2569 );
2570}
2571
2572#[gpui::test]
2573async fn test_search_results_refreshed_on_adding_and_removing_worktrees(
2574 cx: &mut gpui::TestAppContext,
2575) {
2576 let app_state = init_test(cx);
2577
2578 app_state
2579 .fs
2580 .as_fake()
2581 .insert_tree(
2582 "/test",
2583 json!({
2584 "project_1": {
2585 "bar.rs": "// Bar file",
2586 "lib.rs": "// Lib file",
2587 },
2588 "project_2": {
2589 "Cargo.toml": "// Cargo file",
2590 "main.rs": "// Main file",
2591 }
2592 }),
2593 )
2594 .await;
2595
2596 let project = Project::test(app_state.fs.clone(), ["/test/project_1".as_ref()], cx).await;
2597 let (workspace, cx) =
2598 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2599 let worktree_1_id = project.update(cx, |project, cx| {
2600 let worktree = project.worktrees(cx).last().expect("worktree not found");
2601 worktree.read(cx).id()
2602 });
2603
2604 // Initial state
2605 let picker = open_file_picker(&workspace, cx);
2606 cx.simulate_input("rs");
2607 picker.update(cx, |finder, _| {
2608 assert_eq!(finder.delegate.matches.len(), 3);
2609 assert_match_at_position(finder, 0, "bar.rs");
2610 assert_match_at_position(finder, 1, "lib.rs");
2611 assert_match_at_position(finder, 2, "rs");
2612 });
2613
2614 // Add new worktree
2615 project
2616 .update(cx, |project, cx| {
2617 project
2618 .find_or_create_worktree("/test/project_2", true, cx)
2619 .into_future()
2620 })
2621 .await
2622 .expect("unable to create workdir");
2623 cx.executor().advance_clock(FS_WATCH_LATENCY);
2624
2625 // main.rs is among search results
2626 picker.update(cx, |finder, _| {
2627 assert_eq!(finder.delegate.matches.len(), 4);
2628 assert_match_at_position(finder, 0, "bar.rs");
2629 assert_match_at_position(finder, 1, "lib.rs");
2630 assert_match_at_position(finder, 2, "main.rs");
2631 assert_match_at_position(finder, 3, "rs");
2632 });
2633
2634 // Remove the first worktree
2635 project.update(cx, |project, cx| {
2636 project.remove_worktree(worktree_1_id, cx);
2637 });
2638 cx.executor().advance_clock(FS_WATCH_LATENCY);
2639
2640 // Files from the first worktree are not in the search results anymore
2641 picker.update(cx, |finder, _| {
2642 assert_eq!(finder.delegate.matches.len(), 2);
2643 assert_match_at_position(finder, 0, "main.rs");
2644 assert_match_at_position(finder, 1, "rs");
2645 });
2646}
2647
2648#[gpui::test]
2649async fn test_history_items_uniqueness_for_multiple_worktree_open_all_files(
2650 cx: &mut TestAppContext,
2651) {
2652 let app_state = init_test(cx);
2653 app_state
2654 .fs
2655 .as_fake()
2656 .insert_tree(
2657 path!("/repo1"),
2658 json!({
2659 "package.json": r#"{"name": "repo1"}"#,
2660 "src": {
2661 "index.js": "// Repo 1 index",
2662 }
2663 }),
2664 )
2665 .await;
2666
2667 app_state
2668 .fs
2669 .as_fake()
2670 .insert_tree(
2671 path!("/repo2"),
2672 json!({
2673 "package.json": r#"{"name": "repo2"}"#,
2674 "src": {
2675 "index.js": "// Repo 2 index",
2676 }
2677 }),
2678 )
2679 .await;
2680
2681 let project = Project::test(
2682 app_state.fs.clone(),
2683 [path!("/repo1").as_ref(), path!("/repo2").as_ref()],
2684 cx,
2685 )
2686 .await;
2687
2688 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2689 let (worktree_id1, worktree_id2) = cx.read(|cx| {
2690 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
2691 (worktrees[0].read(cx).id(), worktrees[1].read(cx).id())
2692 });
2693
2694 workspace
2695 .update_in(cx, |workspace, window, cx| {
2696 workspace.open_path(
2697 ProjectPath {
2698 worktree_id: worktree_id1,
2699 path: rel_path("package.json").into(),
2700 },
2701 None,
2702 true,
2703 window,
2704 cx,
2705 )
2706 })
2707 .await
2708 .unwrap();
2709
2710 cx.dispatch_action(workspace::CloseActiveItem {
2711 save_intent: None,
2712 close_pinned: false,
2713 });
2714 workspace
2715 .update_in(cx, |workspace, window, cx| {
2716 workspace.open_path(
2717 ProjectPath {
2718 worktree_id: worktree_id2,
2719 path: rel_path("package.json").into(),
2720 },
2721 None,
2722 true,
2723 window,
2724 cx,
2725 )
2726 })
2727 .await
2728 .unwrap();
2729
2730 cx.dispatch_action(workspace::CloseActiveItem {
2731 save_intent: None,
2732 close_pinned: false,
2733 });
2734
2735 let picker = open_file_picker(&workspace, cx);
2736 cx.simulate_input("package.json");
2737
2738 picker.update(cx, |finder, _| {
2739 let matches = &finder.delegate.matches.matches;
2740
2741 assert_eq!(
2742 matches.len(),
2743 2,
2744 "Expected 1 history match + 1 search matches, but got {} matches: {:?}",
2745 matches.len(),
2746 matches
2747 );
2748
2749 assert_matches!(matches[0], Match::History { .. });
2750
2751 let search_matches = collect_search_matches(finder);
2752 assert_eq!(
2753 search_matches.history.len(),
2754 2,
2755 "Should have exactly 2 history match"
2756 );
2757 assert_eq!(
2758 search_matches.search.len(),
2759 0,
2760 "Should have exactly 0 search match (because we already opened the 2 package.json)"
2761 );
2762
2763 if let Match::History { path, panel_match } = &matches[0] {
2764 assert_eq!(path.project.worktree_id, worktree_id2);
2765 assert_eq!(path.project.path.as_ref(), rel_path("package.json"));
2766 let panel_match = panel_match.as_ref().unwrap();
2767 assert_eq!(panel_match.0.path_prefix, rel_path("repo2").into());
2768 assert_eq!(panel_match.0.path, rel_path("package.json").into());
2769 assert_eq!(
2770 panel_match.0.positions,
2771 vec![6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]
2772 );
2773 }
2774
2775 if let Match::History { path, panel_match } = &matches[1] {
2776 assert_eq!(path.project.worktree_id, worktree_id1);
2777 assert_eq!(path.project.path.as_ref(), rel_path("package.json"));
2778 let panel_match = panel_match.as_ref().unwrap();
2779 assert_eq!(panel_match.0.path_prefix, rel_path("repo1").into());
2780 assert_eq!(panel_match.0.path, rel_path("package.json").into());
2781 assert_eq!(
2782 panel_match.0.positions,
2783 vec![6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]
2784 );
2785 }
2786 });
2787}
2788
2789#[gpui::test]
2790async fn test_selected_match_stays_selected_after_matches_refreshed(cx: &mut gpui::TestAppContext) {
2791 let app_state = init_test(cx);
2792
2793 app_state.fs.as_fake().insert_tree("/src", json!({})).await;
2794
2795 app_state
2796 .fs
2797 .create_dir("/src/even".as_ref())
2798 .await
2799 .expect("unable to create dir");
2800
2801 let initial_files_num = 5;
2802 for i in 0..initial_files_num {
2803 let filename = format!("/src/even/file_{}.txt", 10 + i);
2804 app_state
2805 .fs
2806 .create_file(Path::new(&filename), Default::default())
2807 .await
2808 .expect("unable to create file");
2809 }
2810
2811 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
2812 let (workspace, cx) =
2813 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2814
2815 // Initial state
2816 let picker = open_file_picker(&workspace, cx);
2817 cx.simulate_input("file");
2818 let selected_index = 3;
2819 // Checking only the filename, not the whole path
2820 let selected_file = format!("file_{}.txt", 10 + selected_index);
2821 // Select even/file_13.txt
2822 for _ in 0..selected_index {
2823 cx.dispatch_action(SelectNext);
2824 }
2825
2826 picker.update(cx, |finder, _| {
2827 assert_match_selection(finder, selected_index, &selected_file)
2828 });
2829
2830 // Add more matches to the search results
2831 let files_to_add = 10;
2832 for i in 0..files_to_add {
2833 let filename = format!("/src/file_{}.txt", 20 + i);
2834 app_state
2835 .fs
2836 .create_file(Path::new(&filename), Default::default())
2837 .await
2838 .expect("unable to create file");
2839 }
2840 cx.executor().advance_clock(FS_WATCH_LATENCY);
2841
2842 // file_13.txt is still selected
2843 picker.update(cx, |finder, _| {
2844 let expected_selected_index = selected_index + files_to_add;
2845 assert_match_selection(finder, expected_selected_index, &selected_file);
2846 });
2847}
2848
2849#[gpui::test]
2850async fn test_first_match_selected_if_previous_one_is_not_in_the_match_list(
2851 cx: &mut gpui::TestAppContext,
2852) {
2853 let app_state = init_test(cx);
2854
2855 app_state
2856 .fs
2857 .as_fake()
2858 .insert_tree(
2859 "/src",
2860 json!({
2861 "file_1.txt": "// file_1",
2862 "file_2.txt": "// file_2",
2863 "file_3.txt": "// file_3",
2864 }),
2865 )
2866 .await;
2867
2868 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
2869 let (workspace, cx) =
2870 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2871
2872 // Initial state
2873 let picker = open_file_picker(&workspace, cx);
2874 cx.simulate_input("file");
2875 // Select even/file_2.txt
2876 cx.dispatch_action(SelectNext);
2877
2878 // Remove the selected entry
2879 app_state
2880 .fs
2881 .remove_file("/src/file_2.txt".as_ref(), Default::default())
2882 .await
2883 .expect("unable to remove file");
2884 cx.executor().advance_clock(FS_WATCH_LATENCY);
2885
2886 // file_1.txt is now selected
2887 picker.update(cx, |finder, _| {
2888 assert_match_selection(finder, 0, "file_1.txt");
2889 });
2890}
2891
2892#[gpui::test]
2893async fn test_keeps_file_finder_open_after_modifier_keys_release(cx: &mut gpui::TestAppContext) {
2894 let app_state = init_test(cx);
2895
2896 app_state
2897 .fs
2898 .as_fake()
2899 .insert_tree(
2900 path!("/test"),
2901 json!({
2902 "1.txt": "// One",
2903 }),
2904 )
2905 .await;
2906
2907 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
2908 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2909
2910 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
2911
2912 cx.simulate_modifiers_change(Modifiers::secondary_key());
2913 open_file_picker(&workspace, cx);
2914
2915 cx.simulate_modifiers_change(Modifiers::none());
2916 active_file_picker(&workspace, cx);
2917}
2918
2919#[gpui::test]
2920async fn test_opens_file_on_modifier_keys_release(cx: &mut gpui::TestAppContext) {
2921 let app_state = init_test(cx);
2922
2923 app_state
2924 .fs
2925 .as_fake()
2926 .insert_tree(
2927 path!("/test"),
2928 json!({
2929 "1.txt": "// One",
2930 "2.txt": "// Two",
2931 }),
2932 )
2933 .await;
2934
2935 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
2936 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2937
2938 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
2939 open_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
2940
2941 cx.simulate_modifiers_change(Modifiers::secondary_key());
2942 let picker = open_file_picker(&workspace, cx);
2943 picker.update(cx, |finder, _| {
2944 assert_eq!(finder.delegate.matches.len(), 2);
2945 assert_match_selection(finder, 0, "2.txt");
2946 assert_match_at_position(finder, 1, "1.txt");
2947 });
2948
2949 cx.dispatch_action(SelectNext);
2950 cx.simulate_modifiers_change(Modifiers::none());
2951 cx.read(|cx| {
2952 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
2953 assert_eq!(active_editor.read(cx).title(cx), "1.txt");
2954 });
2955}
2956
2957#[gpui::test]
2958async fn test_switches_between_release_norelease_modes_on_forward_nav(
2959 cx: &mut gpui::TestAppContext,
2960) {
2961 let app_state = init_test(cx);
2962
2963 app_state
2964 .fs
2965 .as_fake()
2966 .insert_tree(
2967 path!("/test"),
2968 json!({
2969 "1.txt": "// One",
2970 "2.txt": "// Two",
2971 }),
2972 )
2973 .await;
2974
2975 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
2976 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2977
2978 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
2979 open_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
2980
2981 // Open with a shortcut
2982 cx.simulate_modifiers_change(Modifiers::secondary_key());
2983 let picker = open_file_picker(&workspace, cx);
2984 picker.update(cx, |finder, _| {
2985 assert_eq!(finder.delegate.matches.len(), 2);
2986 assert_match_selection(finder, 0, "2.txt");
2987 assert_match_at_position(finder, 1, "1.txt");
2988 });
2989
2990 // Switch to navigating with other shortcuts
2991 // Don't open file on modifiers release
2992 cx.simulate_modifiers_change(Modifiers::control());
2993 cx.dispatch_action(SelectNext);
2994 cx.simulate_modifiers_change(Modifiers::none());
2995 picker.update(cx, |finder, _| {
2996 assert_eq!(finder.delegate.matches.len(), 2);
2997 assert_match_at_position(finder, 0, "2.txt");
2998 assert_match_selection(finder, 1, "1.txt");
2999 });
3000
3001 // Back to navigation with initial shortcut
3002 // Open file on modifiers release
3003 cx.simulate_modifiers_change(Modifiers::secondary_key());
3004 cx.dispatch_action(ToggleFileFinder::default());
3005 cx.simulate_modifiers_change(Modifiers::none());
3006 cx.read(|cx| {
3007 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
3008 assert_eq!(active_editor.read(cx).title(cx), "2.txt");
3009 });
3010}
3011
3012#[gpui::test]
3013async fn test_switches_between_release_norelease_modes_on_backward_nav(
3014 cx: &mut gpui::TestAppContext,
3015) {
3016 let app_state = init_test(cx);
3017
3018 app_state
3019 .fs
3020 .as_fake()
3021 .insert_tree(
3022 path!("/test"),
3023 json!({
3024 "1.txt": "// One",
3025 "2.txt": "// Two",
3026 "3.txt": "// Three"
3027 }),
3028 )
3029 .await;
3030
3031 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
3032 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
3033
3034 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
3035 open_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
3036 open_queried_buffer("3", 1, "3.txt", &workspace, cx).await;
3037
3038 // Open with a shortcut
3039 cx.simulate_modifiers_change(Modifiers::secondary_key());
3040 let picker = open_file_picker(&workspace, cx);
3041 picker.update(cx, |finder, _| {
3042 assert_eq!(finder.delegate.matches.len(), 3);
3043 assert_match_selection(finder, 0, "3.txt");
3044 assert_match_at_position(finder, 1, "2.txt");
3045 assert_match_at_position(finder, 2, "1.txt");
3046 });
3047
3048 // Switch to navigating with other shortcuts
3049 // Don't open file on modifiers release
3050 cx.simulate_modifiers_change(Modifiers::control());
3051 cx.dispatch_action(menu::SelectPrevious);
3052 cx.simulate_modifiers_change(Modifiers::none());
3053 picker.update(cx, |finder, _| {
3054 assert_eq!(finder.delegate.matches.len(), 3);
3055 assert_match_at_position(finder, 0, "3.txt");
3056 assert_match_at_position(finder, 1, "2.txt");
3057 assert_match_selection(finder, 2, "1.txt");
3058 });
3059
3060 // Back to navigation with initial shortcut
3061 // Open file on modifiers release
3062 cx.simulate_modifiers_change(Modifiers::secondary_key());
3063 cx.dispatch_action(SelectPrevious); // <-- File Finder's SelectPrevious, not menu's
3064 cx.simulate_modifiers_change(Modifiers::none());
3065 cx.read(|cx| {
3066 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
3067 assert_eq!(active_editor.read(cx).title(cx), "3.txt");
3068 });
3069}
3070
3071#[gpui::test]
3072async fn test_extending_modifiers_does_not_confirm_selection(cx: &mut gpui::TestAppContext) {
3073 let app_state = init_test(cx);
3074
3075 app_state
3076 .fs
3077 .as_fake()
3078 .insert_tree(
3079 path!("/test"),
3080 json!({
3081 "1.txt": "// One",
3082 }),
3083 )
3084 .await;
3085
3086 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
3087 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
3088
3089 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
3090
3091 cx.simulate_modifiers_change(Modifiers::secondary_key());
3092 open_file_picker(&workspace, cx);
3093
3094 cx.simulate_modifiers_change(Modifiers::command_shift());
3095 active_file_picker(&workspace, cx);
3096}
3097
3098#[gpui::test]
3099async fn test_repeat_toggle_action(cx: &mut gpui::TestAppContext) {
3100 let app_state = init_test(cx);
3101 app_state
3102 .fs
3103 .as_fake()
3104 .insert_tree(
3105 "/test",
3106 json!({
3107 "00.txt": "",
3108 "01.txt": "",
3109 "02.txt": "",
3110 "03.txt": "",
3111 "04.txt": "",
3112 "05.txt": "",
3113 }),
3114 )
3115 .await;
3116
3117 let project = Project::test(app_state.fs.clone(), ["/test".as_ref()], cx).await;
3118 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
3119
3120 cx.dispatch_action(ToggleFileFinder::default());
3121 let picker = active_file_picker(&workspace, cx);
3122
3123 picker.update_in(cx, |picker, window, cx| {
3124 picker.update_matches(".txt".to_string(), window, cx)
3125 });
3126
3127 cx.run_until_parked();
3128
3129 picker.update(cx, |picker, _| {
3130 assert_eq!(picker.delegate.matches.len(), 7);
3131 assert_eq!(picker.delegate.selected_index, 0);
3132 });
3133
3134 // When toggling repeatedly, the picker scrolls to reveal the selected item.
3135 cx.dispatch_action(ToggleFileFinder::default());
3136 cx.dispatch_action(ToggleFileFinder::default());
3137 cx.dispatch_action(ToggleFileFinder::default());
3138
3139 cx.run_until_parked();
3140
3141 picker.update(cx, |picker, _| {
3142 assert_eq!(picker.delegate.matches.len(), 7);
3143 assert_eq!(picker.delegate.selected_index, 3);
3144 });
3145}
3146
3147async fn open_close_queried_buffer(
3148 input: &str,
3149 expected_matches: usize,
3150 expected_editor_title: &str,
3151 workspace: &Entity<Workspace>,
3152 cx: &mut gpui::VisualTestContext,
3153) -> Vec<FoundPath> {
3154 let history_items = open_queried_buffer(
3155 input,
3156 expected_matches,
3157 expected_editor_title,
3158 workspace,
3159 cx,
3160 )
3161 .await;
3162
3163 cx.dispatch_action(workspace::CloseActiveItem {
3164 save_intent: None,
3165 close_pinned: false,
3166 });
3167
3168 history_items
3169}
3170
3171async fn open_queried_buffer(
3172 input: &str,
3173 expected_matches: usize,
3174 expected_editor_title: &str,
3175 workspace: &Entity<Workspace>,
3176 cx: &mut gpui::VisualTestContext,
3177) -> Vec<FoundPath> {
3178 let picker = open_file_picker(workspace, cx);
3179 cx.simulate_input(input);
3180
3181 let history_items = picker.update(cx, |finder, _| {
3182 assert_eq!(
3183 finder.delegate.matches.len(),
3184 expected_matches + 1, // +1 from CreateNew option
3185 "Unexpected number of matches found for query `{input}`, matches: {:?}",
3186 finder.delegate.matches
3187 );
3188 finder.delegate.history_items.clone()
3189 });
3190
3191 cx.dispatch_action(Confirm);
3192
3193 cx.read(|cx| {
3194 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
3195 let active_editor_title = active_editor.read(cx).title(cx);
3196 assert_eq!(
3197 expected_editor_title, active_editor_title,
3198 "Unexpected editor title for query `{input}`"
3199 );
3200 });
3201
3202 history_items
3203}
3204
3205fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
3206 cx.update(|cx| {
3207 let state = AppState::test(cx);
3208 theme::init(theme::LoadThemes::JustBase, cx);
3209 super::init(cx);
3210 editor::init(cx);
3211 state
3212 })
3213}
3214
3215fn test_path_position(test_str: &str) -> FileSearchQuery {
3216 let path_position = PathWithPosition::parse_str(test_str);
3217
3218 FileSearchQuery {
3219 raw_query: test_str.to_owned(),
3220 file_query_end: if path_position.path.to_str().unwrap() == test_str {
3221 None
3222 } else {
3223 Some(path_position.path.to_str().unwrap().len())
3224 },
3225 path_position,
3226 }
3227}
3228
3229fn build_find_picker(
3230 project: Entity<Project>,
3231 cx: &mut TestAppContext,
3232) -> (
3233 Entity<Picker<FileFinderDelegate>>,
3234 Entity<Workspace>,
3235 &mut VisualTestContext,
3236) {
3237 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
3238 let picker = open_file_picker(&workspace, cx);
3239 (picker, workspace, cx)
3240}
3241
3242#[track_caller]
3243fn open_file_picker(
3244 workspace: &Entity<Workspace>,
3245 cx: &mut VisualTestContext,
3246) -> Entity<Picker<FileFinderDelegate>> {
3247 cx.dispatch_action(ToggleFileFinder {
3248 separate_history: true,
3249 });
3250 active_file_picker(workspace, cx)
3251}
3252
3253#[track_caller]
3254fn active_file_picker(
3255 workspace: &Entity<Workspace>,
3256 cx: &mut VisualTestContext,
3257) -> Entity<Picker<FileFinderDelegate>> {
3258 workspace.update(cx, |workspace, cx| {
3259 workspace
3260 .active_modal::<FileFinder>(cx)
3261 .expect("file finder is not open")
3262 .read(cx)
3263 .picker
3264 .clone()
3265 })
3266}
3267
3268#[derive(Debug, Default)]
3269struct SearchEntries {
3270 history: Vec<Arc<RelPath>>,
3271 history_found_paths: Vec<FoundPath>,
3272 search: Vec<Arc<RelPath>>,
3273 search_matches: Vec<PathMatch>,
3274}
3275
3276impl SearchEntries {
3277 #[track_caller]
3278 fn search_paths_only(self) -> Vec<Arc<RelPath>> {
3279 assert!(
3280 self.history.is_empty(),
3281 "Should have no history matches, but got: {:?}",
3282 self.history
3283 );
3284 self.search
3285 }
3286
3287 #[track_caller]
3288 fn search_matches_only(self) -> Vec<PathMatch> {
3289 assert!(
3290 self.history.is_empty(),
3291 "Should have no history matches, but got: {:?}",
3292 self.history
3293 );
3294 self.search_matches
3295 }
3296}
3297
3298fn collect_search_matches(picker: &Picker<FileFinderDelegate>) -> SearchEntries {
3299 let mut search_entries = SearchEntries::default();
3300 for m in &picker.delegate.matches.matches {
3301 match &m {
3302 Match::History {
3303 path: history_path,
3304 panel_match: path_match,
3305 } => {
3306 if let Some(path_match) = path_match.as_ref() {
3307 search_entries
3308 .history
3309 .push(path_match.0.path_prefix.join(&path_match.0.path));
3310 } else {
3311 // This occurs when the query is empty and we show history matches
3312 // that are outside the project.
3313 panic!("currently not exercised in tests");
3314 }
3315 search_entries
3316 .history_found_paths
3317 .push(history_path.clone());
3318 }
3319 Match::Search(path_match) => {
3320 search_entries
3321 .search
3322 .push(path_match.0.path_prefix.join(&path_match.0.path));
3323 search_entries.search_matches.push(path_match.0.clone());
3324 }
3325 Match::CreateNew(_) => {}
3326 }
3327 }
3328 search_entries
3329}
3330
3331#[track_caller]
3332fn assert_match_selection(
3333 finder: &Picker<FileFinderDelegate>,
3334 expected_selection_index: usize,
3335 expected_file_name: &str,
3336) {
3337 assert_eq!(
3338 finder.delegate.selected_index(),
3339 expected_selection_index,
3340 "Match is not selected"
3341 );
3342 assert_match_at_position(finder, expected_selection_index, expected_file_name);
3343}
3344
3345#[track_caller]
3346fn assert_match_at_position(
3347 finder: &Picker<FileFinderDelegate>,
3348 match_index: usize,
3349 expected_file_name: &str,
3350) {
3351 let match_item = finder
3352 .delegate
3353 .matches
3354 .get(match_index)
3355 .unwrap_or_else(|| panic!("Finder has no match for index {match_index}"));
3356 let match_file_name = match &match_item {
3357 Match::History { path, .. } => path.absolute.file_name().and_then(|s| s.to_str()),
3358 Match::Search(path_match) => path_match.0.path.file_name(),
3359 Match::CreateNew(project_path) => project_path.path.file_name(),
3360 }
3361 .unwrap();
3362 assert_eq!(match_file_name, expected_file_name);
3363}
3364
3365#[gpui::test]
3366async fn test_filename_precedence(cx: &mut TestAppContext) {
3367 let app_state = init_test(cx);
3368
3369 app_state
3370 .fs
3371 .as_fake()
3372 .insert_tree(
3373 path!("/src"),
3374 json!({
3375 "layout": {
3376 "app.css": "",
3377 "app.d.ts": "",
3378 "app.html": "",
3379 "+page.svelte": "",
3380 },
3381 "routes": {
3382 "+layout.svelte": "",
3383 }
3384 }),
3385 )
3386 .await;
3387
3388 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
3389 let (picker, _, cx) = build_find_picker(project, cx);
3390
3391 cx.simulate_input("layout");
3392
3393 picker.update(cx, |finder, _| {
3394 let search_matches = collect_search_matches(finder).search_paths_only();
3395
3396 assert_eq!(
3397 search_matches,
3398 vec![
3399 rel_path("routes/+layout.svelte").into(),
3400 rel_path("layout/app.css").into(),
3401 rel_path("layout/app.d.ts").into(),
3402 rel_path("layout/app.html").into(),
3403 rel_path("layout/+page.svelte").into(),
3404 ],
3405 "File with 'layout' in filename should be prioritized over files in 'layout' directory"
3406 );
3407 });
3408}
3409
3410#[gpui::test]
3411async fn test_paths_with_starting_slash(cx: &mut TestAppContext) {
3412 let app_state = init_test(cx);
3413 app_state
3414 .fs
3415 .as_fake()
3416 .insert_tree(
3417 path!("/root"),
3418 json!({
3419 "a": {
3420 "file1.txt": "",
3421 "b": {
3422 "file2.txt": "",
3423 },
3424 }
3425 }),
3426 )
3427 .await;
3428
3429 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
3430
3431 let (picker, workspace, cx) = build_find_picker(project, cx);
3432
3433 let matching_abs_path = "/file1.txt".to_string();
3434 picker
3435 .update_in(cx, |picker, window, cx| {
3436 picker
3437 .delegate
3438 .update_matches(matching_abs_path, window, cx)
3439 })
3440 .await;
3441 picker.update(cx, |picker, _| {
3442 assert_eq!(
3443 collect_search_matches(picker).search_paths_only(),
3444 vec![rel_path("a/file1.txt").into()],
3445 "Relative path starting with slash should match"
3446 )
3447 });
3448 cx.dispatch_action(SelectNext);
3449 cx.dispatch_action(Confirm);
3450 cx.read(|cx| {
3451 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
3452 assert_eq!(active_editor.read(cx).title(cx), "file1.txt");
3453 });
3454}