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 (worktrees[0].read(cx).id(), worktrees[1].read(cx).id())
1214 });
1215
1216 let b_path = ProjectPath {
1217 worktree_id: worktree_id2,
1218 path: rel_path("the-parent-dirb/fileb").into(),
1219 };
1220 workspace
1221 .update_in(cx, |workspace, window, cx| {
1222 workspace.open_path(b_path, None, true, window, cx)
1223 })
1224 .await
1225 .unwrap();
1226
1227 let finder = open_file_picker(&workspace, cx);
1228
1229 finder
1230 .update_in(cx, |f, window, cx| {
1231 f.delegate.spawn_search(
1232 test_path_position(path!("the-parent-dirb/filec")),
1233 window,
1234 cx,
1235 )
1236 })
1237 .await;
1238 cx.run_until_parked();
1239 finder.update_in(cx, |picker, window, cx| {
1240 assert_eq!(picker.delegate.matches.len(), 1);
1241 picker.delegate.confirm(false, window, cx)
1242 });
1243 cx.run_until_parked();
1244 cx.read(|cx| {
1245 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
1246 let project_path = active_editor.read(cx).project_path(cx);
1247 assert_eq!(
1248 project_path,
1249 Some(ProjectPath {
1250 worktree_id: worktree_id2,
1251 path: rel_path("the-parent-dirb/filec").into()
1252 })
1253 );
1254 });
1255}
1256
1257#[gpui::test]
1258async fn test_create_file_no_focused_with_multiple_worktrees(cx: &mut TestAppContext) {
1259 let app_state = init_test(cx);
1260 app_state
1261 .fs
1262 .as_fake()
1263 .insert_tree(
1264 path!("/roota"),
1265 json!({ "the-parent-dira": { "filea": "" } }),
1266 )
1267 .await;
1268
1269 app_state
1270 .fs
1271 .as_fake()
1272 .insert_tree(
1273 path!("/rootb"),
1274 json!({ "the-parent-dirb": { "fileb": "" } }),
1275 )
1276 .await;
1277
1278 let project = Project::test(
1279 app_state.fs.clone(),
1280 [path!("/roota").as_ref(), path!("/rootb").as_ref()],
1281 cx,
1282 )
1283 .await;
1284
1285 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1286 let (_worktree_id1, worktree_id2) = cx.read(|cx| {
1287 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1288 (worktrees[0].read(cx).id(), worktrees[1].read(cx).id())
1289 });
1290
1291 let finder = open_file_picker(&workspace, cx);
1292
1293 finder
1294 .update_in(cx, |f, window, cx| {
1295 f.delegate
1296 .spawn_search(test_path_position(path!("rootb/filec")), window, cx)
1297 })
1298 .await;
1299 cx.run_until_parked();
1300 finder.update_in(cx, |picker, window, cx| {
1301 assert_eq!(picker.delegate.matches.len(), 1);
1302 picker.delegate.confirm(false, window, cx)
1303 });
1304 cx.run_until_parked();
1305 cx.read(|cx| {
1306 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
1307 let project_path = active_editor.read(cx).project_path(cx);
1308 assert_eq!(
1309 project_path,
1310 Some(ProjectPath {
1311 worktree_id: worktree_id2,
1312 path: rel_path("filec").into()
1313 })
1314 );
1315 });
1316}
1317
1318#[gpui::test]
1319async fn test_path_distance_ordering(cx: &mut TestAppContext) {
1320 let app_state = init_test(cx);
1321 app_state
1322 .fs
1323 .as_fake()
1324 .insert_tree(
1325 path!("/root"),
1326 json!({
1327 "dir1": { "a.txt": "" },
1328 "dir2": {
1329 "a.txt": "",
1330 "b.txt": ""
1331 }
1332 }),
1333 )
1334 .await;
1335
1336 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
1337 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1338
1339 let worktree_id = cx.read(|cx| {
1340 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1341 assert_eq!(worktrees.len(), 1);
1342 worktrees[0].read(cx).id()
1343 });
1344
1345 // When workspace has an active item, sort items which are closer to that item
1346 // first when they have the same name. In this case, b.txt is closer to dir2's a.txt
1347 // so that one should be sorted earlier
1348 let b_path = ProjectPath {
1349 worktree_id,
1350 path: rel_path("dir2/b.txt").into(),
1351 };
1352 workspace
1353 .update_in(cx, |workspace, window, cx| {
1354 workspace.open_path(b_path, None, true, window, cx)
1355 })
1356 .await
1357 .unwrap();
1358 let finder = open_file_picker(&workspace, cx);
1359 finder
1360 .update_in(cx, |f, window, cx| {
1361 f.delegate
1362 .spawn_search(test_path_position("a.txt"), window, cx)
1363 })
1364 .await;
1365
1366 finder.update(cx, |picker, _| {
1367 let matches = collect_search_matches(picker).search_paths_only();
1368 assert_eq!(matches[0].as_ref(), rel_path("dir2/a.txt"));
1369 assert_eq!(matches[1].as_ref(), rel_path("dir1/a.txt"));
1370 });
1371}
1372
1373#[gpui::test]
1374async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
1375 let app_state = init_test(cx);
1376 app_state
1377 .fs
1378 .as_fake()
1379 .insert_tree(
1380 "/root",
1381 json!({
1382 "dir1": {},
1383 "dir2": {
1384 "dir3": {}
1385 }
1386 }),
1387 )
1388 .await;
1389
1390 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1391 let (picker, _workspace, cx) = build_find_picker(project, cx);
1392
1393 picker
1394 .update_in(cx, |f, window, cx| {
1395 f.delegate
1396 .spawn_search(test_path_position("dir"), window, cx)
1397 })
1398 .await;
1399 cx.read(|cx| {
1400 let finder = picker.read(cx);
1401 assert_eq!(finder.delegate.matches.len(), 1);
1402 assert_match_at_position(finder, 0, "dir");
1403 });
1404}
1405
1406#[gpui::test]
1407async fn test_query_history(cx: &mut gpui::TestAppContext) {
1408 let app_state = init_test(cx);
1409
1410 app_state
1411 .fs
1412 .as_fake()
1413 .insert_tree(
1414 path!("/src"),
1415 json!({
1416 "test": {
1417 "first.rs": "// First Rust file",
1418 "second.rs": "// Second Rust file",
1419 "third.rs": "// Third Rust file",
1420 }
1421 }),
1422 )
1423 .await;
1424
1425 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1426 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1427 let worktree_id = cx.read(|cx| {
1428 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1429 assert_eq!(worktrees.len(), 1);
1430 worktrees[0].read(cx).id()
1431 });
1432
1433 // Open and close panels, getting their history items afterwards.
1434 // Ensure history items get populated with opened items, and items are kept in a certain order.
1435 // The history lags one opened buffer behind, since it's updated in the search panel only on its reopen.
1436 //
1437 // TODO: without closing, the opened items do not propagate their history changes for some reason
1438 // it does work in real app though, only tests do not propagate.
1439 workspace.update_in(cx, |_workspace, window, cx| window.focused(cx));
1440
1441 let initial_history = open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1442 assert!(
1443 initial_history.is_empty(),
1444 "Should have no history before opening any files"
1445 );
1446
1447 let history_after_first =
1448 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1449 assert_eq!(
1450 history_after_first,
1451 vec![FoundPath::new(
1452 ProjectPath {
1453 worktree_id,
1454 path: rel_path("test/first.rs").into(),
1455 },
1456 PathBuf::from(path!("/src/test/first.rs"))
1457 )],
1458 "Should show 1st opened item in the history when opening the 2nd item"
1459 );
1460
1461 let history_after_second =
1462 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1463 assert_eq!(
1464 history_after_second,
1465 vec![
1466 FoundPath::new(
1467 ProjectPath {
1468 worktree_id,
1469 path: rel_path("test/second.rs").into(),
1470 },
1471 PathBuf::from(path!("/src/test/second.rs"))
1472 ),
1473 FoundPath::new(
1474 ProjectPath {
1475 worktree_id,
1476 path: rel_path("test/first.rs").into(),
1477 },
1478 PathBuf::from(path!("/src/test/first.rs"))
1479 ),
1480 ],
1481 "Should show 1st and 2nd opened items in the history when opening the 3rd item. \
1482 2nd item should be the first in the history, as the last opened."
1483 );
1484
1485 let history_after_third =
1486 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1487 assert_eq!(
1488 history_after_third,
1489 vec![
1490 FoundPath::new(
1491 ProjectPath {
1492 worktree_id,
1493 path: rel_path("test/third.rs").into(),
1494 },
1495 PathBuf::from(path!("/src/test/third.rs"))
1496 ),
1497 FoundPath::new(
1498 ProjectPath {
1499 worktree_id,
1500 path: rel_path("test/second.rs").into(),
1501 },
1502 PathBuf::from(path!("/src/test/second.rs"))
1503 ),
1504 FoundPath::new(
1505 ProjectPath {
1506 worktree_id,
1507 path: rel_path("test/first.rs").into(),
1508 },
1509 PathBuf::from(path!("/src/test/first.rs"))
1510 ),
1511 ],
1512 "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \
1513 3rd item should be the first in the history, as the last opened."
1514 );
1515
1516 let history_after_second_again =
1517 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1518 assert_eq!(
1519 history_after_second_again,
1520 vec![
1521 FoundPath::new(
1522 ProjectPath {
1523 worktree_id,
1524 path: rel_path("test/second.rs").into(),
1525 },
1526 PathBuf::from(path!("/src/test/second.rs"))
1527 ),
1528 FoundPath::new(
1529 ProjectPath {
1530 worktree_id,
1531 path: rel_path("test/third.rs").into(),
1532 },
1533 PathBuf::from(path!("/src/test/third.rs"))
1534 ),
1535 FoundPath::new(
1536 ProjectPath {
1537 worktree_id,
1538 path: rel_path("test/first.rs").into(),
1539 },
1540 PathBuf::from(path!("/src/test/first.rs"))
1541 ),
1542 ],
1543 "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \
1544 2nd item, as the last opened, 3rd item should go next as it was opened right before."
1545 );
1546}
1547
1548#[gpui::test]
1549async fn test_history_match_positions(cx: &mut gpui::TestAppContext) {
1550 let app_state = init_test(cx);
1551
1552 app_state
1553 .fs
1554 .as_fake()
1555 .insert_tree(
1556 path!("/src"),
1557 json!({
1558 "test": {
1559 "first.rs": "// First Rust file",
1560 "second.rs": "// Second Rust file",
1561 "third.rs": "// Third Rust file",
1562 }
1563 }),
1564 )
1565 .await;
1566
1567 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1568 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1569
1570 workspace.update_in(cx, |_workspace, window, cx| window.focused(cx));
1571
1572 open_close_queried_buffer("efir", 1, "first.rs", &workspace, cx).await;
1573 let history = open_close_queried_buffer("second", 1, "second.rs", &workspace, cx).await;
1574 assert_eq!(history.len(), 1);
1575
1576 let picker = open_file_picker(&workspace, cx);
1577 cx.simulate_input("fir");
1578 picker.update_in(cx, |finder, window, cx| {
1579 let matches = &finder.delegate.matches.matches;
1580 assert_matches!(
1581 matches.as_slice(),
1582 [Match::History { .. }, Match::CreateNew { .. }]
1583 );
1584 assert_eq!(
1585 matches[0].panel_match().unwrap().0.path.as_ref(),
1586 rel_path("test/first.rs")
1587 );
1588 assert_eq!(matches[0].panel_match().unwrap().0.positions, &[5, 6, 7]);
1589
1590 let (file_label, path_label) =
1591 finder
1592 .delegate
1593 .labels_for_match(&finder.delegate.matches.matches[0], window, cx);
1594 assert_eq!(file_label.text(), "first.rs");
1595 assert_eq!(file_label.highlight_indices(), &[0, 1, 2]);
1596 assert_eq!(
1597 path_label.text(),
1598 format!("test{}", PathStyle::local().primary_separator())
1599 );
1600 assert_eq!(path_label.highlight_indices(), &[] as &[usize]);
1601 });
1602}
1603
1604#[gpui::test]
1605async fn test_external_files_history(cx: &mut gpui::TestAppContext) {
1606 let app_state = init_test(cx);
1607
1608 app_state
1609 .fs
1610 .as_fake()
1611 .insert_tree(
1612 path!("/src"),
1613 json!({
1614 "test": {
1615 "first.rs": "// First Rust file",
1616 "second.rs": "// Second Rust file",
1617 }
1618 }),
1619 )
1620 .await;
1621
1622 app_state
1623 .fs
1624 .as_fake()
1625 .insert_tree(
1626 path!("/external-src"),
1627 json!({
1628 "test": {
1629 "third.rs": "// Third Rust file",
1630 "fourth.rs": "// Fourth Rust file",
1631 }
1632 }),
1633 )
1634 .await;
1635
1636 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1637 cx.update(|cx| {
1638 project.update(cx, |project, cx| {
1639 project.find_or_create_worktree(path!("/external-src"), false, cx)
1640 })
1641 })
1642 .detach();
1643 cx.background_executor.run_until_parked();
1644
1645 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1646 let worktree_id = cx.read(|cx| {
1647 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1648 assert_eq!(worktrees.len(), 1,);
1649
1650 worktrees[0].read(cx).id()
1651 });
1652 workspace
1653 .update_in(cx, |workspace, window, cx| {
1654 workspace.open_abs_path(
1655 PathBuf::from(path!("/external-src/test/third.rs")),
1656 OpenOptions {
1657 visible: Some(OpenVisible::None),
1658 ..Default::default()
1659 },
1660 window,
1661 cx,
1662 )
1663 })
1664 .detach();
1665 cx.background_executor.run_until_parked();
1666 let external_worktree_id = cx.read(|cx| {
1667 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1668 assert_eq!(
1669 worktrees.len(),
1670 2,
1671 "External file should get opened in a new worktree"
1672 );
1673
1674 worktrees
1675 .into_iter()
1676 .find(|worktree| worktree.read(cx).id() != worktree_id)
1677 .expect("New worktree should have a different id")
1678 .read(cx)
1679 .id()
1680 });
1681 cx.dispatch_action(workspace::CloseActiveItem {
1682 save_intent: None,
1683 close_pinned: false,
1684 });
1685
1686 let initial_history_items =
1687 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1688 assert_eq!(
1689 initial_history_items,
1690 vec![FoundPath::new(
1691 ProjectPath {
1692 worktree_id: external_worktree_id,
1693 path: rel_path("").into(),
1694 },
1695 PathBuf::from(path!("/external-src/test/third.rs"))
1696 )],
1697 "Should show external file with its full path in the history after it was open"
1698 );
1699
1700 let updated_history_items =
1701 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1702 assert_eq!(
1703 updated_history_items,
1704 vec![
1705 FoundPath::new(
1706 ProjectPath {
1707 worktree_id,
1708 path: rel_path("test/second.rs").into(),
1709 },
1710 PathBuf::from(path!("/src/test/second.rs"))
1711 ),
1712 FoundPath::new(
1713 ProjectPath {
1714 worktree_id: external_worktree_id,
1715 path: rel_path("").into(),
1716 },
1717 PathBuf::from(path!("/external-src/test/third.rs"))
1718 ),
1719 ],
1720 "Should keep external file with history updates",
1721 );
1722}
1723
1724#[gpui::test]
1725async fn test_toggle_panel_new_selections(cx: &mut gpui::TestAppContext) {
1726 let app_state = init_test(cx);
1727
1728 app_state
1729 .fs
1730 .as_fake()
1731 .insert_tree(
1732 path!("/src"),
1733 json!({
1734 "test": {
1735 "first.rs": "// First Rust file",
1736 "second.rs": "// Second Rust file",
1737 "third.rs": "// Third Rust file",
1738 }
1739 }),
1740 )
1741 .await;
1742
1743 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1744 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1745
1746 // generate some history to select from
1747 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1748 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1749 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1750 let current_history = open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1751
1752 for expected_selected_index in 0..current_history.len() {
1753 cx.dispatch_action(ToggleFileFinder::default());
1754 let picker = active_file_picker(&workspace, cx);
1755 let selected_index = picker.update(cx, |picker, _| picker.delegate.selected_index());
1756 assert_eq!(
1757 selected_index, expected_selected_index,
1758 "Should select the next item in the history"
1759 );
1760 }
1761
1762 cx.dispatch_action(ToggleFileFinder::default());
1763 let selected_index = workspace.update(cx, |workspace, cx| {
1764 workspace
1765 .active_modal::<FileFinder>(cx)
1766 .unwrap()
1767 .read(cx)
1768 .picker
1769 .read(cx)
1770 .delegate
1771 .selected_index()
1772 });
1773 assert_eq!(
1774 selected_index, 0,
1775 "Should wrap around the history and start all over"
1776 );
1777}
1778
1779#[gpui::test]
1780async fn test_search_preserves_history_items(cx: &mut gpui::TestAppContext) {
1781 let app_state = init_test(cx);
1782
1783 app_state
1784 .fs
1785 .as_fake()
1786 .insert_tree(
1787 path!("/src"),
1788 json!({
1789 "test": {
1790 "first.rs": "// First Rust file",
1791 "second.rs": "// Second Rust file",
1792 "third.rs": "// Third Rust file",
1793 "fourth.rs": "// Fourth Rust file",
1794 }
1795 }),
1796 )
1797 .await;
1798
1799 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1800 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1801 let worktree_id = cx.read(|cx| {
1802 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1803 assert_eq!(worktrees.len(), 1,);
1804
1805 worktrees[0].read(cx).id()
1806 });
1807
1808 // generate some history to select from
1809 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1810 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1811 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1812 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1813
1814 let finder = open_file_picker(&workspace, cx);
1815 let first_query = "f";
1816 finder
1817 .update_in(cx, |finder, window, cx| {
1818 finder
1819 .delegate
1820 .update_matches(first_query.to_string(), window, cx)
1821 })
1822 .await;
1823 finder.update(cx, |picker, _| {
1824 let matches = collect_search_matches(picker);
1825 assert_eq!(matches.history.len(), 1, "Only one history item contains {first_query}, it should be present and others should be filtered out");
1826 let history_match = matches.history_found_paths.first().expect("Should have path matches for history items after querying");
1827 assert_eq!(history_match, &FoundPath::new(
1828 ProjectPath {
1829 worktree_id,
1830 path: rel_path("test/first.rs").into(),
1831 },
1832 PathBuf::from(path!("/src/test/first.rs")),
1833 ));
1834 assert_eq!(matches.search.len(), 1, "Only one non-history item contains {first_query}, it should be present");
1835 assert_eq!(matches.search.first().unwrap().as_ref(), rel_path("test/fourth.rs"));
1836 });
1837
1838 let second_query = "fsdasdsa";
1839 let finder = active_file_picker(&workspace, cx);
1840 finder
1841 .update_in(cx, |finder, window, cx| {
1842 finder
1843 .delegate
1844 .update_matches(second_query.to_string(), window, cx)
1845 })
1846 .await;
1847 finder.update(cx, |picker, _| {
1848 assert!(
1849 collect_search_matches(picker)
1850 .search_paths_only()
1851 .is_empty(),
1852 "No search entries should match {second_query}"
1853 );
1854 });
1855
1856 let first_query_again = first_query;
1857
1858 let finder = active_file_picker(&workspace, cx);
1859 finder
1860 .update_in(cx, |finder, window, cx| {
1861 finder
1862 .delegate
1863 .update_matches(first_query_again.to_string(), window, cx)
1864 })
1865 .await;
1866 finder.update(cx, |picker, _| {
1867 let matches = collect_search_matches(picker);
1868 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");
1869 let history_match = matches.history_found_paths.first().expect("Should have path matches for history items after querying");
1870 assert_eq!(history_match, &FoundPath::new(
1871 ProjectPath {
1872 worktree_id,
1873 path: rel_path("test/first.rs").into(),
1874 },
1875 PathBuf::from(path!("/src/test/first.rs"))
1876 ));
1877 assert_eq!(matches.search.len(), 1, "Only one non-history item contains {first_query_again}, it should be present, even after non-matching query");
1878 assert_eq!(matches.search.first().unwrap().as_ref(), rel_path("test/fourth.rs"));
1879 });
1880}
1881
1882#[gpui::test]
1883async fn test_search_sorts_history_items(cx: &mut gpui::TestAppContext) {
1884 let app_state = init_test(cx);
1885
1886 app_state
1887 .fs
1888 .as_fake()
1889 .insert_tree(
1890 path!("/root"),
1891 json!({
1892 "test": {
1893 "1_qw": "// First file that matches the query",
1894 "2_second": "// Second file",
1895 "3_third": "// Third file",
1896 "4_fourth": "// Fourth file",
1897 "5_qwqwqw": "// A file with 3 more matches than the first one",
1898 "6_qwqwqw": "// Same query matches as above, but closer to the end of the list due to the name",
1899 "7_qwqwqw": "// One more, same amount of query matches as above",
1900 }
1901 }),
1902 )
1903 .await;
1904
1905 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
1906 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1907 // generate some history to select from
1908 open_close_queried_buffer("1", 1, "1_qw", &workspace, cx).await;
1909 open_close_queried_buffer("2", 1, "2_second", &workspace, cx).await;
1910 open_close_queried_buffer("3", 1, "3_third", &workspace, cx).await;
1911 open_close_queried_buffer("2", 1, "2_second", &workspace, cx).await;
1912 open_close_queried_buffer("6", 1, "6_qwqwqw", &workspace, cx).await;
1913
1914 let finder = open_file_picker(&workspace, cx);
1915 let query = "qw";
1916 finder
1917 .update_in(cx, |finder, window, cx| {
1918 finder
1919 .delegate
1920 .update_matches(query.to_string(), window, cx)
1921 })
1922 .await;
1923 finder.update(cx, |finder, _| {
1924 let search_matches = collect_search_matches(finder);
1925 assert_eq!(
1926 search_matches.history,
1927 vec![
1928 rel_path("test/1_qw").into(),
1929 rel_path("test/6_qwqwqw").into()
1930 ],
1931 );
1932 assert_eq!(
1933 search_matches.search,
1934 vec![
1935 rel_path("test/5_qwqwqw").into(),
1936 rel_path("test/7_qwqwqw").into()
1937 ],
1938 );
1939 });
1940}
1941
1942#[gpui::test]
1943async fn test_select_current_open_file_when_no_history(cx: &mut gpui::TestAppContext) {
1944 let app_state = init_test(cx);
1945
1946 app_state
1947 .fs
1948 .as_fake()
1949 .insert_tree(
1950 path!("/root"),
1951 json!({
1952 "test": {
1953 "1_qw": "",
1954 }
1955 }),
1956 )
1957 .await;
1958
1959 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
1960 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1961 // Open new buffer
1962 open_queried_buffer("1", 1, "1_qw", &workspace, cx).await;
1963
1964 let picker = open_file_picker(&workspace, cx);
1965 picker.update(cx, |finder, _| {
1966 assert_match_selection(finder, 0, "1_qw");
1967 });
1968}
1969
1970#[gpui::test]
1971async fn test_keep_opened_file_on_top_of_search_results_and_select_next_one(
1972 cx: &mut TestAppContext,
1973) {
1974 let app_state = init_test(cx);
1975
1976 app_state
1977 .fs
1978 .as_fake()
1979 .insert_tree(
1980 path!("/src"),
1981 json!({
1982 "test": {
1983 "bar.rs": "// Bar file",
1984 "lib.rs": "// Lib file",
1985 "maaa.rs": "// Maaaaaaa",
1986 "main.rs": "// Main file",
1987 "moo.rs": "// Moooooo",
1988 }
1989 }),
1990 )
1991 .await;
1992
1993 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
1994 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
1995
1996 open_close_queried_buffer("bar", 1, "bar.rs", &workspace, cx).await;
1997 open_close_queried_buffer("lib", 1, "lib.rs", &workspace, cx).await;
1998 open_queried_buffer("main", 1, "main.rs", &workspace, cx).await;
1999
2000 // main.rs is on top, previously used is selected
2001 let picker = open_file_picker(&workspace, cx);
2002 picker.update(cx, |finder, _| {
2003 assert_eq!(finder.delegate.matches.len(), 3);
2004 assert_match_selection(finder, 0, "main.rs");
2005 assert_match_at_position(finder, 1, "lib.rs");
2006 assert_match_at_position(finder, 2, "bar.rs");
2007 });
2008
2009 // all files match, main.rs is still on top, but the second item is selected
2010 picker
2011 .update_in(cx, |finder, window, cx| {
2012 finder
2013 .delegate
2014 .update_matches(".rs".to_string(), window, cx)
2015 })
2016 .await;
2017 picker.update(cx, |finder, _| {
2018 assert_eq!(finder.delegate.matches.len(), 6);
2019 assert_match_at_position(finder, 0, "main.rs");
2020 assert_match_selection(finder, 1, "bar.rs");
2021 assert_match_at_position(finder, 2, "lib.rs");
2022 assert_match_at_position(finder, 3, "moo.rs");
2023 assert_match_at_position(finder, 4, "maaa.rs");
2024 assert_match_at_position(finder, 5, ".rs");
2025 });
2026
2027 // main.rs is not among matches, select top item
2028 picker
2029 .update_in(cx, |finder, window, cx| {
2030 finder.delegate.update_matches("b".to_string(), window, cx)
2031 })
2032 .await;
2033 picker.update(cx, |finder, _| {
2034 assert_eq!(finder.delegate.matches.len(), 3);
2035 assert_match_at_position(finder, 0, "bar.rs");
2036 assert_match_at_position(finder, 1, "lib.rs");
2037 assert_match_at_position(finder, 2, "b");
2038 });
2039
2040 // main.rs is back, put it on top and select next item
2041 picker
2042 .update_in(cx, |finder, window, cx| {
2043 finder.delegate.update_matches("m".to_string(), window, cx)
2044 })
2045 .await;
2046 picker.update(cx, |finder, _| {
2047 assert_eq!(finder.delegate.matches.len(), 4);
2048 assert_match_at_position(finder, 0, "main.rs");
2049 assert_match_selection(finder, 1, "moo.rs");
2050 assert_match_at_position(finder, 2, "maaa.rs");
2051 assert_match_at_position(finder, 3, "m");
2052 });
2053
2054 // get back to the initial state
2055 picker
2056 .update_in(cx, |finder, window, cx| {
2057 finder.delegate.update_matches("".to_string(), window, cx)
2058 })
2059 .await;
2060 picker.update(cx, |finder, _| {
2061 assert_eq!(finder.delegate.matches.len(), 3);
2062 assert_match_selection(finder, 0, "main.rs");
2063 assert_match_at_position(finder, 1, "lib.rs");
2064 assert_match_at_position(finder, 2, "bar.rs");
2065 });
2066}
2067
2068#[gpui::test]
2069async fn test_setting_auto_select_first_and_select_active_file(cx: &mut TestAppContext) {
2070 let app_state = init_test(cx);
2071
2072 cx.update(|cx| {
2073 let settings = *FileFinderSettings::get_global(cx);
2074
2075 FileFinderSettings::override_global(
2076 FileFinderSettings {
2077 skip_focus_for_active_in_search: false,
2078 ..settings
2079 },
2080 cx,
2081 );
2082 });
2083
2084 app_state
2085 .fs
2086 .as_fake()
2087 .insert_tree(
2088 path!("/src"),
2089 json!({
2090 "test": {
2091 "bar.rs": "// Bar file",
2092 "lib.rs": "// Lib file",
2093 "maaa.rs": "// Maaaaaaa",
2094 "main.rs": "// Main file",
2095 "moo.rs": "// Moooooo",
2096 }
2097 }),
2098 )
2099 .await;
2100
2101 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
2102 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2103
2104 open_close_queried_buffer("bar", 1, "bar.rs", &workspace, cx).await;
2105 open_close_queried_buffer("lib", 1, "lib.rs", &workspace, cx).await;
2106 open_queried_buffer("main", 1, "main.rs", &workspace, cx).await;
2107
2108 // main.rs is on top, previously used is selected
2109 let picker = open_file_picker(&workspace, cx);
2110 picker.update(cx, |finder, _| {
2111 assert_eq!(finder.delegate.matches.len(), 3);
2112 assert_match_selection(finder, 0, "main.rs");
2113 assert_match_at_position(finder, 1, "lib.rs");
2114 assert_match_at_position(finder, 2, "bar.rs");
2115 });
2116
2117 // all files match, main.rs is on top, and is selected
2118 picker
2119 .update_in(cx, |finder, window, cx| {
2120 finder
2121 .delegate
2122 .update_matches(".rs".to_string(), window, cx)
2123 })
2124 .await;
2125 picker.update(cx, |finder, _| {
2126 assert_eq!(finder.delegate.matches.len(), 6);
2127 assert_match_selection(finder, 0, "main.rs");
2128 assert_match_at_position(finder, 1, "bar.rs");
2129 assert_match_at_position(finder, 2, "lib.rs");
2130 assert_match_at_position(finder, 3, "moo.rs");
2131 assert_match_at_position(finder, 4, "maaa.rs");
2132 assert_match_at_position(finder, 5, ".rs");
2133 });
2134}
2135
2136#[gpui::test]
2137async fn test_non_separate_history_items(cx: &mut TestAppContext) {
2138 let app_state = init_test(cx);
2139
2140 app_state
2141 .fs
2142 .as_fake()
2143 .insert_tree(
2144 path!("/src"),
2145 json!({
2146 "test": {
2147 "bar.rs": "// Bar file",
2148 "lib.rs": "// Lib file",
2149 "maaa.rs": "// Maaaaaaa",
2150 "main.rs": "// Main file",
2151 "moo.rs": "// Moooooo",
2152 }
2153 }),
2154 )
2155 .await;
2156
2157 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
2158 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2159
2160 open_close_queried_buffer("bar", 1, "bar.rs", &workspace, cx).await;
2161 open_close_queried_buffer("lib", 1, "lib.rs", &workspace, cx).await;
2162 open_queried_buffer("main", 1, "main.rs", &workspace, cx).await;
2163
2164 cx.dispatch_action(ToggleFileFinder::default());
2165 let picker = active_file_picker(&workspace, cx);
2166 // main.rs is on top, previously used is selected
2167 picker.update(cx, |finder, _| {
2168 assert_eq!(finder.delegate.matches.len(), 3);
2169 assert_match_selection(finder, 0, "main.rs");
2170 assert_match_at_position(finder, 1, "lib.rs");
2171 assert_match_at_position(finder, 2, "bar.rs");
2172 });
2173
2174 // all files match, main.rs is still on top, but the second item is selected
2175 picker
2176 .update_in(cx, |finder, window, cx| {
2177 finder
2178 .delegate
2179 .update_matches(".rs".to_string(), window, cx)
2180 })
2181 .await;
2182 picker.update(cx, |finder, _| {
2183 assert_eq!(finder.delegate.matches.len(), 6);
2184 assert_match_at_position(finder, 0, "main.rs");
2185 assert_match_selection(finder, 1, "moo.rs");
2186 assert_match_at_position(finder, 2, "bar.rs");
2187 assert_match_at_position(finder, 3, "lib.rs");
2188 assert_match_at_position(finder, 4, "maaa.rs");
2189 assert_match_at_position(finder, 5, ".rs");
2190 });
2191
2192 // main.rs is not among matches, select top item
2193 picker
2194 .update_in(cx, |finder, window, cx| {
2195 finder.delegate.update_matches("b".to_string(), window, cx)
2196 })
2197 .await;
2198 picker.update(cx, |finder, _| {
2199 assert_eq!(finder.delegate.matches.len(), 3);
2200 assert_match_at_position(finder, 0, "bar.rs");
2201 assert_match_at_position(finder, 1, "lib.rs");
2202 assert_match_at_position(finder, 2, "b");
2203 });
2204
2205 // main.rs is back, put it on top and select next item
2206 picker
2207 .update_in(cx, |finder, window, cx| {
2208 finder.delegate.update_matches("m".to_string(), window, cx)
2209 })
2210 .await;
2211 picker.update(cx, |finder, _| {
2212 assert_eq!(finder.delegate.matches.len(), 4);
2213 assert_match_at_position(finder, 0, "main.rs");
2214 assert_match_selection(finder, 1, "moo.rs");
2215 assert_match_at_position(finder, 2, "maaa.rs");
2216 assert_match_at_position(finder, 3, "m");
2217 });
2218
2219 // get back to the initial state
2220 picker
2221 .update_in(cx, |finder, window, cx| {
2222 finder.delegate.update_matches("".to_string(), window, cx)
2223 })
2224 .await;
2225 picker.update(cx, |finder, _| {
2226 assert_eq!(finder.delegate.matches.len(), 3);
2227 assert_match_selection(finder, 0, "main.rs");
2228 assert_match_at_position(finder, 1, "lib.rs");
2229 assert_match_at_position(finder, 2, "bar.rs");
2230 });
2231}
2232
2233#[gpui::test]
2234async fn test_history_items_shown_in_order_of_open(cx: &mut TestAppContext) {
2235 let app_state = init_test(cx);
2236
2237 app_state
2238 .fs
2239 .as_fake()
2240 .insert_tree(
2241 path!("/test"),
2242 json!({
2243 "test": {
2244 "1.txt": "// One",
2245 "2.txt": "// Two",
2246 "3.txt": "// Three",
2247 }
2248 }),
2249 )
2250 .await;
2251
2252 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
2253 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2254
2255 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
2256 open_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
2257 open_queried_buffer("3", 1, "3.txt", &workspace, cx).await;
2258
2259 let picker = open_file_picker(&workspace, cx);
2260 picker.update(cx, |finder, _| {
2261 assert_eq!(finder.delegate.matches.len(), 3);
2262 assert_match_selection(finder, 0, "3.txt");
2263 assert_match_at_position(finder, 1, "2.txt");
2264 assert_match_at_position(finder, 2, "1.txt");
2265 });
2266
2267 cx.dispatch_action(SelectNext);
2268 cx.dispatch_action(Confirm); // Open 2.txt
2269
2270 let picker = open_file_picker(&workspace, cx);
2271 picker.update(cx, |finder, _| {
2272 assert_eq!(finder.delegate.matches.len(), 3);
2273 assert_match_selection(finder, 0, "2.txt");
2274 assert_match_at_position(finder, 1, "3.txt");
2275 assert_match_at_position(finder, 2, "1.txt");
2276 });
2277
2278 cx.dispatch_action(SelectNext);
2279 cx.dispatch_action(SelectNext);
2280 cx.dispatch_action(Confirm); // Open 1.txt
2281
2282 let picker = open_file_picker(&workspace, cx);
2283 picker.update(cx, |finder, _| {
2284 assert_eq!(finder.delegate.matches.len(), 3);
2285 assert_match_selection(finder, 0, "1.txt");
2286 assert_match_at_position(finder, 1, "2.txt");
2287 assert_match_at_position(finder, 2, "3.txt");
2288 });
2289}
2290
2291#[gpui::test]
2292async fn test_selected_history_item_stays_selected_on_worktree_updated(cx: &mut TestAppContext) {
2293 let app_state = init_test(cx);
2294
2295 app_state
2296 .fs
2297 .as_fake()
2298 .insert_tree(
2299 path!("/test"),
2300 json!({
2301 "test": {
2302 "1.txt": "// One",
2303 "2.txt": "// Two",
2304 "3.txt": "// Three",
2305 }
2306 }),
2307 )
2308 .await;
2309
2310 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
2311 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2312
2313 open_close_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
2314 open_close_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
2315 open_close_queried_buffer("3", 1, "3.txt", &workspace, cx).await;
2316
2317 let picker = open_file_picker(&workspace, cx);
2318 picker.update(cx, |finder, _| {
2319 assert_eq!(finder.delegate.matches.len(), 3);
2320 assert_match_selection(finder, 0, "3.txt");
2321 assert_match_at_position(finder, 1, "2.txt");
2322 assert_match_at_position(finder, 2, "1.txt");
2323 });
2324
2325 cx.dispatch_action(SelectNext);
2326
2327 // Add more files to the worktree to trigger update matches
2328 for i in 0..5 {
2329 let filename = if cfg!(windows) {
2330 format!("C:/test/{}.txt", 4 + i)
2331 } else {
2332 format!("/test/{}.txt", 4 + i)
2333 };
2334 app_state
2335 .fs
2336 .create_file(Path::new(&filename), Default::default())
2337 .await
2338 .expect("unable to create file");
2339 }
2340
2341 cx.executor().advance_clock(FS_WATCH_LATENCY);
2342
2343 picker.update(cx, |finder, _| {
2344 assert_eq!(finder.delegate.matches.len(), 3);
2345 assert_match_at_position(finder, 0, "3.txt");
2346 assert_match_selection(finder, 1, "2.txt");
2347 assert_match_at_position(finder, 2, "1.txt");
2348 });
2349}
2350
2351#[gpui::test]
2352async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppContext) {
2353 let app_state = init_test(cx);
2354
2355 app_state
2356 .fs
2357 .as_fake()
2358 .insert_tree(
2359 path!("/src"),
2360 json!({
2361 "collab_ui": {
2362 "first.rs": "// First Rust file",
2363 "second.rs": "// Second Rust file",
2364 "third.rs": "// Third Rust file",
2365 "collab_ui.rs": "// Fourth Rust file",
2366 }
2367 }),
2368 )
2369 .await;
2370
2371 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
2372 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2373 // generate some history to select from
2374 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
2375 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
2376 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
2377 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
2378
2379 let finder = open_file_picker(&workspace, cx);
2380 let query = "collab_ui";
2381 cx.simulate_input(query);
2382 finder.update(cx, |picker, _| {
2383 let search_entries = collect_search_matches(picker).search_paths_only();
2384 assert_eq!(
2385 search_entries,
2386 vec![
2387 rel_path("collab_ui/collab_ui.rs").into(),
2388 rel_path("collab_ui/first.rs").into(),
2389 rel_path("collab_ui/third.rs").into(),
2390 rel_path("collab_ui/second.rs").into(),
2391 ],
2392 "Despite all search results having the same directory name, the most matching one should be on top"
2393 );
2394 });
2395}
2396
2397#[gpui::test]
2398async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) {
2399 let app_state = init_test(cx);
2400
2401 app_state
2402 .fs
2403 .as_fake()
2404 .insert_tree(
2405 path!("/src"),
2406 json!({
2407 "test": {
2408 "first.rs": "// First Rust file",
2409 "nonexistent.rs": "// Second Rust file",
2410 "third.rs": "// Third Rust file",
2411 }
2412 }),
2413 )
2414 .await;
2415
2416 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
2417 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx)); // generate some history to select from
2418 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
2419 open_close_queried_buffer("non", 1, "nonexistent.rs", &workspace, cx).await;
2420 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
2421 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
2422 app_state
2423 .fs
2424 .remove_file(
2425 Path::new(path!("/src/test/nonexistent.rs")),
2426 RemoveOptions::default(),
2427 )
2428 .await
2429 .unwrap();
2430 cx.run_until_parked();
2431
2432 let picker = open_file_picker(&workspace, cx);
2433 cx.simulate_input("rs");
2434
2435 picker.update(cx, |picker, _| {
2436 assert_eq!(
2437 collect_search_matches(picker).history,
2438 vec![
2439 rel_path("test/first.rs").into(),
2440 rel_path("test/third.rs").into()
2441 ],
2442 "Should have all opened files in the history, except the ones that do not exist on disk"
2443 );
2444 });
2445}
2446
2447#[gpui::test]
2448async fn test_search_results_refreshed_on_worktree_updates(cx: &mut gpui::TestAppContext) {
2449 let app_state = init_test(cx);
2450
2451 app_state
2452 .fs
2453 .as_fake()
2454 .insert_tree(
2455 "/src",
2456 json!({
2457 "lib.rs": "// Lib file",
2458 "main.rs": "// Bar file",
2459 "read.me": "// Readme file",
2460 }),
2461 )
2462 .await;
2463
2464 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
2465 let (workspace, cx) =
2466 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2467
2468 // Initial state
2469 let picker = open_file_picker(&workspace, cx);
2470 cx.simulate_input("rs");
2471 picker.update(cx, |finder, _| {
2472 assert_eq!(finder.delegate.matches.len(), 3);
2473 assert_match_at_position(finder, 0, "lib.rs");
2474 assert_match_at_position(finder, 1, "main.rs");
2475 assert_match_at_position(finder, 2, "rs");
2476 });
2477 // Delete main.rs
2478 app_state
2479 .fs
2480 .remove_file("/src/main.rs".as_ref(), Default::default())
2481 .await
2482 .expect("unable to remove file");
2483 cx.executor().advance_clock(FS_WATCH_LATENCY);
2484
2485 // main.rs is in not among search results anymore
2486 picker.update(cx, |finder, _| {
2487 assert_eq!(finder.delegate.matches.len(), 2);
2488 assert_match_at_position(finder, 0, "lib.rs");
2489 assert_match_at_position(finder, 1, "rs");
2490 });
2491
2492 // Create util.rs
2493 app_state
2494 .fs
2495 .create_file("/src/util.rs".as_ref(), Default::default())
2496 .await
2497 .expect("unable to create file");
2498 cx.executor().advance_clock(FS_WATCH_LATENCY);
2499
2500 // util.rs is among search results
2501 picker.update(cx, |finder, _| {
2502 assert_eq!(finder.delegate.matches.len(), 3);
2503 assert_match_at_position(finder, 0, "lib.rs");
2504 assert_match_at_position(finder, 1, "util.rs");
2505 assert_match_at_position(finder, 2, "rs");
2506 });
2507}
2508
2509#[gpui::test]
2510async fn test_search_results_refreshed_on_standalone_file_creation(cx: &mut gpui::TestAppContext) {
2511 let app_state = init_test(cx);
2512
2513 app_state
2514 .fs
2515 .as_fake()
2516 .insert_tree(
2517 "/src",
2518 json!({
2519 "lib.rs": "// Lib file",
2520 "main.rs": "// Bar file",
2521 "read.me": "// Readme file",
2522 }),
2523 )
2524 .await;
2525 app_state
2526 .fs
2527 .as_fake()
2528 .insert_tree(
2529 "/test",
2530 json!({
2531 "new.rs": "// New file",
2532 }),
2533 )
2534 .await;
2535
2536 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
2537 let (workspace, cx) =
2538 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2539
2540 cx.update(|_, cx| {
2541 open_paths(
2542 &[PathBuf::from(path!("/test/new.rs"))],
2543 app_state,
2544 workspace::OpenOptions::default(),
2545 cx,
2546 )
2547 })
2548 .await
2549 .unwrap();
2550 assert_eq!(cx.update(|_, cx| cx.windows().len()), 1);
2551
2552 let initial_history = open_close_queried_buffer("new", 1, "new.rs", &workspace, cx).await;
2553 assert_eq!(
2554 initial_history.first().unwrap().absolute,
2555 PathBuf::from(path!("/test/new.rs")),
2556 "Should show 1st opened item in the history when opening the 2nd item"
2557 );
2558
2559 let history_after_first = open_close_queried_buffer("lib", 1, "lib.rs", &workspace, cx).await;
2560 assert_eq!(
2561 history_after_first.first().unwrap().absolute,
2562 PathBuf::from(path!("/test/new.rs")),
2563 "Should show 1st opened item in the history when opening the 2nd item"
2564 );
2565}
2566
2567#[gpui::test]
2568async fn test_search_results_refreshed_on_adding_and_removing_worktrees(
2569 cx: &mut gpui::TestAppContext,
2570) {
2571 let app_state = init_test(cx);
2572
2573 app_state
2574 .fs
2575 .as_fake()
2576 .insert_tree(
2577 "/test",
2578 json!({
2579 "project_1": {
2580 "bar.rs": "// Bar file",
2581 "lib.rs": "// Lib file",
2582 },
2583 "project_2": {
2584 "Cargo.toml": "// Cargo file",
2585 "main.rs": "// Main file",
2586 }
2587 }),
2588 )
2589 .await;
2590
2591 let project = Project::test(app_state.fs.clone(), ["/test/project_1".as_ref()], cx).await;
2592 let (workspace, cx) =
2593 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2594 let worktree_1_id = project.update(cx, |project, cx| {
2595 let worktree = project.worktrees(cx).last().expect("worktree not found");
2596 worktree.read(cx).id()
2597 });
2598
2599 // Initial state
2600 let picker = open_file_picker(&workspace, cx);
2601 cx.simulate_input("rs");
2602 picker.update(cx, |finder, _| {
2603 assert_eq!(finder.delegate.matches.len(), 3);
2604 assert_match_at_position(finder, 0, "bar.rs");
2605 assert_match_at_position(finder, 1, "lib.rs");
2606 assert_match_at_position(finder, 2, "rs");
2607 });
2608
2609 // Add new worktree
2610 project
2611 .update(cx, |project, cx| {
2612 project
2613 .find_or_create_worktree("/test/project_2", true, cx)
2614 .into_future()
2615 })
2616 .await
2617 .expect("unable to create workdir");
2618 cx.executor().advance_clock(FS_WATCH_LATENCY);
2619
2620 // main.rs is among search results
2621 picker.update(cx, |finder, _| {
2622 assert_eq!(finder.delegate.matches.len(), 4);
2623 assert_match_at_position(finder, 0, "bar.rs");
2624 assert_match_at_position(finder, 1, "lib.rs");
2625 assert_match_at_position(finder, 2, "main.rs");
2626 assert_match_at_position(finder, 3, "rs");
2627 });
2628
2629 // Remove the first worktree
2630 project.update(cx, |project, cx| {
2631 project.remove_worktree(worktree_1_id, cx);
2632 });
2633 cx.executor().advance_clock(FS_WATCH_LATENCY);
2634
2635 // Files from the first worktree are not in the search results anymore
2636 picker.update(cx, |finder, _| {
2637 assert_eq!(finder.delegate.matches.len(), 2);
2638 assert_match_at_position(finder, 0, "main.rs");
2639 assert_match_at_position(finder, 1, "rs");
2640 });
2641}
2642
2643#[gpui::test]
2644async fn test_history_items_uniqueness_for_multiple_worktree_open_all_files(
2645 cx: &mut TestAppContext,
2646) {
2647 let app_state = init_test(cx);
2648 app_state
2649 .fs
2650 .as_fake()
2651 .insert_tree(
2652 path!("/repo1"),
2653 json!({
2654 "package.json": r#"{"name": "repo1"}"#,
2655 "src": {
2656 "index.js": "// Repo 1 index",
2657 }
2658 }),
2659 )
2660 .await;
2661
2662 app_state
2663 .fs
2664 .as_fake()
2665 .insert_tree(
2666 path!("/repo2"),
2667 json!({
2668 "package.json": r#"{"name": "repo2"}"#,
2669 "src": {
2670 "index.js": "// Repo 2 index",
2671 }
2672 }),
2673 )
2674 .await;
2675
2676 let project = Project::test(
2677 app_state.fs.clone(),
2678 [path!("/repo1").as_ref(), path!("/repo2").as_ref()],
2679 cx,
2680 )
2681 .await;
2682
2683 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2684 let (worktree_id1, worktree_id2) = cx.read(|cx| {
2685 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
2686 (worktrees[0].read(cx).id(), worktrees[1].read(cx).id())
2687 });
2688
2689 workspace
2690 .update_in(cx, |workspace, window, cx| {
2691 workspace.open_path(
2692 ProjectPath {
2693 worktree_id: worktree_id1,
2694 path: rel_path("package.json").into(),
2695 },
2696 None,
2697 true,
2698 window,
2699 cx,
2700 )
2701 })
2702 .await
2703 .unwrap();
2704
2705 cx.dispatch_action(workspace::CloseActiveItem {
2706 save_intent: None,
2707 close_pinned: false,
2708 });
2709 workspace
2710 .update_in(cx, |workspace, window, cx| {
2711 workspace.open_path(
2712 ProjectPath {
2713 worktree_id: worktree_id2,
2714 path: rel_path("package.json").into(),
2715 },
2716 None,
2717 true,
2718 window,
2719 cx,
2720 )
2721 })
2722 .await
2723 .unwrap();
2724
2725 cx.dispatch_action(workspace::CloseActiveItem {
2726 save_intent: None,
2727 close_pinned: false,
2728 });
2729
2730 let picker = open_file_picker(&workspace, cx);
2731 cx.simulate_input("package.json");
2732
2733 picker.update(cx, |finder, _| {
2734 let matches = &finder.delegate.matches.matches;
2735
2736 assert_eq!(
2737 matches.len(),
2738 2,
2739 "Expected 1 history match + 1 search matches, but got {} matches: {:?}",
2740 matches.len(),
2741 matches
2742 );
2743
2744 assert_matches!(matches[0], Match::History { .. });
2745
2746 let search_matches = collect_search_matches(finder);
2747 assert_eq!(
2748 search_matches.history.len(),
2749 2,
2750 "Should have exactly 2 history match"
2751 );
2752 assert_eq!(
2753 search_matches.search.len(),
2754 0,
2755 "Should have exactly 0 search match (because we already opened the 2 package.json)"
2756 );
2757
2758 if let Match::History { path, panel_match } = &matches[0] {
2759 assert_eq!(path.project.worktree_id, worktree_id2);
2760 assert_eq!(path.project.path.as_ref(), rel_path("package.json"));
2761 let panel_match = panel_match.as_ref().unwrap();
2762 assert_eq!(panel_match.0.path_prefix, rel_path("repo2").into());
2763 assert_eq!(panel_match.0.path, rel_path("package.json").into());
2764 assert_eq!(
2765 panel_match.0.positions,
2766 vec![6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]
2767 );
2768 }
2769
2770 if let Match::History { path, panel_match } = &matches[1] {
2771 assert_eq!(path.project.worktree_id, worktree_id1);
2772 assert_eq!(path.project.path.as_ref(), rel_path("package.json"));
2773 let panel_match = panel_match.as_ref().unwrap();
2774 assert_eq!(panel_match.0.path_prefix, rel_path("repo1").into());
2775 assert_eq!(panel_match.0.path, rel_path("package.json").into());
2776 assert_eq!(
2777 panel_match.0.positions,
2778 vec![6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17]
2779 );
2780 }
2781 });
2782}
2783
2784#[gpui::test]
2785async fn test_selected_match_stays_selected_after_matches_refreshed(cx: &mut gpui::TestAppContext) {
2786 let app_state = init_test(cx);
2787
2788 app_state.fs.as_fake().insert_tree("/src", json!({})).await;
2789
2790 app_state
2791 .fs
2792 .create_dir("/src/even".as_ref())
2793 .await
2794 .expect("unable to create dir");
2795
2796 let initial_files_num = 5;
2797 for i in 0..initial_files_num {
2798 let filename = format!("/src/even/file_{}.txt", 10 + i);
2799 app_state
2800 .fs
2801 .create_file(Path::new(&filename), Default::default())
2802 .await
2803 .expect("unable to create file");
2804 }
2805
2806 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
2807 let (workspace, cx) =
2808 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2809
2810 // Initial state
2811 let picker = open_file_picker(&workspace, cx);
2812 cx.simulate_input("file");
2813 let selected_index = 3;
2814 // Checking only the filename, not the whole path
2815 let selected_file = format!("file_{}.txt", 10 + selected_index);
2816 // Select even/file_13.txt
2817 for _ in 0..selected_index {
2818 cx.dispatch_action(SelectNext);
2819 }
2820
2821 picker.update(cx, |finder, _| {
2822 assert_match_selection(finder, selected_index, &selected_file)
2823 });
2824
2825 // Add more matches to the search results
2826 let files_to_add = 10;
2827 for i in 0..files_to_add {
2828 let filename = format!("/src/file_{}.txt", 20 + i);
2829 app_state
2830 .fs
2831 .create_file(Path::new(&filename), Default::default())
2832 .await
2833 .expect("unable to create file");
2834 }
2835 cx.executor().advance_clock(FS_WATCH_LATENCY);
2836
2837 // file_13.txt is still selected
2838 picker.update(cx, |finder, _| {
2839 let expected_selected_index = selected_index + files_to_add;
2840 assert_match_selection(finder, expected_selected_index, &selected_file);
2841 });
2842}
2843
2844#[gpui::test]
2845async fn test_first_match_selected_if_previous_one_is_not_in_the_match_list(
2846 cx: &mut gpui::TestAppContext,
2847) {
2848 let app_state = init_test(cx);
2849
2850 app_state
2851 .fs
2852 .as_fake()
2853 .insert_tree(
2854 "/src",
2855 json!({
2856 "file_1.txt": "// file_1",
2857 "file_2.txt": "// file_2",
2858 "file_3.txt": "// file_3",
2859 }),
2860 )
2861 .await;
2862
2863 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
2864 let (workspace, cx) =
2865 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
2866
2867 // Initial state
2868 let picker = open_file_picker(&workspace, cx);
2869 cx.simulate_input("file");
2870 // Select even/file_2.txt
2871 cx.dispatch_action(SelectNext);
2872
2873 // Remove the selected entry
2874 app_state
2875 .fs
2876 .remove_file("/src/file_2.txt".as_ref(), Default::default())
2877 .await
2878 .expect("unable to remove file");
2879 cx.executor().advance_clock(FS_WATCH_LATENCY);
2880
2881 // file_1.txt is now selected
2882 picker.update(cx, |finder, _| {
2883 assert_match_selection(finder, 0, "file_1.txt");
2884 });
2885}
2886
2887#[gpui::test]
2888async fn test_keeps_file_finder_open_after_modifier_keys_release(cx: &mut gpui::TestAppContext) {
2889 let app_state = init_test(cx);
2890
2891 app_state
2892 .fs
2893 .as_fake()
2894 .insert_tree(
2895 path!("/test"),
2896 json!({
2897 "1.txt": "// One",
2898 }),
2899 )
2900 .await;
2901
2902 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
2903 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2904
2905 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
2906
2907 cx.simulate_modifiers_change(Modifiers::secondary_key());
2908 open_file_picker(&workspace, cx);
2909
2910 cx.simulate_modifiers_change(Modifiers::none());
2911 active_file_picker(&workspace, cx);
2912}
2913
2914#[gpui::test]
2915async fn test_opens_file_on_modifier_keys_release(cx: &mut gpui::TestAppContext) {
2916 let app_state = init_test(cx);
2917
2918 app_state
2919 .fs
2920 .as_fake()
2921 .insert_tree(
2922 path!("/test"),
2923 json!({
2924 "1.txt": "// One",
2925 "2.txt": "// Two",
2926 }),
2927 )
2928 .await;
2929
2930 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
2931 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2932
2933 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
2934 open_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
2935
2936 cx.simulate_modifiers_change(Modifiers::secondary_key());
2937 let picker = open_file_picker(&workspace, cx);
2938 picker.update(cx, |finder, _| {
2939 assert_eq!(finder.delegate.matches.len(), 2);
2940 assert_match_selection(finder, 0, "2.txt");
2941 assert_match_at_position(finder, 1, "1.txt");
2942 });
2943
2944 cx.dispatch_action(SelectNext);
2945 cx.simulate_modifiers_change(Modifiers::none());
2946 cx.read(|cx| {
2947 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
2948 assert_eq!(active_editor.read(cx).title(cx), "1.txt");
2949 });
2950}
2951
2952#[gpui::test]
2953async fn test_switches_between_release_norelease_modes_on_forward_nav(
2954 cx: &mut gpui::TestAppContext,
2955) {
2956 let app_state = init_test(cx);
2957
2958 app_state
2959 .fs
2960 .as_fake()
2961 .insert_tree(
2962 path!("/test"),
2963 json!({
2964 "1.txt": "// One",
2965 "2.txt": "// Two",
2966 }),
2967 )
2968 .await;
2969
2970 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
2971 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
2972
2973 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
2974 open_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
2975
2976 // Open with a shortcut
2977 cx.simulate_modifiers_change(Modifiers::secondary_key());
2978 let picker = open_file_picker(&workspace, cx);
2979 picker.update(cx, |finder, _| {
2980 assert_eq!(finder.delegate.matches.len(), 2);
2981 assert_match_selection(finder, 0, "2.txt");
2982 assert_match_at_position(finder, 1, "1.txt");
2983 });
2984
2985 // Switch to navigating with other shortcuts
2986 // Don't open file on modifiers release
2987 cx.simulate_modifiers_change(Modifiers::control());
2988 cx.dispatch_action(SelectNext);
2989 cx.simulate_modifiers_change(Modifiers::none());
2990 picker.update(cx, |finder, _| {
2991 assert_eq!(finder.delegate.matches.len(), 2);
2992 assert_match_at_position(finder, 0, "2.txt");
2993 assert_match_selection(finder, 1, "1.txt");
2994 });
2995
2996 // Back to navigation with initial shortcut
2997 // Open file on modifiers release
2998 cx.simulate_modifiers_change(Modifiers::secondary_key());
2999 cx.dispatch_action(ToggleFileFinder::default());
3000 cx.simulate_modifiers_change(Modifiers::none());
3001 cx.read(|cx| {
3002 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
3003 assert_eq!(active_editor.read(cx).title(cx), "2.txt");
3004 });
3005}
3006
3007#[gpui::test]
3008async fn test_switches_between_release_norelease_modes_on_backward_nav(
3009 cx: &mut gpui::TestAppContext,
3010) {
3011 let app_state = init_test(cx);
3012
3013 app_state
3014 .fs
3015 .as_fake()
3016 .insert_tree(
3017 path!("/test"),
3018 json!({
3019 "1.txt": "// One",
3020 "2.txt": "// Two",
3021 "3.txt": "// Three"
3022 }),
3023 )
3024 .await;
3025
3026 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
3027 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
3028
3029 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
3030 open_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
3031 open_queried_buffer("3", 1, "3.txt", &workspace, cx).await;
3032
3033 // Open with a shortcut
3034 cx.simulate_modifiers_change(Modifiers::secondary_key());
3035 let picker = open_file_picker(&workspace, cx);
3036 picker.update(cx, |finder, _| {
3037 assert_eq!(finder.delegate.matches.len(), 3);
3038 assert_match_selection(finder, 0, "3.txt");
3039 assert_match_at_position(finder, 1, "2.txt");
3040 assert_match_at_position(finder, 2, "1.txt");
3041 });
3042
3043 // Switch to navigating with other shortcuts
3044 // Don't open file on modifiers release
3045 cx.simulate_modifiers_change(Modifiers::control());
3046 cx.dispatch_action(menu::SelectPrevious);
3047 cx.simulate_modifiers_change(Modifiers::none());
3048 picker.update(cx, |finder, _| {
3049 assert_eq!(finder.delegate.matches.len(), 3);
3050 assert_match_at_position(finder, 0, "3.txt");
3051 assert_match_at_position(finder, 1, "2.txt");
3052 assert_match_selection(finder, 2, "1.txt");
3053 });
3054
3055 // Back to navigation with initial shortcut
3056 // Open file on modifiers release
3057 cx.simulate_modifiers_change(Modifiers::secondary_key());
3058 cx.dispatch_action(SelectPrevious); // <-- File Finder's SelectPrevious, not menu's
3059 cx.simulate_modifiers_change(Modifiers::none());
3060 cx.read(|cx| {
3061 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
3062 assert_eq!(active_editor.read(cx).title(cx), "3.txt");
3063 });
3064}
3065
3066#[gpui::test]
3067async fn test_extending_modifiers_does_not_confirm_selection(cx: &mut gpui::TestAppContext) {
3068 let app_state = init_test(cx);
3069
3070 app_state
3071 .fs
3072 .as_fake()
3073 .insert_tree(
3074 path!("/test"),
3075 json!({
3076 "1.txt": "// One",
3077 }),
3078 )
3079 .await;
3080
3081 let project = Project::test(app_state.fs.clone(), [path!("/test").as_ref()], cx).await;
3082 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
3083
3084 open_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
3085
3086 cx.simulate_modifiers_change(Modifiers::secondary_key());
3087 open_file_picker(&workspace, cx);
3088
3089 cx.simulate_modifiers_change(Modifiers::command_shift());
3090 active_file_picker(&workspace, cx);
3091}
3092
3093#[gpui::test]
3094async fn test_repeat_toggle_action(cx: &mut gpui::TestAppContext) {
3095 let app_state = init_test(cx);
3096 app_state
3097 .fs
3098 .as_fake()
3099 .insert_tree(
3100 "/test",
3101 json!({
3102 "00.txt": "",
3103 "01.txt": "",
3104 "02.txt": "",
3105 "03.txt": "",
3106 "04.txt": "",
3107 "05.txt": "",
3108 }),
3109 )
3110 .await;
3111
3112 let project = Project::test(app_state.fs.clone(), ["/test".as_ref()], cx).await;
3113 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
3114
3115 cx.dispatch_action(ToggleFileFinder::default());
3116 let picker = active_file_picker(&workspace, cx);
3117
3118 picker.update_in(cx, |picker, window, cx| {
3119 picker.update_matches(".txt".to_string(), window, cx)
3120 });
3121
3122 cx.run_until_parked();
3123
3124 picker.update(cx, |picker, _| {
3125 assert_eq!(picker.delegate.matches.len(), 7);
3126 assert_eq!(picker.delegate.selected_index, 0);
3127 });
3128
3129 // When toggling repeatedly, the picker scrolls to reveal the selected item.
3130 cx.dispatch_action(ToggleFileFinder::default());
3131 cx.dispatch_action(ToggleFileFinder::default());
3132 cx.dispatch_action(ToggleFileFinder::default());
3133
3134 cx.run_until_parked();
3135
3136 picker.update(cx, |picker, _| {
3137 assert_eq!(picker.delegate.matches.len(), 7);
3138 assert_eq!(picker.delegate.selected_index, 3);
3139 });
3140}
3141
3142async fn open_close_queried_buffer(
3143 input: &str,
3144 expected_matches: usize,
3145 expected_editor_title: &str,
3146 workspace: &Entity<Workspace>,
3147 cx: &mut gpui::VisualTestContext,
3148) -> Vec<FoundPath> {
3149 let history_items = open_queried_buffer(
3150 input,
3151 expected_matches,
3152 expected_editor_title,
3153 workspace,
3154 cx,
3155 )
3156 .await;
3157
3158 cx.dispatch_action(workspace::CloseActiveItem {
3159 save_intent: None,
3160 close_pinned: false,
3161 });
3162
3163 history_items
3164}
3165
3166async fn open_queried_buffer(
3167 input: &str,
3168 expected_matches: usize,
3169 expected_editor_title: &str,
3170 workspace: &Entity<Workspace>,
3171 cx: &mut gpui::VisualTestContext,
3172) -> Vec<FoundPath> {
3173 let picker = open_file_picker(workspace, cx);
3174 cx.simulate_input(input);
3175
3176 let history_items = picker.update(cx, |finder, _| {
3177 assert_eq!(
3178 finder.delegate.matches.len(),
3179 expected_matches + 1, // +1 from CreateNew option
3180 "Unexpected number of matches found for query `{input}`, matches: {:?}",
3181 finder.delegate.matches
3182 );
3183 finder.delegate.history_items.clone()
3184 });
3185
3186 cx.dispatch_action(Confirm);
3187
3188 cx.read(|cx| {
3189 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
3190 let active_editor_title = active_editor.read(cx).title(cx);
3191 assert_eq!(
3192 expected_editor_title, active_editor_title,
3193 "Unexpected editor title for query `{input}`"
3194 );
3195 });
3196
3197 history_items
3198}
3199
3200fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
3201 cx.update(|cx| {
3202 let state = AppState::test(cx);
3203 theme::init(theme::LoadThemes::JustBase, cx);
3204 super::init(cx);
3205 editor::init(cx);
3206 state
3207 })
3208}
3209
3210fn test_path_position(test_str: &str) -> FileSearchQuery {
3211 let path_position = PathWithPosition::parse_str(test_str);
3212
3213 FileSearchQuery {
3214 raw_query: test_str.to_owned(),
3215 file_query_end: if path_position.path.to_str().unwrap() == test_str {
3216 None
3217 } else {
3218 Some(path_position.path.to_str().unwrap().len())
3219 },
3220 path_position,
3221 }
3222}
3223
3224fn build_find_picker(
3225 project: Entity<Project>,
3226 cx: &mut TestAppContext,
3227) -> (
3228 Entity<Picker<FileFinderDelegate>>,
3229 Entity<Workspace>,
3230 &mut VisualTestContext,
3231) {
3232 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
3233 let picker = open_file_picker(&workspace, cx);
3234 (picker, workspace, cx)
3235}
3236
3237#[track_caller]
3238fn open_file_picker(
3239 workspace: &Entity<Workspace>,
3240 cx: &mut VisualTestContext,
3241) -> Entity<Picker<FileFinderDelegate>> {
3242 cx.dispatch_action(ToggleFileFinder {
3243 separate_history: true,
3244 });
3245 active_file_picker(workspace, cx)
3246}
3247
3248#[track_caller]
3249fn active_file_picker(
3250 workspace: &Entity<Workspace>,
3251 cx: &mut VisualTestContext,
3252) -> Entity<Picker<FileFinderDelegate>> {
3253 workspace.update(cx, |workspace, cx| {
3254 workspace
3255 .active_modal::<FileFinder>(cx)
3256 .expect("file finder is not open")
3257 .read(cx)
3258 .picker
3259 .clone()
3260 })
3261}
3262
3263#[derive(Debug, Default)]
3264struct SearchEntries {
3265 history: Vec<Arc<RelPath>>,
3266 history_found_paths: Vec<FoundPath>,
3267 search: Vec<Arc<RelPath>>,
3268 search_matches: Vec<PathMatch>,
3269}
3270
3271impl SearchEntries {
3272 #[track_caller]
3273 fn search_paths_only(self) -> Vec<Arc<RelPath>> {
3274 assert!(
3275 self.history.is_empty(),
3276 "Should have no history matches, but got: {:?}",
3277 self.history
3278 );
3279 self.search
3280 }
3281
3282 #[track_caller]
3283 fn search_matches_only(self) -> Vec<PathMatch> {
3284 assert!(
3285 self.history.is_empty(),
3286 "Should have no history matches, but got: {:?}",
3287 self.history
3288 );
3289 self.search_matches
3290 }
3291}
3292
3293fn collect_search_matches(picker: &Picker<FileFinderDelegate>) -> SearchEntries {
3294 let mut search_entries = SearchEntries::default();
3295 for m in &picker.delegate.matches.matches {
3296 match &m {
3297 Match::History {
3298 path: history_path,
3299 panel_match: path_match,
3300 } => {
3301 if let Some(path_match) = path_match.as_ref() {
3302 search_entries
3303 .history
3304 .push(path_match.0.path_prefix.join(&path_match.0.path));
3305 } else {
3306 // This occurs when the query is empty and we show history matches
3307 // that are outside the project.
3308 panic!("currently not exercised in tests");
3309 }
3310 search_entries
3311 .history_found_paths
3312 .push(history_path.clone());
3313 }
3314 Match::Search(path_match) => {
3315 search_entries
3316 .search
3317 .push(path_match.0.path_prefix.join(&path_match.0.path));
3318 search_entries.search_matches.push(path_match.0.clone());
3319 }
3320 Match::CreateNew(_) => {}
3321 }
3322 }
3323 search_entries
3324}
3325
3326#[track_caller]
3327fn assert_match_selection(
3328 finder: &Picker<FileFinderDelegate>,
3329 expected_selection_index: usize,
3330 expected_file_name: &str,
3331) {
3332 assert_eq!(
3333 finder.delegate.selected_index(),
3334 expected_selection_index,
3335 "Match is not selected"
3336 );
3337 assert_match_at_position(finder, expected_selection_index, expected_file_name);
3338}
3339
3340#[track_caller]
3341fn assert_match_at_position(
3342 finder: &Picker<FileFinderDelegate>,
3343 match_index: usize,
3344 expected_file_name: &str,
3345) {
3346 let match_item = finder
3347 .delegate
3348 .matches
3349 .get(match_index)
3350 .unwrap_or_else(|| panic!("Finder has no match for index {match_index}"));
3351 let match_file_name = match &match_item {
3352 Match::History { path, .. } => path.absolute.file_name().and_then(|s| s.to_str()),
3353 Match::Search(path_match) => path_match.0.path.file_name(),
3354 Match::CreateNew(project_path) => project_path.path.file_name(),
3355 }
3356 .unwrap();
3357 assert_eq!(match_file_name, expected_file_name);
3358}
3359
3360#[gpui::test]
3361async fn test_filename_precedence(cx: &mut TestAppContext) {
3362 let app_state = init_test(cx);
3363
3364 app_state
3365 .fs
3366 .as_fake()
3367 .insert_tree(
3368 path!("/src"),
3369 json!({
3370 "layout": {
3371 "app.css": "",
3372 "app.d.ts": "",
3373 "app.html": "",
3374 "+page.svelte": "",
3375 },
3376 "routes": {
3377 "+layout.svelte": "",
3378 }
3379 }),
3380 )
3381 .await;
3382
3383 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
3384 let (picker, _, cx) = build_find_picker(project, cx);
3385
3386 cx.simulate_input("layout");
3387
3388 picker.update(cx, |finder, _| {
3389 let search_matches = collect_search_matches(finder).search_paths_only();
3390
3391 assert_eq!(
3392 search_matches,
3393 vec![
3394 rel_path("routes/+layout.svelte").into(),
3395 rel_path("layout/app.css").into(),
3396 rel_path("layout/app.d.ts").into(),
3397 rel_path("layout/app.html").into(),
3398 rel_path("layout/+page.svelte").into(),
3399 ],
3400 "File with 'layout' in filename should be prioritized over files in 'layout' directory"
3401 );
3402 });
3403}
3404
3405#[gpui::test]
3406async fn test_paths_with_starting_slash(cx: &mut TestAppContext) {
3407 let app_state = init_test(cx);
3408 app_state
3409 .fs
3410 .as_fake()
3411 .insert_tree(
3412 path!("/root"),
3413 json!({
3414 "a": {
3415 "file1.txt": "",
3416 "b": {
3417 "file2.txt": "",
3418 },
3419 }
3420 }),
3421 )
3422 .await;
3423
3424 let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
3425
3426 let (picker, workspace, cx) = build_find_picker(project, cx);
3427
3428 let matching_abs_path = "/file1.txt".to_string();
3429 picker
3430 .update_in(cx, |picker, window, cx| {
3431 picker
3432 .delegate
3433 .update_matches(matching_abs_path, window, cx)
3434 })
3435 .await;
3436 picker.update(cx, |picker, _| {
3437 assert_eq!(
3438 collect_search_matches(picker).search_paths_only(),
3439 vec![rel_path("a/file1.txt").into()],
3440 "Relative path starting with slash should match"
3441 )
3442 });
3443 cx.dispatch_action(SelectNext);
3444 cx.dispatch_action(Confirm);
3445 cx.read(|cx| {
3446 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
3447 assert_eq!(active_editor.read(cx).title(cx), "file1.txt");
3448 });
3449}
3450
3451#[gpui::test]
3452async fn test_clear_navigation_history(cx: &mut TestAppContext) {
3453 let app_state = init_test(cx);
3454 app_state
3455 .fs
3456 .as_fake()
3457 .insert_tree(
3458 path!("/src"),
3459 json!({
3460 "test": {
3461 "first.rs": "// First file",
3462 "second.rs": "// Second file",
3463 "third.rs": "// Third file",
3464 }
3465 }),
3466 )
3467 .await;
3468
3469 let project = Project::test(app_state.fs.clone(), [path!("/src").as_ref()], cx).await;
3470 let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
3471
3472 workspace.update_in(cx, |_workspace, window, cx| window.focused(cx));
3473
3474 // Open some files to generate navigation history
3475 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
3476 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
3477 let history_before_clear =
3478 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
3479
3480 assert_eq!(
3481 history_before_clear.len(),
3482 2,
3483 "Should have history items before clearing"
3484 );
3485
3486 // Verify that file finder shows history items
3487 let picker = open_file_picker(&workspace, cx);
3488 cx.simulate_input("fir");
3489 picker.update(cx, |finder, _| {
3490 let matches = collect_search_matches(finder);
3491 assert!(
3492 !matches.history.is_empty(),
3493 "File finder should show history items before clearing"
3494 );
3495 });
3496 workspace.update_in(cx, |_, window, cx| {
3497 window.dispatch_action(menu::Cancel.boxed_clone(), cx);
3498 });
3499
3500 // Verify navigation state before clear
3501 workspace.update(cx, |workspace, cx| {
3502 let pane = workspace.active_pane();
3503 pane.read(cx).can_navigate_backward()
3504 });
3505
3506 // Clear navigation history
3507 cx.dispatch_action(workspace::ClearNavigationHistory);
3508
3509 // Verify that navigation is disabled immediately after clear
3510 workspace.update(cx, |workspace, cx| {
3511 let pane = workspace.active_pane();
3512 assert!(
3513 !pane.read(cx).can_navigate_backward(),
3514 "Should not be able to navigate backward after clearing history"
3515 );
3516 assert!(
3517 !pane.read(cx).can_navigate_forward(),
3518 "Should not be able to navigate forward after clearing history"
3519 );
3520 });
3521
3522 // Verify that file finder no longer shows history items
3523 let picker = open_file_picker(&workspace, cx);
3524 cx.simulate_input("fir");
3525 picker.update(cx, |finder, _| {
3526 let matches = collect_search_matches(finder);
3527 assert!(
3528 matches.history.is_empty(),
3529 "File finder should not show history items after clearing"
3530 );
3531 });
3532 workspace.update_in(cx, |_, window, cx| {
3533 window.dispatch_action(menu::Cancel.boxed_clone(), cx);
3534 });
3535
3536 // Verify history is empty by opening a new file
3537 // (this should not show any previous history)
3538 let history_after_clear =
3539 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
3540 assert_eq!(
3541 history_after_clear.len(),
3542 0,
3543 "Should have no history items after clearing"
3544 );
3545}