diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-13 23:05:30 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-13 23:05:30 +0300 |
| commit | f8e59221cbe5117f8f691a36affa374f5a986a9f (patch) | |
| tree | 3b2792683eff251b061fbbad7343ecffafe91aab /src | |
| parent | 59167658455442ffd2cd5b72a0fa81bcc4bcdf9e (diff) | |
feat: add ctrl-hover highlighting and fix wrapped links (#4074)
refs #1282
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/schema.rs | 2 | ||||
| -rw-r--r-- | src/api/schema/panes.rs | 8 | ||||
| -rw-r--r-- | src/api/schema/response.rs | 3 | ||||
| -rw-r--r-- | src/api/schema/tests.rs | 27 | ||||
| -rw-r--r-- | src/api/server.rs | 1 | ||||
| -rw-r--r-- | src/app/actions.rs | 360 | ||||
| -rw-r--r-- | src/app/api.rs | 13 | ||||
| -rw-r--r-- | src/app/api/plugins/mod.rs | 143 | ||||
| -rw-r--r-- | src/client/shell.rs | 1 | ||||
| -rw-r--r-- | src/client/shell/actions.rs | 4 | ||||
| -rw-r--r-- | src/client/shell/composition.rs | 1 | ||||
| -rw-r--r-- | src/client/shell/input.rs | 2 | ||||
| -rw-r--r-- | src/client/shell/link_hover.rs | 352 | ||||
| -rw-r--r-- | src/client/shell/mouse.rs | 1 | ||||
| -rw-r--r-- | src/client/shell/state.rs | 7 | ||||
| -rw-r--r-- | src/client/shell/surface_patch.rs | 3 | ||||
| -rw-r--r-- | src/client/shell/tests/link_hover.rs | 410 | ||||
| -rw-r--r-- | src/client/shell/tests/mod.rs | 1 | ||||
| -rw-r--r-- | src/client/shell/worktrees.rs | 1 | ||||
| -rw-r--r-- | src/ghostty/bindings.rs | 9 | ||||
| -rw-r--r-- | src/ghostty/mod.rs | 311 | ||||
| -rw-r--r-- | src/pane.rs | 13 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 40 | ||||
| -rw-r--r-- | src/server/client_commands.rs | 20 | ||||
| -rw-r--r-- | src/server/headless/client_views.rs | 1 | ||||
| -rw-r--r-- | src/terminal/runtime.rs | 13 |
26 files changed, 1543 insertions, 204 deletions
diff --git a/src/api/schema.rs b/src/api/schema.rs index ba9f0c3a..7c58fb69 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -185,6 +185,8 @@ pub enum Method { PaneInputSet(PaneInputSetParams), #[serde(rename = "pane.link.activate")] PaneLinkActivate(PaneLinkActivateParams), + #[serde(rename = "pane.link.resolve")] + PaneLinkResolve(PaneLinkActivateParams), #[serde(rename = "pane.rename")] PaneRename(PaneRenameParams), #[serde(rename = "pane.send_text")] diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs index 517870f7..6cbcaf43 100644 --- a/src/api/schema/panes.rs +++ b/src/api/schema/panes.rs @@ -762,3 +762,11 @@ pub struct PaneReadResult { pub revision: u64, pub truncated: bool, } + +/// Inclusive display-cell columns on a pane's current viewport. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct PaneLinkRegion { + pub row: u16, + pub start_col: u16, + pub end_col: u16, +} diff --git a/src/api/schema/response.rs b/src/api/schema/response.rs index e6d4de4d..5cc66bdc 100644 --- a/src/api/schema/response.rs +++ b/src/api/schema/response.rs @@ -274,6 +274,9 @@ pub enum ResponseResult { context: PluginInvocationContext, log: PluginCommandLogInfo, }, + PaneLinkResolved { + regions: Vec<super::panes::PaneLinkRegion>, + }, PaneLinkActivated { #[serde(default, skip_serializing_if = "Option::is_none")] url: Option<String>, diff --git a/src/api/schema/tests.rs b/src/api/schema/tests.rs index b566e8f6..d51c565d 100644 --- a/src/api/schema/tests.rs +++ b/src/api/schema/tests.rs @@ -201,8 +201,7 @@ fn generated_protocol_schema_artifact_is_current() { ) }); assert_eq!( - expected, - actual, + expected, actual, "generated API schema artifact is stale; run `HERDR_UPDATE_API_SCHEMA=1 just test-one generated_protocol_schema_artifact_is_current`" ); } @@ -1433,3 +1432,27 @@ fn popup_close_request_round_trips() { assert_eq!(json["method"], "popup.close"); assert_eq!(json["params"], serde_json::json!({})); } + +#[test] +fn pane_link_resolve_round_trips() { + let request: Request = serde_json::from_value(serde_json::json!({ + "id": "hover", "method": "pane.link.resolve", + "params": {"pane_id": "pane-1", "viewport_row": 2, "col": 3} + })) + .unwrap(); + assert!(matches!(request.method, Method::PaneLinkResolve(_))); + let result = ResponseResult::PaneLinkResolved { + regions: vec![PaneLinkRegion { + row: 2, + start_col: 3, + end_col: 9, + }], + }; + let json = serde_json::to_value(&result).unwrap(); + assert_eq!(json["type"], "pane_link_resolved"); + assert_eq!(json["regions"][0]["end_col"], 9); + assert_eq!( + serde_json::from_value::<ResponseResult>(json).unwrap(), + result + ); +} diff --git a/src/api/server.rs b/src/api/server.rs index b8ef7c38..ea7597e7 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -454,6 +454,7 @@ pub(crate) fn api_method_name(method: &Method) -> &'static str { Method::PaneFocus(_) => "pane.focus", Method::PaneInputSet(_) => "pane.input.set", Method::PaneLinkActivate(_) => "pane.link.activate", + Method::PaneLinkResolve(_) => "pane.link.resolve", Method::PaneRename(_) => "pane.rename", Method::PaneSendText(_) => "pane.send_text", Method::PaneSendKeys(_) => "pane.send_keys", diff --git a/src/app/actions.rs b/src/app/actions.rs index d5d13d5a..85314331 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -10,7 +10,6 @@ use crate::events::AppEvent; use crate::layout::PaneId; #[cfg(test)] use crate::layout::{find_in_direction, NavDirection}; -use crate::selection::Selection; use crate::terminal::{EffectiveStateChange, TerminalStateMutation}; use crate::workspace::WorkspaceGitStatus; @@ -1093,65 +1092,13 @@ impl AppState { } } -impl AppState { - pub(crate) fn url_at_pane_surface_cell( - &self, - terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry, - ws_idx: usize, - pane_id: crate::layout::PaneId, - viewport_row: u16, - col: u16, - ) -> Option<String> { - let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)?; - let (height, width) = rt.current_size(); - url_at_runtime_cell( - rt, - pane_id, - ratatui::layout::Rect::new(0, 0, width, height), - viewport_row, - col, - rt.scroll_metrics(), - ) - } -} - -fn url_at_runtime_cell( - runtime: &crate::terminal::TerminalRuntime, - pane_id: crate::layout::PaneId, - area: ratatui::layout::Rect, - viewport_row: u16, - col: u16, - metrics: Option<crate::pane::ScrollMetrics>, -) -> Option<String> { - if viewport_row >= area.height || col >= area.width { - return None; +pub(super) fn url_from_link_target(target: crate::ghostty::LinkTarget) -> Option<String> { + match target { + crate::ghostty::LinkTarget::Uri(uri) => Some(uri), + crate::ghostty::LinkTarget::Text { text, clicked_byte } => { + url_at_byte(&text, clicked_byte).map(str::to_owned) + } } - let screen_col = area.x.saturating_add(col); - let screen_row = area.y.saturating_add(viewport_row); - if let Some((_, _, uri)) = runtime - .visible_hyperlinks(area) - .into_iter() - .find(|((x, y), _, _)| *x == screen_col && *y == screen_row) - { - return Some(uri); - } - - let visible_selection = Selection::line_range( - pane_id, - crate::selection::absolute_row_for_viewport(0, metrics), - crate::selection::absolute_row_for_viewport(area.height.saturating_sub(1), metrics), - area.width.saturating_sub(1), - ); - let visible_text = runtime.extract_selection(&visible_selection)?; - let logical_cell = logical_cell_for_visible_cell(&visible_text, area.width, viewport_row, col)?; - let line_start = visible_text[..logical_cell.byte_index] - .rfind('\n') - .map_or(0, |idx| idx + 1); - let line_end = visible_text[logical_cell.byte_index..] - .find('\n') - .map_or(visible_text.len(), |idx| logical_cell.byte_index + idx); - let line = visible_text.get(line_start..line_end)?; - url_at_column(line, logical_cell.logical_col).map(str::to_owned) } pub(crate) fn safe_web_url(url: &str) -> Option<&str> { @@ -1201,113 +1148,19 @@ pub(crate) 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_spans(&cells) - .into_iter() - .find(|span| span.contains(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 url_at_byte(text: &str, clicked_byte: usize) -> Option<&str> { + let range = url_byte_range(text, clicked_byte)?; + text.get(range) } -fn url_spans(cells: &[TextCell]) -> Vec<CellSpan> { - let mut spans = Vec::new(); - 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 let Some(span) = trim_url_edges(cells, CellSpan { start, end }) { - spans.push(span); - } - start = end + 1; - } else { - start += 1; - } - } - spans -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct VisibleTextCell { - pub(crate) byte_index: usize, - pub(crate) ch: char, - pub(crate) logical_col: u16, - pub(crate) screen_row: u16, - pub(crate) screen_col: u16, -} - -pub(crate) fn visible_text_cells(text: &str, pane_width: u16) -> Vec<VisibleTextCell> { - if pane_width == 0 { - return Vec::new(); - } - - let mut cells = Vec::new(); - let mut screen_row = 0u16; - let mut screen_col = 0u16; - let mut logical_col = 0u16; - let mut pending_wrap = false; - for (byte_index, ch) in text.char_indices() { - if ch == '\n' { - screen_row = screen_row.saturating_add(1); - screen_col = 0; - logical_col = 0; - pending_wrap = false; - continue; - } - if pending_wrap { - screen_row = screen_row.saturating_add(1); - screen_col = 0; - pending_wrap = false; - } - - let width = u16::from(crate::ghostty::unicode_codepoint_width(ch as u32)); - cells.push(VisibleTextCell { - byte_index, - ch, - logical_col, - screen_row, - screen_col, - }); - - logical_col = logical_col.saturating_add(width); - screen_col = screen_col.saturating_add(width); - while screen_col > pane_width { - screen_col -= pane_width; - screen_row = screen_row.saturating_add(1); - } - if width > 0 && screen_col == pane_width { - pending_wrap = true; - screen_col = pane_width.saturating_sub(1); - } - } - cells -} - -pub(crate) fn logical_cell_for_visible_cell( - text: &str, - pane_width: u16, - target_row: u16, - target_col: u16, -) -> Option<VisibleTextCell> { - visible_text_cells(text, pane_width) - .into_iter() - .find(|cell| { - let width = u16::from(crate::ghostty::unicode_codepoint_width(cell.ch as u32)); - cell.screen_row == target_row - && if width == 0 { - target_col == cell.screen_col - } else { - target_col >= cell.screen_col - && target_col < cell.screen_col.saturating_add(width) - } - }) +pub(super) fn url_byte_range(text: &str, clicked_byte: usize) -> Option<std::ops::Range<usize>> { + let clicked_idx = text.get(..clicked_byte)?.chars().count(); + let cells = text_cells(text); + let span = url_span_at_column(&cells, clicked_idx)?; + let start = byte_index_for_cell(text, span.start); + let end = byte_index_after_cell(text, span.end); + safe_web_url(text.get(start..end)?)?; + Some(start..end) } fn token_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option<CellSpan> { @@ -2314,7 +2167,7 @@ mod tests { } fn selected_url<'a>(row: &'a str, click: &str) -> Option<&'a str> { - url_at_column(row, col_of(row, click)) + url_at_byte(row, row.find(click)?) } fn text_in_cell_range(row: &str, start_col: u16, end_col: u16) -> String { @@ -2506,7 +2359,182 @@ mod tests { } #[test] - fn url_at_column_returns_safe_visible_url_only() { + fn link_resolution_regions_unicode_punctuation_and_explicit_links() { + let mut terminal = crate::ghostty::Terminal::new(80, 4, 1024).unwrap(); + terminal.write("[文档](https://example.com/路径?q=a(b)), next".as_bytes()); + let regions = terminal + .viewport_link_regions(7, 0, url_byte_range) + .unwrap(); + assert_eq!( + regions + .iter() + .map(|r| (r.row, r.start_col, r.end_col)) + .collect::<Vec<_>>(), + vec![(0, 7, 37)] + ); + assert!(terminal + .viewport_link_regions(1, 0, url_byte_range) + .unwrap() + .is_empty()); + terminal.resize(20, 4, 0, 0).unwrap(); + assert_eq!( + terminal + .viewport_link_regions(9, 1, url_byte_range) + .unwrap() + .iter() + .map(|r| (r.row, r.start_col, r.end_col)) + .collect::<Vec<_>>(), + vec![(0, 7, 19), (1, 0, 17)] + ); + for text in [ + "file:///tmp/a", + "javascript:alert(1)", + "\x1b]8;;https://example.com\x1b\\https://example.com\x1b]8;;\x1b\\", + ] { + let mut terminal = crate::ghostty::Terminal::new(80, 4, 1024).unwrap(); + terminal.write(text.as_bytes()); + assert!(terminal + .viewport_link_regions(0, 0, url_byte_range) + .unwrap() + .is_empty()); + } + let mut terminal = crate::ghostty::Terminal::new(80, 4, 1024 * 1024).unwrap(); + terminal.write(format!("https://example.com/{}", "a".repeat(9000)).as_bytes()); + assert!(terminal + .viewport_link_regions(0, 0, url_byte_range) + .unwrap() + .is_empty()); + } + + #[test] + fn link_resolution_regions_keep_grapheme_byte_offsets() { + let mut terminal = crate::ghostty::Terminal::new(40, 3, 1024).unwrap(); + terminal.write("e\u{301}(https://example.com/路e\u{301}),".as_bytes()); + let expected = vec![crate::api::schema::PaneLinkRegion { + row: 0, + start_col: 2, + end_col: 24, + }]; + for col in 2..=24 { + assert_eq!( + terminal + .viewport_link_regions(col, 0, url_byte_range) + .unwrap(), + expected, + "column {col}" + ); + } + assert!(terminal + .viewport_link_regions(0, 0, url_byte_range) + .unwrap() + .is_empty()); + assert!(terminal + .viewport_link_regions(25, 0, url_byte_range) + .unwrap() + .is_empty()); + terminal.resize(21, 1, 0, 0).unwrap(); + let regions = terminal + .viewport_link_regions(1, 0, url_byte_range) + .unwrap(); + assert_eq!( + regions, + vec![crate::api::schema::PaneLinkRegion { + row: 0, + start_col: 0, + end_col: 3 + }] + ); + } + + #[test] + fn link_resolution_regions_clip_wraps_and_wide_padding() { + let mut terminal = crate::ghostty::Terminal::new(21, 3, 1024).unwrap(); + terminal.write("https://example.com/路径".as_bytes()); + let regions = terminal + .viewport_link_regions(1, 1, url_byte_range) + .unwrap(); + assert_eq!( + regions + .iter() + .map(|r| (r.row, r.start_col, r.end_col)) + .collect::<Vec<_>>(), + vec![(0, 0, 19), (1, 0, 3)] + ); + assert!(terminal + .viewport_link_regions(19, 2, url_byte_range) + .unwrap() + .is_empty()); + let mut terminal = crate::ghostty::Terminal::new(20, 2, 1024).unwrap(); + terminal.write(b"https://example.com/abcdefghijklmnopqrstuv"); + assert_eq!( + terminal + .viewport_link_regions(0, 0, url_byte_range) + .unwrap() + .iter() + .map(|r| (r.row, r.start_col, r.end_col)) + .collect::<Vec<_>>(), + vec![(0, 0, 19), (1, 0, 1)] + ); + terminal.scroll_viewport_row(0); + assert_eq!( + terminal + .viewport_link_regions(0, 0, url_byte_range) + .unwrap() + .iter() + .map(|r| (r.row, r.start_col, r.end_col)) + .collect::<Vec<_>>(), + vec![(0, 0, 19), (1, 0, 19)] + ); + assert!(terminal + .viewport_link_regions(0, 2, url_byte_range) + .unwrap() + .is_empty()); + } + + #[test] + fn link_activation_resolves_full_url_with_either_end_offscreen() { + let url = "https://example.com/abcdefghijklmnopqrstuv"; + let mut terminal = crate::ghostty::Terminal::new(20, 2, 1024 * 1024).unwrap(); + terminal.write(url.as_bytes()); + let target = terminal.viewport_link_target(0, 0).unwrap().unwrap(); + assert_eq!(url_from_link_target(target).as_deref(), Some(url)); + terminal.scroll_viewport_row(0); + let target = terminal.viewport_link_target(5, 1).unwrap().unwrap(); + assert_eq!(url_from_link_target(target).as_deref(), Some(url)); + } + + #[test] + fn link_activation_preserves_unicode_and_click_boundaries_after_resize() { + let mut terminal = crate::ghostty::Terminal::new(80, 5, 1024 * 1024).unwrap(); + terminal.write("[文档](https://example.com/路径?q=a(b)), next".as_bytes()); + assert!( + url_from_link_target(terminal.viewport_link_target(1, 0).unwrap().unwrap()).is_none() + ); + assert_eq!( + url_from_link_target(terminal.viewport_link_target(7, 0).unwrap().unwrap()).as_deref(), + Some("https://example.com/路径?q=a(b)") + ); + terminal.resize(20, 5, 0, 0).unwrap(); + assert_eq!( + url_from_link_target(terminal.viewport_link_target(9, 1).unwrap().unwrap()).as_deref(), + Some("https://example.com/路径?q=a(b)") + ); + assert!(terminal.viewport_link_target(19, 4).unwrap().is_none()); + } + + #[test] + fn link_activation_skips_wide_character_wrap_padding() { + let url = "https://example.com/路径"; + let mut terminal = crate::ghostty::Terminal::new(20, 3, 1024).unwrap(); + terminal.write(url.as_bytes()); + for col in 0..4 { + let target = terminal.viewport_link_target(col, 1).unwrap().unwrap(); + assert_eq!(url_from_link_target(target).as_deref(), Some(url)); + } + } + + #[test] + fn url_at_byte_returns_safe_visible_url_only() { assert_eq!( selected_url("see https://example.com/a(b)c.", "example"), Some("https://example.com/a(b)c") diff --git a/src/app/api.rs b/src/app/api.rs index 30fc3be2..1270feff 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -1011,7 +1011,7 @@ impl App { return self.handle_workspace_create(request.id, params); } Method::WorkspaceFocus(target) => { - return self.handle_workspace_focus(request.id, target) + return self.handle_workspace_focus(request.id, target); } Method::WorkspaceRename(params) => { return self.handle_workspace_rename(request.id, params); @@ -1026,7 +1026,7 @@ impl App { return self.handle_workspace_report_metadata(request.id, params); } Method::WorkspaceClose(target) => { - return self.handle_workspace_close(request.id, target) + return self.handle_workspace_close(request.id, target); } Method::WorktreeList(params) => return self.handle_worktree_list(request.id, params), Method::WorktreeCreate(params) => { @@ -1059,7 +1059,7 @@ impl App { Method::AgentRename(params) => return self.handle_agent_rename(request.id, params), Method::AgentViewSet(params) => return self.handle_agent_view_set(request.id, params), Method::AgentViewClear(params) => { - return self.handle_agent_view_clear(request.id, params) + return self.handle_agent_view_clear(request.id, params); } Method::AgentStart(params) => return self.handle_agent_start(request.id, params), Method::AgentPrompt(_) => { @@ -1079,7 +1079,7 @@ impl App { Method::AgentRead(params) => return self.handle_agent_read(request.id, params), Method::AgentExplain(target) => return self.handle_agent_explain(request.id, target), Method::AgentSendKeys(params) => { - return self.handle_agent_send_keys(request.id, params) + return self.handle_agent_send_keys(request.id, params); } Method::PaneSplit(params) => return self.handle_pane_split(request.id, params), Method::PaneSwap(params) => return self.handle_pane_swap(request.id, params), @@ -1118,6 +1118,9 @@ impl App { Method::PaneGet(target) => return self.handle_pane_get(request.id, target), Method::PaneFocus(target) => return self.handle_pane_focus(request.id, target), Method::PaneInputSet(params) => return self.handle_pane_input_set(request.id, params), + Method::PaneLinkResolve(params) => { + return self.handle_pane_link_resolve(request.id, params); + } Method::PaneLinkActivate(params) => { return self.handle_pane_link_activate(request.id, params); } @@ -1168,7 +1171,7 @@ impl App { } Method::PaneSendText(params) => return self.handle_pane_send_text(request.id, params), Method::PaneSendInput(params) => { - return self.handle_pane_send_input(request.id, params) + return self.handle_pane_send_input(request.id, params); } Method::PaneClose(target) => return self.handle_pane_close(request.id, target), Method::PopupClose(_) => { diff --git a/src/app/api/plugins/mod.rs b/src/app/api/plugins/mod.rs index 8ad083d4..cf6327b6 100644 --- a/src/app/api/plugins/mod.rs +++ b/src/app/api/plugins/mod.rs @@ -255,22 +255,25 @@ impl App { .map_err(|(_, message)| message) } - pub(super) fn handle_pane_link_activate( - &mut self, - id: String, - params: PaneLinkActivateParams, - ) -> String { + fn read_checked_pane_link<T>( + &self, + id: &str, + params: &PaneLinkActivateParams, + operation: &str, + read: impl FnOnce(&crate::terminal::TerminalRuntime, u16, u16) -> T, + ) -> Result<(crate::layout::PaneId, T), String> { + let error = |code, message: &str| encode_error(id.to_owned(), code, message); let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { - return encode_error(id, "pane_not_found", "pane not found"); + return Err(error("pane_not_found", "pane not found")); }; if !self.state.pane_visible_on_active_surface(ws_idx, pane_id) { - return encode_error(id, "stale_target", "pane is no longer visible"); + return Err(error("stale_target", "pane is no longer visible")); } let Some(runtime) = self.state .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) else { - return encode_error(id, "pane_not_found", "pane runtime not found"); + return Err(error("pane_not_found", "pane runtime not found")); }; let current_offset = runtime .scroll_metrics() @@ -279,11 +282,10 @@ impl App { .offset_from_bottom .is_some_and(|expected| current_offset != Some(expected)) { - return encode_error( - id, + return Err(error( "stale_content", - "pane viewport changed before link activation", - ); + &format!("pane viewport changed before link {operation}"), + )); } let content_revision = runtime.content_seq(); if content_revision % 2 != 0 @@ -291,31 +293,53 @@ impl App { .content_revision .is_some_and(|expected| expected != content_revision) { - return encode_error( - id, + return Err(error( "stale_content", - "pane content changed before link activation", - ); + &format!("pane content changed before link {operation}"), + )); } - let url = self.state.url_at_pane_surface_cell( - &self.terminal_runtimes, - ws_idx, - pane_id, - params.viewport_row, - params.col, - ); + let value = read(runtime, params.col, params.viewport_row); if runtime.content_seq() != content_revision || runtime .scroll_metrics() .map(|metrics| metrics.offset_from_bottom as u64) != current_offset { - return encode_error( - id, + return Err(error( "stale_content", - "pane content or viewport changed during link activation", - ); + &format!("pane content or viewport changed during link {operation}"), + )); + } + Ok((pane_id, value)) + } + + pub(super) fn handle_pane_link_resolve( + &mut self, + id: String, + params: PaneLinkActivateParams, + ) -> String { + match self.read_checked_pane_link(&id, ¶ms, "resolution", |runtime, col, row| { + runtime.link_regions_at(col, row, crate::app::actions::url_byte_range) + }) { + Ok((_, regions)) => encode_success(id, ResponseResult::PaneLinkResolved { regions }), + Err(error) => error, } + } + + pub(super) fn handle_pane_link_activate( + &mut self, + id: String, + params: PaneLinkActivateParams, + ) -> String { + let (pane_id, url) = + match self.read_checked_pane_link(&id, ¶ms, "activation", |runtime, col, row| { + runtime + .link_target_at(col, row) + .and_then(crate::app::actions::url_from_link_target) + }) { + Ok(value) => value, + Err(error) => return error, + }; let handled = match url.as_deref() { Some(url) => match self.invoke_plugin_link_handler_for_url(url, pane_id) { Ok(handled) => handled, @@ -789,6 +813,71 @@ mod tests { }; use std::time::{SystemTime, UNIX_EPOCH}; + #[tokio::test] + async fn pane_link_resolve_checks_staleness_without_side_effects() { + let mut app = test_app(); + app.state.workspaces = vec![crate::workspace::Workspace::test_new("hover")]; + app.state.ensure_test_terminals(); + app.state.active = Some(0); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + let public_id = app.public_pane_id(0, pane_id).unwrap(); + let terminal_id = app.state.workspaces[0].tabs[0].panes[&pane_id] + .attached_terminal_id + .clone(); + let (runtime, mut rx) = crate::terminal::TerminalRuntime::test_with_channel(80, 4); + runtime.test_process_pty_bytes(b"https://example.com"); + let revision = runtime.content_seq(); + app.terminal_runtimes.insert(terminal_id, runtime); + let params = PaneLinkActivateParams { + pane_id: public_id, + viewport_row: 0, + col: 1, + content_revision: Some(revision), + offset_from_bottom: Some(0), + }; + let response = app.handle_api_request(Request { + id: "hover".into(), + method: Method::PaneLinkResolve(params.clone()), + }); + assert!( + matches!(response_result(&response), ResponseResult::PaneLinkResolved { regions } if regions.len() == 1) + ); + assert!( + rx.try_recv().is_err(), + "resolution must not write to the PTY" + ); + for stale in [ + PaneLinkActivateParams { + content_revision: Some(revision + 2), + ..params.clone() + }, + PaneLinkActivateParams { + offset_from_bottom: Some(1), + ..params.clone() + }, + ] { + let response = app.handle_pane_link_resolve("hover".into(), stale); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + assert_eq!(response["error"]["code"], "stale_content"); + } + let changed = + app.read_checked_pane_link("hover", ¶ms, "resolution", |runtime, _, _| { + runtime.test_process_pty_bytes(b"changed"); + }); + let response: serde_json::Value = serde_json::from_str(&changed.unwrap_err()).unwrap(); + assert_eq!(response["error"]["code"], "stale_content"); + assert_eq!( + response["error"]["message"], + "pane content or viewport changed during link resolution" + ); + assert!(rx.try_recv().is_err()); + + app.state.active = None; + let response: serde_json::Value = + serde_json::from_str(&app.handle_pane_link_resolve("hover".into(), params)).unwrap(); + assert_eq!(response["error"]["code"], "stale_target"); + } + fn test_app() -> App { let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel(); App::new( diff --git a/src/client/shell.rs b/src/client/shell.rs index 4c2fc6cd..c6c7f2ed 100644 --- a/src/client/shell.rs +++ b/src/client/shell.rs @@ -20,6 +20,7 @@ mod global_menu; mod graphics; mod input; mod input_source; +mod link_hover; mod mobile; mod mouse; mod notification_policy; diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index 9190f5e4..95a24a54 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -483,6 +483,9 @@ impl ClientShellState { { return (false, Vec::new()); } + if let PendingEndpointKind::PaneLinkResolve { target } = pending.kind { + return self.complete_link_hover(target, result); + } if result.is_ok() { let timeout_key = ClientEndpointNoticeKey { boot_id: boot_id.to_owned(), @@ -528,6 +531,7 @@ impl ClientShellState { } match pending.kind { PendingEndpointKind::Generic => {} + PendingEndpointKind::PaneLinkResolve { .. } => unreachable!("handled above"), PendingEndpointKind::ProductAnnouncementDismiss { version, id } => { return match result { Ok(_) => (false, Vec::new()), diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs index 3266e54d..8e99059b 100644 --- a/src/client/shell/composition.rs +++ b/src/client/shell/composition.rs @@ -371,6 +371,7 @@ impl ClientShellState { } frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor); } + self.render_link_hover(&mut frame); if self.mode == ClientShellMode::Copy { frame.cursor = None; if let Some(copy_mode) = self.copy_mode.as_ref() { diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index 8b5fbd83..7118819b 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -247,6 +247,7 @@ impl ClientShellState { .push(ClientMessage::ClientShellFocus { focused: true }); } RawInputEvent::OuterFocusLost => { + outcome.repaint |= self.clear_link_hover(); self.outer_focused = Some(false); self.release_input_leases(&mut outcome); outcome @@ -301,6 +302,7 @@ impl ClientShellState { key: crate::input::TerminalKey, outcome: &mut ClientShellInput, ) { + outcome.repaint |= self.clear_link_hover(); if self.copy_operation_in_flight { self.copy_input_queue.push_back(key); return; diff --git a/src/client/shell/link_hover.rs b/src/client/shell/link_hover.rs new file mode 100644 index 00000000..f35bfbc2 --- /dev/null +++ b/src/client/shell/link_hover.rs @@ -0,0 +1,352 @@ +use super::*; +use crate::api::schema::{PaneLinkActivateParams, PaneLinkRegion}; +use crate::protocol::SurfaceRect; +use crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct LinkHoverTarget { + pane_id: String, + inner_rect: Rect, + source_rect: SurfaceRect, + content_revision: u64, + offset_from_bottom: Option<u64>, + row: u16, + col: u16, +} + +impl LinkHoverTarget { + fn same_content(&self, other: &Self) -> bool { + self.pane_id == other.pane_id + && self.inner_rect == other.inner_rect + && self.source_rect == other.source_rect + && self.content_revision == other.content_revision + && self.offset_from_bottom == other.offset_from_bottom + } +} + +pub(super) struct LinkHover { + target: LinkHoverTarget, + pub(super) regions: Vec<PaneLinkRegion>, + resolved: bool, +} + +impl ClientShellState { + pub(super) fn clear_link_hover(&mut self) -> bool { + self.link_hover + .take() + .is_some_and(|hover| !hover.regions.is_empty()) + } + + fn link_hover_allowed(&self) -> bool { + self.mode == ClientShellMode::Terminal + && self.overlay.is_none() + && self.popup_terminal_id.is_none() + && self.outer_focused != Some(false) + && self.chrome_drag.is_none() + && self.pane_mouse_gesture.is_none() + } + + fn link_hover_target_current(&self, target: &LinkHoverTarget) -> bool { + self.link_hover_allowed() + && self.pane_surface.as_ref().is_some_and(|surface| { + self.snapshot.as_ref().is_some_and(|snapshot| { + surface.boot_id == snapshot.boot_id + && surface.projection_revision == snapshot.revision + }) && surface.panes.iter().any(|pane| { + pane.pane_id == target.pane_id + && pane.inner_rect == target.source_rect + && pane.content_revision == target.content_revision + && pane.scroll.map(|scroll| scroll.offset_from_bottom) + == target.offset_from_bottom + }) + }) + && self + .hits + .panes + .iter() + .any(|hit| hit.pane_id == target.pane_id && hit.inner_rect == target.inner_rect) + } + + pub(super) fn invalidate_link_hover(&mut self) { + if self + .link_hover + .as_ref() + .is_some_and(|hover| !self.link_hover_target_current(&hover.target)) + { + self.clear_link_hover(); + } + } + + pub(super) fn update_link_hover(&mut self, mouse: MouseEvent, outcome: &mut ClientShellInput) { + if mouse.kind != MouseEventKind::Moved + || !mouse.modifiers.contains(KeyModifiers::CONTROL) + || !self.link_hover_allowed() + { + outcome.repaint |= self.clear_link_hover(); + return; + } + let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| contains(hit.inner_rect, (mouse.column, mouse.row))) + else { + outcome.repaint |= self.clear_link_hover(); + return; + }; + let Some(pane) = self.pane_surface.as_ref().and_then(|surface| { + surface + .panes + .iter() + .find(|pane| pane.pane_id == hit.pane_id) + }) else { + outcome.repaint |= self.clear_link_hover(); + return; + }; + let target = LinkHoverTarget { + pane_id: hit.pane_id.clone(), + inner_rect: hit.inner_rect, + source_rect: pane.inner_rect, + content_revision: pane.content_revision, + offset_from_bottom: pane.scroll.map(|scroll| scroll.offset_from_bottom), + row: mouse.row.saturating_sub(hit.inner_rect.y), + col: mouse.column.saturating_sub(hit.inner_rect.x), + }; + if !self.link_hover_target_current(&target) || !target.content_revision.is_multiple_of(2) { + outcome.repaint |= self.clear_link_hover(); + return; + } + if self.link_hover.as_ref().is_some_and(|hover| { + hover.target.same_content(&target) + && (hover.target == target + || hover + .regions + .iter() + .any(|region| region_contains(region, target.col, target.row))) + }) { + self.request_link_hover(outcome); + return; + } + outcome.repaint |= self.clear_link_hover(); + let explicit = self.explicit_link_regions(&target); + let resolved = explicit.is_some(); + let regions = explicit.unwrap_or_default(); + outcome.repaint |= !regions.is_empty(); + self.link_hover = Some(LinkHover { + target, + regions, + resolved, + }); + self.request_link_hover(outcome); + } + + fn request_link_hover(&mut self, outcome: &mut ClientShellInput) { + if self + .pending_requests + .values() + .any(|pending| matches!(pending.kind, PendingEndpointKind::PaneLinkResolve { .. })) + { + return; + } + let Some(hover) = self.link_hover.as_ref().filter(|hover| !hover.resolved) else { + return; + }; + if !self.link_hover_target_current(&hover.target) { + return; + } + let target = hover.target.clone(); + // Hover is optional and speculative: do not probe unadvertised methods or show notices. + if !self.endpoint_is_online(&self.active_endpoint_id) + || !self + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == self.active_endpoint_id) + .and_then(|endpoint| endpoint.methods.as_ref()) + .is_some_and(|methods| methods.iter().any(|method| method == "pane.link.resolve")) + { + if let Some(hover) = self.link_hover.as_mut() { + hover.resolved = true; + } + return; + } + self.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneLinkResolve(PaneLinkActivateParams { + pane_id: target.pane_id.clone(), + viewport_row: target.row, + col: target.col, + content_revision: Some(target.content_revision), + offset_from_bottom: target.offset_from_bottom, + }), + PendingEndpointKind::PaneLinkResolve { target }, + outcome, + ); + } + + pub(super) fn complete_link_hover( + &mut self, + target: LinkHoverTarget, + result: Result<crate::api::schema::ResponseResult, ClientShellEndpointError>, + ) -> (bool, Vec<ClientShellAction>) { + let cancelled = result + .as_ref() + .err() + .is_some_and(|error| error.code.as_deref() == Some("endpoint_cancelled")); + let mut outcome = ClientShellInput::default(); + if self.link_hover_target_current(&target) + && self + .link_hover + .as_ref() + .is_some_and(|hover| hover.target == target) + { + let regions = match result { + Ok(crate::api::schema::ResponseResult::PaneLinkResolved { regions }) + if regions.len() <= usize::from(target.inner_rect.height) + && regions.iter().all(|region| { + region.row < target.inner_rect.height + && region.start_col <= region.end_col + && region.end_col < target.inner_rect.width + }) + && regions + .iter() + .any(|region| region_contains(region, target.col, target.row)) => + { + regions + } + _ => Vec::new(), + }; + outcome.repaint = !regions.is_empty(); + self.link_hover = Some(LinkHover { + target, + regions, + resolved: true, + }); + } + if !cancelled { + self.request_link_hover(&mut outcome); + } + (outcome.repaint, outcome.actions) + } + + fn explicit_link_regions(&self, target: &LinkHoverTarget) -> Option<Vec<PaneLinkRegion>> { + let surface = self.pane_surface.as_ref()?; + let rect = target.source_rect; + let cell = |col: u16, row: u16| { + let x = usize::from(rect.x) + usize::from(col); + let y = usize::from(rect.y) + usize::from(row); + if x >= usize::from(surface.frame.width) || y >= usize::from(surface.frame.height) { + return None; + } + surface + .frame + .cells + .get(y * usize::from(surface.frame.width) + x) + }; + let hyperlink_at = |col: u16, row: u16| { + let current = cell(col, row)?; + current.hyperlink.or_else(|| { + // Rendered wide-cell spacers may not carry their own OSC 8 metadata. + if col == 0 || !current.symbol.trim().is_empty() { + return None; + } + let previous = cell(col - 1, row)?; + (previous.symbol.width() == 2) + .then_some(previous.hyperlink) + .flatten() + }) + }; + let hyperlink = hyperlink_at(target.col, target.row)?; + let uri = surface.frame.hyperlinks.get(hyperlink as usize)?; + if crate::app::actions::safe_web_url(uri).is_none() { + return Some(Vec::new()); + } + let width = usize::from(rect.width); + if width == 0 { + return Some(Vec::new()); + } + let clicked = usize::from(target.row) * width + usize::from(target.col); + let mut start = clicked; + let mut end = clicked; + let matches = |index: usize| { + hyperlink_at((index % width) as u16, (index / width) as u16) == Some(hyperlink) + }; + // Frame IDs identify destinations, not OSC 8 runs. Adjacent cells with + // the same destination form one hover region, including across rows. + let mut remaining = 8191usize; + while start > 0 && matches(start - 1) { + if remaining == 0 { + return Some(Vec::new()); + } + remaining -= 1; + start -= 1; + } + while end + 1 < width * usize::from(rect.height) && matches(end + 1) { + if remaining == 0 { + return Some(Vec::new()); + } + remaining -= 1; + end += 1; + } + let regions = (start / width..=end / width) + .map(|row| PaneLinkRegion { + row: row as u16, + start_col: if row == start / width { + (start % width) as u16 + } else { + 0 + }, + end_col: if row == end / width { + (end % width) as u16 + } else { + rect.width - 1 + }, + }) + .collect(); + Some(regions) + } + + pub(super) fn link_hover_blocks_patch( + &self, + patch: &crate::protocol::PaneSurfacePatch, + ) -> bool { + self.link_hover.as_ref().is_some_and(|hover| { + !hover.regions.is_empty() + && patch + .panes + .iter() + .any(|pane| pane.pane_id == hover.target.pane_id) + }) + } + + pub(super) fn render_link_hover(&self, frame: &mut FrameData) { + let Some(hover) = self + .link_hover + .as_ref() + .filter(|hover| self.link_hover_target_current(&hover.target)) + else { + return; + }; + let rect = hover.target.inner_rect; + for region in &hover.regions { + let row = rect.y.saturating_add(region.row); + if row >= frame.height { + continue; + } + for col in region.start_col..=region.end_col { + let col = rect.x.saturating_add(col); + if col >= frame.width { + continue; + } + if let Some(cell) = frame + .cells + .get_mut(usize::from(row) * usize::from(frame.width) + usize::from(col)) + { + cell.modifier |= Modifier::UNDERLINED.bits(); + } + } + } + } +} + +fn region_contains(region: &PaneLinkRegion, col: u16, row: u16) -> bool { + region.row == row && col >= region.start_col && col <= region.end_col +} diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index 7b4c0cb2..1ce14feb 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -652,6 +652,7 @@ impl ClientShellState { } pub(super) fn handle_mouse(&mut self, mouse: MouseEvent, outcome: &mut ClientShellInput) { + self.update_link_hover(mouse, outcome); let point = (mouse.column, mouse.row); if self.mode == ClientShellMode::Navigate && self.workspace_preview_action_blocked() diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index eede0f29..8f2aec43 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -699,6 +699,9 @@ pub(super) enum PendingEndpointKind { absolute_row: u32, generation: u64, }, + PaneLinkResolve { + target: super::link_hover::LinkHoverTarget, + }, PaneLinkActivate { pane_id: String, inner_rect: Rect, @@ -934,6 +937,7 @@ pub(crate) struct ClientShellState { pub(super) overlay: Option<ClientShellOverlay>, pub(super) previous_pane_id: Option<String>, pub(super) pane_mouse_gesture: Option<ClientPaneMouseGesture>, + pub(super) link_hover: Option<super::link_hover::LinkHover>, pub(super) url_click_consumes_until_up: bool, pub(super) replaying_url_click: bool, pub(super) selection: Option<crate::selection::Selection<String>>, @@ -1093,6 +1097,7 @@ impl ClientShellState { overlay, previous_pane_id: None, pane_mouse_gesture: None, + link_hover: None, url_click_consumes_until_up: false, replaying_url_click: false, selection: None, @@ -1284,6 +1289,7 @@ impl ClientShellState { .then_some(ClientShellOverlay::Onboarding); self.previous_pane_id = None; self.pane_mouse_gesture = None; + self.link_hover = None; self.url_click_consumes_until_up = false; self.replaying_url_click = false; self.selection = None; @@ -1811,6 +1817,7 @@ impl ClientShellState { self.graphics .set_scene(std::mem::take(&mut surface.graphics)); self.pane_surface = Some(surface); + self.invalidate_link_hover(); self.resume_mobile_switcher_if_ready(); self.reconcile_input_source(); } diff --git a/src/client/shell/surface_patch.rs b/src/client/shell/surface_patch.rs index 784fbcd8..87f947fa 100644 --- a/src/client/shell/surface_patch.rs +++ b/src/client/shell/surface_patch.rs @@ -72,6 +72,8 @@ fn fast_path_blocker( Some("client_surface_patch.fallback.notification") } else if state.copy_feedback.is_some() { Some("client_surface_patch.fallback.copy_feedback") + } else if state.link_hover_blocks_patch(patch) { + Some("client_surface_patch.fallback.link_hover") } else if state.selection.is_some() { Some("client_surface_patch.fallback.selection") } else if state.copy_mode.is_some() { @@ -240,6 +242,7 @@ impl ClientShellState { } } } + self.invalidate_link_hover(); self.reconcile_input_source(); } else { let mut next = current.clone(); diff --git a/src/client/shell/tests/link_hover.rs b/src/client/shell/tests/link_hover.rs new file mode 100644 index 00000000..f29fdec2 --- /dev/null +++ b/src/client/shell/tests/link_hover.rs @@ -0,0 +1,410 @@ +use super::*; + +fn hover_state() -> ClientShellState { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_endpoint_methods(Some(vec!["pane.link.resolve".into()])); + state.set_pane_surface(surface()); + state.compose(106, 20).unwrap(); + state +} + +fn hover_mouse(state: &ClientShellState, col: u16, row: u16) -> MouseEvent { + let pane = &state.hits.panes[0]; + MouseEvent { + kind: MouseEventKind::Moved, + column: pane.inner_rect.x + col, + row: pane.inner_rect.y + row, + modifiers: KeyModifiers::CONTROL, + } +} + +fn hover_request(outcome: &ClientShellInput) -> String { + let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else { + panic!("expected one side-effect-free link resolution"); + }; + assert!(matches!( + request.method, + crate::api::schema::Method::PaneLinkResolve(_) + )); + request.id.clone() +} + +fn resolve_hover(state: &mut ClientShellState, id: &str) -> bool { + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + id, + Ok(crate::api::schema::ResponseResult::PaneLinkResolved { + regions: vec![ + crate::api::schema::PaneLinkRegion { + row: 0, + start_col: 1, + end_col: 3, + }, + crate::api::schema::PaneLinkRegion { + row: 1, + start_col: 0, + end_col: 2, + }, + ], + }), + ); + assert!( + actions.is_empty(), + "hover must never open a URL or activate a plugin" + ); + repaint +} + +#[test] +fn ctrl_hover_underlines_wrapped_segments_and_caches_the_link() { + let mut state = hover_state(); + let mouse = hover_mouse(&state, 1, 0); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + assert!(resolve_hover(&mut state, &id)); + let frame = state.compose(106, 20).unwrap(); + let pane = &state.hits.panes[0]; + let underlined = |col, row| { + frame.cells[usize::from(pane.inner_rect.y + row) * usize::from(frame.width) + + usize::from(pane.inner_rect.x + col)] + .modifier + & Modifier::UNDERLINED.bits() + != 0 + }; + assert!(!underlined(0, 0)); + assert!(underlined(1, 0)); + assert!(underlined(3, 0)); + assert!(underlined(0, 1)); + assert!(underlined(2, 1)); + assert!(!underlined(3, 1)); + let moved = hover_mouse(&state, 2, 1); + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(moved)]); + assert!(outcome.actions.is_empty()); + assert!(!outcome.repaint); + assert!(state.selection.is_none()); +} + +#[test] +fn ctrl_hover_pending_request_does_not_block_another_endpoint_with_the_same_boot_id() { + use crate::client::endpoint::{ + ClientEndpointId, ClientEndpointStatus, ProfileId, SavedSshEndpoint, + }; + let mut state = hover_state(); + let mouse = hover_mouse(&state, 1, 0); + let old_id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + let profile = SavedSshEndpoint { + id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + label: "Other connection".into(), + target: "host".into(), + session: "default".into(), + enabled: true, + }; + let endpoint = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&endpoint, ClientEndpointStatus::Online); + state.set_endpoint_snapshot(&endpoint, Box::new(snapshot())); + assert!(state.activate_endpoint_projection(&endpoint)); + assert!( + state.pending_requests.is_empty(), + "endpoint switches retire old requests" + ); + state.set_endpoint_methods(Some(vec!["pane.link.resolve".into()])); + state.set_pane_surface(surface()); + state.compose(106, 20).unwrap(); + let mouse = hover_mouse(&state, 1, 0); + let new_id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + assert!( + !resolve_hover(&mut state, &old_id), + "old endpoint must not paint here" + ); + assert!(resolve_hover(&mut state, &new_id)); +} + +#[test] +fn ctrl_hover_coalesces_motion_and_ignores_old_replies() { + let mut state = hover_state(); + let first = hover_mouse(&state, 1, 0); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(first)])); + for col in [2, 3, 0] { + let mouse = hover_mouse(&state, col, 1); + assert!(state + .handle_raw_events(vec![RawInputEvent::Mouse(mouse)]) + .actions + .is_empty()); + } + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &id, + Ok(crate::api::schema::ResponseResult::PaneLinkResolved { regions: vec![] }), + ); + assert!(!repaint); + assert!( + matches!( + &actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::PaneLinkResolve(params) + if params.viewport_row == 1 && params.col == 0) + ), + "only the latest pointer position should be queried" + ); + assert!(state.link_hover.as_ref().unwrap().regions.is_empty()); +} + +#[test] +fn ctrl_hover_clears_on_release_content_change_and_focus_loss() { + for clear in 0..4 { + let mut state = hover_state(); + let mouse = hover_mouse(&state, 1, 0); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + assert!(resolve_hover(&mut state, &id)); + match clear { + 0 => { + state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + modifiers: KeyModifiers::empty(), + ..mouse + })]); + } + 1 => { + let mut next = surface(); + next.surface_revision += 1; + next.panes[0].content_revision += 2; + state.set_pane_surface(next); + } + 2 => { + state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + } + _ => { + state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new( + KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftControl), + KeyModifiers::empty(), + ) + .with_kind(crossterm::event::KeyEventKind::Release), + )]); + } + } + assert!(state.link_hover.is_none()); + } +} + +#[test] +fn ctrl_hover_content_patch_removes_all_old_underlines() { + let mut state = hover_state(); + let mouse = hover_mouse(&state, 1, 0); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + assert!(resolve_hover(&mut state, &id)); + state.compose(106, 20).unwrap(); + let mut pane = surface().panes.remove(0); + pane.content_revision = 2; + let patch = crate::protocol::PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 1, + base_surface_revision: 1, + surface_revision: 2, + rows: vec![crate::protocol::PaneSurfacePatchRow { + x: 0, + y: 0, + cells: vec![surface().frame.cells[0].clone()], + }], + panes: vec![pane], + cursor: None, + }; + assert!(matches!( + state.apply_pane_surface_patch(patch), + ClientPaneSurfacePatchOutcome::Applied(None) + )); + assert!(state.link_hover.is_none()); + let frame = state.compose(106, 20).unwrap(); + let pane = &state.hits.panes[0]; + for row in 0..2 { + for col in 0..4 { + let idx = usize::from(pane.inner_rect.y + row) * usize::from(frame.width) + + usize::from(pane.inner_rect.x + col); + assert_eq!(frame.cells[idx].modifier & Modifier::UNDERLINED.bits(), 0); + } + } +} + +#[test] +fn ctrl_hover_preserves_fast_patches_for_other_panes() { + let mut state = hover_state(); + let mut next = surface(); + next.surface_revision = 2; + next.frame.height = 4; + next.frame.cells.extend(next.frame.cells.clone()); + let mut other = next.panes[0].clone(); + other.pane_id = "pane_2".into(); + other.rect.y = 2; + other.inner_rect.y = 2; + next.panes.push(other.clone()); + state.set_pane_surface(next); + state.compose(106, 20).unwrap(); + let mouse = hover_mouse(&state, 1, 0); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + assert!(resolve_hover(&mut state, &id)); + state.compose(106, 20).unwrap(); + other.content_revision = 2; + let patch = crate::protocol::PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 1, + base_surface_revision: 2, + surface_revision: 3, + rows: vec![crate::protocol::PaneSurfacePatchRow { + x: 0, + y: 2, + cells: vec![surface().frame.cells[0].clone()], + }], + panes: vec![other], + cursor: None, + }; + assert!(matches!( + state.apply_pane_surface_patch(patch), + ClientPaneSurfacePatchOutcome::Applied(Some(_)) + )); + assert!(!state.link_hover.as_ref().unwrap().regions.is_empty()); +} + +#[test] +fn ctrl_hover_ignores_late_reply_after_pointer_leaves() { + let mut state = hover_state(); + let mouse = hover_mouse(&state, 1, 0); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + column: 0, + row: 0, + ..mouse + })]); + assert!(!resolve_hover(&mut state, &id)); + assert!(state.link_hover.is_none()); +} + +#[test] +fn ctrl_hover_is_silent_when_unsupported_or_rejected() { + let mut state = hover_state(); + state.set_endpoint_methods(Some(vec![])); + let mouse = hover_mouse(&state, 1, 0); + assert!(state + .handle_raw_events(vec![RawInputEvent::Mouse(mouse)]) + .actions + .is_empty()); + assert!(state.visible_endpoint_notice.is_none()); + state.set_endpoint_methods(Some(vec!["pane.link.resolve".into()])); + state.clear_link_hover(); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + state.handle_endpoint_result( + "boot-1", + &id, + Err(ClientShellEndpointError { + code: Some("endpoint_timeout".into()), + message: "timeout".into(), + }), + ); + assert!(state.visible_endpoint_notice.is_none()); +} + +#[test] +fn ctrl_hover_explicit_wide_label_includes_the_spacer_cell() { + let mut state = hover_state(); + state.set_endpoint_methods(Some(vec![])); + let mut next = surface(); + next.surface_revision += 1; + next.frame.hyperlinks.push("https://example.com/".into()); + next.frame.cells[1].symbol = "界".into(); + next.frame.cells[1].hyperlink = Some(0); + next.frame.cells[2].symbol = "".into(); + state.set_pane_surface(next); + let mouse = hover_mouse(&state, 2, 0); + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]); + assert!(outcome.actions.is_empty()); + assert!(outcome.repaint); + let regions = &state.link_hover.as_ref().unwrap().regions; + assert_eq!(regions.len(), 1); + assert_eq!((regions[0].start_col, regions[0].end_col), (1, 2)); +} + +#[test] +#[ignore = "non-gating fixed-geometry hover composition profile"] +fn ctrl_hover_render_scale_profile() { + for count in [1, 15] { + let mut state = hover_state(); + let mut next = surface(); + next.surface_revision += 1; + next.frame = + FrameData::from_ratatui_buffer(&Buffer::with_lines(vec!["x".repeat(120); 30]), None); + let template = next.panes[0].clone(); + next.panes.clear(); + for index in 0..count { + let mut pane = template.clone(); + pane.pane_id = format!("pane_{index}"); + pane.inner_rect = SurfaceRect { + x: if count == 1 { 0 } else { (index % 5) * 24 }, + y: if count == 1 { 0 } else { (index / 5) * 10 }, + width: if count == 1 { 120 } else { 24 }, + height: if count == 1 { 30 } else { 10 }, + }; + pane.rect = pane.inner_rect; + next.panes.push(pane); + } + state.set_pane_surface(next); + state.compose(146, 32).unwrap(); + for active in [false, true] { + if active { + let pane = state.hits.panes.last().unwrap(); + let mouse = MouseEvent { + kind: MouseEventKind::Moved, + column: pane.inner_rect.x + 1, + row: pane.inner_rect.y, + modifiers: KeyModifiers::CONTROL, + }; + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + assert!(resolve_hover(&mut state, &id)); + } + let mut samples = Vec::new(); + for _ in 0..200 { + let started = std::time::Instant::now(); + std::hint::black_box(state.compose(146, 32).unwrap()); + samples.push(started.elapsed().as_micros()); + } + samples.sort_unstable(); + eprintln!( + "ctrl-hover panes={count} active={active} median_us={} p95_us={}", + samples[100], samples[190] + ); + assert!( + state.pending_requests.is_empty(), + "composition must never resolve links" + ); + } + } +} + +#[test] +fn ctrl_hover_groups_contiguous_same_destination_cells_without_server_query() { + let mut state = hover_state(); + state.set_endpoint_methods(Some(vec![])); + let mut next = surface(); + next.surface_revision += 1; + next.frame.hyperlinks.push("https://example.com/".into()); + for idx in 1..7 { + next.frame.cells[idx].hyperlink = Some(0); + } + state.set_pane_surface(next); + let mouse = hover_mouse(&state, 1, 0); + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]); + assert!(outcome.actions.is_empty()); + assert!(outcome.repaint); + assert_eq!(state.link_hover.as_ref().unwrap().regions.len(), 2); + + let mut next = state.pane_surface.as_ref().unwrap().clone(); + next.surface_revision += 1; + next.panes[0].content_revision += 2; + next.frame.cells[3].hyperlink = None; + state.set_pane_surface(next); + state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]); + let regions = &state.link_hover.as_ref().unwrap().regions; + assert_eq!(regions.len(), 1, "an unlinked gap must stop grouping"); + assert_eq!( + (regions[0].row, regions[0].start_col, regions[0].end_col), + (0, 1, 2) + ); +} diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs index a308c1f9..6633494c 100644 --- a/src/client/shell/tests/mod.rs +++ b/src/client/shell/tests/mod.rs @@ -251,6 +251,7 @@ mod endpoints; #[path = "input.rs"] mod input_domain; mod keybindings_settings; +mod link_hover; mod mobile; mod mouse_selection; mod popup_focus_projection; diff --git a/src/client/shell/worktrees.rs b/src/client/shell/worktrees.rs index 31ddbeee..d4253672 100644 --- a/src/client/shell/worktrees.rs +++ b/src/client/shell/worktrees.rs @@ -557,6 +557,7 @@ impl ClientShellState { | PendingEndpointKind::PaneScroll { .. } | PendingEndpointKind::WordSelection { .. } | PendingEndpointKind::PaneLinkActivate { .. } + | PendingEndpointKind::PaneLinkResolve { .. } | PendingEndpointKind::CopyMotion { .. } | PendingEndpointKind::CopySearch { .. }, Err(_), diff --git a/src/ghostty/bindings.rs b/src/ghostty/bindings.rs index 49a06ee5..bfa71825 100644 --- a/src/ghostty/bindings.rs +++ b/src/ghostty/bindings.rs @@ -1776,6 +1776,15 @@ unsafe extern "C" { ) -> GhosttyResult; } unsafe extern "C" { + #[doc = " Derive a word selection with a shared forward/backward cell inspection limit.\n\n Like ghostty_terminal_select_word(), but returns GHOSTTY_NO_VALUE if\n max_cells is zero or the search exhausts that budget. No partial selection\n is returned. The existing options structure and unbounded API are unchanged.\n\n @ingroup selection"] + pub fn ghostty_terminal_select_word_bounded( + terminal: GhosttyTerminal, + options: *const GhosttyTerminalSelectWordOptions, + max_cells: usize, + out_selection: *mut GhosttySelection, + ) -> GhosttyResult; +} +unsafe extern "C" { #[doc = " Derive the nearest word selection snapshot between two terminal grid refs.\n\n Starting at options->start, this searches toward options->end (inclusive)\n and returns the first selectable word found using Ghostty's word-selection\n rules.\n\n This is useful for implementing double-click-and-drag selection in a UI. If\n a user double-clicks one word and drags across spaces or punctuation toward\n another word, selecting only the word directly under the current pointer can\n flicker or collapse when the pointer is between words. Instead, ask for the\n nearest word between the original click and the drag point, ask again in the\n reverse direction, and combine the two word bounds into the drag selection.\n\n @snippet c-vt-selection/src/main.c selection-word-between\n\n The returned selection is not installed as the terminal's current\n selection. It is a snapshot with the same lifetime rules as GhosttySelection.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param options Word-between-selection options\n @param[out] out_selection On success, receives the derived selection\n @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if there is no\n selectable word content between the valid refs, or\n GHOSTTY_INVALID_VALUE if the terminal, options, refs, codepoint\n pointer, or output pointer are invalid.\n\n @ingroup selection"] pub fn ghostty_terminal_select_word_between( terminal: GhosttyTerminal, diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index 9ec9d324..d1748aad 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -806,6 +806,12 @@ pub fn encode_focus(event: FocusEvent) -> Result<Vec<u8>, Error> { Ok(buffer) } +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum LinkTarget { + Uri(String), + Text { text: String, clicked_byte: usize }, +} + pub struct Terminal { raw: ffi::GhosttyTerminal, max_scrollback: usize, @@ -1271,6 +1277,187 @@ impl Terminal { grid_ref_hyperlink_uri(&grid_ref) } + pub(crate) fn viewport_link_target(&self, x: u16, y: u32) -> Result<Option<LinkTarget>, Error> { + Ok(self + .viewport_link_selection(x, y)? + .map(|(target, _)| target)) + } + + fn viewport_link_selection( + &self, + x: u16, + y: u32, + ) -> Result<Option<(LinkTarget, Option<ffi::GhosttySelection>)>, Error> { + let mut clicked = self.grid_ref(ghostty_viewport_point(x, y))?; + if grid_ref_wide(&clicked)? == CellWide::SpacerTail { + clicked.x = clicked.x.saturating_sub(1); + } + if let Some(uri) = grid_ref_hyperlink_uri(&clicked)? { + return Ok(Some((LinkTarget::Uri(uri), None))); + } + let graphemes = grid_ref_graphemes(&clicked)?; + if graphemes + .first() + .copied() + .and_then(char::from_u32) + .is_none_or(char::is_whitespace) + { + return Ok(None); + } + // Unicode White_Space, matching Rust's char::is_whitespace URL boundaries. + const BOUNDARIES: &[u32] = &[ + 9, 10, 11, 12, 13, 32, 0x85, 0xa0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, + 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000, + ]; + let options = ffi::GhosttyTerminalSelectWordOptions { + size: mem::size_of::<ffi::GhosttyTerminalSelectWordOptions>(), + ref_: clicked, + boundary_codepoints: BOUNDARIES.as_ptr(), + boundary_codepoints_len: BOUNDARIES.len(), + }; + let mut selection = ffi::GhosttySelection::default(); + // Bound work before formatting, even for an unbroken scrollback-sized token. + let result = unsafe { + ffi::ghostty_terminal_select_word_bounded(self.raw, &options, 8192, &mut selection) + }; + if result == ffi::GhosttyResult_GHOSTTY_NO_VALUE { + return Ok(None); + } + result.into_result()?; + let text = self.format_selection(&selection, FormatterFormat::Plain, true, false)?; + let prefix = self.format_selection( + &ffi::GhosttySelection { + end: clicked, + ..selection + }, + FormatterFormat::Plain, + true, + false, + )?; + let clicked_len: usize = graphemes + .into_iter() + .filter_map(char::from_u32) + .map(char::len_utf8) + .sum(); + Ok(Some(( + LinkTarget::Text { + text, + clicked_byte: prefix.len().saturating_sub(clicked_len), + }, + Some(selection), + ))) + } + + /// Resolve only the bounded plain-text token. OSC 8 regions are resolved by + /// clients from frame hyperlink IDs; their full URI activation path is unchanged. + pub(crate) fn viewport_link_regions( + &self, + x: u16, + y: u32, + resolve: fn(&str, usize) -> Option<std::ops::Range<usize>>, + ) -> Result<Vec<crate::api::schema::PaneLinkRegion>, Error> { + let cols = self.cols()?; + let rows = self.rows()?; + if x >= cols || y >= u32::from(rows) { + return Ok(Vec::new()); + } + let Some((LinkTarget::Text { text, clicked_byte }, Some(selection))) = + self.viewport_link_selection(x, y)? + else { + return Ok(Vec::new()); + }; + let Some(range) = resolve(&text, clicked_byte) else { + return Ok(Vec::new()); + }; + let point = + |grid_ref: &ffi::GhosttyGridRef, clipped: (u16, u32)| -> Result<(u16, u32), Error> { + let mut out = ffi::GhosttyPointCoordinate::default(); + let result = unsafe { + ffi::ghostty_terminal_point_from_grid_ref( + self.raw, + grid_ref, + ffi::GhosttyPointTag_GHOSTTY_POINT_TAG_VIEWPORT, + &mut out, + ) + }; + if result == ffi::GhosttyResult_GHOSTTY_NO_VALUE { + return Ok(clipped); + } + result.into_result()?; + // C viewport coordinates can extend below the visible viewport + // when scrolled up. Clip those as well as NO_VALUE endpoints. + if out.y >= u32::from(rows) { + return Ok(clipped); + } + Ok((out.x, out.y)) + }; + // The selected token contains the visible hovered cell, so an invisible + // start/end necessarily lies above/below the viewport respectively. + let (start_col, start_row) = point(&selection.start, (0, 0))?; + let (end_col, end_row) = point(&selection.end, (cols - 1, u32::from(rows - 1)))?; + let first = self.grid_ref(ghostty_viewport_point(start_col, start_row))?; + let cell_len = |cell: &ffi::GhosttyGridRef| -> Result<usize, Error> { + if matches!( + grid_ref_wide(cell)?, + CellWide::SpacerHead | CellWide::SpacerTail + ) { + return Ok(0); + } + Ok(grid_ref_graphemes(cell)? + .into_iter() + .filter_map(char::from_u32) + .map(char::len_utf8) + .sum()) + }; + // Format a prefix once, not once per cell. Then walk only the selected + // visible cells, retaining one page/grid lookup per row. + let prefix = self.format_selection( + &ffi::GhosttySelection { + end: first, + ..selection + }, + FormatterFormat::Plain, + true, + false, + )?; + let mut byte = prefix.len().saturating_sub(cell_len(&first)?); + let mut regions: Vec<crate::api::schema::PaneLinkRegion> = Vec::new(); + for row in start_row..=end_row { + let mut cell = self.grid_ref(ghostty_viewport_point(0, row))?; + let left = if row == start_row { start_col } else { 0 }; + let right = if row == end_row { end_col } else { cols - 1 }; + for col in left..=right { + cell.x = col; + let wide = grid_ref_wide(&cell)?; + if matches!(wide, CellWide::SpacerHead | CellWide::SpacerTail) { + continue; + } + let len = cell_len(&cell)?; + if byte < range.end && byte + len > range.start { + let end = if wide == CellWide::Wide { + (col + 1).min(cols - 1) + } else { + col + }; + if let Some(last) = regions + .last_mut() + .filter(|last| u32::from(last.row) == row && last.end_col + 1 == col) + { + last.end_col = end; + } else { + regions.push(crate::api::schema::PaneLinkRegion { + row: row as u16, + start_col: col, + end_col: end, + }); + } + } + byte += len; + } + } + Ok(regions) + } + fn grid_ref(&self, point: ffi::GhosttyPoint) -> Result<ffi::GhosttyGridRef, Error> { let mut grid_ref = ffi::GhosttyGridRef { size: mem::size_of::<ffi::GhosttyGridRef>(), @@ -1375,6 +1562,16 @@ impl Terminal { end: end_ref, rectangle, }; + self.format_selection(&selection, format, unwrap, trim) + } + + fn format_selection( + &self, + selection: &ffi::GhosttySelection, + format: FormatterFormat, + unwrap: bool, + trim: bool, + ) -> Result<String, Error> { let mut formatter: ffi::GhosttyFormatter = ptr::null_mut(); let options = ffi::GhosttyFormatterTerminalOptions { size: mem::size_of::<ffi::GhosttyFormatterTerminalOptions>(), @@ -1389,7 +1586,7 @@ impl Terminal { }, ..Default::default() }, - selection: &selection, + selection, }; unsafe { ffi::ghostty_formatter_terminal_new(ptr::null(), &mut formatter, self.raw, options) @@ -3765,6 +3962,118 @@ mod tests { } #[test] + fn link_target_includes_offscreen_wrapped_rows() { + let url = "https://example.com/abcdefghijklmnopqrstuv"; + let mut terminal = Terminal::new(20, 2, 1024 * 1024).unwrap(); + terminal.write(url.as_bytes()); + let expected = LinkTarget::Text { + text: url.to_owned(), + clicked_byte: 20, + }; + assert_eq!(terminal.viewport_link_target(0, 0).unwrap(), Some(expected)); + terminal.scroll_viewport_row(0); + assert_eq!( + terminal.viewport_link_target(0, 0).unwrap(), + Some(LinkTarget::Text { + text: url.to_owned(), + clicked_byte: 0 + }) + ); + } + + #[test] + fn link_target_prefers_explicit_uri_and_does_not_join_hard_lines() { + let mut terminal = Terminal::new(20, 4, 1024).unwrap(); + terminal.write(b"\x1b]8;;https://example.com/real\x1b\\label\x1b]8;;\x1b\\\r\nhttps://example.com/\r\ntail"); + assert_eq!( + terminal.viewport_link_target(2, 0).unwrap(), + Some(LinkTarget::Uri("https://example.com/real".into())) + ); + assert_eq!( + terminal.viewport_link_target(1, 2).unwrap(), + Some(LinkTarget::Text { + text: "tail".into(), + clicked_byte: 1 + }) + ); + } + + #[test] + fn link_target_stops_at_hard_line_when_clicking_last_column() { + let mut terminal = Terminal::new(20, 3, 1024).unwrap(); + terminal.write(b"https://example.com/x\r\nnot-part-of-url"); + assert_eq!( + terminal.viewport_link_target(19, 0).unwrap(), + Some(LinkTarget::Text { + text: "https://example.com/x".into(), + clicked_byte: 19 + }) + ); + } + + #[test] + fn link_target_wide_explicit_label_works_on_both_cell_halves() { + let mut terminal = Terminal::new(20, 2, 1024).unwrap(); + terminal.write("\x1b]8;;https://example.com/\x1b\\路径\x1b]8;;\x1b\\".as_bytes()); + for col in 0..4 { + assert_eq!( + terminal.viewport_link_target(col, 0).unwrap(), + Some(LinkTarget::Uri("https://example.com/".into())) + ); + } + } + + #[test] + fn link_target_selection_budget_is_shared_across_both_directions() { + let mut terminal = Terminal::new(20, 2, 1024).unwrap(); + terminal.write(b" abc "); + let boundaries = [u32::from(b' ')]; + let options = ffi::GhosttyTerminalSelectWordOptions { + size: mem::size_of::<ffi::GhosttyTerminalSelectWordOptions>(), + ref_: terminal.grid_ref(ghostty_viewport_point(2, 0)).unwrap(), + boundary_codepoints: boundaries.as_ptr(), + boundary_codepoints_len: boundaries.len(), + }; + for budget in 0..=5 { + let mut selection = ffi::GhosttySelection::default(); + let result = unsafe { + ffi::ghostty_terminal_select_word_bounded( + terminal.raw, + &options, + budget, + &mut selection, + ) + }; + if budget < 5 { + assert_eq!( + result, + ffi::GhosttyResult_GHOSTTY_NO_VALUE, + "budget={budget}" + ); + } else { + result.into_result().unwrap(); + assert_eq!( + terminal + .format_selection(&selection, FormatterFormat::Plain, true, false) + .unwrap(), + "abc" + ); + } + } + } + + #[test] + fn link_target_rejects_oversized_token_without_returning_a_prefix() { + let mut terminal = Terminal::new(100, 4, 16 * 1024 * 1024).unwrap(); + let text = format!("https://example.com/{}", "a".repeat(1_000_000)); + terminal.write(text.as_bytes()); + let started = std::time::Instant::now(); + let target = terminal.viewport_link_target(0, 0).unwrap(); + eprintln!("million-byte token lookup: {:?}", started.elapsed()); + assert!(target.is_none()); + } + + #[test] fn terminal_read_text_viewport_unwraps_soft_wrapped_selection() { let mut terminal = Terminal::new(5, 3, 0).unwrap(); terminal.write("1ABCD2EFGH3IJKL".as_bytes()); diff --git a/src/pane.rs b/src/pane.rs index 0bb048ed..4ac86c23 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -3173,6 +3173,19 @@ impl PaneRuntime { self.terminal.visible_hyperlinks(area) } + pub(crate) fn link_regions_at( + &self, + col: u16, + row: u16, + resolve: fn(&str, usize) -> Option<std::ops::Range<usize>>, + ) -> Vec<crate::api::schema::PaneLinkRegion> { + self.terminal.link_regions_at(col, row, resolve) + } + + pub(crate) fn link_target_at(&self, col: u16, row: u16) -> Option<crate::ghostty::LinkTarget> { + self.terminal.link_target_at(col, row) + } + pub(crate) fn kitty_graphics_may_have_placements(&self) -> bool { self.terminal.kitty_graphics_may_have_placements() } diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index dfd4f536..931d0485 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -542,6 +542,19 @@ impl PaneTerminal { self.ghostty.visible_hyperlinks(area) } + pub(crate) fn link_regions_at( + &self, + col: u16, + row: u16, + resolve: fn(&str, usize) -> Option<std::ops::Range<usize>>, + ) -> Vec<crate::api::schema::PaneLinkRegion> { + self.ghostty.link_regions_at(col, row, resolve) + } + + pub(crate) fn link_target_at(&self, col: u16, row: u16) -> Option<crate::ghostty::LinkTarget> { + self.ghostty.link_target_at(col, row) + } + pub(crate) fn kitty_graphics_may_have_placements(&self) -> bool { self.ghostty.kitty_graphics_may_have_placements() } @@ -2206,6 +2219,33 @@ impl GhosttyPaneTerminal { .unwrap_or_default() } + pub(crate) fn link_regions_at( + &self, + col: u16, + row: u16, + resolve: fn(&str, usize) -> Option<std::ops::Range<usize>>, + ) -> Vec<crate::api::schema::PaneLinkRegion> { + self.core + .lock() + .ok() + .and_then(|core| { + core.terminal + .viewport_link_regions(col, u32::from(row), resolve) + .ok() + }) + .unwrap_or_default() + } + + pub(crate) fn link_target_at(&self, col: u16, row: u16) -> Option<crate::ghostty::LinkTarget> { + self.core + .lock() + .ok()? + .terminal + .viewport_link_target(col, u32::from(row)) + .ok() + .flatten() + } + pub fn extract_selection(&self, selection: &crate::selection::Selection) -> Option<String> { self.core .lock() diff --git a/src/server/client_commands.rs b/src/server/client_commands.rs index 0af1fad1..4494f053 100644 --- a/src/server/client_commands.rs +++ b/src/server/client_commands.rs @@ -26,6 +26,7 @@ const CLIENT_SHELL_METHODS: &[&str] = &[ "pane.focus_direction", "pane.input.set", "pane.link.activate", + "pane.link.resolve", "pane.rename", "pane.resize", "pane.scroll", @@ -285,11 +286,15 @@ mod tests { "/tests/fixtures/endpoint-method-shapes-v1.json" ))) .expect("endpoint method shape fixture"); - let actual = endpoint_method_shape_digests(); + let mut actual = endpoint_method_shape_digests(); + // Freeze the additive method separately without rewriting the published fixture. + assert_eq!( + actual.remove("pane.link.resolve").as_deref(), + Some("f5e4a3e01453ae7b188f127ce951c12c20e0bebcc17cc364eeb6d1a01fd5bf81") + ); assert_eq!( - actual, - expected, + actual, expected, "an existing endpoint method changed shape; add load-bearing behavior as a new advertised method or explicitly gate new fields" ); } @@ -354,6 +359,15 @@ mod tests { offset_from_bottom: None, }, ))); + assert!(supports_client_shell_method(&Method::PaneLinkResolve( + crate::api::schema::PaneLinkActivateParams { + pane_id: "w1:p1".into(), + viewport_row: 0, + col: 0, + content_revision: None, + offset_from_bottom: None, + }, + ))); assert!(!supports_client_shell_method(&Method::Ping( crate::api::schema::PingParams::default(), ))); diff --git a/src/server/headless/client_views.rs b/src/server/headless/client_views.rs index f0b0158d..886a658e 100644 --- a/src/server/headless/client_views.rs +++ b/src/server/headless/client_views.rs @@ -259,6 +259,7 @@ impl HeadlessServer { | Method::PaneFocusDirection(_) | Method::PaneInputSet(_) | Method::PaneLinkActivate(_) + | Method::PaneLinkResolve(_) | Method::PaneRename(_) | Method::PaneResize(_) | Method::PaneScroll(_) diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index a409545b..46795791 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -423,6 +423,19 @@ impl TerminalRuntime { self.0.visible_hyperlinks(area) } + pub(crate) fn link_regions_at( + &self, + col: u16, + row: u16, + resolve: fn(&str, usize) -> Option<std::ops::Range<usize>>, + ) -> Vec<crate::api::schema::PaneLinkRegion> { + self.0.link_regions_at(col, row, resolve) + } + + pub(crate) fn link_target_at(&self, col: u16, row: u16) -> Option<crate::ghostty::LinkTarget> { + self.0.link_target_at(col, row) + } + pub fn kitty_image_placements_with_data_filter<F>( &self, needs_data: F, |
