aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-07-06 23:59:59 +0300
committerGitHub <noreply@github.com>2026-07-06 23:59:59 +0300
commit4f22e9a73ba4d9029468e078235c5f5ce629d179 (patch)
tree49b56a29305192c8270aa43e87bb5f1d86d00233 /src
parent56edcbcba83b8ff7f6f69ee4231f163e97df3346 (diff)
fix: preserve wrapped pane links (#1099)
refs #1098 Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: Can Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs136
-rw-r--r--src/app/input/terminal.rs144
-rw-r--r--src/server/headless.rs43
-rw-r--r--src/server/render_stream.rs148
4 files changed, 453 insertions, 18 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 019b6692..52ebbbb7 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -1999,15 +1999,23 @@ impl AppState {
}
let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id);
- let row_selection = Selection::range(
+ let visible_selection = Selection::line_range(
pane_id,
- viewport_row,
- 0,
+ Selection::absolute_row_for_viewport(0, metrics),
+ Selection::absolute_row_for_viewport(info.inner_rect.height.saturating_sub(1), metrics),
info.inner_rect.width.saturating_sub(1),
- metrics,
);
- let row_text = rt.extract_selection(&row_selection)?;
- url_at_column(&row_text, col).map(str::to_owned)
+ let visible_text = rt.extract_selection(&visible_selection)?;
+ let logical_cell =
+ logical_cell_for_visible_cell(&visible_text, info.inner_rect.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 fn copy_selection(&mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry) {
@@ -2088,12 +2096,126 @@ fn word_bounds_at_column(row: &str, col: u16) -> Option<(u16, u16)> {
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_span_at_column(&cells, clicked_idx)?;
+ 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)?)
}
+pub(crate) fn visible_url_spans(row: &str) -> Vec<(u16, u16, &str)> {
+ let cells = text_cells(row);
+ url_spans(&cells)
+ .into_iter()
+ .filter_map(|span| {
+ let start_byte = byte_index_for_cell(row, span.start);
+ let end_byte = byte_index_after_cell(row, span.end);
+ let url = row.get(start_byte..end_byte).and_then(safe_web_url)?;
+ let (start_col, end_col) = span.columns(&cells);
+ Some((start_col, end_col, url))
+ })
+ .collect()
+}
+
+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 = UnicodeWidthChar::width(ch).unwrap_or(0) as u16;
+ 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 = UnicodeWidthChar::width(cell.ch).unwrap_or(0) as u16;
+ 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)
+ }
+ })
+}
+
fn token_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option<CellSpan> {
if is_word_separator(cells[clicked_idx].ch) {
return None;
diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs
index 282fa263..c9abbbc2 100644
--- a/src/app/input/terminal.rs
+++ b/src/app/input/terminal.rs
@@ -561,6 +561,150 @@ mod tests {
}
#[tokio::test]
+ async fn pane_cell_url_resolver_finds_soft_wrapped_url() {
+ let (_app, info) = app_with_screen_bytes(b"");
+ let prefix = "https://example.com/";
+ let padding = "a".repeat(info.inner_rect.width as usize - prefix.len());
+ let url = format!("{prefix}{padding}tail");
+ let (app, _info) = app_with_screen_bytes(url.as_bytes());
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+
+ assert_eq!(
+ app.state
+ .url_at_pane_cell(&app.terminal_runtimes, pane_id, 1, 1)
+ .as_deref(),
+ Some(url.as_str())
+ );
+ }
+
+ #[tokio::test]
+ async fn pane_cell_url_resolver_does_not_shift_after_zero_width_mark() {
+ let url = "https://example.com/mark";
+ let screen = format!("e\u{301} {url}");
+ let (app, _info) = app_with_screen_bytes(screen.as_bytes());
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+
+ assert_eq!(
+ app.state
+ .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, 2)
+ .as_deref(),
+ Some(url)
+ );
+ }
+
+ #[tokio::test]
+ async fn pane_cell_url_resolver_handles_hard_newline_after_full_row() {
+ let (_app, info) = app_with_screen_bytes(b"");
+ let full_row = "x".repeat(info.inner_rect.width as usize);
+ let url = "https://example.com/next";
+ let screen = format!("{full_row}\n{url}");
+ let (app, _info) = app_with_screen_bytes(screen.as_bytes());
+ let pane_id = app.state.workspaces[0].tabs[0].root_pane;
+
+ assert_eq!(
+ app.state
+ .url_at_pane_cell(&app.terminal_runtimes, pane_id, 1, 1)
+ .as_deref(),
+ None
+ );
+ assert_eq!(
+ app.state
+ .url_at_pane_cell(&app.terminal_runtimes, pane_id, 2, 1)
+ .as_deref(),
+ Some(url)
+ );
+ }
+
+ #[tokio::test]
+ async fn render_stream_synthesizes_soft_wrapped_url_hyperlinks() {
+ let (_app, info) = app_with_screen_bytes(b"");
+ let prefix = "https://example.com/";
+ let padding = "b".repeat(info.inner_rect.width as usize - prefix.len());
+ let url = format!("{prefix}{padding}tail");
+ let (app, info) = app_with_screen_bytes(url.as_bytes());
+
+ let links = crate::server::render_stream::visible_hyperlinks(
+ &app.state,
+ &app.terminal_runtimes,
+ None,
+ );
+
+ assert!(links.iter().any(|((x, y), symbol, uri)| {
+ *x == info.inner_rect.x + 1
+ && *y == info.inner_rect.y + 1
+ && symbol == "a"
+ && uri == &url
+ }));
+ }
+
+ #[tokio::test]
+ async fn render_stream_does_not_shift_url_hyperlinks_after_zero_width_mark() {
+ let url = "https://example.com/mark";
+ let screen = format!("e\u{301} {url}");
+ let (app, info) = app_with_screen_bytes(screen.as_bytes());
+
+ let links = crate::server::render_stream::visible_hyperlinks(
+ &app.state,
+ &app.terminal_runtimes,
+ None,
+ );
+
+ assert!(links.iter().any(|((x, y), symbol, uri)| {
+ *x == info.inner_rect.x + 2 && *y == info.inner_rect.y && symbol == "h" && uri == url
+ }));
+ }
+
+ #[tokio::test]
+ async fn render_stream_handles_hard_newline_after_full_row() {
+ let (_app, info) = app_with_screen_bytes(b"");
+ let full_row = "x".repeat(info.inner_rect.width as usize);
+ let url = "https://example.com/next";
+ let screen = format!("{full_row}\n{url}");
+ let (app, info) = app_with_screen_bytes(screen.as_bytes());
+ let links = crate::server::render_stream::visible_hyperlinks(
+ &app.state,
+ &app.terminal_runtimes,
+ None,
+ );
+
+ assert!(links.iter().any(|((x, y), symbol, uri)| {
+ *x == info.inner_rect.x + 1
+ && *y == info.inner_rect.y + 2
+ && symbol == "t"
+ && uri == url
+ }));
+ }
+
+ #[tokio::test]
+ async fn render_stream_filters_plain_url_when_final_cell_differs() {
+ let line = "see https://example.com/hidden";
+ let (mut app, _info) = app_with_screen_bytes(line.as_bytes());
+ let (mut buffer, _cursor) =
+ crate::server::render_stream::render_virtual_with_runtime_registry(
+ &mut app.state,
+ &app.terminal_runtimes,
+ ratatui::layout::Rect::new(0, 0, 106, 20),
+ false,
+ crate::kitty_graphics::HostCellSize::default(),
+ );
+ let info = app.state.view.pane_infos[0].clone();
+ let target_x = info.inner_rect.x + line.find("https").expect("url start") as u16;
+ let target_y = info.inner_rect.y;
+ buffer[(target_x, target_y)]
+ .set_style(ratatui::style::Style::default().fg(ratatui::style::Color::Red));
+
+ let links = crate::server::render_stream::visible_hyperlinks(
+ &app.state,
+ &app.terminal_runtimes,
+ Some(&buffer),
+ );
+
+ assert!(!links
+ .iter()
+ .any(|((x, y), _, uri)| *x == target_x && *y == target_y && uri == &line[4..]));
+ }
+
+ #[tokio::test]
async fn pane_cell_url_resolver_prefers_osc8_hyperlink() {
let (app, _info) = app_with_screen_bytes(
b"\x1b]8;;https://example.com/hidden-target\x1b\\label\x1b]8;;\x1b\\",
diff --git a/src/server/headless.rs b/src/server/headless.rs
index e84dfc59..04320ff2 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -163,6 +163,13 @@ fn dirty_patch_intersects_hyperlinks(
false
}
+fn dirty_patch_contains_plain_url_candidate(patch: &crate::pane::TerminalDirtyPatch) -> bool {
+ patch.rows.iter().any(|(_, row_cells)| {
+ let row: String = row_cells.iter().map(|cell| cell.symbol.as_str()).collect();
+ !crate::app::actions::visible_url_spans(&row).is_empty()
+ })
+}
+
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
@@ -3196,6 +3203,9 @@ impl HeadlessServer {
if dirty_patch_intersects_hyperlinks(&frame, info.inner_rect, &patch) {
retained_fallback!("hyperlink_intersection");
}
+ if dirty_patch_contains_plain_url_candidate(&patch) {
+ retained_fallback!("plain_url_candidate");
+ }
if !apply_terminal_dirty_patch(&mut frame, info.inner_rect, patch) {
retained_fallback!("patch_apply_failed");
}
@@ -3372,6 +3382,7 @@ impl HeadlessServer {
let hyperlinks = crate::server::render_stream::visible_hyperlinks(
&self.app.state,
&self.app.terminal_runtimes,
+ Some(&buffer),
);
crate::render_prof::duration_since(
"full_render.visible_hyperlinks",
@@ -7513,6 +7524,38 @@ next_tab = ""
}
#[tokio::test]
+ async fn retained_pty_update_declines_when_dirty_row_creates_plain_url() {
+ let (mut server, client_rx, pane_id) = retained_test_server(b"plain");
+ server.render_and_stream();
+ let _ = client_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("initial frame");
+
+ let runtime = server
+ .app
+ .state
+ .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id)
+ .expect("runtime");
+ runtime.test_process_pty_bytes(b"\rhttps://example.com/new");
+
+ assert!(!server.render_retained_pty_update_and_stream());
+ assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err());
+
+ server.render_and_stream();
+ let full = read_server_frame(
+ client_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("full frame after plain URL"),
+ );
+ assert!(
+ full.hyperlinks
+ .iter()
+ .any(|uri| uri == "https://example.com/new"),
+ "full render should synthesize plain URL hyperlink metadata"
+ );
+ }
+
+ #[tokio::test]
async fn retained_pty_update_allows_kitty_enabled_empty_graphics_cache() {
let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa");
server.app.state.kitty_graphics_enabled = true;
diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs
index 58f7a456..4368bde6 100644
--- a/src/server/render_stream.rs
+++ b/src/server/render_stream.rs
@@ -1,5 +1,7 @@
//! Virtual rendering helpers for headless client frame streaming.
+use std::collections::HashSet;
+
use ratatui::backend::{Backend, ClearType, TestBackend, WindowSize};
use ratatui::layout::{Position, Rect, Size};
@@ -7,7 +9,8 @@ use crate::app::state::AppState;
use crate::app::Mode;
use crate::protocol::render_ansi::{BlitEncoder, EncodedBlit};
use crate::protocol::{CursorState, FrameData, RenderEncoding, ServerMessage, TerminalFrame};
-use crate::terminal::TerminalRuntimeRegistry;
+use crate::selection::Selection;
+use crate::terminal::{TerminalRuntime, TerminalRuntimeRegistry};
/// Per-client render baseline for the negotiated render encoding.
pub(crate) enum ClientRenderState {
@@ -360,28 +363,151 @@ pub(crate) fn render_terminal_virtual(
pub(crate) fn visible_hyperlinks(
app_state: &AppState,
terminal_runtimes: &TerminalRuntimeRegistry,
+ final_buffer: Option<&ratatui::buffer::Buffer>,
) -> Vec<((u16, u16), String, String)> {
let Some(ws_idx) = app_state.active else {
return Vec::new();
};
- let Some(tab) = app_state
- .workspaces
- .get(ws_idx)
- .and_then(crate::workspace::Workspace::active_tab)
- else {
+ if app_state.workspaces.get(ws_idx).is_none() {
return Vec::new();
- };
+ }
let mut links = Vec::new();
+ let mut occupied = HashSet::new();
for info in &app_state.view.pane_infos {
- if let Some(runtime) = tab
- .terminal_id(info.id)
- .and_then(|terminal_id| terminal_runtimes.get(terminal_id))
+ if let Some(runtime) =
+ app_state.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)
{
- links.extend(runtime.visible_hyperlinks(info.inner_rect));
+ for link in runtime.visible_hyperlinks(info.inner_rect) {
+ occupied.insert(link.0);
+ links.push(link);
+ }
+ let metrics = app_state.pane_scroll_metrics(terminal_runtimes, info.id);
+ for link in plain_url_hyperlinks_for_pane(
+ runtime,
+ info.id,
+ info.inner_rect,
+ metrics,
+ final_buffer,
+ ) {
+ if occupied.insert(link.0) {
+ links.push(link);
+ }
+ }
+ }
+ }
+ links
+}
+
+fn plain_url_hyperlinks_for_pane(
+ runtime: &TerminalRuntime,
+ pane_id: crate::layout::PaneId,
+ area: Rect,
+ metrics: Option<crate::pane::ScrollMetrics>,
+ final_buffer: Option<&ratatui::buffer::Buffer>,
+) -> Vec<((u16, u16), String, String)> {
+ if area.width == 0 || area.height == 0 {
+ return Vec::new();
+ }
+
+ let selection = Selection::line_range(
+ pane_id,
+ Selection::absolute_row_for_viewport(0, metrics),
+ Selection::absolute_row_for_viewport(area.height.saturating_sub(1), metrics),
+ area.width.saturating_sub(1),
+ );
+ let Some(text) = runtime.extract_selection(&selection) else {
+ return Vec::new();
+ };
+ let mut links = Vec::new();
+ let cells = crate::app::actions::visible_text_cells(&text, area.width);
+ let mut line_start = 0;
+ for (byte_idx, ch) in text.char_indices() {
+ if ch == '\n' {
+ push_plain_url_line_links(
+ &mut links,
+ &text[line_start..byte_idx],
+ line_start,
+ &cells,
+ area,
+ );
+ line_start = byte_idx + ch.len_utf8();
}
}
+ push_plain_url_line_links(&mut links, &text[line_start..], line_start, &cells, area);
+ if links.is_empty() {
+ return links;
+ }
+ let pane_buffer = final_buffer.and_then(|_| render_runtime_buffer(runtime, area));
links
+ .into_iter()
+ .filter(|((x, y), _, _)| {
+ pane_cell_is_visible(
+ x.saturating_sub(area.x),
+ y.saturating_sub(area.y),
+ *x,
+ *y,
+ pane_buffer.as_ref(),
+ final_buffer,
+ )
+ })
+ .collect()
+}
+
+fn render_runtime_buffer(runtime: &TerminalRuntime, area: Rect) -> Option<ratatui::buffer::Buffer> {
+ let backend = TestBackend::new(area.width, area.height);
+ let mut terminal = ratatui::Terminal::new(backend).ok()?;
+ terminal
+ .draw(|frame| {
+ runtime.render(frame, Rect::new(0, 0, area.width, area.height), false);
+ })
+ .ok()?;
+ Some(terminal.backend().buffer().clone())
+}
+
+fn push_plain_url_line_links(
+ links: &mut Vec<((u16, u16), String, String)>,
+ line: &str,
+ line_start: usize,
+ cells: &[crate::app::actions::VisibleTextCell],
+ area: Rect,
+) {
+ for (start_col, end_col, url) in crate::app::actions::visible_url_spans(line) {
+ for cell in cells.iter().filter(|cell| {
+ cell.byte_index >= line_start
+ && cell.byte_index < line_start + line.len()
+ && cell.logical_col >= start_col
+ && cell.logical_col <= end_col
+ && cell.screen_row < area.height
+ }) {
+ let x = area.x.saturating_add(cell.screen_col);
+ let y = area.y.saturating_add(cell.screen_row);
+ links.push(((x, y), cell.ch.to_string(), url.to_owned()));
+ }
+ }
+}
+
+fn pane_cell_is_visible(
+ pane_x: u16,
+ pane_y: u16,
+ frame_x: u16,
+ frame_y: u16,
+ pane_buffer: Option<&ratatui::buffer::Buffer>,
+ final_buffer: Option<&ratatui::buffer::Buffer>,
+) -> bool {
+ let (Some(pane_buffer), Some(final_buffer)) = (pane_buffer, final_buffer) else {
+ return true;
+ };
+ let Some(pane_cell) = pane_buffer.cell((pane_x, pane_y)) else {
+ return false;
+ };
+ let Some(final_cell) = final_buffer.cell((frame_x, frame_y)) else {
+ return false;
+ };
+ pane_cell.symbol() == final_cell.symbol()
+ && pane_cell.fg == final_cell.fg
+ && pane_cell.bg == final_cell.bg
+ && pane_cell.modifier == final_cell.modifier
}
pub(crate) fn focused_terminal_cursor(