fix: avoid render-time plain url scans
This commit is contained in:
parent
35e9838a44
commit
d6285aaa59
|
|
@ -2122,20 +2122,6 @@ pub(crate) fn url_at_column(row: &str, col: u16) -> Option<&str> {
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -616,92 +616,70 @@ mod tests {
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_stream_synthesizes_soft_wrapped_url_hyperlinks() {
|
||||
async fn render_stream_does_not_synthesize_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 (app, _info) = app_with_screen_bytes(url.as_bytes());
|
||||
|
||||
let links = crate::server::render_stream::visible_hyperlinks(
|
||||
&app.state,
|
||||
&app.terminal_runtimes,
|
||||
None,
|
||||
);
|
||||
let links =
|
||||
crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes);
|
||||
|
||||
assert!(links.iter().any(|((x, y), symbol, uri)| {
|
||||
*x == info.inner_rect.x + 1
|
||||
&& *y == info.inner_rect.y + 1
|
||||
&& symbol == "a"
|
||||
&& uri == &url
|
||||
}));
|
||||
assert!(links.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_stream_does_not_shift_url_hyperlinks_after_zero_width_mark() {
|
||||
async fn render_stream_does_not_synthesize_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 (app, _info) = app_with_screen_bytes(screen.as_bytes());
|
||||
|
||||
let links = crate::server::render_stream::visible_hyperlinks(
|
||||
&app.state,
|
||||
&app.terminal_runtimes,
|
||||
None,
|
||||
);
|
||||
let links =
|
||||
crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes);
|
||||
|
||||
assert!(links.iter().any(|((x, y), symbol, uri)| {
|
||||
*x == info.inner_rect.x + 2 && *y == info.inner_rect.y && symbol == "h" && uri == url
|
||||
}));
|
||||
assert!(links.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn render_stream_handles_hard_newline_after_full_row() {
|
||||
async fn render_stream_does_not_synthesize_hard_newline_plain_url_hyperlinks() {
|
||||
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,
|
||||
);
|
||||
let (app, _info) = app_with_screen_bytes(screen.as_bytes());
|
||||
let links =
|
||||
crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes);
|
||||
|
||||
assert!(links.iter().any(|((x, y), symbol, uri)| {
|
||||
*x == info.inner_rect.x + 1
|
||||
&& *y == info.inner_rect.y + 2
|
||||
&& symbol == "t"
|
||||
&& uri == url
|
||||
}));
|
||||
assert!(links.is_empty());
|
||||
}
|
||||
|
||||
#[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,
|
||||
async fn render_stream_exports_osc8_hyperlink_metadata() {
|
||||
let uri = "https://example.com/target";
|
||||
let (mut app, _info) =
|
||||
app_with_screen_bytes(format!("\x1b]8;;{uri}\x1b\\label\x1b]8;;\x1b\\").as_bytes());
|
||||
let (buffer, cursor) = crate::server::render_stream::render_virtual_with_runtime_registry(
|
||||
&mut app.state,
|
||||
&app.terminal_runtimes,
|
||||
Some(&buffer),
|
||||
ratatui::layout::Rect::new(0, 0, 106, 20),
|
||||
false,
|
||||
crate::kitty_graphics::HostCellSize::default(),
|
||||
);
|
||||
|
||||
assert!(!links
|
||||
let links =
|
||||
crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes);
|
||||
let frame = crate::protocol::FrameData::from_ratatui_buffer_with_hyperlinks(
|
||||
&buffer, cursor, &links,
|
||||
);
|
||||
let ((x, y), symbol, _) = links
|
||||
.iter()
|
||||
.any(|((x, y), _, uri)| *x == target_x && *y == target_y && uri == &line[4..]));
|
||||
.find(|(_, symbol, link_uri)| symbol == "l" && link_uri == uri)
|
||||
.expect("OSC 8 link cell");
|
||||
let linked_cell_index = usize::from(*y) * usize::from(frame.width) + usize::from(*x);
|
||||
|
||||
assert_eq!(frame.hyperlinks, vec![uri.to_owned()]);
|
||||
assert_eq!(symbol, "l");
|
||||
assert_eq!(frame.cells[linked_cell_index].hyperlink, Some(0));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
|
|
|||
|
|
@ -163,13 +163,6 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -3203,9 +3196,6 @@ 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");
|
||||
}
|
||||
|
|
@ -3382,7 +3372,6 @@ 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",
|
||||
|
|
@ -7524,7 +7513,7 @@ next_tab = ""
|
|||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retained_pty_update_declines_when_dirty_row_creates_plain_url() {
|
||||
async fn retained_pty_update_allows_dirty_row_that_creates_plain_url() {
|
||||
let (mut server, client_rx, pane_id) = retained_test_server(b"plain");
|
||||
server.render_and_stream();
|
||||
let _ = client_rx
|
||||
|
|
@ -7538,20 +7527,15 @@ next_tab = ""
|
|||
.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(
|
||||
assert!(server.render_retained_pty_update_and_stream());
|
||||
let patched = read_server_frame(
|
||||
client_rx
|
||||
.recv_timeout(Duration::from_millis(100))
|
||||
.expect("full frame after plain URL"),
|
||||
.expect("retained frame after plain URL"),
|
||||
);
|
||||
assert!(
|
||||
full.hyperlinks
|
||||
.iter()
|
||||
.any(|uri| uri == "https://example.com/new"),
|
||||
"full render should synthesize plain URL hyperlink metadata"
|
||||
patched.hyperlinks.is_empty(),
|
||||
"retained render should not synthesize plain URL hyperlink metadata"
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
//! 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};
|
||||
|
||||
|
|
@ -9,8 +7,7 @@ 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::selection::Selection;
|
||||
use crate::terminal::{TerminalRuntime, TerminalRuntimeRegistry};
|
||||
use crate::terminal::TerminalRuntimeRegistry;
|
||||
|
||||
/// Per-client render baseline for the negotiated render encoding.
|
||||
pub(crate) enum ClientRenderState {
|
||||
|
|
@ -363,7 +360,6 @@ 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();
|
||||
|
|
@ -373,143 +369,16 @@ pub(crate) fn visible_hyperlinks(
|
|||
}
|
||||
|
||||
let mut links = Vec::new();
|
||||
let mut occupied = HashSet::new();
|
||||
for info in &app_state.view.pane_infos {
|
||||
if let Some(runtime) =
|
||||
app_state.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id)
|
||||
{
|
||||
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.extend(runtime.visible_hyperlinks(info.inner_rect));
|
||||
}
|
||||
}
|
||||
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(
|
||||
app_state: &AppState,
|
||||
terminal_runtimes: &TerminalRuntimeRegistry,
|
||||
|
|
|
|||
Loading…
Reference in New Issue