mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-31 20:59:02 +00:00
terminal/snapshot: history record
This commit is contained in:
730
src/terminal/snapshot/history.zig
Normal file
730
src/terminal/snapshot/history.zig
Normal file
@@ -0,0 +1,730 @@
|
||||
//! HISTORY record payload encoding.
|
||||
//!
|
||||
//! One HISTORY record describes the complete history for a previously encoded
|
||||
//! screen. It is followed immediately by the number of complete PAGE records
|
||||
//! declared by `page_count`. Those pages contain the history older than the
|
||||
//! first page in the corresponding SCREEN sequence and are ordered from newest
|
||||
//! to oldest.
|
||||
//!
|
||||
//! Every encoded SCREEN has one corresponding HISTORY record. A screen with no
|
||||
//! history uses zero for all three count fields and has no following PAGE
|
||||
//! records.
|
||||
//!
|
||||
//! The first SCREEN page may begin above the active area. Those incidental
|
||||
//! history rows are counted by `screen_overlap_rows`; they are not repeated
|
||||
//! after HISTORY. `total_rows` is the authoritative number of logical rows
|
||||
//! before the active area, including both the SCREEN overlap and the rows in
|
||||
//! the following PAGE records.
|
||||
//!
|
||||
//! All integers are unsigned and little-endian.
|
||||
//!
|
||||
//! ## Binary Format
|
||||
//!
|
||||
//! A HISTORY record is followed immediately by its declared PAGE records:
|
||||
//!
|
||||
//! ```text
|
||||
//! +----------------------+
|
||||
//! | HISTORY record |
|
||||
//! +----------------------+
|
||||
//! | PAGE record 0 | newest
|
||||
//! +----------------------+
|
||||
//! | ... |
|
||||
//! +----------------------+
|
||||
//! | PAGE record (n - 1) | oldest
|
||||
//! +----------------------+
|
||||
//!
|
||||
//! n = page_count
|
||||
//! ```
|
||||
//!
|
||||
//! Newest-to-oldest order lets a reader start showing most-recent
|
||||
//! history as soon as possible which is more useful to a user.
|
||||
//!
|
||||
//! The PAGE sequence may be empty when all history is already included in the
|
||||
//! first SCREEN page or when the screen has no history.
|
||||
//!
|
||||
//! ### Header
|
||||
//!
|
||||
//! The HISTORY payload consists only of this fixed header:
|
||||
//!
|
||||
//! ```text
|
||||
//! 0 +--------------------------------+
|
||||
//! | Screen key (u16) |
|
||||
//! 2 +--------------------------------+
|
||||
//! | Following page count (u32) |
|
||||
//! 6 +--------------------------------+
|
||||
//! | Total logical history rows |
|
||||
//! | u64 |
|
||||
//! 14 +--------------------------------+
|
||||
//! | SCREEN overlap rows (u16) |
|
||||
//! 16 +--------------------------------+
|
||||
//! ```
|
||||
//!
|
||||
//! The sum of `screen_overlap_rows` and the logical row counts of all
|
||||
//! following PAGE records must equal `total_rows`. A decoder uses `page_count`,
|
||||
//! rather than another record tag, to find the end of the page sequence.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const io = @import("io.zig");
|
||||
const page = @import("page.zig");
|
||||
const record = @import("record.zig");
|
||||
const screen = @import("screen.zig");
|
||||
const TerminalPage = @import("../page.zig").Page;
|
||||
const TerminalPageList = @import("../PageList.zig");
|
||||
const TerminalScreen = @import("../Screen.zig");
|
||||
const TerminalScreenKey = @import("../ScreenSet.zig").Key;
|
||||
|
||||
/// The complete fixed payload of one HISTORY record.
|
||||
pub const Header = struct {
|
||||
/// Number of encoded bytes in the fixed payload, calculated by its encoder.
|
||||
pub const len = computeLen();
|
||||
|
||||
comptime {
|
||||
// This size is part of the wire format. If it changes, the snapshot
|
||||
// version must also change.
|
||||
std.debug.assert(len == 16);
|
||||
}
|
||||
|
||||
/// Identifies the previously encoded screen that owns this history.
|
||||
key: screen.Key,
|
||||
|
||||
/// Number of complete PAGE records immediately following this record.
|
||||
page_count: u32,
|
||||
|
||||
/// Authoritative number of logical rows before the active area.
|
||||
total_rows: u64,
|
||||
|
||||
/// History rows already included at the start of the first SCREEN page.
|
||||
screen_overlap_rows: u16,
|
||||
|
||||
/// Encode the fixed HISTORY payload.
|
||||
pub fn encode(
|
||||
self: Header,
|
||||
writer: *std.Io.Writer,
|
||||
) std.Io.Writer.Error!void {
|
||||
try io.writeInt(writer, u16, @intFromEnum(self.key));
|
||||
try io.writeInt(writer, u32, self.page_count);
|
||||
try io.writeInt(writer, u64, self.total_rows);
|
||||
try io.writeInt(writer, u16, self.screen_overlap_rows);
|
||||
}
|
||||
|
||||
pub const DecodeError = std.Io.Reader.Error || error{InvalidKey};
|
||||
|
||||
/// Decode and validate the fixed HISTORY payload.
|
||||
pub fn decode(reader: *std.Io.Reader) Header.DecodeError!Header {
|
||||
const key = std.enums.fromInt(
|
||||
screen.Key,
|
||||
try io.readInt(reader, u16),
|
||||
) orelse return error.InvalidKey;
|
||||
return .{
|
||||
.key = key,
|
||||
.page_count = try io.readInt(reader, u32),
|
||||
.total_rows = try io.readInt(reader, u64),
|
||||
.screen_overlap_rows = try io.readInt(reader, u16),
|
||||
};
|
||||
}
|
||||
|
||||
fn computeLen() usize {
|
||||
comptime {
|
||||
var buf: [128]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
const value: Header = .{
|
||||
.key = .primary,
|
||||
.page_count = 0,
|
||||
.total_rows = 0,
|
||||
.screen_overlap_rows = 0,
|
||||
};
|
||||
value.encode(&writer) catch unreachable;
|
||||
return writer.end;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Errors possible while encoding one HISTORY and its complete PAGE sequence.
|
||||
pub const EncodeError = Allocator.Error ||
|
||||
page.EncodeError ||
|
||||
record.Writer.FinishError ||
|
||||
error{
|
||||
/// The complete historical prefix has more pages than the header fits.
|
||||
PageCountOverflow,
|
||||
|
||||
/// The complete historical prefix has more rows than the header fits.
|
||||
RowCountOverflow,
|
||||
};
|
||||
|
||||
/// Encode one screen's HISTORY and its complete historical pages.
|
||||
///
|
||||
/// Pages are encoded newest-to-oldest. Compressed source pages are inspected
|
||||
/// without changing their storage state. On failure, the entire sequence is
|
||||
/// removed while earlier destination bytes remain.
|
||||
pub fn encode(
|
||||
terminal_screen: *const TerminalScreen,
|
||||
key: TerminalScreenKey,
|
||||
destination: *std.Io.Writer.Allocating,
|
||||
) EncodeError!void {
|
||||
const sequence_start = destination.written().len;
|
||||
errdefer destination.shrinkRetainingCapacity(sequence_start);
|
||||
|
||||
// SCREEN begins at the page containing the active area's first row. Its
|
||||
// leading rows are already resident; every previous complete page belongs
|
||||
// to this HISTORY sequence.
|
||||
const active_top = terminal_screen.pages.getTopLeft(.active);
|
||||
const page_count: usize, const total_rows: u64 = count: {
|
||||
var page_count: usize = 0;
|
||||
var total_rows: u64 = active_top.y;
|
||||
var node = active_top.node.prev;
|
||||
while (node) |current| : (node = current.prev) {
|
||||
page_count += 1;
|
||||
total_rows = std.math.add(
|
||||
u64,
|
||||
total_rows,
|
||||
current.rows(),
|
||||
) catch return error.RowCountOverflow;
|
||||
}
|
||||
|
||||
break :count .{ page_count, total_rows };
|
||||
};
|
||||
|
||||
const header: Header = .{
|
||||
.key = switch (key) {
|
||||
.primary => .primary,
|
||||
.alternate => .alternate,
|
||||
},
|
||||
.page_count = std.math.cast(
|
||||
u32,
|
||||
page_count,
|
||||
) orelse return error.PageCountOverflow,
|
||||
.total_rows = total_rows,
|
||||
.screen_overlap_rows = active_top.y,
|
||||
};
|
||||
|
||||
// HISTORY declares exactly how many PAGE records follow and how their rows
|
||||
// combine with the overlap already carried by SCREEN.
|
||||
{
|
||||
var record_writer = try record.Writer.init(destination, .history);
|
||||
errdefer record_writer.cancel();
|
||||
try header.encode(record_writer.payloadWriter());
|
||||
try record_writer.finish();
|
||||
}
|
||||
|
||||
// Walk backward so each page can be prepended by the decoder immediately.
|
||||
// PreservedPage borrows resident pages and clones compressed pages without
|
||||
// changing the source node's representation.
|
||||
var node = active_top.node.prev;
|
||||
while (node) |current| : (node = current.prev) {
|
||||
var preserved = try current.pagePreservingState(terminal_screen.alloc);
|
||||
defer preserved.deinit();
|
||||
try page.encode(preserved.page(), destination);
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors possible while restoring one HISTORY and its PAGE sequence.
|
||||
pub const DecodeError = Allocator.Error ||
|
||||
Header.DecodeError ||
|
||||
page.DecodeError ||
|
||||
record.Reader.InitError ||
|
||||
record.Reader.FinishError ||
|
||||
TerminalPageList.PageAllocation.FinalizeError ||
|
||||
error{
|
||||
/// The next record is valid but is not a HISTORY.
|
||||
UnexpectedRecordTag,
|
||||
|
||||
/// The HISTORY key does not match the caller-selected screen.
|
||||
UnexpectedScreenKey,
|
||||
|
||||
/// The Screen already contains complete pages before its active page.
|
||||
ExistingHistory,
|
||||
|
||||
/// The claimed overlap does not match the restored SCREEN sequence.
|
||||
InvalidScreenOverlap,
|
||||
|
||||
/// The declared total does not match the restored historical rows.
|
||||
InvalidHistoryRows,
|
||||
};
|
||||
|
||||
/// Restore one HISTORY and its declared PAGE records into a native Screen.
|
||||
///
|
||||
/// Each PAGE is decoded directly into a detached PageList-pooled allocation and
|
||||
/// prepended only after its record and native integrity are validated. If a
|
||||
/// later PAGE fails, earlier successful pages remain as a contiguous recent
|
||||
/// history prefix.
|
||||
pub fn decode(
|
||||
source: *std.Io.Reader,
|
||||
alloc: Allocator,
|
||||
expected_key: TerminalScreenKey,
|
||||
terminal_screen: *TerminalScreen,
|
||||
) DecodeError!void {
|
||||
// Decode and finish the self-contained HISTORY manifest before consuming
|
||||
// any of its following PAGE records.
|
||||
const header: Header = header: {
|
||||
var record_reader: record.Reader = undefined;
|
||||
try record_reader.init(source);
|
||||
if (record_reader.header.tag != .history) return error.UnexpectedRecordTag;
|
||||
const header = try Header.decode(record_reader.payloadReader());
|
||||
try record_reader.finish();
|
||||
break :header header;
|
||||
};
|
||||
|
||||
const decoded_key: TerminalScreenKey = switch (header.key) {
|
||||
.primary => .primary,
|
||||
.alternate => .alternate,
|
||||
};
|
||||
if (decoded_key != expected_key) return error.UnexpectedScreenKey;
|
||||
|
||||
// A freshly restored SCREEN may carry overlap inside its first page, but
|
||||
// cannot already contain a complete historical page before that boundary.
|
||||
const active_top = terminal_screen.pages.getTopLeft(.active);
|
||||
if (terminal_screen.pages.getTopLeft(.screen).node != active_top.node) {
|
||||
return error.ExistingHistory;
|
||||
}
|
||||
if (header.screen_overlap_rows != active_top.y) {
|
||||
return error.InvalidScreenOverlap;
|
||||
}
|
||||
if (header.total_rows < header.screen_overlap_rows) {
|
||||
return error.InvalidHistoryRows;
|
||||
}
|
||||
|
||||
var restored_rows: u64 = header.screen_overlap_rows;
|
||||
for (0..header.page_count) |_| {
|
||||
// PAGE exposes its exact capacity before decoding the payload, allowing
|
||||
// the destination PageList to allocate the final backing memory once.
|
||||
var decoder: page.Decoder = undefined;
|
||||
try decoder.init(source);
|
||||
var allocation = try terminal_screen.pages.allocatePage(
|
||||
decoder.capacity(),
|
||||
);
|
||||
defer allocation.deinit();
|
||||
try decoder.decode(allocation.page(), alloc);
|
||||
|
||||
// Validate the authoritative row total before publishing this page.
|
||||
const page_rows = allocation.page().size.rows;
|
||||
restored_rows = std.math.add(
|
||||
u64,
|
||||
restored_rows,
|
||||
page_rows,
|
||||
) catch return error.InvalidHistoryRows;
|
||||
if (restored_rows > header.total_rows) {
|
||||
return error.InvalidHistoryRows;
|
||||
}
|
||||
|
||||
const contains_prompt = hasSemanticPrompt(allocation.page());
|
||||
try allocation.finalize(.prepend);
|
||||
if (contains_prompt) terminal_screen.semantic_prompt.seen = true;
|
||||
}
|
||||
|
||||
if (restored_rows != header.total_rows) {
|
||||
return error.InvalidHistoryRows;
|
||||
}
|
||||
|
||||
terminal_screen.pages.assertIntegrity();
|
||||
terminal_screen.assertIntegrity();
|
||||
}
|
||||
|
||||
fn hasSemanticPrompt(terminal_page: *const TerminalPage) bool {
|
||||
const rows = terminal_page.rows.ptr(
|
||||
terminal_page.memory,
|
||||
)[0..terminal_page.size.rows];
|
||||
for (rows) |row| {
|
||||
if (row.semantic_prompt != .none) return true;
|
||||
|
||||
const cells = row.cells.ptr(
|
||||
terminal_page.memory,
|
||||
)[0..terminal_page.size.cols];
|
||||
for (cells) |cell| {
|
||||
if (cell.semantic_content == .prompt) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
test "HISTORY header golden encoding and decoding" {
|
||||
const expected: Header = .{
|
||||
.key = .alternate,
|
||||
.page_count = 0x01020304,
|
||||
.total_rows = 0x0102030405060708,
|
||||
.screen_overlap_rows = 0x090a,
|
||||
};
|
||||
const fixture =
|
||||
"\x01\x00\x04\x03\x02\x01" ++
|
||||
"\x08\x07\x06\x05\x04\x03\x02\x01\x0a\x09";
|
||||
|
||||
try std.testing.expectEqual(Header.len, fixture.len);
|
||||
var encoded: [Header.len]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&encoded);
|
||||
try expected.encode(&writer);
|
||||
try std.testing.expectEqualStrings(fixture, writer.buffered());
|
||||
|
||||
var reader: std.Io.Reader = .fixed(fixture);
|
||||
try std.testing.expectEqualDeep(expected, try Header.decode(&reader));
|
||||
for (0..Header.len) |fixture_len| {
|
||||
var truncated: std.Io.Reader = .fixed(fixture[0..fixture_len]);
|
||||
try std.testing.expectError(
|
||||
error.EndOfStream,
|
||||
Header.decode(&truncated),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test "HISTORY encodes newest first and restores complete history" {
|
||||
// Choose an active height which spans two native pages, then grow until
|
||||
// exactly two additional complete pages precede the active boundary.
|
||||
var probe = try TerminalScreen.init(
|
||||
std.testing.io,
|
||||
std.testing.allocator,
|
||||
.{ .cols = 80, .rows = 1, .max_scrollback_bytes = 0 },
|
||||
);
|
||||
const page_rows = probe.pages.getTopLeft(.screen).node.capacity().rows;
|
||||
probe.deinit();
|
||||
const screen_rows = page_rows + 1;
|
||||
|
||||
var source_screen = try TerminalScreen.init(
|
||||
std.testing.io,
|
||||
std.testing.allocator,
|
||||
.{
|
||||
.cols = 80,
|
||||
.rows = screen_rows,
|
||||
.max_scrollback_bytes = null,
|
||||
},
|
||||
);
|
||||
defer source_screen.deinit();
|
||||
source_screen.cursorAbsolute(0, screen_rows - 1);
|
||||
while (source_screen.pages.totalPages() < 4) {
|
||||
try source_screen.testWriteString("\n");
|
||||
}
|
||||
|
||||
const active_top = source_screen.pages.getTopLeft(.active);
|
||||
const newest_history = active_top.node.prev.?;
|
||||
const oldest_history = newest_history.prev.?;
|
||||
try std.testing.expectEqual(
|
||||
source_screen.pages.getTopLeft(.screen).node,
|
||||
oldest_history,
|
||||
);
|
||||
try std.testing.expectEqual(null, oldest_history.prev);
|
||||
|
||||
// Mark the two complete historical pages so the wire sequence and final
|
||||
// native order are visible. The oldest prompt also verifies derived state
|
||||
// is updated only when that page is restored.
|
||||
oldest_history.page().getRowAndCell(0, 0).cell.* = .init('A');
|
||||
oldest_history.page().getRowAndCell(
|
||||
0,
|
||||
0,
|
||||
).cell.semantic_content = .prompt;
|
||||
newest_history.page().getRowAndCell(0, 0).cell.* = .init('B');
|
||||
|
||||
// Encode SCREEN before history compression, then compress eligible history
|
||||
// and require HISTORY encoding to preserve each source storage state.
|
||||
var destination: std.Io.Writer.Allocating = .init(
|
||||
std.testing.allocator,
|
||||
);
|
||||
defer destination.deinit();
|
||||
try screen.encode(&source_screen, .primary, &destination);
|
||||
const history_offset = destination.written().len;
|
||||
|
||||
_ = source_screen.pages.compress(.full);
|
||||
const oldest_storage = oldest_history.storage();
|
||||
const newest_storage = newest_history.storage();
|
||||
try encode(&source_screen, .primary, &destination);
|
||||
try std.testing.expectEqual(oldest_storage, oldest_history.storage());
|
||||
try std.testing.expectEqual(newest_storage, newest_history.storage());
|
||||
|
||||
// Inspect the manifest and PAGE records independently. Newest history is
|
||||
// sent first even though native PageList order is oldest-to-newest.
|
||||
var history_source: std.Io.Reader = .fixed(
|
||||
destination.written()[history_offset..],
|
||||
);
|
||||
var history_record: record.Reader = undefined;
|
||||
try history_record.init(&history_source);
|
||||
try std.testing.expectEqual(record.Tag.history, history_record.header.tag);
|
||||
const header = try Header.decode(history_record.payloadReader());
|
||||
try history_record.finish();
|
||||
try std.testing.expectEqual(screen.Key.primary, header.key);
|
||||
try std.testing.expectEqual(@as(u32, 2), header.page_count);
|
||||
try std.testing.expectEqual(
|
||||
@as(u16, active_top.y),
|
||||
header.screen_overlap_rows,
|
||||
);
|
||||
try std.testing.expectEqual(
|
||||
@as(u64, active_top.y) +
|
||||
oldest_history.rows() +
|
||||
newest_history.rows(),
|
||||
header.total_rows,
|
||||
);
|
||||
|
||||
var decoded_newest = try page.decode(
|
||||
&history_source,
|
||||
std.testing.allocator,
|
||||
);
|
||||
defer decoded_newest.deinit();
|
||||
try std.testing.expectEqual(
|
||||
@as(u21, 'B'),
|
||||
decoded_newest.getRowAndCell(0, 0).cell.codepoint(),
|
||||
);
|
||||
var decoded_oldest = try page.decode(
|
||||
&history_source,
|
||||
std.testing.allocator,
|
||||
);
|
||||
defer decoded_oldest.deinit();
|
||||
try std.testing.expectEqual(
|
||||
@as(u21, 'A'),
|
||||
decoded_oldest.getRowAndCell(0, 0).cell.codepoint(),
|
||||
);
|
||||
try std.testing.expectError(error.EndOfStream, history_source.takeByte());
|
||||
|
||||
// Restore SCREEN first, then prepend HISTORY directly into that PageList.
|
||||
var restore_source: std.Io.Reader = .fixed(destination.written());
|
||||
var restored = try screen.decode(
|
||||
&restore_source,
|
||||
std.testing.io,
|
||||
std.testing.allocator,
|
||||
.primary,
|
||||
.{
|
||||
.cols = 80,
|
||||
.rows = screen_rows,
|
||||
.max_scrollback_bytes = null,
|
||||
},
|
||||
);
|
||||
defer restored.deinit();
|
||||
try std.testing.expect(!restored.semantic_prompt.seen);
|
||||
try decode(
|
||||
&restore_source,
|
||||
std.testing.allocator,
|
||||
.primary,
|
||||
&restored,
|
||||
);
|
||||
|
||||
try std.testing.expectEqual(
|
||||
source_screen.pages.totalPages(),
|
||||
restored.pages.totalPages(),
|
||||
);
|
||||
const restored_oldest = restored.pages.getTopLeft(.screen).node;
|
||||
try std.testing.expectEqual(
|
||||
@as(u21, 'A'),
|
||||
restored_oldest.page().getRowAndCell(0, 0).cell.codepoint(),
|
||||
);
|
||||
try std.testing.expectEqual(
|
||||
@as(u21, 'B'),
|
||||
restored_oldest.next.?
|
||||
.page().getRowAndCell(0, 0).cell.codepoint(),
|
||||
);
|
||||
try std.testing.expect(restored.semantic_prompt.seen);
|
||||
try std.testing.expectError(error.EndOfStream, restore_source.takeByte());
|
||||
|
||||
// Reuse a writable copy of the sequence for failure-path fixtures.
|
||||
const encoded = try std.testing.allocator.dupe(
|
||||
u8,
|
||||
destination.written(),
|
||||
);
|
||||
defer std.testing.allocator.free(encoded);
|
||||
const first_page_offset = history_offset + record.Header.len + Header.len;
|
||||
const first_payload_len = std.mem.readInt(
|
||||
u32,
|
||||
encoded[first_page_offset + 2 ..][0..4],
|
||||
.little,
|
||||
);
|
||||
const second_page_offset =
|
||||
first_page_offset + record.Header.len + first_payload_len;
|
||||
|
||||
// Truncate the first PAGE only after its header has exposed a capacity.
|
||||
// Its detached allocation is discarded and the live SCREEN list remains
|
||||
// completely unchanged.
|
||||
var truncated_source: std.Io.Reader = .fixed(
|
||||
encoded[0 .. second_page_offset - 1],
|
||||
);
|
||||
var truncated = try screen.decode(
|
||||
&truncated_source,
|
||||
std.testing.io,
|
||||
std.testing.allocator,
|
||||
.primary,
|
||||
.{
|
||||
.cols = 80,
|
||||
.rows = screen_rows,
|
||||
.max_scrollback_bytes = null,
|
||||
},
|
||||
);
|
||||
defer truncated.deinit();
|
||||
const truncated_screen_first = truncated.pages.getTopLeft(.screen).node;
|
||||
const truncated_screen_page_count = truncated.pages.totalPages();
|
||||
try std.testing.expectError(
|
||||
error.EndOfStream,
|
||||
decode(
|
||||
&truncated_source,
|
||||
std.testing.allocator,
|
||||
.primary,
|
||||
&truncated,
|
||||
),
|
||||
);
|
||||
try std.testing.expectEqual(
|
||||
truncated_screen_page_count,
|
||||
truncated.pages.totalPages(),
|
||||
);
|
||||
try std.testing.expectEqual(
|
||||
truncated_screen_first,
|
||||
truncated.pages.getTopLeft(.screen).node,
|
||||
);
|
||||
truncated.pages.assertIntegrity();
|
||||
truncated.assertIntegrity();
|
||||
|
||||
// Corrupt only the older PAGE tag. A failure in a later PAGE keeps only
|
||||
// the successfully prepended newer pages, contiguous with SCREEN.
|
||||
std.mem.writeInt(
|
||||
u16,
|
||||
encoded[second_page_offset..][0..2],
|
||||
@intFromEnum(record.Tag.screen),
|
||||
.little,
|
||||
);
|
||||
|
||||
var partial_source: std.Io.Reader = .fixed(encoded);
|
||||
var partial = try screen.decode(
|
||||
&partial_source,
|
||||
std.testing.io,
|
||||
std.testing.allocator,
|
||||
.primary,
|
||||
.{
|
||||
.cols = 80,
|
||||
.rows = screen_rows,
|
||||
.max_scrollback_bytes = null,
|
||||
},
|
||||
);
|
||||
defer partial.deinit();
|
||||
const screen_page_count = partial.pages.totalPages();
|
||||
try std.testing.expectError(
|
||||
error.UnexpectedRecordTag,
|
||||
decode(
|
||||
&partial_source,
|
||||
std.testing.allocator,
|
||||
.primary,
|
||||
&partial,
|
||||
),
|
||||
);
|
||||
try std.testing.expectEqual(
|
||||
screen_page_count + 1,
|
||||
partial.pages.totalPages(),
|
||||
);
|
||||
try std.testing.expectEqual(
|
||||
@as(u21, 'B'),
|
||||
partial.pages.getTopLeft(.screen).node
|
||||
.page().getRowAndCell(0, 0).cell.codepoint(),
|
||||
);
|
||||
try std.testing.expect(!partial.semantic_prompt.seen);
|
||||
partial.pages.assertIntegrity();
|
||||
partial.assertIntegrity();
|
||||
}
|
||||
|
||||
test "HISTORY encodes and restores an empty sequence" {
|
||||
var terminal_screen = try TerminalScreen.init(
|
||||
std.testing.io,
|
||||
std.testing.allocator,
|
||||
.{
|
||||
.cols = 2,
|
||||
.rows = 2,
|
||||
.max_scrollback_bytes = null,
|
||||
},
|
||||
);
|
||||
defer terminal_screen.deinit();
|
||||
|
||||
var destination: std.Io.Writer.Allocating = .init(
|
||||
std.testing.allocator,
|
||||
);
|
||||
defer destination.deinit();
|
||||
try encode(&terminal_screen, .primary, &destination);
|
||||
|
||||
var inspect_source: std.Io.Reader = .fixed(destination.written());
|
||||
var history_record: record.Reader = undefined;
|
||||
try history_record.init(&inspect_source);
|
||||
const header = try Header.decode(history_record.payloadReader());
|
||||
try history_record.finish();
|
||||
try std.testing.expectEqual(@as(u32, 0), header.page_count);
|
||||
try std.testing.expectEqual(@as(u64, 0), header.total_rows);
|
||||
try std.testing.expectEqual(@as(u16, 0), header.screen_overlap_rows);
|
||||
try std.testing.expectError(error.EndOfStream, inspect_source.takeByte());
|
||||
|
||||
var decode_source: std.Io.Reader = .fixed(destination.written());
|
||||
const initial_first = terminal_screen.pages.getTopLeft(.screen).node;
|
||||
try decode(
|
||||
&decode_source,
|
||||
std.testing.allocator,
|
||||
.primary,
|
||||
&terminal_screen,
|
||||
);
|
||||
try std.testing.expectEqual(
|
||||
initial_first,
|
||||
terminal_screen.pages.getTopLeft(.screen).node,
|
||||
);
|
||||
try std.testing.expectError(error.EndOfStream, decode_source.takeByte());
|
||||
}
|
||||
|
||||
test "HISTORY rejects manifest mismatches" {
|
||||
var terminal_screen = try TerminalScreen.init(
|
||||
std.testing.io,
|
||||
std.testing.allocator,
|
||||
.{
|
||||
.cols = 2,
|
||||
.rows = 2,
|
||||
.max_scrollback_bytes = null,
|
||||
},
|
||||
);
|
||||
defer terminal_screen.deinit();
|
||||
|
||||
// Encode a manifest without PAGE records and vary only the fixed fields so
|
||||
// each structural validation boundary is exercised in isolation.
|
||||
const cases = .{
|
||||
.{
|
||||
Header{
|
||||
.key = .alternate,
|
||||
.page_count = 0,
|
||||
.total_rows = 0,
|
||||
.screen_overlap_rows = 0,
|
||||
},
|
||||
error.UnexpectedScreenKey,
|
||||
},
|
||||
.{
|
||||
Header{
|
||||
.key = .primary,
|
||||
.page_count = 0,
|
||||
.total_rows = 1,
|
||||
.screen_overlap_rows = 1,
|
||||
},
|
||||
error.InvalidScreenOverlap,
|
||||
},
|
||||
.{
|
||||
Header{
|
||||
.key = .primary,
|
||||
.page_count = 0,
|
||||
.total_rows = 1,
|
||||
.screen_overlap_rows = 0,
|
||||
},
|
||||
error.InvalidHistoryRows,
|
||||
},
|
||||
.{
|
||||
Header{
|
||||
.key = .primary,
|
||||
.page_count = 1,
|
||||
.total_rows = 1,
|
||||
.screen_overlap_rows = 0,
|
||||
},
|
||||
error.EndOfStream,
|
||||
},
|
||||
};
|
||||
inline for (cases) |case| {
|
||||
var destination: std.Io.Writer.Allocating = .init(
|
||||
std.testing.allocator,
|
||||
);
|
||||
defer destination.deinit();
|
||||
var record_writer = try record.Writer.init(&destination, .history);
|
||||
try case[0].encode(record_writer.payloadWriter());
|
||||
try record_writer.finish();
|
||||
|
||||
var source: std.Io.Reader = .fixed(destination.written());
|
||||
try std.testing.expectError(
|
||||
case[1],
|
||||
decode(
|
||||
&source,
|
||||
std.testing.allocator,
|
||||
.primary,
|
||||
&terminal_screen,
|
||||
),
|
||||
);
|
||||
terminal_screen.pages.assertIntegrity();
|
||||
terminal_screen.assertIntegrity();
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,16 @@
|
||||
//!
|
||||
//! We call this a "snapshot." The snapshot is purposely laid out in a way
|
||||
//! that prioritizes making a terminal functional as quickly as possible.
|
||||
//! To do that, it sends down the terminal state, viewport, etc. followed
|
||||
//! by a "READY" event. At the READY state, the terminal is functional and
|
||||
//! could in theory begin processing pty bytes. After the READY state the
|
||||
//! binary format continues transmitting history and extra assets such as
|
||||
//! images and so on.
|
||||
//! To do that, it sends the active terminal state followed by a READY record,
|
||||
//! then complete history.
|
||||
//!
|
||||
//! READY denotes that enough of the terminal state is down that it can
|
||||
//! be fully rendered at that point. This is also the point where live
|
||||
//! terminals can also start accepting pty bytes, typically. But the current
|
||||
//! snapshot format lacks some of the information necessary to synchronize
|
||||
//! pty byte state with an authoritative server.
|
||||
//!
|
||||
//! After READY, we send history pages (scrollback).
|
||||
//!
|
||||
//! ## Snapshot Format
|
||||
//!
|
||||
@@ -39,9 +44,35 @@
|
||||
//! +------------------+
|
||||
//! ```
|
||||
//!
|
||||
//! Records have a strict order: TERMINAL, the primary SCREEN and its
|
||||
//! PAGE records, an optional alternate SCREEN and its PAGE records,
|
||||
//! CONTINUATION, READY, and FINISH.
|
||||
//! Records have a strict order:
|
||||
//!
|
||||
//! ```text
|
||||
//! +----------------------------------------+
|
||||
//! | TERMINAL |
|
||||
//! +----------------------------------------+
|
||||
//! | SCREEN (primary) |
|
||||
//! | PAGE * screen.page_count |
|
||||
//! +----------------------------------------+
|
||||
//! | SCREEN (alternate, when present) |
|
||||
//! | PAGE * screen.page_count |
|
||||
//! +----------------------------------------+
|
||||
//! | READY |
|
||||
//! +----------------------------------------+
|
||||
//! | HISTORY (primary) |
|
||||
//! | PAGE * history.page_count |
|
||||
//! +----------------------------------------+
|
||||
//! | HISTORY (alternate, when present) |
|
||||
//! | PAGE * history.page_count |
|
||||
//! +----------------------------------------+
|
||||
//! | FINISH |
|
||||
//! +----------------------------------------+
|
||||
//! ```
|
||||
//!
|
||||
//! The SCREEN sequences contain the complete pages needed to restore each
|
||||
//! active area. A HISTORY sequence contains the older complete pages for its
|
||||
//! screen in newest-to-oldest order so they can be prepended as they arrive.
|
||||
//! Every SCREEN has one corresponding HISTORY, even when its history page count
|
||||
//! is zero. FINISH is followed by end-of-file.
|
||||
//!
|
||||
//! ## Encoding
|
||||
//!
|
||||
@@ -66,6 +97,7 @@
|
||||
|
||||
pub const envelope = @import("envelope.zig");
|
||||
pub const grid = @import("grid.zig");
|
||||
pub const history = @import("history.zig");
|
||||
pub const hyperlink = @import("hyperlink.zig");
|
||||
pub const page = @import("page.zig");
|
||||
pub const record = @import("record.zig");
|
||||
|
||||
@@ -37,8 +37,8 @@ pub const Tag = enum(u16) {
|
||||
/// One complete logical terminal page.
|
||||
page = 3,
|
||||
|
||||
/// Unfinished UTF-8 and terminal parser input.
|
||||
continuation = 4,
|
||||
/// One screen's complete history manifest and page sequence.
|
||||
history = 4,
|
||||
|
||||
/// Digest marking the validated terminal-state prefix.
|
||||
ready = 5,
|
||||
|
||||
@@ -37,9 +37,10 @@
|
||||
//! n = page_count
|
||||
//! ```
|
||||
//!
|
||||
//! A later history-capable snapshot version provides the authoritative history
|
||||
//! manifest. It counts any incidental prefix above as already resident and
|
||||
//! describes older rows that may be loaded after the terminal becomes ready.
|
||||
//! The HISTORY record for this screen provides the authoritative history
|
||||
//! manifest. It counts any incidental prefix above as the SCREEN overlap, then
|
||||
//! sends the older complete pages after the terminal becomes ready. The prefix
|
||||
//! is not sent again.
|
||||
//!
|
||||
//! The SCREEN payload begins with a fixed header. When the header says there is
|
||||
//! no saved cursor, the payload is:
|
||||
|
||||
Reference in New Issue
Block a user