refactor: replace pane mouse and clipboard internals
replaces contributions from othavi0
This commit is contained in:
parent
c234f221f9
commit
1d238bc9a3
|
|
@ -286,31 +286,13 @@ impl App {
|
|||
}
|
||||
|
||||
let handled_pane_double_click = self.handle_pane_double_click(mouse);
|
||||
if !handled_pane_double_click {
|
||||
self.focus_pane_before_mouse_press(mouse);
|
||||
}
|
||||
|
||||
let previous_agent_panel_sort = self.state.agent_panel_sort;
|
||||
let previous_settings_section = self.state.settings.section;
|
||||
if !handled_pane_double_click {
|
||||
let right_button = matches!(
|
||||
mouse.kind,
|
||||
MouseEventKind::Down(MouseButton::Right)
|
||||
| MouseEventKind::Up(MouseButton::Right)
|
||||
| MouseEventKind::Drag(MouseButton::Right)
|
||||
);
|
||||
let intentional_pane_press = matches!(
|
||||
mouse.kind,
|
||||
MouseEventKind::Down(MouseButton::Left | MouseButton::Middle)
|
||||
);
|
||||
if !right_button
|
||||
&& intentional_pane_press
|
||||
&& matches!(self.state.mode, Mode::Terminal | Mode::Resize)
|
||||
{
|
||||
if let (Some(ws_idx), Some(info)) = (
|
||||
self.state.active,
|
||||
self.state.pane_at(mouse.column, mouse.row).cloned(),
|
||||
) {
|
||||
self.focus_pane_internal_via_api(ws_idx, info.id);
|
||||
}
|
||||
}
|
||||
if let Some(action) = self.state.handle_mouse(&mut self.terminal_runtimes, mouse) {
|
||||
match action {
|
||||
MouseAction::NewWorkspace => {
|
||||
|
|
@ -459,6 +441,31 @@ impl App {
|
|||
}
|
||||
}
|
||||
|
||||
fn focus_pane_before_mouse_press(&mut self, mouse: MouseEvent) {
|
||||
if !matches!(self.state.mode, Mode::Terminal | Mode::Resize)
|
||||
|| !matches!(
|
||||
mouse.kind,
|
||||
MouseEventKind::Down(MouseButton::Left | MouseButton::Middle)
|
||||
)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(pane_id) = self
|
||||
.state
|
||||
.pane_at(mouse.column, mouse.row)
|
||||
.map(|info| info.id)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(ws_idx) = self.state.active else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Focus through the runtime API before an application can consume its press.
|
||||
self.focus_pane_internal_via_api(ws_idx, pane_id);
|
||||
}
|
||||
|
||||
fn handle_modified_url_click(&mut self, mouse: MouseEvent) -> bool {
|
||||
if self.state.mode != Mode::Terminal
|
||||
|| !matches!(mouse.kind, MouseEventKind::Down(MouseButton::Left))
|
||||
|
|
|
|||
|
|
@ -627,13 +627,7 @@ impl AppState {
|
|||
if self.forward_pane_mouse_button(terminal_runtimes, &info, mouse) {
|
||||
self.selection = None;
|
||||
self.selection_autoscroll = None;
|
||||
if let Some(ws_idx) = self.active {
|
||||
return Some(MouseAction::FocusPane {
|
||||
ws_idx,
|
||||
pane_id: info.id,
|
||||
});
|
||||
}
|
||||
return None;
|
||||
return self.mouse_pane_focus_action(info.id);
|
||||
}
|
||||
|
||||
let (row, col) = (
|
||||
|
|
@ -646,12 +640,7 @@ impl AppState {
|
|||
col,
|
||||
self.pane_scroll_metrics(terminal_runtimes, info.id),
|
||||
));
|
||||
if let Some(ws_idx) = self.active {
|
||||
return Some(MouseAction::FocusPane {
|
||||
ws_idx,
|
||||
pane_id: info.id,
|
||||
});
|
||||
}
|
||||
return self.mouse_pane_focus_action(info.id);
|
||||
} else if let Some(info) = self.view.pane_infos.iter().find(|p| {
|
||||
mouse.column >= p.rect.x
|
||||
&& mouse.column < p.rect.x + p.rect.width
|
||||
|
|
@ -662,12 +651,7 @@ impl AppState {
|
|||
if self.mode != Mode::Terminal {
|
||||
self.mode = Mode::Terminal;
|
||||
}
|
||||
if let Some(ws_idx) = self.active {
|
||||
return Some(MouseAction::FocusPane {
|
||||
ws_idx,
|
||||
pane_id: id,
|
||||
});
|
||||
}
|
||||
return self.mouse_pane_focus_action(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1414,6 +1398,16 @@ impl AppState {
|
|||
.or_else(|| self.pane_frame_at(col, row))
|
||||
}
|
||||
|
||||
fn mouse_pane_focus_action(&self, pane_id: crate::layout::PaneId) -> Option<MouseAction> {
|
||||
let ws_idx = self.active?;
|
||||
(self
|
||||
.workspaces
|
||||
.get(ws_idx)
|
||||
.and_then(|workspace| workspace.focused_pane_id())
|
||||
!= Some(pane_id))
|
||||
.then_some(MouseAction::FocusPane { ws_idx, pane_id })
|
||||
}
|
||||
|
||||
pub(crate) fn pane_info_by_id(&self, pane_id: crate::layout::PaneId) -> Option<&PaneInfo> {
|
||||
self.view.pane_infos.iter().find(|info| info.id == pane_id)
|
||||
}
|
||||
|
|
@ -2065,6 +2059,45 @@ mod tests {
|
|||
assert!(input_rx.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn captured_left_press_focuses_target_before_forwarding() {
|
||||
let mut app = app_for_mouse_test();
|
||||
let mut ws = Workspace::test_new("test");
|
||||
let source = ws.tabs[0].root_pane;
|
||||
let target = ws.test_split(Direction::Horizontal);
|
||||
ws.tabs[0].layout.focus_pane(source);
|
||||
app.state.workspaces = vec![ws];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 106, 20));
|
||||
let info = app
|
||||
.state
|
||||
.pane_info_by_id(target)
|
||||
.expect("target pane info")
|
||||
.clone();
|
||||
let (runtime, mut input_rx) =
|
||||
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
|
||||
info.inner_rect.width,
|
||||
info.inner_rect.height,
|
||||
0,
|
||||
b"\x1b[?1002h\x1b[?1006h",
|
||||
4,
|
||||
);
|
||||
app.state.insert_test_runtime(target, runtime);
|
||||
|
||||
app.handle_mouse(mouse(
|
||||
MouseEventKind::Down(MouseButton::Left),
|
||||
info.inner_rect.x + 1,
|
||||
info.inner_rect.y + 1,
|
||||
));
|
||||
|
||||
assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(target));
|
||||
assert_eq!(
|
||||
input_rx.try_recv().expect("forwarded captured left press"),
|
||||
Bytes::from_static(b"\x1b[<0;2;2M")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pane_mouse_only_forwards_moved_events_for_any_motion_apps() {
|
||||
let mut app = app_for_mouse_test();
|
||||
|
|
@ -2335,6 +2368,52 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn normal_right_click_keeps_focus_and_exposes_swap_for_reporting_pane() {
|
||||
let mut app = app_for_mouse_test();
|
||||
let mut ws = Workspace::test_new("test");
|
||||
let source = ws.tabs[0].root_pane;
|
||||
let target = ws.test_split(Direction::Horizontal);
|
||||
ws.tabs[0].layout.focus_pane(source);
|
||||
app.state.workspaces = vec![ws];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
crate::ui::compute_view(&mut app.state, Rect::new(0, 0, 100, 20));
|
||||
let target_info = app
|
||||
.state
|
||||
.pane_info_by_id(target)
|
||||
.expect("target pane info")
|
||||
.clone();
|
||||
let (runtime, mut input_rx) =
|
||||
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
|
||||
target_info.inner_rect.width,
|
||||
target_info.inner_rect.height,
|
||||
0,
|
||||
b"\x1b[?1002h\x1b[?1006h",
|
||||
4,
|
||||
);
|
||||
app.state.insert_test_runtime(target, runtime);
|
||||
|
||||
app.handle_mouse(mouse(
|
||||
MouseEventKind::Down(MouseButton::Right),
|
||||
target_info.inner_rect.x,
|
||||
target_info.inner_rect.y,
|
||||
));
|
||||
|
||||
assert!(input_rx.try_recv().is_err());
|
||||
assert_eq!(app.state.workspaces[0].focused_pane_id(), Some(source));
|
||||
let menu = app.state.context_menu.as_mut().expect("pane context menu");
|
||||
assert!(matches!(
|
||||
menu.kind,
|
||||
ContextMenuKind::Pane {
|
||||
pane_id,
|
||||
source_pane_id: Some(source_pane_id),
|
||||
..
|
||||
} if pane_id == target && source_pane_id == source
|
||||
));
|
||||
assert!(menu.items().contains(&"Swap with focused pane"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn right_click_passthrough_requires_exact_modifier_match() {
|
||||
let mut app = app_for_mouse_test();
|
||||
|
|
@ -2357,6 +2436,7 @@ mod tests {
|
|||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
app.state.view.pane_infos = pane_infos;
|
||||
|
||||
app.state.right_click_passthrough_modifiers = Some(KeyModifiers::CONTROL);
|
||||
|
||||
let col = info.inner_rect.x + 2;
|
||||
|
|
|
|||
|
|
@ -1051,121 +1051,6 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clicking_unfocused_pane_with_mouse_reporting_focuses_it_via_left_button() {
|
||||
let mut app = app_for_mouse_test();
|
||||
let mut ws = Workspace::test_new("test");
|
||||
let first_pane = ws.tabs[0].root_pane;
|
||||
let second_pane = ws.test_split(ratatui::layout::Direction::Vertical);
|
||||
|
||||
let terminal_area = Rect::new(26, 2, 80, 18);
|
||||
let pane_infos = ws.tabs[0].layout.panes(terminal_area);
|
||||
let first_info = pane_infos
|
||||
.iter()
|
||||
.find(|p| p.id == first_pane)
|
||||
.unwrap()
|
||||
.clone();
|
||||
let second_info = pane_infos
|
||||
.iter()
|
||||
.find(|p| p.id == second_pane)
|
||||
.unwrap()
|
||||
.clone();
|
||||
|
||||
ws.insert_test_runtime(
|
||||
first_pane,
|
||||
crate::terminal::TerminalRuntime::test_with_screen_bytes(
|
||||
first_info.inner_rect.width.max(1),
|
||||
first_info.inner_rect.height.max(1),
|
||||
b"",
|
||||
),
|
||||
);
|
||||
ws.insert_test_runtime(
|
||||
second_pane,
|
||||
crate::terminal::TerminalRuntime::test_with_screen_bytes(
|
||||
second_info.inner_rect.width.max(1),
|
||||
second_info.inner_rect.height.max(1),
|
||||
b"\x1b[?1002h",
|
||||
),
|
||||
);
|
||||
|
||||
ws.tabs[0].layout.focus_pane(first_pane);
|
||||
|
||||
app.state.workspaces = vec![ws];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
app.state.view.pane_infos = pane_infos;
|
||||
|
||||
app.handle_mouse(mouse(
|
||||
MouseEventKind::Down(MouseButton::Left),
|
||||
second_info.inner_rect.x + 2,
|
||||
second_info.inner_rect.y + 2,
|
||||
));
|
||||
|
||||
assert_eq!(
|
||||
app.state.workspaces[0].tabs[0].layout.focused(),
|
||||
second_pane
|
||||
);
|
||||
assert_eq!(app.state.mode, Mode::Terminal);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn right_clicking_unfocused_mouse_reporting_pane_keeps_focus_for_context_menu() {
|
||||
let mut app = app_for_mouse_test();
|
||||
let mut ws = Workspace::test_new("test");
|
||||
let first_pane = ws.tabs[0].root_pane;
|
||||
let second_pane = ws.test_split(ratatui::layout::Direction::Vertical);
|
||||
|
||||
let terminal_area = Rect::new(26, 2, 80, 18);
|
||||
let pane_infos = ws.tabs[0].layout.panes(terminal_area);
|
||||
let first_info = pane_infos
|
||||
.iter()
|
||||
.find(|p| p.id == first_pane)
|
||||
.unwrap()
|
||||
.clone();
|
||||
let second_info = pane_infos
|
||||
.iter()
|
||||
.find(|p| p.id == second_pane)
|
||||
.unwrap()
|
||||
.clone();
|
||||
|
||||
ws.insert_test_runtime(
|
||||
first_pane,
|
||||
crate::terminal::TerminalRuntime::test_with_screen_bytes(
|
||||
first_info.inner_rect.width.max(1),
|
||||
first_info.inner_rect.height.max(1),
|
||||
b"",
|
||||
),
|
||||
);
|
||||
ws.insert_test_runtime(
|
||||
second_pane,
|
||||
crate::terminal::TerminalRuntime::test_with_screen_bytes(
|
||||
second_info.inner_rect.width.max(1),
|
||||
second_info.inner_rect.height.max(1),
|
||||
b"\x1b[?1002h",
|
||||
),
|
||||
);
|
||||
|
||||
ws.tabs[0].layout.focus_pane(first_pane);
|
||||
|
||||
app.state.workspaces = vec![ws];
|
||||
app.state.active = Some(0);
|
||||
app.state.selected = 0;
|
||||
app.state.mode = Mode::Terminal;
|
||||
app.state.view.pane_infos = pane_infos;
|
||||
|
||||
app.handle_mouse(mouse(
|
||||
MouseEventKind::Down(MouseButton::Right),
|
||||
second_info.inner_rect.x + 2,
|
||||
second_info.inner_rect.y + 2,
|
||||
));
|
||||
|
||||
assert_eq!(app.state.workspaces[0].tabs[0].layout.focused(), first_pane);
|
||||
assert_eq!(app.state.mode, Mode::ContextMenu);
|
||||
let menu = app.state.context_menu.as_ref().expect("pane context menu");
|
||||
assert!(menu.items().contains(&"Swap with focused pane"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn terminal_direct_focus_pane_shortcut_switches_focus_without_leaving_terminal_mode() {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
|
|
|
|||
|
|
@ -443,10 +443,13 @@ impl CellWide {
|
|||
|
||||
type WritePtyCallback = dyn FnMut(&[u8]) + Send;
|
||||
|
||||
const MAX_CLIPBOARD_BYTES: usize = 192 * 1024;
|
||||
|
||||
#[derive(Default)]
|
||||
struct TerminalCallbackState {
|
||||
write_pty: Option<Box<WritePtyCallback>>,
|
||||
pwd_changes: Vec<Vec<u8>>,
|
||||
clipboard_writes: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
unsafe extern "C" fn write_pty_trampoline(
|
||||
|
|
@ -470,6 +473,90 @@ unsafe extern "C" fn write_pty_trampoline(
|
|||
callback(bytes);
|
||||
}
|
||||
|
||||
unsafe extern "C" fn clipboard_write_trampoline(
|
||||
_terminal: ffi::GhosttyTerminal,
|
||||
userdata: *mut c_void,
|
||||
write: *const ffi::GhosttyClipboardWrite,
|
||||
) -> ffi::GhosttyClipboardWriteResult {
|
||||
std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
// SAFETY: libghostty-vt owns these values for the synchronous callback.
|
||||
unsafe { capture_clipboard_write(userdata, write) }
|
||||
}))
|
||||
.unwrap_or(ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA)
|
||||
}
|
||||
|
||||
unsafe fn capture_clipboard_write(
|
||||
userdata: *mut c_void,
|
||||
write: *const ffi::GhosttyClipboardWrite,
|
||||
) -> ffi::GhosttyClipboardWriteResult {
|
||||
if userdata.is_null() || write.is_null() {
|
||||
return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA;
|
||||
}
|
||||
|
||||
let required_size = std::mem::offset_of!(ffi::GhosttyClipboardWrite, contents_len)
|
||||
+ std::mem::size_of::<usize>();
|
||||
// SAFETY: size is the leading field of the live request.
|
||||
if unsafe { (*write).size } < required_size {
|
||||
return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA;
|
||||
}
|
||||
// SAFETY: the size check covers every field accessed below.
|
||||
let request = unsafe { &*write };
|
||||
if request.location != ffi::GhosttyClipboardLocation_GHOSTTY_CLIPBOARD_LOCATION_STANDARD {
|
||||
return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED;
|
||||
}
|
||||
|
||||
// SAFETY: userdata is the TerminalCallbackState installed with this terminal.
|
||||
let state = unsafe { &mut *userdata.cast::<TerminalCallbackState>() };
|
||||
if request.contents_len == 0 {
|
||||
state.clipboard_writes.push(Vec::new());
|
||||
return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS;
|
||||
}
|
||||
if request.contents_len != 1 {
|
||||
return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED;
|
||||
}
|
||||
if request.contents.is_null() {
|
||||
return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA;
|
||||
}
|
||||
|
||||
// SAFETY: libghostty-vt keeps the single content and its strings alive for the callback.
|
||||
let content = unsafe { &*request.contents };
|
||||
// SAFETY: the MIME string is borrowed from the live callback request.
|
||||
let Some(mime) = (unsafe { borrowed_bytes(content.mime) }) else {
|
||||
return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA;
|
||||
};
|
||||
let is_text = std::str::from_utf8(mime)
|
||||
.ok()
|
||||
.and_then(|mime| mime.split(';').next())
|
||||
.is_some_and(|mime| mime.trim().eq_ignore_ascii_case("text/plain"));
|
||||
if !is_text {
|
||||
return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED;
|
||||
}
|
||||
|
||||
// SAFETY: the data string is borrowed from the live callback request.
|
||||
let Some(bytes) = (unsafe { borrowed_bytes(content.data) }) else {
|
||||
return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA;
|
||||
};
|
||||
if bytes.is_empty() {
|
||||
return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED;
|
||||
}
|
||||
if bytes.len() > MAX_CLIPBOARD_BYTES {
|
||||
return ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA;
|
||||
}
|
||||
state.clipboard_writes.push(bytes.to_vec());
|
||||
ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS
|
||||
}
|
||||
|
||||
unsafe fn borrowed_bytes<'a>(value: ffi::GhosttyString) -> Option<&'a [u8]> {
|
||||
if value.len == 0 {
|
||||
Some(&[])
|
||||
} else if value.ptr.is_null() {
|
||||
None
|
||||
} else {
|
||||
// SAFETY: the callback contract keeps pointer and length valid until return.
|
||||
Some(unsafe { slice::from_raw_parts(value.ptr, value.len) })
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn pwd_changed_trampoline(terminal: ffi::GhosttyTerminal, userdata: *mut c_void) {
|
||||
if terminal.is_null() || userdata.is_null() {
|
||||
return;
|
||||
|
|
@ -660,6 +747,12 @@ impl Terminal {
|
|||
(pwd_changed_trampoline as *const ()).cast(),
|
||||
)
|
||||
.into_result()?;
|
||||
ffi::ghostty_terminal_set(
|
||||
terminal.raw,
|
||||
ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE,
|
||||
(clipboard_write_trampoline as *const ()).cast(),
|
||||
)
|
||||
.into_result()?;
|
||||
ffi::ghostty_terminal_set(
|
||||
terminal.raw,
|
||||
ffi::GhosttyTerminalOption_GHOSTTY_TERMINAL_OPT_GLYPH_PROTOCOL,
|
||||
|
|
@ -758,6 +851,10 @@ impl Terminal {
|
|||
mem::take(&mut self.callback_state.pwd_changes)
|
||||
}
|
||||
|
||||
pub fn take_clipboard_writes(&mut self) -> Vec<Vec<u8>> {
|
||||
mem::take(&mut self.callback_state.clipboard_writes)
|
||||
}
|
||||
|
||||
pub fn mode_get(&self, mode: u16) -> Result<bool, Error> {
|
||||
let mut out = false;
|
||||
unsafe { ffi::ghostty_terminal_mode_get(self.raw, mode, &mut out).into_result()? };
|
||||
|
|
@ -3640,6 +3737,98 @@ mod tests {
|
|||
assert_eq!(rows.selection().unwrap(), None);
|
||||
}
|
||||
|
||||
fn test_clipboard_content(mime: &[u8], data: &[u8]) -> ffi::GhosttyClipboardContent {
|
||||
ffi::GhosttyClipboardContent {
|
||||
mime: ffi::GhosttyString {
|
||||
ptr: mime.as_ptr(),
|
||||
len: mime.len(),
|
||||
},
|
||||
data: ffi::GhosttyString {
|
||||
ptr: data.as_ptr(),
|
||||
len: data.len(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn invoke_clipboard_callback(
|
||||
terminal: &mut Terminal,
|
||||
contents: &[ffi::GhosttyClipboardContent],
|
||||
size: usize,
|
||||
) -> ffi::GhosttyClipboardWriteResult {
|
||||
let request = ffi::GhosttyClipboardWrite {
|
||||
size,
|
||||
location: ffi::GhosttyClipboardLocation_GHOSTTY_CLIPBOARD_LOCATION_STANDARD,
|
||||
contents: contents.as_ptr(),
|
||||
contents_len: contents.len(),
|
||||
};
|
||||
// SAFETY: the request and its borrowed content live through this call.
|
||||
unsafe {
|
||||
clipboard_write_trampoline(
|
||||
terminal.raw,
|
||||
(&mut *terminal.callback_state as *mut TerminalCallbackState).cast(),
|
||||
&request,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clipboard_callback_rejects_writes_the_text_pipeline_cannot_represent() {
|
||||
let mut terminal = Terminal::new(10, 5, 0).unwrap();
|
||||
let full_size = std::mem::size_of::<ffi::GhosttyClipboardWrite>();
|
||||
let success = ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_SUCCESS;
|
||||
let unsupported =
|
||||
ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_UNSUPPORTED;
|
||||
let invalid = ffi::GhosttyClipboardWriteResult_GHOSTTY_CLIPBOARD_WRITE_RESULT_INVALID_DATA;
|
||||
|
||||
assert_eq!(
|
||||
invoke_clipboard_callback(&mut terminal, &[], full_size),
|
||||
success
|
||||
);
|
||||
assert_eq!(terminal.take_clipboard_writes(), vec![Vec::<u8>::new()]);
|
||||
|
||||
let empty = test_clipboard_content(b"text/plain", b"");
|
||||
assert_eq!(
|
||||
invoke_clipboard_callback(&mut terminal, &[empty], full_size),
|
||||
unsupported
|
||||
);
|
||||
let text = test_clipboard_content(b"text/plain", b"text");
|
||||
let image = test_clipboard_content(b"image/png", b"image");
|
||||
assert_eq!(
|
||||
invoke_clipboard_callback(&mut terminal, &[text, image], full_size),
|
||||
unsupported
|
||||
);
|
||||
|
||||
let oversized = vec![b'x'; MAX_CLIPBOARD_BYTES + 1];
|
||||
let oversized = test_clipboard_content(b"text/plain", &oversized);
|
||||
assert_eq!(
|
||||
invoke_clipboard_callback(&mut terminal, &[oversized], full_size),
|
||||
invalid
|
||||
);
|
||||
assert_eq!(
|
||||
invoke_clipboard_callback(&mut terminal, &[text], full_size - 1),
|
||||
invalid
|
||||
);
|
||||
assert!(terminal.take_clipboard_writes().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn libghostty_completes_osc52_writes_for_bel_and_st_without_queries() {
|
||||
let mut terminal = Terminal::new(10, 5, 0).unwrap();
|
||||
terminal.write(b"\x1b]52;c;aGVs");
|
||||
assert!(terminal.take_clipboard_writes().is_empty());
|
||||
terminal.write(b"bG8=\x07");
|
||||
assert_eq!(terminal.take_clipboard_writes(), vec![b"hello".to_vec()]);
|
||||
|
||||
terminal.write(b"\x1b]52;c;d29ybGQ=\x1b\\");
|
||||
assert_eq!(terminal.take_clipboard_writes(), vec![b"world".to_vec()]);
|
||||
|
||||
terminal.write(b"\x1b]52;c;?\x07");
|
||||
assert!(terminal.take_clipboard_writes().is_empty());
|
||||
|
||||
terminal.write(b"\x1b]52;c;\x07");
|
||||
assert_eq!(terminal.take_clipboard_writes(), vec![Vec::<u8>::new()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn row_cell_basic_data_uses_batched_vendor_reads() {
|
||||
let mut terminal = Terminal::new(8, 3, 100).unwrap();
|
||||
|
|
|
|||
515
src/pane/osc.rs
515
src/pane/osc.rs
|
|
@ -314,88 +314,6 @@ fn parse_default_color_set_events(body: &[u8]) -> Vec<DefaultColorEvent> {
|
|||
.collect()
|
||||
}
|
||||
|
||||
/// 256 KiB of base64 ≈ 192 KiB of text — enough for real source-file copies
|
||||
/// while still bounding memory against stream garbage.
|
||||
const OSC52_MAX_PAYLOAD_BYTES: usize = 256 * 1024;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
enum Osc52ForwarderState {
|
||||
#[default]
|
||||
Ground,
|
||||
Escape,
|
||||
OscBody,
|
||||
OscEscape,
|
||||
}
|
||||
|
||||
/// Reconstructs OSC 52 clipboard-write sequences from raw PTY bytes so the
|
||||
/// main loop can re-emit them. `libghostty-vt` drops `.clipboard_contents`,
|
||||
/// so child clipboard writes never reach the host terminal unless we forward
|
||||
/// them ourselves.
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct Osc52Forwarder {
|
||||
state: Osc52ForwarderState,
|
||||
body: Vec<u8>,
|
||||
pending: Vec<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl Osc52Forwarder {
|
||||
pub(super) fn observe(&mut self, bytes: &[u8]) {
|
||||
for &byte in bytes {
|
||||
match self.state {
|
||||
Osc52ForwarderState::Ground => {
|
||||
if byte == 0x1b {
|
||||
self.state = Osc52ForwarderState::Escape;
|
||||
}
|
||||
}
|
||||
Osc52ForwarderState::Escape => {
|
||||
if byte == b']' {
|
||||
self.body.clear();
|
||||
self.state = Osc52ForwarderState::OscBody;
|
||||
} else if byte == 0x1b {
|
||||
self.state = Osc52ForwarderState::Escape;
|
||||
} else {
|
||||
self.state = Osc52ForwarderState::Ground;
|
||||
}
|
||||
}
|
||||
Osc52ForwarderState::OscBody => match byte {
|
||||
0x07 => {
|
||||
self.finalize();
|
||||
self.state = Osc52ForwarderState::Ground;
|
||||
}
|
||||
0x1b => self.state = Osc52ForwarderState::OscEscape,
|
||||
_ => self.body.push(byte),
|
||||
},
|
||||
Osc52ForwarderState::OscEscape => {
|
||||
if byte == b'\\' {
|
||||
self.finalize();
|
||||
self.state = Osc52ForwarderState::Ground;
|
||||
} else {
|
||||
self.body.push(0x1b);
|
||||
self.body.push(byte);
|
||||
self.state = Osc52ForwarderState::OscBody;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.body.len() > OSC52_MAX_PAYLOAD_BYTES {
|
||||
self.body.clear();
|
||||
self.state = Osc52ForwarderState::Ground;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize(&mut self) {
|
||||
if let Some(content) = parse_osc52_clipboard_write(&self.body) {
|
||||
self.pending.push(content);
|
||||
}
|
||||
self.body.clear();
|
||||
}
|
||||
|
||||
pub(super) fn drain_pending(&mut self) -> Vec<Vec<u8>> {
|
||||
std::mem::take(&mut self.pending)
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn parse_reported_cwd(value: &[u8]) -> Option<PathBuf> {
|
||||
let value = std::str::from_utf8(value).ok()?.trim();
|
||||
if value.starts_with("file://") {
|
||||
|
|
@ -405,6 +323,126 @@ pub(super) fn parse_reported_cwd(value: &[u8]) -> Option<PathBuf> {
|
|||
(!path.is_empty()).then(|| PathBuf::from(path))
|
||||
}
|
||||
|
||||
/// Collects complete OSC bodies from a raw byte stream. Consumers receive only
|
||||
/// bodies, keeping the framing state machine independent from OSC commands.
|
||||
#[derive(Debug, Default)]
|
||||
struct OscStreamCollector {
|
||||
state: OscStreamState,
|
||||
body: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
enum OscStreamState {
|
||||
#[default]
|
||||
Ground,
|
||||
Escape,
|
||||
Body,
|
||||
BodyEscape,
|
||||
IgnoringString,
|
||||
IgnoringStringEscape,
|
||||
Discarding,
|
||||
DiscardingEscape,
|
||||
}
|
||||
|
||||
impl OscStreamCollector {
|
||||
const MAX_BODY_BYTES: usize = 4096;
|
||||
|
||||
fn observe(&mut self, bytes: &[u8], mut receive: impl FnMut(&[u8])) {
|
||||
for &byte in bytes {
|
||||
match self.state {
|
||||
OscStreamState::Ground => {
|
||||
if byte == 0x1b {
|
||||
self.state = OscStreamState::Escape;
|
||||
}
|
||||
}
|
||||
OscStreamState::Escape => match byte {
|
||||
b']' => {
|
||||
self.body.clear();
|
||||
self.state = OscStreamState::Body;
|
||||
}
|
||||
0x1b => self.state = OscStreamState::Escape,
|
||||
byte if is_ignored_string_intro(byte) => {
|
||||
self.state = OscStreamState::IgnoringString;
|
||||
}
|
||||
_ => self.state = OscStreamState::Ground,
|
||||
},
|
||||
OscStreamState::Body => match byte {
|
||||
0x07 => self.finish(&mut receive),
|
||||
0x1b => self.state = OscStreamState::BodyEscape,
|
||||
_ => self.push(byte),
|
||||
},
|
||||
OscStreamState::BodyEscape => match byte {
|
||||
b'\\' => self.finish(&mut receive),
|
||||
0x07 => {
|
||||
self.push(0x1b);
|
||||
if matches!(self.state, OscStreamState::Body) {
|
||||
self.finish(&mut receive);
|
||||
} else {
|
||||
self.state = OscStreamState::Ground;
|
||||
}
|
||||
}
|
||||
0x1b => {
|
||||
self.push(0x1b);
|
||||
self.state = match self.state {
|
||||
OscStreamState::Body => OscStreamState::BodyEscape,
|
||||
OscStreamState::Discarding => OscStreamState::DiscardingEscape,
|
||||
state => state,
|
||||
};
|
||||
}
|
||||
_ => {
|
||||
self.push(0x1b);
|
||||
if matches!(self.state, OscStreamState::Body) {
|
||||
self.push(byte);
|
||||
}
|
||||
}
|
||||
},
|
||||
OscStreamState::IgnoringString => {
|
||||
if byte == 0x1b {
|
||||
self.state = OscStreamState::IgnoringStringEscape;
|
||||
}
|
||||
}
|
||||
OscStreamState::IgnoringStringEscape => {
|
||||
if byte == b'\\' {
|
||||
self.state = OscStreamState::Ground;
|
||||
} else if byte != 0x1b {
|
||||
self.state = OscStreamState::IgnoringString;
|
||||
}
|
||||
}
|
||||
OscStreamState::Discarding => {
|
||||
if byte == 0x07 {
|
||||
self.state = OscStreamState::Ground;
|
||||
} else if byte == 0x1b {
|
||||
self.state = OscStreamState::DiscardingEscape;
|
||||
}
|
||||
}
|
||||
OscStreamState::DiscardingEscape => {
|
||||
if byte == b'\\' {
|
||||
self.state = OscStreamState::Ground;
|
||||
} else if byte != 0x1b {
|
||||
self.state = OscStreamState::Discarding;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push(&mut self, byte: u8) {
|
||||
self.body.push(byte);
|
||||
if self.body.len() > Self::MAX_BODY_BYTES {
|
||||
self.body.clear();
|
||||
self.state = OscStreamState::Discarding;
|
||||
} else {
|
||||
self.state = OscStreamState::Body;
|
||||
}
|
||||
}
|
||||
|
||||
fn finish(&mut self, receive: &mut impl FnMut(&[u8])) {
|
||||
receive(&self.body);
|
||||
self.body.clear();
|
||||
self.state = OscStreamState::Ground;
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum retained string length for agent OSC title and progress payloads.
|
||||
/// Title text is untrusted model output; cap it to bound memory and log size.
|
||||
const AGENT_OSC_MAX_CHARS: usize = 256;
|
||||
|
|
@ -419,8 +457,7 @@ const AGENT_OSC_MAX_CHARS: usize = 256;
|
|||
/// as-is after sanitization. E.g. `"4;3;"` or `"4;0;"`.
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct AgentOscStateTracker {
|
||||
state: Osc52ForwarderState,
|
||||
body: Vec<u8>,
|
||||
collector: OscStreamCollector,
|
||||
latest_title: Option<String>,
|
||||
terminal_title: Option<String>,
|
||||
latest_progress: Option<String>,
|
||||
|
|
@ -428,71 +465,29 @@ pub(super) struct AgentOscStateTracker {
|
|||
|
||||
impl AgentOscStateTracker {
|
||||
pub(super) fn observe(&mut self, bytes: &[u8]) {
|
||||
for &byte in bytes {
|
||||
match self.state {
|
||||
Osc52ForwarderState::Ground => {
|
||||
if byte == 0x1b {
|
||||
self.state = Osc52ForwarderState::Escape;
|
||||
}
|
||||
}
|
||||
Osc52ForwarderState::Escape => {
|
||||
if byte == b']' {
|
||||
self.body.clear();
|
||||
self.state = Osc52ForwarderState::OscBody;
|
||||
} else if byte == 0x1b {
|
||||
self.state = Osc52ForwarderState::Escape;
|
||||
} else {
|
||||
self.state = Osc52ForwarderState::Ground;
|
||||
}
|
||||
}
|
||||
Osc52ForwarderState::OscBody => match byte {
|
||||
0x07 => {
|
||||
self.finalize();
|
||||
self.state = Osc52ForwarderState::Ground;
|
||||
}
|
||||
0x1b => self.state = Osc52ForwarderState::OscEscape,
|
||||
_ => self.body.push(byte),
|
||||
},
|
||||
Osc52ForwarderState::OscEscape => {
|
||||
if byte == b'\\' {
|
||||
self.finalize();
|
||||
self.state = Osc52ForwarderState::Ground;
|
||||
} else {
|
||||
self.body.push(0x1b);
|
||||
self.body.push(byte);
|
||||
self.state = Osc52ForwarderState::OscBody;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if self.body.len() > 4096 {
|
||||
self.body.clear();
|
||||
self.state = Osc52ForwarderState::Ground;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize(&mut self) {
|
||||
if let Some((command, payload)) = parse_agent_osc_body(&self.body) {
|
||||
let (collector, latest_title, terminal_title, latest_progress) = (
|
||||
&mut self.collector,
|
||||
&mut self.latest_title,
|
||||
&mut self.terminal_title,
|
||||
&mut self.latest_progress,
|
||||
);
|
||||
collector.observe(bytes, |body| {
|
||||
let Some((command, payload)) = parse_agent_osc_body(body) else {
|
||||
return;
|
||||
};
|
||||
match command {
|
||||
b"0" | b"2" => {
|
||||
let title = if payload.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let title = sanitize_agent_osc_string(payload, AGENT_OSC_MAX_CHARS);
|
||||
(!title.is_empty()).then_some(title)
|
||||
};
|
||||
self.latest_title.clone_from(&title);
|
||||
self.terminal_title = title;
|
||||
let title = sanitize_agent_osc_string(payload, AGENT_OSC_MAX_CHARS);
|
||||
*terminal_title = (!title.is_empty()).then_some(title.clone());
|
||||
*latest_title = (!title.is_empty()).then_some(title);
|
||||
}
|
||||
b"9" => {
|
||||
self.latest_progress =
|
||||
*latest_progress =
|
||||
Some(sanitize_agent_osc_string(payload, AGENT_OSC_MAX_CHARS));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
self.body.clear();
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn terminal_title(&self) -> Option<&str> {
|
||||
|
|
@ -549,8 +544,7 @@ fn sanitize_agent_osc_string(payload: &[u8], max_chars: usize) -> String {
|
|||
#[derive(Debug)]
|
||||
pub(super) struct OscDebugTracker {
|
||||
enabled: bool,
|
||||
state: Osc52ForwarderState,
|
||||
body: Vec<u8>,
|
||||
collector: OscStreamCollector,
|
||||
pending: Vec<OscDebugEvent>,
|
||||
}
|
||||
|
||||
|
|
@ -564,8 +558,7 @@ impl OscDebugTracker {
|
|||
pub(super) fn from_env() -> Self {
|
||||
Self {
|
||||
enabled: osc_debug_enabled_from_env(),
|
||||
state: Osc52ForwarderState::Ground,
|
||||
body: Vec::new(),
|
||||
collector: OscStreamCollector::default(),
|
||||
pending: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
|
@ -574,56 +567,12 @@ impl OscDebugTracker {
|
|||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
for &byte in bytes {
|
||||
match self.state {
|
||||
Osc52ForwarderState::Ground => {
|
||||
if byte == 0x1b {
|
||||
self.state = Osc52ForwarderState::Escape;
|
||||
}
|
||||
}
|
||||
Osc52ForwarderState::Escape => {
|
||||
if byte == b']' {
|
||||
self.body.clear();
|
||||
self.state = Osc52ForwarderState::OscBody;
|
||||
} else if byte == 0x1b {
|
||||
self.state = Osc52ForwarderState::Escape;
|
||||
} else {
|
||||
self.state = Osc52ForwarderState::Ground;
|
||||
}
|
||||
}
|
||||
Osc52ForwarderState::OscBody => match byte {
|
||||
0x07 => {
|
||||
self.finalize();
|
||||
self.state = Osc52ForwarderState::Ground;
|
||||
}
|
||||
0x1b => self.state = Osc52ForwarderState::OscEscape,
|
||||
_ => self.body.push(byte),
|
||||
},
|
||||
Osc52ForwarderState::OscEscape => {
|
||||
if byte == b'\\' {
|
||||
self.finalize();
|
||||
self.state = Osc52ForwarderState::Ground;
|
||||
} else {
|
||||
self.body.push(0x1b);
|
||||
self.body.push(byte);
|
||||
self.state = Osc52ForwarderState::OscBody;
|
||||
}
|
||||
}
|
||||
let (collector, pending) = (&mut self.collector, &mut self.pending);
|
||||
collector.observe(bytes, |body| {
|
||||
if let Some(event) = parse_osc_debug_event(body) {
|
||||
pending.push(event);
|
||||
}
|
||||
|
||||
if self.body.len() > 4096 {
|
||||
self.body.clear();
|
||||
self.state = Osc52ForwarderState::Ground;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize(&mut self) {
|
||||
if let Some(event) = parse_osc_debug_event(&self.body) {
|
||||
self.pending.push(event);
|
||||
}
|
||||
self.body.clear();
|
||||
});
|
||||
}
|
||||
|
||||
pub(super) fn drain_pending(&mut self) -> Vec<OscDebugEvent> {
|
||||
|
|
@ -733,22 +682,6 @@ fn hex_value(byte: u8) -> Option<u8> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Accepts `52;c;<base64>` and `52;;<base64>`.
|
||||
/// Queries (`?`) are rejected because herdr has no reply path.
|
||||
/// The payload must decode as base64 before it is forwarded.
|
||||
fn parse_osc52_clipboard_write(body: &[u8]) -> Option<Vec<u8>> {
|
||||
use base64::Engine;
|
||||
|
||||
let rest = body.strip_prefix(b"52;")?;
|
||||
let sep = rest.iter().position(|b| *b == b';')?;
|
||||
let selector = &rest[..sep];
|
||||
let data = &rest[sep + 1..];
|
||||
if !(selector.is_empty() || selector == b"c") || data == b"?" {
|
||||
return None;
|
||||
}
|
||||
base64::engine::general_purpose::STANDARD.decode(data).ok()
|
||||
}
|
||||
|
||||
fn foreground_job_is_shell(job: &crate::platform::ForegroundJob, shell_pid: u32) -> bool {
|
||||
job.processes.iter().any(|process| process.pid == shell_pid)
|
||||
}
|
||||
|
|
@ -983,12 +916,25 @@ mod tests {
|
|||
fn enabled_osc_debug_tracker() -> OscDebugTracker {
|
||||
OscDebugTracker {
|
||||
enabled: true,
|
||||
state: Osc52ForwarderState::Ground,
|
||||
body: Vec::new(),
|
||||
collector: OscStreamCollector::default(),
|
||||
pending: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_stream_collector_ignores_strings_and_preserves_escaped_bytes() {
|
||||
let mut collector = OscStreamCollector::default();
|
||||
let mut bodies = Vec::new();
|
||||
|
||||
collector.observe(
|
||||
b"\x1bPignored\x1b]0;not-osc\x07\x1b\\\x1b]9;a\x1b",
|
||||
|body| bodies.push(body.to_vec()),
|
||||
);
|
||||
collector.observe(b"\x1b\\\x1b]2;b\x1b\x07", |body| bodies.push(body.to_vec()));
|
||||
|
||||
assert_eq!(bodies, vec![b"9;a\x1b".to_vec(), b"2;b\x1b".to_vec()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_color_tracker_detects_split_osc_11_sequences() {
|
||||
let mut tracker = DefaultColorOscTracker::default();
|
||||
|
|
@ -1399,151 +1345,6 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_forwarder_detects_write_with_bel() {
|
||||
let mut fw = Osc52Forwarder::default();
|
||||
fw.observe(b"\x1b]52;c;aGVsbG8=\x07");
|
||||
let pending = fw.drain_pending();
|
||||
assert_eq!(pending, vec![b"hello".to_vec()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_forwarder_detects_write_with_st() {
|
||||
let mut fw = Osc52Forwarder::default();
|
||||
fw.observe(b"\x1b]52;c;aGVsbG8=\x1b\\");
|
||||
let pending = fw.drain_pending();
|
||||
assert_eq!(pending, vec![b"hello".to_vec()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_forwarder_detects_empty_selector_form() {
|
||||
let mut fw = Osc52Forwarder::default();
|
||||
fw.observe(b"\x1b]52;;aGVsbG8=\x07");
|
||||
let pending = fw.drain_pending();
|
||||
assert_eq!(pending, vec![b"hello".to_vec()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_forwarder_accepts_clear_clipboard() {
|
||||
let mut fw = Osc52Forwarder::default();
|
||||
fw.observe(b"\x1b]52;c;\x07");
|
||||
let pending = fw.drain_pending();
|
||||
assert_eq!(pending, vec![Vec::<u8>::new()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_forwarder_ignores_query() {
|
||||
let mut fw = Osc52Forwarder::default();
|
||||
fw.observe(b"\x1b]52;c;?\x07");
|
||||
assert!(fw.drain_pending().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_forwarder_ignores_empty_selector_query() {
|
||||
let mut fw = Osc52Forwarder::default();
|
||||
fw.observe(b"\x1b]52;;?\x07");
|
||||
assert!(fw.drain_pending().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_forwarder_ignores_other_kinds() {
|
||||
let mut fw = Osc52Forwarder::default();
|
||||
fw.observe(b"\x1b]52;p;aGk=\x07");
|
||||
fw.observe(b"\x1b]52;s;aGk=\x07");
|
||||
fw.observe(b"\x1b]52;q;aGk=\x07");
|
||||
fw.observe(b"\x1b]52;0;aGk=\x07");
|
||||
fw.observe(b"\x1b]52;7;aGk=\x07");
|
||||
assert!(fw.drain_pending().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_forwarder_ignores_invalid_base64() {
|
||||
let mut fw = Osc52Forwarder::default();
|
||||
fw.observe(b"\x1b]52;c;%%%\x07");
|
||||
fw.observe(b"\x1b]52;c;aGVs\x1b[bG8=\x07");
|
||||
assert!(fw.drain_pending().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_forwarder_ignores_non_osc52() {
|
||||
let mut fw = Osc52Forwarder::default();
|
||||
fw.observe(b"\x1b]11;?\x07");
|
||||
fw.observe(b"\x1b]0;title\x07");
|
||||
fw.observe(b"\x1b]8;;https://example.com\x1b\\");
|
||||
assert!(fw.drain_pending().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_forwarder_handles_split_sequence_mid_payload() {
|
||||
let mut fw = Osc52Forwarder::default();
|
||||
fw.observe(b"\x1b]52;c;aGVs");
|
||||
assert!(fw.drain_pending().is_empty());
|
||||
fw.observe(b"bG8gd29y");
|
||||
assert!(fw.drain_pending().is_empty());
|
||||
fw.observe(b"bGQ=\x07");
|
||||
let pending = fw.drain_pending();
|
||||
assert_eq!(pending, vec![b"hello world".to_vec()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_forwarder_handles_split_before_bel() {
|
||||
let mut fw = Osc52Forwarder::default();
|
||||
fw.observe(b"\x1b]52;c;aGk=");
|
||||
assert!(fw.drain_pending().is_empty());
|
||||
fw.observe(b"\x07");
|
||||
let pending = fw.drain_pending();
|
||||
assert_eq!(pending, vec![b"hi".to_vec()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_forwarder_handles_split_between_esc_and_backslash() {
|
||||
let mut fw = Osc52Forwarder::default();
|
||||
fw.observe(b"\x1b]52;c;aGk=\x1b");
|
||||
assert!(fw.drain_pending().is_empty());
|
||||
fw.observe(b"\\");
|
||||
let pending = fw.drain_pending();
|
||||
assert_eq!(pending, vec![b"hi".to_vec()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_forwarder_payload_size_limit() {
|
||||
let mut fw = Osc52Forwarder::default();
|
||||
let mut huge = Vec::with_capacity(OSC52_MAX_PAYLOAD_BYTES + 32);
|
||||
huge.extend_from_slice(b"\x1b]52;c;");
|
||||
huge.extend(std::iter::repeat_n(b'A', OSC52_MAX_PAYLOAD_BYTES + 16));
|
||||
huge.push(0x07);
|
||||
fw.observe(&huge);
|
||||
assert!(fw.drain_pending().is_empty());
|
||||
|
||||
fw.observe(b"\x1b]52;c;aGk=\x07");
|
||||
let pending = fw.drain_pending();
|
||||
assert_eq!(pending, vec![b"hi".to_vec()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_forwarder_recovers_after_garbage() {
|
||||
let mut fw = Osc52Forwarder::default();
|
||||
fw.observe(b"\x01\x02random\x7fbytes\x1b]52;c;aGk=\x07tail");
|
||||
let pending = fw.drain_pending();
|
||||
assert_eq!(pending, vec![b"hi".to_vec()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_forwarder_multiple_in_one_chunk() {
|
||||
let mut fw = Osc52Forwarder::default();
|
||||
fw.observe(b"\x1b]52;c;aGk=\x07\x1b]52;c;Ynll\x07");
|
||||
let pending = fw.drain_pending();
|
||||
assert_eq!(pending, vec![b"hi".to_vec(), b"bye".to_vec()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_forwarder_drain_clears_pending() {
|
||||
let mut fw = Osc52Forwarder::default();
|
||||
fw.observe(b"\x1b]52;c;aGk=\x07");
|
||||
assert_eq!(fw.drain_pending(), vec![b"hi".to_vec()]);
|
||||
assert!(fw.drain_pending().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn droid_scrollback_compat_matches_process_name_and_cmdline() {
|
||||
let name_only = crate::platform::ForegroundJob {
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ use super::{
|
|||
maybe_filter_primary_screen_scrollback_clear, parse_reported_cwd,
|
||||
restore_host_terminal_theme_if_needed, write_host_terminal_theme_selective,
|
||||
AgentOscStateTracker, DefaultColorEvent, DefaultColorEventTracker, DefaultColorOscTracker,
|
||||
DefaultColorQuery, DefaultColorTrackedEvent, Osc52Forwarder, OscDebugTracker,
|
||||
DefaultColorQuery, DefaultColorTrackedEvent, OscDebugTracker,
|
||||
},
|
||||
xtgettcap::{XtgettcapQueryTracker, XtgettcapResponse},
|
||||
};
|
||||
|
|
@ -161,7 +161,6 @@ pub(crate) struct GhosttyPaneCore {
|
|||
pub default_color_event_tracker: DefaultColorEventTracker,
|
||||
pub child_default_foreground_changed: bool,
|
||||
pub child_default_background_changed: bool,
|
||||
pub osc52_forwarder: Osc52Forwarder,
|
||||
pub osc_debug_tracker: OscDebugTracker,
|
||||
pub agent_osc_state: AgentOscStateTracker,
|
||||
pub xtgettcap_query_tracker: XtgettcapQueryTracker,
|
||||
|
|
@ -915,7 +914,6 @@ impl GhosttyPaneTerminal {
|
|||
default_color_event_tracker: DefaultColorEventTracker::default(),
|
||||
child_default_foreground_changed: false,
|
||||
child_default_background_changed: false,
|
||||
osc52_forwarder: Osc52Forwarder::default(),
|
||||
osc_debug_tracker: OscDebugTracker::default(),
|
||||
agent_osc_state: AgentOscStateTracker::default(),
|
||||
xtgettcap_query_tracker: XtgettcapQueryTracker::default(),
|
||||
|
|
@ -1049,6 +1047,9 @@ impl GhosttyPaneTerminal {
|
|||
};
|
||||
|
||||
let _ = core.terminal.take_pwd_changes();
|
||||
// Restored history may have exercised terminal callbacks before this live PTY write.
|
||||
// Those writes must not be delivered as live pane output.
|
||||
let _ = core.terminal.take_clipboard_writes();
|
||||
let default_color_observation = core.default_color_tracker.observe(bytes);
|
||||
if shell_pid > 0 && default_color_observation {
|
||||
if let Some(owner_pgid) = current_transient_default_color_owner(shell_pid) {
|
||||
|
|
@ -1060,8 +1061,6 @@ impl GhosttyPaneTerminal {
|
|||
}
|
||||
}
|
||||
|
||||
core.osc52_forwarder.observe(bytes);
|
||||
let clipboard_writes = core.osc52_forwarder.drain_pending();
|
||||
core.osc_debug_tracker.observe(bytes);
|
||||
for event in core.osc_debug_tracker.drain_pending() {
|
||||
debug!(
|
||||
|
|
@ -1116,6 +1115,7 @@ impl GhosttyPaneTerminal {
|
|||
xtgettcap_responses,
|
||||
&mut terminal_responses,
|
||||
);
|
||||
let clipboard_writes = core.terminal.take_clipboard_writes();
|
||||
let reported_cwd = core
|
||||
.terminal
|
||||
.take_pwd_changes()
|
||||
|
|
@ -3221,6 +3221,38 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn process_pty_bytes_surfaces_clipboard_writes_without_other_results() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
let terminal = crate::ghostty::Terminal::new(80, 24, 100).unwrap();
|
||||
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
|
||||
|
||||
let result = pane.process_pty_bytes(
|
||||
PaneId::from_raw(1),
|
||||
0,
|
||||
b"output\x1b]52;c;Y2xpcGJvYXJk\x07",
|
||||
&tx,
|
||||
);
|
||||
|
||||
assert!(result.request_render);
|
||||
assert_eq!(result.render_delay, None);
|
||||
assert_eq!(result.clipboard_writes, vec![b"clipboard".to_vec()]);
|
||||
assert_eq!(result.reported_cwd, None);
|
||||
assert!(result.terminal_responses.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeded_history_clipboard_write_does_not_leak_into_live_output() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
let terminal = crate::ghostty::Terminal::new(80, 24, 100).unwrap();
|
||||
let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap();
|
||||
pane.seed_history_ansi("\x1b]52;c;c3RhbGU=\x07");
|
||||
|
||||
let result = pane.process_pty_bytes(PaneId::from_raw(1), 0, b"live output", &tx);
|
||||
|
||||
assert!(result.clipboard_writes.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn seeded_history_pwd_does_not_leak_into_live_output() {
|
||||
let (tx, _rx) = mpsc::channel(4);
|
||||
|
|
|
|||
Loading…
Reference in New Issue