From 9ff467bda4aa0fcb79ccb4a04392b63e37ce7f37 Mon Sep 17 00:00:00 2001 From: akbash Date: Tue, 25 Aug 2026 22:02:37 +0300 Subject: [PATCH] fix: route osc 8 file links to plugin handlers (#2942) refs #2941 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> --- docs/next/CHANGELOG.md | 1 + src/app/actions.rs | 2 +- src/app/input/mod.rs | 17 +++++++++---- src/app/input/terminal.rs | 51 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 65 insertions(+), 6 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 48aa4a3a..886ffca8 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -23,6 +23,7 @@ - Claude Code panes now remain working while MCP tasks continue in the background after a turn ends. (#3090) - Tab bar status commands now remove ESC-prefixed terminal control sequences instead of displaying their sequence bodies as text. (#3001) - Unix plugin pane commands now default `PWD` to their resolved working directory, so direct popup tools open at explicit `--cwd` paths while preserving caller-provided `PWD` values. (#2984) +- Plugin link handlers now receive matching OSC 8 `file://` clicks while unmatched file links remain excluded from the system URL opener. (#2941) ## [0.8.2] - 2026-08-19 diff --git a/src/app/actions.rs b/src/app/actions.rs index 6cbd9cc4..e3fc2639 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -2245,7 +2245,7 @@ impl AppState { .into_iter() .find(|((x, y), _, _)| *x == screen_col && *y == screen_row) { - return safe_web_url(&uri).map(str::to_owned); + return Some(uri); } let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index 597d9296..10edc0c0 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -595,14 +595,21 @@ impl App { return false; }; - self.last_pane_click = None; - self.pending_url_click_sources.insert(source_id); - match self.invoke_plugin_link_handler_for_url(&url, info.id) { - Ok(true) => return true, - Ok(false) => {} + let plugin_handled = match self.invoke_plugin_link_handler_for_url(&url, info.id) { + Ok(handled) => handled, Err(err) => { tracing::warn!(err = %err, url = %url, "failed to invoke plugin link handler"); + false } + }; + if !plugin_handled && crate::app::actions::safe_web_url(&url).is_none() { + return false; + } + + self.last_pane_click = None; + self.pending_url_click_sources.insert(source_id); + if plugin_handled { + return true; } match open_url(&url) { Ok(Some(child)) => self.detached_process_children.push(child), diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 6b3d3537..0b910621 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -1042,6 +1042,57 @@ mod tests { ); } + #[cfg(unix)] + #[tokio::test] + async fn ctrl_click_osc8_file_url_invokes_plugin_link_handler() { + let uri = "file:///tmp/herdr-file-repro.txt"; + let screen = format!("\x1b]8;;{uri}\x1b\\FILE\x1b]8;;\x1b\\"); + let (mut app, info) = app_with_screen_bytes(screen.as_bytes()); + install_test_link_handler(&mut app); + app.state + .installed_plugins + .get_mut("example.links") + .expect("test plugin") + .link_handlers[0] + .pattern = r"^file:///tmp/herdr-file-repro\.txt$".into(); + + let handled = app.handle_modified_url_click_with( + 41, + modified_mouse( + MouseEventKind::Down(MouseButton::Left), + info.inner_rect.x + 1, + info.inner_rect.y, + KeyModifiers::CONTROL, + ), + |_| panic!("matched file link should not use the system URL opener"), + ); + + assert!(handled); + let log = app + .state + .plugin_command_logs + .last() + .expect("ctrl-click should start plugin link handler"); + assert_eq!(log.plugin_id, "example.links"); + assert_eq!(log.action_id.as_deref(), Some("open")); + + let (mut unmatched_app, unmatched_info) = app_with_screen_bytes(screen.as_bytes()); + install_test_link_handler(&mut unmatched_app); + let unmatched_handled = unmatched_app.handle_modified_url_click_with( + 42, + modified_mouse( + MouseEventKind::Down(MouseButton::Left), + unmatched_info.inner_rect.x + 1, + unmatched_info.inner_rect.y, + KeyModifiers::CONTROL, + ), + |_| panic!("unmatched file link should not use the system URL opener"), + ); + + assert!(!unmatched_handled); + assert!(unmatched_app.state.plugin_command_logs.is_empty()); + } + #[cfg(unix)] #[tokio::test] async fn ctrl_click_url_invokes_plugin_link_handler_but_super_click_does_not() {