aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-26 18:26:12 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-26 18:26:12 +0300
commitaf399094f5d24f157e35aaa657ed5ad5c44e5325 (patch)
tree2f44cc902312fbe8c405a972b9a438ca6bead27c /src
parent3750e0bae21c54da3947d337584f180a21d0bc67 (diff)
feat: open pane links with ctrl-click
refs #307
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs86
-rw-r--r--src/app/input/mod.rs38
-rw-r--r--src/app/input/terminal.rs72
-rw-r--r--src/platform/fallback.rs8
-rw-r--r--src/platform/linux.rs10
-rw-r--r--src/platform/macos.rs10
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",