aboutsummaryrefslogtreecommitdiff
path: root/src/app/api/plugins
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-13 23:05:30 +0300
committerGitHub <noreply@github.com>2026-09-13 23:05:30 +0300
commitf8e59221cbe5117f8f691a36affa374f5a986a9f (patch)
tree3b2792683eff251b061fbbad7343ecffafe91aab /src/app/api/plugins
parent59167658455442ffd2cd5b72a0fa81bcc4bcdf9e (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.rs143
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(&params.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, &params, "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, &params, "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", &params, "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(