diff --git a/src/terminal/search.rs b/src/terminal/search.rs index 09b7d2e9..dff459e1 100644 --- a/src/terminal/search.rs +++ b/src/terminal/search.rs @@ -727,6 +727,103 @@ pub(super) fn local_probe(path: &Path, require_file: bool) -> Probe { } } +/// Which language a pane's paths are written in. +/// +/// The machine tty7 runs on and the machine a pane's paths live on need not +/// agree, and `std::path` only ever speaks the first one's dialect. On a +/// Windows client that is the whole difference between a link and nothing: a +/// leading `/` is not absolute to `Path` there, so `/etc/hosts` printed by a +/// Linux pane used to be measured from that pane's directory instead of +/// standing alone, and a relative `src/lib.rs` was joined onto it with a +/// backslash the far side has never heard of. +/// +/// The two arms are the two dialects, not the two operating systems: a WSL +/// distro and an SSH host both speak [`PathStyle::Posix`] whatever the client +/// is, and a pane on this machine speaks [`PathStyle::NATIVE`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum PathStyle { + /// `/`-rooted and `/`-joined. Everything a Unix host, a WSL distro or a + /// Git-Bash-style shell prints. + Posix, + /// Rooted by a drive letter or a UNC share, joined with `\`. + Windows, +} + +impl Default for PathStyle { + fn default() -> Self { + Self::NATIVE + } +} + +impl PathStyle { + /// The dialect the machine tty7 is running on speaks. + pub const NATIVE: PathStyle = match cfg!(windows) { + true => PathStyle::Windows, + false => PathStyle::Posix, + }; + + /// The dialect a host that called `sample` one of its own directories + /// speaks: a drive letter or a UNC share is a Windows host's, and anything + /// else is read as POSIX. + /// + /// Only a directory that names a *Windows* root counts as one, rather than + /// every directory that does not name a POSIX one. A cwd that is neither — + /// a relative one, or an empty one from a host that has not settled yet — + /// is the same "nothing to go on" as no cwd at all, and has to fall the + /// same way; reading it as Windows would spell a Linux host's paths with + /// backslashes on the strength of a directory it never really reported. + /// + /// This is inference from what a host says about itself in passing. It is + /// sound in the direction that matters: nothing but a POSIX host reports a + /// `/`-rooted cwd. + pub fn of_dir(sample: &Path) -> Self { + match PathStyle::Windows.is_absolute(&sample.to_string_lossy()) { + true => PathStyle::Windows, + false => PathStyle::Posix, + } + } + + /// Whether a token says for itself which filesystem root it hangs off. + /// + /// Deliberately textual rather than [`Path::is_absolute`], which answers + /// for *this* machine: the same string has to be read the pane's way on + /// every client, or a link works on a Mac and not on the Windows box next + /// to it. + pub fn is_absolute(self, path: &str) -> bool { + match self { + PathStyle::Posix => path.starts_with('/'), + // A UNC share, or a drive letter with a separator behind it. + // `C:foo` is drive-*relative* and deliberately not included, which + // is what `Path::is_absolute` says on Windows too. + PathStyle::Windows => { + if path.starts_with("\\\\") { + return true; + } + let mut chars = path.chars(); + chars.next().is_some_and(|c| c.is_ascii_alphabetic()) + && chars.next() == Some(':') + && matches!(chars.next(), Some('\\' | '/')) + } + } + } + + /// `rel` measured from `root`, spelled the way the pane's host spells it. + /// + /// The POSIX arm joins textually because `Path::join` would reach for this + /// machine's separator: on Windows it turns `/home/u` and `src/lib.rs` + /// into `/home/u\src/lib.rs`, which the Linux box on the other end of the + /// probe cannot stat. + pub fn join(self, root: &Path, rel: &str) -> PathBuf { + match self { + PathStyle::Posix => PathBuf::from(format!( + "{}/{rel}", + root.to_string_lossy().trim_end_matches('/') + )), + PathStyle::Windows => root.join(rel), + } + } +} + /// Where a relative path printed by a pane is measured from. /// /// `local_home` is the part that is easy to miss. `~` has to become a real @@ -743,15 +840,18 @@ pub(super) struct LinkRoots { /// Whether this machine's `$HOME` may stand in for a `~` the roots cannot /// explain. pub local_home: bool, + /// How the pane's host spells the paths it prints. + pub style: PathStyle, } impl LinkRoots { /// Roots on the machine tty7 is running on, where `$HOME` means what it - /// says. + /// says and paths are spelled this OS's way. pub fn local(dirs: Vec) -> Self { Self { dirs, local_home: true, + style: PathStyle::NATIVE, } } @@ -841,18 +941,18 @@ impl FileCandidate { /// Every path this token could mean, best guess first: absolute paths /// stand alone, relative ones are joined onto each root in turn. pub fn paths(&self, roots: &LinkRoots) -> Vec { - let Some(expanded) = expand_home(&self.path, roots.cwd(), roots.local_home) else { + let Some(expanded) = expand_home(&self.path, roots) else { return Vec::new(); }; - if expanded.as_os_str().is_empty() { + if expanded.is_empty() { return Vec::new(); } - if expanded.is_absolute() { - return vec![expanded]; + if roots.style.is_absolute(&expanded) { + return vec![PathBuf::from(expanded)]; } let mut out: Vec = Vec::new(); for root in &roots.dirs { - let joined = root.join(&expanded); + let joined = roots.style.join(root, &expanded); if !out.contains(&joined) { out.push(joined); } @@ -864,17 +964,26 @@ impl FileCandidate { /// measured from anywhere. [`Self::paths`] ignores the roots entirely for /// these, so a report about one must not name a directory as the place it /// was looked for. - pub fn is_rooted(&self) -> bool { - self.path.starts_with('~') || Path::new(&self.path).is_absolute() + /// + /// Takes the pane's own dialect for the same reason `paths` does: on a + /// Windows client `/etc/hosts` printed by a Linux pane is rooted and + /// `/etc/hosts` printed by a `cmd.exe` pane is not, and `Path` alone + /// cannot tell those apart. + pub fn is_rooted(&self, style: PathStyle) -> bool { + self.path.starts_with('~') || style.is_absolute(&self.path) } /// Whether the token is written enough like a path to be worth telling the /// user about when nothing answers for it. A bare word is not — every /// modifier-click on ordinary output would raise a notification saying so. - pub fn looks_like_a_path(&self) -> bool { + /// + /// A backslash counts only where it separates directories. In a POSIX + /// pane it is an escape or an ordinary filename character, so `foo\ bar` + /// there is a word, not a path. + pub fn looks_like_a_path(&self, style: PathStyle) -> bool { self.path.starts_with('~') || self.path.contains('/') - || (cfg!(windows) && self.path.contains('\\')) + || (style == PathStyle::Windows && self.path.contains('\\')) } } @@ -1054,14 +1163,29 @@ fn strip_numeric_suffix(token: &str) -> Option<(&str, u32)> { Some((prefix, value)) } -fn expand_home(path: &str, cwd: Option<&Path>, local_home: bool) -> Option { +/// The token with a leading `~` turned into a real directory, still spelled +/// the pane's way — a string rather than a `PathBuf`, because deciding what is +/// absolute and how to join is the [`PathStyle`]'s job from here on and +/// `Path` would answer for the wrong machine. +fn expand_home(path: &str, roots: &LinkRoots) -> Option { + let home = || { + home_dir(roots.cwd(), roots.local_home, roots.style) + .map(|home| home.to_string_lossy().into_owned()) + }; if path == "~" { - return home_dir(cwd, local_home); + return home(); } - if let Some(rest) = path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")) { - return home_dir(cwd, local_home).map(|home| home.join(rest)); + let rest = match roots.style { + PathStyle::Posix => path.strip_prefix("~/"), + PathStyle::Windows => path.strip_prefix("~/").or_else(|| path.strip_prefix("~\\")), + }; + if let Some(rest) = rest { + return home().map(|home| match roots.style { + PathStyle::Posix => format!("{}/{rest}", home.trim_end_matches('/')), + PathStyle::Windows => Path::new(&home).join(rest).to_string_lossy().into_owned(), + }); } - Some(PathBuf::from(path)) + Some(path.to_string()) } /// The home `~` stands for, read out of the cwd where it can be and out of the @@ -1072,8 +1196,8 @@ fn expand_home(path: &str, cwd: Option<&Path>, local_home: bool) -> Option, local_home: bool) -> Option { - if let Some(home) = cwd.and_then(home_from_cwd) { +fn home_dir(cwd: Option<&Path>, local_home: bool, style: PathStyle) -> Option { + if let Some(home) = cwd.and_then(|cwd| home_from_cwd(cwd, style)) { return Some(home); } if !local_home { @@ -1085,25 +1209,26 @@ fn home_dir(cwd: Option<&Path>, local_home: bool) -> Option { .map(PathBuf::from) } -#[cfg(unix)] -fn home_from_cwd(cwd: &Path) -> Option { - let mut components = cwd.components(); - let root = components.next()?; - let base = components.next()?; - let user = components.next()?; - let base = base.as_os_str().to_str()?; - matches!(base, "Users" | "home").then(|| { - let mut home = PathBuf::new(); - home.push(root.as_os_str()); - home.push(base); - home.push(user.as_os_str()); - home - }) -} - -#[cfg(not(unix))] -fn home_from_cwd(_cwd: &Path) -> Option { - None +/// The `/home/` or `/Users/` a POSIX cwd sits under. +/// +/// Read off the string rather than `Path::components`, and keyed off the +/// pane's dialect rather than `cfg!(unix)`. The old spelling was gated to Unix +/// clients, which meant a Windows tty7 could not say what `~` meant in *any* +/// pane it was looking at — `~/.zshrc` printed by a Linux host resolved on a +/// Mac and silently did not on a Windows box beside it. +/// +/// A Windows-dialect cwd still gets no answer: nothing about `D:\Users\team` +/// says whose home it is, and the environment fallback in [`home_dir`] is both +/// available and right for the panes that spell paths that way. +fn home_from_cwd(cwd: &Path, style: PathStyle) -> Option { + if style != PathStyle::Posix { + return None; + } + let cwd = cwd.to_str()?; + let mut parts = cwd.strip_prefix('/')?.split('/').filter(|p| !p.is_empty()); + let base = parts.next()?; + let user = parts.next()?; + matches!(base, "Users" | "home").then(|| PathBuf::from(format!("/{base}/{user}"))) } fn trim_trailing_punct(token: &mut String) { @@ -1481,13 +1606,19 @@ mod tests { assert_eq!((link.start, link.end), (6, 21)); } + /// Ungated with the rest: what a `~` in a POSIX pane stands for is that + /// pane's business and not its client's, and the `#[cfg(unix)]` this used + /// to carry described the wrong machine. #[test] - #[cfg(unix)] fn tilde_expansion_prefers_home_inferred_from_the_pane_cwd() { - let cwd = Path::new("/Users/alice/clone/tty7"); + let roots = LinkRoots { + dirs: vec![PathBuf::from("/Users/alice/clone/tty7")], + local_home: true, + style: PathStyle::Posix, + }; assert_eq!( - expand_home("~/clone/tty7/src/main.rs", Some(cwd), true), - Some(PathBuf::from("/Users/alice/clone/tty7/src/main.rs")) + expand_home("~/clone/tty7/src/main.rs", &roots), + Some("/Users/alice/clone/tty7/src/main.rs".to_string()) ); } @@ -1495,17 +1626,24 @@ mod tests { /// machine: this machine's `$HOME` describes nobody there, and a path built /// out of it would be asked about — and possibly answered — on the far side. #[test] - #[cfg(unix)] fn tilde_expansion_does_not_borrow_this_machines_home_for_another_one() { - let cwd = Path::new("/srv/app"); - assert_eq!(expand_home("~/.zshrc", Some(cwd), false), None); + let elsewhere = |cwd: &str| LinkRoots { + dirs: vec![PathBuf::from(cwd)], + local_home: false, + style: PathStyle::Posix, + }; + assert_eq!(expand_home("~/.zshrc", &elsewhere("/srv/app")), None); assert_eq!( - expand_home("~/.zshrc", Some(Path::new("/home/deploy/app")), false), - Some(PathBuf::from("/home/deploy/.zshrc")), + expand_home("~/.zshrc", &elsewhere("/home/deploy/app")), + Some("/home/deploy/.zshrc".to_string()), "a cwd that does reveal the home needs nothing from us" ); assert!( - expand_home("~/.zshrc", Some(cwd), true).is_some(), + expand_home( + "~/.zshrc", + &LinkRoots::local(vec![PathBuf::from("/srv/app")]) + ) + .is_some(), "a local pane still falls back to the environment" ); } @@ -1682,14 +1820,25 @@ mod tests { fn a_path_shaped_token_is_kept_apart_from_a_bare_word() { let path_shaped = file_candidate_at("wrote scratchpad/notes.md now", 8).expect("candidate"); assert_eq!(path_shaped.path, "scratchpad/notes.md"); - assert!(path_shaped.looks_like_a_path()); + assert!(path_shaped.looks_like_a_path(PathStyle::NATIVE)); let word = file_candidate_at("wrote notes now", 8).expect("candidate"); assert_eq!(word.path, "notes"); assert!( - !word.looks_like_a_path(), + !word.looks_like_a_path(PathStyle::NATIVE), "a bare word must not raise a notification on every modifier-click" ); + + let escaped = file_candidate_at(r"wrote a\b now", 6).expect("candidate"); + assert_eq!(escaped.path, r"a\b"); + assert!( + escaped.looks_like_a_path(PathStyle::Windows), + "a backslash separates directories in a Windows pane" + ); + assert!( + !escaped.looks_like_a_path(PathStyle::Posix), + "and escapes a space in a POSIX one, whatever this client runs" + ); } #[test] @@ -1721,23 +1870,230 @@ mod tests { /// `is_rooted` decides whether a report about an unresolved token may name /// a directory it was "looked for under", so it has to agree with /// [`FileCandidate::paths`] about when the roots are consulted at all. - /// Both ask `is_absolute`, and on Windows a leading `/` does not make a - /// path that — which is why this only claims to hold where it does. + /// + /// Both used to ask `Path::is_absolute`, which answers for the machine + /// tty7 runs on rather than the one the pane's paths are on — so this only + /// held on Unix and was gated to it. Both now ask the pane's own dialect, + /// and the agreement holds on every client. #[test] - #[cfg(unix)] fn a_rooted_candidate_is_told_apart_from_one_measured_from_a_root() { + let posix_roots = LinkRoots { + dirs: vec![PathBuf::from("/home/u/proj")], + local_home: false, + style: PathStyle::Posix, + }; for line in ["open /etc/hosts now", "open ~/.zshrc now"] { + let candidate = file_candidate_at(line, 6).expect("candidate"); assert!( - file_candidate_at(line, 6).expect("candidate").is_rooted(), + candidate.is_rooted(PathStyle::Posix), + "{line} says for itself where it starts" + ); + let paths = candidate.paths(&posix_roots); + assert!( + !paths.iter().any(|p| p.starts_with("/home/u/proj")), + "{line} was not measured from the pane's directory: {paths:?}" + ); + } + for line in [r"open C:\Windows\win.ini now", "open ~/.gitconfig now"] { + assert!( + file_candidate_at(line, 6) + .expect("candidate") + .is_rooted(PathStyle::Windows), "{line} says for itself where it starts" ); } - assert!( - !file_candidate_at("see src/lib.rs here", 5) - .expect("candidate") - .is_rooted(), - "a relative path is only ever found by measuring from somewhere" + for style in [PathStyle::Posix, PathStyle::Windows] { + assert!( + !file_candidate_at("see src/lib.rs here", 5) + .expect("candidate") + .is_rooted(style), + "a relative path is only ever found by measuring from somewhere" + ); + } + } + + /// The bug this whole [`PathStyle`] exists for: a Windows tty7 looking at + /// a Linux pane — a remote workspace, a native-SSH pane, or a WSL distro + /// reached as a host — used to read `/etc/hosts` as a *relative* path, + /// because `Path::is_absolute` speaks for the client and a leading `/` is + /// not absolute on Windows. The pane's paths were then measured from its + /// own directory and the far side was asked about something it never + /// printed, so nothing ever underlined. + #[test] + fn a_posix_pane_roots_its_own_paths_on_every_client() { + let roots = LinkRoots { + dirs: vec![PathBuf::from("/home/u/proj")], + local_home: false, + style: PathStyle::Posix, + }; + let candidate = file_candidate_at("open /etc/hosts now", 6).expect("candidate"); + assert_eq!( + candidate.paths(&roots), + vec![PathBuf::from("/etc/hosts")], + "the pane said where the path starts; the roots have nothing to add" ); + + // And with no cwd reported at all there is still exactly one thing it + // can mean. This is the case that failed outright: no roots meant no + // paths, so the host was never even asked. + let roots = LinkRoots { + dirs: Vec::new(), + local_home: false, + style: PathStyle::Posix, + }; + assert_eq!(candidate.paths(&roots), vec![PathBuf::from("/etc/hosts")]); + } + + /// A relative path is the other half, and it fails more quietly: the join + /// used to reach for the *client's* separator, so a Windows tty7 asked a + /// Linux host about `/home/u/proj\src/lib.rs`. + #[test] + fn a_posix_pane_joins_a_relative_path_with_its_own_separator() { + let roots = LinkRoots { + dirs: vec![PathBuf::from("/home/u/proj"), PathBuf::from("/home/u")], + local_home: false, + style: PathStyle::Posix, + }; + let paths = file_candidate_at("see src/lib.rs here", 5) + .expect("candidate") + .paths(&roots); + assert_eq!( + paths + .iter() + .map(|p| p.to_string_lossy()) + .collect::>(), + vec!["/home/u/proj/src/lib.rs", "/home/u/src/lib.rs"], + "the string the far side is asked about has to be one it can stat" + ); + } + + /// `~` in a POSIX pane is read out of the pane's own cwd — on every + /// client. The rule was gated to Unix ones, so a Windows tty7 could not + /// resolve `~/.zshrc` in any pane it was looking at. + #[test] + fn a_posix_pane_reads_a_tilde_out_of_its_own_cwd() { + let roots = LinkRoots { + dirs: vec![PathBuf::from("/home/u/proj")], + local_home: false, + style: PathStyle::Posix, + }; + let paths = file_candidate_at("open ~/.zshrc now", 6) + .expect("candidate") + .paths(&roots); + assert_eq!( + paths + .iter() + .map(|p| p.to_string_lossy()) + .collect::>(), + vec!["/home/u/.zshrc"] + ); + + let no_home = LinkRoots { + dirs: vec![PathBuf::from("/srv/app")], + local_home: false, + style: PathStyle::Posix, + }; + assert!( + file_candidate_at("open ~/.zshrc now", 6) + .expect("candidate") + .paths(&no_home) + .is_empty(), + "a cwd that reveals no home may not borrow this machine's (#568)" + ); + } + + /// The deliberate other half: a pane *on this machine* keeps this OS's + /// reading of its own output. `/etc` in a `cmd.exe` pane sitting on `C:` + /// means `C:\etc`, the way `cd /etc` does there — so it is measured from + /// the pane's root and not turned into a link to a file Windows has not + /// got. Pinned because the temptation is to make every `/`-rooted token + /// stand alone, and that would underline `/etc/hosts` in a PowerShell pane + /// with nothing behind it. + #[test] + fn a_local_pane_reads_its_own_output_the_way_its_own_os_does() { + let candidate = file_candidate_at("open /etc/hosts now", 6).expect("candidate"); + assert_eq!( + candidate.is_rooted(PathStyle::NATIVE), + cfg!(unix), + "a leading slash roots a path on Unix and names a drive-relative \ + directory on Windows" + ); + + let roots = LinkRoots::local(vec![PathBuf::from("/w")]); + assert_eq!( + roots.style, + PathStyle::NATIVE, + "a pane on this machine spells paths this machine's way" + ); + assert_eq!( + candidate.paths(&roots), + vec![PathBuf::from("/etc/hosts")], + "which comes to the same thing under a root with no drive letter" + ); + } + + /// The Windows half of the rule above, spelled out where it can be: the + /// drive the pane is on is what a leading `/` there is measured from. + /// Cannot be asserted on a Unix client, where `PathBuf` has no notion of a + /// drive at all — the *rule* is pinned ungated above, this is the reading. + #[test] + #[cfg(windows)] + fn a_local_windows_pane_measures_a_leading_slash_from_its_own_drive() { + let roots = LinkRoots::local(vec![PathBuf::from(r"C:\proj")]); + assert_eq!( + file_candidate_at("open /etc/hosts now", 6) + .expect("candidate") + .paths(&roots), + vec![PathBuf::from(r"C:\etc\hosts")], + "`/etc` in a cmd.exe pane on C: is C:\\etc, the way `cd /etc` is" + ); + } + + #[test] + fn a_windows_pane_roots_a_drive_letter_and_a_share() { + let style = PathStyle::Windows; + for rooted in [r"C:\Windows", "C:/Windows", r"\\server\share\x"] { + assert!(style.is_absolute(rooted), "{rooted} names its own root"); + } + for measured in ["/etc/hosts", "C:notes.txt", r"src\lib.rs", "", "C:"] { + assert!( + !style.is_absolute(measured), + "{measured} has to be measured from somewhere" + ); + } + for measured in [r"C:\Windows", r"src\lib.rs", ""] { + assert!( + !PathStyle::Posix.is_absolute(measured), + "{measured} is not a POSIX root" + ); + } + } + + #[test] + fn a_hosts_dialect_is_read_off_the_directory_it_reports() { + assert_eq!( + PathStyle::of_dir(Path::new("/home/u/proj")), + PathStyle::Posix + ); + assert_eq!( + PathStyle::of_dir(Path::new(r"C:\Users\u\proj")), + PathStyle::Windows + ); + assert_eq!( + PathStyle::of_dir(Path::new(r"\\wsl$\Ubuntu\home\u")), + PathStyle::Windows + ); + // A directory that names no root at all says nothing about the host, + // so it has to fall the way no directory does — POSIX. Reading it as + // Windows would hand a Linux host `\`-joined paths on the strength of + // a cwd it never really reported. + for nothing_to_go_on in ["", "proj", "~/proj", "C:notes"] { + assert_eq!( + PathStyle::of_dir(Path::new(nothing_to_go_on)), + PathStyle::Posix, + "{nothing_to_go_on:?} names no Windows root" + ); + } } #[test] diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 316d10cd..5978fd8f 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -219,6 +219,30 @@ fn cwd_is_on_host(pane_runs_remotely: bool, host_is_local: bool) -> bool { } } +/// Which path dialect a pane's output is written in. +/// +/// A pane running on this machine spells paths the way this OS does, and that +/// is the end of it: `/etc` printed by a `cmd.exe` pane sitting on `C:` means +/// `C:\etc`, exactly as `cd /etc` would there. Reading it as a rooted POSIX +/// path would underline a file this machine has not got, and a link that +/// cannot be opened is worse than no link. +/// +/// A pane whose paths live somewhere else is asked instead — by the only thing +/// that host ever says about its own spelling, the directory it reports. A +/// `/`-rooted cwd is a POSIX host's. A pane that has not said where it is +/// falls to POSIX: there is no local drive to measure it from either way, and +/// every host tty7 installs a server on over SSH or WSL spells paths that way. +fn link_path_style( + paths_are_local: bool, + host_cwd: Option<&std::path::Path>, +) -> super::search::PathStyle { + use super::search::PathStyle; + match paths_are_local { + true => PathStyle::NATIVE, + false => host_cwd.map_or(PathStyle::Posix, PathStyle::of_dir), + } +} + pub struct TerminalView { pub terminal: RemoteTerminal, host_id: crate::ui::host_ops::HostId, @@ -5327,9 +5351,10 @@ impl TerminalView { window: &mut Window, cx: &mut Context, ) -> bool { + let roots = self.link_roots(cx); // A word that is not written like a path was never a link, and saying // so on every modifier-click over ordinary output would be noise. - if !candidate.looks_like_a_path() { + if !candidate.looks_like_a_path(roots.style) { return false; } // The host has not answered yet. The underline is the promise that it @@ -5340,10 +5365,10 @@ impl TerminalView { // An absolute or `~`-rooted path was never measured from anywhere, so // naming a directory it was "looked for under" would send the user to // somewhere nothing was ever asked about. - let rooted = candidate.is_rooted(); + let rooted = candidate.is_rooted(roots.style); let root = match rooted { true => None, - false => self.link_roots(cx).dirs.into_iter().next(), + false => roots.dirs.into_iter().next(), }; let message = match root { Some(root) => t_fmt( @@ -5596,10 +5621,12 @@ impl TerminalView { /// that exists in both is the near one. fn link_roots(&mut self, cx: &mut Context) -> super::search::LinkRoots { let local_home = self.host_id.is_local(); + let style = self.link_path_style(); let Some(cwd) = self.effective_host_cwd() else { return super::search::LinkRoots { dirs: Vec::new(), local_home, + style, }; }; self.request_link_repo_root(&cwd, cx); @@ -5610,7 +5637,17 @@ impl TerminalView { { dirs.push(root.clone()); } - super::search::LinkRoots { dirs, local_home } + super::search::LinkRoots { + dirs, + local_home, + style, + } + } + + /// Which path dialect this pane's output is written in — see + /// [`link_path_style`]. + fn link_path_style(&self) -> super::search::PathStyle { + link_path_style(self.paths_are_local(), self.effective_host_cwd().as_deref()) } fn request_link_repo_root(&mut self, cwd: &std::path::Path, cx: &mut Context) { @@ -7285,7 +7322,7 @@ mod tests { use super::{ COMPLETION_MENU_MAX_W, LoopbackPlan, RawInput, SelectEndCopy, Typeahead, WheelRoute, clipboard_paste_text, compose_notification_title, cwd_is_on_host, display_width, - is_typeahead_interrupt, loopback_plan, observe_typeahead_for_owner, + is_typeahead_interrupt, link_path_style, loopback_plan, observe_typeahead_for_owner, }; use super::{SCROLL_ANIM_FRAME, scroll_anim_step}; use super::{ @@ -8812,6 +8849,39 @@ mod tests { assert!(!cwd_is_on_host(false, false)); } + /// Which machine's spelling a pane's paths are read in. Ungated on + /// purpose: the bug this settles was a Windows-only one that hid behind a + /// `#[cfg(unix)]` on the test that covered it. + #[test] + fn a_panes_paths_are_read_in_its_own_hosts_spelling() { + use super::super::search::PathStyle; + use std::path::Path; + + assert_eq!( + link_path_style(true, Some(Path::new("/home/u/proj"))), + PathStyle::NATIVE, + "a pane on this machine reads its own output this OS's way, \ + whatever its shell spells the cwd like" + ); + assert_eq!( + link_path_style(false, Some(Path::new("/home/u/proj"))), + PathStyle::Posix, + "an SSH host, a remote workspace or a WSL distro reporting a \ + /-rooted cwd is a POSIX one on every client" + ); + assert_eq!( + link_path_style(false, Some(Path::new(r"C:\Users\u\proj"))), + PathStyle::Windows, + "and a remote Windows host is not" + ); + assert_eq!( + link_path_style(false, None), + PathStyle::Posix, + "a remote pane that has not said where it is still has no local \ + drive its paths could hang off" + ); + } + #[test] fn a_panes_host_is_its_workspaces_machine() { use crate::core::session::{RemoteTarget, WorkspaceId}; @@ -9500,7 +9570,7 @@ mod gpui_tests { LinkAt::Unresolved { candidate, pending } => { assert_eq!(candidate.path, "scratchpad/gone.md"); assert!( - candidate.looks_like_a_path(), + candidate.looks_like_a_path(view.link_path_style()), "so the click reports it instead of staying silent" ); assert!(!pending, "a local pane answers on the spot");