aboutsummaryrefslogtreecommitdiff
path: root/src/app
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
parent59167658455442ffd2cd5b72a0fa81bcc4bcdf9e (diff)
feat: add ctrl-hover highlighting and fix wrapped links (#4074)
refs #1282
Diffstat (limited to 'src/app')
-rw-r--r--src/app/actions.rs360
-rw-r--r--src/app/api.rs13
-rw-r--r--src/app/api/plugins/mod.rs143
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(&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(