From 00f04ac65cedf882f9785c0aa20831523c554dd0 Mon Sep 17 00:00:00 2001 From: JJ Liebig Date: Fri, 7 Aug 2026 18:24:56 +0200 Subject: [PATCH] fix: avoid redundant Windows recent-history snapshots (#2474) refs #962 Co-authored-by: Can Celik --- src/ghostty/mod.rs | 28 +++ src/pane/terminal.rs | 43 ++-- src/pane/terminal/windows_recent_fallback.rs | 204 +++++++++++++++++-- 3 files changed, 241 insertions(+), 34 deletions(-) diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs index ef67b86f..04ad87af 100644 --- a/src/ghostty/mod.rs +++ b/src/ghostty/mod.rs @@ -768,6 +768,9 @@ pub fn encode_focus(event: FocusEvent) -> Result, Error> { pub struct Terminal { raw: ffi::GhosttyTerminal, + max_scrollback: usize, + #[cfg(windows)] + tracked_row: ffi::GhosttyTrackedGridRef, callback_state: Box, kitty_fingerprints: Mutex>, kitty_empty_generation: Cell>, @@ -788,6 +791,9 @@ impl Terminal { let mut terminal = Self { raw, + max_scrollback, + #[cfg(windows)] + tracked_row: ptr::null_mut(), callback_state: Box::new(TerminalCallbackState { size_report: ffi::GhosttySizeReportSize { rows, @@ -1033,6 +1039,10 @@ impl Terminal { self.get_usize(ffi::GhosttyTerminalData_GHOSTTY_TERMINAL_DATA_SCROLLBACK_ROWS) } + pub fn max_scrollback(&self) -> usize { + self.max_scrollback + } + pub fn scrollbar(&self) -> Result { let mut out = ffi::GhosttyTerminalScrollbar::default(); unsafe { @@ -1050,6 +1060,22 @@ impl Terminal { }) } + #[cfg(windows)] + pub(crate) fn track_row(&mut self, y: u32) -> Option { + let mut point = ffi::GhosttyPointCoordinate::default(); + let tag = ffi::GhosttyPointTag_GHOSTTY_POINT_TAG_SCREEN; + let result = + unsafe { ffi::ghostty_tracked_grid_ref_point(self.tracked_row, tag, &mut point) }; + let terminal = self.raw; + let target = ghostty_viewport_point(0, y); + unsafe { + ffi::ghostty_tracked_grid_ref_free(self.tracked_row); + self.tracked_row = ptr::null_mut(); + let _ = ffi::ghostty_terminal_grid_ref_track(terminal, target, &mut self.tracked_row); + } + (result == ffi::GhosttyResult_GHOSTTY_SUCCESS).then_some(point.y as usize) + } + pub fn screen_cell(&self, x: u16, y: u32) -> Result<(CellWide, Vec), Error> { let grid_ref = self.grid_ref(ghostty_screen_point(x, y))?; let wide = grid_ref_wide(&grid_ref)?; @@ -1834,6 +1860,8 @@ impl Drop for Terminal { fn drop(&mut self) { // SAFETY: freeing a null or live handle is allowed by the C API. unsafe { + #[cfg(windows)] + ffi::ghostty_tracked_grid_ref_free(self.tracked_row); ffi::ghostty_terminal_free(self.raw); } } diff --git a/src/pane/terminal.rs b/src/pane/terminal.rs index d1dddb55..67b57153 100644 --- a/src/pane/terminal.rs +++ b/src/pane/terminal.rs @@ -1284,7 +1284,7 @@ impl GhosttyPaneTerminal { .filter_map(|value| parse_reported_cwd(&value)) .next_back(); #[cfg(windows)] - windows_recent_fallback::update(&mut core); + windows_recent_fallback::update_after_write(&mut core); crate::render_prof::duration_since("pty.ghostty_write", write_started); let has_kitty_graphics_sequence = crate::kitty_graphics::is_enabled() @@ -1545,7 +1545,7 @@ impl GhosttyPaneTerminal { .saturating_sub(scrollbar.offset + scrollbar.len) }) .unwrap_or(0); - let bottom_before_resize = ghostty_detection_text(&core) + let bottom_before_resize = ghostty_detection_text(&mut core) .map(|text| !text.trim().is_empty()) .unwrap_or(false); let resize_recovery_probe_lines = usize::from(rows) @@ -1555,7 +1555,7 @@ impl GhosttyPaneTerminal { == Some(crate::ghostty::ActiveScreen::Primary) && bottom_before_resize { - ghostty_recent_ansi(&core, resize_recovery_probe_lines, true) + ghostty_recent_ansi(&mut core, resize_recovery_probe_lines, true) .ok() .filter(|ansi| !ansi.trim().is_empty()) } else { @@ -1567,7 +1567,7 @@ impl GhosttyPaneTerminal { .resize(cols, rows, cell_width_px, cell_height_px); let terminal_responses = self.drain_pending_pty_responses(); - let bottom_is_blank = ghostty_detection_text(&core) + let bottom_is_blank = ghostty_detection_text(&mut core) .map(|text| text.trim().is_empty()) .unwrap_or(false); if bottom_is_blank { @@ -1576,6 +1576,12 @@ impl GhosttyPaneTerminal { core.terminal.write(ansi.as_bytes()); } } + #[cfg(windows)] + if core.recent_fallback.usable { + core.recent_fallback.needs_refresh = true; + core.terminal.scroll_viewport_bottom(); + windows_recent_fallback::update(&mut core); + } 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); @@ -1596,6 +1602,8 @@ impl GhosttyPaneTerminal { pub fn scroll_up(&self, lines: usize) { if let Ok(mut core) = self.core.lock() { + #[cfg(windows)] + windows_recent_fallback::refresh_if_needed(&mut core); core.terminal.scroll_viewport_delta(-(lines as isize)); } } @@ -1614,6 +1622,8 @@ impl GhosttyPaneTerminal { pub fn set_scroll_offset_from_bottom(&self, lines: usize) { if let Ok(mut core) = self.core.lock() { + #[cfg(windows)] + windows_recent_fallback::refresh_if_needed(&mut core); ghostty_set_scroll_offset_from_bottom(&mut core.terminal, lines); } } @@ -1915,7 +1925,7 @@ impl GhosttyPaneTerminal { self.core .lock() .ok() - .and_then(|core| ghostty_detection_text(&core).ok()) + .and_then(|mut core| ghostty_detection_text(&mut core).ok()) .unwrap_or_default() } @@ -1927,7 +1937,7 @@ impl GhosttyPaneTerminal { self.core .lock() .ok() - .and_then(|core| ghostty_recent_text_snapshot(&core, lines).ok()) + .and_then(|mut core| ghostty_recent_text_snapshot(&mut core, lines).ok()) .unwrap_or_default() } @@ -1940,7 +1950,7 @@ impl GhosttyPaneTerminal { self.core .lock() .ok() - .and_then(|core| ghostty_recent_ansi_snapshot(&core, lines, false).ok()) + .and_then(|mut core| ghostty_recent_ansi_snapshot(&mut core, lines, false).ok()) .unwrap_or_default() } @@ -1953,7 +1963,7 @@ impl GhosttyPaneTerminal { self.core .lock() .ok() - .and_then(|core| ghostty_recent_text_unwrapped_snapshot(&core, lines).ok()) + .and_then(|mut core| ghostty_recent_text_unwrapped_snapshot(&mut core, lines).ok()) .unwrap_or_default() } @@ -1965,7 +1975,7 @@ impl GhosttyPaneTerminal { self.core .lock() .ok() - .and_then(|core| ghostty_recent_ansi_snapshot(&core, lines, true).ok()) + .and_then(|mut core| ghostty_recent_ansi_snapshot(&mut core, lines, true).ok()) .unwrap_or_default() } @@ -2450,7 +2460,7 @@ fn ghostty_visible_ansi(core: &GhosttyPaneCore) -> Result Result { +fn ghostty_detection_text(core: &mut GhosttyPaneCore) -> Result { let lines = core .terminal .rows() @@ -2524,14 +2534,14 @@ fn windows_powershell_prompt_line_cwd(line: &str) -> Option } fn ghostty_recent_text( - core: &GhosttyPaneCore, + core: &mut GhosttyPaneCore, lines: usize, ) -> Result { ghostty_recent_text_snapshot(core, lines).map(|snapshot| snapshot.text) } fn ghostty_recent_text_snapshot( - core: &GhosttyPaneCore, + core: &mut GhosttyPaneCore, lines: usize, ) -> Result { let text = ghostty_recent_text_for_terminal(&core.terminal, lines)?; @@ -2539,7 +2549,7 @@ fn ghostty_recent_text_snapshot( } fn ghostty_recent_text_unwrapped_snapshot( - core: &GhosttyPaneCore, + core: &mut GhosttyPaneCore, lines: usize, ) -> Result { let text = ghostty_recent_text_unwrapped_for_terminal(&core.terminal, lines)?; @@ -2547,7 +2557,7 @@ fn ghostty_recent_text_unwrapped_snapshot( } fn ghostty_recent_ansi( - core: &GhosttyPaneCore, + core: &mut GhosttyPaneCore, lines: usize, unwrap: bool, ) -> Result { @@ -2555,7 +2565,7 @@ fn ghostty_recent_ansi( } fn ghostty_recent_ansi_snapshot( - core: &GhosttyPaneCore, + core: &mut GhosttyPaneCore, lines: usize, unwrap: bool, ) -> Result { @@ -2564,7 +2574,7 @@ fn ghostty_recent_ansi_snapshot( } fn finish_recent_snapshot( - core: &GhosttyPaneCore, + core: &mut GhosttyPaneCore, text: String, lines: usize, unwrap: bool, @@ -2573,6 +2583,7 @@ fn finish_recent_snapshot( let _ = unwrap; #[cfg(windows)] if text.trim().is_empty() { + windows_recent_fallback::refresh_if_needed(core); let fallback = windows_recent_fallback::recent_text(core, lines, unwrap); if !fallback.text.trim().is_empty() { return fallback; diff --git a/src/pane/terminal/windows_recent_fallback.rs b/src/pane/terminal/windows_recent_fallback.rs index caa3b9f7..efb58b49 100644 --- a/src/pane/terminal/windows_recent_fallback.rs +++ b/src/pane/terminal/windows_recent_fallback.rs @@ -6,7 +6,9 @@ const CACHE_LINES: usize = 2000; pub(super) struct Cache { rows: Vec, last_snapshot: Vec, - usable: bool, + pub(super) usable: bool, + last_scrollbar: Option<(usize, usize)>, + pub(super) needs_refresh: bool, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -20,10 +22,23 @@ pub(super) fn update(core: &mut GhosttyPaneCore) { if !primary_screen_active(core) { return; } - if !viewport_is_at_bottom(core) { + let scrollbar = core.terminal.scrollbar().ok(); + let pending_refresh = std::mem::take(&mut core.recent_fallback.needs_refresh); + let total = scrollbar.map(|metrics| metrics.total).unwrap_or_default(); + let len = scrollbar.map(|metrics| metrics.len).unwrap_or_default(); + let (old_total, old_len) = core.recent_fallback.last_scrollbar.unwrap_or((total, len)); + core.recent_fallback.last_scrollbar = scrollbar.map(|metrics| (metrics.total, metrics.len)); + if scrollbar.is_some_and(|metrics| !at_bottom(metrics)) { core.recent_fallback.usable = false; return; } + let tracked_row = core.terminal.track_row(len.saturating_sub(1) as u32); + let history_growth = if pending_refresh { + tracked_row.map_or(CACHE_LINES, |row| total.saturating_sub(row + 1)) + } else { + total.saturating_sub(old_total) + }; + let history_changed = history_growth > 0 || old_total != total || old_len != len; let Ok(snapshot) = visible_render_lines(core) else { core.recent_fallback.usable = false; return; @@ -34,14 +49,56 @@ pub(super) fn update(core: &mut GhosttyPaneCore) { core.recent_fallback.usable = false; return; } - if snapshot == core.recent_fallback.last_snapshot { + if snapshot == core.recent_fallback.last_snapshot && !history_changed { core.recent_fallback.usable = true; return; } - merge_snapshot(&mut core.recent_fallback.rows, &snapshot); - core.recent_fallback.last_snapshot = snapshot; - core.recent_fallback.usable = true; + let mut moved_snapshot = Vec::new(); + if pending_refresh && history_changed { + let viewport_rows = scrollbar.map_or(1, |metrics| metrics.len.max(1)); + let recovery_rows = history_growth.max(viewport_rows).min(CACHE_LINES); + for offset in (1..=recovery_rows).rev().step_by(viewport_rows) { + super::ghostty_set_scroll_offset_from_bottom(&mut core.terminal, offset); + let Ok(moved) = visible_render_lines(core) else { + core.terminal.scroll_viewport_bottom(); + core.recent_fallback.usable = false; + return; + }; + merge_snapshot(&mut moved_snapshot, &moved); + } + core.terminal.scroll_viewport_bottom(); + } + let cache = &mut core.recent_fallback; + if pending_refresh && cache.rows.ends_with(&cache.last_snapshot) { + cache + .rows + .truncate(cache.rows.len() - cache.last_snapshot.len()); + } + merge_snapshot(&mut cache.rows, &moved_snapshot); + merge_snapshot(&mut cache.rows, &snapshot); + cache.last_snapshot = snapshot; + cache.usable = true; +} + +pub(super) fn update_after_write(core: &mut GhosttyPaneCore) { + let scrollbar = core.terminal.scrollbar().ok(); + let old_total = core.recent_fallback.last_scrollbar.map(|old| old.0); + if primary_screen_active(core) + && core.terminal.max_scrollback() > 0 + && scrollbar.is_some_and(|metrics| old_total == Some(metrics.total) && at_bottom(metrics)) + { + core.recent_fallback.needs_refresh = true; + } else { + update(core); + } +} + +pub(super) fn refresh_if_needed(core: &mut GhosttyPaneCore) { + let bottom = core.terminal.scrollbar().map_or(true, at_bottom); + if core.recent_fallback.needs_refresh && primary_screen_active(core) && bottom { + update(core); + } } pub(super) fn recent_text( @@ -96,10 +153,7 @@ fn unwrapped_text(core: &GhosttyPaneCore, lines: usize) -> TerminalReadSnapshot } } -fn viewport_is_at_bottom(core: &GhosttyPaneCore) -> bool { - let Ok(scrollbar) = core.terminal.scrollbar() else { - return true; - }; +fn at_bottom(scrollbar: crate::ghostty::TerminalScrollbar) -> bool { scrollbar.offset.saturating_add(scrollbar.len) >= scrollbar.total } @@ -266,13 +320,95 @@ mod tests { assert_eq!(pane.recent_text(3).trim(), ""); } + #[test] + fn deferred_refresh_replaces_visible_tail_after_repaint() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(40, 3, 1024).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + pane.process_pty_bytes(pane_id, 0, b"older", &tx); + pane.process_pty_bytes(pane_id, 0, b"\r\nold\r\nprompt", &tx); + let mut core = pane.core.lock().unwrap(); + let _ = super::super::finish_recent_snapshot(&mut core, String::new(), 3, false); + drop(core); + pane.resize(2, 40, 8, 16); + pane.process_pty_bytes(pane_id, 0, b"\rupdated", &tx); + let mut core = pane.core.lock().unwrap(); + let resized = super::super::finish_recent_snapshot(&mut core, String::new(), 10, false); + assert_eq!(resized.text, "older\nold\nupdated\n"); + drop(core); + pane.process_pty_bytes(pane_id, 0, b"\x1b[2J\x1b[H", &tx); + pane.process_pty_bytes(pane_id, 0, b"updated", &tx); + let mut core = pane.core.lock().unwrap(); + let refreshed = super::super::finish_recent_snapshot(&mut core, String::new(), 10, false); + assert_eq!(refreshed.text, "older\nupdated\n"); + } + + #[test] + fn deferred_repaint_survives_real_scrollback_pruning() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(40, 1, 1).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + let state = || { + let core = pane.core.lock().unwrap(); + let total = core.terminal.scrollbar().unwrap().total; + (total, core.recent_fallback.needs_refresh) + }; + pane.process_pty_bytes(pane_id, 0, b"line-0000", &tx); + let first_total = state().0; + let mut max_total = first_total; + let prune = (1..=5000).find_map(|line| { + let repaint = format!("repaint-{line:04}"); + pane.process_pty_bytes(pane_id, 0, format!("\r{repaint}").as_bytes(), &tx); + let (before, pending) = state(); + assert!(pending); + pane.process_pty_bytes(pane_id, 0, format!("\r\nline-{line:04}").as_bytes(), &tx); + let after = state().0; + max_total = max_total.max(after); + (after < before).then_some((before, after, repaint)) + }); + let Some((before, after, repaint)) = prune else { + panic!("no Ghostty total decrease: first={first_total}, max={max_total}"); + }; + assert!(recent_text(&pane.core.lock().unwrap(), CACHE_LINES, false) + .text + .lines() + .any(|row| row == repaint)); + + let page_rows = before + 1 - after; + for line in 0..before - after { + pane.process_pty_bytes(pane_id, 0, format!("\r\nrefill-{line:04}").as_bytes(), &tx); + } + assert_eq!(state().0, before); + pane.process_pty_bytes(pane_id, 0, b"\rnet-zero-repaint", &tx); + let batch = (0..page_rows) + .map(|line| format!("\r\nbatch-{line:04}")) + .collect::(); + pane.process_pty_bytes(pane_id, 0, batch.as_bytes(), &tx); + assert_eq!(state(), (before, true)); + let mut core = pane.core.lock().unwrap(); + let fallback = + super::super::finish_recent_snapshot(&mut core, String::new(), CACHE_LINES, false); + for row in [ + "net-zero-repaint", + "batch-0000", + &format!("batch-{:04}", page_rows / 2), + &format!("batch-{:04}", page_rows - 1), + ] { + assert!( + fallback.text.lines().any(|line| line == row), + "lost {row}: total {before}->{after}->{before}" + ); + } + } + #[test] fn ignores_alternate_screen_snapshots() { let (tx, _rx) = mpsc::channel(4); let terminal = crate::ghostty::Terminal::new(40, 3, 1024).unwrap(); let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); let pane_id = PaneId::from_raw(1); - for line in 0..20 { pane.process_pty_bytes(pane_id, 0, format!("{line:06}\r\n").as_bytes(), &tx); } @@ -305,26 +441,58 @@ mod tests { for line in 0..20 { pane.process_pty_bytes(pane_id, 0, format!("{line:06}\r\n").as_bytes(), &tx); } + pane.process_pty_bytes(pane_id, 0, b"\rredraw", &tx); let before = pane.scroll_metrics().expect("scroll metrics before scroll"); pane.set_scroll_offset_from_bottom(before.max_offset_from_bottom); + { + let core = pane.core.lock().unwrap(); + assert!(!core.recent_fallback.needs_refresh); + assert!(recent_text(&core, 3, false).text.contains("redraw")); + } + pane.resize(4, 40, 8, 16); + pane.scroll_reset(); + assert!(recent_text(&pane.core.lock().unwrap(), 3, false) + .text + .contains("redraw")); + pane.set_scroll_offset_from_bottom(before.max_offset_from_bottom); pane.process_pty_bytes(pane_id, 0, b"new output\r\n", &tx); + pane.resize(4, 40, 8, 16); let core = pane.core.lock().unwrap(); + assert!(!core.recent_fallback.needs_refresh); assert_eq!(recent_text(&core, 3, false).text, ""); assert_eq!(recent_text(&core, 3, true).text, ""); } #[test] - fn seed_history_updates_fallback() { + fn zero_scrollback_keeps_eager_snapshots_across_writes() { let (tx, _rx) = mpsc::channel(4); - let terminal = crate::ghostty::Terminal::new(40, 3, 1024).unwrap(); - let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); - - pane.seed_history_ansi("seeded history\r\n"); + let terminal = crate::ghostty::Terminal::new(40, 2, 0).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(); + let pane_id = PaneId::from_raw(1); + pane.process_pty_bytes(pane_id, 0, b"one\r\n", &tx); + let total = pane.core.lock().unwrap().recent_fallback.last_scrollbar; + pane.process_pty_bytes(pane_id, 0, b"two\r\n", &tx); + pane.process_pty_bytes(pane_id, 0, b"three\r\n", &tx); let core = pane.core.lock().unwrap(); - assert!(recent_text(&core, 3, false).text.contains("seeded history")); - assert!(recent_text(&core, 3, true).text.contains("seeded history")); + assert_eq!(core.recent_fallback.last_scrollbar, total); + assert!(!core.recent_fallback.needs_refresh); + assert_eq!(recent_text(&core, 10, false).text, "one\ntwo\nthree\n"); + } + + #[test] + fn seed_history_updates_fallback() { + let (tx, _rx) = mpsc::channel(4); + let terminal = crate::ghostty::Terminal::new(5, 2, 1024).unwrap(); + let pane = GhosttyPaneTerminal::new(terminal, tx).unwrap(); + + pane.seed_history_ansi("abcdefghij\r\nend"); + pane.resize(3, 10, 8, 16); + + let core = pane.core.lock().unwrap(); + assert_eq!(recent_text(&core, 10, false).text, "abcdefghij\nend\n"); + assert_eq!(recent_text(&core, 10, true).text, "abcdefghij\nend\n"); } #[test]