From 4acea2bbe76d9fb0119f921bf4fee748a648d316 Mon Sep 17 00:00:00 2001 From: whackur Date: Tue, 8 Sep 2026 23:32:59 +0900 Subject: [PATCH 1/3] fix(terminal): open links from attached panes --- Cargo.toml | 2 +- docs/architecture/terminal.md | 5 +- docs/keybindings.md | 2 + src/application/event_loop.rs | 8 + src/application/input/dispatch.rs | 6 + src/application/input/mouse.rs | 14 + src/application/tests/mod.rs | 1 + src/application/tests/mouse_links.rs | 158 +++++++++++ src/backend/pty_tests.rs | 2 + src/backend/pty_tests/links.rs | 164 +++++++++++ src/platform/link_target.rs | 300 ++++++++++++++++++++ src/platform/links.rs | 204 +++++++++++++ src/platform/links_tests.rs | 101 +++++++ src/platform/mod.rs | 2 + src/runtime/emulator/links.rs | 246 ++++++++++++++++ src/runtime/emulator/links_paths.rs | 84 ++++++ src/runtime/emulator/links_tests.rs | 122 ++++++++ src/runtime/emulator/mod.rs | 6 + src/runtime/emulator/snapshot.rs | 78 ++++- src/runtime/emulator/snapshot_link_tests.rs | 79 ++++++ src/runtime/emulator/view.rs | 15 + 21 files changed, 1588 insertions(+), 11 deletions(-) create mode 100644 src/application/tests/mouse_links.rs create mode 100644 src/backend/pty_tests/links.rs create mode 100644 src/platform/link_target.rs create mode 100644 src/platform/links.rs create mode 100644 src/platform/links_tests.rs create mode 100644 src/runtime/emulator/links.rs create mode 100644 src/runtime/emulator/links_paths.rs create mode 100644 src/runtime/emulator/links_tests.rs create mode 100644 src/runtime/emulator/snapshot_link_tests.rs diff --git a/Cargo.toml b/Cargo.toml index 6fc336a1..4b7b2c2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,4 +87,4 @@ ctrlc = "3" # adding it directly here so the feature gate is explicit. # `SetConsoleCtrlHandler` for `Win32_System_Console`: clearing the inherited # ignore-Ctrl-C flag so panes get an interrupt, not just the 0x03 byte. -windows-sys = { version = "0.61", features = ["Win32_System_Time", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_System_Console"] } +windows-sys = { version = "0.61", features = ["Win32_System_Time", "Win32_Storage_FileSystem", "Win32_Foundation", "Win32_System_Console", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] } diff --git a/docs/architecture/terminal.md b/docs/architecture/terminal.md index 57aa980c..febf5513 100644 --- a/docs/architecture/terminal.md +++ b/docs/architecture/terminal.md @@ -15,6 +15,9 @@ `runtime::emulator::PaneEmulator`가 pane마다 alacritty_terminal `Term`과 ANSI `Processor`를 감싼다. UI는 `ScreenView`/`CellView`만 보고, VT 구현 타입은 모듈 밖으로 새지 않는다. emulator는 최소 1행 × 2열로 clamp한다. - PTY byte는 client emulator에 적용한다. emulator가 OSC 0/2 title, DSR/DA query reply, terminal modes를 수집하고, title은 pane metadata로 세션에 전달한다. +- `ScreenView::link_at`은 viewport 좌표에서 OSC 8 셀 링크를 먼저 조회하고, 메타데이터가 없으면 현재 visible line의 bounded URL·Markdown·파일 위치를 찾는다. soft wrap과 scrollback을 포함하지만 파일을 열거나 검증하지 않고 platform seam에 target을 넘긴다. PTY 경로에서 OSC 8 메타데이터를 받지 못하는 경우에도 visible text fallback을 사용할 수 있다. +- 이미 제거된 label-only 링크는 재생할 URI가 없어 복구할 수 없다. +- screen snapshot은 OSC 8 run과 cursor template의 활성 링크를 재생하며, 초기 화면 지우기와 행 끝 erase 전에 입력 링크를 명시적으로 닫아 이전 stream 상태가 새 셀로 번지지 않게 한다. - hub는 재접속을 위해 mode와 screen snapshot을 별도로 보관한다. alternate screen은 현재 screen을, normal screen은 ring history와 snapshot 이후 tail을 조합해 replay한다. reconnect replay는 `screen` 뒤에 `since` byte를 붙여 snapshot 이후 broadcast를 잃지 않는다. - replay frame은 1 MiB 이하로 분할되고 daemon frame은 4 MiB를 넘지 않는다. terminal stream은 byte를 생략하거나 conflation하지 않으며, frame/queue 상한을 넘긴 연결은 명시적으로 종료한다. @@ -32,7 +35,7 @@ ## Mouse routing -`[mouse] enabled`가 켜져 있으면 crossterm이 화면을 캡처한다. `pane_at`은 렌더와 같은 `terminal_content_areas`를 사용한다. pane press는 focus와 active pane을 바꾸고, 프로그램이 mouse button mode + SGR encoding을 요청한 경우에만 pane-local SGR button report를 보낸다. release는 포인터 현재 위치가 아니라 press를 받은 pane에 짝지으며, pane이 닫히거나 숨겨졌으면 버린다. +`[mouse] enabled`가 켜져 있으면 crossterm이 화면을 캡처한다. `pane_at`은 렌더와 같은 `terminal_content_areas`를 사용한다. pane press는 focus와 active pane을 바꾸며, modifier 없는 왼쪽 press가 링크 target을 찾으면 SGR button report보다 먼저 링크 열기 명령으로 반환한다. 링크가 없으면 프로그램이 mouse button mode + SGR encoding을 요청한 경우에만 pane-local SGR button report를 보낸다. release는 포인터 현재 위치가 아니라 press를 받은 pane에 짝지으며, pane이 닫히거나 숨겨졌으면 버린다. wheel은 포인터 아래 pane을 대상으로 하며 sink 규칙은 keyboard scroll과 같다. tab bar와 hint bar의 클릭 대상은 렌더러가 만든 segment에서 파생하고, ` s` 대기 중 pane 클릭은 swap target으로 해석한다. motion/drag는 PTY로 전달하지 않는다. 외부 터미널의 text selection은 capture bypass modifier를 사용한다. diff --git a/docs/keybindings.md b/docs/keybindings.md index ed0fbf6e..efe8d3ba 100644 --- a/docs/keybindings.md +++ b/docs/keybindings.md @@ -91,3 +91,5 @@ The leader followed by `t`, `w`, `s`, `z`, `c`, `l`, `b`, `o`, `x`, `p`, or `u` Mouse capture is enabled by default. Click a project tab or panel to focus it; click a terminal pane to focus it and forward the report to programs that requested mouse input. The wheel scrolls the pane under the pointer. Clickable hint-bar commands behave like their key equivalents. While capture is enabled, hold the outer terminal's selection modifier while dragging to select text: `Shift` in xterm-family terminals, `Option` in iTerm2, and `Fn` or `Option` in macOS Terminal.app. Set `[mouse] enabled = false` to restore ordinary outer-terminal selection and disable click forwarding. + +An unmodified left click on a terminal hyperlink opens `http`/`https` links in the system browser. Local links open through a GUI editor (`Code.exe` or Notepad on Windows, `code`, `gedit`, `kate`, or `geany` on Unix, and TextEdit on macOS) and accept repository-relative paths, `file://` URLs, Windows drive paths, and `:line[:column]` or `#Lline` locations. Repository-relative files must exist inside the current worktree; network, device, traversal, `.git`, unsupported-scheme, and control-character targets are refused. Hold the outer terminal's selection modifier to bypass link handling. diff --git a/src/application/event_loop.rs b/src/application/event_loop.rs index 73af0834..1df9366e 100644 --- a/src/application/event_loop.rs +++ b/src/application/event_loop.rs @@ -239,6 +239,14 @@ pub(crate) fn apply_outcome( KeyOutcome::Quit => return Ok(true), KeyOutcome::Redraw => terminal.clear()?, KeyOutcome::Continue => {} + KeyOutcome::OpenLink { target, base } => { + if let Err(error) = crate::platform::links::open(&target, std::path::Path::new(&base)) { + ws.raise_notice( + crate::app::NoticeKind::Terminal, + format!("could not open terminal link {target:?}: {error}"), + ); + } + } // Through the link: attached, opening and closing a tab is a request to // whoever owns the tab list, not a local edit. KeyOutcome::Project(request) => link.request(ws, request), diff --git a/src/application/input/dispatch.rs b/src/application/input/dispatch.rs index 5fcc9084..ad46c623 100644 --- a/src/application/input/dispatch.rs +++ b/src/application/input/dispatch.rs @@ -20,6 +20,12 @@ pub(crate) enum KeyOutcome { /// take `&mut App` — one project — so they cannot reach the tab list; /// they name the intent here and `main_loop` carries it out. Project(ProjectRequest), + /// Open a link detected in a terminal screen. Launching is kept out of + /// input handlers so failures can become a visible terminal notice. + OpenLink { + target: String, + base: String, + }, } /// A workspace-level action requested by a key or click. diff --git a/src/application/input/mouse.rs b/src/application/input/mouse.rs index 2b4afb9e..064ec8e0 100644 --- a/src/application/input/mouse.rs +++ b/src/application/input/mouse.rs @@ -134,7 +134,21 @@ pub(crate) fn handle_mouse( let row = mouse.row - rect.y + 1; match mouse.kind { MouseEventKind::Down(button) => { + // Link handling keeps the ordinary click's focus behavior. A + // link is opened by the app after the clicked pane is active. focus_clicked_pane(app, id); + if button == crossterm::event::MouseButton::Left + && mouse.modifiers == KeyModifiers::NONE + && let Some(target) = app + .terminal + .screen_for_pane(id) + .and_then(|screen| screen.link_at(row - 1, col - 1)) + { + return KeyOutcome::OpenLink { + target, + base: app.repository_path().to_owned(), + }; + } if app.terminal.click_pane(id, button, true, col, row) { app.interaction.pending_mouse_press = Some((id, button, col, row)); } diff --git a/src/application/tests/mod.rs b/src/application/tests/mod.rs index 9b0e13e8..db6cfa95 100644 --- a/src/application/tests/mod.rs +++ b/src/application/tests/mod.rs @@ -4,6 +4,7 @@ mod helpers; mod mouse; mod mouse_clicks; mod mouse_empty; +mod mouse_links; mod mouse_release; mod paste; mod paste_burst; diff --git a/src/application/tests/mouse_links.rs b/src/application/tests/mouse_links.rs new file mode 100644 index 00000000..074b6e9c --- /dev/null +++ b/src/application/tests/mouse_links.rs @@ -0,0 +1,158 @@ +use super::helpers::*; +use crate::app::Focus; +use crate::application::input::dispatch::KeyOutcome; +use crate::application::input::mouse::handle_mouse; +use crossterm::event::{KeyModifiers, MouseButton, MouseEventKind}; + +fn osc8(target: &str) -> Vec { + format!("\x1b]8;;{target}\x1b\\open\x1b]8;;\x1b\\").into_bytes() +} + +#[test] +fn left_link_click_focuses_pane_and_preempts_mouse_reporting() { + let (mut app, areas) = app_with_two_panes_and_areas(); + let (id, rect) = areas[0]; + let target = "https://example.test/docs"; + app.terminal + .emulators + .get_mut(&id) + .unwrap() + .process(&osc8(target)); + app.terminal + .emulators + .get_mut(&id) + .unwrap() + .process(b"\x1b[?1000h\x1b[?1006h"); + app.focus = Focus::FileList; + + let outcome = handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(MouseEventKind::Down(MouseButton::Left), rect.x, rect.y), + MOUSE_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + ); + + assert_eq!( + outcome, + KeyOutcome::OpenLink { + target: target.into(), + base: ".".into(), + } + ); + assert_eq!(app.focus, Focus::Terminal); + assert_eq!(app.terminal.active, 0, "ordinary click focus is preserved"); + assert!(backend_payloads(&app).is_empty()); + assert!(app.interaction.pending_mouse_press.is_none()); +} + +#[test] +fn link_release_does_not_create_an_unpaired_mouse_report() { + let (mut app, areas) = app_with_two_panes_and_areas(); + let (id, rect) = areas[0]; + app.terminal + .emulators + .get_mut(&id) + .unwrap() + .process(&osc8("https://example.test")); + let layout = crate::config::LayoutConfig::default(); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(MouseEventKind::Down(MouseButton::Left), rect.x, rect.y), + MOUSE_TEST_SCREEN, + &layout, + ); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse(MouseEventKind::Up(MouseButton::Left), rect.x, rect.y), + MOUSE_TEST_SCREEN, + &layout, + ); + assert!(backend_payloads(&app).is_empty()); +} + +#[test] +fn selection_modifier_bypasses_link_opening() { + let (mut app, areas) = app_with_two_panes_and_areas(); + let (id, rect) = areas[0]; + app.terminal + .emulators + .get_mut(&id) + .unwrap() + .process(&osc8("https://example.test")); + app.terminal + .emulators + .get_mut(&id) + .unwrap() + .process(b"\x1b[?1000h\x1b[?1006h"); + let mut event = mouse(MouseEventKind::Down(MouseButton::Left), rect.x, rect.y); + event.modifiers = KeyModifiers::SHIFT; + + let outcome = handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + event, + MOUSE_TEST_SCREEN, + &crate::config::LayoutConfig::default(), + ); + assert_eq!(outcome, KeyOutcome::Continue); + assert_eq!(backend_payloads(&app), vec![b"\x1b[<0;1;1M".to_vec()]); +} + +#[test] +fn link_click_keeps_a_previous_press_paired_for_release() { + let (mut app, areas) = app_with_two_panes_and_areas(); + let (first_id, first_rect) = areas[0]; + let (second_id, second_rect) = areas[1]; + app.terminal + .emulators + .get_mut(&first_id) + .unwrap() + .process(b"\x1b[?1000h\x1b[?1006h"); + app.terminal + .emulators + .get_mut(&second_id) + .unwrap() + .process(&osc8("https://example.test")); + let layout = crate::config::LayoutConfig::default(); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse( + MouseEventKind::Down(MouseButton::Left), + first_rect.x, + first_rect.y, + ), + MOUSE_TEST_SCREEN, + &layout, + ); + let outcome = handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse( + MouseEventKind::Down(MouseButton::Left), + second_rect.x, + second_rect.y, + ), + MOUSE_TEST_SCREEN, + &layout, + ); + assert!(matches!(outcome, KeyOutcome::OpenLink { .. })); + handle_mouse( + &mut app, + test_tab_view(&test_tabs()), + mouse( + MouseEventKind::Up(MouseButton::Left), + second_rect.x, + second_rect.y, + ), + MOUSE_TEST_SCREEN, + &layout, + ); + assert_eq!( + backend_payloads(&app), + vec![b"\x1b[<0;1;1M".to_vec(), b"\x1b[<0;48;1m".to_vec()] + ); +} diff --git a/src/backend/pty_tests.rs b/src/backend/pty_tests.rs index 85e672a6..15e4bbc8 100644 --- a/src/backend/pty_tests.rs +++ b/src/backend/pty_tests.rs @@ -6,6 +6,8 @@ use crate::config::ShellConfig; mod identity; #[path = "pty_tests/lifecycle.rs"] mod lifecycle; +#[path = "pty_tests/links.rs"] +mod links; #[path = "pty_tests/relaunch.rs"] mod relaunch; diff --git a/src/backend/pty_tests/links.rs b/src/backend/pty_tests/links.rs new file mode 100644 index 00000000..fe3ec09b --- /dev/null +++ b/src/backend/pty_tests/links.rs @@ -0,0 +1,164 @@ +//! End-to-end link discovery through a real local PTY. +//! +//! The child is this test binary itself. That keeps the fixture independent of +//! the host shell while still making the bytes cross the same PTY backend used +//! by an attached terminal. + +use super::*; +use crate::runtime::emulator::PaneEmulator; +use std::io::Write; + +const ROWS: u16 = 16; +const COLS: u16 = 100; + +const FILE_TARGET: &str = "file:///workspace/src/main.rs"; +const WEB_TARGET: &str = "https://example.invalid/docs"; +const FILE_LABEL: &str = "FILE LINK"; +const WEB_LABEL: &str = "WEB LINK"; +const PLAIN_TARGET: &str = "src/runtime/emulator/view.rs:73"; +const PLAIN_WEB_TARGET: &str = "https://example.invalid/fallback"; + +const FILE_ROW: u16 = 0; +const WEB_ROW: u16 = 1; +const PLAIN_ROW: u16 = 2; +const PLAIN_WEB_ROW: u16 = 3; + +/// This test is only a child fixture for `a_real_pty_preserves_link_targets`. +/// `--ignored` and `--exact` make the parent launch this one test explicitly. +#[test] +#[ignore] +fn emit_link_fixture() { + print!( + "\x1b[2J\x1b[1;1H\x1b]8;;{FILE_TARGET}\x1b\\{FILE_LABEL}\x1b]8;;\x1b\\\x1b[2;1H\x1b]8;;{WEB_TARGET}\x1b\\{WEB_LABEL}\x1b]8;;\x1b\\\x1b[3;1H{PLAIN_TARGET}\r\n\x1b[4;1H{PLAIN_WEB_TARGET}\r\n" + ); + std::io::stdout().flush().expect("fixture stdout flush"); +} + +fn fixture_test_name() -> &'static str { + "backend::pty::tests::links::emit_link_fixture" +} + +fn fixture_shell() -> ShellConfig { + ShellConfig { + program: Some( + std::env::current_exe() + .expect("current test executable") + .to_string_lossy() + .into_owned(), + ), + command_args: ["--ignored", "--nocapture", "--exact", "--test-threads=1"] + .into_iter() + .map(str::to_string) + .collect(), + } +} + +fn contains_osc8_target(output: &[u8], target: &str) -> bool { + let prefix = b"\x1b]8;"; + let mut search_from = 0; + while search_from < output.len() { + let Some(offset) = output[search_from..] + .windows(prefix.len()) + .position(|window| window == prefix) + else { + return false; + }; + let start = search_from + offset + prefix.len(); + let Some(separator) = output[start..].iter().position(|byte| *byte == b';') else { + return false; + }; + let target_start = start + separator + 1; + let mut end = target_start; + while end < output.len() { + if output[end] == b'\x07' + || (output[end] == b'\x1b' && output.get(end + 1) == Some(&b'\\')) + { + break; + } + end += 1; + } + if output[target_start..end] + .windows(target.len()) + .any(|window| window == target.as_bytes()) + { + return true; + } + search_from = end.saturating_add(2); + } + false +} + +fn screen_from(output: &[u8]) -> PaneEmulator { + let mut emulator = PaneEmulator::new(ROWS, COLS, 0); + emulator.process(output); + emulator +} + +fn assert_target(emulator: &PaneEmulator, row: u16, col: u16, target: &str) { + assert_eq!( + emulator.view().link_at(row, col).as_deref(), + Some(target), + "link target at ({row}, {col})" + ); +} + +#[test] +fn a_real_pty_preserves_link_targets() { + let mut backend = PtyBackend::new(".", fixture_shell()); + let pane = backend + .open_pane(ROWS, COLS, Some(fixture_test_name())) + .expect("open fixture pane"); + let drained = drain_until_exit(&mut backend, pane); + + assert_eq!(drained.exits, 1, "fixture test did not exit"); + assert!(!drained.output.is_empty(), "fixture PTY produced no output"); + let output = String::from_utf8_lossy(&drained.output); + assert!( + output.contains(FILE_LABEL), + "file label missing: {output:?}" + ); + assert!(output.contains(WEB_LABEL), "web label missing: {output:?}"); + assert!( + output.contains(PLAIN_TARGET), + "plain file reference missing: {output:?}" + ); + assert!( + output.contains(PLAIN_WEB_TARGET), + "plain web URL missing: {output:?}" + ); + let file_osc8 = contains_osc8_target(&drained.output, FILE_TARGET); + let web_osc8 = contains_osc8_target(&drained.output, WEB_TARGET); + #[cfg(unix)] + { + assert!( + file_osc8, + "Unix PTY must preserve the deterministic file OSC 8 target" + ); + assert!( + web_osc8, + "Unix PTY must preserve the deterministic web OSC 8 target" + ); + } + let original = screen_from(&drained.output); + assert_target(&original, PLAIN_ROW, 0, PLAIN_TARGET); + assert_target(&original, PLAIN_WEB_ROW, 0, PLAIN_WEB_TARGET); + if file_osc8 { + assert_target(&original, FILE_ROW, 0, FILE_TARGET); + } + if web_osc8 { + assert_target(&original, WEB_ROW, 0, WEB_TARGET); + } + + let snapshot = original.screen_snapshot(); + let replayed = screen_from(&snapshot); + assert_target(&replayed, PLAIN_ROW, 0, PLAIN_TARGET); + assert_target(&replayed, PLAIN_WEB_ROW, 0, PLAIN_WEB_TARGET); + if file_osc8 { + assert_target(&replayed, FILE_ROW, 0, FILE_TARGET); + } + if web_osc8 { + assert_target(&replayed, WEB_ROW, 0, WEB_TARGET); + } + + backend.destroy_pane(pane); +} diff --git a/src/platform/link_target.rs b/src/platform/link_target.rs new file mode 100644 index 00000000..8e5066e5 --- /dev/null +++ b/src/platform/link_target.rs @@ -0,0 +1,300 @@ +use anyhow::{Context, Result, bail}; +use std::path::{Component, Path, PathBuf}; +type Location = (Option, Option); +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum LinkTarget { + Web(String), + File { + path: PathBuf, + line: Option, + column: Option, + }, +} +pub(crate) fn parse_target(raw: &str) -> Result { + valid_text(raw)?; + if is_http_scheme(raw) { + return parse_web(raw); + } + if starts_with_scheme(raw, "file") { + return parse_file_url(raw); + } + let (path, fragment) = split_fragment(raw)?; + reject_scheme(path)?; + parse_local(path, fragment.as_deref(), false) +} +fn parse_web(raw: &str) -> Result { + let scheme_end = raw.find("://").context("web link is missing //")?; + let rest = &raw[scheme_end + 3..]; + let end = rest.find(['/', '?', '#']).unwrap_or(rest.len()); + let authority = &rest[..end]; + if authority.is_empty() || authority.contains('@') || authority.contains('\\') { + bail!("malformed web link"); + } + if authority.starts_with('[') { + let close = authority.find(']').context("malformed web host")?; + if close == 1 + || (!authority[close + 1..].is_empty() && !authority[close + 1..].starts_with(':')) + { + bail!("malformed web host"); + } + if let Some(port) = authority[close + 1..].strip_prefix(':') { + validate_port(port)?; + } + } else { + let mut pieces = authority.split(':'); + let host = pieces.next().unwrap_or_default(); + if host.is_empty() || pieces.clone().count() > 1 { + bail!("malformed web host"); + } + if let Some(port) = pieces.next() { + validate_port(port)?; + } + } + if raw.contains('\\') || raw.chars().any(char::is_whitespace) { + bail!("web link contains whitespace or a backslash"); + } + valid_percent_encoding(raw)?; + Ok(LinkTarget::Web(raw.to_owned())) +} +fn parse_file_url(raw: &str) -> Result { + if raw.len() < 7 || !starts_with_scheme(raw, "file") || !raw[7..].starts_with('/') { + bail!("file link must use a local file URI"); + } + let (encoded_path, fragment) = split_fragment(&raw[7..])?; + if encoded_path.contains('?') { + bail!("file link queries are unsupported"); + } + valid_percent_encoding(encoded_path)?; + let decoded = percent_decode(encoded_path)?; + #[cfg(windows)] + let decoded = decoded + .strip_prefix('/') + .filter(|path| is_drive_path(path)) + .unwrap_or(&decoded) + .to_owned(); + parse_local(&decoded, fragment.as_deref(), true) +} +fn parse_local(raw_path: &str, fragment: Option<&str>, file_url: bool) -> Result { + valid_text(raw_path)?; + let fragment_location = fragment.map(parse_fragment).transpose()?; + let (path, suffix_location) = split_line_ref(raw_path)?; + if fragment_location.is_some() && suffix_location.is_some() { + bail!("file link has two line references"); + } + let (line, column) = fragment_location + .or(suffix_location) + .unwrap_or((None, None)); + let path = validate_local_path(path, file_url)?; + Ok(LinkTarget::File { path, line, column }) +} +fn validate_local_path(raw: &str, file_url: bool) -> Result { + if raw.is_empty() || is_forbidden_namespace(raw) { + bail!("local link path is empty or uses a network/device namespace"); + } + #[cfg(windows)] + let native_absolute = is_drive_path(raw); + #[cfg(not(windows))] + let native_absolute = raw.starts_with('/'); + if file_url && !native_absolute { + bail!("local link path is not native to this platform"); + } + if !file_url && native_absolute && !cfg!(windows) { + bail!("plain local links must be repository-relative"); + } + #[cfg(windows)] + if !is_drive_path(raw) && raw.starts_with(['/', '\\']) { + bail!("Windows local links must use a drive path"); + } + if raw.starts_with("//") || raw.starts_with(r"\\") { + bail!("network paths are not opened"); + } + let path = Path::new(raw); + for component in path.components() { + match component { + Component::ParentDir => bail!("local link escapes its repository"), + Component::Normal(name) + if name.to_str().is_some_and(crate::git::path::is_git_dir_name) => + { + bail!("links into .git are not opened") + } + _ => {} + } + } + #[cfg(windows)] + if raw.contains(':') && (!is_drive_path(raw) || raw[2..].contains(':')) { + bail!("alternate data streams are not opened"); + } + Ok(PathBuf::from(raw)) +} +fn parse_fragment(fragment: &str) -> Result { + let rest = fragment + .strip_prefix('L') + .context("unsupported link fragment")?; + let rest = if let Some((start, end)) = rest.split_once('-') { + let end = end.strip_prefix('L').unwrap_or(end); + if !is_line_location(end) { + bail!("malformed line range"); + } + start + } else { + rest + }; + let (line, column) = rest + .split_once(':') + .or_else(|| rest.split_once('C')) + .map_or((rest, None), |(line, column)| (line, Some(column))); + let line = parse_positive(line, "line")?; + let column = column + .map(|column| parse_positive(column, "column")) + .transpose()?; + Ok((Some(line), column)) +} +fn split_line_ref(path: &str) -> Result<(&str, Option)> { + let path = path + .rsplit_once('-') + .filter(|(start, end)| { + let Some((_, line_part)) = start.rsplit_once(':') else { + return false; + }; + line_part.bytes().all(|byte| byte.is_ascii_digit()) && is_line_location(end) + }) + .map_or(path, |(start, _)| start); + let Some((prefix, last)) = path.rsplit_once(':') else { + return Ok((path, None)); + }; + if !last.bytes().all(|b| b.is_ascii_digit()) { + return Ok((path, None)); + } + let value = parse_positive(last, "line or column")?; + let Some((base, line)) = prefix.rsplit_once(':') else { + return Ok((prefix, Some((Some(value), None)))); + }; + if line.bytes().all(|b| b.is_ascii_digit()) && (!base.contains(':') || is_drive_path(base)) { + return Ok(( + base, + Some((Some(parse_positive(line, "line")?), Some(value))), + )); + } + Ok((prefix, Some((Some(value), None)))) +} +fn is_line_location(value: &str) -> bool { + let (line, column) = value + .split_once(':') + .or_else(|| value.split_once('C')) + .map_or((value, None), |(line, column)| (line, Some(column))); + !line.is_empty() + && line.bytes().all(|byte| byte.is_ascii_digit()) + && column.is_none_or(|column| { + !column.is_empty() && column.bytes().all(|byte| byte.is_ascii_digit()) + }) +} +fn parse_positive(value: &str, label: &str) -> Result { + let number = value + .parse::() + .with_context(|| format!("invalid {label}"))?; + if number == 0 { + bail!("{label} must be greater than zero"); + } + Ok(number) +} +fn reject_scheme(path: &str) -> Result<()> { + let Some(colon) = path.find(':') else { + return Ok(()); + }; + if is_drive_path(path) { + return Ok(()); + } + if path[..colon].bytes().all(|byte| byte.is_ascii_alphabetic()) { + bail!("unsupported link scheme"); + } + Ok(()) +} +fn validate_port(port: &str) -> Result<()> { + if port.is_empty() || port.parse::().is_err() { + bail!("malformed web port"); + } + Ok(()) +} +fn valid_percent_encoding(text: &str) -> Result<()> { + let bytes = text.as_bytes(); + for (index, byte) in bytes.iter().enumerate() { + if *byte == b'%' { + if index + 2 >= bytes.len() + || !bytes[index + 1].is_ascii_hexdigit() + || !bytes[index + 2].is_ascii_hexdigit() + { + bail!("malformed percent escape"); + } + let value = u8::from_str_radix(&text[index + 1..index + 3], 16); + if value.is_ok_and(|byte| byte.is_ascii_control()) { + bail!("percent escape contains a control character"); + } + } + } + Ok(()) +} +fn percent_decode(text: &str) -> Result { + let mut bytes = Vec::with_capacity(text.len()); + let raw = text.as_bytes(); + let mut index = 0; + while index < raw.len() { + if raw[index] == b'%' { + bytes.push(u8::from_str_radix(&text[index + 1..index + 3], 16)?); + index += 3; + } else { + let character = text[index..].chars().next().context("invalid UTF-8")?; + let mut encoded = [0; 4]; + bytes.extend_from_slice(character.encode_utf8(&mut encoded).as_bytes()); + index += character.len_utf8(); + } + } + String::from_utf8(bytes).context("file URI is not valid UTF-8") +} +fn split_fragment(raw: &str) -> Result<(&str, Option)> { + let Some((path, fragment)) = raw.split_once('#') else { + return Ok((raw, None)); + }; + if fragment.is_empty() { + bail!("empty link fragment"); + } + Ok((path, Some(fragment.to_owned()))) +} +fn valid_text(text: &str) -> Result<()> { + if text.is_empty() || text.chars().any(char::is_control) { + bail!("link contains an empty or control character"); + } + Ok(()) +} +fn starts_with_scheme(text: &str, scheme: &str) -> bool { + text.as_bytes() + .get(..scheme.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(scheme.as_bytes())) + && text.as_bytes().get(scheme.len()) == Some(&b':') +} +fn is_http_scheme(text: &str) -> bool { + text.as_bytes() + .get(..7) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"http://")) + || text + .as_bytes() + .get(..8) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"https://")) +} +fn is_drive_path(path: &str) -> bool { + let bytes = path.as_bytes(); + bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'/' | b'\\') +} +fn is_forbidden_namespace(path: &str) -> bool { + path.starts_with(r"\\") + || path.starts_with("//") + || path.starts_with(r"\\?\") + || path.starts_with(r"\\.\") + || path.starts_with("//?/") + || path.starts_with("//./") +} +#[cfg(test)] +#[path = "links_tests.rs"] +mod tests; diff --git a/src/platform/links.rs b/src/platform/links.rs new file mode 100644 index 00000000..61063eff --- /dev/null +++ b/src/platform/links.rs @@ -0,0 +1,204 @@ +//! Platform launchers for already-parsed terminal links. + +use anyhow::{Context, Result, bail}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::process::{Child, Stdio}; + +use super::link_target::{LinkTarget, parse_target}; + +/// Parse and launch a target. Every child receives fixed arguments directly; +/// no shell or platform association is used for local files. +pub(crate) fn open(raw: &str, base: &Path) -> Result<()> { + match parse_target(raw)? { + LinkTarget::Web(url) => open_web(&url), + LinkTarget::File { path, line, column } => { + let path = resolve_file(&path, base)?; + open_editor(&path, line, column) + } + } +} + +fn resolve_file(path: &Path, base: &Path) -> Result { + if path.is_absolute() { + ensure_file(path)?; + return Ok(path.to_path_buf()); + } + let relative = path + .to_str() + .context("local link path is not valid UTF-8")?; + crate::git::path::resolve_in_workdir(base, relative) +} + +fn ensure_file(path: &Path) -> Result<()> { + let metadata = std::fs::metadata(path) + .with_context(|| format!("cannot access linked file {}", path.display()))?; + if !metadata.is_file() { + bail!("linked target is not a regular file: {}", path.display()); + } + Ok(()) +} + +#[derive(Clone, Copy)] +enum Editor { + Code, + #[cfg(not(windows))] + CliLine, + #[cfg(windows)] + Plain, + #[cfg(target_os = "macos")] + MacText, +} + +fn open_editor(path: &Path, line: Option, column: Option) -> Result<()> { + let location = line.map(|line| match column { + Some(column) => format!("{}:{line}:{column}", path.display()), + None => format!("{}:{line}", path.display()), + }); + let mut last_error = None; + for (program, editor) in editor_candidates() { + let mut command = Command::new(program); + match editor { + Editor::Code => { + command.arg("--goto").arg( + location + .as_deref() + .unwrap_or_else(|| path.to_str().unwrap_or_default()), + ); + } + #[cfg(not(windows))] + Editor::CliLine => { + if let Some(line) = line { + command.arg("--line").arg(line.to_string()); + if let Some(column) = column { + command.arg("--column").arg(column.to_string()); + } + } + command.arg(path); + } + #[cfg(windows)] + Editor::Plain => { + command.arg(path); + } + #[cfg(target_os = "macos")] + Editor::MacText => { + command.arg("-t").arg(path); + } + } + let result = command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + match result { + Ok(child) => { + reap_child(child); + return Ok(()); + } + Err(error) => last_error = Some(error), + } + } + let detail = last_error + .map(|error| error.to_string()) + .unwrap_or_else(|| "no editor found".into()); + bail!("could not open {}: {detail}", path.display()); +} + +#[cfg(windows)] +fn editor_candidates() -> Vec<(PathBuf, Editor)> { + let mut candidates = Vec::new(); + for root in [ + std::env::var_os("LOCALAPPDATA").map(PathBuf::from), + std::env::var_os("ProgramFiles").map(PathBuf::from), + ] + .into_iter() + .flatten() + { + candidates.push(( + root.join("Programs/Microsoft VS Code/Code.exe"), + Editor::Code, + )); + candidates.push((root.join("Microsoft VS Code/Code.exe"), Editor::Code)); + } + candidates.push((PathBuf::from("notepad.exe"), Editor::Plain)); + candidates +} + +#[cfg(not(windows))] +fn editor_candidates() -> Vec<(PathBuf, Editor)> { + vec![ + (PathBuf::from("code"), Editor::Code), + (PathBuf::from("gedit"), Editor::CliLine), + (PathBuf::from("kate"), Editor::CliLine), + (PathBuf::from("geany"), Editor::CliLine), + #[cfg(target_os = "macos")] + (PathBuf::from("open"), Editor::MacText), + ] +} + +fn reap_child(mut child: Child) { + std::thread::spawn(move || { + let _ = child.wait(); + }); +} + +#[cfg(windows)] +fn open_web(url: &str) -> Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::UI::Shell::ShellExecuteW; + use windows_sys::Win32::UI::WindowsAndMessaging::SW_SHOWNORMAL; + + let operation: Vec = std::ffi::OsStr::new("open") + .encode_wide() + .chain(Some(0)) + .collect(); + let target: Vec = std::ffi::OsStr::new(url) + .encode_wide() + .chain(Some(0)) + .collect(); + // SAFETY: both strings are NUL-terminated UTF-16 buffers valid for this call. + let result = unsafe { + ShellExecuteW( + std::ptr::null_mut(), + operation.as_ptr(), + target.as_ptr(), + std::ptr::null(), + std::ptr::null(), + SW_SHOWNORMAL, + ) + } as isize; + if result <= 32 { + bail!( + "could not open web link: {}", + std::io::Error::last_os_error() + ); + } + Ok(()) +} + +#[cfg(target_os = "macos")] +fn open_web(url: &str) -> Result<()> { + let mut command = Command::new("open"); + command.arg(url); + spawn_detached(&mut command).context("starting macOS URL opener")?; + Ok(()) +} + +#[cfg(all(unix, not(target_os = "macos")))] +fn open_web(url: &str) -> Result<()> { + let mut command = Command::new("xdg-open"); + command.arg(url); + spawn_detached(&mut command).context("starting xdg-open")?; + Ok(()) +} + +#[cfg(unix)] +fn spawn_detached(command: &mut Command) -> std::io::Result<()> { + let child = command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + reap_child(child); + Ok(()) +} diff --git a/src/platform/links_tests.rs b/src/platform/links_tests.rs new file mode 100644 index 00000000..cd9980f6 --- /dev/null +++ b/src/platform/links_tests.rs @@ -0,0 +1,101 @@ +use super::{LinkTarget, parse_target}; +use std::path::PathBuf; + +#[test] +fn parses_web_links_without_rewriting_the_target() { + assert_eq!( + parse_target("https://example.test/a path") + .unwrap_err() + .to_string(), + "web link contains whitespace or a backslash" + ); + assert_eq!( + parse_target("https://example.test/a%20path#L4").unwrap(), + LinkTarget::Web("https://example.test/a%20path#L4".into()) + ); +} + +#[test] +fn parses_relative_paths_with_line_and_column() { + assert!(parse_target("한글.txt").is_ok()); + assert!(parse_target("docs/readme.md#L9-").is_err()); + assert_eq!( + parse_target("src/한 파일.rs:12:7").unwrap(), + LinkTarget::File { + path: PathBuf::from("src/한 파일.rs"), + line: Some(12), + column: Some(7), + } + ); + assert_eq!( + parse_target("docs/readme.md#L9C3-L10C4").unwrap(), + LinkTarget::File { + path: PathBuf::from("docs/readme.md"), + line: Some(9), + column: Some(3), + } + ); + assert_eq!( + parse_target("src/file-2026.rs:12").unwrap(), + LinkTarget::File { + path: PathBuf::from("src/file-2026.rs"), + line: Some(12), + column: None, + } + ); +} + +#[test] +fn rejects_unsafe_or_ambiguous_targets_before_launching() { + for target in [ + "javascript:alert(1)", + "file://server/share/readme.md", + r"\\?\C:\secret.txt", + "../secret.txt", + "src/file.rs\n:3", + "src/file.rs:0", + "https://example.test/%0A", + "file:///tmp/readme.md#section", + ] { + assert!( + parse_target(target).is_err(), + "accepted unsafe target {target:?}" + ); + } +} + +#[cfg(windows)] +#[test] +fn parses_windows_drive_paths_and_file_urls() { + assert_eq!( + parse_target(r"C:\work\space file.rs:4:2").unwrap(), + LinkTarget::File { + path: PathBuf::from(r"C:\work\space file.rs"), + line: Some(4), + column: Some(2), + } + ); + assert_eq!( + parse_target("file:///C:/work/space%20file.rs#L4").unwrap(), + LinkTarget::File { + path: PathBuf::from(r"C:\work\space file.rs"), + line: Some(4), + column: None, + } + ); + assert!(parse_target(r"C:\work\space.txt:secret").is_err()); +} + +#[cfg(unix)] +#[test] +fn parses_absolute_file_urls_but_plain_paths_stay_repo_relative() { + assert_eq!( + parse_target("file:///tmp/space%20file.md:6").unwrap(), + LinkTarget::File { + path: PathBuf::from("/tmp/space file.md"), + line: Some(6), + column: None, + } + ); + assert!(parse_target("/tmp/space file.md").is_err()); +} diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 426af8ec..d7d4e7de 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -2,6 +2,8 @@ pub(crate) mod console; pub(crate) mod fs; +mod link_target; +pub(crate) mod links; pub(crate) mod logging; pub(crate) mod paths; pub(crate) mod self_replace; diff --git a/src/runtime/emulator/links.rs b/src/runtime/emulator/links.rs new file mode 100644 index 00000000..3241ea6a --- /dev/null +++ b/src/runtime/emulator/links.rs @@ -0,0 +1,246 @@ +use super::links_paths::location_suffix; +use super::view::ScreenView; + +const MAX_LINK_SCAN_BYTES: usize = 16 * 1024; + +struct CellSpan { + row: u16, + col: u16, + start: usize, + end: usize, +} + +pub(super) fn find(view: &ScreenView<'_>, row: u16, col: u16) -> Option { + let (rows, cols) = view.size(); + let cell = view.cell(row, col)?; + if let Some(target) = cell.hyperlink() { + return Some(target); + } + + let mut first = row; + let last_col = cols.saturating_sub(1); + while first > 0 && view.cell(first - 1, last_col)?.is_wrap_line() { + first -= 1; + } + let mut last = row; + while last + 1 < rows && view.cell(last, last_col)?.is_wrap_line() { + last += 1; + } + + let mut text = String::new(); + let mut spans = Vec::new(); + let mut previous_span = None; + let mut complete = true; + 'rows: for current_row in first..=last { + if current_row > first + && !view + .cell(current_row - 1, last_col) + .is_some_and(|line| line.is_wrap_line()) + { + if text.len() == MAX_LINK_SCAN_BYTES { + complete = false; + break; + } + text.push('\n'); + } + for current_col in 0..cols { + if text.len() >= MAX_LINK_SCAN_BYTES { + complete = false; + break 'rows; + } + let current = view.cell(current_row, current_col)?; + let start = text.len(); + if current.is_wide_spacer() { + let (start, end) = previous_span.unwrap_or((start, start)); + spans.push(CellSpan { + row: current_row, + col: current_col, + start, + end, + }); + continue; + } else { + let mut contents = String::new(); + current.append_contents(&mut contents); + if text.len() + contents.len() > MAX_LINK_SCAN_BYTES { + complete = false; + break 'rows; + } + text.push_str(&contents); + } + previous_span = Some((start, text.len())); + spans.push(CellSpan { + row: current_row, + col: current_col, + start, + end: text.len(), + }); + } + } + + let clicked = spans + .iter() + .find(|span| span.row == row && span.col == col) + .map(|span| { + if span.start == span.end { + span.start.saturating_sub(1) + } else { + span.start + } + })?; + find_markdown(&text, clicked, complete) + .or_else(|| find_url(&text, clicked, complete)) + .or_else(|| find_path(&text, clicked, complete)) +} + +fn find_markdown(text: &str, clicked: usize, complete: bool) -> Option { + for (open, _) in text.match_indices('[') { + if open > 0 && text[..open].ends_with('!') { + continue; + } + let Some(close) = matching_bracket(text, open, '[', ']') else { + continue; + }; + let target_open = close + 1; + if text.as_bytes().get(target_open) != Some(&b'(') { + continue; + } + let Some(target_close) = matching_bracket(text, target_open, '(', ')') else { + continue; + }; + if !complete && target_close == text.len() - 1 { + continue; + } + if clicked < open || clicked > close { + continue; + } + let mut target = text[target_open + 1..target_close].trim(); + if target.starts_with('<') && target.ends_with('>') { + target = target[1..target.len() - 1].trim(); + } + if !target.is_empty() { + return Some(target.to_owned()); + } + } + None +} + +fn matching_bracket(text: &str, open: usize, left: char, right: char) -> Option { + let mut depth = 0; + let mut escaped = false; + for (offset, character) in text[open..].char_indices() { + if escaped { + escaped = false; + continue; + } + if character == '\\' { + escaped = true; + } else if character == left { + depth += 1; + } else if character == right { + depth -= 1; + if depth == 0 { + return Some(open + offset); + } + } + } + None +} + +fn find_url(text: &str, clicked: usize, complete: bool) -> Option { + for scheme in ["https://", "http://"] { + for (start, _) in text.char_indices() { + if !text[start..] + .get(..scheme.len()) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(scheme)) + { + continue; + } + if start > 0 + && text[..start].chars().next_back().is_some_and(|previous| { + previous.is_ascii_alphanumeric() || "._-".contains(previous) + }) + { + continue; + } + let raw_end = text[start..] + .char_indices() + .find(|(_, character)| character.is_whitespace() || character.is_control()) + .map_or(text.len(), |(offset, _)| start + offset); + let end = trim_punctuation(text, start, raw_end, true); + if end > start && (complete || end < text.len()) && clicked >= start && clicked < end { + return Some(text[start..end].to_owned()); + } + } + } + None +} + +fn find_path(text: &str, clicked: usize, complete: bool) -> Option { + for (start, character) in text.char_indices() { + if !character.is_alphanumeric() && !"._/~\\".contains(character) { + continue; + } + if start > 0 + && !text[..start] + .chars() + .next_back() + .is_some_and(is_token_boundary) + { + continue; + } + let raw_end = text[start..] + .char_indices() + .find(|(_, character)| character.is_whitespace() || character.is_control()) + .map_or(text.len(), |(offset, _)| start + offset); + let end = trim_punctuation(text, start, raw_end, false); + let candidate = &text[start..end]; + let path_end = location_suffix(candidate).unwrap_or(candidate.len()); + let path = &candidate[..path_end]; + if !is_file_like_path(path) { + continue; + } + if (complete || end < text.len()) && clicked >= start && clicked < end { + return Some(candidate.to_owned()); + } + } + None +} + +fn is_file_like_path(path: &str) -> bool { + if path.is_empty() { + return false; + } + if path.contains(['/', '\\']) { + return true; + } + let Some((stem, extension)) = path.rsplit_once('.') else { + return false; + }; + !stem.is_empty() + && (1..=16).contains(&extension.len()) + && extension.bytes().all(|byte| byte.is_ascii_alphabetic()) +} + +fn is_token_boundary(character: char) -> bool { + character.is_whitespace() || "([{<'\"`".contains(character) +} + +fn trim_punctuation(text: &str, start: usize, mut end: usize, balanced: bool) -> usize { + while let Some(character) = text[start..end].chars().next_back() { + let strip = match character { + '.' | ',' | ';' | ':' | '!' | '?' | ']' | '}' | '\'' | '"' | '`' => true, + ')' if balanced => { + text[start..end].chars().filter(|c| *c == ')').count() + > text[start..end].chars().filter(|c| *c == '(').count() + } + ')' => true, + _ => false, + }; + if !strip { + break; + } + end -= character.len_utf8(); + } + end +} diff --git a/src/runtime/emulator/links_paths.rs b/src/runtime/emulator/links_paths.rs new file mode 100644 index 00000000..a85dba0f --- /dev/null +++ b/src/runtime/emulator/links_paths.rs @@ -0,0 +1,84 @@ +pub(super) fn location_suffix(candidate: &str) -> Option { + for (offset, character) in candidate.char_indices() { + if character == ':' && is_line_suffix(&candidate[offset..]) { + return Some(offset); + } + } + let hash = candidate.rfind("#L")?; + is_hash_suffix(&candidate[hash..]).then_some(hash) +} + +fn is_line_suffix(suffix: &str) -> bool { + let bytes = suffix.as_bytes(); + let mut at = 0; + if bytes.get(at) != Some(&b':') { + return false; + } + at = digits_end(bytes, at + 1); + if at == 1 { + return false; + } + if bytes.get(at) == Some(&b':') { + let next = digits_end(bytes, at + 1); + if next == at + 1 { + return false; + } + at = next; + } + if bytes.get(at) == Some(&b'-') { + at = digits_end(bytes, at + 1); + if at == suffix.len() { + return false; + } + if bytes.get(at) == Some(&b':') { + let next = digits_end(bytes, at + 1); + if next == at + 1 { + return false; + } + at = next; + } + } + at == bytes.len() +} + +fn is_hash_suffix(suffix: &str) -> bool { + let bytes = suffix.as_bytes(); + if !suffix.starts_with("#L") { + return false; + } + let mut at = digits_end(bytes, 2); + if at == 2 { + return false; + } + if bytes.get(at) == Some(&b'C') { + at = digits_end(bytes, at + 1); + if at == suffix.len() { + return false; + } + } + if bytes.get(at) == Some(&b'-') { + at += 1; + if bytes.get(at) == Some(&b'L') { + at += 1; + } + let line_end = digits_end(bytes, at); + if line_end == at { + return false; + } + at = line_end; + if bytes.get(at) == Some(&b'C') { + at = digits_end(bytes, at + 1); + if at == line_end + 1 { + return false; + } + } + } + at == bytes.len() +} + +fn digits_end(bytes: &[u8], mut at: usize) -> usize { + while bytes.get(at).is_some_and(u8::is_ascii_digit) { + at += 1; + } + at +} diff --git a/src/runtime/emulator/links_tests.rs b/src/runtime/emulator/links_tests.rs new file mode 100644 index 00000000..3ef6150e --- /dev/null +++ b/src/runtime/emulator/links_tests.rs @@ -0,0 +1,122 @@ +use super::PaneEmulator; + +fn link(input: &str, row: u16, col: u16) -> Option { + let mut emulator = PaneEmulator::new(4, 80, 8); + emulator.process(input.as_bytes()); + emulator.view().link_at(row, col) +} + +fn link_at(input: &str, needle: &str, target: &str) { + let col = input + .find(needle) + .map(|offset| input[..offset].chars().count() as u16) + .expect("needle in fixture"); + assert_eq!(link(input, 0, col).as_deref(), Some(target)); +} + +#[test] +fn osc8_is_authoritative_for_glyph_and_wide_spacer() { + let target = "https://example.test/wide"; + let input = format!("\x1b]8;;{target}\x1b\\가\x1b]8;;\x1b\\"); + let mut emulator = PaneEmulator::new(4, 80, 0); + emulator.process(input.as_bytes()); + let view = emulator.view(); + + assert_eq!(view.link_at(0, 0).as_deref(), Some(target)); + assert_eq!(view.link_at(0, 1).as_deref(), Some(target)); +} + +#[test] +fn markdown_label_returns_destination() { + assert_eq!( + link("See [the source](src/main.rs:42:7) here", 0, 6).as_deref(), + Some("src/main.rs:42:7") + ); +} + +#[test] +fn urls_keep_balanced_parentheses_and_drop_sentence_punctuation() { + let input = "Open https://example.test/a_(b), then stop"; + assert_eq!( + link(input, 0, 16).as_deref(), + Some("https://example.test/a_(b)") + ); +} + +#[test] +fn url_schemes_are_case_insensitive() { + link_at( + "Open HTTPS://example.test/docs", + "HTTPS://example.test/docs", + "HTTPS://example.test/docs", + ); +} + +#[test] +fn wrapped_urls_are_one_visible_link() { + let mut emulator = PaneEmulator::new(3, 10, 0); + emulator.process(b"https://example.test/path"); + assert_eq!( + emulator.view().link_at(1, 2).as_deref(), + Some("https://example.test/path") + ); +} + +#[test] +fn paths_with_line_locations_are_detected_without_link_metadata() { + let input = r"Open C:\repo\src\main.rs:12:4 and src/lib.rs#L9"; + assert_eq!( + link(input, 0, 10).as_deref(), + Some(r"C:\repo\src\main.rs:12:4") + ); + assert_eq!(link(input, 0, 39).as_deref(), Some("src/lib.rs#L9")); +} + +#[test] +fn absolute_home_and_unicode_paths_support_line_locations() { + link_at("/repo/한글.rs:12", "/repo/한글.rs:12", "/repo/한글.rs:12"); + link_at( + "/repo/한글.rs#L12C3-L14C2", + "/repo/한글.rs#L12C3-L14C2", + "/repo/한글.rs#L12C3-L14C2", + ); + link_at("~/src/main.rs:4", "~/src/main.rs:4", "~/src/main.rs:4"); +} + +#[test] +fn malformed_markdown_does_not_hide_a_later_valid_link() { + let input = "[unfinished text [open](https://example.test/ok)"; + link_at(input, "open", "https://example.test/ok"); +} + +#[test] +fn file_like_names_without_locations_are_detected_but_bare_words_are_not() { + link_at("Read README.md next", "README.md", "README.md"); + link_at("Open src/main.rs now", "src/main.rs", "src/main.rs"); + let mut emulator = PaneEmulator::new(2, 80, 0); + emulator.process(b"ordinary words version.2"); + assert_eq!(emulator.view().link_at(0, 0), None); + assert_eq!(emulator.view().link_at(0, 18), None); +} + +#[test] +fn scrollback_uses_viewport_coordinates() { + let mut emulator = PaneEmulator::new(2, 30, 8); + emulator.process(b"old\r\nhttps://example.test/history\r\nnow"); + emulator.set_scroll_offset(1); + assert_eq!( + emulator.view().link_at(1, 8).as_deref(), + Some("https://example.test/history") + ); +} + +#[test] +fn empty_plain_text_and_out_of_range_are_not_links() { + let mut emulator = PaneEmulator::new(2, 30, 0); + emulator.process(b"ordinary words"); + let view = emulator.view(); + assert_eq!(view.link_at(0, 0), None); + assert_eq!(view.link_at(99, 0), None); + assert_eq!(view.link_at(0, 99), None); + assert_eq!(PaneEmulator::new(2, 30, 0).view().link_at(0, 0), None); +} diff --git a/src/runtime/emulator/mod.rs b/src/runtime/emulator/mod.rs index 38ed14c0..132ba0f5 100644 --- a/src/runtime/emulator/mod.rs +++ b/src/runtime/emulator/mod.rs @@ -254,6 +254,8 @@ fn term_size(rows: u16, cols: u16) -> TermSize { } mod boundary; +mod links; +mod links_paths; mod modes; mod snapshot; mod sync; @@ -265,10 +267,14 @@ pub use view::{CellView, ScreenView}; #[cfg(test)] mod boundary_tests; #[cfg(test)] +mod links_tests; +#[cfg(test)] mod modes_tests; #[cfg(test)] mod snapshot_cost_tests; #[cfg(test)] +mod snapshot_link_tests; +#[cfg(test)] mod snapshot_tests; #[cfg(test)] mod sync_tests; diff --git a/src/runtime/emulator/snapshot.rs b/src/runtime/emulator/snapshot.rs index f2402b6c..4972f1e8 100644 --- a/src/runtime/emulator/snapshot.rs +++ b/src/runtime/emulator/snapshot.rs @@ -19,15 +19,16 @@ //! reflows it differently. Nothing reads that difference today: //! alternate-screen programs redraw on resize, and a normal-screen pane's //! history is still replayed from its byte ring, which keeps its wrapping — -//! the snapshot stands in only for the screen itself. Underline colour, -//! hyperlinks (OSC 8) and the scrolling region (DECSTBM) are not carried -//! either. +//! the snapshot stands in only for the screen itself. Underline colour and the +//! scrolling region (DECSTBM) are not carried either. OSC 8 hyperlink runs and +//! the cursor's active hyperlink are carried so a reattaching client can keep +//! links clickable after the repaint. use super::EventProxy; use alacritty_terminal::grid::Dimensions; use alacritty_terminal::index::{Column, Line, Point}; use alacritty_terminal::term::Term; -use alacritty_terminal::term::cell::{Cell, Flags}; +use alacritty_terminal::term::cell::{Cell, Flags, Hyperlink}; use alacritty_terminal::vte::ansi::{Color, NamedColor}; use std::fmt::Write as _; @@ -35,11 +36,12 @@ use std::fmt::Write as _; /// rather than anything a terminal can be told to enter, so they are masked /// out — and comparing what is left is what lets a run of equal attributes /// cost one escape. -#[derive(PartialEq, Eq, Clone, Copy)] +#[derive(PartialEq, Eq, Clone)] struct Pen { fg: Color, bg: Color, flags: Flags, + hyperlink: Option, } impl Pen { @@ -48,6 +50,7 @@ impl Pen { fg: cell.fg, bg: cell.bg, flags: cell.flags & rendered_flags(), + hyperlink: cell.hyperlink(), } } } @@ -81,10 +84,14 @@ pub(super) fn screen_snapshot(term: &Term) -> Vec { // snapshot of a large pane is taken on the worker's tick, where a dozen // reallocations of a megabyte-long string is the whole cost. let mut out = String::with_capacity(rows * cols + rows * 16 + 32); + // SGR reset does not clear OSC 8 in alacritty, and a receiver may have + // inherited a link from bytes just before this snapshot. + out.push_str(OSC8_CLEAR); out.push_str("\x1b[m\x1b[2J"); // Carried across rows: `SGR` survives a `CUP`, so a run of equal // attributes spanning a row boundary still costs one escape. let mut pen: Option = None; + let mut hyperlink: Option = None; for row in 0..rows { // Positioned rather than reached by a newline. Writing the last column @@ -100,6 +107,7 @@ pub(super) fn screen_snapshot(term: &Term) -> Vec { .rev() .find(|&col| !is_blank(&grid[Point::new(Line(row as i32), Column(col))])); let Some(last) = last else { + clear_hyperlink(&mut out, &mut hyperlink); out.push_str(ERASE_TO_END_OF_ROW); pen = Some(Pen::of(&Cell::default())); continue; @@ -113,11 +121,31 @@ pub(super) fn screen_snapshot(term: &Term) -> Vec { .flags .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { + // A leading spacer has no glyph in this row to recreate it. A + // linked one still needs a real write so OSC 8 metadata survives. + if cell.flags.contains(Flags::LEADING_WIDE_CHAR_SPACER) + && cell.hyperlink().is_some() + { + let cell_pen = Pen::of(cell); + if hyperlink != cell_pen.hyperlink { + write_hyperlink(&mut out, cell_pen.hyperlink.as_ref()); + hyperlink = cell_pen.hyperlink.clone(); + } + if pen.as_ref() != Some(&cell_pen) { + write_sgr(&mut out, &cell_pen); + pen = Some(cell_pen); + } + out.push(' '); + } continue; } let cell_pen = Pen::of(cell); - if pen != Some(cell_pen) { - write_sgr(&mut out, cell_pen); + if hyperlink != cell_pen.hyperlink { + write_hyperlink(&mut out, cell_pen.hyperlink.as_ref()); + hyperlink = cell_pen.hyperlink.clone(); + } + if pen.as_ref() != Some(&cell_pen) { + write_sgr(&mut out, &cell_pen); pen = Some(cell_pen); } out.push(cell.c); @@ -129,6 +157,7 @@ pub(super) fn screen_snapshot(term: &Term) -> Vec { // cursor is left pending-wrap *on* that column, and erasing from there // would wipe the cell just written. if last + 1 < cols { + clear_hyperlink(&mut out, &mut hyperlink); out.push_str(ERASE_TO_END_OF_ROW); pen = Some(Pen::of(&Cell::default())); } @@ -136,7 +165,13 @@ pub(super) fn screen_snapshot(term: &Term) -> Vec { // The pen the program left set, so the next thing it writes looks the way it // means to rather than inheriting the last cell of the screen. - write_sgr(&mut out, Pen::of(&grid.cursor.template)); + let template_pen = Pen::of(&grid.cursor.template); + if pen.as_ref() != Some(&template_pen) { + write_sgr(&mut out, &template_pen); + } + if hyperlink != template_pen.hyperlink { + write_hyperlink(&mut out, template_pen.hyperlink.as_ref()); + } let point = grid.cursor.point; let _ = write!( out, @@ -152,6 +187,7 @@ pub(super) fn screen_snapshot(term: &Term) -> Vec { /// the default one — which is what makes the erased cells equal the cells /// they stand in for. const ERASE_TO_END_OF_ROW: &str = "\x1b[m\x1b[K"; +const OSC8_CLEAR: &str = "\x1b]8;;\x1b\\"; /// One absolute `SGR`. Leads with `0` so the sequence states the whole pen /// rather than a change from whatever the reader had. @@ -159,7 +195,7 @@ const ERASE_TO_END_OF_ROW: &str = "\x1b[m\x1b[K"; /// Appended in place rather than returned: on a densely coloured screen this /// runs once per cell, and building a string per call was measurably the cost /// of the whole snapshot. -fn write_sgr(out: &mut String, pen: Pen) { +fn write_sgr(out: &mut String, pen: &Pen) { out.push_str("\x1b[0"); for (flag, param) in [ (Flags::BOLD, "1"), @@ -184,6 +220,30 @@ fn write_sgr(out: &mut String, pen: Pen) { out.push('m'); } +fn clear_hyperlink(out: &mut String, hyperlink: &mut Option) { + if hyperlink.is_some() { + write_hyperlink(out, None); + *hyperlink = None; + } +} + +pub(super) fn write_hyperlink(out: &mut String, hyperlink: Option<&Hyperlink>) { + out.push_str("\x1b]8;;"); + if let Some(hyperlink) = hyperlink { + for character in hyperlink.uri().chars() { + if character.is_control() || character == '\u{7f}' { + let mut encoded = [0; 4]; + for byte in character.encode_utf8(&mut encoded).bytes() { + let _ = write!(out, "%{byte:02X}"); + } + } else { + out.push(character); + } + } + } + out.push_str("\x1b\\"); +} + /// The `SGR` parameter selecting `color`. The default is written as nothing — /// every sequence starts from a reset, so it needs no saying. /// diff --git a/src/runtime/emulator/snapshot_link_tests.rs b/src/runtime/emulator/snapshot_link_tests.rs new file mode 100644 index 00000000..fefe442d --- /dev/null +++ b/src/runtime/emulator/snapshot_link_tests.rs @@ -0,0 +1,79 @@ +use super::PaneEmulator; +use alacritty_terminal::term::cell::Hyperlink; + +#[test] +fn snapshot_round_trip_preserves_linked_wide_cells_and_following_link_state() { + let target = "https://example.test/roundtrip"; + let input = format!("\x1b]8;;{target}\x1b\\가 \x1b]8;;\x1b\\"); + let mut origin = PaneEmulator::new(3, 30, 0); + origin.process(input.as_bytes()); + let mut replayed = PaneEmulator::new(3, 30, 0); + replayed.process(b"\x1b]8;;https://stale.example\x1b\\stale"); + replayed.process(&origin.screen_snapshot()); + + assert_eq!(origin.view().link_at(0, 0).as_deref(), Some(target)); + assert_eq!(origin.view().link_at(0, 1).as_deref(), Some(target)); + assert_eq!(origin.view().link_at(0, 2).as_deref(), Some(target)); + assert_eq!(replayed.view().link_at(0, 0).as_deref(), Some(target)); + assert_eq!(replayed.view().link_at(0, 1).as_deref(), Some(target)); + assert_eq!(replayed.view().link_at(0, 2).as_deref(), Some(target)); + + origin.process(b"X"); + replayed.process(b"X"); + assert_eq!(replayed.view().link_at(0, 3), None); + assert_eq!(origin.view().link_at(0, 3), None); +} + +#[test] +fn snapshot_round_trip_preserves_a_linked_leading_wide_spacer() { + let target = "https://example.test/wrapped"; + let input = format!("abc\x1b]8;;{target}\x1b\\한\x1b]8;;\x1b\\"); + let mut origin = PaneEmulator::new(3, 4, 0); + origin.process(input.as_bytes()); + let mut replayed = PaneEmulator::new(3, 4, 0); + replayed.process(&origin.screen_snapshot()); + + for (row, col) in [(0, 3), (1, 0), (1, 1)] { + assert_eq!(origin.view().link_at(row, col).as_deref(), Some(target)); + assert_eq!(replayed.view().link_at(row, col).as_deref(), Some(target)); + } +} + +#[test] +fn snapshot_restores_the_cursor_hyperlink_for_following_output() { + let target = "https://example.test/active"; + let input = format!("\x1b]8;;{target}\x1b\\가 "); + let mut origin = PaneEmulator::new(3, 30, 0); + origin.process(input.as_bytes()); + let mut replayed = PaneEmulator::new(3, 30, 0); + replayed.process(&origin.screen_snapshot()); + + origin.process(b"X"); + replayed.process(b"X"); + assert_eq!(origin.view().link_at(0, 3).as_deref(), Some(target)); + assert_eq!(replayed.view().link_at(0, 3).as_deref(), Some(target)); +} + +#[test] +fn a_no_links_snapshot_stays_compact() { + let mut emulator = PaneEmulator::new(40, 120, 0); + emulator.process(b"just a small plain screen"); + let snapshot = emulator.screen_snapshot(); + assert!(snapshot.len() < 40 * 120 / 4); + assert_eq!( + snapshot + .windows(3) + .filter(|window| *window == b"\x1b]8") + .count(), + 1, + "a plain snapshot only needs the incoming-link reset" + ); +} + +#[test] +fn unsafe_link_bytes_are_percent_encoded_in_snapshot_fields() { + let link = Hyperlink::new(None::, "https://example.test/\x1b\\\x07".to_owned()); + let mut output = String::new(); + super::snapshot::write_hyperlink(&mut output, Some(&link)); + assert_eq!(output, "\x1b]8;;https://example.test/%1B\\%07\x1b\\"); +} diff --git a/src/runtime/emulator/view.rs b/src/runtime/emulator/view.rs index 77facd0e..78306137 100644 --- a/src/runtime/emulator/view.rs +++ b/src/runtime/emulator/view.rs @@ -32,6 +32,13 @@ impl<'a> ScreenView<'a> { Some(CellView { cell: &grid[point] }) } + /// Return the raw target at zero-based viewport coordinates. OSC 8 cell + /// metadata wins; otherwise bounded visible text detection covers URLs, + /// Markdown destinations, and file-like paths. + pub fn link_at(&self, row: u16, col: u16) -> Option { + super::links::find(self, row, col) + } + /// Live cursor position as (row, col), independent of the scrollback /// offset and of the program's DECTCEM show/hide state — nightcrow /// always exposes the input point of the focused pane. @@ -62,6 +69,14 @@ impl CellView<'_> { .intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) } + pub(super) fn is_wrap_line(&self) -> bool { + self.cell.flags.contains(Flags::WRAPLINE) + } + + pub(super) fn hyperlink(&self) -> Option { + self.cell.hyperlink().map(|link| link.uri().to_owned()) + } + /// Append the cell's visible contents (base char plus any zero-width /// combining chars) to `out`. pub fn append_contents(&self, out: &mut String) { From 0d9c0794f3f82e0749dd2e7d93cc955c8d96846c Mon Sep 17 00:00:00 2001 From: whackur Date: Tue, 8 Sep 2026 23:47:56 +0900 Subject: [PATCH 2/3] fix(terminal): validate link targets and editor launches --- src/platform/link_target.rs | 18 +--- src/platform/link_target_paths.rs | 22 ++++ src/platform/links.rs | 148 ++++++++++++++++++--------- src/platform/links_launcher_tests.rs | 54 ++++++++++ src/platform/links_tests.rs | 8 ++ src/platform/mod.rs | 1 + src/runtime/emulator/links.rs | 12 ++- src/runtime/emulator/links_paths.rs | 55 +++++++--- src/runtime/emulator/links_tests.rs | 12 +++ 9 files changed, 249 insertions(+), 81 deletions(-) create mode 100644 src/platform/link_target_paths.rs create mode 100644 src/platform/links_launcher_tests.rs diff --git a/src/platform/link_target.rs b/src/platform/link_target.rs index 8e5066e5..618b1549 100644 --- a/src/platform/link_target.rs +++ b/src/platform/link_target.rs @@ -1,3 +1,4 @@ +use super::link_target_paths::{is_drive_path, is_file_url_prefix, is_forbidden_namespace}; use anyhow::{Context, Result, bail}; use std::path::{Component, Path, PathBuf}; type Location = (Option, Option); @@ -57,7 +58,7 @@ fn parse_web(raw: &str) -> Result { Ok(LinkTarget::Web(raw.to_owned())) } fn parse_file_url(raw: &str) -> Result { - if raw.len() < 7 || !starts_with_scheme(raw, "file") || !raw[7..].starts_with('/') { + if !is_file_url_prefix(raw) || raw.as_bytes().get(7) != Some(&b'/') { bail!("file link must use a local file URI"); } let (encoded_path, fragment) = split_fragment(&raw[7..])?; @@ -280,21 +281,6 @@ fn is_http_scheme(text: &str) -> bool { .get(..8) .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"https://")) } -fn is_drive_path(path: &str) -> bool { - let bytes = path.as_bytes(); - bytes.len() >= 3 - && bytes[0].is_ascii_alphabetic() - && bytes[1] == b':' - && matches!(bytes[2], b'/' | b'\\') -} -fn is_forbidden_namespace(path: &str) -> bool { - path.starts_with(r"\\") - || path.starts_with("//") - || path.starts_with(r"\\?\") - || path.starts_with(r"\\.\") - || path.starts_with("//?/") - || path.starts_with("//./") -} #[cfg(test)] #[path = "links_tests.rs"] mod tests; diff --git a/src/platform/link_target_paths.rs b/src/platform/link_target_paths.rs new file mode 100644 index 00000000..f8c656db --- /dev/null +++ b/src/platform/link_target_paths.rs @@ -0,0 +1,22 @@ +pub(super) fn is_file_url_prefix(text: &str) -> bool { + text.as_bytes() + .get(..7) + .is_some_and(|prefix| prefix.eq_ignore_ascii_case(b"file://")) +} + +pub(super) fn is_drive_path(path: &str) -> bool { + let bytes = path.as_bytes(); + bytes.len() >= 3 + && bytes[0].is_ascii_alphabetic() + && bytes[1] == b':' + && matches!(bytes[2], b'/' | b'\\') +} + +pub(super) fn is_forbidden_namespace(path: &str) -> bool { + path.starts_with(r"\\") + || path.starts_with("//") + || path.starts_with(r"\\?\") + || path.starts_with(r"\\.\") + || path.starts_with("//?/") + || path.starts_with("//./") +} diff --git a/src/platform/links.rs b/src/platform/links.rs index 61063eff..6ac608ec 100644 --- a/src/platform/links.rs +++ b/src/platform/links.rs @@ -1,9 +1,10 @@ //! Platform launchers for already-parsed terminal links. use anyhow::{Context, Result, bail}; +use std::ffi::OsString; use std::path::{Path, PathBuf}; -use std::process::Command; -use std::process::{Child, Stdio}; +use std::process::{Child, Command, Stdio}; +use std::time::Duration; use super::link_target::{LinkTarget, parse_target}; @@ -43,6 +44,8 @@ fn ensure_file(path: &Path) -> Result<()> { enum Editor { Code, #[cfg(not(windows))] + Gedit, + #[cfg(not(windows))] CliLine, #[cfg(windows)] Plain, @@ -50,6 +53,8 @@ enum Editor { MacText, } +const EDITOR_STARTUP_GRACE: Duration = Duration::from_millis(75); + fn open_editor(path: &Path, line: Option, column: Option) -> Result<()> { let location = line.map(|line| match column { Some(column) => format!("{}:{line}:{column}", path.display()), @@ -57,44 +62,12 @@ fn open_editor(path: &Path, line: Option, column: Option) -> Result<() }); let mut last_error = None; for (program, editor) in editor_candidates() { - let mut command = Command::new(program); - match editor { - Editor::Code => { - command.arg("--goto").arg( - location - .as_deref() - .unwrap_or_else(|| path.to_str().unwrap_or_default()), - ); - } - #[cfg(not(windows))] - Editor::CliLine => { - if let Some(line) = line { - command.arg("--line").arg(line.to_string()); - if let Some(column) = column { - command.arg("--column").arg(column.to_string()); - } - } - command.arg(path); - } - #[cfg(windows)] - Editor::Plain => { - command.arg(path); - } - #[cfg(target_os = "macos")] - Editor::MacText => { - command.arg("-t").arg(path); - } + let mut command = Command::new(&program); + for argument in editor_args(editor, path, line, column, location.as_deref()) { + command.arg(argument); } - let result = command - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .spawn(); - match result { - Ok(child) => { - reap_child(child); - return Ok(()); - } + match spawn_editor(&mut command, &program) { + Ok(()) => return Ok(()), Err(error) => last_error = Some(error), } } @@ -128,7 +101,7 @@ fn editor_candidates() -> Vec<(PathBuf, Editor)> { fn editor_candidates() -> Vec<(PathBuf, Editor)> { vec![ (PathBuf::from("code"), Editor::Code), - (PathBuf::from("gedit"), Editor::CliLine), + (PathBuf::from("gedit"), Editor::Gedit), (PathBuf::from("kate"), Editor::CliLine), (PathBuf::from("geany"), Editor::CliLine), #[cfg(target_os = "macos")] @@ -136,9 +109,86 @@ fn editor_candidates() -> Vec<(PathBuf, Editor)> { ] } -fn reap_child(mut child: Child) { - std::thread::spawn(move || { - let _ = child.wait(); +fn editor_args( + editor: Editor, + path: &Path, + line: Option, + column: Option, + location: Option<&str>, +) -> Vec { + #[cfg(windows)] + let _ = (line, column); + match editor { + Editor::Code => vec![ + OsString::from("--goto"), + location + .map(OsString::from) + .unwrap_or_else(|| path.as_os_str().to_owned()), + ], + #[cfg(not(windows))] + Editor::Gedit => { + let mut args = Vec::new(); + if let Some(line) = line { + let location = + column.map_or_else(|| line.to_string(), |column| format!("{line}:{column}")); + args.push(format!("+{location}").into()); + } + args.push(path.as_os_str().to_owned()); + args + } + #[cfg(not(windows))] + Editor::CliLine => { + let mut args = Vec::new(); + if let Some(line) = line { + args.extend([OsString::from("--line"), line.to_string().into()]); + if let Some(column) = column { + args.extend([OsString::from("--column"), column.to_string().into()]); + } + } + args.push(path.as_os_str().to_owned()); + args + } + #[cfg(windows)] + Editor::Plain => vec![path.as_os_str().to_owned()], + #[cfg(target_os = "macos")] + Editor::MacText => vec![OsString::from("-t"), path.as_os_str().to_owned()], + } +} + +fn spawn_editor(command: &mut Command, program: &Path) -> std::io::Result<()> { + let mut child = command + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + std::thread::sleep(EDITOR_STARTUP_GRACE); + match child.try_wait() { + Ok(Some(status)) if status.success() => Ok(()), + Ok(Some(status)) => Err(std::io::Error::other(format!( + "{} exited with {status}", + program.display() + ))), + Ok(None) => { + reap_child(child, program); + Ok(()) + } + Err(error) => { + reap_child(child, program); + Err(error) + } + } +} + +fn reap_child(mut child: Child, program: &Path) { + let program = program.to_owned(); + std::thread::spawn(move || match child.wait() { + Ok(status) if !status.success() => { + tracing::warn!(editor = %program.display(), ?status, "editor exited unsuccessfully") + } + Err(error) => { + tracing::warn!(editor = %program.display(), %error, "waiting for editor failed") + } + _ => {} }); } @@ -180,7 +230,7 @@ fn open_web(url: &str) -> Result<()> { fn open_web(url: &str) -> Result<()> { let mut command = Command::new("open"); command.arg(url); - spawn_detached(&mut command).context("starting macOS URL opener")?; + spawn_detached(&mut command, Path::new("open")).context("starting macOS URL opener")?; Ok(()) } @@ -188,17 +238,21 @@ fn open_web(url: &str) -> Result<()> { fn open_web(url: &str) -> Result<()> { let mut command = Command::new("xdg-open"); command.arg(url); - spawn_detached(&mut command).context("starting xdg-open")?; + spawn_detached(&mut command, Path::new("xdg-open")).context("starting xdg-open")?; Ok(()) } #[cfg(unix)] -fn spawn_detached(command: &mut Command) -> std::io::Result<()> { +fn spawn_detached(command: &mut Command, program: &Path) -> std::io::Result<()> { let child = command .stdin(Stdio::null()) .stdout(Stdio::null()) .stderr(Stdio::null()) .spawn()?; - reap_child(child); + reap_child(child, program); Ok(()) } + +#[cfg(test)] +#[path = "links_launcher_tests.rs"] +mod tests; diff --git a/src/platform/links_launcher_tests.rs b/src/platform/links_launcher_tests.rs new file mode 100644 index 00000000..62bc5fc9 --- /dev/null +++ b/src/platform/links_launcher_tests.rs @@ -0,0 +1,54 @@ +use super::{Editor, editor_args, spawn_editor}; +use std::ffi::OsString; +use std::path::Path; +use std::process::Command; + +#[cfg(not(windows))] +#[test] +fn gedit_uses_plus_line_and_column_arguments() { + let args = editor_args( + Editor::Gedit, + Path::new("src/main.rs"), + Some(12), + Some(7), + None, + ); + assert_eq!( + args, + vec![OsString::from("+12:7"), OsString::from("src/main.rs")] + ); +} + +#[test] +fn code_uses_one_goto_location_argument() { + let args = editor_args( + Editor::Code, + Path::new("src/main.rs"), + Some(12), + Some(7), + Some("src/main.rs:12:7"), + ); + assert_eq!( + args, + vec![OsString::from("--goto"), OsString::from("src/main.rs:12:7")] + ); +} + +#[test] +fn immediate_editor_failure_is_reported_without_running_a_shell() { + if std::env::var_os("NIGHTCROW_LINK_EDITOR_FIXTURE").is_some() { + std::process::exit(97); + } + let executable = std::env::current_exe().expect("test executable"); + let mut command = Command::new(executable); + command + .args([ + "--exact", + "platform::links::tests::immediate_editor_failure_is_reported_without_running_a_shell", + "--nocapture", + ]) + .env("NIGHTCROW_LINK_EDITOR_FIXTURE", "1"); + let error = spawn_editor(&mut command, Path::new("fixture")) + .expect_err("the guarded fixture must fail immediately"); + assert!(error.to_string().contains("exited with")); +} diff --git a/src/platform/links_tests.rs b/src/platform/links_tests.rs index cd9980f6..cb2313d4 100644 --- a/src/platform/links_tests.rs +++ b/src/platform/links_tests.rs @@ -50,6 +50,10 @@ fn rejects_unsafe_or_ambiguous_targets_before_launching() { for target in [ "javascript:alert(1)", "file://server/share/readme.md", + "file:", + "file:😀", + "file:한글", + "file:aa/C:/temp/a.txt", r"\\?\C:\secret.txt", "../secret.txt", "src/file.rs\n:3", @@ -83,6 +87,10 @@ fn parses_windows_drive_paths_and_file_urls() { column: None, } ); + assert!(matches!( + parse_target("FILE:///C:/work/space%20file.rs"), + Ok(LinkTarget::File { .. }) + )); assert!(parse_target(r"C:\work\space.txt:secret").is_err()); } diff --git a/src/platform/mod.rs b/src/platform/mod.rs index d7d4e7de..355af73f 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -3,6 +3,7 @@ pub(crate) mod console; pub(crate) mod fs; mod link_target; +mod link_target_paths; pub(crate) mod links; pub(crate) mod logging; pub(crate) mod paths; diff --git a/src/runtime/emulator/links.rs b/src/runtime/emulator/links.rs index 3241ea6a..12e363f7 100644 --- a/src/runtime/emulator/links.rs +++ b/src/runtime/emulator/links.rs @@ -1,4 +1,4 @@ -use super::links_paths::location_suffix; +use super::links_paths::{has_location_marker, location_suffix}; use super::view::ScreenView; const MAX_LINK_SCAN_BYTES: usize = 16 * 1024; @@ -193,9 +193,17 @@ fn find_path(text: &str, clicked: usize, complete: bool) -> Option { .char_indices() .find(|(_, character)| character.is_whitespace() || character.is_control()) .map_or(text.len(), |(offset, _)| start + offset); + let raw_candidate = &text[start..raw_end]; + if raw_candidate.ends_with(':') && has_location_marker(raw_candidate) { + continue; + } let end = trim_punctuation(text, start, raw_end, false); let candidate = &text[start..end]; - let path_end = location_suffix(candidate).unwrap_or(candidate.len()); + let Some(path_end) = location_suffix(candidate) + .or_else(|| (!has_location_marker(candidate)).then_some(candidate.len())) + else { + continue; + }; let path = &candidate[..path_end]; if !is_file_like_path(path) { continue; diff --git a/src/runtime/emulator/links_paths.rs b/src/runtime/emulator/links_paths.rs index a85dba0f..be5bd4b3 100644 --- a/src/runtime/emulator/links_paths.rs +++ b/src/runtime/emulator/links_paths.rs @@ -8,31 +8,47 @@ pub(super) fn location_suffix(candidate: &str) -> Option { is_hash_suffix(&candidate[hash..]).then_some(hash) } +pub(super) fn has_location_marker(candidate: &str) -> bool { + candidate.contains("#L") + || candidate.char_indices().any(|(offset, character)| { + character == ':' + && candidate + .as_bytes() + .get(offset + 1) + .is_some_and(u8::is_ascii_digit) + }) +} + fn is_line_suffix(suffix: &str) -> bool { let bytes = suffix.as_bytes(); let mut at = 0; if bytes.get(at) != Some(&b':') { return false; } - at = digits_end(bytes, at + 1); - if at == 1 { + let next = digits_end(bytes, at + 1); + if next == at + 1 { return false; } + at = next; if bytes.get(at) == Some(&b':') { - let next = digits_end(bytes, at + 1); - if next == at + 1 { + let before = at + 1; + let next = digits_end(bytes, before); + if next == before { return false; } at = next; } if bytes.get(at) == Some(&b'-') { - at = digits_end(bytes, at + 1); - if at == suffix.len() { + let before = at + 1; + let next = digits_end(bytes, before); + if next == before { return false; } + at = next; if bytes.get(at) == Some(&b':') { - let next = digits_end(bytes, at + 1); - if next == at + 1 { + let before = at + 1; + let next = digits_end(bytes, before); + if next == before { return false; } at = next; @@ -46,31 +62,38 @@ fn is_hash_suffix(suffix: &str) -> bool { if !suffix.starts_with("#L") { return false; } - let mut at = digits_end(bytes, 2); - if at == 2 { + let mut at = 2; + let next = digits_end(bytes, at); + if next == at { return false; } + at = next; if bytes.get(at) == Some(&b'C') { - at = digits_end(bytes, at + 1); - if at == suffix.len() { + let before = at + 1; + let next = digits_end(bytes, before); + if next == before { return false; } + at = next; } if bytes.get(at) == Some(&b'-') { at += 1; if bytes.get(at) == Some(&b'L') { at += 1; } - let line_end = digits_end(bytes, at); - if line_end == at { + let line_start = at; + let line_end = digits_end(bytes, line_start); + if line_end == line_start { return false; } at = line_end; if bytes.get(at) == Some(&b'C') { - at = digits_end(bytes, at + 1); - if at == line_end + 1 { + let before = at + 1; + let next = digits_end(bytes, before); + if next == before { return false; } + at = next; } } at == bytes.len() diff --git a/src/runtime/emulator/links_tests.rs b/src/runtime/emulator/links_tests.rs index 3ef6150e..6bf19076 100644 --- a/src/runtime/emulator/links_tests.rs +++ b/src/runtime/emulator/links_tests.rs @@ -99,6 +99,18 @@ fn file_like_names_without_locations_are_detected_but_bare_words_are_not() { assert_eq!(emulator.view().link_at(0, 18), None); } +#[test] +fn filename_line_ranges_and_hash_columns_are_detected_without_truncation() { + for input in ["README.md:12-14", "README.md:12:3-14", "README.md#L12C3"] { + link_at(input, input, input); + } + for input in ["README.md:12-", "README.md:12:", "README.md#L12C"] { + let mut emulator = PaneEmulator::new(2, 80, 0); + emulator.process(input.as_bytes()); + assert_eq!(emulator.view().link_at(0, 0), None, "{input}"); + } +} + #[test] fn scrollback_uses_viewport_coordinates() { let mut emulator = PaneEmulator::new(2, 30, 8); From bdfbba08c24a4b50664d4d152948e59d86ceacfe Mon Sep 17 00:00:00 2001 From: whackur Date: Tue, 8 Sep 2026 23:59:52 +0900 Subject: [PATCH 3/3] chore: prepare release v0.1.3 --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- plugins/nightcrow-recovery/Cargo.toml | 2 +- viewer-ui/package-lock.json | 4 ++-- viewer-ui/package.json | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2fb8a30e..c059e536 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1274,7 +1274,7 @@ dependencies = [ [[package]] name = "nightcrow" -version = "0.1.2" +version = "0.1.3" dependencies = [ "alacritty_terminal", "anyhow", @@ -1311,7 +1311,7 @@ dependencies = [ [[package]] name = "nightcrow-recovery" -version = "0.1.2" +version = "0.1.3" dependencies = [ "anyhow", "serde", diff --git a/Cargo.toml b/Cargo.toml index 4b7b2c2f..2e231566 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ members = [".", "plugins/nightcrow-recovery"] [package] name = "nightcrow" -version = "0.1.2" +version = "0.1.3" edition = "2024" description = "Agent-adjacent terminal workbench — git diff viewer + multi-terminal panes for running CLIs next to your code" license = "Apache-2.0" diff --git a/plugins/nightcrow-recovery/Cargo.toml b/plugins/nightcrow-recovery/Cargo.toml index e02212af..96061580 100644 --- a/plugins/nightcrow-recovery/Cargo.toml +++ b/plugins/nightcrow-recovery/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "nightcrow-recovery" -version = "0.1.2" +version = "0.1.3" edition = "2024" description = "nightcrow plugin: notices a coding CLI hit its usage limit and resumes it when the limit resets" license = "Apache-2.0" diff --git a/viewer-ui/package-lock.json b/viewer-ui/package-lock.json index 93b1a4f4..b6c4e5fc 100644 --- a/viewer-ui/package-lock.json +++ b/viewer-ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "nightcrow-viewer-ui", - "version": "0.1.2", + "version": "0.1.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "nightcrow-viewer-ui", - "version": "0.1.2", + "version": "0.1.3", "dependencies": { "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", diff --git a/viewer-ui/package.json b/viewer-ui/package.json index 7b30bca8..631a487d 100644 --- a/viewer-ui/package.json +++ b/viewer-ui/package.json @@ -1,7 +1,7 @@ { "name": "nightcrow-viewer-ui", "private": true, - "version": "0.1.2", + "version": "0.1.3", "type": "module", "scripts": { "dev": "vite",