diff --git a/CHANGELOG.md b/CHANGELOG.md index 426b3eed..9d8ac1ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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/`. 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** diff --git a/src/terminal/search.rs b/src/terminal/search.rs index 26e1f1ee..07e42e45 100644 --- a/src/terminal/search.rs +++ b/src/terminal/search.rs @@ -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 { + 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 = 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 { 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, Vec) { + 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"), + (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/`, 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"), ""); + } }