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