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/app | |
| parent | 59167658455442ffd2cd5b72a0fa81bcc4bcdf9e (diff) | |
feat: add ctrl-hover highlighting and fix wrapped links (#4074)
refs #1282
Diffstat (limited to 'src/app')
| -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 |
3 files changed, 318 insertions, 198 deletions
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( |
