diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-17 03:26:33 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-17 03:26:33 +0300 |
| commit | 8d2af4e046c2a95dfc54aafd6f88da12389d76e8 (patch) | |
| tree | 5643a1ec2f04b150d465817f2896ada4f2fbc46b /src/pane | |
| parent | 6f9ac7c108e22510e1b27e3242477fac660fa4cb (diff) | |
perf: adopt libghostty integration improvements
refs #276
Diffstat (limited to 'src/pane')
| -rw-r--r-- | src/pane/osc.rs | 123 | ||||
| -rw-r--r-- | src/pane/terminal.rs | 110 |
2 files changed, 100 insertions, 133 deletions
diff --git a/src/pane/osc.rs b/src/pane/osc.rs index f7cf9eb9..22e76ff1 100644 --- a/src/pane/osc.rs +++ b/src/pane/osc.rs @@ -396,84 +396,13 @@ impl Osc52Forwarder { } } -/// Reconstructs cwd-reporting OSC sequences from child output. Shell -/// integrations commonly use OSC 7 (`file://...`), while Windows Terminal -/// documents OSC 9;9 for the same practical purpose. -#[derive(Debug, Default)] -pub(super) struct CwdOscTracker { - state: Osc52ForwarderState, - body: Vec<u8>, - pending: Vec<PathBuf>, -} - -impl CwdOscTracker { - 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(cwd) = parse_cwd_osc(&self.body) { - self.pending.push(cwd); - } - self.body.clear(); - } - - pub(super) fn drain_latest(&mut self) -> Option<PathBuf> { - self.pending.drain(..).next_back() +pub(super) fn parse_reported_cwd(value: &[u8]) -> Option<PathBuf> { + let value = std::str::from_utf8(value).ok()?.trim(); + if value.starts_with("file://") { + return parse_file_uri_cwd(value); } -} - -fn parse_cwd_osc(body: &[u8]) -> Option<PathBuf> { - let body = std::str::from_utf8(body).ok()?; - if let Some(uri) = body.strip_prefix("7;") { - return parse_file_uri_cwd(uri); - } - if let Some(path) = body.strip_prefix("9;9;") { - let path = path.trim().trim_matches('"'); - return (!path.is_empty()).then(|| PathBuf::from(path)); - } - None + let path = value.trim_matches('"'); + (!path.is_empty()).then(|| PathBuf::from(path)) } /// Maximum retained string length for agent OSC title and progress payloads. @@ -1077,46 +1006,28 @@ mod tests { } #[test] - fn cwd_osc_tracker_detects_split_osc7_sequence() { - let mut tracker = CwdOscTracker::default(); - - tracker.observe(b"\x1b]7;file:///tmp/herdr%20repo"); - assert_eq!(tracker.drain_latest(), None); - tracker.observe(b"\x07"); - + fn reported_cwd_parses_file_uri_and_bare_paths() { assert_eq!( - tracker.drain_latest(), + parse_reported_cwd(b"file:///tmp/herdr%20repo"), Some(std::path::PathBuf::from("/tmp/herdr repo")) ); - } - - #[test] - fn cwd_osc_tracker_detects_windows_terminal_cwd_sequence() { - let mut tracker = CwdOscTracker::default(); - - tracker.observe(b"\x1b]9;9;C:\\Users\\herdr\\src\\herdr\x1b\\"); - assert_eq!( - tracker.drain_latest(), + parse_reported_cwd(b"C:\\Users\\herdr\\src\\herdr"), Some(std::path::PathBuf::from("C:\\Users\\herdr\\src\\herdr")) ); - } - - // The quoted form is what Windows Terminal's documented shell integration - // snippet emits. Herdr's own injected prompt integration deliberately - // emits the path unquoted; see WINDOWS_POWERSHELL_SHELL_INTEGRATION_COMMAND. - #[test] - fn cwd_osc_tracker_detects_quoted_powershell_prompt_cwd_sequence() { - let mut tracker = CwdOscTracker::default(); - - tracker.observe(b"PS C:\\my proj> \x1b]9;9;\"C:\\my proj\"\x1b\\"); - assert_eq!( - tracker.drain_latest(), + parse_reported_cwd(b"\"C:\\my proj\""), Some(std::path::PathBuf::from("C:\\my proj")) ); } + #[test] + fn reported_cwd_rejects_invalid_or_empty_values() { + assert_eq!(parse_reported_cwd(b""), None); + assert_eq!(parse_reported_cwd(b"\xff"), None); + assert_eq!(parse_reported_cwd(b"file://remote/tmp"), None); + } + // ----------------------------------------------------------------------- // AgentOscStateTracker tests // ----------------------------------------------------------------------- diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index cf7a82f4..8e33b1b3 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -28,10 +28,10 @@ use super::{ kitty_keyboard::KittyKeyboardTracker, osc::{ contains_scrollback_clear_sequence, current_transient_default_color_owner, - maybe_filter_primary_screen_scrollback_clear, restore_host_terminal_theme_if_needed, - write_host_terminal_theme_selective, AgentOscStateTracker, CwdOscTracker, - DefaultColorEvent, DefaultColorEventTracker, DefaultColorOscTracker, DefaultColorQuery, - DefaultColorTrackedEvent, Osc52Forwarder, OscDebugTracker, + 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, }, xtgettcap::{XtgettcapQueryTracker, XtgettcapResponse}, }; @@ -162,7 +162,6 @@ pub(crate) struct GhosttyPaneCore { pub child_default_foreground_changed: bool, pub child_default_background_changed: bool, pub osc52_forwarder: Osc52Forwarder, - pub cwd_osc_tracker: CwdOscTracker, pub osc_debug_tracker: OscDebugTracker, pub agent_osc_state: AgentOscStateTracker, pub xtgettcap_query_tracker: XtgettcapQueryTracker, @@ -917,7 +916,6 @@ impl GhosttyPaneTerminal { child_default_foreground_changed: false, child_default_background_changed: false, osc52_forwarder: Osc52Forwarder::default(), - cwd_osc_tracker: CwdOscTracker::default(), osc_debug_tracker: OscDebugTracker::default(), agent_osc_state: AgentOscStateTracker::default(), xtgettcap_query_tracker: XtgettcapQueryTracker::default(), @@ -1050,6 +1048,7 @@ impl GhosttyPaneTerminal { }; }; + let _ = core.terminal.take_pwd_changes(); 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) { @@ -1063,8 +1062,6 @@ impl GhosttyPaneTerminal { core.osc52_forwarder.observe(bytes); let clipboard_writes = core.osc52_forwarder.drain_pending(); - core.cwd_osc_tracker.observe(bytes); - let reported_cwd = core.cwd_osc_tracker.drain_latest(); core.osc_debug_tracker.observe(bytes); for event in core.osc_debug_tracker.drain_pending() { debug!( @@ -1119,6 +1116,12 @@ impl GhosttyPaneTerminal { xtgettcap_responses, &mut terminal_responses, ); + let reported_cwd = core + .terminal + .take_pwd_changes() + .into_iter() + .filter_map(|value| parse_reported_cwd(&value)) + .next_back(); #[cfg(windows)] windows_recent_fallback::update(&mut core); crate::render_prof::duration_since("pty.ghostty_write", write_started); @@ -1406,7 +1409,7 @@ impl GhosttyPaneTerminal { core.terminal.write(ansi.as_bytes()); } } - ghostty_restore_scroll_offset_from_bottom(&mut core.terminal, offset_from_bottom); + ghostty_set_scroll_offset_from_bottom(&mut core.terminal, offset_from_bottom); if offset_from_bottom > 0 { let mut remaining = offset_from_bottom.min(resize_recovery_probe_lines); while remaining > 0 @@ -1444,10 +1447,7 @@ impl GhosttyPaneTerminal { pub fn set_scroll_offset_from_bottom(&self, lines: usize) { if let Ok(mut core) = self.core.lock() { - core.terminal.scroll_viewport_bottom(); - if lines > 0 { - core.terminal.scroll_viewport_delta(-(lines as isize)); - } + ghostty_set_scroll_offset_from_bottom(&mut core.terminal, lines); } } @@ -2381,22 +2381,16 @@ fn ghostty_recent_read_range( Ok(Some((start, end, cols))) } -fn ghostty_restore_scroll_offset_from_bottom( +fn ghostty_set_scroll_offset_from_bottom( terminal: &mut crate::ghostty::Terminal, offset_from_bottom: usize, ) { - terminal.scroll_viewport_bottom(); - if offset_from_bottom == 0 { - return; - } let Ok(scrollbar) = terminal.scrollbar() else { + terminal.scroll_viewport_bottom(); return; }; let max_offset = scrollbar.total.saturating_sub(scrollbar.len); - let offset = offset_from_bottom.min(max_offset).min(isize::MAX as usize) as isize; - if offset > 0 { - terminal.scroll_viewport_delta(-offset); - } + terminal.scroll_viewport_row(max_offset.saturating_sub(offset_from_bottom)); } fn ghostty_extract_selection( @@ -3188,6 +3182,52 @@ mod tests { Bytes::from(format!("\x1b]{command};rgb:{r:04x}/{g:04x}/{b:04x}\x1b\\")) } + #[test] + fn process_pty_bytes_reports_latest_libghostty_pwd_callback() { + 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 pane_id = PaneId::from_raw(1); + + let partial = pane.process_pty_bytes(pane_id, 0, b"\x1b]7;file:///tmp/herdr%20", &tx); + assert_eq!(partial.reported_cwd, None); + + let completed = pane.process_pty_bytes(pane_id, 0, b"repo\x07", &tx); + #[cfg(not(windows))] + assert_eq!( + completed.reported_cwd, + Some(std::path::PathBuf::from("/tmp/herdr repo")) + ); + #[cfg(windows)] + assert_eq!( + completed.reported_cwd, + Some(std::path::PathBuf::from("\\tmp\\herdr repo")) + ); + + let latest = pane.process_pty_bytes( + pane_id, + 0, + b"\x1b]9;9;/tmp/conemu\x1b\\\x1b]1337;CurrentDir=/tmp/iterm2\x1b\\", + &tx, + ); + assert_eq!( + latest.reported_cwd, + Some(std::path::PathBuf::from("/tmp/iterm2")) + ); + } + + #[test] + fn seeded_history_pwd_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]7;file:///tmp/restored\x07"); + + let result = pane.process_pty_bytes(PaneId::from_raw(1), 0, b"live output", &tx); + + assert_eq!(result.reported_cwd, None); + } + #[cfg(windows)] #[test] fn windows_powershell_prompt_cwd_uses_latest_default_prompt_path() { @@ -4125,7 +4165,7 @@ mod tests { } #[test] - fn pane_scrollback_controls_reach_top_without_ui_interference() { + fn pane_scrollback_controls_round_trip_and_clamp_without_ui_interference() { let (tx, _rx) = mpsc::channel(4); let mut terminal = crate::ghostty::Terminal::new(80, 3, 100).unwrap(); write_numbered_lines(&mut terminal, 1000); @@ -4135,10 +4175,20 @@ mod tests { assert!(before.max_offset_from_bottom > 0); assert_eq!(before.offset_from_bottom, 0); - pane.set_scroll_offset_from_bottom(before.max_offset_from_bottom); + for offset in [ + 0, + before.max_offset_from_bottom / 2, + before.max_offset_from_bottom, + usize::MAX, + ] { + pane.set_scroll_offset_from_bottom(offset); + let after = pane.scroll_metrics().expect("scroll metrics after scroll"); + assert_eq!( + after.offset_from_bottom, + offset.min(after.max_offset_from_bottom) + ); + } - let after = pane.scroll_metrics().expect("scroll metrics after scroll"); - assert_eq!(after.offset_from_bottom, after.max_offset_from_bottom); assert!(pane.visible_text().contains("000000")); } @@ -4270,11 +4320,17 @@ mod tests { assert!(!pane.visible_text().trim().is_empty()); for (rows, cols) in [(4, 10), (4, 7), (6, 18), (3, 9), (5, 12)] { + let before_resize = pane.scroll_metrics().expect("scroll metrics before resize"); pane.resize(rows, cols, 0, 0); let metrics = pane.scroll_metrics().expect("scroll metrics after resize"); assert_eq!(metrics.viewport_rows, rows as usize); - assert!(metrics.offset_from_bottom <= metrics.max_offset_from_bottom); + assert_eq!( + metrics.offset_from_bottom, + before_resize + .offset_from_bottom + .min(metrics.max_offset_from_bottom) + ); assert!( metrics.offset_from_bottom > 0, "resize should preserve a scrolled viewport instead of jumping to bottom" |
