diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-26 18:26:12 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-26 18:26:12 +0300 |
| commit | af399094f5d24f157e35aaa657ed5ad5c44e5325 (patch) | |
| tree | 2f44cc902312fbe8c405a972b9a438ca6bead27c /src/app/input | |
| parent | 3750e0bae21c54da3947d337584f180a21d0bc67 (diff) | |
feat: open pane links with ctrl-click
refs #307
Diffstat (limited to 'src/app/input')
| -rw-r--r-- | src/app/input/mod.rs | 38 | ||||
| -rw-r--r-- | src/app/input/terminal.rs | 72 |
2 files changed, 109 insertions, 1 deletions
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index e145e628..8ba8e4bd 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -1,6 +1,6 @@ //! Input handling — translates crossterm key/mouse events into state mutations. -use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind}; use crate::app::PaneClickState; use crate::input::TerminalKey; @@ -195,6 +195,10 @@ impl App { } } + if self.handle_modified_url_click(mouse) { + return; + } + let handled_pane_double_click = self.handle_pane_double_click(mouse); let previous_agent_panel_scope = self.state.agent_panel_scope; @@ -248,6 +252,33 @@ impl App { } } + fn handle_modified_url_click(&mut self, mouse: MouseEvent) -> bool { + if self.state.mode != Mode::Terminal + || !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left)) + || !mouse.modifiers.contains(KeyModifiers::CONTROL) + { + return false; + } + + let Some(info) = self.state.pane_at(mouse.column, mouse.row).cloned() else { + return false; + }; + let viewport_row = mouse.row.saturating_sub(info.inner_rect.y); + let col = mouse.column.saturating_sub(info.inner_rect.x); + let Some(url) = + self.state + .url_at_pane_cell(&self.terminal_runtimes, info.id, viewport_row, col) + else { + return false; + }; + + self.last_pane_click = None; + if let Err(err) = crate::platform::open_url(&url) { + tracing::warn!(err = %err, url = %url, "failed to open pane URL"); + } + true + } + fn handle_pane_double_click(&mut self, mouse: MouseEvent) -> bool { // A pane press stops being a double-click candidate once it becomes // a drag or completes as a real text selection. @@ -291,6 +322,11 @@ impl App { return None; } + if !mouse.modifiers.is_empty() { + self.last_pane_click = None; + return None; + } + if self.state.mode != Mode::Terminal { self.last_pane_click = None; return None; diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 71728be4..0a35c3be 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -232,6 +232,20 @@ mod tests { app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); } + fn modified_mouse( + kind: MouseEventKind, + col: u16, + row: u16, + modifiers: KeyModifiers, + ) -> crossterm::event::MouseEvent { + crossterm::event::MouseEvent { + kind, + column: col, + row, + modifiers, + } + } + fn clipboard_write_content(app: &mut App) -> Vec<u8> { match app.event_rx.try_recv().expect("clipboard write event") { AppEvent::ClipboardWrite { content } => content, @@ -433,6 +447,64 @@ mod tests { } #[tokio::test] + async fn modified_pane_click_does_not_seed_double_click_copy() { + let (mut app, info) = app_with_screen_bytes(b"alpha beta"); + let col = info.inner_rect.x + 7; + let row = info.inner_rect.y; + + app.handle_mouse(modified_mouse( + MouseEventKind::Down(MouseButton::Left), + col, + row, + KeyModifiers::CONTROL, + )); + app.handle_mouse(mouse(MouseEventKind::Up(MouseButton::Left), col, row)); + app.handle_mouse(mouse(MouseEventKind::Down(MouseButton::Left), col, row)); + + assert!(app.event_rx.try_recv().is_err()); + assert!(app.selection_highlight_clear_deadline.is_none()); + } + + #[tokio::test] + async fn pane_cell_url_resolver_finds_visible_url() { + let line = "see https://example.com/pr/307."; + let (app, info) = app_with_screen_bytes(line.as_bytes()); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let col = line.find("example").expect("url host") as u16; + + assert_eq!( + app.state + .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, col) + .as_deref(), + Some("https://example.com/pr/307") + ); + assert_eq!( + app.state.url_at_pane_cell( + &app.terminal_runtimes, + pane_id, + 0, + info.inner_rect.width - 1 + ), + None + ); + } + + #[tokio::test] + async fn pane_cell_url_resolver_prefers_osc8_hyperlink() { + let (app, _info) = app_with_screen_bytes( + b"\x1b]8;;https://example.com/hidden-target\x1b\\label\x1b]8;;\x1b\\", + ); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + + assert_eq!( + app.state + .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, 1) + .as_deref(), + Some("https://example.com/hidden-target") + ); + } + + #[tokio::test] async fn double_click_highlight_clears_after_short_delay() { let (mut app, info) = app_with_screen_bytes(b"copied"); let col = info.inner_rect.x + 2; |
