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_matches(picker).search_only(),
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_matches(picker).search_only(),
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_matches(picker).search_only(),
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].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;
560 assert_eq!(matches[0].0.path.as_ref(), Path::new("dir2/a.txt"));
561 assert_eq!(matches[1].0.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().0.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().0.path.as_ref(), Path::new("test/fourth.rs"));
1006 });
1007}
1008
1009#[gpui::test]
1010async fn test_search_sorts_history_items(cx: &mut gpui::TestAppContext) {
1011 let app_state = init_test(cx);
1012
1013 app_state
1014 .fs
1015 .as_fake()
1016 .insert_tree(
1017 "/root",
1018 json!({
1019 "test": {
1020 "1_qw": "// First file that matches the query",
1021 "2_second": "// Second file",
1022 "3_third": "// Third file",
1023 "4_fourth": "// Fourth file",
1024 "5_qwqwqw": "// A file with 3 more matches than the first one",
1025 "6_qwqwqw": "// Same query matches as above, but closer to the end of the list due to the name",
1026 "7_qwqwqw": "// One more, same amount of query matches as above",
1027 }
1028 }),
1029 )
1030 .await;
1031
1032 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1033 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1034 // generate some history to select from
1035 open_close_queried_buffer("1", 1, "1_qw", &workspace, cx).await;
1036 open_close_queried_buffer("2", 1, "2_second", &workspace, cx).await;
1037 open_close_queried_buffer("3", 1, "3_third", &workspace, cx).await;
1038 open_close_queried_buffer("2", 1, "2_second", &workspace, cx).await;
1039 open_close_queried_buffer("6", 1, "6_qwqwqw", &workspace, cx).await;
1040
1041 let finder = open_file_picker(&workspace, cx);
1042 let query = "qw";
1043 finder
1044 .update(cx, |finder, cx| {
1045 finder.delegate.update_matches(query.to_string(), cx)
1046 })
1047 .await;
1048 finder.update(cx, |finder, _| {
1049 let search_matches = collect_search_matches(finder);
1050 assert_eq!(
1051 search_matches.history,
1052 vec![PathBuf::from("test/1_qw"), PathBuf::from("test/6_qwqwqw"),],
1053 );
1054 assert_eq!(
1055 search_matches.search,
1056 vec![
1057 PathBuf::from("test/5_qwqwqw"),
1058 PathBuf::from("test/7_qwqwqw"),
1059 ],
1060 );
1061 });
1062}
1063
1064#[gpui::test]
1065async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppContext) {
1066 let app_state = init_test(cx);
1067
1068 app_state
1069 .fs
1070 .as_fake()
1071 .insert_tree(
1072 "/src",
1073 json!({
1074 "collab_ui": {
1075 "first.rs": "// First Rust file",
1076 "second.rs": "// Second Rust file",
1077 "third.rs": "// Third Rust file",
1078 "collab_ui.rs": "// Fourth Rust file",
1079 }
1080 }),
1081 )
1082 .await;
1083
1084 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1085 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1086 // generate some history to select from
1087 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1088 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1089 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1090 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1091
1092 let finder = open_file_picker(&workspace, cx);
1093 let query = "collab_ui";
1094 cx.simulate_input(query);
1095 finder.update(cx, |finder, _| {
1096 let delegate = &finder.delegate;
1097 assert!(
1098 delegate.matches.history.is_empty(),
1099 "History items should not math query {query}, they should be matched by name only"
1100 );
1101
1102 let search_entries = delegate
1103 .matches
1104 .search
1105 .iter()
1106 .map(|path_match| path_match.0.path.to_path_buf())
1107 .collect::<Vec<_>>();
1108 assert_eq!(
1109 search_entries,
1110 vec![
1111 PathBuf::from("collab_ui/collab_ui.rs"),
1112 PathBuf::from("collab_ui/first.rs"),
1113 PathBuf::from("collab_ui/third.rs"),
1114 PathBuf::from("collab_ui/second.rs"),
1115 ],
1116 "Despite all search results having the same directory name, the most matching one should be on top"
1117 );
1118 });
1119}
1120
1121#[gpui::test]
1122async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) {
1123 let app_state = init_test(cx);
1124
1125 app_state
1126 .fs
1127 .as_fake()
1128 .insert_tree(
1129 "/src",
1130 json!({
1131 "test": {
1132 "first.rs": "// First Rust file",
1133 "nonexistent.rs": "// Second Rust file",
1134 "third.rs": "// Third Rust file",
1135 }
1136 }),
1137 )
1138 .await;
1139
1140 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1141 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); // generate some history to select from
1142 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1143 open_close_queried_buffer("non", 1, "nonexistent.rs", &workspace, cx).await;
1144 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1145 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1146
1147 let picker = open_file_picker(&workspace, cx);
1148 cx.simulate_input("rs");
1149
1150 picker.update(cx, |finder, _| {
1151 let history_entries = finder.delegate
1152 .matches
1153 .history
1154 .iter()
1155 .map(|(_, path_match)| path_match.as_ref().expect("should have a path match").0.path.to_path_buf())
1156 .collect::<Vec<_>>();
1157 assert_eq!(
1158 history_entries,
1159 vec![
1160 PathBuf::from("test/first.rs"),
1161 PathBuf::from("test/third.rs"),
1162 ],
1163 "Should have all opened files in the history, except the ones that do not exist on disk"
1164 );
1165 });
1166}
1167
1168async fn open_close_queried_buffer(
1169 input: &str,
1170 expected_matches: usize,
1171 expected_editor_title: &str,
1172 workspace: &View<Workspace>,
1173 cx: &mut gpui::VisualTestContext,
1174) -> Vec<FoundPath> {
1175 let picker = open_file_picker(&workspace, cx);
1176 cx.simulate_input(input);
1177
1178 let history_items = picker.update(cx, |finder, _| {
1179 assert_eq!(
1180 finder.delegate.matches.len(),
1181 expected_matches,
1182 "Unexpected number of matches found for query `{input}`, matches: {:?}",
1183 finder.delegate.matches
1184 );
1185 finder.delegate.history_items.clone()
1186 });
1187
1188 cx.dispatch_action(SelectNext);
1189 cx.dispatch_action(Confirm);
1190
1191 cx.read(|cx| {
1192 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
1193 let active_editor_title = active_editor.read(cx).title(cx);
1194 assert_eq!(
1195 expected_editor_title, active_editor_title,
1196 "Unexpected editor title for query `{input}`"
1197 );
1198 });
1199
1200 cx.dispatch_action(workspace::CloseActiveItem { save_intent: None });
1201
1202 history_items
1203}
1204
1205fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1206 cx.update(|cx| {
1207 let state = AppState::test(cx);
1208 theme::init(theme::LoadThemes::JustBase, cx);
1209 language::init(cx);
1210 super::init(cx);
1211 editor::init(cx);
1212 workspace::init_settings(cx);
1213 Project::init_settings(cx);
1214 state
1215 })
1216}
1217
1218fn test_path_like(test_str: &str) -> PathLikeWithPosition<FileSearchQuery> {
1219 PathLikeWithPosition::parse_str(test_str, |path_like_str| {
1220 Ok::<_, std::convert::Infallible>(FileSearchQuery {
1221 raw_query: test_str.to_owned(),
1222 file_query_end: if path_like_str == test_str {
1223 None
1224 } else {
1225 Some(path_like_str.len())
1226 },
1227 })
1228 })
1229 .unwrap()
1230}
1231
1232fn build_find_picker(
1233 project: Model<Project>,
1234 cx: &mut TestAppContext,
1235) -> (
1236 View<Picker<FileFinderDelegate>>,
1237 View<Workspace>,
1238 &mut VisualTestContext,
1239) {
1240 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1241 let picker = open_file_picker(&workspace, cx);
1242 (picker, workspace, cx)
1243}
1244
1245#[track_caller]
1246fn open_file_picker(
1247 workspace: &View<Workspace>,
1248 cx: &mut VisualTestContext,
1249) -> View<Picker<FileFinderDelegate>> {
1250 cx.dispatch_action(Toggle);
1251 active_file_picker(workspace, cx)
1252}
1253
1254#[track_caller]
1255fn active_file_picker(
1256 workspace: &View<Workspace>,
1257 cx: &mut VisualTestContext,
1258) -> View<Picker<FileFinderDelegate>> {
1259 workspace.update(cx, |workspace, cx| {
1260 workspace
1261 .active_modal::<FileFinder>(cx)
1262 .unwrap()
1263 .read(cx)
1264 .picker
1265 .clone()
1266 })
1267}
1268
1269#[derive(Debug)]
1270struct SearchEntries {
1271 history: Vec<PathBuf>,
1272 search: Vec<PathBuf>,
1273}
1274
1275impl SearchEntries {
1276 #[track_caller]
1277 fn search_only(self) -> Vec<PathBuf> {
1278 assert!(
1279 self.history.is_empty(),
1280 "Should have no history matches, but got: {:?}",
1281 self.history
1282 );
1283 self.search
1284 }
1285}
1286
1287fn collect_search_matches(picker: &Picker<FileFinderDelegate>) -> SearchEntries {
1288 let matches = &picker.delegate.matches;
1289 SearchEntries {
1290 history: matches
1291 .history
1292 .iter()
1293 .map(|(history_path, path_match)| {
1294 path_match
1295 .as_ref()
1296 .map(|path_match| {
1297 Path::new(path_match.0.path_prefix.as_ref()).join(&path_match.0.path)
1298 })
1299 .unwrap_or_else(|| {
1300 history_path
1301 .absolute
1302 .as_deref()
1303 .unwrap_or_else(|| &history_path.project.path)
1304 .to_path_buf()
1305 })
1306 })
1307 .collect(),
1308 search: matches
1309 .search
1310 .iter()
1311 .map(|path_match| Path::new(path_match.0.path_prefix.as_ref()).join(&path_match.0.path))
1312 .collect(),
1313 }
1314}