fix: avoid redundant Windows recent-history snapshots (#2474)

refs #962

Co-authored-by: Can Celik <ogulcancelik@gmail.com>
This commit is contained in:
JJ Liebig 2026-08-07 18:24:56 +02:00 committed by GitHub
parent b0723b7906
commit 00f04ac65c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 241 additions and 34 deletions

View File

@ -768,6 +768,9 @@ pub fn encode_focus(event: FocusEvent) -> Result<Vec<u8>, Error> {
pub struct Terminal {
raw: ffi::GhosttyTerminal,
max_scrollback: usize,
#[cfg(windows)]
tracked_row: ffi::GhosttyTrackedGridRef,
callback_state: Box<TerminalCallbackState>,
kitty_fingerprints: Mutex<HashMap<u32, KittyImageFingerprintEntry>>,
kitty_empty_generation: Cell<Option<u64>>,
@ -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<TerminalScrollbar, Error> {
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<usize> {
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<u32>), 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);
}
}

View File

@ -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<String, crate::ghostty
)
}
fn ghostty_detection_text(core: &GhosttyPaneCore) -> Result<String, crate::ghostty::Error> {
fn ghostty_detection_text(core: &mut GhosttyPaneCore) -> Result<String, crate::ghostty::Error> {
let lines = core
.terminal
.rows()
@ -2524,14 +2534,14 @@ fn windows_powershell_prompt_line_cwd(line: &str) -> Option<std::path::PathBuf>
}
fn ghostty_recent_text(
core: &GhosttyPaneCore,
core: &mut GhosttyPaneCore,
lines: usize,
) -> Result<String, crate::ghostty::Error> {
ghostty_recent_text_snapshot(core, lines).map(|snapshot| snapshot.text)
}
fn ghostty_recent_text_snapshot(
core: &GhosttyPaneCore,
core: &mut GhosttyPaneCore,
lines: usize,
) -> Result<TerminalReadSnapshot, crate::ghostty::Error> {
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<TerminalReadSnapshot, crate::ghostty::Error> {
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<String, crate::ghostty::Error> {
@ -2555,7 +2565,7 @@ fn ghostty_recent_ansi(
}
fn ghostty_recent_ansi_snapshot(
core: &GhosttyPaneCore,
core: &mut GhosttyPaneCore,
lines: usize,
unwrap: bool,
) -> Result<TerminalReadSnapshot, crate::ghostty::Error> {
@ -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;

View File

@ -6,7 +6,9 @@ const CACHE_LINES: usize = 2000;
pub(super) struct Cache {
rows: Vec<RenderedLine>,
last_snapshot: Vec<RenderedLine>,
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::<String>();
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]