fix(links): five holes review found in the new path detection

The second column of a wide character is written as a space, and the
blank-cell shortcut read that as an empty cell. `logical_line_at` hands
a click there back to the character that owns it, so the underline was
going out on every other column of a path spelled in CJK or emoji. Read
a spacer as part of the glyph it belongs to.

Handing a file the built-in editor cannot read to the desktop is how a
click opens a PNG. On macOS it is also how a click *runs* a program:
`open` on a Mach-O binary launches it, and a build's output is full of
paths to programs. A file the execute bit is set on keeps the words it
had before.

`explorer /select,<path>` went through `Command::arg`, which quotes the
whole argument the moment the path holds a space. Explorer answers a
quoted switch by opening Documents and reporting success, so "Show in
Folder" silently showed the wrong folder. Write that command line by
hand instead, with the switch bare and the path quoted behind it.

The right-click menu resolved a path with no regard for the switch that
decides whether a path underlines at all, so a pane with link detection
turned off still offered to open files.

Finally, the `label:` left cut peeled anything after a colon, so
`branch:main` was probed as `main` and resolved against any directory
of that name. Require what follows to be written like a path too.

Claude-Session: https://claude.ai/code/session_01NE3M5Q94Jyxmj5Rdm9bcg4
This commit is contained in:
l0ng-ai
2026-09-09 16:16:21 +08:00
parent 0ab089c0b3
commit 262a166a8d
3 changed files with 192 additions and 7 deletions
+30 -1
View File
@@ -1087,13 +1087,19 @@ fn left_cuts(chars: &[char]) -> Vec<usize> {
}
// `note:src/main.rs`. Only a plain word may sit in front: one letter
// is a Windows drive (`C:\src`), and a prefix carrying a `/` or a `.`
// is more likely a path with a line number written onto it.
// is more likely a path with a line number written onto it. What
// follows has to be spelled like a path too, or `branch:main` and
// `remote:origin` become links the moment the pane's directory holds
// a `main/` or an `origin/`.
if let Some(i) = rest.iter().position(|&c| c == ':')
&& i >= 2
&& rest[0].is_ascii_alphabetic()
&& rest[..i]
.iter()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+'))
&& rest[i + 1..]
.iter()
.any(|c| matches!(c, '/' | '\\' | '.' | '~'))
{
offsets.push(i + 1);
}
@@ -2505,6 +2511,29 @@ mod tests {
}
}
/// A label in front of a colon only peels off when what follows is
/// written like a path. Otherwise a prompt segment turns into a link the
/// moment the directory happens to hold a folder by that name.
#[test]
fn a_label_in_front_of_a_bare_word_is_not_a_path() {
let file = temp_file("labelled/main/keep.txt");
// `<tmp>/labelled`, the one directory that holds a `main/`.
let labelled = file.parent().and_then(Path::parent).unwrap();
assert!(
local_link_at("on main/keep.txt", 4, &one_root(labelled), true).is_some(),
"the directory itself is still reachable"
);
assert!(
local_link_at("branch:main", 8, &one_root(labelled), true).is_none(),
"but the branch a prompt is reporting is not a link"
);
assert!(
local_link_at("note:main/keep.txt", 6, &one_root(labelled), true).is_some(),
"a label in front of something written like a path still peels"
);
}
/// Every reading of one token is worth at most a handful of questions.
#[test]
fn the_candidate_ladder_stays_short() {
+90 -5
View File
@@ -5632,6 +5632,13 @@ impl TerminalView {
/// pointer, and asking the grid then would be asking about wherever the
/// mouse has since gone.
pub fn record_menu_link(&mut self, col: usize, row: usize, cx: &mut Context<Self>) {
// The same switch that decides whether a path underlines and whether a
// click follows one. Without this the menu would go on offering to
// open files in a pane where link detection is turned off.
if !cx.global::<Config>().link_url {
self.menu_link = None;
return;
}
let include_loopback = self.can_forward_loopback(cx);
self.menu_link = match self.resolve_link_at(col, row, true, include_loopback, cx) {
LinkAt::Found(target @ LinkTarget::File { .. }, ..) => Some(target),
@@ -5907,11 +5914,26 @@ impl TerminalView {
/// Whether the cell under the pointer holds anything a link could be made
/// of.
fn cell_is_blank(&self, col: usize, row: usize) -> bool {
use alacritty_terminal::term::cell::Flags;
let term = self.terminal.term.lock();
let Some(line) = Self::grid_line(&term, row) else {
return true;
};
col >= term.columns() || term.grid()[line][Column(col)].c.is_whitespace()
if col >= term.columns() {
return true;
}
let cell = &term.grid()[line][Column(col)];
// The second column of a wide glyph is written as a space, and the
// logical line hands a click there back to the character that owns it.
// Reading it as empty would drop the underline on every other column
// of a path spelled in CJK or emoji.
if cell.flags.intersects(
Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER | Flags::WIDE_CHAR,
) {
return false;
}
cell.c.is_whitespace()
}
pub fn refresh_link_hover(&mut self, armed: bool, cx: &mut Context<Self>) -> bool {
@@ -7289,15 +7311,25 @@ pub(crate) fn open_file_path(path: &std::path::Path) -> std::io::Result<()> {
/// no desktop-neutral Linux equivalent exists, so there the folder is opened
/// and the file is left for the eye to find.
pub(crate) fn reveal_file_path(path: &std::path::Path) -> std::io::Result<()> {
let mut command = if cfg!(target_os = "macos") {
#[cfg(target_os = "macos")]
let mut command = {
let mut c = std::process::Command::new("open");
c.arg("-R").arg(path);
c
} else if cfg!(windows) {
};
// Explorer wants `/select,` bare and the path quoted behind it. `arg`
// quotes the whole thing the moment the path holds a space, and Explorer
// answers a quoted switch by opening Documents and reporting success —
// so the command line is written out by hand.
#[cfg(windows)]
let mut command = {
use std::os::windows::process::CommandExt;
let mut c = std::process::Command::new("explorer");
c.arg(format!("/select,{}", path.display()));
c.raw_arg(format!("/select,\"{}\"", path.display()));
c
} else {
};
#[cfg(not(any(target_os = "macos", windows)))]
let mut command = {
let mut c = std::process::Command::new("xdg-open");
c.arg(path.parent().unwrap_or(path));
c
@@ -10286,6 +10318,17 @@ mod gpui_tests {
"and a right click over `ready` opens the ordinary one"
);
let mut off = cx.global::<Config>().clone();
off.link_url = false;
cx.set_global(off);
view.record_menu_link(7, 0, cx);
assert!(
view.menu_link_path().is_none(),
"and with link detection turned off the menu offers nothing \
the underline and the click both refuse"
);
cx.set_global(Config::default());
// `ready (scratchpad...`: the blank between the two words.
assert!(!view.hover_link_at(5, 0, false, cx));
assert!(view.hovered_link.is_none(), "a blank holds no link");
@@ -10606,6 +10649,48 @@ mod gpui_tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// A wide character owns two columns, and the second one holds a space.
/// The hover has to read that as part of the glyph, or the underline goes
/// out on every other column of a path written in CJK.
#[gpui::test]
fn the_second_column_of_a_wide_character_still_hovers(cx: &mut TestAppContext) {
let dir = std::env::temp_dir().join(format!("tty7-view-wide-{}", std::process::id()));
std::fs::create_dir_all(dir.join("文档")).expect("create dirs");
std::fs::write(dir.join("文档/笔记.md"), b"# notes").expect("create notes");
let (window, mut daemon) = harness(cx);
DaemonMsg::Cwd(dir.clone()).encode(&mut daemon).unwrap();
DaemonMsg::Output("see 文档/笔记.md here\r\n".as_bytes().to_vec())
.encode(&mut daemon)
.unwrap();
for _ in 0..200 {
let seen = window
.update(cx, |view, _, _| {
view.cwd().is_some()
&& view.terminal.term.lock().grid()[Line(0)][Column(4)].c == '文'
})
.unwrap();
if seen {
break;
}
std::thread::sleep(std::time::Duration::from_millis(5));
}
window
.update(cx, |view, _, cx| {
// `see 文档/…`: column 4 carries 文, column 5 is its spacer.
assert!(!view.cell_is_blank(5, 0), "the spacer belongs to the glyph");
for col in [4, 5] {
assert!(
view.hover_link_at(col, 0, true, cx),
"column {col} of the same character is the same link"
);
}
})
.unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
/// A full-screen application drew the grid and is watching the mouse
/// itself, so tty7 stays out of it until asked.
#[gpui::test]
+72 -1
View File
@@ -244,6 +244,47 @@ fn looks_binary(bytes: &[u8]) -> bool {
bytes.iter().take(8192).any(|b| *b == 0)
}
/// Whether handing this path to the desktop would run it rather than show it.
///
/// The execute bit is what `open` reads to decide between displaying a file
/// and launching it; Windows has no such bit, so there the extension is the
/// only thing that says so.
fn is_program(path: &Path) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
return std::fs::metadata(path)
.is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0);
}
#[cfg(not(unix))]
{
let Some(ext) = path.extension().and_then(|e| e.to_str()) else {
return false;
};
matches!(
ext.to_ascii_lowercase().as_str(),
"exe"
| "com"
| "bat"
| "cmd"
| "scr"
| "pif"
| "msi"
| "ps1"
| "vbs"
| "js"
| "jse"
| "wsf"
| "wsh"
| "cpl"
| "msc"
| "hta"
| "reg"
| "lnk"
)
}
}
#[derive(Debug, PartialEq, Eq)]
enum ExternalChange {
Ignore,
@@ -590,6 +631,10 @@ impl Tty7App {
/// A click on a PNG or a `.zip` meant "open this", not "tell me it is not
/// text", and on this machine the desktop knows how. A file on another
/// machine has nobody here to hand it to, so that one gets the words.
///
/// A program does not: `open` on a Mach-O binary runs it, and a build's
/// output is full of paths to programs. Clicking a word in a terminal
/// must not be a way to execute one, so those keep the words too.
fn open_outside_the_editor(
&mut self,
host_id: HostId,
@@ -597,7 +642,7 @@ impl Tty7App {
window: &mut Window,
cx: &mut Context<Self>,
) {
if !host_id.is_local() || !self.can_spawn_locally(cx) {
if !host_id.is_local() || !self.can_spawn_locally(cx) || is_program(path) {
window.push_notification(
t_fmt(
L10nKey::EditorBinaryFile,
@@ -1485,6 +1530,32 @@ impl Tty7App {
mod tests {
use super::*;
/// Handing a file the editor cannot read to the desktop is how a click
/// opens a PNG. It must not be how a click runs a build's output.
#[cfg(unix)]
#[test]
fn a_file_the_desktop_would_run_is_not_handed_to_it() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("tty7-program-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("create dir");
let image = dir.join("shot.png");
let program = dir.join("built");
std::fs::write(&image, b"\x89PNG\0\0").expect("write image");
std::fs::write(&program, b"\x7fELF\0\0").expect("write program");
std::fs::set_permissions(&program, std::fs::Permissions::from_mode(0o755))
.expect("mark executable");
assert!(!is_program(&image), "a picture is only ever shown");
assert!(is_program(&program), "a binary would be launched");
assert!(
!is_program(&dir),
"a directory is not a program, whatever its mode says"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn language_map_covers_common_extensions() {
for (path, lang) in [