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