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 = 16; 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 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 = "", }; 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, /// 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 { 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 = &.{}, }; /// 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; if (col.panes.len == 0 or 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(); } }; 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; 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; }; if (ids_len > 0) { columns[columns_len] = .{ .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], }; 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 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; const replacement = try initDump(p.gpa, opts, zon_bytes, p); pardes.ctlfs.events.notePath(replacement, "restore", from); return replacement; } pub fn initFromDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8) !*Pardes { return initDump(gpa, opts, zon_bytes, null); } fn initDump(gpa: std.mem.Allocator, opts: Options, zon_bytes: []const u8, previous: ?*const Pardes) !*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; 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; 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. const live = comptime (terminal_panes and platform != .web and !isolated); const revive = live and t.cwd.len <= effect_path_cap; const restored = try pardes.panes.Terminal.restore(p, src, revive); p.installPane(i, restored); try restored.setOwnedCwd(t.cwd); if (std.mem.startsWith(u8, src.tag, "TTY ")) restored.body.mode = .tty; if (revive) p.emit(.{ .spawn = .{ .pane = @intCast(i), .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 (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; }