const std = @import("std"); const builtin = @import("builtin"); const limits = @import("memory.zig").limits; // scoped, not bare std.log: main.zig's logFn drops the unscoped .default scope // wholesale (ghostty and uucode log there too), and a corrupt dump's parse // diagnostic is the only thing that says WHICH field went bad. const log = std.log.scoped(.dump); const pardes = @import("pardes.zig"); const tagline = @import("tagline.zig"); const layout = @import("layout.zig"); const filesystem = @import("fs.zig"); const ctlfs = @import("ninep/tree.zig"); const exec = @import("exec.zig"); const body_layer = @import("body_layer.zig"); const colors = @import("colors.zig"); const themes = colors.themes; const config = @import("config.zig"); const pdf_enabled = pardes.pdf_enabled; const platform = pardes.platform; const isolated = pardes.isolated; const terminal_panes = pardes.terminal_panes; const MAX_PANES = pardes.MAX_PANES; const MAX_COLS = pardes.MAX_COLS; const column_weight_unit = layout.column_weight_unit; const max_column_weight = layout.max_column_weight; const effect_path_cap = pardes.effect_path_cap; const Options = pardes.Options; const Pardes = pardes.Pardes; /// Where the next dump goes. $PARDES_DUMP wins verbatim (the snapshot harness /// pins it for deterministic goldens); else $XDG_DATA_HOME|~/.local/share /// /pardes/pardes-.zon — timestamped so dumps never overwrite each other. /// Creates the pardes dir (parents assumed; a failure surfaces at open). pub fn outPath(buf: *[1024:0]u8, dir_setting: []const u8) ?[:0]const u8 { if (std.c.getenv("PARDES_DUMP")) |p| return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(p)}, 0) catch null; var dir_buf: [900]u8 = undefined; const dir = directory(&dir_buf, dir_setting) orelse return null; var dz: [901:0]u8 = undefined; @memcpy(dz[0..dir.len], dir); dz[dir.len] = 0; _ = std.c.mkdir(dz[0..dir.len :0], 0o755); // EEXIST is fine var ts: std.c.timespec = undefined; _ = std.c.clock_gettime(.REALTIME, &ts); const es: std.time.epoch.EpochSeconds = .{ .secs = @intCast(@max(0, ts.sec)) }; const yd = es.getEpochDay().calculateYearDay(); const md = yd.calculateMonthDay(); const ds = es.getDaySeconds(); return std.fmt.bufPrintSentinel(buf, "{s}/pardes-{d:0>4}{d:0>2}{d:0>2}-{d:0>2}{d:0>2}{d:0>2}.zon", .{ dir, yd.year, md.month.numeric(), @as(u8, md.day_index) + 1, ds.getHoursIntoDay(), ds.getMinutesIntoHour(), ds.getSecondsIntoMinute(), }, 0) catch null; } /// Where dumps go and relative Restore names are looked up: the `DumpDir` /// setting when set (a leading `~/` is the home directory), else the default. pub fn directory(buf: []u8, dir_setting: []const u8) ?[]const u8 { if (dir_setting.len == 0) return defaultDirectory(buf); if (std.mem.startsWith(u8, dir_setting, "~/")) if (std.c.getenv("HOME")) |h| return std.fmt.bufPrint(buf, "{s}/{s}", .{ std.mem.span(h), dir_setting[2..] }) catch null; return std.fmt.bufPrint(buf, "{s}", .{dir_setting}) catch null; } /// The standard dump directory, without creating it or generating a filename. pub fn defaultDirectory(buf: []u8) ?[]const u8 { if (std.c.getenv("XDG_DATA_HOME")) |x| return std.fmt.bufPrint(buf, "{s}/pardes", .{std.mem.span(x)}) catch null; if (std.c.getenv("HOME")) |h| return std.fmt.bufPrint(buf, "{s}/.local/share/pardes", .{std.mem.span(h)}) catch null; return null; } pub const magic = "pardes-dump"; pub const version: u32 = 1; pub const max_panes: usize = MAX_PANES; pub const max_cols: usize = 6; /// Output arguments are typed in the same bounded one-line tag storage. Keep /// the schema limit named independently so a dump reader can validate it /// without importing the output-pane implementation. The bound itself is /// `limits.max_tag_tail` — the one place a board-shaped capacity is chosen. pub const max_origin_arg: usize = limits.max_tag_tail; pub const Size = struct { cols: u16, rows: u16, }; pub const Cursor = struct { col: u16 = 0, row: u16 = 0, }; pub const PaneKind = enum { terminal, file, image, }; pub const Terminal = struct { cwd: []const u8 = "", /// The shell it runs when not the configured one (`Tty bash`); empty, /// the configured one, as in dumps from before. shell: []const u8 = "", /// A command pane's line and how it ended (null: unknown, or still /// running when dumped); empty for an interactive terminal. command: []const u8 = "", status: ?u8 = null, /// The rendered scrollback and screen as plain text, one row per line. stream: []const u8 = "", stream_b64: []const u8 = "", /// `stream_b64` is the emulator's primary screen and scrollback as clean /// VT (ghostty's formatter), which replays to what the screen showed. /// Absent (older dumps), it is the raw tail of the pty's output, which /// replays faithfully only if it holds the whole session at one size. stream_is_screen: bool = false, cursor: Cursor = .{}, }; pub const LocationRow = struct { kind: enum { match, context, preview }, declaration: bool = false, path: []const u8, line: usize, col: usize = 0, end_line: usize = 0, end_col: usize = 0, location_end: usize, code_start: usize, colors_b64: []const u8 = "", }; pub const File = struct { path: []const u8 = "", content: []const u8 = "", content_b64: []const u8 = "", dirty: bool = false, tree_context: bool = false, location_rows: []const LocationRow = &.{}, // Builtin names remain stable when enum ordinals change. origin: []const u8 = "", origin_arg: []const u8 = "", mini_source: []const u8 = "", mini_colors_b64: []const u8 = "", /// Its dot, q0 and q1 in bytes, as acme's dump keeps a window's; null /// in older dumps. dot: ?[2]u32 = null, }; pub const ImagePalette = enum { commodore, terminal, }; pub const Image = struct { path: []const u8 = "", bytes_b64: []const u8 = "", // Defaults are the renderer's historical state, so version-1 dumps which // predate these fields remain readable without a schema fork. petscii: bool = false, palette: ImagePalette = .commodore, ascii: bool = true, }; pub const Pane = struct { kind: PaneKind, tag: []const u8, body: []const u8, scroll: usize = 0, cols: u16 = 0, rows: u16 = 0, vweight: f32 = 1, collapsed: bool = false, /// Its serial when dumped (0 in older dumps): a Restore logs /// `restored ` for it. serial: u32 = 0, /// Exact editable tail when the user touched it. Null retains the live /// default; a present empty slice deliberately restores an empty tail. tag_tail: ?[]const u8 = null, terminal: ?Terminal = null, file: ?File = null, image: ?Image = null, }; pub const Column = struct { /// Its serial when dumped (0 in older dumps): a Restore logs /// `restoredcol ` for it. serial: u32 = 0, tag: ?[]const u8 = null, weight: f32 = 1, panes: []const usize = &.{}, }; pub const Mount = struct { name: []const u8, dial: []const u8 }; pub const State = struct { magic: []const u8 = magic, version: u32 = version, screen: Size, active: usize = 0, topbar: []const u8 = "", topbar_custom: ?[]const u8 = null, theme: []const u8 = "dark", locations_config: @import("locations_config.zig").Config = .{}, tree_context_tag_style: bool = true, columns: []const Column = &.{}, panes: []const Pane = &.{}, mounts: []const Mount = &.{}, /// The settings that differ from a fresh session's, each a line as the /// root ctl takes it (`Placement pardes`); the theme and the locations /// config are kept on their own, and the font is the frontend's. Absent /// in older dumps. settings: []const []const u8 = &.{}, }; /// A header is a text like any other: it may hold newlines, but not a /// carriage return or a NUL. fn validateTag(bar: []const u8) !void { if (bar.len > limits.max_tag_tail or std.mem.indexOfAny(u8, bar, "\r\x00") != null or !std.unicode.utf8ValidateSlice(bar)) return error.BadDumpTag; } pub fn validate(state: State) !void { if (state.topbar_custom) |bar| try validateTag(bar); for (state.columns) |column| if (column.tag) |bar| try validateTag(bar); if (!std.mem.eql(u8, state.magic, magic)) return error.BadDumpMagic; if (state.version != version) return error.BadDumpVersion; if (state.panes.len == 0 or state.panes.len > max_panes) return error.BadDumpPanes; if (state.columns.len == 0 or state.columns.len > max_cols) return error.BadDumpColumns; if (state.active >= state.panes.len) return error.BadDumpActive; if (state.mounts.len > 8) return error.BadDumpMounts; for (state.mounts, 0..) |mount, i| { if (mount.name.len == 0 or mount.name.len > 255 or mount.dial.len == 0 or std.mem.indexOfScalar(u8, mount.dial, 0) != null) return error.BadDumpMounts; if (std.mem.eql(u8, mount.name, ".") or std.mem.eql(u8, mount.name, "..") or std.mem.eql(u8, mount.name, "os") or std.mem.eql(u8, mount.name, "self")) return error.BadDumpMounts; for (mount.name) |c| if (!std.ascii.isAlphanumeric(c) and c != '_' and c != '-' and c != '.') return error.BadDumpMounts; for (state.mounts[0..i]) |previous| if (std.mem.eql(u8, mount.name, previous.name)) return error.BadDumpMounts; } for (state.columns) |col| { if (!std.math.isFinite(col.weight) or col.weight <= 0) return error.BadDumpColumns; // An empty column is a column, as in acme's dump (rows.c:639). if (col.panes.len > max_panes) return error.BadDumpColumns; for (col.panes) |pane| { if (pane >= state.panes.len) return error.BadDumpPaneRef; } } for (state.panes) |pane| { if (!std.math.isFinite(pane.vweight) or pane.vweight <= 0) return error.BadDumpPaneWeight; if (pane.scroll > std.math.maxInt(i32)) return error.BadDumpPaneScroll; if (pane.tag_tail) |tail| if (tail.len > limits.max_tag_tail) return error.BadDumpTagTail; if (pane.file) |file| if (file.origin_arg.len > max_origin_arg) return error.BadDumpOriginArg; if (pane.file) |file| { if (file.mini_source.len > 4096 or std.mem.indexOfScalar(u8, file.mini_source, 0) != null or (file.mini_source.len == 0 and file.mini_colors_b64.len != 0) or (file.mini_source.len != 0 and !std.mem.eql(u8, file.origin, "Mini"))) return error.BadDumpMini; } switch (pane.kind) { .terminal => if (pane.terminal == null) return error.BadDumpPaneKind, .file => if (pane.file == null) return error.BadDumpPaneKind, .image => if (pane.image == null) return error.BadDumpPaneKind, } } } test "oversized recognized output argument is rejected before restore" { var oversized: [max_origin_arg + 1]u8 = @splat('x'); const pane_ids = [_]usize{0}; const columns = [_]Column{.{ .panes = &pane_ids }}; const panes = [_]Pane{.{ .kind = .file, .tag = "+Search", .body = "", .file = .{ .path = "/tmp/+Search", .origin = "Find", .origin_arg = &oversized, }, }}; const state: State = .{ .screen = .{ .cols = 80, .rows = 24 }, .columns = &columns, .panes = &panes, }; // Serialize without writeFile: that writer correctly rejects the state // too, while this exercises the hostile bytes a restore actually reads. var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator); defer encoded.deinit(); try std.zon.stringify.serialize(state, .{}, &encoded.writer); try std.testing.expectError( error.BadDumpOriginArg, readZon(std.testing.allocator, encoded.written(), "oversized-origin-arg"), ); } pub fn writeFile(io: std.Io, gpa: std.mem.Allocator, path: []const u8, state: State) !void { try validate(state); var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); var file = try std.Io.Dir.createFileAbsolute(io, path, .{}); defer file.close(io); try file.writeStreamingAll(io, out.written()); } pub const Parsed = struct { value: State, arena: std.heap.ArenaAllocator, pub fn deinit(parsed: *Parsed) void { parsed.arena.deinit(); } }; /// Whether `bytes` are a dump a Restore could load, saying nothing: a /// Restore asks before it warns about unsaved text or replaces anything. pub fn check(gpa: std.mem.Allocator, bytes: []const u8) !void { var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); const source = try arena.allocator().dupeZ(u8, bytes); try validate(try std.zon.parse.fromSliceAlloc(State, arena.allocator(), source, null, .{ .free_on_error = false })); } pub fn readZon(gpa: std.mem.Allocator, bytes: []const u8, label: []const u8) !Parsed { var parsed: Parsed = .{ .value = undefined, .arena = .init(gpa) }; errdefer parsed.deinit(); const arena = parsed.arena.allocator(); const source = try gpa.dupeZ(u8, bytes); defer gpa.free(source); parsed.value = if (builtin.os.tag == .emscripten or builtin.os.tag == .freestanding) std.zon.parse.fromSliceAlloc(State, arena, source, null, .{ .free_on_error = false }) catch |err| { if (err == error.ParseZon) log.err("parse dump {s}: {s}", .{ label, @errorName(err) }); return err; } else blk: { var diag: std.zon.parse.Diagnostics = .{}; defer diag.deinit(arena); break :blk std.zon.parse.fromSliceAlloc(State, arena, source, &diag, .{ .free_on_error = false }) catch |err| { if (err == error.ParseZon) log.err("parse dump {s}: {f}", .{ label, diag }); return err; }; }; try validate(parsed.value); return parsed; } pub fn encodeBytes(alloc: std.mem.Allocator, bytes: []const u8) ![]const u8 { const enc = std.base64.standard.Encoder; const out = try alloc.alloc(u8, enc.calcSize(bytes.len)); return enc.encode(out, bytes); } pub fn decodeBytes(alloc: std.mem.Allocator, b64: []const u8) ![]u8 { const dec = std.base64.standard.Decoder; const n = try dec.calcSizeForSlice(b64); const out = try alloc.alloc(u8, n); errdefer alloc.free(out); try dec.decode(out, b64); return out; } test "dump zon roundtrip" { const gpa = std.testing.allocator; const tty_b64 = try encodeBytes(gpa, "old\nhello\nworld"); defer gpa.free(tty_b64); const file_b64 = try encodeBytes(gpa, "alpha\nbeta\n"); defer gpa.free(file_b64); const panes = [_]Pane{ .{ .kind = .terminal, .tag = "nm /tmp Del", .body = "hello\nworld", .scroll = 1, .cols = 40, .rows = 10, .terminal = .{ .cwd = "/tmp", .stream = "old\nhello\nworld", .stream_b64 = tty_b64, .cursor = .{ .col = 3, .row = 2 } }, }, .{ .kind = .file, .tag = "nm /tmp/a.txt Save Del", .body = " 1 alpha", .cols = 20, .rows = 5, .file = .{ .path = "/tmp/a.txt", .content = "alpha\nbeta\n", .content_b64 = file_b64, .dirty = true }, }, }; const col_panes = [_]usize{ 0, 1 }; const columns = [_]Column{.{ .weight = 1, .panes = &col_panes }}; const state: State = .{ .screen = .{ .cols = 80, .rows = 24 }, .topbar = "Kill Newcol Dump", .theme = "dark", .columns = &columns, .panes = &panes, }; var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); var result = try readZon(gpa, out.written(), "roundtrip"); defer result.deinit(); const parsed = result.value; try std.testing.expectEqual(@as(usize, 2), parsed.panes.len); try std.testing.expectEqualStrings("dark", parsed.theme); try std.testing.expectEqualStrings("old\nhello\nworld", parsed.panes[0].terminal.?.stream); try std.testing.expectEqualStrings("alpha\nbeta\n", parsed.panes[1].file.?.content); try std.testing.expect(parsed.panes[1].file.?.dirty); { const bytes = try decodeBytes(gpa, parsed.panes[0].terminal.?.stream_b64); defer gpa.free(bytes); try std.testing.expectEqualStrings("old\nhello\nworld", bytes); } { const bytes = try decodeBytes(gpa, parsed.panes[1].file.?.content_b64); defer gpa.free(bytes); try std.testing.expectEqualStrings("alpha\nbeta\n", bytes); } } test "omitted dump defaults roundtrip without borrowing input" { const gpa = std.testing.allocator; const fixture = \\.{ \\ .screen = .{ .cols = 80, .rows = 24 }, \\ .columns = .{.{ .panes = .{0, 1, 2} }}, \\ .panes = .{ \\ .{ .kind = .terminal, .tag = "terminal", .body = "", .terminal = .{} }, \\ .{ .kind = .file, .tag = "file", .body = "", .file = .{ .path = "file.zig", .content = "const café = 1;" } }, \\ .{ .kind = .image, .tag = "image", .body = "", .image = .{} }, \\ }, \\} ; const input = try gpa.dupe(u8, fixture); defer gpa.free(input); var parsed = try readZon(gpa, input, "omitted-defaults"); defer parsed.deinit(); @memset(input, 'x'); const value = parsed.value; try std.testing.expectEqualStrings(magic, value.magic); try std.testing.expectEqualStrings("dark", value.theme); try std.testing.expectEqualStrings("", value.topbar); try std.testing.expectEqual(@as(usize, 0), value.mounts.len); try std.testing.expectEqual(@as(usize, 3), value.panes.len); try std.testing.expectEqualStrings("file.zig", value.panes[1].file.?.path); try std.testing.expectEqualStrings("const café = 1;", value.panes[1].file.?.content); try std.testing.expect(!value.panes[1].file.?.dirty); try std.testing.expect(value.panes[2].image.?.ascii); var encoded: std.Io.Writer.Allocating = .init(gpa); defer encoded.deinit(); try std.zon.stringify.serialize(value, .{}, &encoded.writer); var again = try readZon(gpa, encoded.written(), "default-roundtrip"); defer again.deinit(); try std.testing.expectEqualDeep(value, again.value); } test "dump parser releases its arena at every allocation failure" { const Check = struct { fn run(gpa: std.mem.Allocator) !void { var parsed = try readZon(gpa, \\.{ \\ .screen = .{ .cols = 80, .rows = 24 }, \\ .columns = .{.{ .panes = .{0} }}, \\ .panes = .{.{ .kind = .file, .tag = "file", .body = "", .file = .{ .content = "owned" } }}, \\} , "allocation-cleanup"); defer parsed.deinit(); try std.testing.expectEqualStrings("owned", parsed.value.panes[0].file.?.content); } }; try std.testing.checkAllAllocationFailures(std.testing.allocator, Check.run, .{}); } test "dump mount validation bounds names ownership inputs and duplicates" { const panes = [_]Pane{.{ .kind = .file, .tag = "", .body = "", .file = .{} }}; const ids = [_]usize{0}; const columns = [_]Column{.{ .panes = &ids }}; var state: State = .{ .screen = .{ .cols = 80, .rows = 24 }, .columns = &columns, .panes = &panes, }; try validate(state); for ([_]Mount{ .{ .name = "peer", .dial = "/tmp/peer.sock" }, .{ .name = "build-1.local", .dial = "tcp!127.0.0.1!5640" }, }) |mount| { state.mounts = &.{mount}; try validate(state); } for ([_]Mount{ .{ .name = "", .dial = "/tmp/peer.sock" }, .{ .name = ".", .dial = "/tmp/peer.sock" }, .{ .name = "..", .dial = "/tmp/peer.sock" }, .{ .name = "os", .dial = "/tmp/peer.sock" }, .{ .name = "self", .dial = "/tmp/peer.sock" }, .{ .name = "two/parts", .dial = "/tmp/peer.sock" }, .{ .name = "two parts", .dial = "/tmp/peer.sock" }, .{ .name = "peer", .dial = "" }, .{ .name = "peer", .dial = "unix!/tmp/peer\x00.sock" }, }) |mount| { state.mounts = &.{mount}; try std.testing.expectError(error.BadDumpMounts, validate(state)); } const duplicate = Mount{ .name = "peer", .dial = "/tmp/peer.sock" }; state.mounts = &.{ duplicate, duplicate }; try std.testing.expectError(error.BadDumpMounts, validate(state)); var name: [256]u8 = @splat('x'); state.mounts = &.{.{ .name = name[0..255], .dial = "/tmp/peer.sock" }}; try validate(state); state.mounts = &.{.{ .name = &name, .dial = "/tmp/peer.sock" }}; try std.testing.expectError(error.BadDumpMounts, validate(state)); const mounts = [_]Mount{ .{ .name = "a", .dial = "/tmp/a" }, .{ .name = "b", .dial = "/tmp/b" }, .{ .name = "c", .dial = "/tmp/c" }, .{ .name = "d", .dial = "/tmp/d" }, .{ .name = "e", .dial = "/tmp/e" }, .{ .name = "f", .dial = "/tmp/f" }, .{ .name = "g", .dial = "/tmp/g" }, .{ .name = "h", .dial = "/tmp/h" }, .{ .name = "i", .dial = "/tmp/i" }, }; state.mounts = mounts[0..8]; try validate(state); state.mounts = &mounts; try std.testing.expectError(error.BadDumpMounts, validate(state)); } test "version-one image records default old fields and roundtrip new state" { const gpa = std.testing.allocator; const legacy = \\.{ \\ .magic = "pardes-dump", \\ .version = 1, \\ .screen = .{ .cols = 80, .rows = 24 }, \\ .active = 0, \\ .topbar = "", \\ .theme = "dark", \\ .columns = .{.{ .panes = .{0} }}, \\ .panes = .{.{ \\ .kind = .image, \\ .tag = "img /tmp/old.ppm New Del", \\ .body = "", \\ .image = .{ .path = "/tmp/old.ppm", .bytes_b64 = "" }, \\ }}, \\} ; var old = try readZon(gpa, legacy, "legacy-image"); defer old.deinit(); const old_image = old.value.panes[0].image.?; try std.testing.expect(!old_image.petscii); try std.testing.expectEqual(ImagePalette.commodore, old_image.palette); try std.testing.expect(old_image.ascii); try std.testing.expect(old.value.panes[0].tag_tail == null); const pane = Pane{ .kind = .image, .tag = "img petscii:on palette:terminal ascii:off /tmp/new.ppm", .body = "", .tag_tail = "", .image = .{ .path = "/tmp/new.ppm", .petscii = true, .palette = .terminal, .ascii = false, }, }; const ids = [_]usize{0}; const columns = [_]Column{.{ .panes = &ids }}; const panes = [_]Pane{pane}; const state = State{ .screen = .{ .cols = 80, .rows = 24 }, .columns = &columns, .panes = &panes, }; var out: std.Io.Writer.Allocating = .init(gpa); defer out.deinit(); try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); var parsed = try readZon(gpa, out.written(), "new-image"); defer parsed.deinit(); const restored = parsed.value.panes[0].image.?; try std.testing.expect(restored.petscii); try std.testing.expectEqual(ImagePalette.terminal, restored.palette); try std.testing.expect(!restored.ascii); try std.testing.expect(parsed.value.panes[0].tag_tail != null); try std.testing.expectEqualStrings("", parsed.value.panes[0].tag_tail.?); } test "validation bounds pane restore state" { const ids = [_]usize{0}; const columns = [_]Column{.{ .panes = &ids }}; var panes = [_]Pane{.{ .kind = .terminal, .tag = "term", .body = "", .terminal = .{}, }}; const state = State{ .screen = .{ .cols = 80, .rows = 24 }, .columns = &columns, .panes = &panes, }; try validate(state); panes[0].vweight = 0; try std.testing.expectError(error.BadDumpPaneWeight, validate(state)); panes[0].vweight = std.math.inf(f32); try std.testing.expectError(error.BadDumpPaneWeight, validate(state)); panes[0].vweight = 1; panes[0].scroll = @as(usize, @intCast(std.math.maxInt(i32))) + 1; try std.testing.expectError(error.BadDumpPaneScroll, validate(state)); panes[0].scroll = 0; var oversized_tail: [limits.max_tag_tail + 1]u8 = @splat('x'); panes[0].tag_tail = &oversized_tail; try std.testing.expectError(error.BadDumpTagTail, validate(state)); panes[0].tag_tail = oversized_tail[0..limits.max_tag_tail]; try validate(state); } // ---- a whole editor to and from a dump (acme keeps this in rows.c) ---- pub fn dumpState(p: *Pardes) !void { const arena = p.scratch.allocator(); var slot_to_pane: [MAX_PANES]?usize = @splat(null); var dump_panes: [MAX_PANES]Pane = undefined; var panes_len: usize = 0; for (p.panes, 0..) |slot, id| { const pane = slot orelse continue; slot_to_pane[id] = panes_len; const tag = try tagline.tagText(p, arena, pane); const body = try body_layer.bodyText(p, arena, pane); const scroll: usize = @intCast(@max(0, pane.scroll())); var dp: Pane = if (pane.file) |*f| try pardes.panes.File.dumpPane( arena, pane, f, tag, body, scroll, if (f.output) |o| pardes.panes.Output.word(o.from) else "", if (f.output) |*o| o.arg() else "", ) else if (pane.hasPdf()) blk: { if (comptime !pdf_enabled) unreachable; const pv = &pane.pdf.?; break :blk try pardes.panes.Image.dumpPane(p, arena, pane, tag, body, pv.page, pv.path, &.{}); } else if (pane.image) |iv| try pardes.panes.Image.dumpPane(p, arena, pane, tag, body, scroll, iv.path, iv.raw) else try pardes.panes.Terminal.dumpPane(pane, arena, tag, body, scroll); dp.tag_tail = pane.tag.own; if (dp.file) |*df| { const dot = ctlfs.pane.dotOf(pane); df.dot = .{ dot.q0, dot.q1 }; } dp.serial = pane.serial; dump_panes[panes_len] = dp; panes_len += 1; } var columns: [MAX_COLS]Column = undefined; var columns_len: usize = 0; var column_ids: [MAX_COLS][MAX_PANES]usize = undefined; for (0..p.ncol) |c| { var ids_len: usize = 0; for (0..p.col_n[c]) |k| if (slot_to_pane[p.col_panes[c][k]]) |compact| { column_ids[c][ids_len] = compact; ids_len += 1; }; columns[columns_len] = .{ .serial = layout.columnSerial(p, c), .tag = p.col_tags[c].own, .weight = @as(f32, @floatFromInt(p.col_weight[c])) / @as(f32, @floatFromInt(column_weight_unit)), .panes = column_ids[c][0..ids_len], }; columns_len += 1; } var mounts: [filesystem.max_mounts]Mount = undefined; for (p.fs.mounts.items, 0..) |mount, i| mounts[i] = .{ .name = mount.name, .dial = mount.dial }; const state: State = .{ .screen = .{ .cols = p.screen_w, .rows = p.screen_h }, .active = slot_to_pane[p.active] orelse 0, .topbar = config.topbar_str, .topbar_custom = p.global_tag.own, .theme = p.theme().name, .locations_config = p.locations_config, .tree_context_tag_style = p.settings.tree_context_tag_style, .columns = columns[0..columns_len], .panes = dump_panes[0..panes_len], .mounts = mounts[0..p.fs.mounts.items.len], .settings = try changedSettings(p, arena), }; try validate(state); var out: std.Io.Writer.Allocating = .init(p.gpa); errdefer out.deinit(); try std.zon.stringify.serialize(state, .{ .whitespace = true }, &out.writer); if (p.dump_out) |d| p.gpa.free(d); p.dump_out = try out.toOwnedSlice(); p.emit(.write_dump); } /// The setting lines that differ from a fresh session's, as the root ctl /// reads them, less the ones a dump keeps apart. fn changedSettings(p: *Pardes, arena: std.mem.Allocator) ![]const []const u8 { var now: std.Io.Writer.Allocating = .init(arena); var fresh: std.Io.Writer.Allocating = .init(arena); const defaults: config.Runtime = .{}; try pardes.ctlfs.ctl.writeSettingsOf(p, &p.settings, &now.writer); try pardes.ctlfs.ctl.writeSettingsOf(p, &defaults, &fresh.writer); var out: std.ArrayList([]const u8) = .empty; var lines = std.mem.splitScalar(u8, now.written(), '\n'); while (lines.next()) |line| { if (line.len == 0) continue; const word = line[0 .. std.mem.indexOfScalar(u8, line, ' ') orelse line.len]; if (std.mem.eql(u8, word, "Theme") or std.mem.eql(u8, word, "Font")) continue; var same = std.mem.splitScalar(u8, fresh.written(), '\n'); while (same.next()) |other| { if (std.mem.eql(u8, other, line)) break; } else try out.append(arena, line); } return out.items; } /// Applies a dump's setting lines, each as the root ctl would. fn applySettings(p: *Pardes, lines: []const []const u8) void { for (lines) |line| { const sp = std.mem.indexOfScalar(u8, line, ' '); const setting = config.Runtime.find(line[0 .. sp orelse line.len]) orelse continue; _ = p.settings.apply(setting, if (sp) |s| line[s + 1 ..] else null); } } /// The replacement core for a Restore of the dump at `from`, whose log says /// so after its panes' `new`s, for a client that reconnects to read. pub fn restore(p: *Pardes, zon_bytes: []const u8, from: []const u8) !*Pardes { var opts = p.opts; opts.cols = p.screen_w; opts.rows = p.screen_h; var old: OldSerials = .{}; const replacement = try initDump(p.gpa, opts, zon_bytes, p, &old); pardes.ctlfs.events.notePath(replacement, "restore", from); // Which serial each pane and column had, for a script that held them. for (replacement.panes, old.panes) |slot, was| if (slot) |pane| if (was != 0) { var rec: [32]u8 = undefined; pardes.ctlfs.events.notePath(replacement, "restored", std.fmt.bufPrint(&rec, "{d} {d}", .{ was, pane.serial }) catch continue); }; for (old.cols[0..replacement.ncol], 0..) |was, c| if (was != 0) { var rec: [32]u8 = undefined; pardes.ctlfs.events.notePath(replacement, "restoredcol", std.fmt.bufPrint(&rec, "{d} {d}", .{ was, layout.columnSerial(replacement, c) }) catch continue); }; return replacement; } pub fn initFromDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8) !*Pardes { return initDump(gpa, opts, zon_bytes, null, null); } /// The serials a dump's panes and columns had, read as it is restored. const OldSerials = struct { panes: [MAX_PANES]u32 = @splat(0), cols: [MAX_COLS]u32 = @splat(0), }; fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previous: ?*const Pardes, old_serials: ?*OldSerials) !*Pardes { const image_gpa = opts.image_allocator orelse gpa; const pdf_gpa = opts.pdf_allocator orelse gpa; const tree_sitter_gpa = opts.tree_sitter_allocator orelse gpa; const p = try gpa.create(Pardes); p.* = .{ .gpa = gpa, .image_gpa = image_gpa, .pdf_gpa = pdf_gpa, .tree_sitter_gpa = tree_sitter_gpa, .opts = opts, .fs = .{}, .screen_w = opts.cols, .screen_h = opts.rows, .scratch = .init(gpa), .frame_arena = .init(opts.frame_allocator orelse gpa), .fallback = .{ .gpa = gpa }, }; errdefer p.deinit(); if (previous) |old| { p.next_serial = old.next_serial; p.lsp_seq = old.lsp_seq; p.pipe.seq = old.pipe.seq; p.cell_pixels = old.cell_pixels; p.row_metrics = old.row_metrics; p.native_images = old.native_images; } for (opts.mounts) |mount| try p.fs.mount(gpa, mount.name, mount.dial); p.opts.mounts = &.{}; var parsed = try readZon(gpa, zon_bytes, "load"); defer parsed.deinit(); const st = parsed.value; p.locations_config = st.locations_config; p.settings.tree_context_tag_style = st.tree_context_tag_style; applySettings(p, st.settings); for (st.mounts) |mount| { var already_mounted = false; for (p.fs.mounts.items) |existing| { if (!std.mem.eql(u8, mount.name, existing.name)) continue; if (!std.mem.eql(u8, mount.dial, existing.dial)) return error.MountConflict; already_mounted = true; break; } if (!already_mounted) try p.fs.mount(gpa, mount.name, mount.dial); } for (themes, 0..) |t, i| { if (std.mem.eql(u8, t.name, st.theme)) p.settings.theme = @intCast(i); } for (st.panes, 0..) |src, i| { if (i >= MAX_PANES) break; if (old_serials) |o| o.panes[i] = src.serial; const pane: *pardes.panes.Pane = switch (src.kind) { .terminal => terminal: { const t = src.terminal.?; // A host that runs shells gets them back: the old history // above a new shell in the directory it was in. The web // shell and an isolated build only ever replay it. // A command pane comes back finished, showing what it said // and how it ended: its command is not run again. const live = comptime (terminal_panes and platform != .web and !isolated); const revive = live and t.cwd.len <= effect_path_cap and t.command.len == 0; const restored = try pardes.panes.Terminal.restore(p, src, revive); p.installPane(i, restored); try restored.setOwnedCwd(t.cwd); if (t.shell.len > 0) restored.shell = try gpa.dupe(u8, t.shell); if (t.command.len > 0) { restored.command = try gpa.dupe(u8, t.command); restored.command_done = true; restored.command_status = t.status; } if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.body.mode = .tty; if (revive) p.emit(.{ .spawn = .{ .pane = @intCast(i), .serial = restored.serial, .cwd = .from(t.cwd) } }); break :terminal restored; }, .file => try pardes.panes.File.restore(p, i, src), .image => restore_image: { const im = src.image.?; if (pardes.panes.Pdf.isPath(im.path)) { var raw: []u8 = if (im.bytes_b64.len > 0) try decodeBytes(gpa, im.bytes_b64) else &.{}; errdefer if (raw.len > 0) gpa.free(raw); if (comptime pdf_enabled) { if (pardes.panes.Pdf.openPane(p, i, im.path, src.scroll + 1) catch null) |restored| { if (raw.len > 0) gpa.free(raw); raw = &.{}; restored.cols = @max(1, src.cols); restored.rows = @max(1, src.rows); break :restore_image restored; } } // If the source path is unavailable, retain its bytes // as an ordinary file rather than dropping the pane. const path = try gpa.dupe(u8, im.path); errdefer gpa.free(path); const content = if (raw.len > 0) raw else try gpa.dupe(u8, ""); raw = &.{}; errdefer if (content.len > 0) gpa.free(content); const history = try pardes.panes.File.History.create(gpa); errdefer gpa.destroy(history); const restored = try p.newDocPane(i); restored.file = .{ .path = path, .content = content, .history = history }; restored.body.cur_pinned = true; restored.cols = @max(1, src.cols); restored.rows = @max(1, src.rows); p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true, .mode = .baseline_disk } }); break :restore_image restored; } break :restore_image try pardes.panes.Image.restore(p, i, src); }, }; tagline.restoreDumpTail(p, pane, src); pane.vweight = src.vweight; pane.collapsed = src.collapsed; } p.ncol = @min(st.columns.len, MAX_COLS); p.col_serial = @splat(0); if (st.topbar_custom) |bar| p.global_tag.own = try p.gpa.dupe(u8, bar); for (st.columns[0..p.ncol], 0..) |col, c| { if (old_serials) |o| o.cols[c] = col.serial; if (col.tag) |bar| p.col_tags[c].own = try p.gpa.dupe(u8, bar); const scaled = @as(f64, @floatCast(col.weight)) * @as(f64, @floatFromInt(column_weight_unit)); const bounded = @min(scaled, @as(f64, @floatFromInt(max_column_weight))); p.col_weight[c] = @max(1, @as(u64, @intFromFloat(@round(bounded)))); p.col_n[c] = @min(col.panes.len, MAX_PANES); for (col.panes[0..p.col_n[c]], 0..) |pid, k| p.col_panes[c][k] = pid; } p.active = @min(st.active, MAX_PANES - 1); p.sync(); exec.applyStartupConfig(p); colors.finishThemeInitialization(p); p.sync(); p.presentation.enabled = true; _ = p.takeHaptic(); // see init: a restored session is not a gesture try p.fs.log.buf.ensureTotalCapacityPrecise(p.gpa, p.fs.log.cap); ctlfs.events.announce(p); return p; } test "a dump keeps the settings that differ from a fresh session's, and a restore applies them" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); try std.testing.expect(p.executeBuiltinLine(p.active, "Placement pardes")); try std.testing.expect(p.executeBuiltinLine(p.active, "Verbose off")); try dumpState(p); try std.testing.expect(std.mem.indexOf(u8, p.dump_out.?, "\"Placement pardes\"") != null); // What is left at its default is not written. try std.testing.expect(std.mem.indexOf(u8, p.dump_out.?, "\"BootShell keep\"") == null); const restored = try initFromDump(gpa, .{ .tty_only = true }, p.dump_out.?); defer restored.deinit(); try std.testing.expectEqual(config.Runtime.Placement.pardes, restored.settings.placement); try std.testing.expect(!restored.settings.verbose); } test "a restored pane keeps its dot, as acme's dump keeps a window's" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("one two three\nfour\n"); ctlfs.pane.setDot(pane, .{ .q0 = 4, .q1 = 7 }); try dumpState(p); const restored = try initFromDump(gpa, .{ .tty_only = true }, p.dump_out.?); defer restored.deinit(); const again = for (restored.panes) |slot| { if (slot) |candidate| if (candidate.file != null) break candidate; } else return error.FileLost; const dot = ctlfs.pane.dotOf(again); try std.testing.expectEqual(@as(u32, 4), dot.q0); try std.testing.expectEqual(@as(u32, 7), dot.q1); }