1use super::{HoverTarget, HoveredWord, TerminalView};
2use anyhow::{Context as _, Result};
3use editor::Editor;
4use gpui::{App, AppContext, Context, Task, WeakEntity, Window};
5use itertools::Itertools;
6use project::{Entry, Metadata};
7use std::path::PathBuf;
8use terminal::PathLikeTarget;
9use util::{
10 ResultExt, debug_panic,
11 paths::{PathStyle, PathWithPosition, normalize_lexically},
12 rel_path::RelPath,
13};
14use workspace::{OpenOptions, OpenVisible, Workspace};
15
16/// The way we found the open target. This is important to have for test assertions.
17/// For example, remote projects never look in the file system.
18#[cfg(test)]
19#[derive(Debug, Clone, Copy, Eq, PartialEq)]
20enum OpenTargetFoundBy {
21 WorktreeExact,
22 WorktreeScan,
23 FileSystemBackground,
24}
25
26#[cfg(test)]
27#[derive(Debug, Clone, Copy, Eq, PartialEq)]
28enum BackgroundFsChecks {
29 Enabled,
30 Disabled,
31}
32
33#[derive(Debug, Clone)]
34enum OpenTarget {
35 Worktree(PathWithPosition, Entry, #[cfg(test)] OpenTargetFoundBy),
36 File(PathWithPosition, Metadata),
37}
38
39impl OpenTarget {
40 fn is_file(&self) -> bool {
41 match self {
42 OpenTarget::Worktree(_, entry, ..) => entry.is_file(),
43 OpenTarget::File(_, metadata) => !metadata.is_dir,
44 }
45 }
46
47 fn is_dir(&self) -> bool {
48 match self {
49 OpenTarget::Worktree(_, entry, ..) => entry.is_dir(),
50 OpenTarget::File(_, metadata) => metadata.is_dir,
51 }
52 }
53
54 fn path(&self) -> &PathWithPosition {
55 match self {
56 OpenTarget::Worktree(path, ..) => path,
57 OpenTarget::File(path, _) => path,
58 }
59 }
60
61 #[cfg(test)]
62 fn found_by(&self) -> OpenTargetFoundBy {
63 match self {
64 OpenTarget::Worktree(.., found_by) => *found_by,
65 OpenTarget::File(..) => OpenTargetFoundBy::FileSystemBackground,
66 }
67 }
68}
69
70pub(super) fn hover_path_like_target(
71 workspace: &WeakEntity<Workspace>,
72 hovered_word: HoveredWord,
73 path_like_target: &PathLikeTarget,
74 cx: &mut Context<TerminalView>,
75) -> Task<()> {
76 #[cfg(not(test))]
77 {
78 possible_hover_target(workspace, hovered_word, path_like_target, cx)
79 }
80 #[cfg(test)]
81 {
82 possible_hover_target(
83 workspace,
84 hovered_word,
85 path_like_target,
86 cx,
87 BackgroundFsChecks::Enabled,
88 )
89 }
90}
91
92fn possible_hover_target(
93 workspace: &WeakEntity<Workspace>,
94 hovered_word: HoveredWord,
95 path_like_target: &PathLikeTarget,
96 cx: &mut Context<TerminalView>,
97 #[cfg(test)] background_fs_checks: BackgroundFsChecks,
98) -> Task<()> {
99 let file_to_open_task = possible_open_target(
100 workspace,
101 path_like_target,
102 cx,
103 #[cfg(test)]
104 background_fs_checks,
105 );
106 cx.spawn(async move |terminal_view, cx| {
107 let file_to_open = file_to_open_task.await;
108 terminal_view
109 .update(cx, |terminal_view, _| match file_to_open {
110 Some(OpenTarget::File(path, _) | OpenTarget::Worktree(path, ..)) => {
111 terminal_view.hover = Some(HoverTarget {
112 tooltip: path
113 .to_string(&|path: &PathBuf| path.to_string_lossy().into_owned()),
114 hovered_word,
115 });
116 }
117 None => {
118 terminal_view.hover = None;
119 }
120 })
121 .ok();
122 })
123}
124
125fn possible_open_target(
126 workspace: &WeakEntity<Workspace>,
127 path_like_target: &PathLikeTarget,
128 cx: &App,
129 #[cfg(test)] background_fs_checks: BackgroundFsChecks,
130) -> Task<Option<OpenTarget>> {
131 let Some(workspace) = workspace.upgrade() else {
132 return Task::ready(None);
133 };
134 // We have to check for both paths, as on Unix, certain paths with positions are valid file paths too.
135 // We can be on FS remote part, without real FS, so cannot canonicalize or check for existence the path right away.
136 let mut potential_paths = Vec::new();
137 let cwd = path_like_target.terminal_dir.as_ref();
138 let maybe_path = &path_like_target.maybe_path;
139 let original_path = PathWithPosition::from_path(PathBuf::from(maybe_path));
140 let path_with_position = PathWithPosition::parse_str(maybe_path);
141 let worktree_candidates = workspace
142 .read(cx)
143 .worktrees(cx)
144 .sorted_by_key(|worktree| {
145 let worktree_root = worktree.read(cx).abs_path();
146 match cwd.and_then(|cwd| worktree_root.strip_prefix(cwd).ok()) {
147 Some(cwd_child) => cwd_child.components().count(),
148 None => usize::MAX,
149 }
150 })
151 .collect::<Vec<_>>();
152 // Since we do not check paths via FS and joining, we need to strip off potential `./`, `a/`, `b/` prefixes out of it.
153 const GIT_DIFF_PATH_PREFIXES: &[&str] = &["a", "b"];
154 for prefix_str in GIT_DIFF_PATH_PREFIXES.iter().chain(std::iter::once(&".")) {
155 if let Some(stripped) = original_path.path.strip_prefix(prefix_str).ok() {
156 potential_paths.push(PathWithPosition {
157 path: stripped.to_owned(),
158 row: original_path.row,
159 column: original_path.column,
160 });
161 }
162 if let Some(stripped) = path_with_position.path.strip_prefix(prefix_str).ok() {
163 potential_paths.push(PathWithPosition {
164 path: stripped.to_owned(),
165 row: path_with_position.row,
166 column: path_with_position.column,
167 });
168 }
169 }
170
171 let insert_both_paths = original_path != path_with_position;
172 potential_paths.insert(0, original_path);
173 if insert_both_paths {
174 potential_paths.insert(1, path_with_position);
175 }
176
177 // If we won't find paths "easily", we can traverse the entire worktree to look what ends with the potential path suffix.
178 // That will be slow, though, so do the fast checks first.
179 let mut worktree_paths_to_check = Vec::new();
180 let mut is_cwd_in_worktree = false;
181 let mut open_target = None;
182 'worktree_loop: for worktree in &worktree_candidates {
183 let worktree_root = worktree.read(cx).abs_path();
184 let mut paths_to_check = Vec::with_capacity(potential_paths.len());
185 let relative_cwd = cwd
186 .and_then(|cwd| cwd.strip_prefix(&worktree_root).ok())
187 .and_then(|cwd| RelPath::new(cwd, PathStyle::local()).ok())
188 .and_then(|cwd_stripped| {
189 (cwd_stripped.as_ref() != RelPath::empty()).then(|| {
190 is_cwd_in_worktree = true;
191 cwd_stripped
192 })
193 });
194
195 for path_with_position in &potential_paths {
196 let path_to_check = if worktree_root.ends_with(&path_with_position.path) {
197 let root_path_with_position = PathWithPosition {
198 path: worktree_root.to_path_buf(),
199 row: path_with_position.row,
200 column: path_with_position.column,
201 };
202 match worktree.read(cx).root_entry() {
203 Some(root_entry) => {
204 open_target = Some(OpenTarget::Worktree(
205 root_path_with_position,
206 root_entry.clone(),
207 #[cfg(test)]
208 OpenTargetFoundBy::WorktreeExact,
209 ));
210 break 'worktree_loop;
211 }
212 None => root_path_with_position,
213 }
214 } else {
215 PathWithPosition {
216 path: path_with_position
217 .path
218 .strip_prefix(&worktree_root)
219 .unwrap_or(&path_with_position.path)
220 .to_owned(),
221 row: path_with_position.row,
222 column: path_with_position.column,
223 }
224 };
225
226 // Normalize the path by joining with cwd if available (handles `.` and `..` segments)
227 let normalized_path = if path_to_check.path.is_relative() {
228 relative_cwd.as_ref().and_then(|relative_cwd| {
229 let joined = relative_cwd
230 .as_ref()
231 .as_std_path()
232 .join(&path_to_check.path);
233 normalize_lexically(&joined).ok().and_then(|p| {
234 RelPath::new(&p, PathStyle::local())
235 .ok()
236 .map(std::borrow::Cow::into_owned)
237 })
238 })
239 } else {
240 None
241 };
242 let original_path = RelPath::new(&path_to_check.path, PathStyle::local()).ok();
243
244 if !worktree.read(cx).is_single_file()
245 && let Some(entry) = normalized_path
246 .as_ref()
247 .and_then(|p| worktree.read(cx).entry_for_path(p))
248 .or_else(|| {
249 original_path
250 .as_ref()
251 .and_then(|p| worktree.read(cx).entry_for_path(p.as_ref()))
252 })
253 {
254 open_target = Some(OpenTarget::Worktree(
255 PathWithPosition {
256 path: worktree.read(cx).absolutize(&entry.path),
257 row: path_to_check.row,
258 column: path_to_check.column,
259 },
260 entry.clone(),
261 #[cfg(test)]
262 OpenTargetFoundBy::WorktreeExact,
263 ));
264 break 'worktree_loop;
265 }
266
267 paths_to_check.push(path_to_check);
268 }
269
270 if !paths_to_check.is_empty() {
271 worktree_paths_to_check.push((worktree.clone(), paths_to_check));
272 }
273 }
274
275 #[cfg(not(test))]
276 let enable_background_fs_checks = workspace.read(cx).project().read(cx).is_local();
277 #[cfg(test)]
278 let enable_background_fs_checks = background_fs_checks == BackgroundFsChecks::Enabled;
279
280 if open_target.is_some() {
281 // We we want to prefer open targets found via background fs checks over worktree matches,
282 // however we can return early if either:
283 // - This is a remote project, or
284 // - If the terminal working directory is inside of at least one worktree
285 if !enable_background_fs_checks || is_cwd_in_worktree {
286 return Task::ready(open_target);
287 }
288 }
289
290 // Before entire worktree traversal(s), make an attempt to do FS checks if available.
291 let fs_paths_to_check =
292 if enable_background_fs_checks {
293 let fs_cwd_paths_to_check = cwd
294 .iter()
295 .flat_map(|cwd| {
296 let mut paths_to_check = Vec::new();
297 for path_to_check in &potential_paths {
298 let maybe_path = &path_to_check.path;
299 if path_to_check.path.is_relative() {
300 paths_to_check.push(PathWithPosition {
301 path: cwd.join(&maybe_path),
302 row: path_to_check.row,
303 column: path_to_check.column,
304 });
305 }
306 }
307 paths_to_check
308 })
309 .collect::<Vec<_>>();
310 fs_cwd_paths_to_check
311 .into_iter()
312 .chain(
313 potential_paths
314 .into_iter()
315 .flat_map(|path_to_check| {
316 let mut paths_to_check = Vec::new();
317 let maybe_path = &path_to_check.path;
318 if maybe_path.starts_with("~") {
319 if let Some(home_path) = maybe_path.strip_prefix("~").ok().and_then(
320 |stripped_maybe_path| {
321 Some(dirs::home_dir()?.join(stripped_maybe_path))
322 },
323 ) {
324 paths_to_check.push(PathWithPosition {
325 path: home_path,
326 row: path_to_check.row,
327 column: path_to_check.column,
328 });
329 }
330 } else {
331 paths_to_check.push(PathWithPosition {
332 path: maybe_path.clone(),
333 row: path_to_check.row,
334 column: path_to_check.column,
335 });
336 if maybe_path.is_relative() {
337 for worktree in &worktree_candidates {
338 if !worktree.read(cx).is_single_file() {
339 paths_to_check.push(PathWithPosition {
340 path: worktree.read(cx).abs_path().join(maybe_path),
341 row: path_to_check.row,
342 column: path_to_check.column,
343 });
344 }
345 }
346 }
347 }
348 paths_to_check
349 })
350 .collect::<Vec<_>>(),
351 )
352 .collect()
353 } else {
354 Vec::new()
355 };
356
357 let fs = workspace.read(cx).project().read(cx).fs().clone();
358 let background_fs_checks_task = cx.background_spawn(async move {
359 for mut path_to_check in fs_paths_to_check {
360 if let Some(fs_path_to_check) = fs.canonicalize(&path_to_check.path).await.ok()
361 && let Some(metadata) = fs.metadata(&fs_path_to_check).await.ok().flatten()
362 {
363 if open_target
364 .as_ref()
365 .map(|open_target| open_target.path().path != fs_path_to_check)
366 .unwrap_or(true)
367 {
368 path_to_check.path = fs_path_to_check;
369 return Some(OpenTarget::File(path_to_check, metadata));
370 }
371
372 break;
373 }
374 }
375
376 open_target
377 });
378
379 cx.spawn(async move |cx| {
380 background_fs_checks_task.await.or_else(|| {
381 for (worktree, worktree_paths_to_check) in worktree_paths_to_check {
382 if let Some(found_entry) =
383 worktree.update(cx, |worktree, _| -> Option<OpenTarget> {
384 let traversal =
385 worktree.traverse_from_path(true, true, false, RelPath::empty());
386 for entry in traversal {
387 if let Some(path_in_worktree) =
388 worktree_paths_to_check.iter().find(|path_to_check| {
389 RelPath::new(&path_to_check.path, PathStyle::local())
390 .is_ok_and(|path| entry.path.ends_with(&path))
391 })
392 {
393 return Some(OpenTarget::Worktree(
394 PathWithPosition {
395 path: worktree.absolutize(&entry.path),
396 row: path_in_worktree.row,
397 column: path_in_worktree.column,
398 },
399 entry.clone(),
400 #[cfg(test)]
401 OpenTargetFoundBy::WorktreeScan,
402 ));
403 }
404 }
405 None
406 })
407 {
408 return Some(found_entry);
409 }
410 }
411 None
412 })
413 })
414}
415
416pub(super) fn open_path_like_target(
417 workspace: &WeakEntity<Workspace>,
418 terminal_view: &mut TerminalView,
419 path_like_target: &PathLikeTarget,
420 window: &mut Window,
421 cx: &mut Context<TerminalView>,
422) {
423 #[cfg(not(test))]
424 {
425 possibly_open_target(workspace, terminal_view, path_like_target, window, cx)
426 .detach_and_log_err(cx)
427 }
428 #[cfg(test)]
429 {
430 possibly_open_target(
431 workspace,
432 terminal_view,
433 path_like_target,
434 window,
435 cx,
436 BackgroundFsChecks::Enabled,
437 )
438 .detach_and_log_err(cx)
439 }
440}
441
442fn possibly_open_target(
443 workspace: &WeakEntity<Workspace>,
444 terminal_view: &mut TerminalView,
445 path_like_target: &PathLikeTarget,
446 window: &mut Window,
447 cx: &mut Context<TerminalView>,
448 #[cfg(test)] background_fs_checks: BackgroundFsChecks,
449) -> Task<Result<Option<OpenTarget>>> {
450 if terminal_view.hover.is_none() {
451 return Task::ready(Ok(None));
452 }
453 let workspace = workspace.clone();
454 let path_like_target = path_like_target.clone();
455 cx.spawn_in(window, async move |terminal_view, cx| {
456 let Some(open_target) = terminal_view
457 .update(cx, |_, cx| {
458 possible_open_target(
459 &workspace,
460 &path_like_target,
461 cx,
462 #[cfg(test)]
463 background_fs_checks,
464 )
465 })?
466 .await
467 else {
468 return Ok(None);
469 };
470
471 let path_to_open = open_target.path();
472 let opened_items = workspace
473 .update_in(cx, |workspace, window, cx| {
474 workspace.open_paths(
475 vec![path_to_open.path.clone()],
476 OpenOptions {
477 visible: Some(OpenVisible::OnlyDirectories),
478 ..Default::default()
479 },
480 None,
481 window,
482 cx,
483 )
484 })
485 .context("workspace update")?
486 .await;
487 if opened_items.len() != 1 {
488 debug_panic!(
489 "Received {} items for one path {path_to_open:?}",
490 opened_items.len(),
491 );
492 }
493
494 if let Some(opened_item) = opened_items.first() {
495 if open_target.is_file() {
496 if let Some(Ok(opened_item)) = opened_item {
497 if let Some(row) = path_to_open.row {
498 let col = path_to_open.column.unwrap_or(0);
499 if let Some(active_editor) = opened_item.downcast::<Editor>() {
500 active_editor
501 .downgrade()
502 .update_in(cx, |editor, window, cx| {
503 editor.go_to_singleton_buffer_point(
504 language::Point::new(
505 row.saturating_sub(1),
506 col.saturating_sub(1),
507 ),
508 window,
509 cx,
510 )
511 })
512 .log_err();
513 }
514 }
515 return Ok(Some(open_target));
516 }
517 } else if open_target.is_dir() {
518 workspace.update(cx, |workspace, cx| {
519 workspace.project().update(cx, |_, cx| {
520 cx.emit(project::Event::ActivateProjectPanel);
521 })
522 })?;
523 return Ok(Some(open_target));
524 }
525 }
526 Ok(None)
527 })
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533 use gpui::TestAppContext;
534 use project::Project;
535 use serde_json::json;
536 use std::path::{Path, PathBuf};
537 use terminal::{
538 HoveredWord, TerminalBuilder,
539 alacritty_terminal::index::Point as AlacPoint,
540 terminal_settings::{AlternateScroll, CursorShape},
541 };
542 use util::path;
543 use workspace::{AppState, MultiWorkspace};
544
545 async fn init_test(
546 app_cx: &mut TestAppContext,
547 trees: impl IntoIterator<Item = (&str, serde_json::Value)>,
548 worktree_roots: impl IntoIterator<Item = &str>,
549 ) -> impl AsyncFnMut(
550 HoveredWord,
551 PathLikeTarget,
552 BackgroundFsChecks,
553 ) -> (Option<HoverTarget>, Option<OpenTarget>) {
554 let fs = app_cx.update(AppState::test).fs.as_fake().clone();
555
556 app_cx.update(|cx| {
557 theme_settings::init(theme::LoadThemes::JustBase, cx);
558 editor::init(cx);
559 });
560
561 for (path, tree) in trees {
562 fs.insert_tree(path, tree).await;
563 }
564
565 let project: gpui::Entity<Project> = Project::test(
566 fs.clone(),
567 worktree_roots.into_iter().map(Path::new),
568 app_cx,
569 )
570 .await;
571
572 let (multi_workspace, cx) = app_cx
573 .add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
574 let workspace = multi_workspace.read_with(cx, |mw, _| mw.workspace().clone());
575
576 let terminal = app_cx.new(|cx| {
577 TerminalBuilder::new_display_only(
578 CursorShape::default(),
579 AlternateScroll::On,
580 None,
581 0,
582 cx.background_executor(),
583 PathStyle::local(),
584 )
585 .expect("Failed to create display-only terminal")
586 .subscribe(cx)
587 });
588
589 let workspace_a = workspace.clone();
590 let (terminal_view, cx) = app_cx.add_window_view(|window, cx| {
591 TerminalView::new(
592 terminal,
593 workspace_a.downgrade(),
594 None,
595 project.downgrade(),
596 window,
597 cx,
598 )
599 });
600
601 async move |hovered_word: HoveredWord,
602 path_like_target: PathLikeTarget,
603 background_fs_checks: BackgroundFsChecks|
604 -> (Option<HoverTarget>, Option<OpenTarget>) {
605 let workspace_a = workspace.clone();
606 terminal_view
607 .update(cx, |_, cx| {
608 possible_hover_target(
609 &workspace_a.downgrade(),
610 hovered_word,
611 &path_like_target,
612 cx,
613 background_fs_checks,
614 )
615 })
616 .await;
617
618 let hover_target =
619 terminal_view.read_with(cx, |terminal_view, _| terminal_view.hover.clone());
620
621 let open_target = terminal_view
622 .update_in(cx, |terminal_view, window, cx| {
623 possibly_open_target(
624 &workspace.downgrade(),
625 terminal_view,
626 &path_like_target,
627 window,
628 cx,
629 background_fs_checks,
630 )
631 })
632 .await
633 .expect("Failed to possibly open target");
634
635 (hover_target, open_target)
636 }
637 }
638
639 async fn test_path_like_simple(
640 test_path_like: &mut impl AsyncFnMut(
641 HoveredWord,
642 PathLikeTarget,
643 BackgroundFsChecks,
644 ) -> (Option<HoverTarget>, Option<OpenTarget>),
645 maybe_path: &str,
646 tooltip: &str,
647 terminal_dir: Option<PathBuf>,
648 background_fs_checks: BackgroundFsChecks,
649 mut open_target_found_by: OpenTargetFoundBy,
650 file: &str,
651 line: u32,
652 ) {
653 let (hover_target, open_target) = test_path_like(
654 HoveredWord {
655 word: maybe_path.to_string(),
656 word_match: AlacPoint::default()..=AlacPoint::default(),
657 id: 0,
658 },
659 PathLikeTarget {
660 maybe_path: maybe_path.to_string(),
661 terminal_dir,
662 },
663 background_fs_checks,
664 )
665 .await;
666
667 let Some(hover_target) = hover_target else {
668 assert!(
669 hover_target.is_some(),
670 "Hover target should not be `None` at {file}:{line}:"
671 );
672 return;
673 };
674
675 assert_eq!(
676 hover_target.tooltip, tooltip,
677 "Tooltip mismatch at {file}:{line}:"
678 );
679 assert_eq!(
680 hover_target.hovered_word.word, maybe_path,
681 "Hovered word mismatch at {file}:{line}:"
682 );
683
684 let Some(open_target) = open_target else {
685 assert!(
686 open_target.is_some(),
687 "Open target should not be `None` at {file}:{line}:"
688 );
689 return;
690 };
691
692 assert_eq!(
693 open_target.path().path,
694 Path::new(tooltip),
695 "Open target path mismatch at {file}:{line}:"
696 );
697
698 if background_fs_checks == BackgroundFsChecks::Disabled
699 && open_target_found_by == OpenTargetFoundBy::FileSystemBackground
700 {
701 open_target_found_by = OpenTargetFoundBy::WorktreeScan;
702 }
703
704 assert_eq!(
705 open_target.found_by(),
706 open_target_found_by,
707 "Open target found by mismatch at {file}:{line}:"
708 );
709 }
710
711 macro_rules! none_or_some_pathbuf {
712 (None) => {
713 None
714 };
715 ($cwd:literal) => {
716 Some($crate::PathBuf::from(path!($cwd)))
717 };
718 }
719
720 macro_rules! test_path_like {
721 (
722 $test_path_like:expr,
723 $maybe_path:literal,
724 $tooltip:literal,
725 $cwd:tt,
726 $found_by:expr
727 ) => {{
728 test_path_like!(
729 $test_path_like,
730 $maybe_path,
731 $tooltip,
732 $cwd,
733 BackgroundFsChecks::Enabled,
734 $found_by
735 );
736 test_path_like!(
737 $test_path_like,
738 $maybe_path,
739 $tooltip,
740 $cwd,
741 BackgroundFsChecks::Disabled,
742 $found_by
743 );
744 }};
745
746 (
747 $test_path_like:expr,
748 $maybe_path:literal,
749 $tooltip:literal,
750 $cwd:tt,
751 $background_fs_checks:path,
752 $found_by:expr
753 ) => {
754 test_path_like_simple(
755 &mut $test_path_like,
756 path!($maybe_path),
757 path!($tooltip),
758 none_or_some_pathbuf!($cwd),
759 $background_fs_checks,
760 $found_by,
761 std::file!(),
762 std::line!(),
763 )
764 .await
765 };
766 }
767
768 // Note the arms of `test`, `test_local`, and `test_remote` should be collapsed once macro
769 // metavariable expressions (#![feature(macro_metavar_expr)]) are stabilized.
770 // See https://github.com/rust-lang/rust/issues/83527
771 #[doc = "test_path_likes!(<cx>, <trees>, <worktrees>, { $(<tests>;)+ })"]
772 macro_rules! test_path_likes {
773 ($cx:expr, $trees:expr, $worktrees:expr, { $($tests:expr;)+ }) => { {
774 let mut test_path_like = init_test($cx, $trees, $worktrees).await;
775 #[doc ="test!(<hovered maybe_path>, <expected tooltip>, <terminal cwd> "]
776 #[doc ="\\[, found by \\])"]
777 #[allow(unused_macros)]
778 macro_rules! test {
779 ($maybe_path:literal, $tooltip:literal, $cwd:tt) => {
780 test_path_like!(
781 test_path_like,
782 $maybe_path,
783 $tooltip,
784 $cwd,
785 OpenTargetFoundBy::WorktreeExact
786 )
787 };
788 ($maybe_path:literal, $tooltip:literal, $cwd:tt, $found_by:ident) => {
789 test_path_like!(
790 test_path_like,
791 $maybe_path,
792 $tooltip,
793 $cwd,
794 OpenTargetFoundBy::$found_by
795 )
796 }
797 }
798 #[doc ="test_local!(<hovered maybe_path>, <expected tooltip>, <terminal cwd> "]
799 #[doc ="\\[, found by \\])"]
800 #[allow(unused_macros)]
801 macro_rules! test_local {
802 ($maybe_path:literal, $tooltip:literal, $cwd:tt) => {
803 test_path_like!(
804 test_path_like,
805 $maybe_path,
806 $tooltip,
807 $cwd,
808 BackgroundFsChecks::Enabled,
809 OpenTargetFoundBy::WorktreeExact
810 )
811 };
812 ($maybe_path:literal, $tooltip:literal, $cwd:tt, $found_by:ident) => {
813 test_path_like!(
814 test_path_like,
815 $maybe_path,
816 $tooltip,
817 $cwd,
818 BackgroundFsChecks::Enabled,
819 OpenTargetFoundBy::$found_by
820 )
821 }
822 }
823 #[doc ="test_remote!(<hovered maybe_path>, <expected tooltip>, <terminal cwd> "]
824 #[doc ="\\[, found by \\])"]
825 #[allow(unused_macros)]
826 macro_rules! test_remote {
827 ($maybe_path:literal, $tooltip:literal, $cwd:tt) => {
828 test_path_like!(
829 test_path_like,
830 $maybe_path,
831 $tooltip,
832 $cwd,
833 BackgroundFsChecks::Disabled,
834 OpenTargetFoundBy::WorktreeExact
835 )
836 };
837 ($maybe_path:literal, $tooltip:literal, $cwd:tt, $found_by:ident) => {
838 test_path_like!(
839 test_path_like,
840 $maybe_path,
841 $tooltip,
842 $cwd,
843 BackgroundFsChecks::Disabled,
844 OpenTargetFoundBy::$found_by
845 )
846 }
847 }
848 $($tests);+
849 } }
850 }
851
852 #[gpui::test]
853 async fn one_folder_worktree(cx: &mut TestAppContext) {
854 test_path_likes!(
855 cx,
856 vec![(
857 path!("/test"),
858 json!({
859 "lib.rs": "",
860 "test.rs": "",
861 }),
862 )],
863 vec![path!("/test")],
864 {
865 test!("lib.rs", "/test/lib.rs", None);
866 test!("/test/lib.rs", "/test/lib.rs", None);
867 test!("test.rs", "/test/test.rs", None);
868 test!("/test/test.rs", "/test/test.rs", None);
869 }
870 )
871 }
872
873 #[gpui::test]
874 async fn mixed_worktrees(cx: &mut TestAppContext) {
875 test_path_likes!(
876 cx,
877 vec![
878 (
879 path!("/"),
880 json!({
881 "file.txt": "",
882 }),
883 ),
884 (
885 path!("/test"),
886 json!({
887 "lib.rs": "",
888 "test.rs": "",
889 "file.txt": "",
890 }),
891 ),
892 ],
893 vec![path!("/file.txt"), path!("/test")],
894 {
895 test!("file.txt", "/file.txt", "/");
896 test!("/file.txt", "/file.txt", "/");
897
898 test!("lib.rs", "/test/lib.rs", "/test");
899 test!("test.rs", "/test/test.rs", "/test");
900 test!("file.txt", "/test/file.txt", "/test");
901
902 test!("/test/lib.rs", "/test/lib.rs", "/test");
903 test!("/test/test.rs", "/test/test.rs", "/test");
904 test!("/test/file.txt", "/test/file.txt", "/test");
905 }
906 )
907 }
908
909 #[gpui::test]
910 async fn worktree_file_preferred(cx: &mut TestAppContext) {
911 test_path_likes!(
912 cx,
913 vec![
914 (
915 path!("/"),
916 json!({
917 "file.txt": "",
918 }),
919 ),
920 (
921 path!("/test"),
922 json!({
923 "file.txt": "",
924 }),
925 ),
926 ],
927 vec![path!("/test")],
928 {
929 test!("file.txt", "/test/file.txt", "/test");
930 }
931 )
932 }
933
934 mod issues {
935 use super::*;
936
937 // https://github.com/zed-industries/zed/issues/28407
938 #[gpui::test]
939 async fn issue_28407_siblings(cx: &mut TestAppContext) {
940 test_path_likes!(
941 cx,
942 vec![(
943 path!("/dir1"),
944 json!({
945 "dir 2": {
946 "C.py": ""
947 },
948 "dir 3": {
949 "C.py": ""
950 },
951 }),
952 )],
953 vec![path!("/dir1")],
954 {
955 test!("C.py", "/dir1/dir 2/C.py", "/dir1", WorktreeScan);
956 test!("C.py", "/dir1/dir 2/C.py", "/dir1/dir 2");
957 test!("C.py", "/dir1/dir 3/C.py", "/dir1/dir 3");
958 }
959 )
960 }
961
962 // https://github.com/zed-industries/zed/issues/28407
963 // See https://github.com/zed-industries/zed/issues/34027
964 // See https://github.com/zed-industries/zed/issues/33498
965 #[gpui::test]
966 async fn issue_28407_nesting(cx: &mut TestAppContext) {
967 test_path_likes!(
968 cx,
969 vec![(
970 path!("/project"),
971 json!({
972 "lib": {
973 "src": {
974 "main.rs": "",
975 "only_in_lib.rs": ""
976 },
977 },
978 "src": {
979 "main.rs": ""
980 },
981 }),
982 )],
983 vec![path!("/project")],
984 {
985 test!("main.rs", "/project/src/main.rs", "/project/src");
986 test!("main.rs", "/project/lib/src/main.rs", "/project/lib/src");
987
988 test!("src/main.rs", "/project/src/main.rs", "/project");
989 test!("src/main.rs", "/project/src/main.rs", "/project/src");
990 test!("src/main.rs", "/project/lib/src/main.rs", "/project/lib");
991
992 test!("lib/src/main.rs", "/project/lib/src/main.rs", "/project");
993 test!(
994 "lib/src/main.rs",
995 "/project/lib/src/main.rs",
996 "/project/src"
997 );
998 test!(
999 "lib/src/main.rs",
1000 "/project/lib/src/main.rs",
1001 "/project/lib"
1002 );
1003 test!(
1004 "lib/src/main.rs",
1005 "/project/lib/src/main.rs",
1006 "/project/lib/src"
1007 );
1008 test!(
1009 "src/only_in_lib.rs",
1010 "/project/lib/src/only_in_lib.rs",
1011 "/project/lib/src",
1012 WorktreeScan
1013 );
1014 }
1015 )
1016 }
1017
1018 // https://github.com/zed-industries/zed/issues/28339
1019 #[gpui::test]
1020 async fn issue_28339(cx: &mut TestAppContext) {
1021 test_path_likes!(
1022 cx,
1023 vec![(
1024 path!("/tmp"),
1025 json!({
1026 "issue28339": {
1027 "foo": {
1028 "bar.txt": ""
1029 },
1030 },
1031 }),
1032 )],
1033 vec![path!("/tmp")],
1034 {
1035 test_local!(
1036 "foo/./bar.txt",
1037 "/tmp/issue28339/foo/bar.txt",
1038 "/tmp/issue28339",
1039 WorktreeExact
1040 );
1041 test_local!(
1042 "foo/../foo/bar.txt",
1043 "/tmp/issue28339/foo/bar.txt",
1044 "/tmp/issue28339",
1045 WorktreeExact
1046 );
1047 test_local!(
1048 "foo/..///foo/bar.txt",
1049 "/tmp/issue28339/foo/bar.txt",
1050 "/tmp/issue28339",
1051 WorktreeExact
1052 );
1053 test_local!(
1054 "issue28339/../issue28339/foo/../foo/bar.txt",
1055 "/tmp/issue28339/foo/bar.txt",
1056 "/tmp/issue28339",
1057 WorktreeExact
1058 );
1059 test_local!(
1060 "./bar.txt",
1061 "/tmp/issue28339/foo/bar.txt",
1062 "/tmp/issue28339/foo",
1063 WorktreeExact
1064 );
1065 test_local!(
1066 "../foo/bar.txt",
1067 "/tmp/issue28339/foo/bar.txt",
1068 "/tmp/issue28339/foo",
1069 WorktreeExact
1070 );
1071 }
1072 )
1073 }
1074
1075 // https://github.com/zed-industries/zed/issues/28339
1076 #[gpui::test]
1077 async fn issue_28339_remote(cx: &mut TestAppContext) {
1078 test_path_likes!(
1079 cx,
1080 vec![(
1081 path!("/tmp"),
1082 json!({
1083 "issue28339": {
1084 "foo": {
1085 "bar.txt": ""
1086 },
1087 },
1088 }),
1089 )],
1090 vec![path!("/tmp")],
1091 {
1092 test_remote!(
1093 "foo/./bar.txt",
1094 "/tmp/issue28339/foo/bar.txt",
1095 "/tmp/issue28339"
1096 );
1097 test_remote!(
1098 "foo/../foo/bar.txt",
1099 "/tmp/issue28339/foo/bar.txt",
1100 "/tmp/issue28339"
1101 );
1102 test_remote!(
1103 "foo/..///foo/bar.txt",
1104 "/tmp/issue28339/foo/bar.txt",
1105 "/tmp/issue28339"
1106 );
1107 test_remote!(
1108 "issue28339/../issue28339/foo/../foo/bar.txt",
1109 "/tmp/issue28339/foo/bar.txt",
1110 "/tmp/issue28339"
1111 );
1112 test_remote!(
1113 "./bar.txt",
1114 "/tmp/issue28339/foo/bar.txt",
1115 "/tmp/issue28339/foo"
1116 );
1117 test_remote!(
1118 "../foo/bar.txt",
1119 "/tmp/issue28339/foo/bar.txt",
1120 "/tmp/issue28339/foo"
1121 );
1122 }
1123 )
1124 }
1125
1126 // https://github.com/zed-industries/zed/issues/34027
1127 #[gpui::test]
1128 async fn issue_34027(cx: &mut TestAppContext) {
1129 test_path_likes!(
1130 cx,
1131 vec![(
1132 path!("/tmp/issue34027"),
1133 json!({
1134 "test.txt": "",
1135 "foo": {
1136 "test.txt": "",
1137 }
1138 }),
1139 ),],
1140 vec![path!("/tmp/issue34027")],
1141 {
1142 test!("test.txt", "/tmp/issue34027/test.txt", "/tmp/issue34027");
1143 test!(
1144 "test.txt",
1145 "/tmp/issue34027/foo/test.txt",
1146 "/tmp/issue34027/foo"
1147 );
1148 }
1149 )
1150 }
1151
1152 // https://github.com/zed-industries/zed/issues/34027
1153 #[gpui::test]
1154 async fn issue_34027_siblings(cx: &mut TestAppContext) {
1155 test_path_likes!(
1156 cx,
1157 vec![(
1158 path!("/test"),
1159 json!({
1160 "sub1": {
1161 "file.txt": "",
1162 },
1163 "sub2": {
1164 "file.txt": "",
1165 }
1166 }),
1167 ),],
1168 vec![path!("/test")],
1169 {
1170 test!("file.txt", "/test/sub1/file.txt", "/test/sub1");
1171 test!("file.txt", "/test/sub2/file.txt", "/test/sub2");
1172 test!("sub1/file.txt", "/test/sub1/file.txt", "/test/sub1");
1173 test!("sub2/file.txt", "/test/sub2/file.txt", "/test/sub2");
1174 test!("sub1/file.txt", "/test/sub1/file.txt", "/test/sub2");
1175 test!("sub2/file.txt", "/test/sub2/file.txt", "/test/sub1");
1176 }
1177 )
1178 }
1179
1180 // https://github.com/zed-industries/zed/issues/34027
1181 #[gpui::test]
1182 async fn issue_34027_nesting(cx: &mut TestAppContext) {
1183 test_path_likes!(
1184 cx,
1185 vec![(
1186 path!("/test"),
1187 json!({
1188 "sub1": {
1189 "file.txt": "",
1190 "subsub1": {
1191 "file.txt": "",
1192 }
1193 },
1194 "sub2": {
1195 "file.txt": "",
1196 "subsub1": {
1197 "file.txt": "",
1198 }
1199 }
1200 }),
1201 ),],
1202 vec![path!("/test")],
1203 {
1204 test!(
1205 "file.txt",
1206 "/test/sub1/subsub1/file.txt",
1207 "/test/sub1/subsub1"
1208 );
1209 test!(
1210 "file.txt",
1211 "/test/sub2/subsub1/file.txt",
1212 "/test/sub2/subsub1"
1213 );
1214 test!(
1215 "subsub1/file.txt",
1216 "/test/sub1/subsub1/file.txt",
1217 "/test",
1218 WorktreeScan
1219 );
1220 test!(
1221 "subsub1/file.txt",
1222 "/test/sub1/subsub1/file.txt",
1223 "/test",
1224 WorktreeScan
1225 );
1226 test!(
1227 "subsub1/file.txt",
1228 "/test/sub1/subsub1/file.txt",
1229 "/test/sub1"
1230 );
1231 test!(
1232 "subsub1/file.txt",
1233 "/test/sub2/subsub1/file.txt",
1234 "/test/sub2"
1235 );
1236 test!(
1237 "subsub1/file.txt",
1238 "/test/sub1/subsub1/file.txt",
1239 "/test/sub1/subsub1",
1240 WorktreeScan
1241 );
1242 }
1243 )
1244 }
1245
1246 // https://github.com/zed-industries/zed/issues/34027
1247 #[gpui::test]
1248 async fn issue_34027_non_worktree_local_file(cx: &mut TestAppContext) {
1249 test_path_likes!(
1250 cx,
1251 vec![
1252 (
1253 path!("/"),
1254 json!({
1255 "file.txt": "",
1256 }),
1257 ),
1258 (
1259 path!("/test"),
1260 json!({
1261 "file.txt": "",
1262 }),
1263 ),
1264 ],
1265 vec![path!("/test")],
1266 {
1267 // Note: Opening a non-worktree file adds that file as a single file worktree.
1268 test_local!("file.txt", "/file.txt", "/", FileSystemBackground);
1269 }
1270 )
1271 }
1272
1273 // https://github.com/zed-industries/zed/issues/34027
1274 #[gpui::test]
1275 async fn issue_34027_non_worktree_remote_file(cx: &mut TestAppContext) {
1276 test_path_likes!(
1277 cx,
1278 vec![
1279 (
1280 path!("/"),
1281 json!({
1282 "file.txt": "",
1283 }),
1284 ),
1285 (
1286 path!("/test"),
1287 json!({
1288 "file.txt": "",
1289 }),
1290 ),
1291 ],
1292 vec![path!("/test")],
1293 {
1294 // Note: Opening a non-worktree file adds that file as a single file worktree.
1295 test_remote!("file.txt", "/test/file.txt", "/");
1296 test_remote!("/test/file.txt", "/test/file.txt", "/");
1297 }
1298 )
1299 }
1300
1301 // See https://github.com/zed-industries/zed/issues/34027
1302 #[gpui::test]
1303 #[should_panic(expected = "Tooltip mismatch")]
1304 async fn issue_34027_gaps(cx: &mut TestAppContext) {
1305 test_path_likes!(
1306 cx,
1307 vec![(
1308 path!("/project"),
1309 json!({
1310 "lib": {
1311 "src": {
1312 "main.rs": ""
1313 },
1314 },
1315 "src": {
1316 "main.rs": ""
1317 },
1318 }),
1319 )],
1320 vec![path!("/project")],
1321 {
1322 test!("main.rs", "/project/src/main.rs", "/project");
1323 test!("main.rs", "/project/lib/src/main.rs", "/project/lib");
1324 }
1325 )
1326 }
1327
1328 // See https://github.com/zed-industries/zed/issues/34027
1329 #[gpui::test]
1330 #[should_panic(expected = "Tooltip mismatch")]
1331 async fn issue_34027_overlap(cx: &mut TestAppContext) {
1332 test_path_likes!(
1333 cx,
1334 vec![(
1335 path!("/project"),
1336 json!({
1337 "lib": {
1338 "src": {
1339 "main.rs": ""
1340 },
1341 },
1342 "src": {
1343 "main.rs": ""
1344 },
1345 }),
1346 )],
1347 vec![path!("/project")],
1348 {
1349 // Finds "/project/src/main.rs"
1350 test!(
1351 "src/main.rs",
1352 "/project/lib/src/main.rs",
1353 "/project/lib/src"
1354 );
1355 }
1356 )
1357 }
1358 }
1359}