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/api/plugins | |
| parent | 59167658455442ffd2cd5b72a0fa81bcc4bcdf9e (diff) | |
feat: add ctrl-hover highlighting and fix wrapped links (#4074)
refs #1282
Diffstat (limited to 'src/app/api/plugins')
| -rw-r--r-- | src/app/api/plugins/mod.rs | 143 |
1 files changed, 116 insertions, 27 deletions
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( |
