terminal_path_like_target.rs

   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}