From 87188cc035ed8eea30d2f27a0d52367bd351cd3b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:39:17 +0800 Subject: [PATCH] feat(links): let Cmd+click open directories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #49: iTerm2-style semantic paths — an existing directory in the row text links like a file does, and the system opener (open / xdg-open / explorer) already handles directories natively, so detection is the only change. Bare paths only: a token carrying a :line suffix still requires a file, so localhost:8080 can't link just because a directory named localhost exists in the cwd. --- src/terminal/search.rs | 54 ++++++++++++++++++++++++++++++++++++++---- src/terminal/view.rs | 4 ++-- 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/terminal/search.rs b/src/terminal/search.rs index 3c3c58ea..e02e477d 100644 --- a/src/terminal/search.rs +++ b/src/terminal/search.rs @@ -23,6 +23,8 @@ const MAX_MATCHES: usize = 10_000; #[derive(Clone, Debug, PartialEq, Eq)] pub(super) enum LinkTarget { Url(String), + /// An existing local file — or directory (`line`/`column` then `None`; + /// dirs never match a `path:line` form). File { path: PathBuf, line: Option, @@ -335,7 +337,7 @@ pub(super) fn url_at(text: &str, col: usize) -> Option { } /// Detect a link spanning column `col` within a line's text: a bare URL -/// always (see [`url_span_at`]), plus an existing file path when +/// always (see [`url_span_at`]), plus an existing file or directory path when /// `include_files` — URL detection wins when both would match. `cwd` anchors /// relative paths and `~` expansion. pub(super) fn link_at( @@ -455,7 +457,10 @@ fn file_span_at(text: &str, col: usize, cwd: Option<&Path>) -> Option location = split_file_location(&token); } - let path = resolve_existing_file(&location.path, cwd)?; + // A `:line` suffix only makes sense for a file — without requiring one, + // `localhost:8080` would link whenever a directory named `localhost` + // happens to exist in the cwd. + let path = resolve_existing_path(&location.path, cwd, location.line.is_some())?; (start..=end).contains(&col).then_some(LinkMatch { start, end, @@ -565,7 +570,7 @@ fn strip_numeric_suffix(token: &str) -> Option<(&str, u32)> { Some((prefix, value)) } -fn resolve_existing_file(path: &str, cwd: Option<&Path>) -> Option { +fn resolve_existing_path(path: &str, cwd: Option<&Path>, require_file: bool) -> Option { if path.is_empty() { return None; } @@ -575,7 +580,8 @@ fn resolve_existing_file(path: &str, cwd: Option<&Path>) -> Option { } else { cwd?.join(path) }; - candidate.is_file().then_some(candidate) + let hit = candidate.is_file() || (!require_file && candidate.is_dir()); + hit.then_some(candidate) } fn expand_home(path: &str, cwd: Option<&Path>) -> Option { @@ -1050,4 +1056,44 @@ mod tests { } ); } + + #[test] + fn link_at_detects_directory_paths() { + let file = temp_file("dircase/nested/inner.txt"); + let dir = file.parent().unwrap(); + let cwd = dir.parent().and_then(Path::parent).unwrap(); + + let link = link_at("artifacts in dircase/nested here", 14, Some(cwd), true) + .expect("directory link"); + assert_eq!((link.start, link.end), (13, 26)); + match link.target { + LinkTarget::File { path, line, column } => { + assert_eq!(path, dir); + assert_eq!(line, None); + assert_eq!(column, None); + } + LinkTarget::Url(url) => panic!("expected directory link, got URL {url}"), + } + + // `ls -p` style trailing slash resolves too. + assert!(link_at("ls dircase/nested/ done", 5, Some(cwd), true).is_some()); + // Off without the modifier, like files. + assert!(link_at("artifacts in dircase/nested here", 14, Some(cwd), false).is_none()); + } + + #[test] + fn link_at_requires_a_file_when_a_line_suffix_is_present() { + // `localhost:8080` must not become a link just because a directory + // named `localhost` exists in the cwd — `:line` only makes sense for + // files. + let file = temp_file("localhost/keep.txt"); + let cwd = file.parent().and_then(Path::parent).unwrap(); + + assert_eq!( + link_at("listening on localhost:8080", 15, Some(cwd), true), + None + ); + // The bare directory still links. + assert!(link_at("listening on localhost", 15, Some(cwd), true).is_some()); + } } diff --git a/src/terminal/view.rs b/src/terminal/view.rs index 518d2695..1519191c 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -2871,7 +2871,7 @@ impl TerminalView { } /// Open the link under the given cell, if any (OSC 8 hyperlink, plain URL or - /// existing file path detected in the row text). Returns true if one opened. + /// existing file or directory path detected in the row text). Returns true if one opened. pub fn open_link_at(&self, col: usize, row: usize, cx: &mut Context) -> bool { if !cx.global::().link_url { return false; @@ -2960,7 +2960,7 @@ impl TerminalView { /// Resolve the link span at screen cell `(col, row)`: an OSC 8 hyperlink (the /// contiguous run of cells sharing the same target), a bare URL token, or an - /// existing file path in the row text. Mirrors [`open_link_at`](Self::open_link_at)'s + /// existing file or directory path in the row text. Mirrors [`open_link_at`](Self::open_link_at)'s /// detection so the underline covers exactly what a Cmd+click would open. fn link_span_at(&self, col: usize, row: usize, include_files: bool) -> Option { let term = self.terminal.term.lock();