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