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