feat(links): let Cmd+click open directories

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.
This commit is contained in:
l0ng-ai
2026-07-11 14:39:17 +08:00
parent 63a05b256e
commit 87188cc035
2 changed files with 52 additions and 6 deletions
+50 -4
View File
@@ -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<u32>,
@@ -335,7 +337,7 @@ pub(super) fn url_at(text: &str, col: usize) -> Option<String> {
}
/// 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<LinkMatch>
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<PathBuf> {
fn resolve_existing_path(path: &str, cwd: Option<&Path>, require_file: bool) -> Option<PathBuf> {
if path.is_empty() {
return None;
}
@@ -575,7 +580,8 @@ fn resolve_existing_file(path: &str, cwd: Option<&Path>) -> Option<PathBuf> {
} 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<PathBuf> {
@@ -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());
}
}
+2 -2
View File
@@ -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<Self>) -> bool {
if !cx.global::<Config>().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<HoveredLink> {
let term = self.terminal.term.lock();