From 89eca063e9f59c1826f5c1017c17417b0d0acfb4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 10 Jul 2026 09:50:40 -0700 Subject: [PATCH] terminal/search: avoid pruning history on reload reloadActive previously pruned every cached history result whenever the active area changed. Search reconciliation runs under the terminal lock, so this scaled with both result and page counts on a frame-paced path. Validate only the selected history result during reload. Keep full pruning before selection navigation so stale candidates are removed before their coordinates are tracked. --- src/terminal/search/screen.zig | 84 ++++++++++++++++++++++++++++++---- 1 file changed, 75 insertions(+), 9 deletions(-) diff --git a/src/terminal/search/screen.zig b/src/terminal/search/screen.zig index 859759739..e0cd99110 100644 --- a/src/terminal/search/screen.zig +++ b/src/terminal/search/screen.zig @@ -344,6 +344,39 @@ pub const ScreenSearch = struct { } } + fn historyResultIsValid( + self: *const ScreenSearch, + hl: *const FlattenedHighlight, + ) bool { + const chunks = hl.chunks.slice(); + const nodes = chunks.items(.node); + const serials = chunks.items(.serial); + for (nodes, serials) |node, serial| { + if (!self.screen.pages.nodeIsValid(node, serial)) return false; + } + + return true; + } + + /// Clear a selected history match if its flattened page coordinates are + /// stale. reloadActive only inspects the selected history result, so this + /// avoids validating every cached result on each active-area refresh. + fn validateSelectedHistory(self: *ScreenSearch) void { + const m = if (self.selected) |*m| m else return; + const active_len = self.active_results.items.len; + if (m.idx < active_len) return; + + const history_idx = m.idx - active_len; + if (history_idx < self.history_results.items.len and + self.historyResultIsValid(&self.history_results.items[history_idx])) + { + return; + } + + m.deinit(self.screen); + self.selected = null; + } + fn pruneHistory(self: *ScreenSearch) void { // Go through our history results in order (newest to oldest) to find // any result that contains an invalid serial. @@ -374,10 +407,7 @@ pub const ScreenSearch = struct { // Ordinary pruning, layout changes, and replacements do not // advance the epoch. Validate their pointer-plus-generation pairs // against the live list. - const nodes = chunks.items(.node); - for (nodes, serials) |node, serial| { - if (!self.screen.pages.nodeIsValid(node, serial)) break; - } else { + if (self.historyResultIsValid(hl)) { i += 1; continue; } @@ -459,9 +489,9 @@ pub const ScreenSearch = struct { // searchers: column reflow may have freed every node they reference. if (try self.resetIfDimensionsChanged()) return; - // Validate flattened history before any coordinate is converted back - // into a pin or compared with a tracked selection below. - self.pruneHistory(); + // reloadActive only inspects the selected history result, so validate + // that result without scanning every cached match on this hot path. + self.validateSelectedHistory(); const tw = reloadActive_tw; @@ -772,9 +802,11 @@ pub const ScreenSearch = struct { // selected result so no pre-resize pointer is ever dereferenced. _ = try self.resetIfDimensionsChanged(); - // All selection requires valid pins, so reload immediately. Reload - // validates flattened history before inspecting cached coordinates. + // Reload validates the selected history result before inspecting its + // cached coordinates. Prune the remaining results afterward so every + // candidate is valid before selection tracks it. try self.reloadActive(); + self.pruneHistory(); return switch (to) { .next => try self.selectNext(), @@ -1653,6 +1685,40 @@ test "select after partial history page erase ignores shifted results" { try testing.expect(search.selected == null); } +test "reload defers pruning unselected history results" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 10, + .rows = 2, + .max_scrollback = std.math.maxInt(usize), + }); + defer t.deinit(alloc); + const list: *PageList = &t.screens.active.pages; + + var stream = t.vtStream(); + defer stream.deinit(); + + const first = list.pages.first.?; + while (first.rows() < first.capacity().rows) stream.nextSlice("\r\n"); + stream.nextSlice("error"); + for (0..list.rows + 1) |_| stream.nextSlice("\r\n"); + + var search: ScreenSearch = try .init(alloc, t.screens.active, "error"); + defer search.deinit(); + try search.searchAll(); + try testing.expectEqual(1, search.history_results.items.len); + + list.eraseHistory(.{ .history = .{ .y = 0 } }); + + // Routine active refreshes don't inspect unselected history results. + try search.reloadActive(); + try testing.expectEqual(1, search.history_results.items.len); + + // Selection prunes every candidate before attempting to track it. + try testing.expect(!try search.select(.next)); + try testing.expectEqual(0, search.history_results.items.len); +} + test "reload after partial history page erase drops shifted selection first" { const alloc = testing.allocator; var t: Terminal = try .init(alloc, .{