feat(links): make Windows file paths clickable

Drive-letter paths with either separator and UNC shares resolve as file
links, including when CJK prose or a Markdown link is glued onto them.
Windows paths are spelled with backslashes and an upper-case drive so
Explorer can open and reveal them, the Windows join is textual so a Mac
asking a Windows host sends one spelling, and a POSIX pane looks a drive
path up under /mnt/<drive> for WSL. Drive-relative tokens (a:b, C:,
C:notes.txt) are never probed.

Closes #965
This commit is contained in:
l0ng-ai
2026-09-26 14:00:44 +02:00
parent cf0e8f02e6
commit 760be844aa
2 changed files with 439 additions and 21 deletions
+11
View File
@@ -64,6 +64,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
pane in front as before, and an ended session still says so. The key is
`ssh_tab_title` (`dynamic`, `profile-name`, `hostname`).
- **Windows file paths are links** (#965). `C:\Users\me\a.png`,
`c:/Users/me/a.png` and `\\server\share\a.png` underline and open like any
other file path, with a `:10:2`, `(10,2)` or `#L10` location kept. The path
is found when Chinese prose is glued straight onto it
(`图片已保存到:c:/Users/me/a.png`) and inside a Markdown link
(`![chart](c:/out/chart.png)`), and is handed on with backslashes so
Explorer opens and reveals it — a forward-slashed path used to open
Documents instead. In a WSL pane a drive path is looked up under
`/mnt/<drive>`. A letter and a colon alone (`a:b`, `C:`, `C:notes.txt`) is
never read as a drive.
### Changed
- **The sidebar groups tabs by repo automatically; pin what you want to keep**
+428 -21
View File
@@ -794,35 +794,128 @@ impl PathStyle {
// 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('\\' | '/'))
}
PathStyle::Windows => path.starts_with("\\\\") || drive_root(path).is_some(),
}
}
/// The one path a token that names its own root stands for, spelled the
/// way the pane's host can look it up — `None` for a token that has to be
/// measured from somewhere.
///
/// A Windows pane gets its path back with every separator a `\` and the
/// drive letter upper-cased. `c:/Users/me/a.png` is what Node prints on
/// Windows, and while the filesystem takes either slash, Explorer does
/// not: handed a forward-slashed path to open or `/select,` it shows
/// Documents and reports success.
///
/// A POSIX pane has no drives, but it may well be a WSL distro looking at
/// the Windows machine it runs on — a Windows tool run through interop, a
/// `wslpath -w`, a build log from the other side. There `C:\Users` is
/// `/mnt/c/Users`, and that is what the host is asked about. Anywhere else
/// the mount is not there and the probe simply misses, so nothing
/// underlines; that beats measuring `C:\Users` from the pane's directory,
/// which could only ever be nonsense.
pub fn rooted(self, path: &str) -> Option<String> {
match self {
PathStyle::Posix if path.starts_with('/') => Some(path.to_string()),
PathStyle::Posix => {
let (drive, rest) = drive_root(path)?;
let rest = rest.replace('\\', "/");
Some(format!(
"/mnt/{}/{}",
drive.to_ascii_lowercase(),
rest.trim_start_matches('/')
))
}
PathStyle::Windows => self.is_absolute(path).then(|| windows_spelling(path)),
}
}
/// Whether a token only means something against the current directory of
/// one particular drive: `C:notes.txt`, or `C:` on its own.
///
/// There is no such thing as "the current directory of drive C" for a
/// pane — only for a process — so the one answer that would exist is tty7's
/// own, which has nothing to do with what the pane printed. And a bare
/// letter and a colon is far more often prose than a path: `a:b`, `x:y`.
fn is_drive_relative(self, path: &str) -> bool {
let mut chars = path.chars();
self == PathStyle::Windows
&& 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
/// Both arms join 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.
/// probe cannot stat, and on a Mac it turns `C:\proj` and `src\lib.rs`
/// into `C:\proj/src\lib.rs` for a Windows host.
///
/// A Windows `rel` that starts with a separator is rooted on the root's
/// own drive (or share), the way `cd \etc` is in a `cmd.exe` sitting on C:.
pub fn join(self, root: &Path, rel: &str) -> PathBuf {
let root = root.to_string_lossy();
match self {
PathStyle::Posix => PathBuf::from(format!(
"{}/{rel}",
root.to_string_lossy().trim_end_matches('/')
)),
PathStyle::Windows => root.join(rel),
PathStyle::Posix => PathBuf::from(format!("{}/{rel}", root.trim_end_matches('/'))),
PathStyle::Windows => {
let joined = match rel.starts_with(['\\', '/']) {
true => format!("{}{rel}", windows_prefix(&root)),
false => format!("{}\\{rel}", root.trim_end_matches(['\\', '/'])),
};
PathBuf::from(windows_spelling(&joined))
}
}
}
}
/// The drive letter a path starts with, and everything after the colon — only
/// when a separator follows it, which is what makes it absolute. `C:\x` and
/// `c:/x` are; `C:x`, `C:` and `12:30` are not.
fn drive_root(path: &str) -> Option<(char, &str)> {
let mut chars = path.chars();
let drive = chars.next().filter(char::is_ascii_alphabetic)?;
let rest = path.strip_prefix(drive)?.strip_prefix(':')?;
rest.starts_with(['\\', '/']).then_some((drive, rest))
}
/// The part of a Windows path that names its drive or share: `C:` out of
/// `C:\proj`, `\\server\share` out of `\\server\share\proj`. Empty when there
/// is none to take.
fn windows_prefix(path: &str) -> &str {
if drive_root(path).is_some() || path.len() == 2 && path.ends_with(':') {
return &path[..2];
}
if path.starts_with("\\\\") || path.starts_with("//") {
// Past the two leading separators, the server, and the share.
let mut seps = path
.char_indices()
.skip(2)
.filter(|&(_, c)| matches!(c, '\\' | '/'));
seps.next();
return match seps.next() {
Some((end, _)) => &path[..end],
None => path,
};
}
""
}
/// A Windows path the way Windows itself writes one: `\` throughout and an
/// upper-case drive letter. Every Windows API takes `/` too, but Explorer —
/// the system opener and the "Reveal" menu item — does not.
fn windows_spelling(path: &str) -> String {
let mut out = path.replace('/', "\\");
if let Some((drive, _)) = drive_root(&out)
&& drive.is_ascii_lowercase()
{
out.replace_range(..1, &drive.to_ascii_uppercase().to_string());
}
out
}
/// 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
@@ -955,11 +1048,11 @@ impl FileCandidate {
let Some(expanded) = expand_home(&self.path, roots) else {
return Vec::new();
};
if expanded.is_empty() {
if expanded.is_empty() || roots.style.is_drive_relative(&expanded) {
return Vec::new();
}
if roots.style.is_absolute(&expanded) {
return vec![PathBuf::from(expanded)];
if let Some(rooted) = roots.style.rooted(&expanded) {
return vec![PathBuf::from(rooted)];
}
let mut out: Vec<PathBuf> = Vec::new();
for root in &roots.dirs {
@@ -981,7 +1074,7 @@ impl FileCandidate {
/// `/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)
self.path.starts_with('~') || style.rooted(&self.path).is_some()
}
/// Whether the token is written enough like a path to be worth telling the
@@ -990,11 +1083,12 @@ impl FileCandidate {
///
/// 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.
/// there is a word, not a path. A drive letter is one in any pane.
pub fn looks_like_a_path(&self, style: PathStyle) -> bool {
self.path.starts_with('~')
|| self.path.contains('/')
|| (style == PathStyle::Windows && self.path.contains('\\'))
|| drive_root(&self.path).is_some()
}
}
@@ -1101,6 +1195,24 @@ fn left_cuts(chars: &[char]) -> Vec<usize> {
if matches!(rest.first(), Some('a' | 'b')) && rest.get(1) == Some(&'/') {
offsets.push(2);
}
// A Windows drive glued onto whatever came before it: `保存到:C:\out`,
// `图片已生成c:/Users/me/a.png`. CJK prose puts no space in front of a
// path, and the label rule above cannot see these — the only ASCII
// colon in the token is the drive's own. The letter must not be the
// tail of an ASCII word, or `abc:/x` would read as drive `c:`.
if let Some(i) = (1..rest.len()).find(|&i| {
!rest[i - 1].is_ascii_alphanumeric()
&& rest[i].is_ascii_alphabetic()
&& rest.get(i + 1) == Some(&':')
&& matches!(rest.get(i + 2), Some('\\' | '/'))
}) {
offsets.push(i);
}
// A Markdown link's target, which is what an agent prints a file as:
// `![chart](c:/out/chart.png)`, `[notes](docs/notes.md)`.
if let Some(i) = rest.windows(2).position(|w| w == [']', '(']) {
offsets.push(i + 2);
}
for offset in offsets {
let cut = base + offset;
if cut < chars.len() && !cuts.contains(&cut) {
@@ -2547,4 +2659,299 @@ mod tests {
"the reading behind the `note:` label is still on the ladder"
);
}
/// A Windows pane sitting in `C:\proj`, the way the reporter of #965 saw it.
fn windows_roots() -> LinkRoots {
LinkRoots {
dirs: vec![PathBuf::from(r"C:\proj")],
local_home: false,
style: PathStyle::Windows,
}
}
/// `link_at` over `line` with the pointer on the first char of `needle`,
/// against a filesystem holding exactly `exists` — and every path it was
/// asked about, so a test can say what was *not* looked up.
fn link_with(
line: &str,
needle: &str,
roots: &LinkRoots,
exists: &str,
) -> (Option<LinkMatch>, Vec<String>) {
let byte = line.find(needle).expect("needle in line");
let col = line[..byte].chars().count();
let mut asked = Vec::new();
let mut probe = |path: &Path, _: bool| {
let path = path.to_string_lossy().into_owned();
let hit = path == exists;
asked.push(path);
match hit {
true => Probe::Hit { is_dir: false },
false => Probe::Miss,
}
};
(link_at(line, col, roots, true, &mut probe), asked)
}
/// The path and span a Windows pane underlines under `needle`.
fn windows_link(line: &str, needle: &str, exists: &str) -> (String, usize, usize) {
let (link, asked) = link_with(line, needle, &windows_roots(), exists);
let link = link.unwrap_or_else(|| panic!("{line:?}: no link, asked {asked:?}"));
match link.target {
LinkTarget::File { path, .. } => {
(path.to_string_lossy().into_owned(), link.start, link.end)
}
LinkTarget::Url(url) => panic!("{line:?}: expected a file link, got {url}"),
}
}
/// The columns `needle` occupies in `line`, inclusive.
fn span_of(line: &str, needle: &str) -> (usize, usize) {
let byte = line.find(needle).expect("needle in line");
let start = line[..byte].chars().count();
(start, start + needle.chars().count() - 1)
}
/// #965: the path exactly as the reporter pasted it, and its backslashed
/// twin. Either separator names the same file, and the file is handed on
/// spelled the way Explorer will take it.
#[test]
fn a_drive_letter_path_is_a_link_with_either_separator() {
let want = r"C:\Users\xxxxx\images\gen_gptimage2_gen_20260925_220725.png";
for written in [
"c:/Users/xxxxx/images/gen_gptimage2_gen_20260925_220725.png",
r"C:\Users\xxxxx\images\gen_gptimage2_gen_20260925_220725.png",
r"C:/Users\xxxxx/images\gen_gptimage2_gen_20260925_220725.png",
] {
let line = format!("saved {written} ok");
let (path, start, end) = windows_link(&line, "images", want);
assert_eq!(path, want, "{line:?}");
assert_eq!((start, end), span_of(&line, written), "{line:?}");
}
}
/// Prose and markup around a Windows path is not part of it — the same
/// trimming a POSIX path gets.
#[test]
fn a_drive_letter_path_sheds_the_punctuation_around_it() {
let want = r"C:\out\a.png";
for (line, path) in [
("wrote c:/out/a.png.", "c:/out/a.png"),
(r"wrote C:\out\a.png, then quit", r"C:\out\a.png"),
(r"(see C:\out\a.png)", r"C:\out\a.png"),
(r#"open "C:\out\a.png";"#, r"C:\out\a.png"),
(r"`C:\out\a.png`", r"C:\out\a.png"),
("「c:/out/a.png」。", "c:/out/a.png"),
("<c:/out/a.png>", "c:/out/a.png"),
("│ C:/out/a.png │", "C:/out/a.png"),
(r"C:\out\a.png:", r"C:\out\a.png"),
] {
let (got, start, end) = windows_link(line, "out", want);
assert_eq!(got, want, "{line:?}");
assert_eq!((start, end), span_of(line, path), "{line:?}");
}
}
/// Compilers on Windows write the location onto the path; the drive's own
/// colon must not be mistaken for one.
#[test]
fn a_drive_letter_path_keeps_its_line_and_column() {
let want = r"C:\src\main.rs";
for (line, loc) in [
(r"C:\src\main.rs:10:2: error", (Some(10), Some(2))),
("c:/src/main.rs:10", (Some(10), None)),
(r"C:\src\main.rs(10,2): error C2065", (Some(10), Some(2))),
("c:/src/main.rs#L10", (Some(10), None)),
(r#"File "C:\src\main.rs", line 7"#, (Some(7), None)),
] {
let (link, asked) = link_with(line, "src", &windows_roots(), want);
match link.map(|l| l.target) {
Some(LinkTarget::File {
path,
line: l,
column,
..
}) => {
assert_eq!(path, PathBuf::from(want), "{line:?}");
assert_eq!((l, column), loc, "{line:?}");
}
other => panic!("{line:?}: {other:?}, asked {asked:?}"),
}
}
}
/// Chinese output glues a path straight onto the sentence in front of it,
/// often behind a full-width colon — which is how an agent reports the
/// file it just wrote in #965. The sentence is not part of the path.
#[test]
fn a_drive_letter_path_is_found_inside_the_prose_glued_onto_it() {
let want = r"C:\Users\me\a.png";
for (line, path) in [
("图片已保存到:c:/Users/me/a.png", "c:/Users/me/a.png"),
("图片已生成c:/Users/me/a.png", "c:/Users/me/a.png"),
(r"路径:C:\Users\me\a.png", r"C:\Users\me\a.png"),
("--out=c:/Users/me/a.png", "c:/Users/me/a.png"),
("output:c:/Users/me/a.png", "c:/Users/me/a.png"),
] {
let (got, start, end) = windows_link(line, "Users", want);
assert_eq!(got, want, "{line:?}");
assert_eq!((start, end), span_of(line, path), "{line:?}");
}
}
/// An agent prints the image it made as Markdown. The target is the link;
/// the brackets and alt text are not.
#[test]
fn a_markdown_link_target_is_the_path() {
let want = r"C:\out\chart.png";
for line in [
"![chart](c:/out/chart.png)",
"see [the chart](c:/out/chart.png).",
] {
let (got, start, end) = windows_link(line, "out", want);
assert_eq!(got, want, "{line:?}");
assert_eq!((start, end), span_of(line, "c:/out/chart.png"), "{line:?}");
}
let posix = LinkRoots {
dirs: vec![PathBuf::from("/home/u/proj")],
local_home: false,
style: PathStyle::Posix,
};
let line = "[notes](docs/notes.md)";
let (link, asked) = link_with(line, "docs", &posix, "/home/u/proj/docs/notes.md");
let link = link.unwrap_or_else(|| panic!("no link, asked {asked:?}"));
assert_eq!((link.start, link.end), span_of(line, "docs/notes.md"));
}
#[test]
fn a_unc_share_is_a_link_that_stands_alone() {
let want = r"\\server\share\team\a.png";
let line = r"copied to \\server\share\team\a.png";
let (got, start, end) = windows_link(line, "server", want);
assert_eq!(got, want);
assert_eq!((start, end), span_of(line, want));
}
/// Text that merely has a letter and a colon in it is not a drive path,
/// and must never be looked up as one.
#[test]
fn a_letter_and_a_colon_is_not_a_drive_path() {
for (line, needle) in [
// Drive-relative, or a bare drive: nothing a pane can resolve.
("a:b", "a:b"),
("x:y and more", "x:y"),
("C:notes.txt", "C:notes"),
("see C: now", "C:"),
// A time, a ratio, a port.
("at 12:30 today", "12:30"),
("ratio 16:9", "16:9"),
("localhost:8080", "localhost"),
// A word that ends in a letter is not a drive behind it.
("abc:/x/y", "abc"),
("http:/x", "http"),
] {
let (link, asked) = link_with(line, needle, &windows_roots(), "");
assert_eq!(link, None, "{line:?}");
for path in &asked {
assert!(
path.starts_with(r"C:\proj\"),
"{line:?} asked about {path:?}, which is not under the pane's directory"
);
}
}
// The drive-relative ones are not asked about at all.
for line in ["a:b", "C:notes.txt"] {
let (_, asked) = link_with(line, line, &windows_roots(), "");
assert!(asked.is_empty(), "{line:?} asked about {asked:?}");
}
// And nothing in `abc:/x/y` is read as drive `c:`.
assert!(
file_candidates_at("abc:/x/y", 0)
.iter()
.all(|c| !c.path.starts_with("c:")),
"{:?}",
file_candidates_at("abc:/x/y", 0)
);
}
/// A Windows path that does not exist is not underlined: the probe is
/// the gate, exactly as for a POSIX one.
#[test]
fn a_missing_drive_letter_path_is_not_a_link() {
let (link, asked) = link_with("wrote c:/out/gone.png", "out", &windows_roots(), "");
assert_eq!(link, None);
assert_eq!(asked, vec![r"C:\out\gone.png".to_string()]);
let candidate =
unresolved_candidate("wrote c:/out/gone.png", 8, PathStyle::Windows).unwrap();
assert!(candidate.looks_like_a_path(PathStyle::Windows));
assert!(candidate.is_rooted(PathStyle::Windows));
}
/// A POSIX pane has no drives, but a WSL distro can reach the Windows
/// machine it runs on through `/mnt/<drive>`, so that is what it is asked.
#[test]
fn a_posix_pane_reads_a_drive_letter_through_the_wsl_mount() {
let roots = LinkRoots {
dirs: vec![PathBuf::from("/home/u/proj")],
local_home: false,
style: PathStyle::Posix,
};
let want = "/mnt/c/Users/me/a.png";
for written in ["c:/Users/me/a.png", r"C:\Users\me\a.png"] {
let line = format!("wrote {written}");
let (link, asked) = link_with(&line, "Users", &roots, want);
assert_eq!(asked.first().map(String::as_str), Some(want), "{line:?}");
let link = link.unwrap_or_else(|| panic!("{line:?}: no link"));
assert_eq!((link.start, link.end), span_of(&line, written));
let candidate = file_candidate_at(&line, 8).unwrap();
assert!(candidate.is_rooted(PathStyle::Posix), "{line:?}");
assert!(candidate.looks_like_a_path(PathStyle::Posix), "{line:?}");
}
// Where there is no such mount the probe misses and nothing underlines.
let (link, _) = link_with("wrote c:/Users/me/a.png", "Users", &roots, "");
assert_eq!(link, None);
}
#[test]
fn a_rooted_token_is_spelled_the_way_its_host_looks_it_up() {
let w = PathStyle::Windows;
assert_eq!(w.rooted("c:/Users/me").as_deref(), Some(r"C:\Users\me"));
assert_eq!(w.rooted(r"D:\x/y").as_deref(), Some(r"D:\x\y"));
assert_eq!(
w.rooted(r"\\server\share\x").as_deref(),
Some(r"\\server\share\x")
);
for relative in ["src/lib.rs", r"src\lib.rs", "C:x", "C:", "/etc/hosts", ""] {
assert_eq!(w.rooted(relative), None, "{relative:?}");
}
let p = PathStyle::Posix;
assert_eq!(p.rooted("/etc/hosts").as_deref(), Some("/etc/hosts"));
assert_eq!(p.rooted(r"C:\Users\me").as_deref(), Some("/mnt/c/Users/me"));
assert_eq!(p.rooted("d:/").as_deref(), Some("/mnt/d/"));
for relative in ["src/lib.rs", "C:x", r"\\server\share", "12:30"] {
assert_eq!(p.rooted(relative), None, "{relative:?}");
}
}
/// The Windows join is textual so a Mac looking at a Windows host asks it
/// about a path it can stat, and so it can be pinned here at all.
#[test]
fn a_windows_join_spells_the_whole_path_the_windows_way() {
let w = PathStyle::Windows;
let join =
|root: &str, rel: &str| w.join(Path::new(root), rel).to_string_lossy().into_owned();
assert_eq!(join(r"C:\proj", "src/lib.rs"), r"C:\proj\src\lib.rs");
assert_eq!(join(r"C:\proj\", r"src\lib.rs"), r"C:\proj\src\lib.rs");
assert_eq!(join("c:/proj", "a.txt"), r"C:\proj\a.txt");
assert_eq!(join(r"C:\", "a.txt"), r"C:\a.txt");
// A leading separator is rooted on the root's own drive or share.
assert_eq!(join(r"C:\proj", "/etc/hosts"), r"C:\etc\hosts");
assert_eq!(join(r"D:\proj", r"\tmp\x"), r"D:\tmp\x");
assert_eq!(join(r"\\srv\share\proj", r"\x"), r"\\srv\share\x");
assert_eq!(join(r"\\srv\share\proj", "x"), r"\\srv\share\proj\x");
assert_eq!(windows_prefix(r"\\srv\share"), r"\\srv\share");
assert_eq!(windows_prefix("C:"), "C:");
assert_eq!(windows_prefix("proj"), "");
}
}