1use std::{assert_eq, path::Path, time::Duration};
2
3use super::*;
4use editor::Editor;
5use gpui::{Entity, TestAppContext, VisualTestContext};
6use menu::{Confirm, SelectNext};
7use serde_json::json;
8use workspace::{AppState, Workspace};
9
10#[ctor::ctor]
11fn init_logger() {
12 if std::env::var("RUST_LOG").is_ok() {
13 env_logger::init();
14 }
15}
16
17#[gpui::test]
18async fn test_matching_paths(cx: &mut TestAppContext) {
19 let app_state = init_test(cx);
20 app_state
21 .fs
22 .as_fake()
23 .insert_tree(
24 "/root",
25 json!({
26 "a": {
27 "banana": "",
28 "bandana": "",
29 }
30 }),
31 )
32 .await;
33
34 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
35
36 let (picker, workspace, cx) = build_find_picker(project, cx);
37
38 cx.simulate_input("bna");
39 picker.update(cx, |picker, _| {
40 assert_eq!(picker.delegate.matches.len(), 2);
41 });
42 cx.dispatch_action(SelectNext);
43 cx.dispatch_action(Confirm);
44 cx.read(|cx| {
45 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
46 assert_eq!(active_editor.read(cx).title(cx), "bandana");
47 });
48
49 for bandana_query in [
50 "bandana",
51 " bandana",
52 "bandana ",
53 " bandana ",
54 " ndan ",
55 " band ",
56 "a bandana",
57 ] {
58 picker
59 .update(cx, |picker, cx| {
60 picker
61 .delegate
62 .update_matches(bandana_query.to_string(), cx)
63 })
64 .await;
65 picker.update(cx, |picker, _| {
66 assert_eq!(
67 picker.delegate.matches.len(),
68 1,
69 "Wrong number of matches for bandana query '{bandana_query}'"
70 );
71 });
72 cx.dispatch_action(SelectNext);
73 cx.dispatch_action(Confirm);
74 cx.read(|cx| {
75 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
76 assert_eq!(
77 active_editor.read(cx).title(cx),
78 "bandana",
79 "Wrong match for bandana query '{bandana_query}'"
80 );
81 });
82 }
83}
84
85#[gpui::test]
86async fn test_absolute_paths(cx: &mut TestAppContext) {
87 let app_state = init_test(cx);
88 app_state
89 .fs
90 .as_fake()
91 .insert_tree(
92 "/root",
93 json!({
94 "a": {
95 "file1.txt": "",
96 "b": {
97 "file2.txt": "",
98 },
99 }
100 }),
101 )
102 .await;
103
104 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
105
106 let (picker, workspace, cx) = build_find_picker(project, cx);
107
108 let matching_abs_path = "/root/a/b/file2.txt";
109 picker
110 .update(cx, |picker, cx| {
111 picker
112 .delegate
113 .update_matches(matching_abs_path.to_string(), cx)
114 })
115 .await;
116 picker.update(cx, |picker, _| {
117 assert_eq!(
118 collect_search_matches(picker).search_only(),
119 vec![PathBuf::from("a/b/file2.txt")],
120 "Matching abs path should be the only match"
121 )
122 });
123 cx.dispatch_action(SelectNext);
124 cx.dispatch_action(Confirm);
125 cx.read(|cx| {
126 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
127 assert_eq!(active_editor.read(cx).title(cx), "file2.txt");
128 });
129
130 let mismatching_abs_path = "/root/a/b/file1.txt";
131 picker
132 .update(cx, |picker, cx| {
133 picker
134 .delegate
135 .update_matches(mismatching_abs_path.to_string(), cx)
136 })
137 .await;
138 picker.update(cx, |picker, _| {
139 assert_eq!(
140 collect_search_matches(picker).search_only(),
141 Vec::<PathBuf>::new(),
142 "Mismatching abs path should produce no matches"
143 )
144 });
145}
146
147#[gpui::test]
148async fn test_complex_path(cx: &mut TestAppContext) {
149 let app_state = init_test(cx);
150 app_state
151 .fs
152 .as_fake()
153 .insert_tree(
154 "/root",
155 json!({
156 "其他": {
157 "S数据表格": {
158 "task.xlsx": "some content",
159 },
160 }
161 }),
162 )
163 .await;
164
165 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
166
167 let (picker, workspace, cx) = build_find_picker(project, cx);
168
169 cx.simulate_input("t");
170 picker.update(cx, |picker, _| {
171 assert_eq!(picker.delegate.matches.len(), 1);
172 assert_eq!(
173 collect_search_matches(picker).search_only(),
174 vec![PathBuf::from("其他/S数据表格/task.xlsx")],
175 )
176 });
177 cx.dispatch_action(SelectNext);
178 cx.dispatch_action(Confirm);
179 cx.read(|cx| {
180 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
181 assert_eq!(active_editor.read(cx).title(cx), "task.xlsx");
182 });
183}
184
185#[gpui::test]
186async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
187 let app_state = init_test(cx);
188
189 let first_file_name = "first.rs";
190 let first_file_contents = "// First Rust file";
191 app_state
192 .fs
193 .as_fake()
194 .insert_tree(
195 "/src",
196 json!({
197 "test": {
198 first_file_name: first_file_contents,
199 "second.rs": "// Second Rust file",
200 }
201 }),
202 )
203 .await;
204
205 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
206
207 let (picker, workspace, cx) = build_find_picker(project, cx);
208
209 let file_query = &first_file_name[..3];
210 let file_row = 1;
211 let file_column = 3;
212 assert!(file_column <= first_file_contents.len());
213 let query_inside_file = format!("{file_query}:{file_row}:{file_column}");
214 picker
215 .update(cx, |finder, cx| {
216 finder
217 .delegate
218 .update_matches(query_inside_file.to_string(), cx)
219 })
220 .await;
221 picker.update(cx, |finder, _| {
222 let finder = &finder.delegate;
223 assert_eq!(finder.matches.len(), 1);
224 let latest_search_query = finder
225 .latest_search_query
226 .as_ref()
227 .expect("Finder should have a query after the update_matches call");
228 assert_eq!(latest_search_query.path_like.raw_query, query_inside_file);
229 assert_eq!(
230 latest_search_query.path_like.file_query_end,
231 Some(file_query.len())
232 );
233 assert_eq!(latest_search_query.row, Some(file_row));
234 assert_eq!(latest_search_query.column, Some(file_column as u32));
235 });
236
237 cx.dispatch_action(SelectNext);
238 cx.dispatch_action(Confirm);
239
240 let editor = cx.update(|cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
241 cx.executor().advance_clock(Duration::from_secs(2));
242
243 editor.update(cx, |editor, cx| {
244 let all_selections = editor.selections.all_adjusted(cx);
245 assert_eq!(
246 all_selections.len(),
247 1,
248 "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
249 );
250 let caret_selection = all_selections.into_iter().next().unwrap();
251 assert_eq!(caret_selection.start, caret_selection.end,
252 "Caret selection should have its start and end at the same position");
253 assert_eq!(file_row, caret_selection.start.row + 1,
254 "Query inside file should get caret with the same focus row");
255 assert_eq!(file_column, caret_selection.start.column as usize + 1,
256 "Query inside file should get caret with the same focus column");
257 });
258}
259
260#[gpui::test]
261async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
262 let app_state = init_test(cx);
263
264 let first_file_name = "first.rs";
265 let first_file_contents = "// First Rust file";
266 app_state
267 .fs
268 .as_fake()
269 .insert_tree(
270 "/src",
271 json!({
272 "test": {
273 first_file_name: first_file_contents,
274 "second.rs": "// Second Rust file",
275 }
276 }),
277 )
278 .await;
279
280 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
281
282 let (picker, workspace, cx) = build_find_picker(project, cx);
283
284 let file_query = &first_file_name[..3];
285 let file_row = 200;
286 let file_column = 300;
287 assert!(file_column > first_file_contents.len());
288 let query_outside_file = format!("{file_query}:{file_row}:{file_column}");
289 picker
290 .update(cx, |picker, cx| {
291 picker
292 .delegate
293 .update_matches(query_outside_file.to_string(), cx)
294 })
295 .await;
296 picker.update(cx, |finder, _| {
297 let delegate = &finder.delegate;
298 assert_eq!(delegate.matches.len(), 1);
299 let latest_search_query = delegate
300 .latest_search_query
301 .as_ref()
302 .expect("Finder should have a query after the update_matches call");
303 assert_eq!(latest_search_query.path_like.raw_query, query_outside_file);
304 assert_eq!(
305 latest_search_query.path_like.file_query_end,
306 Some(file_query.len())
307 );
308 assert_eq!(latest_search_query.row, Some(file_row));
309 assert_eq!(latest_search_query.column, Some(file_column as u32));
310 });
311
312 cx.dispatch_action(SelectNext);
313 cx.dispatch_action(Confirm);
314
315 let editor = cx.update(|cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
316 cx.executor().advance_clock(Duration::from_secs(2));
317
318 editor.update(cx, |editor, cx| {
319 let all_selections = editor.selections.all_adjusted(cx);
320 assert_eq!(
321 all_selections.len(),
322 1,
323 "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
324 );
325 let caret_selection = all_selections.into_iter().next().unwrap();
326 assert_eq!(caret_selection.start, caret_selection.end,
327 "Caret selection should have its start and end at the same position");
328 assert_eq!(0, caret_selection.start.row,
329 "Excessive rows (as in query outside file borders) should get trimmed to last file row");
330 assert_eq!(first_file_contents.len(), caret_selection.start.column as usize,
331 "Excessive columns (as in query outside file borders) should get trimmed to selected row's last column");
332 });
333}
334
335#[gpui::test]
336async fn test_matching_cancellation(cx: &mut TestAppContext) {
337 let app_state = init_test(cx);
338 app_state
339 .fs
340 .as_fake()
341 .insert_tree(
342 "/dir",
343 json!({
344 "hello": "",
345 "goodbye": "",
346 "halogen-light": "",
347 "happiness": "",
348 "height": "",
349 "hi": "",
350 "hiccup": "",
351 }),
352 )
353 .await;
354
355 let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await;
356
357 let (picker, _, cx) = build_find_picker(project, cx);
358
359 let query = test_path_like("hi");
360 picker
361 .update(cx, |picker, cx| {
362 picker.delegate.spawn_search(query.clone(), cx)
363 })
364 .await;
365
366 picker.update(cx, |picker, _cx| {
367 assert_eq!(picker.delegate.matches.len(), 5)
368 });
369
370 picker.update(cx, |picker, cx| {
371 let delegate = &mut picker.delegate;
372 assert!(
373 delegate.matches.history.is_empty(),
374 "Search matches expected"
375 );
376 let matches = delegate.matches.search.clone();
377
378 // Simulate a search being cancelled after the time limit,
379 // returning only a subset of the matches that would have been found.
380 drop(delegate.spawn_search(query.clone(), cx));
381 delegate.set_search_matches(
382 delegate.latest_search_id,
383 true, // did-cancel
384 query.clone(),
385 vec![matches[1].clone(), matches[3].clone()],
386 cx,
387 );
388
389 // Simulate another cancellation.
390 drop(delegate.spawn_search(query.clone(), cx));
391 delegate.set_search_matches(
392 delegate.latest_search_id,
393 true, // did-cancel
394 query.clone(),
395 vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
396 cx,
397 );
398
399 assert!(
400 delegate.matches.history.is_empty(),
401 "Search matches expected"
402 );
403 assert_eq!(delegate.matches.search.as_slice(), &matches[0..4]);
404 });
405}
406
407#[gpui::test]
408async fn test_ignored_root(cx: &mut TestAppContext) {
409 let app_state = init_test(cx);
410 app_state
411 .fs
412 .as_fake()
413 .insert_tree(
414 "/ancestor",
415 json!({
416 ".gitignore": "ignored-root",
417 "ignored-root": {
418 "happiness": "",
419 "height": "",
420 "hi": "",
421 "hiccup": "",
422 },
423 "tracked-root": {
424 ".gitignore": "height",
425 "happiness": "",
426 "height": "",
427 "hi": "",
428 "hiccup": "",
429 },
430 }),
431 )
432 .await;
433
434 let project = Project::test(
435 app_state.fs.clone(),
436 [
437 "/ancestor/tracked-root".as_ref(),
438 "/ancestor/ignored-root".as_ref(),
439 ],
440 cx,
441 )
442 .await;
443
444 let (picker, _, cx) = build_find_picker(project, cx);
445
446 picker
447 .update(cx, |picker, cx| {
448 picker.delegate.spawn_search(test_path_like("hi"), cx)
449 })
450 .await;
451 picker.update(cx, |picker, _| assert_eq!(picker.delegate.matches.len(), 7));
452}
453
454#[gpui::test]
455async fn test_single_file_worktrees(cx: &mut TestAppContext) {
456 let app_state = init_test(cx);
457 app_state
458 .fs
459 .as_fake()
460 .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
461 .await;
462
463 let project = Project::test(
464 app_state.fs.clone(),
465 ["/root/the-parent-dir/the-file".as_ref()],
466 cx,
467 )
468 .await;
469
470 let (picker, _, cx) = build_find_picker(project, cx);
471
472 // Even though there is only one worktree, that worktree's filename
473 // is included in the matching, because the worktree is a single file.
474 picker
475 .update(cx, |picker, cx| {
476 picker.delegate.spawn_search(test_path_like("thf"), cx)
477 })
478 .await;
479 cx.read(|cx| {
480 let picker = picker.read(cx);
481 let delegate = &picker.delegate;
482 assert!(
483 delegate.matches.history.is_empty(),
484 "Search matches expected"
485 );
486 let matches = delegate.matches.search.clone();
487 assert_eq!(matches.len(), 1);
488
489 let (file_name, file_name_positions, full_path, full_path_positions) =
490 delegate.labels_for_path_match(&matches[0].0);
491 assert_eq!(file_name, "the-file");
492 assert_eq!(file_name_positions, &[0, 1, 4]);
493 assert_eq!(full_path, "the-file");
494 assert_eq!(full_path_positions, &[0, 1, 4]);
495 });
496
497 // Since the worktree root is a file, searching for its name followed by a slash does
498 // not match anything.
499 picker
500 .update(cx, |f, cx| {
501 f.delegate.spawn_search(test_path_like("thf/"), cx)
502 })
503 .await;
504 picker.update(cx, |f, _| assert_eq!(f.delegate.matches.len(), 0));
505}
506
507#[gpui::test]
508async fn test_path_distance_ordering(cx: &mut TestAppContext) {
509 let app_state = init_test(cx);
510 app_state
511 .fs
512 .as_fake()
513 .insert_tree(
514 "/root",
515 json!({
516 "dir1": { "a.txt": "" },
517 "dir2": {
518 "a.txt": "",
519 "b.txt": ""
520 }
521 }),
522 )
523 .await;
524
525 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
526 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
527
528 let worktree_id = cx.read(|cx| {
529 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
530 assert_eq!(worktrees.len(), 1);
531 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
532 });
533
534 // When workspace has an active item, sort items which are closer to that item
535 // first when they have the same name. In this case, b.txt is closer to dir2's a.txt
536 // so that one should be sorted earlier
537 let b_path = ProjectPath {
538 worktree_id,
539 path: Arc::from(Path::new("dir2/b.txt")),
540 };
541 workspace
542 .update(cx, |workspace, cx| {
543 workspace.open_path(b_path, None, true, cx)
544 })
545 .await
546 .unwrap();
547 let finder = open_file_picker(&workspace, cx);
548 finder
549 .update(cx, |f, cx| {
550 f.delegate.spawn_search(test_path_like("a.txt"), cx)
551 })
552 .await;
553
554 finder.update(cx, |f, _| {
555 let delegate = &f.delegate;
556 assert!(
557 delegate.matches.history.is_empty(),
558 "Search matches expected"
559 );
560 let matches = &delegate.matches.search;
561 assert_eq!(matches[0].0.path.as_ref(), Path::new("dir2/a.txt"));
562 assert_eq!(matches[1].0.path.as_ref(), Path::new("dir1/a.txt"));
563 });
564}
565
566#[gpui::test]
567async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
568 let app_state = init_test(cx);
569 app_state
570 .fs
571 .as_fake()
572 .insert_tree(
573 "/root",
574 json!({
575 "dir1": {},
576 "dir2": {
577 "dir3": {}
578 }
579 }),
580 )
581 .await;
582
583 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
584 let (picker, _workspace, cx) = build_find_picker(project, cx);
585
586 picker
587 .update(cx, |f, cx| {
588 f.delegate.spawn_search(test_path_like("dir"), cx)
589 })
590 .await;
591 cx.read(|cx| {
592 let finder = picker.read(cx);
593 assert_eq!(finder.delegate.matches.len(), 0);
594 });
595}
596
597#[gpui::test]
598async fn test_query_history(cx: &mut gpui::TestAppContext) {
599 let app_state = init_test(cx);
600
601 app_state
602 .fs
603 .as_fake()
604 .insert_tree(
605 "/src",
606 json!({
607 "test": {
608 "first.rs": "// First Rust file",
609 "second.rs": "// Second Rust file",
610 "third.rs": "// Third Rust file",
611 }
612 }),
613 )
614 .await;
615
616 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
617 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
618 let worktree_id = cx.read(|cx| {
619 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
620 assert_eq!(worktrees.len(), 1);
621 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
622 });
623
624 // Open and close panels, getting their history items afterwards.
625 // Ensure history items get populated with opened items, and items are kept in a certain order.
626 // The history lags one opened buffer behind, since it's updated in the search panel only on its reopen.
627 //
628 // TODO: without closing, the opened items do not propagate their history changes for some reason
629 // it does work in real app though, only tests do not propagate.
630 workspace.update(cx, |_, cx| cx.focused());
631
632 let initial_history = open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
633 assert!(
634 initial_history.is_empty(),
635 "Should have no history before opening any files"
636 );
637
638 let history_after_first =
639 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
640 assert_eq!(
641 history_after_first,
642 vec![FoundPath::new(
643 ProjectPath {
644 worktree_id,
645 path: Arc::from(Path::new("test/first.rs")),
646 },
647 Some(PathBuf::from("/src/test/first.rs"))
648 )],
649 "Should show 1st opened item in the history when opening the 2nd item"
650 );
651
652 let history_after_second =
653 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
654 assert_eq!(
655 history_after_second,
656 vec![
657 FoundPath::new(
658 ProjectPath {
659 worktree_id,
660 path: Arc::from(Path::new("test/second.rs")),
661 },
662 Some(PathBuf::from("/src/test/second.rs"))
663 ),
664 FoundPath::new(
665 ProjectPath {
666 worktree_id,
667 path: Arc::from(Path::new("test/first.rs")),
668 },
669 Some(PathBuf::from("/src/test/first.rs"))
670 ),
671 ],
672 "Should show 1st and 2nd opened items in the history when opening the 3rd item. \
673 2nd item should be the first in the history, as the last opened."
674 );
675
676 let history_after_third =
677 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
678 assert_eq!(
679 history_after_third,
680 vec![
681 FoundPath::new(
682 ProjectPath {
683 worktree_id,
684 path: Arc::from(Path::new("test/third.rs")),
685 },
686 Some(PathBuf::from("/src/test/third.rs"))
687 ),
688 FoundPath::new(
689 ProjectPath {
690 worktree_id,
691 path: Arc::from(Path::new("test/second.rs")),
692 },
693 Some(PathBuf::from("/src/test/second.rs"))
694 ),
695 FoundPath::new(
696 ProjectPath {
697 worktree_id,
698 path: Arc::from(Path::new("test/first.rs")),
699 },
700 Some(PathBuf::from("/src/test/first.rs"))
701 ),
702 ],
703 "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \
704 3rd item should be the first in the history, as the last opened."
705 );
706
707 let history_after_second_again =
708 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
709 assert_eq!(
710 history_after_second_again,
711 vec![
712 FoundPath::new(
713 ProjectPath {
714 worktree_id,
715 path: Arc::from(Path::new("test/second.rs")),
716 },
717 Some(PathBuf::from("/src/test/second.rs"))
718 ),
719 FoundPath::new(
720 ProjectPath {
721 worktree_id,
722 path: Arc::from(Path::new("test/third.rs")),
723 },
724 Some(PathBuf::from("/src/test/third.rs"))
725 ),
726 FoundPath::new(
727 ProjectPath {
728 worktree_id,
729 path: Arc::from(Path::new("test/first.rs")),
730 },
731 Some(PathBuf::from("/src/test/first.rs"))
732 ),
733 ],
734 "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \
735 2nd item, as the last opened, 3rd item should go next as it was opened right before."
736 );
737}
738
739#[gpui::test]
740async fn test_external_files_history(cx: &mut gpui::TestAppContext) {
741 let app_state = init_test(cx);
742
743 app_state
744 .fs
745 .as_fake()
746 .insert_tree(
747 "/src",
748 json!({
749 "test": {
750 "first.rs": "// First Rust file",
751 "second.rs": "// Second Rust file",
752 }
753 }),
754 )
755 .await;
756
757 app_state
758 .fs
759 .as_fake()
760 .insert_tree(
761 "/external-src",
762 json!({
763 "test": {
764 "third.rs": "// Third Rust file",
765 "fourth.rs": "// Fourth Rust file",
766 }
767 }),
768 )
769 .await;
770
771 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
772 cx.update(|cx| {
773 project.update(cx, |project, cx| {
774 project.find_or_create_local_worktree("/external-src", false, cx)
775 })
776 })
777 .detach();
778 cx.background_executor.run_until_parked();
779
780 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
781 let worktree_id = cx.read(|cx| {
782 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
783 assert_eq!(worktrees.len(), 1,);
784
785 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
786 });
787 workspace
788 .update(cx, |workspace, cx| {
789 workspace.open_abs_path(PathBuf::from("/external-src/test/third.rs"), false, cx)
790 })
791 .detach();
792 cx.background_executor.run_until_parked();
793 let external_worktree_id = cx.read(|cx| {
794 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
795 assert_eq!(
796 worktrees.len(),
797 2,
798 "External file should get opened in a new worktree"
799 );
800
801 WorktreeId::from_usize(
802 worktrees
803 .into_iter()
804 .find(|worktree| worktree.entity_id().as_u64() as usize != worktree_id.to_usize())
805 .expect("New worktree should have a different id")
806 .entity_id()
807 .as_u64() as usize,
808 )
809 });
810 cx.dispatch_action(workspace::CloseActiveItem { save_intent: None });
811
812 let initial_history_items =
813 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
814 assert_eq!(
815 initial_history_items,
816 vec![FoundPath::new(
817 ProjectPath {
818 worktree_id: external_worktree_id,
819 path: Arc::from(Path::new("")),
820 },
821 Some(PathBuf::from("/external-src/test/third.rs"))
822 )],
823 "Should show external file with its full path in the history after it was open"
824 );
825
826 let updated_history_items =
827 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
828 assert_eq!(
829 updated_history_items,
830 vec![
831 FoundPath::new(
832 ProjectPath {
833 worktree_id,
834 path: Arc::from(Path::new("test/second.rs")),
835 },
836 Some(PathBuf::from("/src/test/second.rs"))
837 ),
838 FoundPath::new(
839 ProjectPath {
840 worktree_id: external_worktree_id,
841 path: Arc::from(Path::new("")),
842 },
843 Some(PathBuf::from("/external-src/test/third.rs"))
844 ),
845 ],
846 "Should keep external file with history updates",
847 );
848}
849
850#[gpui::test]
851async fn test_toggle_panel_new_selections(cx: &mut gpui::TestAppContext) {
852 let app_state = init_test(cx);
853
854 app_state
855 .fs
856 .as_fake()
857 .insert_tree(
858 "/src",
859 json!({
860 "test": {
861 "first.rs": "// First Rust file",
862 "second.rs": "// Second Rust file",
863 "third.rs": "// Third Rust file",
864 }
865 }),
866 )
867 .await;
868
869 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
870 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
871
872 // generate some history to select from
873 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
874 cx.executor().run_until_parked();
875 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
876 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
877 let current_history = open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
878
879 for expected_selected_index in 0..current_history.len() {
880 cx.dispatch_action(Toggle);
881 let picker = active_file_picker(&workspace, cx);
882 let selected_index = picker.update(cx, |picker, _| picker.delegate.selected_index());
883 assert_eq!(
884 selected_index, expected_selected_index,
885 "Should select the next item in the history"
886 );
887 }
888
889 cx.dispatch_action(Toggle);
890 let selected_index = workspace.update(cx, |workspace, cx| {
891 workspace
892 .active_modal::<FileFinder>(cx)
893 .unwrap()
894 .read(cx)
895 .picker
896 .read(cx)
897 .delegate
898 .selected_index()
899 });
900 assert_eq!(
901 selected_index, 0,
902 "Should wrap around the history and start all over"
903 );
904}
905
906#[gpui::test]
907async fn test_search_preserves_history_items(cx: &mut gpui::TestAppContext) {
908 let app_state = init_test(cx);
909
910 app_state
911 .fs
912 .as_fake()
913 .insert_tree(
914 "/src",
915 json!({
916 "test": {
917 "first.rs": "// First Rust file",
918 "second.rs": "// Second Rust file",
919 "third.rs": "// Third Rust file",
920 "fourth.rs": "// Fourth Rust file",
921 }
922 }),
923 )
924 .await;
925
926 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
927 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
928 let worktree_id = cx.read(|cx| {
929 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
930 assert_eq!(worktrees.len(), 1,);
931
932 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
933 });
934
935 // generate some history to select from
936 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
937 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
938 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
939 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
940
941 let finder = open_file_picker(&workspace, cx);
942 let first_query = "f";
943 finder
944 .update(cx, |finder, cx| {
945 finder.delegate.update_matches(first_query.to_string(), cx)
946 })
947 .await;
948 finder.update(cx, |finder, _| {
949 let delegate = &finder.delegate;
950 assert_eq!(delegate.matches.history.len(), 1, "Only one history item contains {first_query}, it should be present and others should be filtered out");
951 let history_match = delegate.matches.history.first().unwrap();
952 assert!(history_match.1.is_some(), "Should have path matches for history items after querying");
953 assert_eq!(history_match.0, FoundPath::new(
954 ProjectPath {
955 worktree_id,
956 path: Arc::from(Path::new("test/first.rs")),
957 },
958 Some(PathBuf::from("/src/test/first.rs"))
959 ));
960 assert_eq!(delegate.matches.search.len(), 1, "Only one non-history item contains {first_query}, it should be present");
961 assert_eq!(delegate.matches.search.first().unwrap().0.path.as_ref(), Path::new("test/fourth.rs"));
962 });
963
964 let second_query = "fsdasdsa";
965 let finder = active_file_picker(&workspace, cx);
966 finder
967 .update(cx, |finder, cx| {
968 finder.delegate.update_matches(second_query.to_string(), cx)
969 })
970 .await;
971 finder.update(cx, |finder, _| {
972 let delegate = &finder.delegate;
973 assert!(
974 delegate.matches.history.is_empty(),
975 "No history entries should match {second_query}"
976 );
977 assert!(
978 delegate.matches.search.is_empty(),
979 "No search entries should match {second_query}"
980 );
981 });
982
983 let first_query_again = first_query;
984
985 let finder = active_file_picker(&workspace, cx);
986 finder
987 .update(cx, |finder, cx| {
988 finder
989 .delegate
990 .update_matches(first_query_again.to_string(), cx)
991 })
992 .await;
993 finder.update(cx, |finder, _| {
994 let delegate = &finder.delegate;
995 assert_eq!(delegate.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");
996 let history_match = delegate.matches.history.first().unwrap();
997 assert!(history_match.1.is_some(), "Should have path matches for history items after querying");
998 assert_eq!(history_match.0, FoundPath::new(
999 ProjectPath {
1000 worktree_id,
1001 path: Arc::from(Path::new("test/first.rs")),
1002 },
1003 Some(PathBuf::from("/src/test/first.rs"))
1004 ));
1005 assert_eq!(delegate.matches.search.len(), 1, "Only one non-history item contains {first_query_again}, it should be present, even after non-matching query");
1006 assert_eq!(delegate.matches.search.first().unwrap().0.path.as_ref(), Path::new("test/fourth.rs"));
1007 });
1008}
1009
1010#[gpui::test]
1011async fn test_search_sorts_history_items(cx: &mut gpui::TestAppContext) {
1012 let app_state = init_test(cx);
1013
1014 app_state
1015 .fs
1016 .as_fake()
1017 .insert_tree(
1018 "/root",
1019 json!({
1020 "test": {
1021 "1_qw": "// First file that matches the query",
1022 "2_second": "// Second file",
1023 "3_third": "// Third file",
1024 "4_fourth": "// Fourth file",
1025 "5_qwqwqw": "// A file with 3 more matches than the first one",
1026 "6_qwqwqw": "// Same query matches as above, but closer to the end of the list due to the name",
1027 "7_qwqwqw": "// One more, same amount of query matches as above",
1028 }
1029 }),
1030 )
1031 .await;
1032
1033 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1034 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1035 // generate some history to select from
1036 open_close_queried_buffer("1", 1, "1_qw", &workspace, cx).await;
1037 open_close_queried_buffer("2", 1, "2_second", &workspace, cx).await;
1038 open_close_queried_buffer("3", 1, "3_third", &workspace, cx).await;
1039 open_close_queried_buffer("2", 1, "2_second", &workspace, cx).await;
1040 open_close_queried_buffer("6", 1, "6_qwqwqw", &workspace, cx).await;
1041
1042 let finder = open_file_picker(&workspace, cx);
1043 let query = "qw";
1044 finder
1045 .update(cx, |finder, cx| {
1046 finder.delegate.update_matches(query.to_string(), cx)
1047 })
1048 .await;
1049 finder.update(cx, |finder, _| {
1050 let search_matches = collect_search_matches(finder);
1051 assert_eq!(
1052 search_matches.history,
1053 vec![PathBuf::from("test/1_qw"), PathBuf::from("test/6_qwqwqw"),],
1054 );
1055 assert_eq!(
1056 search_matches.search,
1057 vec![
1058 PathBuf::from("test/5_qwqwqw"),
1059 PathBuf::from("test/7_qwqwqw"),
1060 ],
1061 );
1062 });
1063}
1064
1065#[gpui::test]
1066async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppContext) {
1067 let app_state = init_test(cx);
1068
1069 app_state
1070 .fs
1071 .as_fake()
1072 .insert_tree(
1073 "/src",
1074 json!({
1075 "collab_ui": {
1076 "first.rs": "// First Rust file",
1077 "second.rs": "// Second Rust file",
1078 "third.rs": "// Third Rust file",
1079 "collab_ui.rs": "// Fourth Rust file",
1080 }
1081 }),
1082 )
1083 .await;
1084
1085 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1086 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1087 // generate some history to select from
1088 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1089 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1090 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1091 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1092
1093 let finder = open_file_picker(&workspace, cx);
1094 let query = "collab_ui";
1095 cx.simulate_input(query);
1096 finder.update(cx, |finder, _| {
1097 let delegate = &finder.delegate;
1098 assert!(
1099 delegate.matches.history.is_empty(),
1100 "History items should not math query {query}, they should be matched by name only"
1101 );
1102
1103 let search_entries = delegate
1104 .matches
1105 .search
1106 .iter()
1107 .map(|path_match| path_match.0.path.to_path_buf())
1108 .collect::<Vec<_>>();
1109 assert_eq!(
1110 search_entries,
1111 vec![
1112 PathBuf::from("collab_ui/collab_ui.rs"),
1113 PathBuf::from("collab_ui/first.rs"),
1114 PathBuf::from("collab_ui/third.rs"),
1115 PathBuf::from("collab_ui/second.rs"),
1116 ],
1117 "Despite all search results having the same directory name, the most matching one should be on top"
1118 );
1119 });
1120}
1121
1122#[gpui::test]
1123async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) {
1124 let app_state = init_test(cx);
1125
1126 app_state
1127 .fs
1128 .as_fake()
1129 .insert_tree(
1130 "/src",
1131 json!({
1132 "test": {
1133 "first.rs": "// First Rust file",
1134 "nonexistent.rs": "// Second Rust file",
1135 "third.rs": "// Third Rust file",
1136 }
1137 }),
1138 )
1139 .await;
1140
1141 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1142 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); // generate some history to select from
1143 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1144 open_close_queried_buffer("non", 1, "nonexistent.rs", &workspace, cx).await;
1145 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1146 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1147
1148 let picker = open_file_picker(&workspace, cx);
1149 cx.simulate_input("rs");
1150
1151 picker.update(cx, |finder, _| {
1152 let history_entries = finder.delegate
1153 .matches
1154 .history
1155 .iter()
1156 .map(|(_, path_match)| path_match.as_ref().expect("should have a path match").0.path.to_path_buf())
1157 .collect::<Vec<_>>();
1158 assert_eq!(
1159 history_entries,
1160 vec![
1161 PathBuf::from("test/first.rs"),
1162 PathBuf::from("test/third.rs"),
1163 ],
1164 "Should have all opened files in the history, except the ones that do not exist on disk"
1165 );
1166 });
1167}
1168
1169async fn open_close_queried_buffer(
1170 input: &str,
1171 expected_matches: usize,
1172 expected_editor_title: &str,
1173 workspace: &View<Workspace>,
1174 cx: &mut gpui::VisualTestContext,
1175) -> Vec<FoundPath> {
1176 let picker = open_file_picker(&workspace, cx);
1177 cx.simulate_input(input);
1178
1179 let history_items = picker.update(cx, |finder, _| {
1180 assert_eq!(
1181 finder.delegate.matches.len(),
1182 expected_matches,
1183 "Unexpected number of matches found for query `{input}`, matches: {:?}",
1184 finder.delegate.matches
1185 );
1186 finder.delegate.history_items.clone()
1187 });
1188
1189 cx.dispatch_action(SelectNext);
1190 cx.dispatch_action(Confirm);
1191
1192 cx.read(|cx| {
1193 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
1194 let active_editor_title = active_editor.read(cx).title(cx);
1195 assert_eq!(
1196 expected_editor_title, active_editor_title,
1197 "Unexpected editor title for query `{input}`"
1198 );
1199 });
1200
1201 cx.dispatch_action(workspace::CloseActiveItem { save_intent: None });
1202
1203 history_items
1204}
1205
1206fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1207 cx.update(|cx| {
1208 let state = AppState::test(cx);
1209 theme::init(theme::LoadThemes::JustBase, cx);
1210 language::init(cx);
1211 super::init(cx);
1212 editor::init(cx);
1213 workspace::init_settings(cx);
1214 Project::init_settings(cx);
1215 state
1216 })
1217}
1218
1219fn test_path_like(test_str: &str) -> PathLikeWithPosition<FileSearchQuery> {
1220 PathLikeWithPosition::parse_str(test_str, |path_like_str| {
1221 Ok::<_, std::convert::Infallible>(FileSearchQuery {
1222 raw_query: test_str.to_owned(),
1223 file_query_end: if path_like_str == test_str {
1224 None
1225 } else {
1226 Some(path_like_str.len())
1227 },
1228 })
1229 })
1230 .unwrap()
1231}
1232
1233fn build_find_picker(
1234 project: Model<Project>,
1235 cx: &mut TestAppContext,
1236) -> (
1237 View<Picker<FileFinderDelegate>>,
1238 View<Workspace>,
1239 &mut VisualTestContext,
1240) {
1241 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1242 let picker = open_file_picker(&workspace, cx);
1243 (picker, workspace, cx)
1244}
1245
1246#[track_caller]
1247fn open_file_picker(
1248 workspace: &View<Workspace>,
1249 cx: &mut VisualTestContext,
1250) -> View<Picker<FileFinderDelegate>> {
1251 cx.dispatch_action(Toggle);
1252 active_file_picker(workspace, cx)
1253}
1254
1255#[track_caller]
1256fn active_file_picker(
1257 workspace: &View<Workspace>,
1258 cx: &mut VisualTestContext,
1259) -> View<Picker<FileFinderDelegate>> {
1260 workspace.update(cx, |workspace, cx| {
1261 workspace
1262 .active_modal::<FileFinder>(cx)
1263 .unwrap()
1264 .read(cx)
1265 .picker
1266 .clone()
1267 })
1268}
1269
1270#[derive(Debug)]
1271struct SearchEntries {
1272 history: Vec<PathBuf>,
1273 search: Vec<PathBuf>,
1274}
1275
1276impl SearchEntries {
1277 #[track_caller]
1278 fn search_only(self) -> Vec<PathBuf> {
1279 assert!(
1280 self.history.is_empty(),
1281 "Should have no history matches, but got: {:?}",
1282 self.history
1283 );
1284 self.search
1285 }
1286}
1287
1288fn collect_search_matches(picker: &Picker<FileFinderDelegate>) -> SearchEntries {
1289 let matches = &picker.delegate.matches;
1290 SearchEntries {
1291 history: matches
1292 .history
1293 .iter()
1294 .map(|(history_path, path_match)| {
1295 path_match
1296 .as_ref()
1297 .map(|path_match| {
1298 Path::new(path_match.0.path_prefix.as_ref()).join(&path_match.0.path)
1299 })
1300 .unwrap_or_else(|| {
1301 history_path
1302 .absolute
1303 .as_deref()
1304 .unwrap_or_else(|| &history_path.project.path)
1305 .to_path_buf()
1306 })
1307 })
1308 .collect(),
1309 search: matches
1310 .search
1311 .iter()
1312 .map(|path_match| Path::new(path_match.0.path_prefix.as_ref()).join(&path_match.0.path))
1313 .collect(),
1314 }
1315}