diff --git a/src/api/schema.rs b/src/api/schema.rs index 074cd4ed..c6648085 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -167,6 +167,8 @@ pub enum Method { PaneEditScrollback(PaneTarget), #[serde(rename = "pane.selection.read")] PaneSelectionRead(PaneSelectionReadParams), + #[serde(rename = "pane.selection.read_checked")] + PaneSelectionReadChecked(PaneSelectionReadCheckedParams), #[serde(rename = "pane.copy_motion")] PaneCopyMotion(PaneCopyMotionParams), #[serde(rename = "pane.copy_search")] diff --git a/src/api/schema/panes.rs b/src/api/schema/panes.rs index 517870f7..8409b74c 100644 --- a/src/api/schema/panes.rs +++ b/src/api/schema/panes.rs @@ -263,6 +263,15 @@ pub struct PaneSelectionReadParams { pub content_revision: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct PaneSelectionReadCheckedParams { + pub pane_id: String, + pub anchor: PaneTextPoint, + pub cursor: PaneTextPoint, + /// Selected cell symbols in reading order, including empty wide-character tails. + pub expected_cells: Vec, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] pub enum PaneCopyMotion { diff --git a/src/api/server.rs b/src/api/server.rs index 655d89c5..bc2df1df 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -435,6 +435,7 @@ pub(crate) fn api_method_name(method: &Method) -> &'static str { Method::PaneScroll(_) => "pane.scroll", Method::PaneEditScrollback(_) => "pane.edit_scrollback", Method::PaneSelectionRead(_) => "pane.selection.read", + Method::PaneSelectionReadChecked(_) => "pane.selection.read_checked", Method::PaneCopyMotion(_) => "pane.copy_motion", Method::PaneCopySearch(_) => "pane.copy_search", Method::PaneList(_) => "pane.list", diff --git a/src/app/api.rs b/src/app/api.rs index 598356b0..45af3a1b 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -1090,6 +1090,9 @@ impl App { Method::PaneSelectionRead(params) => { return self.handle_pane_selection_read(request.id, params); } + Method::PaneSelectionReadChecked(params) => { + return self.handle_pane_selection_read_checked(request.id, params); + } Method::PaneCopyMotion(params) => { return self.handle_pane_copy_motion(request.id, params); } diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index f1c3a667..aa412c80 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -266,6 +266,55 @@ impl App { } } + pub(crate) fn pane_selection_text_checked( + &self, + params: &crate::api::schema::PaneSelectionReadCheckedParams, + ) -> Result { + let runtime = self + .parse_pane_id(¶ms.pane_id) + .and_then(|(ws_idx, pane_id)| { + self.state + .runtime_for_pane_in_workspace(&self.terminal_runtimes, ws_idx, pane_id) + .map(|runtime| (pane_id, runtime)) + }); + let Some((pane_id, runtime)) = runtime else { + return Err(( + "pane_not_found", + format!("pane not found: {}", params.pane_id), + )); + }; + let selection = crate::selection::Selection::absolute_range( + pane_id, + (params.anchor.row, params.anchor.col), + (params.cursor.row, params.cursor.col), + ); + runtime + .extract_selection_checked(&selection, ¶ms.expected_cells) + .ok_or_else(|| { + ( + "stale_content", + "selected text changed or is unavailable".to_owned(), + ) + }) + } + + pub(super) fn handle_pane_selection_read_checked( + &mut self, + id: String, + params: crate::api::schema::PaneSelectionReadCheckedParams, + ) -> String { + match self.pane_selection_text_checked(¶ms) { + Ok(text) => encode_success( + id, + ResponseResult::PaneSelection { + pane_id: params.pane_id, + text, + }, + ), + Err((code, message)) => encode_error(id, code, message), + } + } + pub(super) fn handle_pane_copy_motion( &mut self, id: String, diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index db1feaa4..302f4ef5 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -1,6 +1,54 @@ use super::*; impl ClientShellState { + pub(super) fn request_retained_selection_copy(&mut self, outcome: &mut ClientShellInput) { + let checked = (|| { + let selection = self.selection.as_ref()?; + let surface = self.pane_surface.as_ref()?; + let pane = surface + .panes + .iter() + .find(|pane| pane.pane_id == selection.pane_id)?; + let ((start_row, start_col), (end_row, end_col)) = selection.ordered_cells(); + let mut expected_cells = Vec::new(); + for row in start_row..=end_row { + let first = if row == start_row { start_col } else { 0 }; + let last = if row == end_row { + end_col + } else { + pane.inner_rect.width.checked_sub(1)? + }; + let cells = pane_surface_row(surface, pane, row)? + .get(usize::from(first)..=usize::from(last))?; + expected_cells.extend(cells.iter().map(|cell| cell.symbol.clone())); + } + Some(crate::api::schema::Method::PaneSelectionReadChecked( + crate::api::schema::PaneSelectionReadCheckedParams { + pane_id: selection.pane_id.clone(), + anchor: crate::api::schema::PaneTextPoint { + row: start_row, + col: start_col, + }, + cursor: crate::api::schema::PaneTextPoint { + row: end_row, + col: end_col, + }, + expected_cells, + }, + )) + })(); + if let Some(method) = checked.filter(|method| self.supports_endpoint_method(method)) { + self.push_endpoint_method_with_kind( + method, + PendingEndpointKind::SelectionCopy, + outcome, + ); + } else { + // Older endpoints and ranges beyond the retained surface still require an exact revision. + self.request_selection_copy(outcome); + } + } + pub(super) fn record_binding( &mut self, binding: crate::input::KeybindMatch, @@ -245,22 +293,16 @@ impl ClientShellState { } pub(super) fn request_selection_copy(&mut self, outcome: &mut ClientShellInput) { - let content_revision = self.selection.as_ref().and_then(|selection| { + let Some(content_revision) = self.selection.as_ref().and_then(|selection| { self.pane_surface .as_ref()? .panes .iter() .find(|pane| pane.pane_id == selection.pane_id) .map(|pane| pane.content_revision) - }); - self.request_selection_copy_at_revision(outcome, content_revision); - } - - pub(super) fn request_selection_copy_at_revision( - &mut self, - outcome: &mut ClientShellInput, - content_revision: Option, - ) { + }) else { + return; + }; let Some(selection) = self.selection.as_ref() else { return; }; @@ -278,7 +320,7 @@ impl ClientShellState { row: cursor.0, col: cursor.1, }, - content_revision, + content_revision: Some(content_revision), }, ), PendingEndpointKind::SelectionCopy, diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index 4d191048..b63693d5 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -520,8 +520,7 @@ impl ClientShellState { .as_ref() .is_some_and(crate::selection::Selection::is_visible) { - // Unrelated live output must not invalidate a retained selection's copy. - self.request_selection_copy_at_revision(outcome, None); + self.request_retained_selection_copy(outcome); self.selection = None; self.stop_selection_autoscroll(); self.selection_highlight_clear_deadline = None; diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index a43ad717..11156a35 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -4,7 +4,7 @@ pub(super) const MIN_TAB_WIDTH: u16 = 8; pub(super) const NEW_TAB_WIDTH: u16 = 3; pub(super) const WORKSPACE_HEADER_ROWS: u16 = 2; -fn pane_surface_row<'a>( +pub(super) fn pane_surface_row<'a>( surface: &'a PaneSurfaceFrame, pane: &crate::protocol::PaneSurfacePane, absolute_row: u32, diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index 06a5e780..68620a65 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -218,13 +218,13 @@ async fn retained_mouse_selection_copies_only_on_exact_copy_shortcut() { assert!(matches!( ©.actions[..], [ClientShellAction::Endpoint { request, .. }] - if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_)) + if matches!(request.method, crate::api::schema::Method::PaneSelectionReadChecked(_)) )); assert!(copy.requests.is_empty()); let ClientShellAction::Endpoint { request, .. } = ©.actions[0] else { unreachable!(); }; - let crate::api::schema::Method::PaneSelectionRead(params) = &request.method else { + let crate::api::schema::Method::PaneSelectionReadChecked(params) = &request.method else { unreachable!(); }; let (_tx, rx) = tokio::sync::mpsc::unbounded_channel(); @@ -246,16 +246,29 @@ async fn retained_mouse_selection_copies_only_on_exact_copy_shortcut() { app.state.insert_test_runtime(pane_id, runtime); let mut params = params.clone(); params.pane_id = app.public_pane_id(0, pane_id).expect("pane id"); - let mut checked = params.clone(); - checked.content_revision = Some(2); + let checked = crate::api::schema::PaneSelectionReadParams { + pane_id: params.pane_id.clone(), + anchor: params.anchor, + cursor: params.cursor, + content_revision: Some(2), + }; assert_eq!( app.pane_selection_text(&checked).unwrap_err().0, "stale_content" ); let text = app - .pane_selection_text(¶ms) + .pane_selection_text_checked(¶ms) .expect("copy during output"); assert_eq!(text, "LIV"); + // Selected cells changing between the key and extraction must reject the copy. + app.state + .runtime_for_pane_in_workspace(&app.terminal_runtimes, 0, pane_id) + .expect("runtime") + .test_process_pty_bytes(b"\x1b[HOTHER"); + assert_eq!( + app.pane_selection_text_checked(¶ms).unwrap_err().0, + "stale_content" + ); let (_, actions) = state.handle_endpoint_result( "boot-1", &request.id, @@ -314,6 +327,51 @@ fn retained_selection_copy_failures_never_send_terminal_input() { } } +#[test] +fn retained_selection_copy_requires_revision_without_checked_read() { + for (offscreen, invalidate) in [(false, false), (true, false), (false, true)] { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.config.copy_on_select = false; + state.set_snapshot(Box::new(snapshot())); + state.set_pane_surface(surface()); + if !offscreen { + state.set_endpoint_methods(Some(vec!["pane.selection.read".into()])); + } + let mut selection = crate::selection::Selection::absolute_range( + "pane_1".to_owned(), + (0, 0), + (if offscreen { 2 } else { 0 }, 2), + ); + selection.finish(); + state.selection = Some(selection); + if invalidate { + state.invalidate_pane_surface(); + } + let copy = state.handle_input_bytes(b"\x03"); + if invalidate { + assert!(copy.actions.is_empty()); + assert!(copy.requests.is_empty()); + continue; + } + let [ClientShellAction::Endpoint { request, .. }] = ©.actions[..] else { + panic!("copy request"); + }; + assert!( + matches!(&request.method, crate::api::schema::Method::PaneSelectionRead(params) if params.content_revision == Some(0)) + ); + let (_, actions) = state.handle_endpoint_result( + "boot-1", + &request.id, + Err(ClientShellEndpointError { + code: Some("stale_content".into()), + message: "pane content changed".into(), + }), + ); + assert!(actions.is_empty()); + assert!(copy.requests.is_empty()); + } +} + #[test] fn selection_edge_drag_requests_scroll_and_timer_continues_it() { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); @@ -1002,7 +1060,7 @@ fn retained_selection_copy_suppresses_key_repeats() { assert!(press.actions.iter().any(|action| matches!( action, ClientShellAction::Endpoint { request, .. } - if matches!(request.method, crate::api::schema::Method::PaneSelectionRead(_)) + if matches!(request.method, crate::api::schema::Method::PaneSelectionReadChecked(_)) ))); let repeat = state.handle_raw_events(vec![RawInputEvent::Key( key.with_kind(crossterm::event::KeyEventKind::Repeat), diff --git a/src/pane.rs b/src/pane.rs index 4f1ea0ad..3ce0b9ee 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -3070,6 +3070,18 @@ impl PaneRuntime { result } + pub(crate) fn extract_selection_checked( + &self, + selection: &crate::selection::Selection, + expected_cells: &[String], + ) -> Option { + let result = self + .terminal + .extract_selection_checked(selection, expected_cells); + self.compression.wake(); + result + } + pub fn render(&self, frame: &mut Frame, area: Rect, show_cursor: bool) { self.terminal.render(frame, area, show_cursor); } diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index ecc12ed5..71097ec1 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -522,6 +522,15 @@ impl PaneTerminal { self.ghostty.extract_selection(selection) } + pub(crate) fn extract_selection_checked( + &self, + selection: &crate::selection::Selection, + expected_cells: &[String], + ) -> Option { + self.ghostty + .extract_selection_checked(selection, expected_cells) + } + pub fn render(&self, frame: &mut Frame, area: Rect, show_cursor: bool) { self.ghostty.render(frame, area, show_cursor); } @@ -2182,6 +2191,54 @@ impl GhosttyPaneTerminal { .and_then(|mut core| ghostty_extract_selection(&mut core, selection).ok()) } + pub(crate) fn extract_selection_checked( + &self, + selection: &crate::selection::Selection, + expected_cells: &[String], + ) -> Option { + // Compare and extract under one lock: output may change other cells, not this range. + let mut core = self.core.lock().ok()?; + let cols = core.terminal.cols().ok()?; + let ((start_row, start_col), (end_row, end_col)) = selection.ordered_cells(); + let mut expected = expected_cells.iter(); + for row in start_row..=end_row { + let first = if row == start_row { start_col } else { 0 }; + let last = if row == end_row { + end_col + } else { + cols.checked_sub(1)? + }; + for col in first..=last { + let expected_symbol = expected.next()?; + let (wide, graphemes) = core.terminal.screen_cell(col, row).ok()?; + let symbol = match wide { + crate::ghostty::CellWide::SpacerTail => String::new(), + crate::ghostty::CellWide::SpacerHead => " ".to_owned(), + _ => { + let text: String = + graphemes.into_iter().filter_map(char::from_u32).collect(); + if text.is_empty() + || (crate::kitty_graphics::is_enabled() + && text.chars().next().map(u32::from) + == Some(crate::ghostty::KITTY_UNICODE_PLACEHOLDER)) + { + " ".to_owned() + } else { + text + } + } + }; + if ghostty_normalize_buffer_symbol(&symbol, wide) != *expected_symbol { + return None; + } + } + } + if expected.next().is_some() { + return None; + } + ghostty_extract_selection(&mut core, selection).ok() + } + pub fn visible_hyperlinks(&self, area: Rect) -> Vec<((u16, u16), String, String)> { self.core .lock() @@ -2975,7 +3032,6 @@ pub(super) fn ghostty_blank_symbol_for_width(wide: crate::ghostty::CellWide) -> } } -#[cfg(test)] pub(super) fn ghostty_normalize_buffer_symbol( symbol: &str, wide: crate::ghostty::CellWide, @@ -5319,6 +5375,61 @@ mod tests { assert_eq!(text, "000003\n000004\n000005"); } + #[test] + fn checked_selection_preserves_terminal_wrapping_and_wide_graphemes() { + for (input, start, end, expected, text) in [ + ( + "1ABCD2EFGH3IJKL", + (1, 0), + (2, 2), + vec!["2", "E", "F", "G", "H", "3", "I", "J"], + "2EFGH3IJ", + ), + ( + "abc\r\ndef", + (0, 0), + (1, 2), + vec!["a", "b", "c", " ", " ", "d", "e", "f"], + "abc\ndef", + ), + ( + "中e\u{301}!", + (0, 0), + (0, 3), + vec!["中", "", "e\u{301}", "!"], + "中e\u{301}!", + ), + ] { + let (tx, _rx) = mpsc::channel(4); + let mut terminal = crate::ghostty::Terminal::new(5, 3, 0).unwrap(); + terminal.write(input.as_bytes()); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + let selection = + crate::selection::Selection::absolute_range(PaneId::from_raw(1), start, end); + let expected: Vec = expected.into_iter().map(str::to_owned).collect(); + assert_eq!( + pane.extract_selection_checked(&selection, &expected) + .as_deref(), + Some(text) + ); + assert!(pane + .extract_selection_checked(&selection, &expected[..expected.len() - 1]) + .is_none()); + let mut extra = expected.clone(); + extra.push(" ".into()); + assert!(pane.extract_selection_checked(&selection, &extra).is_none()); + // Scrolling with no history replaces the absolute rows beneath the request. + pane.core + .lock() + .unwrap() + .terminal + .write(b"\r\nxxxxx\r\nyyyyy\r\nzzzzz"); + assert!(pane + .extract_selection_checked(&selection, &expected) + .is_none()); + } + } + #[test] fn recent_reads_include_viewport_before_scrollback_exists() { let (tx, _rx) = mpsc::channel(4); diff --git a/src/server/client_commands.rs b/src/server/client_commands.rs index 34be0519..afae72a9 100644 --- a/src/server/client_commands.rs +++ b/src/server/client_commands.rs @@ -29,6 +29,7 @@ const CLIENT_SHELL_METHODS: &[&str] = &[ "pane.resize", "pane.scroll", "pane.selection.read", + "pane.selection.read_checked", "pane.split", "pane.swap", "pane.zoom", diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 4fa80326..7e92a7bd 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -407,6 +407,14 @@ impl TerminalRuntime { self.0.extract_selection(selection) } + pub(crate) fn extract_selection_checked( + &self, + selection: &crate::selection::Selection, + expected_cells: &[String], + ) -> Option { + self.0.extract_selection_checked(selection, expected_cells) + } + pub fn render(&self, frame: &mut Frame, area: Rect, show_cursor: bool) { self.0.render(frame, area, show_cursor); } diff --git a/tests/fixtures/endpoint-method-shapes-v1.json b/tests/fixtures/endpoint-method-shapes-v1.json index b473869f..900a337c 100644 --- a/tests/fixtures/endpoint-method-shapes-v1.json +++ b/tests/fixtures/endpoint-method-shapes-v1.json @@ -15,6 +15,7 @@ "pane.resize": "9fbcc70b8908c43f47b0ff85d0a9040ce18c9179a029ed7d8f2616a05d8c301d", "pane.scroll": "3229d55230b89730e5231a43d87cefa98cfda24acaaf6365f7c6a3548cc42db1", "pane.selection.read": "2f7b0fdf4f0fc1f1fe9ab4d962c833dd39a1e9bd866d77bbf0031f8b2c485534", + "pane.selection.read_checked": "7a831a263fb9beda5e287efd9189275a8a55ae6789e98a1a0993f9c3b75ee280", "pane.split": "5d740322cac5287070194fb522c8135795a6f877b44652ea4959c63b4c67cefd", "pane.swap": "3146d499d8174d73cfdfa3b6ca44f5d5d0951ab1cdd1808f94a3cf8846dc8057", "pane.zoom": "0521e444ea892abb1f73d16dc0e10a5667c8a752284eae4cbc0961cd6d96bbfe",