mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
fix(terminal): read a pane's paths in its own host's spelling (#795)
This commit is contained in:
+413
-57
@@ -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<PathBuf>) -> 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<PathBuf> {
|
||||
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<PathBuf> = 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<PathBuf> {
|
||||
/// 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<String> {
|
||||
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<PathB
|
||||
/// box says nothing about that box's home, and turning `~/.zshrc` into
|
||||
/// `/Users/me/.zshrc` and asking the far side about it is how a link ends up
|
||||
/// pointing at a file nobody meant.
|
||||
fn home_dir(cwd: Option<&Path>, local_home: bool) -> Option<PathBuf> {
|
||||
if let Some(home) = cwd.and_then(home_from_cwd) {
|
||||
fn home_dir(cwd: Option<&Path>, local_home: bool, style: PathStyle) -> Option<PathBuf> {
|
||||
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<PathBuf> {
|
||||
.map(PathBuf::from)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn home_from_cwd(cwd: &Path) -> Option<PathBuf> {
|
||||
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<PathBuf> {
|
||||
None
|
||||
/// The `/home/<user>` or `/Users/<user>` 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<PathBuf> {
|
||||
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<_>>(),
|
||||
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<_>>(),
|
||||
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]
|
||||
|
||||
+76
-6
@@ -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<Self>,
|
||||
) -> 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<Self>) -> 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<Self>) {
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user