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 | |
| parent | 3750e0bae21c54da3947d337584f180a21d0bc67 (diff) | |
feat: open pane links with ctrl-click
refs #307
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/actions.rs | 86 | ||||
| -rw-r--r-- | src/app/input/mod.rs | 38 | ||||
| -rw-r--r-- | src/app/input/terminal.rs | 72 | ||||
| -rw-r--r-- | src/platform/fallback.rs | 8 | ||||
| -rw-r--r-- | src/platform/linux.rs | 10 | ||||
| -rw-r--r-- | src/platform/macos.rs | 10 |
6 files changed, 223 insertions, 1 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index ab2539bf..9b18eef7 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1530,6 +1530,44 @@ impl AppState { true } + pub(crate) fn url_at_pane_cell( + &self, + terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, + pane_id: crate::layout::PaneId, + viewport_row: u16, + col: u16, + ) -> Option<String> { + let ws_idx = self + .active + .filter(|idx| self.workspaces.get(*idx).is_some())?; + let info = self.pane_info_by_id(pane_id)?; + if viewport_row >= info.inner_rect.height || col >= info.inner_rect.width { + return None; + } + + let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)?; + let screen_col = info.inner_rect.x.saturating_add(col); + let screen_row = info.inner_rect.y.saturating_add(viewport_row); + if let Some((_, _, uri)) = rt + .visible_hyperlinks(info.inner_rect) + .into_iter() + .find(|((x, y), _, _)| *x == screen_col && *y == screen_row) + { + return safe_web_url(&uri).map(str::to_owned); + } + + let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); + let row_selection = Selection::range( + pane_id, + viewport_row, + 0, + info.inner_rect.width.saturating_sub(1), + metrics, + ); + let row_text = rt.extract_selection(&row_selection)?; + url_at_column(&row_text, col).map(str::to_owned) + } + pub fn copy_selection(&mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry) { let mut sel = match self.selection.take() { Some(sel) => sel, @@ -1558,6 +1596,10 @@ impl AppState { } } +pub(crate) fn safe_web_url(url: &str) -> Option<&str> { + (url.starts_with("http://") || url.starts_with("https://")).then_some(url) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct TextCell { ch: char, @@ -1601,6 +1643,15 @@ fn word_bounds_at_column(row: &str, col: u16) -> Option<(u16, u16)> { Some(span.columns(&cells)) } +pub(crate) fn url_at_column(row: &str, col: u16) -> Option<&str> { + let cells = text_cells(row); + let clicked_idx = cell_index_at_column(&cells, col)?; + let span = url_span_at_column(&cells, clicked_idx)?; + let start_byte = byte_index_for_cell(row, span.start); + let end_byte = byte_index_after_cell(row, span.end); + safe_web_url(row.get(start_byte..end_byte)?) +} + fn token_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option<CellSpan> { if is_word_separator(cells[clicked_idx].ch) { return None; @@ -1647,6 +1698,20 @@ fn cell_index_at_column(cells: &[TextCell], col: u16) -> Option<usize> { .position(|cell| cell.start_col <= col && col <= cell.end_col) } +fn byte_index_for_cell(row: &str, cell_idx: usize) -> usize { + row.char_indices() + .nth(cell_idx) + .map(|(idx, _)| idx) + .unwrap_or(row.len()) +} + +fn byte_index_after_cell(row: &str, cell_idx: usize) -> usize { + row.char_indices() + .nth(cell_idx.saturating_add(1)) + .map(|(idx, _)| idx) + .unwrap_or(row.len()) +} + fn url_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option<CellSpan> { let mut start = 0; while start < cells.len() { @@ -2175,6 +2240,10 @@ mod tests { Some(text_in_cell_range(row, start, end)) } + fn selected_url<'a>(row: &'a str, click: &str) -> Option<&'a str> { + url_at_column(row, col_of(row, click)) + } + fn text_in_cell_range(row: &str, start_col: u16, end_col: u16) -> String { text_cells(row) .into_iter() @@ -2344,6 +2413,23 @@ mod tests { } #[test] + fn url_at_column_returns_safe_visible_url_only() { + assert_eq!( + selected_url("see https://example.com/a(b)c.", "example"), + Some("https://example.com/a(b)c") + ); + assert_eq!( + selected_url("[docs](https://example.com/docs),", "example"), + Some("https://example.com/docs") + ); + assert_eq!( + selected_url("[docs](https://example.com/docs)", "docs"), + None + ); + assert_eq!(selected_url("open file:///tmp/report", "file"), None); + } + + #[test] fn navigator_rows_show_tab_nodes_only_for_multi_tab_workspaces() { let mut state = app_with_workspaces(&["single", "multi"]); state.workspaces[1].test_add_tab(Some("tests")); 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; diff --git a/src/platform/fallback.rs b/src/platform/fallback.rs index a8f9d8ce..d7360f9b 100644 --- a/src/platform/fallback.rs +++ b/src/platform/fallback.rs @@ -41,6 +41,14 @@ pub fn write_clipboard(_bytes: &[u8]) -> bool { } /// Unsupported platform stub. +pub fn open_url(_url: &str) -> std::io::Result<()> { + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "opening URLs is not supported on this platform", + )) +} + +/// Unsupported platform stub. pub fn read_clipboard_image() -> Option<ClipboardImage> { None } diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 00f4b906..2ea26559 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -179,6 +179,16 @@ pub fn write_clipboard(bytes: &[u8]) -> bool { false } +pub fn open_url(url: &str) -> std::io::Result<()> { + Command::new("xdg-open") + .arg(url) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + Ok(()) +} + pub fn read_clipboard_image() -> Option<ClipboardImage> { for (mime, extension) in [ ("image/png", "png"), diff --git a/src/platform/macos.rs b/src/platform/macos.rs index 354a6b3c..3ff23251 100644 --- a/src/platform/macos.rs +++ b/src/platform/macos.rs @@ -228,6 +228,16 @@ pub fn write_clipboard(bytes: &[u8]) -> bool { ) } +pub fn open_url(url: &str) -> std::io::Result<()> { + Command::new("open") + .arg(url) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn()?; + Ok(()) +} + pub fn read_clipboard_image() -> Option<ClipboardImage> { let path = std::env::temp_dir().join(format!( "herdr-clipboard-image-{}-{}.png", |
