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.
This commit is contained in:
Mitchell Hashimoto
2026-07-10 09:50:40 -07:00
parent 0bad91adb8
commit 89eca063e9

View File

@@ -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, .{