diff --git a/src/terminal/search.rs b/src/terminal/search.rs index 1e14db52..b887daa0 100644 --- a/src/terminal/search.rs +++ b/src/terminal/search.rs @@ -1087,13 +1087,19 @@ fn left_cuts(chars: &[char]) -> Vec { } // `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"); + // `/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() { diff --git a/src/terminal/view.rs b/src/terminal/view.rs index a4525d89..c29d0e59 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -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) { + // 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::().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) -> 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::().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] diff --git a/src/ui/code_editor.rs b/src/ui/code_editor.rs index 9822c57c..690a4001 100644 --- a/src/ui/code_editor.rs +++ b/src/ui/code_editor.rs @@ -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, ) { - 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 [