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