parent
dcfe11a758
commit
b0aa7b2cdd
|
|
@ -941,7 +941,7 @@ mod tests {
|
|||
id: "req_1".into(),
|
||||
result: ResponseResult::Pong {
|
||||
version: "0.1.2".into(),
|
||||
protocol: 2,
|
||||
protocol: 3,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -178,7 +178,11 @@ fn build_sgr(fg: u32, bg: u32, modifier: u16) -> String {
|
|||
/// Checks if two cells are visually identical.
|
||||
#[cfg(test)]
|
||||
fn cells_equal(a: &CellData, b: &CellData) -> bool {
|
||||
a.symbol == b.symbol && a.fg == b.fg && a.bg == b.bg && a.modifier == b.modifier
|
||||
a.symbol == b.symbol
|
||||
&& a.fg == b.fg
|
||||
&& a.bg == b.bg
|
||||
&& a.modifier == b.modifier
|
||||
&& a.hyperlink == b.hyperlink
|
||||
// Skip flag is only for ratatui internal use, not visual.
|
||||
}
|
||||
|
||||
|
|
@ -226,6 +230,11 @@ fn blit_frame_to_with_cursor_memory(
|
|||
// on terminals that render the hardware cursor at intermediate CUP positions.
|
||||
let _ = writer.write_all(b"\x1b[?25l");
|
||||
|
||||
// Start each frame from a known OSC 8 state. If a previous write was
|
||||
// interrupted or the outer terminal had an active hyperlink, unlinked cells
|
||||
// must not inherit it.
|
||||
let _ = writer.write_all(b"\x1b]8;;\x1b\\");
|
||||
|
||||
if full_redraw {
|
||||
// Clear the screen and write all cells.
|
||||
let _ = writer.write_all(b"\x1b[2J\x1b[H");
|
||||
|
|
@ -338,6 +347,7 @@ fn write_ime_anchor_cursor_state(writer: &mut impl Write, cursor: HostCursorStat
|
|||
}
|
||||
|
||||
fn write_all_cells(writer: &mut impl Write, frame: &FrameData) {
|
||||
let mut active_hyperlink = None;
|
||||
for row in 0..frame.height {
|
||||
let mut to_skip = 0usize;
|
||||
for col in 0..frame.width {
|
||||
|
|
@ -360,17 +370,87 @@ fn write_all_cells(writer: &mut impl Write, frame: &FrameData) {
|
|||
let sgr = build_sgr(cell.fg, cell.bg, cell.modifier);
|
||||
let _ = writer.write_all(sgr.as_bytes());
|
||||
|
||||
write_hyperlink_if_changed(
|
||||
writer,
|
||||
&mut active_hyperlink,
|
||||
cell_hyperlink_uri(frame, cell),
|
||||
);
|
||||
|
||||
// Write the symbol.
|
||||
let _ = writer.write_all(cell.symbol.as_bytes());
|
||||
to_skip = cell_width(cell).saturating_sub(1);
|
||||
}
|
||||
}
|
||||
|
||||
close_hyperlink(writer, &mut active_hyperlink);
|
||||
|
||||
// Reset style at the end.
|
||||
let _ = writer.write_all(b"\x1b[0m");
|
||||
}
|
||||
|
||||
fn write_cell(writer: &mut impl Write, row: u16, col: u16, cell: &CellData, last_sgr: &mut String) {
|
||||
fn cell_hyperlink_uri<'a>(frame: &'a FrameData, cell: &CellData) -> Option<&'a str> {
|
||||
let index = cell.hyperlink? as usize;
|
||||
frame.hyperlinks.get(index).map(String::as_str)
|
||||
}
|
||||
|
||||
fn sanitized_hyperlink_uri(uri: &str) -> Option<String> {
|
||||
let sanitized: String = uri
|
||||
.chars()
|
||||
.filter(|ch| *ch != '\x1b' && *ch != '\x07' && !ch.is_control())
|
||||
.collect();
|
||||
(!sanitized.is_empty()).then_some(sanitized)
|
||||
}
|
||||
|
||||
fn sanitized_frame_hyperlinks(frame: &FrameData) -> Vec<Option<String>> {
|
||||
frame
|
||||
.hyperlinks
|
||||
.iter()
|
||||
.map(|uri| sanitized_hyperlink_uri(uri))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn sanitized_cell_hyperlink_uri<'a>(
|
||||
sanitized_hyperlinks: &'a [Option<String>],
|
||||
cell: &CellData,
|
||||
) -> Option<&'a str> {
|
||||
let index = cell.hyperlink? as usize;
|
||||
sanitized_hyperlinks.get(index)?.as_deref()
|
||||
}
|
||||
|
||||
fn write_hyperlink_if_changed(
|
||||
writer: &mut impl Write,
|
||||
active: &mut Option<String>,
|
||||
requested: Option<&str>,
|
||||
) {
|
||||
let requested = requested.and_then(sanitized_hyperlink_uri);
|
||||
if active.as_deref() == requested.as_deref() {
|
||||
return;
|
||||
}
|
||||
|
||||
if active.is_some() {
|
||||
let _ = writer.write_all(b"\x1b]8;;\x1b\\");
|
||||
}
|
||||
*active = requested;
|
||||
if let Some(uri) = active.as_deref() {
|
||||
let _ = write!(writer, "\x1b]8;;{uri}\x1b\\");
|
||||
}
|
||||
}
|
||||
|
||||
fn close_hyperlink(writer: &mut impl Write, active: &mut Option<String>) {
|
||||
if active.take().is_some() {
|
||||
let _ = writer.write_all(b"\x1b]8;;\x1b\\");
|
||||
}
|
||||
}
|
||||
|
||||
fn write_cell(
|
||||
writer: &mut impl Write,
|
||||
row: u16,
|
||||
col: u16,
|
||||
cell: &CellData,
|
||||
last_sgr: &mut String,
|
||||
active_hyperlink: &mut Option<String>,
|
||||
frame: &FrameData,
|
||||
) {
|
||||
if cell.skip {
|
||||
return;
|
||||
}
|
||||
|
|
@ -383,12 +463,31 @@ fn write_cell(writer: &mut impl Write, row: u16, col: u16, cell: &CellData, last
|
|||
*last_sgr = sgr;
|
||||
}
|
||||
|
||||
write_hyperlink_if_changed(writer, active_hyperlink, cell_hyperlink_uri(frame, cell));
|
||||
let _ = writer.write_all(cell.symbol.as_bytes());
|
||||
}
|
||||
|
||||
/// Writes only the cells that changed between the previous and current frame.
|
||||
fn cells_visually_equal(
|
||||
sanitized_hyperlinks: &[Option<String>],
|
||||
cell: &CellData,
|
||||
prev_sanitized_hyperlinks: &[Option<String>],
|
||||
prev_cell: &CellData,
|
||||
) -> bool {
|
||||
cell.symbol == prev_cell.symbol
|
||||
&& cell.fg == prev_cell.fg
|
||||
&& cell.bg == prev_cell.bg
|
||||
&& cell.modifier == prev_cell.modifier
|
||||
&& sanitized_cell_hyperlink_uri(sanitized_hyperlinks, cell)
|
||||
== sanitized_cell_hyperlink_uri(prev_sanitized_hyperlinks, prev_cell)
|
||||
// Skip flag is only for ratatui internal use, not visual.
|
||||
}
|
||||
|
||||
fn write_changed_cells(writer: &mut impl Write, frame: &FrameData, prev: &FrameData) {
|
||||
let mut last_sgr = String::new(); // Track last SGR to avoid redundant style changes.
|
||||
let mut active_hyperlink = None;
|
||||
let sanitized_hyperlinks = sanitized_frame_hyperlinks(frame);
|
||||
let prev_sanitized_hyperlinks = sanitized_frame_hyperlinks(prev);
|
||||
|
||||
for row in 0..frame.height {
|
||||
let mut invalidated = 0usize;
|
||||
|
|
@ -399,8 +498,24 @@ fn write_changed_cells(writer: &mut impl Write, frame: &FrameData, prev: &FrameD
|
|||
let cell = &frame.cells[idx];
|
||||
let prev_cell = &prev.cells[idx];
|
||||
|
||||
if !cell.skip && (cell != prev_cell || invalidated > 0) && to_skip == 0 {
|
||||
write_cell(writer, row, col, cell, &mut last_sgr);
|
||||
if !cell.skip
|
||||
&& (!cells_visually_equal(
|
||||
&sanitized_hyperlinks,
|
||||
cell,
|
||||
&prev_sanitized_hyperlinks,
|
||||
prev_cell,
|
||||
) || invalidated > 0)
|
||||
&& to_skip == 0
|
||||
{
|
||||
write_cell(
|
||||
writer,
|
||||
row,
|
||||
col,
|
||||
cell,
|
||||
&mut last_sgr,
|
||||
&mut active_hyperlink,
|
||||
frame,
|
||||
);
|
||||
}
|
||||
|
||||
to_skip = cell_width(cell).saturating_sub(1);
|
||||
|
|
@ -409,6 +524,8 @@ fn write_changed_cells(writer: &mut impl Write, frame: &FrameData, prev: &FrameD
|
|||
}
|
||||
}
|
||||
|
||||
close_hyperlink(writer, &mut active_hyperlink);
|
||||
|
||||
// Reset style if we wrote anything.
|
||||
if !last_sgr.is_empty() {
|
||||
let _ = writer.write_all(b"\x1b[0m");
|
||||
|
|
@ -433,6 +550,7 @@ mod tests {
|
|||
bg,
|
||||
modifier,
|
||||
skip: false,
|
||||
hyperlink: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -442,9 +560,16 @@ mod tests {
|
|||
width,
|
||||
height,
|
||||
cursor: None,
|
||||
hyperlinks: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn linked_cell(symbol: &str, index: u32) -> CellData {
|
||||
let mut cell = make_cell(symbol, 0, 0, 0);
|
||||
cell.hyperlink = Some(index);
|
||||
cell
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_to_sgr_fg_named_colors() {
|
||||
assert_eq!(color_to_sgr_fg(0x00_00_00_00), "39"); // Reset
|
||||
|
|
@ -619,6 +744,7 @@ mod tests {
|
|||
y: 1,
|
||||
visible: true,
|
||||
}),
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
|
|
@ -646,6 +772,7 @@ mod tests {
|
|||
y: 0,
|
||||
visible: true,
|
||||
}),
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
let hidden = FrameData {
|
||||
cells: vec![make_cell("B", 0, 0, 0); 9],
|
||||
|
|
@ -656,6 +783,7 @@ mod tests {
|
|||
y: 1,
|
||||
visible: false,
|
||||
}),
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
let mut last_visible_cursor = None;
|
||||
let mut output = Vec::new();
|
||||
|
|
@ -680,6 +808,42 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blit_frame_emits_osc8_for_linked_cells() {
|
||||
let mut frame = make_frame(
|
||||
3,
|
||||
1,
|
||||
vec![
|
||||
linked_cell("L", 0),
|
||||
linked_cell("i", 0),
|
||||
make_cell("!", 0, 0, 0),
|
||||
],
|
||||
);
|
||||
frame.hyperlinks.push("https://example.com".to_owned());
|
||||
|
||||
let mut output = Vec::new();
|
||||
blit_frame_to(&mut output, &frame, None);
|
||||
|
||||
let output_str = String::from_utf8(output).unwrap();
|
||||
assert!(output_str.contains("\x1b]8;;https://example.com\x1b\\L"));
|
||||
assert!(output_str.contains('i'));
|
||||
assert!(output_str.contains("\x1b]8;;\x1b\\"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blit_frame_sanitizes_hyperlink_uris() {
|
||||
let mut frame = make_frame(1, 1, vec![linked_cell("L", 0)]);
|
||||
frame
|
||||
.hyperlinks
|
||||
.push("https://exa\x1b\x07mple.com".to_owned());
|
||||
|
||||
let mut output = Vec::new();
|
||||
blit_frame_to(&mut output, &frame, None);
|
||||
|
||||
let output_str = String::from_utf8(output).unwrap();
|
||||
assert!(output_str.contains("\x1b]8;;https://example.com\x1b\\L"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blit_frame_first_frame_produces_output() {
|
||||
let frame = make_frame(
|
||||
|
|
@ -773,6 +937,7 @@ mod tests {
|
|||
y: 0,
|
||||
visible: true,
|
||||
}),
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
|
|
@ -796,6 +961,7 @@ mod tests {
|
|||
y: 0,
|
||||
visible: false,
|
||||
}),
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
|
|
@ -815,6 +981,7 @@ mod tests {
|
|||
width: 1,
|
||||
height: 1,
|
||||
cursor: None,
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
|
|
@ -839,6 +1006,7 @@ mod tests {
|
|||
y: 0,
|
||||
visible: false,
|
||||
}),
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
|
|
@ -858,6 +1026,7 @@ mod tests {
|
|||
y: 0,
|
||||
visible: true,
|
||||
}),
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
|
|
@ -884,6 +1053,7 @@ mod tests {
|
|||
y: 0,
|
||||
visible: true,
|
||||
}),
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
let mut curr = prev.clone();
|
||||
curr.cells[0] = make_cell("B", 0, 0, 0);
|
||||
|
|
@ -920,12 +1090,14 @@ mod tests {
|
|||
y: 1,
|
||||
visible: true,
|
||||
}),
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
let hidden = FrameData {
|
||||
cells: vec![make_cell("B", 0, 0, 0); 9],
|
||||
width: 3,
|
||||
height: 3,
|
||||
cursor: None,
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
let mut last_visible_cursor = None;
|
||||
let mut output = Vec::new();
|
||||
|
|
@ -956,6 +1128,7 @@ mod tests {
|
|||
width: 3,
|
||||
height: 2,
|
||||
cursor: None,
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
let mut last_visible_cursor = None;
|
||||
let mut output = Vec::new();
|
||||
|
|
@ -980,12 +1153,14 @@ mod tests {
|
|||
y: 0,
|
||||
visible: true,
|
||||
}),
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
let curr = FrameData {
|
||||
cells: vec![make_cell("B", 0, 0, 0)],
|
||||
width: 1,
|
||||
height: 1,
|
||||
cursor: None,
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
|
|
@ -1008,6 +1183,7 @@ mod tests {
|
|||
width: 3,
|
||||
height: 1,
|
||||
cursor: None,
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
|
|
@ -1030,6 +1206,7 @@ mod tests {
|
|||
width: 3,
|
||||
height: 1,
|
||||
cursor: None,
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
let curr = FrameData {
|
||||
cells: vec![
|
||||
|
|
@ -1040,6 +1217,7 @@ mod tests {
|
|||
width: 3,
|
||||
height: 1,
|
||||
cursor: None,
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
|
|
@ -1064,6 +1242,7 @@ mod tests {
|
|||
width: 3,
|
||||
height: 1,
|
||||
cursor: None,
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
let curr = FrameData {
|
||||
cells: vec![
|
||||
|
|
@ -1074,6 +1253,7 @@ mod tests {
|
|||
width: 3,
|
||||
height: 1,
|
||||
cursor: None,
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
|
||||
let mut output = Vec::new();
|
||||
|
|
|
|||
|
|
@ -401,12 +401,16 @@ impl Terminal {
|
|||
}
|
||||
|
||||
pub fn screen_graphemes(&self, x: u16, y: u32) -> Result<Vec<u32>, Error> {
|
||||
let point = ffi::GhosttyPoint {
|
||||
tag: ffi::GhosttyPointTag_GHOSTTY_POINT_TAG_SCREEN,
|
||||
value: ffi::GhosttyPointValue {
|
||||
coordinate: ffi::GhosttyPointCoordinate { x, y },
|
||||
},
|
||||
};
|
||||
let grid_ref = self.grid_ref(ghostty_screen_point(x, y))?;
|
||||
grid_ref_graphemes(&grid_ref)
|
||||
}
|
||||
|
||||
pub fn viewport_hyperlink_uri(&self, x: u16, y: u32) -> Result<Option<String>, Error> {
|
||||
let grid_ref = self.grid_ref(ghostty_viewport_point(x, y))?;
|
||||
grid_ref_hyperlink_uri(&grid_ref)
|
||||
}
|
||||
|
||||
fn grid_ref(&self, point: ffi::GhosttyPoint) -> Result<ffi::GhosttyGridRef, Error> {
|
||||
let mut grid_ref = ffi::GhosttyGridRef {
|
||||
size: mem::size_of::<ffi::GhosttyGridRef>(),
|
||||
..Default::default()
|
||||
|
|
@ -414,28 +418,7 @@ impl Terminal {
|
|||
unsafe {
|
||||
ffi::ghostty_terminal_grid_ref(self.raw, point, &mut grid_ref).into_result()?;
|
||||
}
|
||||
let mut required = 0usize;
|
||||
let result = unsafe {
|
||||
ffi::ghostty_grid_ref_graphemes(&grid_ref, ptr::null_mut(), 0, &mut required)
|
||||
};
|
||||
if result != ffi::GhosttyResult_GHOSTTY_OUT_OF_SPACE {
|
||||
result.into_result()?;
|
||||
}
|
||||
let mut buffer = vec![0u32; required];
|
||||
if required == 0 {
|
||||
return Ok(buffer);
|
||||
}
|
||||
unsafe {
|
||||
ffi::ghostty_grid_ref_graphemes(
|
||||
&grid_ref,
|
||||
buffer.as_mut_ptr(),
|
||||
buffer.len(),
|
||||
&mut required,
|
||||
)
|
||||
.into_result()?;
|
||||
}
|
||||
buffer.truncate(required);
|
||||
Ok(buffer)
|
||||
Ok(grid_ref)
|
||||
}
|
||||
|
||||
pub fn read_text_viewport(
|
||||
|
|
@ -620,6 +603,49 @@ fn ghostty_screen_point(x: u16, y: u32) -> ffi::GhosttyPoint {
|
|||
}
|
||||
}
|
||||
|
||||
fn grid_ref_graphemes(grid_ref: &ffi::GhosttyGridRef) -> Result<Vec<u32>, Error> {
|
||||
let mut required = 0usize;
|
||||
let result =
|
||||
unsafe { ffi::ghostty_grid_ref_graphemes(grid_ref, ptr::null_mut(), 0, &mut required) };
|
||||
if result != ffi::GhosttyResult_GHOSTTY_OUT_OF_SPACE {
|
||||
result.into_result()?;
|
||||
}
|
||||
let mut buffer = vec![0u32; required];
|
||||
if required == 0 {
|
||||
return Ok(buffer);
|
||||
}
|
||||
unsafe {
|
||||
ffi::ghostty_grid_ref_graphemes(grid_ref, buffer.as_mut_ptr(), buffer.len(), &mut required)
|
||||
.into_result()?;
|
||||
}
|
||||
buffer.truncate(required);
|
||||
Ok(buffer)
|
||||
}
|
||||
|
||||
fn grid_ref_hyperlink_uri(grid_ref: &ffi::GhosttyGridRef) -> Result<Option<String>, Error> {
|
||||
let mut required = 0usize;
|
||||
let result =
|
||||
unsafe { ffi::ghostty_grid_ref_hyperlink_uri(grid_ref, ptr::null_mut(), 0, &mut required) };
|
||||
if result != ffi::GhosttyResult_GHOSTTY_OUT_OF_SPACE {
|
||||
result.into_result()?;
|
||||
}
|
||||
if required == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut buffer = vec![0u8; required];
|
||||
unsafe {
|
||||
ffi::ghostty_grid_ref_hyperlink_uri(
|
||||
grid_ref,
|
||||
buffer.as_mut_ptr(),
|
||||
buffer.len(),
|
||||
&mut required,
|
||||
)
|
||||
.into_result()?;
|
||||
}
|
||||
buffer.truncate(required);
|
||||
Ok(Some(String::from_utf8_lossy(&buffer).into_owned()))
|
||||
}
|
||||
|
||||
pub struct RenderState {
|
||||
raw: ffi::GhosttyRenderState_ptr,
|
||||
}
|
||||
|
|
@ -1083,6 +1109,20 @@ impl<'a> RowCellIter<'a> {
|
|||
Ok(CellWide::from_raw(wide))
|
||||
}
|
||||
|
||||
pub fn has_hyperlink(&self) -> Result<bool, Error> {
|
||||
let raw = self.raw_cell()?;
|
||||
let mut has_hyperlink = false;
|
||||
unsafe {
|
||||
ffi::ghostty_cell_get(
|
||||
raw,
|
||||
ffi::GhosttyCellData_GHOSTTY_CELL_DATA_HAS_HYPERLINK,
|
||||
(&mut has_hyperlink as *mut bool).cast(),
|
||||
)
|
||||
.into_result()?;
|
||||
}
|
||||
Ok(has_hyperlink)
|
||||
}
|
||||
|
||||
pub fn style(&self) -> Result<CellStyle, Error> {
|
||||
let mut style = ffi::GhosttyStyle {
|
||||
size: mem::size_of::<ffi::GhosttyStyle>(),
|
||||
|
|
@ -1292,6 +1332,18 @@ mod tests {
|
|||
assert_eq!(text, "2EFGH3IJ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_extracts_viewport_hyperlink_uri() {
|
||||
let mut terminal = Terminal::new(20, 3, 0).unwrap();
|
||||
terminal.write(b"\x1b]8;;https://example.com\x1b\\Link\x1b]8;;\x1b\\");
|
||||
|
||||
assert_eq!(
|
||||
terminal.viewport_hyperlink_uri(0, 0).unwrap().as_deref(),
|
||||
Some("https://example.com")
|
||||
);
|
||||
assert_eq!(terminal.viewport_hyperlink_uri(4, 0).unwrap(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_read_text_viewport_handles_wide_chars() {
|
||||
let mut terminal = Terminal::new(5, 3, 0).unwrap();
|
||||
|
|
|
|||
|
|
@ -777,6 +777,10 @@ impl PaneRuntime {
|
|||
self.terminal.render(frame, area, show_cursor);
|
||||
}
|
||||
|
||||
pub fn visible_hyperlinks(&self, area: Rect) -> Vec<((u16, u16), String, String)> {
|
||||
self.terminal.visible_hyperlinks(area)
|
||||
}
|
||||
|
||||
pub fn keyboard_protocol(&self) -> crate::input::KeyboardProtocol {
|
||||
let fallback = crate::input::KeyboardProtocol::from_kitty_flags(
|
||||
self.kitty_keyboard_flags.load(Ordering::Relaxed),
|
||||
|
|
|
|||
|
|
@ -153,6 +153,10 @@ impl PaneTerminal {
|
|||
self.ghostty.render(frame, area, show_cursor);
|
||||
}
|
||||
|
||||
pub fn visible_hyperlinks(&self, area: Rect) -> Vec<((u16, u16), String, String)> {
|
||||
self.ghostty.visible_hyperlinks(area)
|
||||
}
|
||||
|
||||
pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) {
|
||||
self.ghostty.apply_host_terminal_theme(theme);
|
||||
}
|
||||
|
|
@ -573,6 +577,14 @@ impl GhosttyPaneTerminal {
|
|||
.and_then(|mut core| ghostty_extract_selection(&mut core, selection).ok())
|
||||
}
|
||||
|
||||
pub fn visible_hyperlinks(&self, area: Rect) -> Vec<((u16, u16), String, String)> {
|
||||
self.core
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut core| ghostty_visible_hyperlinks(&mut core, area).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn render(&self, frame: &mut Frame, area: Rect, show_cursor: bool) {
|
||||
let Ok(mut core) = self.core.lock() else {
|
||||
return;
|
||||
|
|
@ -664,6 +676,37 @@ impl GhosttyPaneTerminal {
|
|||
}
|
||||
}
|
||||
|
||||
fn ghostty_visible_hyperlinks(
|
||||
core: &mut GhosttyPaneCore,
|
||||
area: Rect,
|
||||
) -> Result<Vec<((u16, u16), String, String)>, crate::ghostty::Error> {
|
||||
let GhosttyPaneCore {
|
||||
terminal,
|
||||
render_state,
|
||||
..
|
||||
} = core;
|
||||
render_state.update(terminal)?;
|
||||
let mut row_iterator = crate::ghostty::RowIterator::new()?;
|
||||
let mut row_cells = crate::ghostty::RowCells::new()?;
|
||||
let mut rows = render_state.populate_row_iterator(&mut row_iterator)?;
|
||||
let mut links = Vec::new();
|
||||
let mut y = 0u16;
|
||||
while y < area.height && rows.next() {
|
||||
let mut cells = rows.populate_cells(&mut row_cells)?;
|
||||
let mut x = 0u16;
|
||||
while x < area.width && cells.next() {
|
||||
if cells.has_hyperlink()? {
|
||||
if let Some(uri) = terminal.viewport_hyperlink_uri(x, y.into())? {
|
||||
links.push(((area.x + x, area.y + y), ghostty_cell_symbol(&cells)?, uri));
|
||||
}
|
||||
}
|
||||
x += 1;
|
||||
}
|
||||
y += 1;
|
||||
}
|
||||
Ok(links)
|
||||
}
|
||||
|
||||
fn ghostty_visible_text(core: &mut GhosttyPaneCore) -> Result<String, crate::ghostty::Error> {
|
||||
let GhosttyPaneCore {
|
||||
terminal,
|
||||
|
|
|
|||
|
|
@ -368,6 +368,27 @@ fn render_virtual(
|
|||
(buffer, cursor)
|
||||
}
|
||||
|
||||
fn visible_hyperlinks(app_state: &AppState) -> 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 {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
let mut links = Vec::new();
|
||||
for info in &app_state.view.pane_infos {
|
||||
if let Some(runtime) = tab.runtimes.get(&info.id) {
|
||||
links.extend(runtime.visible_hyperlinks(info.inner_rect));
|
||||
}
|
||||
}
|
||||
links
|
||||
}
|
||||
|
||||
fn focused_terminal_cursor(app_state: &AppState) -> Option<CursorState> {
|
||||
if app_state.mode != Mode::Terminal {
|
||||
return None;
|
||||
|
|
@ -1120,6 +1141,13 @@ impl HeadlessServer {
|
|||
fn frame_server_message(msg: &ServerMessage) -> Result<Vec<u8>, protocol::FramingError> {
|
||||
let mut framed = Vec::new();
|
||||
protocol::write_message(&mut framed, msg)?;
|
||||
let payload_len = framed.len().saturating_sub(4);
|
||||
if payload_len > MAX_FRAME_SIZE {
|
||||
return Err(protocol::FramingError::Oversized {
|
||||
claimed: payload_len,
|
||||
max: MAX_FRAME_SIZE,
|
||||
});
|
||||
}
|
||||
Ok(framed)
|
||||
}
|
||||
|
||||
|
|
@ -1568,7 +1596,9 @@ impl HeadlessServer {
|
|||
for (client_id, (cols, rows), is_foreground) in render_targets {
|
||||
let area = Rect::new(0, 0, cols, rows);
|
||||
let (buffer, cursor) = render_virtual(&mut self.app.state, area, is_foreground);
|
||||
let frame = FrameData::from_ratatui_buffer(&buffer, cursor);
|
||||
let hyperlinks = visible_hyperlinks(&self.app.state);
|
||||
let frame =
|
||||
FrameData::from_ratatui_buffer_with_hyperlinks(&buffer, cursor, &hyperlinks);
|
||||
|
||||
let Some(client) = self.clients.get_mut(&client_id) else {
|
||||
continue;
|
||||
|
|
@ -1584,6 +1614,14 @@ impl HeadlessServer {
|
|||
let message = ServerMessage::Frame(frame.clone());
|
||||
let serialized = match Self::frame_server_message(&message) {
|
||||
Ok(framed) => framed,
|
||||
Err(protocol::FramingError::Oversized { claimed, max }) => {
|
||||
warn!(
|
||||
client_id,
|
||||
claimed, max, "skipping oversized frame for client"
|
||||
);
|
||||
client.last_frame = Some(frame);
|
||||
continue;
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(client_id, err = %err, "failed to serialize frame for client");
|
||||
broken_clients.push(client_id);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
//! Defines the message types, framing, version negotiation, and safety
|
||||
//! constraints for the binary protocol over Unix domain sockets.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{self, Read, Write};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -12,7 +13,7 @@ use serde::{Deserialize, Serialize};
|
|||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Current protocol version. Bumped when wire format changes incompatibly.
|
||||
pub const PROTOCOL_VERSION: u32 = 2;
|
||||
pub const PROTOCOL_VERSION: u32 = 3;
|
||||
|
||||
/// Maximum allowed frame payload size (2 MB). Frames larger than this are
|
||||
/// rejected to prevent denial-of-service via oversized length prefixes.
|
||||
|
|
@ -74,6 +75,8 @@ pub struct CellData {
|
|||
pub modifier: u16,
|
||||
/// Whether this cell should be skipped during diff-based rendering.
|
||||
pub skip: bool,
|
||||
/// Index into `FrameData::hyperlinks` for this cell's OSC 8 target, if any.
|
||||
pub hyperlink: Option<u32>,
|
||||
}
|
||||
|
||||
/// Cursor position within a rendered frame.
|
||||
|
|
@ -98,6 +101,8 @@ pub struct FrameData {
|
|||
pub height: u16,
|
||||
/// Cursor state for this frame, if applicable.
|
||||
pub cursor: Option<CursorState>,
|
||||
/// OSC 8 hyperlink URIs referenced by cells.
|
||||
pub hyperlinks: Vec<String>,
|
||||
}
|
||||
|
||||
impl FrameData {
|
||||
|
|
@ -106,24 +111,52 @@ impl FrameData {
|
|||
/// This converts ratatui's internal cell representation into the
|
||||
/// wire-protocol cell format. The conversion is lossless for all
|
||||
/// commonly used cell attributes.
|
||||
#[cfg(test)]
|
||||
pub fn from_ratatui_buffer(
|
||||
buffer: &ratatui::buffer::Buffer,
|
||||
cursor: Option<CursorState>,
|
||||
) -> Self {
|
||||
Self::from_ratatui_buffer_with_hyperlinks(buffer, cursor, &[])
|
||||
}
|
||||
|
||||
pub fn from_ratatui_buffer_with_hyperlinks(
|
||||
buffer: &ratatui::buffer::Buffer,
|
||||
cursor: Option<CursorState>,
|
||||
hyperlinks: &[((u16, u16), String, String)],
|
||||
) -> Self {
|
||||
let area = buffer.area;
|
||||
let width = area.width;
|
||||
let height = area.height;
|
||||
|
||||
let mut hyperlink_uris = Vec::<String>::new();
|
||||
let mut hyperlink_indices = HashMap::<&str, u32>::new();
|
||||
let mut hyperlink_by_position = HashMap::<(u16, u16), (&str, &str)>::new();
|
||||
for ((x, y), symbol, uri) in hyperlinks {
|
||||
hyperlink_by_position.insert((*x, *y), (symbol.as_str(), uri.as_str()));
|
||||
}
|
||||
let mut cells = Vec::with_capacity((width as usize) * (height as usize));
|
||||
for row in 0..height {
|
||||
for col in 0..width {
|
||||
let cell = buffer.cell((col, row)).expect("cell within bounds");
|
||||
let hyperlink = hyperlink_by_position
|
||||
.get(&(col, row))
|
||||
.and_then(|(symbol, uri)| {
|
||||
if *symbol != cell.symbol() {
|
||||
return None;
|
||||
}
|
||||
Some(*hyperlink_indices.entry(*uri).or_insert_with(|| {
|
||||
let index = hyperlink_uris.len() as u32;
|
||||
hyperlink_uris.push((*uri).to_owned());
|
||||
index
|
||||
}))
|
||||
});
|
||||
cells.push(CellData {
|
||||
symbol: cell.symbol().to_owned(),
|
||||
fg: color_to_u32(cell.fg),
|
||||
bg: color_to_u32(cell.bg),
|
||||
modifier: modifier_to_u16(cell.modifier),
|
||||
skip: cell.skip,
|
||||
hyperlink,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -133,6 +166,7 @@ impl FrameData {
|
|||
width,
|
||||
height,
|
||||
cursor,
|
||||
hyperlinks: hyperlink_uris,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -555,6 +589,7 @@ mod tests {
|
|||
bg: color_to_u32(Color::Black),
|
||||
modifier: Modifier::BOLD.bits(),
|
||||
skip: false,
|
||||
hyperlink: None,
|
||||
},
|
||||
CellData {
|
||||
symbol: "i".into(),
|
||||
|
|
@ -562,6 +597,7 @@ mod tests {
|
|||
bg: color_to_u32(Color::Reset),
|
||||
modifier: Modifier::ITALIC.bits(),
|
||||
skip: false,
|
||||
hyperlink: None,
|
||||
},
|
||||
CellData {
|
||||
symbol: "!".into(),
|
||||
|
|
@ -569,6 +605,7 @@ mod tests {
|
|||
bg: color_to_u32(Color::Indexed(220)),
|
||||
modifier: (Modifier::BOLD | Modifier::UNDERLINED).bits(),
|
||||
skip: false,
|
||||
hyperlink: Some(0),
|
||||
},
|
||||
CellData {
|
||||
symbol: " ".into(),
|
||||
|
|
@ -576,6 +613,7 @@ mod tests {
|
|||
bg: color_to_u32(Color::Reset),
|
||||
modifier: Modifier::empty().bits(),
|
||||
skip: true,
|
||||
hyperlink: None,
|
||||
},
|
||||
CellData {
|
||||
symbol: "→".into(), // multi-byte grapheme
|
||||
|
|
@ -583,6 +621,7 @@ mod tests {
|
|||
bg: color_to_u32(Color::Blue),
|
||||
modifier: Modifier::REVERSED.bits(),
|
||||
skip: false,
|
||||
hyperlink: None,
|
||||
},
|
||||
CellData {
|
||||
symbol: "🦀".into(), // emoji, wide grapheme cluster
|
||||
|
|
@ -590,6 +629,7 @@ mod tests {
|
|||
bg: color_to_u32(Color::Magenta),
|
||||
modifier: Modifier::empty().bits(),
|
||||
skip: false,
|
||||
hyperlink: None,
|
||||
},
|
||||
],
|
||||
width: 3,
|
||||
|
|
@ -599,12 +639,20 @@ mod tests {
|
|||
y: 0,
|
||||
visible: true,
|
||||
}),
|
||||
hyperlinks: vec!["https://example.com".to_owned()],
|
||||
};
|
||||
let msg = ServerMessage::Frame(frame.clone());
|
||||
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
|
||||
let (decoded, _): (ServerMessage, _) =
|
||||
bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
|
||||
assert_eq!(msg, decoded);
|
||||
match decoded {
|
||||
ServerMessage::Frame(frame) => {
|
||||
assert_eq!(frame.cells[2].hyperlink, Some(0));
|
||||
assert_eq!(frame.hyperlinks, vec!["https://example.com".to_owned()]);
|
||||
}
|
||||
other => panic!("expected frame, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -683,6 +731,7 @@ mod tests {
|
|||
bg: color_to_u32(Color::Indexed((i % 256) as u8)),
|
||||
modifier: ((i % 16) as u16),
|
||||
skip: i % 100 == 0,
|
||||
hyperlink: None,
|
||||
})
|
||||
.collect();
|
||||
|
||||
|
|
@ -695,6 +744,7 @@ mod tests {
|
|||
y: 5,
|
||||
visible: true,
|
||||
}),
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
let msg = ServerMessage::Frame(frame);
|
||||
|
||||
|
|
@ -983,6 +1033,17 @@ mod tests {
|
|||
assert_eq!(frame.cells[2].fg, color_to_u32(Color::Rgb(255, 128, 0)));
|
||||
assert_eq!(frame.cells[2].bg, color_to_u32(Color::Indexed(220)));
|
||||
|
||||
let with_links = FrameData::from_ratatui_buffer_with_hyperlinks(
|
||||
&buffer,
|
||||
None,
|
||||
&[((1, 0), "i".to_owned(), "https://example.com".to_owned())],
|
||||
);
|
||||
assert_eq!(with_links.cells[1].hyperlink, Some(0));
|
||||
assert_eq!(
|
||||
with_links.hyperlinks,
|
||||
vec!["https://example.com".to_owned()]
|
||||
);
|
||||
|
||||
// Convert back to ratatui buffer and compare.
|
||||
let restored = frame.to_ratatui_buffer().expect("should reconstruct");
|
||||
assert_eq!(restored.area, area);
|
||||
|
|
@ -1004,12 +1065,14 @@ mod tests {
|
|||
bg: 0,
|
||||
modifier: 0,
|
||||
skip: false,
|
||||
hyperlink: None,
|
||||
};
|
||||
5
|
||||
], // 5 cells but 3×2 = 6 expected
|
||||
width: 3,
|
||||
height: 2,
|
||||
cursor: None,
|
||||
hyperlinks: Vec::new(),
|
||||
};
|
||||
assert!(frame.to_ratatui_buffer().is_none());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -223,7 +223,7 @@ fn ping_over_socket_returns_version() {
|
|||
assert_eq!(value["id"], "req_1");
|
||||
assert_eq!(value["result"]["type"], "pong");
|
||||
assert_eq!(value["result"]["version"], env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(value["result"]["protocol"], 2);
|
||||
assert_eq!(value["result"]["protocol"], 3);
|
||||
|
||||
cleanup_spawned_herdr(child, base);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -871,7 +871,7 @@ fn status_commands_report_client_and_server_versions() {
|
|||
"stdout: {full_stdout}"
|
||||
);
|
||||
assert!(
|
||||
full_stdout.contains(" protocol: 2"),
|
||||
full_stdout.contains(" protocol: 3"),
|
||||
"stdout: {full_stdout}"
|
||||
);
|
||||
assert!(full_stdout.contains("server:\n"), "stdout: {full_stdout}");
|
||||
|
|
@ -904,7 +904,7 @@ fn status_commands_report_client_and_server_versions() {
|
|||
"stdout: {server_stdout}"
|
||||
);
|
||||
assert!(
|
||||
server_stdout.contains("protocol: 2"),
|
||||
server_stdout.contains("protocol: 3"),
|
||||
"stdout: {server_stdout}"
|
||||
);
|
||||
|
||||
|
|
@ -916,7 +916,7 @@ fn status_commands_report_client_and_server_versions() {
|
|||
"stdout: {client_stdout}"
|
||||
);
|
||||
assert!(
|
||||
client_stdout.contains("protocol: 2"),
|
||||
client_stdout.contains("protocol: 3"),
|
||||
"stdout: {client_stdout}"
|
||||
);
|
||||
assert!(
|
||||
|
|
|
|||
|
|
@ -158,14 +158,17 @@ fn ping_socket(socket_path: &PathBuf) -> String {
|
|||
response.trim().to_string()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FrameWire {
|
||||
cells: Vec<CellWire>,
|
||||
width: u16,
|
||||
height: u16,
|
||||
cursor: Option<CursorWire>,
|
||||
hyperlinks: Vec<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CellWire {
|
||||
symbol: String,
|
||||
|
|
@ -173,6 +176,7 @@ struct CellWire {
|
|||
bg: u32,
|
||||
modifier: u16,
|
||||
skip: bool,
|
||||
hyperlink: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -246,8 +250,8 @@ fn client_connects_and_receives_frame() {
|
|||
// Connect and handshake.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 2, "server should report protocol version 2");
|
||||
client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 3, "server should report protocol version 3");
|
||||
assert!(
|
||||
error.is_none(),
|
||||
"handshake should not have error: {:?}",
|
||||
|
|
@ -326,8 +330,8 @@ fn client_sees_headless_startup_config_diagnostic() {
|
|||
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 2);
|
||||
client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 3);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
stream
|
||||
|
|
@ -375,8 +379,8 @@ fn client_input_forwarded_to_pane() {
|
|||
// Connect and handshake.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 2);
|
||||
client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 3);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Send an Input message containing "echo hello\n".
|
||||
|
|
@ -429,8 +433,8 @@ fn client_resize_sends_message() {
|
|||
// Connect and handshake.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 2);
|
||||
client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 3);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Drain the initial frame(s).
|
||||
|
|
@ -486,8 +490,8 @@ fn server_shutdown_sends_message_to_client() {
|
|||
// Connect and handshake.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 2);
|
||||
client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 3);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Send SIGINT so the server takes the graceful shutdown path and
|
||||
|
|
@ -713,8 +717,8 @@ fn client_receives_frame_after_pane_output() {
|
|||
// Connect and handshake.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 2);
|
||||
client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 3);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Read the initial frame (server renders immediately on client connect).
|
||||
|
|
@ -767,8 +771,8 @@ fn navigate_mode_keybind_dispatch_in_server() {
|
|||
// Connect and handshake.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 2);
|
||||
client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 3);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Drain initial frames.
|
||||
|
|
@ -885,8 +889,8 @@ fn graceful_shutdown_sends_server_shutdown_to_client() {
|
|||
// Connect and handshake.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 2);
|
||||
client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 3);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Drain initial frame(s).
|
||||
|
|
@ -983,8 +987,8 @@ fn client_receives_notify_on_agent_state_change() {
|
|||
// Connect as a client and perform handshake.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 2);
|
||||
client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 3);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Drain initial frame(s).
|
||||
|
|
|
|||
|
|
@ -483,14 +483,17 @@ fn is_timeout(err: &io::Error) -> bool {
|
|||
)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FrameWire {
|
||||
cells: Vec<CellWire>,
|
||||
width: u16,
|
||||
height: u16,
|
||||
cursor: Option<CursorWire>,
|
||||
hyperlinks: Vec<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CellWire {
|
||||
symbol: String,
|
||||
|
|
@ -498,6 +501,7 @@ struct CellWire {
|
|||
bg: u32,
|
||||
modifier: u16,
|
||||
skip: bool,
|
||||
hyperlink: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
@ -665,7 +669,7 @@ fn cross_area_detach_and_reattach_preserves_state() {
|
|||
|
||||
// Local attach (client A).
|
||||
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
|
||||
client_handshake(&mut client_a, 2, 100, 30);
|
||||
client_handshake(&mut client_a, 3, 100, 30);
|
||||
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
|
||||
|
||||
// Use herdr: create a workspace and write output into its pane.
|
||||
|
|
@ -702,7 +706,7 @@ fn cross_area_detach_and_reattach_preserves_state() {
|
|||
|
||||
// Reattach from another terminal/session (client B).
|
||||
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
|
||||
client_handshake(&mut client_b, 2, 80, 24);
|
||||
client_handshake(&mut client_b, 3, 80, 24);
|
||||
assert!(
|
||||
wait_for_frame(&mut client_b, Duration::from_secs(5)),
|
||||
"reattached client should receive frame"
|
||||
|
|
@ -758,7 +762,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
|
|||
wait_for_socket(&client_socket, Duration::from_secs(10));
|
||||
|
||||
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
|
||||
client_handshake(&mut client_a, 2, 100, 30);
|
||||
client_handshake(&mut client_a, 3, 100, 30);
|
||||
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
|
||||
|
||||
let created = workspace_create(&api_socket, "agent-persist");
|
||||
|
|
@ -811,7 +815,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
|
|||
|
||||
// Reattach and ensure client-side state reflects the persisted working status.
|
||||
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
|
||||
client_handshake(&mut client_b, 2, 80, 24);
|
||||
client_handshake(&mut client_b, 3, 80, 24);
|
||||
let saw_working_on_client =
|
||||
wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| {
|
||||
frame_contains_text(frame, "working")
|
||||
|
|
@ -856,7 +860,7 @@ fn cross_area_client_and_api_workspace_views_are_consistent() {
|
|||
wait_for_socket(&client_socket, Duration::from_secs(10));
|
||||
|
||||
let mut client = UnixStream::connect(&client_socket).expect("client should connect");
|
||||
client_handshake(&mut client, 2, 100, 30);
|
||||
client_handshake(&mut client, 3, 100, 30);
|
||||
assert!(wait_for_frame(&mut client, Duration::from_secs(2)));
|
||||
drain_server_messages(&mut client, Duration::from_millis(300));
|
||||
|
||||
|
|
@ -919,9 +923,9 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() {
|
|||
wait_for_socket(&client_socket, Duration::from_secs(10));
|
||||
|
||||
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
|
||||
client_handshake(&mut client_a, 2, 110, 30);
|
||||
client_handshake(&mut client_a, 3, 110, 30);
|
||||
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
|
||||
client_handshake(&mut client_b, 2, 100, 30);
|
||||
client_handshake(&mut client_b, 3, 100, 30);
|
||||
|
||||
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
|
||||
assert!(wait_for_frame(&mut client_b, Duration::from_secs(2)));
|
||||
|
|
@ -1087,7 +1091,7 @@ fn cross_area_server_kill_then_restart_and_reconnect() {
|
|||
|
||||
let mut reconnect_client =
|
||||
UnixStream::connect(&client_socket).expect("new client should connect after restart");
|
||||
client_handshake(&mut reconnect_client, 2, 80, 24);
|
||||
client_handshake(&mut reconnect_client, 3, 80, 24);
|
||||
assert!(
|
||||
wait_for_frame(&mut reconnect_client, Duration::from_secs(5)),
|
||||
"new client should receive frame after restart"
|
||||
|
|
|
|||
|
|
@ -276,8 +276,8 @@ fn navigate_q_detaches_client_and_server_persists() {
|
|||
// Connect and handshake.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 2);
|
||||
client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 3);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Drain initial frames.
|
||||
|
|
@ -338,8 +338,8 @@ fn explicit_detach_message_causes_clean_disconnect() {
|
|||
// Connect and handshake.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 2);
|
||||
client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 3);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Drain initial frames.
|
||||
|
|
@ -397,8 +397,8 @@ fn reattach_after_detach_shows_current_state() {
|
|||
// --- Client A ---
|
||||
let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream_a, 2, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 2);
|
||||
client_handshake(&mut stream_a, 3, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 3);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Drain initial frames.
|
||||
|
|
@ -436,8 +436,8 @@ fn reattach_after_detach_shows_current_state() {
|
|||
// --- Client B (reattach) ---
|
||||
let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream_b, 2, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 2);
|
||||
client_handshake(&mut stream_b, 3, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 3);
|
||||
assert!(
|
||||
error.is_none(),
|
||||
"reattach handshake should succeed: {:?}",
|
||||
|
|
@ -516,8 +516,8 @@ fn processes_survive_during_and_after_detach() {
|
|||
// Connect and handshake.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 2);
|
||||
client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 3);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Drain initial frames.
|
||||
|
|
@ -555,8 +555,8 @@ fn processes_survive_during_and_after_detach() {
|
|||
// Reattach — verify we can connect and receive a frame.
|
||||
let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream_b, 2, 80, 24).expect("reattach handshake should succeed");
|
||||
assert_eq!(version, 2);
|
||||
client_handshake(&mut stream_b, 3, 80, 24).expect("reattach handshake should succeed");
|
||||
assert_eq!(version, 3);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Verify the reattached client receives a frame.
|
||||
|
|
@ -604,8 +604,8 @@ fn server_persists_after_client_connection_drop() {
|
|||
// Connect and handshake.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 2);
|
||||
client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 3);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Drain initial frames.
|
||||
|
|
@ -631,8 +631,8 @@ fn server_persists_after_client_connection_drop() {
|
|||
// Reattach — verify we can connect and handshake again.
|
||||
let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream_b, 2, 80, 24).expect("reattach handshake should succeed");
|
||||
assert_eq!(version, 2);
|
||||
client_handshake(&mut stream_b, 3, 80, 24).expect("reattach handshake should succeed");
|
||||
assert_eq!(version, 3);
|
||||
assert!(error.is_none(), "reattach should succeed: {:?}", error);
|
||||
|
||||
cleanup_spawned_herdr(spawned, base);
|
||||
|
|
@ -653,8 +653,8 @@ fn detached_output_preserves_last_attached_pty_size() {
|
|||
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("client should connect");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 2, 120, 40).expect("handshake should succeed");
|
||||
assert_eq!(version, 2);
|
||||
client_handshake(&mut stream, 3, 120, 40).expect("handshake should succeed");
|
||||
assert_eq!(version, 3);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
drain_messages(&mut stream);
|
||||
|
||||
|
|
@ -722,8 +722,8 @@ fn output_accumulated_while_detached_visible_on_reattach() {
|
|||
// Connect and handshake client A.
|
||||
let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream_a, 2, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 2);
|
||||
client_handshake(&mut stream_a, 3, 80, 24).expect("handshake should succeed");
|
||||
assert_eq!(version, 3);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Detach client A immediately.
|
||||
|
|
@ -780,8 +780,8 @@ fn output_accumulated_while_detached_visible_on_reattach() {
|
|||
// --- Client B (reattach) ---
|
||||
let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect");
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream_b, 2, 80, 24).expect("reattach handshake should succeed");
|
||||
assert_eq!(version, 2);
|
||||
client_handshake(&mut stream_b, 3, 80, 24).expect("reattach handshake should succeed");
|
||||
assert_eq!(version, 3);
|
||||
assert!(error.is_none(), "{:?}", error);
|
||||
|
||||
// Client B should receive a frame with the current state.
|
||||
|
|
|
|||
|
|
@ -542,7 +542,7 @@ fn client_handshake(
|
|||
|
||||
fn connect_raw_client(client_socket: &Path, cols: u16, rows: u16) -> UnixStream {
|
||||
let mut stream = UnixStream::connect(client_socket).expect("should connect to client socket");
|
||||
client_handshake(&mut stream, 2, cols, rows).expect("handshake should succeed");
|
||||
client_handshake(&mut stream, 3, cols, rows).expect("handshake should succeed");
|
||||
stream
|
||||
}
|
||||
|
||||
|
|
@ -565,14 +565,17 @@ fn send_client_detach(stream: &mut UnixStream) {
|
|||
stream.flush().unwrap();
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FrameWire {
|
||||
cells: Vec<CellWire>,
|
||||
width: u16,
|
||||
height: u16,
|
||||
cursor: Option<CursorWire>,
|
||||
hyperlinks: Vec<String>,
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CellWire {
|
||||
symbol: String,
|
||||
|
|
@ -580,6 +583,7 @@ struct CellWire {
|
|||
bg: u32,
|
||||
modifier: u16,
|
||||
skip: bool,
|
||||
hyperlink: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -578,9 +578,9 @@ fn client_handshake_succeeds() {
|
|||
|
||||
// Send Hello with version 2, 80 cols, 24 rows.
|
||||
let (version, error) =
|
||||
client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
|
||||
client_handshake(&mut stream, 3, 80, 24).expect("handshake should succeed");
|
||||
|
||||
assert_eq!(version, 2, "server should report protocol version 2");
|
||||
assert_eq!(version, 3, "server should report protocol version 3");
|
||||
assert!(
|
||||
error.is_none(),
|
||||
"handshake should not have an error: {:?}",
|
||||
|
|
@ -609,7 +609,7 @@ fn client_handshake_rejects_incompatible_version() {
|
|||
let (version, error) = client_handshake(&mut stream, 0, 80, 24)
|
||||
.expect("should read Welcome response even on rejection");
|
||||
|
||||
assert_eq!(version, 2, "server should report its version 2");
|
||||
assert_eq!(version, 3, "server should report its version 3");
|
||||
assert!(
|
||||
error.is_some(),
|
||||
"version 0 should be rejected with an error"
|
||||
|
|
@ -634,10 +634,10 @@ fn client_handshake_clamps_small_terminal_size() {
|
|||
// Send Hello with 0x0 terminal size — should be clamped.
|
||||
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
|
||||
|
||||
let (version, error) = client_handshake(&mut stream, 2, 0, 0)
|
||||
let (version, error) = client_handshake(&mut stream, 3, 0, 0)
|
||||
.expect("handshake with 0x0 should succeed (server clamps)");
|
||||
|
||||
assert_eq!(version, 2);
|
||||
assert_eq!(version, 3);
|
||||
assert!(
|
||||
error.is_none(),
|
||||
"0x0 size should be accepted (clamped): {:?}",
|
||||
|
|
@ -697,9 +697,9 @@ fn no_hello_client_closed_within_five_seconds() {
|
|||
// Verify the server is still healthy — a proper client can still connect.
|
||||
let mut good_stream =
|
||||
UnixStream::connect(&client_socket).expect("should connect after no-hello client");
|
||||
let (version, error) = client_handshake(&mut good_stream, 2, 80, 24)
|
||||
let (version, error) = client_handshake(&mut good_stream, 3, 80, 24)
|
||||
.expect("proper handshake should still work after no-hello client");
|
||||
assert_eq!(version, 2);
|
||||
assert_eq!(version, 3);
|
||||
assert!(error.is_none());
|
||||
|
||||
// API should still work.
|
||||
|
|
|
|||
Loading…
Reference in New Issue