1use collections::HashMap;
2use editor::{scroll::autoscroll::Autoscroll, Bias, Editor};
3use fuzzy::{CharBag, PathMatch, PathMatchCandidate};
4use gpui::{
5 actions, rems, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, Model,
6 ParentElement, Render, Styled, Task, View, ViewContext, VisualContext, WeakView,
7};
8use picker::{Picker, PickerDelegate};
9use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
10use std::{
11 path::{Path, PathBuf},
12 sync::{
13 atomic::{self, AtomicBool},
14 Arc,
15 },
16};
17use text::Point;
18use ui::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
19use util::{paths::PathLikeWithPosition, post_inc, ResultExt};
20use workspace::{ModalView, Workspace};
21
22actions!(file_finder, [Toggle]);
23
24impl ModalView for FileFinder {}
25
26pub struct FileFinder {
27 picker: View<Picker<FileFinderDelegate>>,
28}
29
30pub fn init(cx: &mut AppContext) {
31 cx.observe_new_views(FileFinder::register).detach();
32}
33
34impl FileFinder {
35 fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
36 workspace.register_action(|workspace, _: &Toggle, cx| {
37 let Some(file_finder) = workspace.active_modal::<Self>(cx) else {
38 Self::open(workspace, cx);
39 return;
40 };
41
42 file_finder.update(cx, |file_finder, cx| {
43 file_finder
44 .picker
45 .update(cx, |picker, cx| picker.cycle_selection(cx))
46 });
47 });
48 }
49
50 fn open(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
51 let project = workspace.project().read(cx);
52
53 let currently_opened_path = workspace
54 .active_item(cx)
55 .and_then(|item| item.project_path(cx))
56 .map(|project_path| {
57 let abs_path = project
58 .worktree_for_id(project_path.worktree_id, cx)
59 .map(|worktree| worktree.read(cx).abs_path().join(&project_path.path));
60 FoundPath::new(project_path, abs_path)
61 });
62
63 // if exists, bubble the currently opened path to the top
64 let history_items = currently_opened_path
65 .clone()
66 .into_iter()
67 .chain(
68 workspace
69 .recent_navigation_history(Some(MAX_RECENT_SELECTIONS), cx)
70 .into_iter()
71 .filter(|(history_path, _)| {
72 Some(history_path)
73 != currently_opened_path
74 .as_ref()
75 .map(|found_path| &found_path.project)
76 })
77 .filter(|(_, history_abs_path)| {
78 history_abs_path.as_ref()
79 != currently_opened_path
80 .as_ref()
81 .and_then(|found_path| found_path.absolute.as_ref())
82 })
83 .filter(|(_, history_abs_path)| match history_abs_path {
84 Some(abs_path) => history_file_exists(abs_path),
85 None => true,
86 })
87 .map(|(history_path, abs_path)| FoundPath::new(history_path, abs_path)),
88 )
89 .collect();
90
91 let project = workspace.project().clone();
92 let weak_workspace = cx.view().downgrade();
93 workspace.toggle_modal(cx, |cx| {
94 let delegate = FileFinderDelegate::new(
95 cx.view().downgrade(),
96 weak_workspace,
97 project,
98 currently_opened_path,
99 history_items,
100 cx,
101 );
102
103 FileFinder::new(delegate, cx)
104 });
105 }
106
107 fn new(delegate: FileFinderDelegate, cx: &mut ViewContext<Self>) -> Self {
108 Self {
109 picker: cx.new_view(|cx| Picker::new(delegate, cx)),
110 }
111 }
112}
113
114impl EventEmitter<DismissEvent> for FileFinder {}
115impl FocusableView for FileFinder {
116 fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
117 self.picker.focus_handle(cx)
118 }
119}
120impl Render for FileFinder {
121 fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
122 v_stack().w(rems(34.)).child(self.picker.clone())
123 }
124}
125
126pub struct FileFinderDelegate {
127 file_finder: WeakView<FileFinder>,
128 workspace: WeakView<Workspace>,
129 project: Model<Project>,
130 search_count: usize,
131 latest_search_id: usize,
132 latest_search_did_cancel: bool,
133 latest_search_query: Option<PathLikeWithPosition<FileSearchQuery>>,
134 currently_opened_path: Option<FoundPath>,
135 matches: Matches,
136 selected_index: Option<usize>,
137 cancel_flag: Arc<AtomicBool>,
138 history_items: Vec<FoundPath>,
139}
140
141#[derive(Debug, Default)]
142struct Matches {
143 history: Vec<(FoundPath, Option<PathMatch>)>,
144 search: Vec<PathMatch>,
145}
146
147#[derive(Debug)]
148enum Match<'a> {
149 History(&'a FoundPath, Option<&'a PathMatch>),
150 Search(&'a PathMatch),
151}
152
153impl Matches {
154 fn len(&self) -> usize {
155 self.history.len() + self.search.len()
156 }
157
158 fn get(&self, index: usize) -> Option<Match<'_>> {
159 if index < self.history.len() {
160 self.history
161 .get(index)
162 .map(|(path, path_match)| Match::History(path, path_match.as_ref()))
163 } else {
164 self.search
165 .get(index - self.history.len())
166 .map(Match::Search)
167 }
168 }
169
170 fn push_new_matches(
171 &mut self,
172 history_items: &Vec<FoundPath>,
173 query: &PathLikeWithPosition<FileSearchQuery>,
174 mut new_search_matches: Vec<PathMatch>,
175 extend_old_matches: bool,
176 ) {
177 let matching_history_paths = matching_history_item_paths(history_items, query);
178 new_search_matches
179 .retain(|path_match| !matching_history_paths.contains_key(&path_match.path));
180 let history_items_to_show = history_items
181 .iter()
182 .filter_map(|history_item| {
183 Some((
184 history_item.clone(),
185 Some(
186 matching_history_paths
187 .get(&history_item.project.path)?
188 .clone(),
189 ),
190 ))
191 })
192 .collect::<Vec<_>>();
193 self.history = history_items_to_show;
194 if extend_old_matches {
195 self.search
196 .retain(|path_match| !matching_history_paths.contains_key(&path_match.path));
197 util::extend_sorted(
198 &mut self.search,
199 new_search_matches.into_iter(),
200 100,
201 |a, b| b.cmp(a),
202 )
203 } else {
204 self.search = new_search_matches;
205 }
206 }
207}
208
209fn matching_history_item_paths(
210 history_items: &Vec<FoundPath>,
211 query: &PathLikeWithPosition<FileSearchQuery>,
212) -> HashMap<Arc<Path>, PathMatch> {
213 let history_items_by_worktrees = history_items
214 .iter()
215 .filter_map(|found_path| {
216 let candidate = PathMatchCandidate {
217 path: &found_path.project.path,
218 // Only match history items names, otherwise their paths may match too many queries, producing false positives.
219 // E.g. `foo` would match both `something/foo/bar.rs` and `something/foo/foo.rs` and if the former is a history item,
220 // it would be shown first always, despite the latter being a better match.
221 char_bag: CharBag::from_iter(
222 found_path
223 .project
224 .path
225 .file_name()?
226 .to_string_lossy()
227 .to_lowercase()
228 .chars(),
229 ),
230 };
231 Some((found_path.project.worktree_id, candidate))
232 })
233 .fold(
234 HashMap::default(),
235 |mut candidates, (worktree_id, new_candidate)| {
236 candidates
237 .entry(worktree_id)
238 .or_insert_with(Vec::new)
239 .push(new_candidate);
240 candidates
241 },
242 );
243 let mut matching_history_paths = HashMap::default();
244 for (worktree, candidates) in history_items_by_worktrees {
245 let max_results = candidates.len() + 1;
246 matching_history_paths.extend(
247 fuzzy::match_fixed_path_set(
248 candidates,
249 worktree.to_usize(),
250 query.path_like.path_query(),
251 false,
252 max_results,
253 )
254 .into_iter()
255 .map(|path_match| (Arc::clone(&path_match.path), path_match)),
256 );
257 }
258 matching_history_paths
259}
260
261#[derive(Debug, Clone, PartialEq, Eq)]
262struct FoundPath {
263 project: ProjectPath,
264 absolute: Option<PathBuf>,
265}
266
267impl FoundPath {
268 fn new(project: ProjectPath, absolute: Option<PathBuf>) -> Self {
269 Self { project, absolute }
270 }
271}
272
273const MAX_RECENT_SELECTIONS: usize = 20;
274
275#[cfg(not(test))]
276fn history_file_exists(abs_path: &PathBuf) -> bool {
277 abs_path.exists()
278}
279
280#[cfg(test)]
281fn history_file_exists(abs_path: &PathBuf) -> bool {
282 !abs_path.ends_with("nonexistent.rs")
283}
284
285pub enum Event {
286 Selected(ProjectPath),
287 Dismissed,
288}
289
290#[derive(Debug, Clone)]
291struct FileSearchQuery {
292 raw_query: String,
293 file_query_end: Option<usize>,
294}
295
296impl FileSearchQuery {
297 fn path_query(&self) -> &str {
298 match self.file_query_end {
299 Some(file_path_end) => &self.raw_query[..file_path_end],
300 None => &self.raw_query,
301 }
302 }
303}
304
305impl FileFinderDelegate {
306 fn new(
307 file_finder: WeakView<FileFinder>,
308 workspace: WeakView<Workspace>,
309 project: Model<Project>,
310 currently_opened_path: Option<FoundPath>,
311 history_items: Vec<FoundPath>,
312 cx: &mut ViewContext<FileFinder>,
313 ) -> Self {
314 cx.observe(&project, |file_finder, _, cx| {
315 //todo!() We should probably not re-render on every project anything
316 file_finder
317 .picker
318 .update(cx, |picker, cx| picker.refresh(cx))
319 })
320 .detach();
321
322 Self {
323 file_finder,
324 workspace,
325 project,
326 search_count: 0,
327 latest_search_id: 0,
328 latest_search_did_cancel: false,
329 latest_search_query: None,
330 currently_opened_path,
331 matches: Matches::default(),
332 selected_index: None,
333 cancel_flag: Arc::new(AtomicBool::new(false)),
334 history_items,
335 }
336 }
337
338 fn spawn_search(
339 &mut self,
340 query: PathLikeWithPosition<FileSearchQuery>,
341 cx: &mut ViewContext<Picker<Self>>,
342 ) -> Task<()> {
343 let relative_to = self
344 .currently_opened_path
345 .as_ref()
346 .map(|found_path| Arc::clone(&found_path.project.path));
347 let worktrees = self
348 .project
349 .read(cx)
350 .visible_worktrees(cx)
351 .collect::<Vec<_>>();
352 let include_root_name = worktrees.len() > 1;
353 let candidate_sets = worktrees
354 .into_iter()
355 .map(|worktree| PathMatchCandidateSet {
356 snapshot: worktree.read(cx).snapshot(),
357 include_ignored: true,
358 include_root_name,
359 })
360 .collect::<Vec<_>>();
361
362 let search_id = util::post_inc(&mut self.search_count);
363 self.cancel_flag.store(true, atomic::Ordering::Relaxed);
364 self.cancel_flag = Arc::new(AtomicBool::new(false));
365 let cancel_flag = self.cancel_flag.clone();
366 cx.spawn(|picker, mut cx| async move {
367 let matches = fuzzy::match_path_sets(
368 candidate_sets.as_slice(),
369 query.path_like.path_query(),
370 relative_to,
371 false,
372 100,
373 &cancel_flag,
374 cx.background_executor().clone(),
375 )
376 .await;
377 let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
378 picker
379 .update(&mut cx, |picker, cx| {
380 picker
381 .delegate
382 .set_search_matches(search_id, did_cancel, query, matches, cx)
383 })
384 .log_err();
385 })
386 }
387
388 fn set_search_matches(
389 &mut self,
390 search_id: usize,
391 did_cancel: bool,
392 query: PathLikeWithPosition<FileSearchQuery>,
393 matches: Vec<PathMatch>,
394 cx: &mut ViewContext<Picker<Self>>,
395 ) {
396 if search_id >= self.latest_search_id {
397 self.latest_search_id = search_id;
398 let extend_old_matches = self.latest_search_did_cancel
399 && Some(query.path_like.path_query())
400 == self
401 .latest_search_query
402 .as_ref()
403 .map(|query| query.path_like.path_query());
404 self.matches
405 .push_new_matches(&self.history_items, &query, matches, extend_old_matches);
406 self.latest_search_query = Some(query);
407 self.latest_search_did_cancel = did_cancel;
408 cx.notify();
409 }
410 }
411
412 fn labels_for_match(
413 &self,
414 path_match: Match,
415 cx: &AppContext,
416 ix: usize,
417 ) -> (String, Vec<usize>, String, Vec<usize>) {
418 let (file_name, file_name_positions, full_path, full_path_positions) = match path_match {
419 Match::History(found_path, found_path_match) => {
420 let worktree_id = found_path.project.worktree_id;
421 let project_relative_path = &found_path.project.path;
422 let has_worktree = self
423 .project
424 .read(cx)
425 .worktree_for_id(worktree_id, cx)
426 .is_some();
427
428 if !has_worktree {
429 if let Some(absolute_path) = &found_path.absolute {
430 return (
431 absolute_path
432 .file_name()
433 .map_or_else(
434 || project_relative_path.to_string_lossy(),
435 |file_name| file_name.to_string_lossy(),
436 )
437 .to_string(),
438 Vec::new(),
439 absolute_path.to_string_lossy().to_string(),
440 Vec::new(),
441 );
442 }
443 }
444
445 let mut path = Arc::clone(project_relative_path);
446 if project_relative_path.as_ref() == Path::new("") {
447 if let Some(absolute_path) = &found_path.absolute {
448 path = Arc::from(absolute_path.as_path());
449 }
450 }
451
452 let mut path_match = PathMatch {
453 score: ix as f64,
454 positions: Vec::new(),
455 worktree_id: worktree_id.to_usize(),
456 path,
457 path_prefix: "".into(),
458 distance_to_relative_ancestor: usize::MAX,
459 };
460 if let Some(found_path_match) = found_path_match {
461 path_match
462 .positions
463 .extend(found_path_match.positions.iter())
464 }
465
466 self.labels_for_path_match(&path_match)
467 }
468 Match::Search(path_match) => self.labels_for_path_match(path_match),
469 };
470
471 if file_name_positions.is_empty() {
472 if let Some(user_home_path) = std::env::var("HOME").ok() {
473 let user_home_path = user_home_path.trim();
474 if !user_home_path.is_empty() {
475 if (&full_path).starts_with(user_home_path) {
476 return (
477 file_name,
478 file_name_positions,
479 full_path.replace(user_home_path, "~"),
480 full_path_positions,
481 );
482 }
483 }
484 }
485 }
486
487 (
488 file_name,
489 file_name_positions,
490 full_path,
491 full_path_positions,
492 )
493 }
494
495 fn labels_for_path_match(
496 &self,
497 path_match: &PathMatch,
498 ) -> (String, Vec<usize>, String, Vec<usize>) {
499 let path = &path_match.path;
500 let path_string = path.to_string_lossy();
501 let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
502 let path_positions = path_match.positions.clone();
503
504 let file_name = path.file_name().map_or_else(
505 || path_match.path_prefix.to_string(),
506 |file_name| file_name.to_string_lossy().to_string(),
507 );
508 let file_name_start = path_match.path_prefix.len() + path_string.len() - file_name.len();
509 let file_name_positions = path_positions
510 .iter()
511 .filter_map(|pos| {
512 if pos >= &file_name_start {
513 Some(pos - file_name_start)
514 } else {
515 None
516 }
517 })
518 .collect();
519
520 (file_name, file_name_positions, full_path, path_positions)
521 }
522}
523
524impl PickerDelegate for FileFinderDelegate {
525 type ListItem = ListItem;
526
527 fn placeholder_text(&self) -> Arc<str> {
528 "Search project files...".into()
529 }
530
531 fn match_count(&self) -> usize {
532 self.matches.len()
533 }
534
535 fn selected_index(&self) -> usize {
536 self.selected_index.unwrap_or(0)
537 }
538
539 fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
540 self.selected_index = Some(ix);
541 cx.notify();
542 }
543
544 fn separators_after_indices(&self) -> Vec<usize> {
545 let history_items = self.matches.history.len();
546 if history_items == 0 || self.matches.search.is_empty() {
547 Vec::new()
548 } else {
549 vec![history_items - 1]
550 }
551 }
552
553 fn update_matches(
554 &mut self,
555 raw_query: String,
556 cx: &mut ViewContext<Picker<Self>>,
557 ) -> Task<()> {
558 let raw_query = raw_query.trim();
559 if raw_query.is_empty() {
560 let project = self.project.read(cx);
561 self.latest_search_id = post_inc(&mut self.search_count);
562 self.matches = Matches {
563 history: self
564 .history_items
565 .iter()
566 .filter(|history_item| {
567 project
568 .worktree_for_id(history_item.project.worktree_id, cx)
569 .is_some()
570 || (project.is_local() && history_item.absolute.is_some())
571 })
572 .cloned()
573 .map(|p| (p, None))
574 .collect(),
575 search: Vec::new(),
576 };
577 cx.notify();
578 Task::ready(())
579 } else {
580 let query = PathLikeWithPosition::parse_str(raw_query, |path_like_str| {
581 Ok::<_, std::convert::Infallible>(FileSearchQuery {
582 raw_query: raw_query.to_owned(),
583 file_query_end: if path_like_str == raw_query {
584 None
585 } else {
586 Some(path_like_str.len())
587 },
588 })
589 })
590 .expect("infallible");
591 self.spawn_search(query, cx)
592 }
593 }
594
595 fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<FileFinderDelegate>>) {
596 if let Some(m) = self.matches.get(self.selected_index()) {
597 if let Some(workspace) = self.workspace.upgrade() {
598 let open_task = workspace.update(cx, move |workspace, cx| {
599 let split_or_open = |workspace: &mut Workspace, project_path, cx| {
600 if secondary {
601 workspace.split_path(project_path, cx)
602 } else {
603 workspace.open_path(project_path, None, true, cx)
604 }
605 };
606 match m {
607 Match::History(history_match, _) => {
608 let worktree_id = history_match.project.worktree_id;
609 if workspace
610 .project()
611 .read(cx)
612 .worktree_for_id(worktree_id, cx)
613 .is_some()
614 {
615 split_or_open(
616 workspace,
617 ProjectPath {
618 worktree_id,
619 path: Arc::clone(&history_match.project.path),
620 },
621 cx,
622 )
623 } else {
624 match history_match.absolute.as_ref() {
625 Some(abs_path) => {
626 if secondary {
627 workspace.split_abs_path(
628 abs_path.to_path_buf(),
629 false,
630 cx,
631 )
632 } else {
633 workspace.open_abs_path(
634 abs_path.to_path_buf(),
635 false,
636 cx,
637 )
638 }
639 }
640 None => split_or_open(
641 workspace,
642 ProjectPath {
643 worktree_id,
644 path: Arc::clone(&history_match.project.path),
645 },
646 cx,
647 ),
648 }
649 }
650 }
651 Match::Search(m) => split_or_open(
652 workspace,
653 ProjectPath {
654 worktree_id: WorktreeId::from_usize(m.worktree_id),
655 path: m.path.clone(),
656 },
657 cx,
658 ),
659 }
660 });
661
662 let row = self
663 .latest_search_query
664 .as_ref()
665 .and_then(|query| query.row)
666 .map(|row| row.saturating_sub(1));
667 let col = self
668 .latest_search_query
669 .as_ref()
670 .and_then(|query| query.column)
671 .unwrap_or(0)
672 .saturating_sub(1);
673 let finder = self.file_finder.clone();
674
675 cx.spawn(|_, mut cx| async move {
676 let item = open_task.await.log_err()?;
677 if let Some(row) = row {
678 if let Some(active_editor) = item.downcast::<Editor>() {
679 active_editor
680 .downgrade()
681 .update(&mut cx, |editor, cx| {
682 let snapshot = editor.snapshot(cx).display_snapshot;
683 let point = snapshot
684 .buffer_snapshot
685 .clip_point(Point::new(row, col), Bias::Left);
686 editor.change_selections(Some(Autoscroll::center()), cx, |s| {
687 s.select_ranges([point..point])
688 });
689 })
690 .log_err();
691 }
692 }
693 finder.update(&mut cx, |_, cx| cx.emit(DismissEvent)).ok()?;
694
695 Some(())
696 })
697 .detach();
698 }
699 }
700 }
701
702 fn dismissed(&mut self, cx: &mut ViewContext<Picker<FileFinderDelegate>>) {
703 self.file_finder
704 .update(cx, |_, cx| cx.emit(DismissEvent))
705 .log_err();
706 }
707
708 fn render_match(
709 &self,
710 ix: usize,
711 selected: bool,
712 cx: &mut ViewContext<Picker<Self>>,
713 ) -> Option<Self::ListItem> {
714 let path_match = self
715 .matches
716 .get(ix)
717 .expect("Invalid matches state: no element for index {ix}");
718
719 let (file_name, file_name_positions, full_path, full_path_positions) =
720 self.labels_for_match(path_match, cx, ix);
721
722 Some(
723 ListItem::new(ix)
724 .spacing(ListItemSpacing::Sparse)
725 .inset(true)
726 .selected(selected)
727 .child(
728 v_stack()
729 .child(HighlightedLabel::new(file_name, file_name_positions))
730 .child(HighlightedLabel::new(full_path, full_path_positions)),
731 ),
732 )
733 }
734}
735
736#[cfg(test)]
737mod tests {
738 use std::{assert_eq, path::Path, time::Duration};
739
740 use super::*;
741 use editor::Editor;
742 use gpui::{Entity, TestAppContext, VisualTestContext};
743 use menu::{Confirm, SelectNext};
744 use serde_json::json;
745 use workspace::{AppState, Workspace};
746
747 #[ctor::ctor]
748 fn init_logger() {
749 if std::env::var("RUST_LOG").is_ok() {
750 env_logger::init();
751 }
752 }
753
754 #[gpui::test]
755 async fn test_matching_paths(cx: &mut TestAppContext) {
756 let app_state = init_test(cx);
757 app_state
758 .fs
759 .as_fake()
760 .insert_tree(
761 "/root",
762 json!({
763 "a": {
764 "banana": "",
765 "bandana": "",
766 }
767 }),
768 )
769 .await;
770
771 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
772
773 let (picker, workspace, cx) = build_find_picker(project, cx);
774
775 cx.simulate_input("bna");
776 picker.update(cx, |picker, _| {
777 assert_eq!(picker.delegate.matches.len(), 2);
778 });
779 cx.dispatch_action(SelectNext);
780 cx.dispatch_action(Confirm);
781 cx.read(|cx| {
782 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
783 assert_eq!(active_editor.read(cx).title(cx), "bandana");
784 });
785
786 for bandana_query in [
787 "bandana",
788 " bandana",
789 "bandana ",
790 " bandana ",
791 " ndan ",
792 " band ",
793 ] {
794 picker
795 .update(cx, |picker, cx| {
796 picker
797 .delegate
798 .update_matches(bandana_query.to_string(), cx)
799 })
800 .await;
801 picker.update(cx, |picker, _| {
802 assert_eq!(
803 picker.delegate.matches.len(),
804 1,
805 "Wrong number of matches for bandana query '{bandana_query}'"
806 );
807 });
808 cx.dispatch_action(SelectNext);
809 cx.dispatch_action(Confirm);
810 cx.read(|cx| {
811 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
812 assert_eq!(
813 active_editor.read(cx).title(cx),
814 "bandana",
815 "Wrong match for bandana query '{bandana_query}'"
816 );
817 });
818 }
819 }
820
821 #[gpui::test]
822 async fn test_complex_path(cx: &mut TestAppContext) {
823 let app_state = init_test(cx);
824 app_state
825 .fs
826 .as_fake()
827 .insert_tree(
828 "/root",
829 json!({
830 "其他": {
831 "S数据表格": {
832 "task.xlsx": "some content",
833 },
834 }
835 }),
836 )
837 .await;
838
839 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
840
841 let (picker, workspace, cx) = build_find_picker(project, cx);
842
843 cx.simulate_input("t");
844 picker.update(cx, |picker, _| {
845 assert_eq!(picker.delegate.matches.len(), 1);
846 assert_eq!(
847 collect_search_results(picker),
848 vec![PathBuf::from("其他/S数据表格/task.xlsx")],
849 )
850 });
851 cx.dispatch_action(SelectNext);
852 cx.dispatch_action(Confirm);
853 cx.read(|cx| {
854 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
855 assert_eq!(active_editor.read(cx).title(cx), "task.xlsx");
856 });
857 }
858
859 #[gpui::test]
860 async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
861 let app_state = init_test(cx);
862
863 let first_file_name = "first.rs";
864 let first_file_contents = "// First Rust file";
865 app_state
866 .fs
867 .as_fake()
868 .insert_tree(
869 "/src",
870 json!({
871 "test": {
872 first_file_name: first_file_contents,
873 "second.rs": "// Second Rust file",
874 }
875 }),
876 )
877 .await;
878
879 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
880
881 let (picker, workspace, cx) = build_find_picker(project, cx);
882
883 let file_query = &first_file_name[..3];
884 let file_row = 1;
885 let file_column = 3;
886 assert!(file_column <= first_file_contents.len());
887 let query_inside_file = format!("{file_query}:{file_row}:{file_column}");
888 picker
889 .update(cx, |finder, cx| {
890 finder
891 .delegate
892 .update_matches(query_inside_file.to_string(), cx)
893 })
894 .await;
895 picker.update(cx, |finder, _| {
896 let finder = &finder.delegate;
897 assert_eq!(finder.matches.len(), 1);
898 let latest_search_query = finder
899 .latest_search_query
900 .as_ref()
901 .expect("Finder should have a query after the update_matches call");
902 assert_eq!(latest_search_query.path_like.raw_query, query_inside_file);
903 assert_eq!(
904 latest_search_query.path_like.file_query_end,
905 Some(file_query.len())
906 );
907 assert_eq!(latest_search_query.row, Some(file_row));
908 assert_eq!(latest_search_query.column, Some(file_column as u32));
909 });
910
911 cx.dispatch_action(SelectNext);
912 cx.dispatch_action(Confirm);
913
914 let editor = cx.update(|cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
915 cx.executor().advance_clock(Duration::from_secs(2));
916
917 editor.update(cx, |editor, cx| {
918 let all_selections = editor.selections.all_adjusted(cx);
919 assert_eq!(
920 all_selections.len(),
921 1,
922 "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
923 );
924 let caret_selection = all_selections.into_iter().next().unwrap();
925 assert_eq!(caret_selection.start, caret_selection.end,
926 "Caret selection should have its start and end at the same position");
927 assert_eq!(file_row, caret_selection.start.row + 1,
928 "Query inside file should get caret with the same focus row");
929 assert_eq!(file_column, caret_selection.start.column as usize + 1,
930 "Query inside file should get caret with the same focus column");
931 });
932 }
933
934 #[gpui::test]
935 async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
936 let app_state = init_test(cx);
937
938 let first_file_name = "first.rs";
939 let first_file_contents = "// First Rust file";
940 app_state
941 .fs
942 .as_fake()
943 .insert_tree(
944 "/src",
945 json!({
946 "test": {
947 first_file_name: first_file_contents,
948 "second.rs": "// Second Rust file",
949 }
950 }),
951 )
952 .await;
953
954 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
955
956 let (picker, workspace, cx) = build_find_picker(project, cx);
957
958 let file_query = &first_file_name[..3];
959 let file_row = 200;
960 let file_column = 300;
961 assert!(file_column > first_file_contents.len());
962 let query_outside_file = format!("{file_query}:{file_row}:{file_column}");
963 picker
964 .update(cx, |picker, cx| {
965 picker
966 .delegate
967 .update_matches(query_outside_file.to_string(), cx)
968 })
969 .await;
970 picker.update(cx, |finder, _| {
971 let delegate = &finder.delegate;
972 assert_eq!(delegate.matches.len(), 1);
973 let latest_search_query = delegate
974 .latest_search_query
975 .as_ref()
976 .expect("Finder should have a query after the update_matches call");
977 assert_eq!(latest_search_query.path_like.raw_query, query_outside_file);
978 assert_eq!(
979 latest_search_query.path_like.file_query_end,
980 Some(file_query.len())
981 );
982 assert_eq!(latest_search_query.row, Some(file_row));
983 assert_eq!(latest_search_query.column, Some(file_column as u32));
984 });
985
986 cx.dispatch_action(SelectNext);
987 cx.dispatch_action(Confirm);
988
989 let editor = cx.update(|cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
990 cx.executor().advance_clock(Duration::from_secs(2));
991
992 editor.update(cx, |editor, cx| {
993 let all_selections = editor.selections.all_adjusted(cx);
994 assert_eq!(
995 all_selections.len(),
996 1,
997 "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
998 );
999 let caret_selection = all_selections.into_iter().next().unwrap();
1000 assert_eq!(caret_selection.start, caret_selection.end,
1001 "Caret selection should have its start and end at the same position");
1002 assert_eq!(0, caret_selection.start.row,
1003 "Excessive rows (as in query outside file borders) should get trimmed to last file row");
1004 assert_eq!(first_file_contents.len(), caret_selection.start.column as usize,
1005 "Excessive columns (as in query outside file borders) should get trimmed to selected row's last column");
1006 });
1007 }
1008
1009 #[gpui::test]
1010 async fn test_matching_cancellation(cx: &mut TestAppContext) {
1011 let app_state = init_test(cx);
1012 app_state
1013 .fs
1014 .as_fake()
1015 .insert_tree(
1016 "/dir",
1017 json!({
1018 "hello": "",
1019 "goodbye": "",
1020 "halogen-light": "",
1021 "happiness": "",
1022 "height": "",
1023 "hi": "",
1024 "hiccup": "",
1025 }),
1026 )
1027 .await;
1028
1029 let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await;
1030
1031 let (picker, _, cx) = build_find_picker(project, cx);
1032
1033 let query = test_path_like("hi");
1034 picker
1035 .update(cx, |picker, cx| {
1036 picker.delegate.spawn_search(query.clone(), cx)
1037 })
1038 .await;
1039
1040 picker.update(cx, |picker, _cx| {
1041 assert_eq!(picker.delegate.matches.len(), 5)
1042 });
1043
1044 picker.update(cx, |picker, cx| {
1045 let delegate = &mut picker.delegate;
1046 assert!(
1047 delegate.matches.history.is_empty(),
1048 "Search matches expected"
1049 );
1050 let matches = delegate.matches.search.clone();
1051
1052 // Simulate a search being cancelled after the time limit,
1053 // returning only a subset of the matches that would have been found.
1054 drop(delegate.spawn_search(query.clone(), cx));
1055 delegate.set_search_matches(
1056 delegate.latest_search_id,
1057 true, // did-cancel
1058 query.clone(),
1059 vec![matches[1].clone(), matches[3].clone()],
1060 cx,
1061 );
1062
1063 // Simulate another cancellation.
1064 drop(delegate.spawn_search(query.clone(), cx));
1065 delegate.set_search_matches(
1066 delegate.latest_search_id,
1067 true, // did-cancel
1068 query.clone(),
1069 vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
1070 cx,
1071 );
1072
1073 assert!(
1074 delegate.matches.history.is_empty(),
1075 "Search matches expected"
1076 );
1077 assert_eq!(delegate.matches.search.as_slice(), &matches[0..4]);
1078 });
1079 }
1080
1081 #[gpui::test]
1082 async fn test_ignored_root(cx: &mut TestAppContext) {
1083 let app_state = init_test(cx);
1084 app_state
1085 .fs
1086 .as_fake()
1087 .insert_tree(
1088 "/ancestor",
1089 json!({
1090 ".gitignore": "ignored-root",
1091 "ignored-root": {
1092 "happiness": "",
1093 "height": "",
1094 "hi": "",
1095 "hiccup": "",
1096 },
1097 "tracked-root": {
1098 ".gitignore": "height",
1099 "happiness": "",
1100 "height": "",
1101 "hi": "",
1102 "hiccup": "",
1103 },
1104 }),
1105 )
1106 .await;
1107
1108 let project = Project::test(
1109 app_state.fs.clone(),
1110 [
1111 "/ancestor/tracked-root".as_ref(),
1112 "/ancestor/ignored-root".as_ref(),
1113 ],
1114 cx,
1115 )
1116 .await;
1117
1118 let (picker, _, cx) = build_find_picker(project, cx);
1119
1120 picker
1121 .update(cx, |picker, cx| {
1122 picker.delegate.spawn_search(test_path_like("hi"), cx)
1123 })
1124 .await;
1125 picker.update(cx, |picker, _| {
1126 assert_eq!(
1127 collect_search_results(picker),
1128 vec![
1129 PathBuf::from("ignored-root/happiness"),
1130 PathBuf::from("ignored-root/height"),
1131 PathBuf::from("ignored-root/hi"),
1132 PathBuf::from("ignored-root/hiccup"),
1133 PathBuf::from("tracked-root/happiness"),
1134 PathBuf::from("tracked-root/height"),
1135 PathBuf::from("tracked-root/hi"),
1136 PathBuf::from("tracked-root/hiccup"),
1137 ],
1138 "All files in all roots (including gitignored) should be searched"
1139 )
1140 });
1141 }
1142
1143 #[gpui::test]
1144 async fn test_ignored_files(cx: &mut TestAppContext) {
1145 let app_state = init_test(cx);
1146 app_state
1147 .fs
1148 .as_fake()
1149 .insert_tree(
1150 "/root",
1151 json!({
1152 ".git": {},
1153 ".gitignore": "ignored_a\n.env\n",
1154 "a": {
1155 "banana_env": "11",
1156 "bandana_env": "12",
1157 },
1158 "ignored_a": {
1159 "ignored_banana_env": "21",
1160 "ignored_bandana_env": "22",
1161 "ignored_nested": {
1162 "ignored_nested_banana_env": "31",
1163 "ignored_nested_bandana_env": "32",
1164 },
1165 },
1166 ".env": "something",
1167 }),
1168 )
1169 .await;
1170
1171 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1172
1173 let (picker, workspace, cx) = build_find_picker(project, cx);
1174
1175 cx.simulate_input("env");
1176 picker.update(cx, |picker, _| {
1177 assert_eq!(
1178 collect_search_results(picker),
1179 vec![
1180 PathBuf::from(".env"),
1181 PathBuf::from("a/banana_env"),
1182 PathBuf::from("a/bandana_env"),
1183 ],
1184 "Root gitignored files and all non-gitignored files should be searched"
1185 )
1186 });
1187
1188 let _ = workspace
1189 .update(cx, |workspace, cx| {
1190 workspace.open_abs_path(
1191 PathBuf::from("/root/ignored_a/ignored_banana_env"),
1192 true,
1193 cx,
1194 )
1195 })
1196 .await
1197 .unwrap();
1198 cx.run_until_parked();
1199 cx.simulate_input("env");
1200 picker.update(cx, |picker, _| {
1201 assert_eq!(
1202 collect_search_results(picker),
1203 vec![
1204 PathBuf::from(".env"),
1205 PathBuf::from("a/banana_env"),
1206 PathBuf::from("a/bandana_env"),
1207 PathBuf::from("ignored_a/ignored_banana_env"),
1208 PathBuf::from("ignored_a/ignored_bandana_env"),
1209 ],
1210 "Root gitignored dir got listed and its entries got into worktree, but all gitignored dirs below it were not listed. Old entries + new listed gitignored entries should be searched"
1211 )
1212 });
1213 }
1214
1215 #[gpui::test]
1216 async fn test_single_file_worktrees(cx: &mut TestAppContext) {
1217 let app_state = init_test(cx);
1218 app_state
1219 .fs
1220 .as_fake()
1221 .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
1222 .await;
1223
1224 let project = Project::test(
1225 app_state.fs.clone(),
1226 ["/root/the-parent-dir/the-file".as_ref()],
1227 cx,
1228 )
1229 .await;
1230
1231 let (picker, _, cx) = build_find_picker(project, cx);
1232
1233 // Even though there is only one worktree, that worktree's filename
1234 // is included in the matching, because the worktree is a single file.
1235 picker
1236 .update(cx, |picker, cx| {
1237 picker.delegate.spawn_search(test_path_like("thf"), cx)
1238 })
1239 .await;
1240 cx.read(|cx| {
1241 let picker = picker.read(cx);
1242 let delegate = &picker.delegate;
1243 assert!(
1244 delegate.matches.history.is_empty(),
1245 "Search matches expected"
1246 );
1247 let matches = delegate.matches.search.clone();
1248 assert_eq!(matches.len(), 1);
1249
1250 let (file_name, file_name_positions, full_path, full_path_positions) =
1251 delegate.labels_for_path_match(&matches[0]);
1252 assert_eq!(file_name, "the-file");
1253 assert_eq!(file_name_positions, &[0, 1, 4]);
1254 assert_eq!(full_path, "the-file");
1255 assert_eq!(full_path_positions, &[0, 1, 4]);
1256 });
1257
1258 // Since the worktree root is a file, searching for its name followed by a slash does
1259 // not match anything.
1260 picker
1261 .update(cx, |f, cx| {
1262 f.delegate.spawn_search(test_path_like("thf/"), cx)
1263 })
1264 .await;
1265 picker.update(cx, |f, _| assert_eq!(f.delegate.matches.len(), 0));
1266 }
1267
1268 #[gpui::test]
1269 async fn test_path_distance_ordering(cx: &mut TestAppContext) {
1270 let app_state = init_test(cx);
1271 app_state
1272 .fs
1273 .as_fake()
1274 .insert_tree(
1275 "/root",
1276 json!({
1277 "dir1": { "a.txt": "" },
1278 "dir2": {
1279 "a.txt": "",
1280 "b.txt": ""
1281 }
1282 }),
1283 )
1284 .await;
1285
1286 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1287 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1288
1289 let worktree_id = cx.read(|cx| {
1290 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1291 assert_eq!(worktrees.len(), 1);
1292 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1293 });
1294
1295 // When workspace has an active item, sort items which are closer to that item
1296 // first when they have the same name. In this case, b.txt is closer to dir2's a.txt
1297 // so that one should be sorted earlier
1298 let b_path = ProjectPath {
1299 worktree_id,
1300 path: Arc::from(Path::new("/root/dir2/b.txt")),
1301 };
1302 workspace
1303 .update(cx, |workspace, cx| {
1304 workspace.open_path(b_path, None, true, cx)
1305 })
1306 .await
1307 .unwrap();
1308 let finder = open_file_picker(&workspace, cx);
1309 finder
1310 .update(cx, |f, cx| {
1311 f.delegate.spawn_search(test_path_like("a.txt"), cx)
1312 })
1313 .await;
1314
1315 finder.update(cx, |f, _| {
1316 let delegate = &f.delegate;
1317 assert!(
1318 delegate.matches.history.is_empty(),
1319 "Search matches expected"
1320 );
1321 let matches = delegate.matches.search.clone();
1322 assert_eq!(matches[0].path.as_ref(), Path::new("dir2/a.txt"));
1323 assert_eq!(matches[1].path.as_ref(), Path::new("dir1/a.txt"));
1324 });
1325 }
1326
1327 #[gpui::test]
1328 async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
1329 let app_state = init_test(cx);
1330 app_state
1331 .fs
1332 .as_fake()
1333 .insert_tree(
1334 "/root",
1335 json!({
1336 "dir1": {},
1337 "dir2": {
1338 "dir3": {}
1339 }
1340 }),
1341 )
1342 .await;
1343
1344 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1345 let (picker, _workspace, cx) = build_find_picker(project, cx);
1346
1347 picker
1348 .update(cx, |f, cx| {
1349 f.delegate.spawn_search(test_path_like("dir"), cx)
1350 })
1351 .await;
1352 cx.read(|cx| {
1353 let finder = picker.read(cx);
1354 assert_eq!(finder.delegate.matches.len(), 0);
1355 });
1356 }
1357
1358 #[gpui::test]
1359 async fn test_query_history(cx: &mut gpui::TestAppContext) {
1360 let app_state = init_test(cx);
1361
1362 app_state
1363 .fs
1364 .as_fake()
1365 .insert_tree(
1366 "/src",
1367 json!({
1368 "test": {
1369 "first.rs": "// First Rust file",
1370 "second.rs": "// Second Rust file",
1371 "third.rs": "// Third Rust file",
1372 }
1373 }),
1374 )
1375 .await;
1376
1377 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1378 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1379 let worktree_id = cx.read(|cx| {
1380 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1381 assert_eq!(worktrees.len(), 1);
1382 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1383 });
1384
1385 // Open and close panels, getting their history items afterwards.
1386 // Ensure history items get populated with opened items, and items are kept in a certain order.
1387 // The history lags one opened buffer behind, since it's updated in the search panel only on its reopen.
1388 //
1389 // TODO: without closing, the opened items do not propagate their history changes for some reason
1390 // it does work in real app though, only tests do not propagate.
1391 workspace.update(cx, |_, cx| cx.focused());
1392
1393 let initial_history = open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1394 assert!(
1395 initial_history.is_empty(),
1396 "Should have no history before opening any files"
1397 );
1398
1399 let history_after_first =
1400 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1401 assert_eq!(
1402 history_after_first,
1403 vec![FoundPath::new(
1404 ProjectPath {
1405 worktree_id,
1406 path: Arc::from(Path::new("test/first.rs")),
1407 },
1408 Some(PathBuf::from("/src/test/first.rs"))
1409 )],
1410 "Should show 1st opened item in the history when opening the 2nd item"
1411 );
1412
1413 let history_after_second =
1414 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1415 assert_eq!(
1416 history_after_second,
1417 vec![
1418 FoundPath::new(
1419 ProjectPath {
1420 worktree_id,
1421 path: Arc::from(Path::new("test/second.rs")),
1422 },
1423 Some(PathBuf::from("/src/test/second.rs"))
1424 ),
1425 FoundPath::new(
1426 ProjectPath {
1427 worktree_id,
1428 path: Arc::from(Path::new("test/first.rs")),
1429 },
1430 Some(PathBuf::from("/src/test/first.rs"))
1431 ),
1432 ],
1433 "Should show 1st and 2nd opened items in the history when opening the 3rd item. \
1434 2nd item should be the first in the history, as the last opened."
1435 );
1436
1437 let history_after_third =
1438 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1439 assert_eq!(
1440 history_after_third,
1441 vec![
1442 FoundPath::new(
1443 ProjectPath {
1444 worktree_id,
1445 path: Arc::from(Path::new("test/third.rs")),
1446 },
1447 Some(PathBuf::from("/src/test/third.rs"))
1448 ),
1449 FoundPath::new(
1450 ProjectPath {
1451 worktree_id,
1452 path: Arc::from(Path::new("test/second.rs")),
1453 },
1454 Some(PathBuf::from("/src/test/second.rs"))
1455 ),
1456 FoundPath::new(
1457 ProjectPath {
1458 worktree_id,
1459 path: Arc::from(Path::new("test/first.rs")),
1460 },
1461 Some(PathBuf::from("/src/test/first.rs"))
1462 ),
1463 ],
1464 "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \
1465 3rd item should be the first in the history, as the last opened."
1466 );
1467
1468 let history_after_second_again =
1469 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1470 assert_eq!(
1471 history_after_second_again,
1472 vec![
1473 FoundPath::new(
1474 ProjectPath {
1475 worktree_id,
1476 path: Arc::from(Path::new("test/second.rs")),
1477 },
1478 Some(PathBuf::from("/src/test/second.rs"))
1479 ),
1480 FoundPath::new(
1481 ProjectPath {
1482 worktree_id,
1483 path: Arc::from(Path::new("test/third.rs")),
1484 },
1485 Some(PathBuf::from("/src/test/third.rs"))
1486 ),
1487 FoundPath::new(
1488 ProjectPath {
1489 worktree_id,
1490 path: Arc::from(Path::new("test/first.rs")),
1491 },
1492 Some(PathBuf::from("/src/test/first.rs"))
1493 ),
1494 ],
1495 "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \
1496 2nd item, as the last opened, 3rd item should go next as it was opened right before."
1497 );
1498 }
1499
1500 #[gpui::test]
1501 async fn test_external_files_history(cx: &mut gpui::TestAppContext) {
1502 let app_state = init_test(cx);
1503
1504 app_state
1505 .fs
1506 .as_fake()
1507 .insert_tree(
1508 "/src",
1509 json!({
1510 "test": {
1511 "first.rs": "// First Rust file",
1512 "second.rs": "// Second Rust file",
1513 }
1514 }),
1515 )
1516 .await;
1517
1518 app_state
1519 .fs
1520 .as_fake()
1521 .insert_tree(
1522 "/external-src",
1523 json!({
1524 "test": {
1525 "third.rs": "// Third Rust file",
1526 "fourth.rs": "// Fourth Rust file",
1527 }
1528 }),
1529 )
1530 .await;
1531
1532 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1533 cx.update(|cx| {
1534 project.update(cx, |project, cx| {
1535 project.find_or_create_local_worktree("/external-src", false, cx)
1536 })
1537 })
1538 .detach();
1539 cx.background_executor.run_until_parked();
1540
1541 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1542 let worktree_id = cx.read(|cx| {
1543 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1544 assert_eq!(worktrees.len(), 1,);
1545
1546 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1547 });
1548 workspace
1549 .update(cx, |workspace, cx| {
1550 workspace.open_abs_path(PathBuf::from("/external-src/test/third.rs"), false, cx)
1551 })
1552 .detach();
1553 cx.background_executor.run_until_parked();
1554 let external_worktree_id = cx.read(|cx| {
1555 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1556 assert_eq!(
1557 worktrees.len(),
1558 2,
1559 "External file should get opened in a new worktree"
1560 );
1561
1562 WorktreeId::from_usize(
1563 worktrees
1564 .into_iter()
1565 .find(|worktree| {
1566 worktree.entity_id().as_u64() as usize != worktree_id.to_usize()
1567 })
1568 .expect("New worktree should have a different id")
1569 .entity_id()
1570 .as_u64() as usize,
1571 )
1572 });
1573 cx.dispatch_action(workspace::CloseActiveItem { save_intent: None });
1574
1575 let initial_history_items =
1576 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1577 assert_eq!(
1578 initial_history_items,
1579 vec![FoundPath::new(
1580 ProjectPath {
1581 worktree_id: external_worktree_id,
1582 path: Arc::from(Path::new("")),
1583 },
1584 Some(PathBuf::from("/external-src/test/third.rs"))
1585 )],
1586 "Should show external file with its full path in the history after it was open"
1587 );
1588
1589 let updated_history_items =
1590 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1591 assert_eq!(
1592 updated_history_items,
1593 vec![
1594 FoundPath::new(
1595 ProjectPath {
1596 worktree_id,
1597 path: Arc::from(Path::new("test/second.rs")),
1598 },
1599 Some(PathBuf::from("/src/test/second.rs"))
1600 ),
1601 FoundPath::new(
1602 ProjectPath {
1603 worktree_id: external_worktree_id,
1604 path: Arc::from(Path::new("")),
1605 },
1606 Some(PathBuf::from("/external-src/test/third.rs"))
1607 ),
1608 ],
1609 "Should keep external file with history updates",
1610 );
1611 }
1612
1613 #[gpui::test]
1614 async fn test_toggle_panel_new_selections(cx: &mut gpui::TestAppContext) {
1615 let app_state = init_test(cx);
1616
1617 app_state
1618 .fs
1619 .as_fake()
1620 .insert_tree(
1621 "/src",
1622 json!({
1623 "test": {
1624 "first.rs": "// First Rust file",
1625 "second.rs": "// Second Rust file",
1626 "third.rs": "// Third Rust file",
1627 }
1628 }),
1629 )
1630 .await;
1631
1632 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1633 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1634
1635 // generate some history to select from
1636 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1637 cx.executor().run_until_parked();
1638 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1639 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1640 let current_history =
1641 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1642
1643 for expected_selected_index in 0..current_history.len() {
1644 cx.dispatch_action(Toggle);
1645 let picker = active_file_picker(&workspace, cx);
1646 let selected_index = picker.update(cx, |picker, _| picker.delegate.selected_index());
1647 assert_eq!(
1648 selected_index, expected_selected_index,
1649 "Should select the next item in the history"
1650 );
1651 }
1652
1653 cx.dispatch_action(Toggle);
1654 let selected_index = workspace.update(cx, |workspace, cx| {
1655 workspace
1656 .active_modal::<FileFinder>(cx)
1657 .unwrap()
1658 .read(cx)
1659 .picker
1660 .read(cx)
1661 .delegate
1662 .selected_index()
1663 });
1664 assert_eq!(
1665 selected_index, 0,
1666 "Should wrap around the history and start all over"
1667 );
1668 }
1669
1670 #[gpui::test]
1671 async fn test_search_preserves_history_items(cx: &mut gpui::TestAppContext) {
1672 let app_state = init_test(cx);
1673
1674 app_state
1675 .fs
1676 .as_fake()
1677 .insert_tree(
1678 "/src",
1679 json!({
1680 "test": {
1681 "first.rs": "// First Rust file",
1682 "second.rs": "// Second Rust file",
1683 "third.rs": "// Third Rust file",
1684 "fourth.rs": "// Fourth Rust file",
1685 }
1686 }),
1687 )
1688 .await;
1689
1690 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1691 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1692 let worktree_id = cx.read(|cx| {
1693 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1694 assert_eq!(worktrees.len(), 1,);
1695
1696 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1697 });
1698
1699 // generate some history to select from
1700 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1701 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1702 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1703 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1704
1705 let finder = open_file_picker(&workspace, cx);
1706 let first_query = "f";
1707 finder
1708 .update(cx, |finder, cx| {
1709 finder.delegate.update_matches(first_query.to_string(), cx)
1710 })
1711 .await;
1712 finder.update(cx, |finder, _| {
1713 let delegate = &finder.delegate;
1714 assert_eq!(delegate.matches.history.len(), 1, "Only one history item contains {first_query}, it should be present and others should be filtered out");
1715 let history_match = delegate.matches.history.first().unwrap();
1716 assert!(history_match.1.is_some(), "Should have path matches for history items after querying");
1717 assert_eq!(history_match.0, FoundPath::new(
1718 ProjectPath {
1719 worktree_id,
1720 path: Arc::from(Path::new("test/first.rs")),
1721 },
1722 Some(PathBuf::from("/src/test/first.rs"))
1723 ));
1724 assert_eq!(delegate.matches.search.len(), 1, "Only one non-history item contains {first_query}, it should be present");
1725 assert_eq!(delegate.matches.search.first().unwrap().path.as_ref(), Path::new("test/fourth.rs"));
1726 });
1727
1728 let second_query = "fsdasdsa";
1729 let finder = active_file_picker(&workspace, cx);
1730 finder
1731 .update(cx, |finder, cx| {
1732 finder.delegate.update_matches(second_query.to_string(), cx)
1733 })
1734 .await;
1735 finder.update(cx, |finder, _| {
1736 let delegate = &finder.delegate;
1737 assert!(
1738 delegate.matches.history.is_empty(),
1739 "No history entries should match {second_query}"
1740 );
1741 assert!(
1742 delegate.matches.search.is_empty(),
1743 "No search entries should match {second_query}"
1744 );
1745 });
1746
1747 let first_query_again = first_query;
1748
1749 let finder = active_file_picker(&workspace, cx);
1750 finder
1751 .update(cx, |finder, cx| {
1752 finder
1753 .delegate
1754 .update_matches(first_query_again.to_string(), cx)
1755 })
1756 .await;
1757 finder.update(cx, |finder, _| {
1758 let delegate = &finder.delegate;
1759 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");
1760 let history_match = delegate.matches.history.first().unwrap();
1761 assert!(history_match.1.is_some(), "Should have path matches for history items after querying");
1762 assert_eq!(history_match.0, FoundPath::new(
1763 ProjectPath {
1764 worktree_id,
1765 path: Arc::from(Path::new("test/first.rs")),
1766 },
1767 Some(PathBuf::from("/src/test/first.rs"))
1768 ));
1769 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");
1770 assert_eq!(delegate.matches.search.first().unwrap().path.as_ref(), Path::new("test/fourth.rs"));
1771 });
1772 }
1773
1774 #[gpui::test]
1775 async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppContext) {
1776 let app_state = init_test(cx);
1777
1778 app_state
1779 .fs
1780 .as_fake()
1781 .insert_tree(
1782 "/src",
1783 json!({
1784 "collab_ui": {
1785 "first.rs": "// First Rust file",
1786 "second.rs": "// Second Rust file",
1787 "third.rs": "// Third Rust file",
1788 "collab_ui.rs": "// Fourth Rust file",
1789 }
1790 }),
1791 )
1792 .await;
1793
1794 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1795 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1796 // generate some history to select from
1797 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1798 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1799 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1800 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1801
1802 let finder = open_file_picker(&workspace, cx);
1803 let query = "collab_ui";
1804 cx.simulate_input(query);
1805 finder.update(cx, |finder, _| {
1806 let delegate = &finder.delegate;
1807 assert!(
1808 delegate.matches.history.is_empty(),
1809 "History items should not math query {query}, they should be matched by name only"
1810 );
1811
1812 let search_entries = delegate
1813 .matches
1814 .search
1815 .iter()
1816 .map(|path_match| path_match.path.to_path_buf())
1817 .collect::<Vec<_>>();
1818 assert_eq!(
1819 search_entries,
1820 vec![
1821 PathBuf::from("collab_ui/collab_ui.rs"),
1822 PathBuf::from("collab_ui/third.rs"),
1823 PathBuf::from("collab_ui/first.rs"),
1824 PathBuf::from("collab_ui/second.rs"),
1825 ],
1826 "Despite all search results having the same directory name, the most matching one should be on top"
1827 );
1828 });
1829 }
1830
1831 #[gpui::test]
1832 async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) {
1833 let app_state = init_test(cx);
1834
1835 app_state
1836 .fs
1837 .as_fake()
1838 .insert_tree(
1839 "/src",
1840 json!({
1841 "test": {
1842 "first.rs": "// First Rust file",
1843 "nonexistent.rs": "// Second Rust file",
1844 "third.rs": "// Third Rust file",
1845 }
1846 }),
1847 )
1848 .await;
1849
1850 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1851 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); // generate some history to select from
1852 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1853 open_close_queried_buffer("non", 1, "nonexistent.rs", &workspace, cx).await;
1854 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1855 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1856
1857 let picker = open_file_picker(&workspace, cx);
1858 cx.simulate_input("rs");
1859
1860 picker.update(cx, |finder, _| {
1861 let history_entries = finder.delegate
1862 .matches
1863 .history
1864 .iter()
1865 .map(|(_, path_match)| path_match.as_ref().expect("should have a path match").path.to_path_buf())
1866 .collect::<Vec<_>>();
1867 assert_eq!(
1868 history_entries,
1869 vec![
1870 PathBuf::from("test/first.rs"),
1871 PathBuf::from("test/third.rs"),
1872 ],
1873 "Should have all opened files in the history, except the ones that do not exist on disk"
1874 );
1875 });
1876 }
1877
1878 async fn open_close_queried_buffer(
1879 input: &str,
1880 expected_matches: usize,
1881 expected_editor_title: &str,
1882 workspace: &View<Workspace>,
1883 cx: &mut gpui::VisualTestContext,
1884 ) -> Vec<FoundPath> {
1885 let picker = open_file_picker(&workspace, cx);
1886 cx.simulate_input(input);
1887
1888 let history_items = picker.update(cx, |finder, _| {
1889 assert_eq!(
1890 finder.delegate.matches.len(),
1891 expected_matches,
1892 "Unexpected number of matches found for query {input}"
1893 );
1894 finder.delegate.history_items.clone()
1895 });
1896
1897 cx.dispatch_action(SelectNext);
1898 cx.dispatch_action(Confirm);
1899
1900 cx.read(|cx| {
1901 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
1902 let active_editor_title = active_editor.read(cx).title(cx);
1903 assert_eq!(
1904 expected_editor_title, active_editor_title,
1905 "Unexpected editor title for query {input}"
1906 );
1907 });
1908
1909 cx.dispatch_action(workspace::CloseActiveItem { save_intent: None });
1910
1911 history_items
1912 }
1913
1914 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1915 cx.update(|cx| {
1916 let state = AppState::test(cx);
1917 theme::init(theme::LoadThemes::JustBase, cx);
1918 language::init(cx);
1919 super::init(cx);
1920 editor::init(cx);
1921 workspace::init_settings(cx);
1922 Project::init_settings(cx);
1923 state
1924 })
1925 }
1926
1927 fn test_path_like(test_str: &str) -> PathLikeWithPosition<FileSearchQuery> {
1928 PathLikeWithPosition::parse_str(test_str, |path_like_str| {
1929 Ok::<_, std::convert::Infallible>(FileSearchQuery {
1930 raw_query: test_str.to_owned(),
1931 file_query_end: if path_like_str == test_str {
1932 None
1933 } else {
1934 Some(path_like_str.len())
1935 },
1936 })
1937 })
1938 .unwrap()
1939 }
1940
1941 fn build_find_picker(
1942 project: Model<Project>,
1943 cx: &mut TestAppContext,
1944 ) -> (
1945 View<Picker<FileFinderDelegate>>,
1946 View<Workspace>,
1947 &mut VisualTestContext,
1948 ) {
1949 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1950 let picker = open_file_picker(&workspace, cx);
1951 (picker, workspace, cx)
1952 }
1953
1954 #[track_caller]
1955 fn open_file_picker(
1956 workspace: &View<Workspace>,
1957 cx: &mut VisualTestContext,
1958 ) -> View<Picker<FileFinderDelegate>> {
1959 cx.dispatch_action(Toggle);
1960 active_file_picker(workspace, cx)
1961 }
1962
1963 #[track_caller]
1964 fn active_file_picker(
1965 workspace: &View<Workspace>,
1966 cx: &mut VisualTestContext,
1967 ) -> View<Picker<FileFinderDelegate>> {
1968 workspace.update(cx, |workspace, cx| {
1969 workspace
1970 .active_modal::<FileFinder>(cx)
1971 .unwrap()
1972 .read(cx)
1973 .picker
1974 .clone()
1975 })
1976 }
1977
1978 fn collect_search_results(picker: &Picker<FileFinderDelegate>) -> Vec<PathBuf> {
1979 let matches = &picker.delegate.matches;
1980 assert!(
1981 matches.history.is_empty(),
1982 "Should have no history matches, but got: {:?}",
1983 matches.history
1984 );
1985 let mut results = matches
1986 .search
1987 .iter()
1988 .map(|path_match| Path::new(path_match.path_prefix.as_ref()).join(&path_match.path))
1989 .collect::<Vec<_>>();
1990 results.sort();
1991 results
1992 }
1993}