mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-10 11:19:45 +00:00
terminal: preserve compressed pages during search
Search previously used the normal page access boundary while formatting history and checking soft-wrap boundaries. Inspecting compressed history therefore restored its retained mapping and undid the memory reclamation. Format through preserved page snapshots and copy row counts and wrap state into the sliding window's owned metadata. Overlap decisions reuse the same snapshot, so compressed pages are decoded at most once per append and remain compressed after matching. Add a cross-page regression which searches compressed history and verifies both source nodes retain their compressed representation.
This commit is contained in:
@@ -79,15 +79,11 @@ pub const ActiveSearch = struct {
|
||||
// Next, add enough overlap to cover needle.len - 1 bytes (if it
|
||||
// exists) so we can cover the overlap.
|
||||
while (node_) |node| : (node_ = node.prev) {
|
||||
// If the last row of this node isn't wrapped we can't overlap.
|
||||
const row = node.page().getRow(node.rows() - 1);
|
||||
if (!row.wrap) break;
|
||||
|
||||
// We could be more accurate here and count bytes since the
|
||||
// last wrap but its complicated and unlikely multiple pages
|
||||
// wrap so this should be fine.
|
||||
const added = try self.window.append(node);
|
||||
if (added >= self.window.needle.len - 1) break;
|
||||
const appended = try self.window.appendIfWrapped(node) orelse break;
|
||||
if (appended.content_len >= self.window.needle.len - 1) break;
|
||||
}
|
||||
|
||||
// Return the last node we added to our window.
|
||||
|
||||
@@ -3,6 +3,7 @@ const Allocator = std.mem.Allocator;
|
||||
const testing = std.testing;
|
||||
const terminal = @import("../main.zig");
|
||||
const point = terminal.point;
|
||||
const size = terminal.size;
|
||||
const FlattenedHighlight = @import("../highlight.zig").Flattened;
|
||||
const Page = terminal.Page;
|
||||
const PageList = terminal.PageList;
|
||||
@@ -121,7 +122,7 @@ pub const PageListSearch = struct {
|
||||
// get our desired amount of data.
|
||||
var node_: ?*PageList.List.Node = self.pin.node.prev;
|
||||
while (node_) |node| : (node_ = node.prev) {
|
||||
rem -|= try self.window.append(node);
|
||||
rem -|= (try self.window.append(node)).content_len;
|
||||
|
||||
// Move our tracked pin to the new node.
|
||||
self.pin.node = node;
|
||||
@@ -359,6 +360,76 @@ test "feed with match spanning page boundary" {
|
||||
try testing.expect(!try search.feed());
|
||||
}
|
||||
|
||||
test "compressed history match spanning page boundary remains compressed" {
|
||||
const alloc = testing.allocator;
|
||||
var t: Terminal = try .init(alloc, .{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
.max_scrollback = 10 * 1024 * 1024,
|
||||
});
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var stream = t.vtStream();
|
||||
defer stream.deinit();
|
||||
|
||||
const pages = &t.screens.active.pages;
|
||||
const first = pages.pages.first.?;
|
||||
const first_page_rows = first.capacity().rows;
|
||||
|
||||
// Put half the needle on either side of a soft-wrapped page boundary.
|
||||
for (0..first_page_rows - 1) |_| stream.nextSlice("\r\n");
|
||||
for (0..pages.cols - 2) |_| stream.nextSlice("x");
|
||||
stream.nextSlice("Test");
|
||||
const second = first.next.?;
|
||||
try testing.expectEqual(second, pages.pages.last.?);
|
||||
|
||||
// Move both matching pages completely into history. The third page holds
|
||||
// the active area, making the first two eligible for compression.
|
||||
while (pages.pages.last.? == second) stream.nextSlice("\r\n");
|
||||
for (0..pages.rows) |_| stream.nextSlice("\r\n");
|
||||
try testing.expect(pages.getTopLeft(.active).node != second);
|
||||
|
||||
_ = pages.compress(.full);
|
||||
try testing.expectEqual(PageList.List.Node.Storage.compressed, first.storage());
|
||||
try testing.expectEqual(PageList.List.Node.Storage.compressed, second.storage());
|
||||
const compressed = pages.memoryStats();
|
||||
try testing.expect(compressed.compressed_pages >= 2);
|
||||
|
||||
var search: PageListSearch = try .init(
|
||||
alloc,
|
||||
"Test",
|
||||
pages,
|
||||
pages.pages.last.?,
|
||||
);
|
||||
defer search.deinit();
|
||||
|
||||
// Drain and feed incrementally until the boundary match is available. The
|
||||
// exact number of feeds depends on how much blank-page text the formatter
|
||||
// trims, so bound progress by the number of pages rather than encoding
|
||||
// that implementation detail in the test.
|
||||
var found: ?FlattenedHighlight = null;
|
||||
for (0..pages.totalPages() + 1) |_| {
|
||||
if (search.next()) |match| {
|
||||
found = match;
|
||||
break;
|
||||
}
|
||||
if (!try search.feed()) break;
|
||||
}
|
||||
try testing.expect(found != null);
|
||||
|
||||
const match = found.?.untracked();
|
||||
try testing.expectEqual(first, match.start.node);
|
||||
try testing.expectEqual(second, match.end.node);
|
||||
try testing.expectEqual(@as(size.CellCountInt, pages.cols - 2), match.start.x);
|
||||
try testing.expectEqual(@as(size.CellCountInt, 1), match.end.x);
|
||||
|
||||
// Search owns formatted text and coordinate maps, not page snapshots. The
|
||||
// original raw mappings therefore remain discarded after matching.
|
||||
try testing.expectEqual(compressed, pages.memoryStats());
|
||||
try testing.expectEqual(PageList.List.Node.Storage.compressed, first.storage());
|
||||
try testing.expectEqual(PageList.List.Node.Storage.compressed, second.storage());
|
||||
}
|
||||
|
||||
test "feed with match spanning page boundary with newline" {
|
||||
const alloc = testing.allocator;
|
||||
var t: Terminal = try .init(alloc, .{ .cols = 80, .rows = 24 });
|
||||
|
||||
@@ -89,6 +89,7 @@ pub const SlidingWindow = struct {
|
||||
const Meta = struct {
|
||||
node: *PageList.List.Node,
|
||||
serial: u64,
|
||||
rows: size.CellCountInt,
|
||||
cell_map: std.ArrayList(point.Coordinate),
|
||||
|
||||
pub fn deinit(self: *Meta, alloc: Allocator) void {
|
||||
@@ -96,6 +97,17 @@ pub const SlidingWindow = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// Information copied from a page while appending it to the window.
|
||||
///
|
||||
/// Both values remain safe after the node's preserved page is released.
|
||||
/// In particular, callers use `last_row_wrapped` to decide whether an
|
||||
/// adjacent page can contribute to a cross-page match without reading the
|
||||
/// node again.
|
||||
pub const AppendResult = struct {
|
||||
content_len: usize,
|
||||
last_row_wrapped: bool,
|
||||
};
|
||||
|
||||
pub fn init(
|
||||
alloc: Allocator,
|
||||
direction: Direction,
|
||||
@@ -309,6 +321,12 @@ pub const SlidingWindow = struct {
|
||||
self.chunk_buf.clearRetainingCapacity();
|
||||
var result: terminal.highlight.Flattened = .empty;
|
||||
|
||||
// A reverse cross-page match needs the row count of the last meta in
|
||||
// search order after the chunks themselves are reversed. Snapshot it
|
||||
// while traversing Meta rather than dereferencing a PageList node after
|
||||
// the terminal lock has been released.
|
||||
var cross_page_end_rows: ?size.CellCountInt = null;
|
||||
|
||||
// Go through the meta nodes to find our start.
|
||||
const tl: struct {
|
||||
/// If non-null, we need to continue searching for the bottom-right.
|
||||
@@ -375,7 +393,7 @@ pub const SlidingWindow = struct {
|
||||
.node = meta.node,
|
||||
.serial = meta.serial,
|
||||
.start = @intCast(map.y),
|
||||
.end = meta.node.rows(),
|
||||
.end = meta.rows,
|
||||
});
|
||||
|
||||
break :tl .{
|
||||
@@ -410,7 +428,7 @@ pub const SlidingWindow = struct {
|
||||
.node = meta.node,
|
||||
.serial = meta.serial,
|
||||
.start = 0,
|
||||
.end = meta.node.rows(),
|
||||
.end = meta.rows,
|
||||
});
|
||||
|
||||
meta_consumed += meta.cell_map.items.len;
|
||||
@@ -420,6 +438,7 @@ pub const SlidingWindow = struct {
|
||||
// We found it
|
||||
const map = meta.cell_map.items[meta_i];
|
||||
result.bot_x = map.x;
|
||||
cross_page_end_rows = meta.rows;
|
||||
self.chunk_buf.appendAssumeCapacity(.{
|
||||
.node = meta.node,
|
||||
.serial = meta.serial,
|
||||
@@ -491,7 +510,7 @@ pub const SlidingWindow = struct {
|
||||
// order.
|
||||
assert(nodes.len >= 2);
|
||||
starts[0] = ends[0] - 1;
|
||||
ends[0] = nodes[0].rows();
|
||||
ends[0] = cross_page_end_rows.?;
|
||||
ends[nodes.len - 1] = starts[nodes.len - 1] + 1;
|
||||
starts[nodes.len - 1] = 0;
|
||||
} else {
|
||||
@@ -526,11 +545,49 @@ pub const SlidingWindow = struct {
|
||||
pub fn append(
|
||||
self: *SlidingWindow,
|
||||
node: *PageList.List.Node,
|
||||
) Allocator.Error!usize {
|
||||
) Allocator.Error!AppendResult {
|
||||
var preserved = try node.pagePreservingState(self.alloc);
|
||||
defer preserved.deinit();
|
||||
|
||||
const page = preserved.page();
|
||||
const last_row_wrapped = page.getRow(page.size.rows - 1).wrap;
|
||||
return self.appendPage(node, page, last_row_wrapped);
|
||||
}
|
||||
|
||||
/// Append a node only when its last row is soft wrapped.
|
||||
///
|
||||
/// This acquires one preserved page for both the wrap check and formatting
|
||||
/// so a compressed node is decoded at most once. It is used when loading
|
||||
/// the overlap around an otherwise complete search region.
|
||||
pub fn appendIfWrapped(
|
||||
self: *SlidingWindow,
|
||||
node: *PageList.List.Node,
|
||||
) Allocator.Error!?AppendResult {
|
||||
var preserved = try node.pagePreservingState(self.alloc);
|
||||
defer preserved.deinit();
|
||||
|
||||
const page = preserved.page();
|
||||
const last_row_wrapped = page.getRow(page.size.rows - 1).wrap;
|
||||
if (!last_row_wrapped) return null;
|
||||
return try self.appendPage(node, page, last_row_wrapped);
|
||||
}
|
||||
|
||||
/// Copy one preserved page into the window's owned search buffers.
|
||||
///
|
||||
/// No pointer into `page` may escape this function: compressed preserved
|
||||
/// pages own temporary decode storage, while resident values borrow node
|
||||
/// memory.
|
||||
fn appendPage(
|
||||
self: *SlidingWindow,
|
||||
node: *PageList.List.Node,
|
||||
page: *const terminal.Page,
|
||||
last_row_wrapped: bool,
|
||||
) Allocator.Error!AppendResult {
|
||||
// Initialize our metadata for the node.
|
||||
var meta: Meta = .{
|
||||
.node = node,
|
||||
.serial = node.serial,
|
||||
.rows = page.size.rows,
|
||||
.cell_map = .empty,
|
||||
};
|
||||
errdefer meta.deinit(self.alloc);
|
||||
@@ -544,7 +601,7 @@ pub const SlidingWindow = struct {
|
||||
|
||||
// Encode the page into the buffer.
|
||||
const formatter: PageFormatter = formatter: {
|
||||
var formatter: PageFormatter = .init(meta.node.page(), .{
|
||||
var formatter: PageFormatter = .init(page, .{
|
||||
.emit = .plain,
|
||||
.unwrap = true,
|
||||
});
|
||||
@@ -563,8 +620,7 @@ pub const SlidingWindow = struct {
|
||||
|
||||
// If the node we're adding isn't soft-wrapped, we add the
|
||||
// trailing newline.
|
||||
const row = node.page().getRow(node.rows() - 1);
|
||||
if (!row.wrap) {
|
||||
if (!last_row_wrapped) {
|
||||
encoded.writer.writeByte('\n') catch return error.OutOfMemory;
|
||||
try meta.cell_map.append(
|
||||
self.alloc,
|
||||
@@ -580,7 +636,10 @@ pub const SlidingWindow = struct {
|
||||
const written = encoded.written();
|
||||
if (written.len == 0) {
|
||||
self.assertIntegrity();
|
||||
return 0;
|
||||
return .{
|
||||
.content_len = 0,
|
||||
.last_row_wrapped = last_row_wrapped,
|
||||
};
|
||||
}
|
||||
|
||||
// Get our written data. If we're doing a reverse search then we
|
||||
@@ -603,7 +662,10 @@ pub const SlidingWindow = struct {
|
||||
self.meta.appendAssumeCapacity(meta);
|
||||
|
||||
self.assertIntegrity();
|
||||
return written.len;
|
||||
return .{
|
||||
.content_len = written.len,
|
||||
.last_row_wrapped = last_row_wrapped,
|
||||
};
|
||||
}
|
||||
|
||||
/// Only for tests!
|
||||
|
||||
@@ -137,37 +137,35 @@ pub const ViewportSearch = struct {
|
||||
var node_ = fingerprint.nodes[0].prev;
|
||||
var added: usize = 0;
|
||||
while (node_) |node| : (node_ = node.prev) {
|
||||
// If the last row of this node isn't wrapped we can't overlap.
|
||||
const row = node.page().getRow(node.rows() - 1);
|
||||
if (!row.wrap) break;
|
||||
|
||||
// We could be more accurate here and count bytes since the
|
||||
// last wrap but its complicated and unlikely multiple pages
|
||||
// wrap so this should be fine.
|
||||
added += try self.window.append(node);
|
||||
const appended = try self.window.appendIfWrapped(node) orelse break;
|
||||
added += appended.content_len;
|
||||
if (added >= self.window.needle.len - 1) break;
|
||||
}
|
||||
|
||||
// We can use our fingerprint nodes to initialize our sliding
|
||||
// window, since we already traversed the viewport once.
|
||||
var end_append: SlidingWindow.AppendResult = undefined;
|
||||
for (fingerprint.nodes) |node| {
|
||||
_ = try self.window.append(node);
|
||||
end_append = try self.window.append(node);
|
||||
}
|
||||
|
||||
// Add any trailing overlap as well.
|
||||
trailing: {
|
||||
const end: *PageList.List.Node = fingerprint.nodes[fingerprint.nodes.len - 1];
|
||||
if (!end.page().getRow(end.rows() - 1).wrap) break :trailing;
|
||||
if (!end_append.last_row_wrapped) break :trailing;
|
||||
|
||||
node_ = end.next;
|
||||
added = 0;
|
||||
while (node_) |node| : (node_ = node.next) {
|
||||
added += try self.window.append(node);
|
||||
const appended = try self.window.append(node);
|
||||
added += appended.content_len;
|
||||
if (added >= self.window.needle.len - 1) break;
|
||||
|
||||
// If this row doesn't wrap, then we can quit
|
||||
const row = node.page().getRow(node.rows() - 1);
|
||||
if (!row.wrap) break;
|
||||
if (!appended.last_row_wrapped) break;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user