aboutsummaryrefslogtreecommitdiff
path: root/src/app/actions.rs
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/app/actions.rs
parent3750e0bae21c54da3947d337584f180a21d0bc67 (diff)
feat: open pane links with ctrl-click
refs #307
Diffstat (limited to 'src/app/actions.rs')
-rw-r--r--src/app/actions.rs86
1 files changed, 86 insertions, 0 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"));