aboutsummaryrefslogtreecommitdiff
path: root/src/app/actions.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/app/actions.rs')
-rw-r--r--src/app/actions.rs486
1 files changed, 483 insertions, 3 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 9132d7bd..65142fd1 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -6,8 +6,10 @@ use tracing::{info, warn};
use crate::detect::{Agent, AgentState};
use crate::events::AppEvent;
use crate::layout::{find_in_direction, NavDirection, PaneId};
+use crate::selection::Selection;
use crate::terminal::{EffectiveStateChange, TerminalStateMutation};
use crate::workspace::WorkspaceGitStatus;
+use unicode_width::UnicodeWidthChar;
use super::state::{
AppState, Mode, NavigatorRow, NavigatorStateFilter, NavigatorTarget, ToastKind,
@@ -1256,6 +1258,76 @@ impl AppState {
self.selection_autoscroll = None;
}
+ pub(crate) fn copy_word_at_pane_cell(
+ &mut self,
+ terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
+ pane_id: crate::layout::PaneId,
+ viewport_row: u16,
+ col: u16,
+ ) -> bool {
+ // Resolve the active pane cell the double-click landed on.
+ let Some(ws_idx) = self
+ .active
+ .filter(|idx| self.workspaces.get(*idx).is_some())
+ else {
+ return false;
+ };
+
+ let Some(info) = self.pane_info_by_id(pane_id) else {
+ return false;
+ };
+ if viewport_row >= info.inner_rect.height || col >= info.inner_rect.width {
+ return false;
+ }
+
+ // Leave mouse input to terminal apps that requested it.
+ let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)
+ else {
+ return false;
+ };
+ if rt
+ .input_state()
+ .is_some_and(crate::pane::InputState::mouse_reporting_enabled)
+ {
+ return false;
+ }
+
+ // Read the visible row and identify the clicked token bounds.
+ 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 Some(row_text) = rt.extract_selection(&row_selection) else {
+ return false;
+ };
+ let Some((start_col, end_col)) = word_bounds_at_column(&row_text, col) else {
+ return false;
+ };
+
+ // Copy the token and keep its selection visible as short-lived feedback.
+ let mut selection = Selection::range(pane_id, viewport_row, start_col, end_col, metrics);
+ if !selection.finish() {
+ return false;
+ }
+
+ let Some(text) = rt
+ .extract_selection(&selection)
+ .filter(|text| !text.is_empty())
+ else {
+ self.clear_selection();
+ return false;
+ };
+ self.request_clipboard_write = Some(text.into_bytes());
+ self.selection = Some(selection);
+ self.selection_autoscroll = None;
+ info!("copied double-clicked token to clipboard");
+ true
+ }
+
pub fn copy_selection(&mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry) {
let mut sel = match self.selection.take() {
Some(sel) => sel,
@@ -1273,7 +1345,6 @@ impl AppState {
let text = self
.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, sel.pane_id)
.and_then(|rt| rt.extract_selection(&sel));
-
if let Some(text) = text {
if !text.is_empty() {
self.request_clipboard_write = Some(text.into_bytes());
@@ -1281,9 +1352,245 @@ impl AppState {
}
}
- self.selection = None;
- self.selection_autoscroll = None;
+ self.clear_selection();
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct TextCell {
+ ch: char,
+ start_col: u16,
+ end_col: u16,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct CellSpan {
+ start: usize,
+ end: usize,
+}
+
+impl CellSpan {
+ fn contains(self, idx: usize) -> bool {
+ idx >= self.start && idx <= self.end
+ }
+
+ fn columns(self, cells: &[TextCell]) -> (u16, u16) {
+ (cells[self.start].start_col, cells[self.end].end_col)
+ }
+}
+
+/// Finds the terminal display-column bounds for the token under a double-click.
+///
+/// The algorithm first maps text to terminal cells so wide characters and
+/// zero-width marks use display columns, then prefers structured spans that
+/// users expect to copy whole (URLs and quoted paths), and finally falls back
+/// to a separator-delimited token.
+fn word_bounds_at_column(row: &str, col: u16) -> Option<(u16, u16)> {
+ // Map the row into display cells before doing any word-boundary work.
+ let cells = text_cells(row);
+ let clicked_idx = cell_index_at_column(&cells, col)?;
+
+ // Prefer spans that can legally include punctuation or spaces.
+ let span = url_span_at_column(&cells, clicked_idx)
+ .or_else(|| quoted_path_span_at_column(&cells, clicked_idx))
+ .or_else(|| token_span_at_column(&cells, clicked_idx))?;
+
+ // Convert the internal cell span back to inclusive terminal columns.
+ Some(span.columns(&cells))
+}
+
+fn token_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option<CellSpan> {
+ if is_word_separator(cells[clicked_idx].ch) {
+ return None;
+ }
+
+ let mut start = clicked_idx;
+ while start > 0 && !is_word_separator(cells[start - 1].ch) {
+ start -= 1;
+ }
+
+ let mut end = clicked_idx;
+ while end + 1 < cells.len() && !is_word_separator(cells[end + 1].ch) {
+ end += 1;
}
+
+ trim_token_edges(cells, CellSpan { start, end }).filter(|span| span.contains(clicked_idx))
+}
+
+fn text_cells(row: &str) -> Vec<TextCell> {
+ let mut next_col = 0u16;
+ row.chars()
+ .map(|ch| {
+ let width = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
+ let start_col = if width == 0 {
+ next_col.saturating_sub(1)
+ } else {
+ next_col
+ };
+ if width > 0 {
+ next_col = next_col.saturating_add(width);
+ }
+ TextCell {
+ ch,
+ start_col,
+ end_col: next_col.saturating_sub(1),
+ }
+ })
+ .collect()
+}
+
+fn cell_index_at_column(cells: &[TextCell], col: u16) -> Option<usize> {
+ cells
+ .iter()
+ .position(|cell| cell.start_col <= col && col <= cell.end_col)
+}
+
+fn url_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option<CellSpan> {
+ let mut start = 0;
+ while start < cells.len() {
+ if starts_with_chars(&cells[start..], "http://")
+ || starts_with_chars(&cells[start..], "https://")
+ {
+ let mut end = start;
+ while end + 1 < cells.len() && !cells[end + 1].ch.is_whitespace() {
+ end += 1;
+ }
+ if clicked_idx >= start && clicked_idx <= end {
+ let span = trim_url_edges(cells, CellSpan { start, end })?;
+ return span.contains(clicked_idx).then_some(span);
+ }
+ start = end + 1;
+ } else {
+ start += 1;
+ }
+ }
+ None
+}
+
+fn trim_url_edges(cells: &[TextCell], span: CellSpan) -> Option<CellSpan> {
+ let start = span.start;
+ let mut end = span.end;
+ while start <= end && should_trim_trailing_url_cell(cells, start, end) {
+ if end == 0 {
+ return None;
+ }
+ end -= 1;
+ }
+ (start <= end).then_some(CellSpan { start, end })
+}
+
+fn should_trim_trailing_url_cell(cells: &[TextCell], start: usize, end: usize) -> bool {
+ match cells[end].ch {
+ '"' | '\'' | '`' | '.' | ',' | ';' | ':' | '!' | '?' => true,
+ ')' => !trailing_url_closer_is_balanced(cells, start, end, '(', ')'),
+ ']' => !trailing_url_closer_is_balanced(cells, start, end, '[', ']'),
+ '}' => !trailing_url_closer_is_balanced(cells, start, end, '{', '}'),
+ _ => false,
+ }
+}
+
+fn trailing_url_closer_is_balanced(
+ cells: &[TextCell],
+ start: usize,
+ end: usize,
+ open: char,
+ close: char,
+) -> bool {
+ let mut balance = 0i32;
+ for cell in &cells[start..end] {
+ if cell.ch == open {
+ balance += 1;
+ } else if cell.ch == close {
+ balance -= 1;
+ }
+ }
+ balance > 0
+}
+
+fn quoted_path_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option<CellSpan> {
+ let clicked = cells.get(clicked_idx)?.ch;
+ if clicked == '"' || clicked == '\'' || clicked == '`' {
+ return None;
+ }
+
+ for quote in ['"', '\'', '`'] {
+ let mut start = None;
+ for (idx, cell) in cells.iter().copied().enumerate() {
+ let ch = cell.ch;
+ if ch != quote || is_escaped(cells, idx) {
+ continue;
+ }
+ if let Some(open) = start {
+ if clicked_idx > open
+ && clicked_idx < idx
+ && cells[open + 1..idx].iter().any(|cell| cell.ch == '/')
+ {
+ return Some(CellSpan {
+ start: open + 1,
+ end: idx - 1,
+ });
+ }
+ start = None;
+ } else {
+ start = Some(idx);
+ }
+ }
+ }
+ None
+}
+
+fn is_escaped(cells: &[TextCell], idx: usize) -> bool {
+ let mut slashes = 0;
+ let mut cursor = idx;
+ while cursor > 0 && cells[cursor - 1].ch == '\\' {
+ slashes += 1;
+ cursor -= 1;
+ }
+ slashes % 2 == 1
+}
+
+fn starts_with_chars(cells: &[TextCell], prefix: &str) -> bool {
+ prefix
+ .chars()
+ .enumerate()
+ .all(|(idx, expected)| cells.get(idx).is_some_and(|cell| cell.ch == expected))
+}
+
+fn is_word_separator(ch: char) -> bool {
+ ch.is_whitespace()
+ || matches!(
+ ch,
+ '|' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' | '!'
+ )
+}
+
+fn trim_token_edges(cells: &[TextCell], span: CellSpan) -> Option<CellSpan> {
+ let mut start = span.start;
+ let mut end = span.end;
+ while start <= end && is_leading_token_wrapper(cells[start].ch) {
+ start += 1;
+ }
+ if start < end && cells[end].ch == '$' && is_trailing_token_wrapper(cells[end - 1].ch) {
+ end -= 1;
+ }
+ while start <= end && is_trailing_token_wrapper(cells[end].ch) {
+ if end == 0 {
+ return None;
+ }
+ end -= 1;
+ }
+ (start <= end).then_some(CellSpan { start, end })
+}
+
+fn is_leading_token_wrapper(ch: char) -> bool {
+ matches!(ch, '(' | '[' | '{' | '<' | '"' | '\'' | '`')
+}
+
+fn is_trailing_token_wrapper(ch: char) -> bool {
+ matches!(
+ ch,
+ ')' | ']' | '}' | '>' | '"' | '\'' | '`' | '.' | ',' | ';' | ':' | '!' | '?'
+ )
}
// ---------------------------------------------------------------------------
@@ -1625,6 +1932,179 @@ mod tests {
});
}
+ fn selected_word(row: &str, col: u16) -> Option<String> {
+ let (start, end) = word_bounds_at_column(row, col)?;
+ Some(text_in_cell_range(row, start, end))
+ }
+
+ fn text_in_cell_range(row: &str, start_col: u16, end_col: u16) -> String {
+ text_cells(row)
+ .into_iter()
+ .filter(|cell| cell.start_col >= start_col && cell.end_col <= end_col)
+ .map(|cell| cell.ch)
+ .collect()
+ }
+
+ fn col_of(row: &str, needle: &str) -> u16 {
+ let byte_idx = row
+ .find(needle)
+ .unwrap_or_else(|| panic!("{needle:?} not found in {row:?}"));
+ let prefix = &row[..byte_idx];
+ prefix
+ .chars()
+ .map(|ch| UnicodeWidthChar::width(ch).unwrap_or(0) as u16)
+ .sum()
+ }
+
+ fn assert_selects(row: &str, click: &str, expected: &str) {
+ assert_eq!(
+ selected_word(row, col_of(row, click)).as_deref(),
+ Some(expected),
+ "row={row:?}, click={click:?}"
+ );
+ }
+
+ fn assert_selects_nothing(row: &str, click: &str) {
+ assert_eq!(
+ selected_word(row, col_of(row, click)),
+ None,
+ "row={row:?}, click={click:?}"
+ );
+ }
+
+ #[test]
+ fn double_click_word_bounds_cover_terminal_text() {
+ let cases = [
+ (
+ "see https://example.com/a-b_c?q=x@y.",
+ "example.com",
+ "https://example.com/a-b_c?q=x@y",
+ ),
+ (
+ "open \"https://example.com/a,b;c?q=x\";",
+ "example.com",
+ "https://example.com/a,b;c?q=x",
+ ),
+ (
+ "see https://en.wikipedia.org/wiki/Foo_(bar_(baz)),",
+ "wikipedia",
+ "https://en.wikipedia.org/wiki/Foo_(bar_(baz))",
+ ),
+ (
+ "see https://example.com/a(b[c{d}e]f),",
+ "example.com",
+ "https://example.com/a(b[c{d}e]f)",
+ ),
+ (
+ "see (https://example.com/a(b(c)d)))",
+ "example.com",
+ "https://example.com/a(b(c)d)",
+ ),
+ (
+ "open /tmp/foo-bar/baz_qux/",
+ "foo-bar",
+ "/tmp/foo-bar/baz_qux/",
+ ),
+ (
+ "open ./src/app/actions.rs:795",
+ "actions",
+ "./src/app/actions.rs:795",
+ ),
+ (
+ "open ../herdr-worktrees/issue-1",
+ "herdr",
+ "../herdr-worktrees/issue-1",
+ ),
+ (
+ "edit src/app/actions.rs,then",
+ "actions",
+ "src/app/actions.rs",
+ ),
+ (
+ "cat \"/tmp/build output/log.txt\"",
+ "output",
+ "/tmp/build output/log.txt",
+ ),
+ (
+ "cat '/Users/me/Library/Application Support/app/config.json'",
+ "Support",
+ "/Users/me/Library/Application Support/app/config.json",
+ ),
+ ("echo 你好-world done", "好", "你好-world"),
+ ("先跑 cargo test", "cargo", "cargo"),
+ (
+ "export PATH=$HOME/.cargo/bin:$PATH",
+ "$HOME",
+ "PATH=$HOME/.cargo/bin:$PATH",
+ ),
+ (
+ "git checkout feature/foo-bar_baz",
+ "foo",
+ "feature/foo-bar_baz",
+ ),
+ ("refs #123 and @owner/name", "#123", "#123"),
+ ("refs #123 and @owner/name", "owner", "@owner/name"),
+ ("cargo test --package=herdr", "--package", "--package=herdr"),
+ (
+ "cargo test app::actions::tests",
+ "app::",
+ "app::actions::tests",
+ ),
+ (
+ "image ghcr.io/org/app:latest",
+ "ghcr",
+ "ghcr.io/org/app:latest",
+ ),
+ ("ERROR [worker-1] request_id=abc-123", "worker", "worker-1"),
+ (
+ "tmux|newhoo|fixhoo|newmoo|notification|window_bell|herdr",
+ "newhoo",
+ "newhoo",
+ ),
+ (
+ "render_status_line(app, area)",
+ "render",
+ "render_status_line",
+ ),
+ ("render_status_line(app, area)", "app", "app"),
+ ("render_status_line(app, area)", "area", "area"),
+ ("if !enabled {", "enabled", "enabled"),
+ ("println!(\"hi\")", "println", "println"),
+ ("( master)$", "master", "master"),
+ ("regex foo$", "foo", "foo$"),
+ ];
+
+ for (row, click, expected) in cases {
+ assert_selects(row, click, expected);
+ }
+
+ let row = "echo 你好-world done";
+ assert_eq!(
+ selected_word(row, col_of(row, "好") + 1).as_deref(),
+ Some("你好-world")
+ );
+ }
+
+ #[test]
+ fn double_click_word_bounds_ignore_delimiters() {
+ for (row, click) in [
+ (
+ "tmux|newhoo|fixhoo|newmoo|notification|window_bell|herdr",
+ "|",
+ ),
+ ("alpha,beta;gamma", ","),
+ ("alpha,beta;gamma", ";"),
+ ("render_status_line(app, area)", "("),
+ ("render_status_line(app, area)", ")"),
+ ("if !enabled {", "!"),
+ ("if !enabled {", "{"),
+ ("(done).", "("),
+ ("(done).", "."),
+ ] {
+ assert_selects_nothing(row, click);
+ }
+ }
+
#[test]
fn navigator_rows_show_tab_nodes_only_for_multi_tab_workspaces() {
let mut state = app_with_workspaces(&["single", "multi"]);