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 directory and any parent missing (a fresh HOME has no /// ~/.local/share); 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; // Each missing directory on the way, as `mkdir -p`: EEXIST is fine. for (dz[1..dir.len], 1..) |c, i| if (c == '/') { dz[i] = 0; _ = std.c.mkdir(dz[0..i :0], 0o755); dz[i] = '/'; }; _ = std.c.mkdir(dz[0..dir.len :0], 0o755); 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(); var stamp: [32]u8 = undefined; const base = std.fmt.bufPrint(&stamp, "pardes-{d:0>4}{d:0>2}{d:0>2}-{d:0>2}{d:0>2}{d:0>2}", .{ yd.year, md.month.numeric(), @as(u8, md.day_index) + 1, ds.getHoursIntoDay(), ds.getMinutesIntoHour(), ds.getSecondsIntoMinute(), }) catch return null; return uniqueIn(buf, dir, base); } /// `/.zon`, or `-2.zon` and on when two dumps land in one /// second: a dump never overwrites another. fn uniqueIn(buf: *[1024:0]u8, dir: []const u8, base: []const u8) ?[:0]const u8 { var n: usize = 1; while (n < 1000) : (n += 1) { const path = (if (n == 1) std.fmt.bufPrintSentinel(buf, "{s}/{s}.zon", .{ dir, base }, 0) else std.fmt.bufPrintSentinel(buf, "{s}/{s}-{d}.zon", .{ dir, base, n }, 0)) catch return null; if (comptime !pardes.hosted) return path; if (std.c.access(path.ptr, std.c.F_OK) != 0) return path; } return null; } test "two dumps in one second get names of their own" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var dir_buf: [900]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)]; var buf: [1024:0]u8 = undefined; const first = try std.testing.allocator.dupe(u8, uniqueIn(&buf, dir, "pardes-x").?); defer std.testing.allocator.free(first); try std.testing.expect(std.mem.endsWith(u8, first, "/pardes-x.zon")); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "pardes-x.zon", .data = "" }); try std.testing.expect(std.mem.endsWith(u8, uniqueIn(&buf, dir, "pardes-x").?, "/pardes-x-2.zon")); } /// The host's environment; a freestanding core (web, the board) has none /// and no libc to ask, so only a DumpDir with no `~/` names a directory there. fn hostEnv(name: [*:0]const u8) ?[*:0]u8 { if (builtin.os.tag == .freestanding) return null; return std.c.getenv(name); } /// 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); var joined: [4096]u8 = undefined; // `~/x`, else a relative one from the directory pardes started in: the // same place however it was written, and shown so. const raw = pardes.filesystem.expandHome(dir_setting, &joined); var fba_buf: [4096]u8 = undefined; var fba = std.heap.FixedBufferAllocator.init(&fba_buf); var cwd_buf: [1024]u8 = undefined; // No libc on the web or the board: there a relative one is from `/`. const cwd: []const u8 = if (comptime !pardes.hosted) "/" else if (std.fs.path.isAbsolute(raw)) "/" else if (std.c.getcwd(&cwd_buf, cwd_buf.len)) |c| std.mem.span(@as([*:0]const u8, @ptrCast(c))) else "/"; const resolved = std.fs.path.resolvePosix(fba.allocator(), &.{ cwd, raw }) catch return null; return std.fmt.bufPrint(buf, "{s}", .{resolved}) catch null; } test "a dump makes every directory missing on the way to its DumpDir" { if (std.c.getenv("PARDES_DUMP") != null) return error.SkipZigTest; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var at: [4096]u8 = undefined; const base = at[0..try tmp.dir.realPath(std.testing.io, &at)]; var setting: [4200]u8 = undefined; const dir = try std.fmt.bufPrint(&setting, "{s}/fresh/.local/share/pardes", .{base}); var out: [1024:0]u8 = undefined; const path = outPath(&out, dir).?; try std.testing.expect(std.mem.startsWith(u8, path, dir)); var made = try tmp.dir.openDir(std.testing.io, "fresh/.local/share/pardes", .{}); made.close(std.testing.io); } test "a DumpDir reads back absolute, however it was written" { var buf: [4096]u8 = undefined; try std.testing.expectEqualStrings("/tmp/dumps", directory(&buf, "/tmp/dumps/").?); try std.testing.expectEqualStrings("/tmp/dumps", directory(&buf, "/tmp//x/../dumps").?); const rel = directory(&buf, "rel/"); try std.testing.expect(std.fs.path.isAbsolute(rel.?) and std.mem.endsWith(u8, rel.?, "/rel")); } /// The standard dump directory, without creating it or generating a filename. pub fn defaultDirectory(buf: []u8) ?[]const u8 { if (hostEnv("XDG_DATA_HOME")) |x| return std.fmt.bufPrint(buf, "{s}/pardes", .{std.mem.span(x)}) catch null; if (hostEnv("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 = MAX_COLS; /// 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 = .{}, /// Petscii on: its kitty graphics drawn as glyph art by choice. petscii: bool = false, }; 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 = "", /// The text, in older dumps plain and base64 both; now base64 alone, /// and only for a pane whose text is not its file's on disk (unsaved, /// an output, a file never written): a clean file's is read from disk. content: []const u8 = "", content_b64: []const u8 = "", dirty: bool = false, /// An unsaved pane's: the hash of the file on disk its text was read /// against. A Restore that finds another there says it changed, and /// Save asks before overwriting it. disk_hash: ?u64 = null, /// Its file was on disk when dumped: one gone by the Restore is said /// deleted, one never written (Config's new file) is not. True in /// older dumps. on_disk: bool = true, 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, /// A Zerox twin: the index, in `panes`, of the earlier pane whose /// buffer it shares, its text kept there once; null for none. twin_of: ?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, /// A PDF's PdfFit and PdfTint, by their tag words (`width`, `filtered`); /// empty, the defaults, as in dumps from before. pdf_fit: []const u8 = "", pdf_tint: []const u8 = "", }; 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.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 = &.{}, /// Incl's directories, in order; null in older dumps (the defaults). incl: ?[]const []const u8 = null, }; /// 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. /// Whether `bytes` are a dump; a ZON failure's line and reason are left in /// `why`, ended by a 0. pub fn check(gpa: std.mem.Allocator, bytes: []const u8, why: []u8) !void { var arena: std.heap.ArenaAllocator = .init(gpa); defer arena.deinit(); const source = try arena.allocator().dupeZ(u8, bytes); var diag: std.zon.parse.Diagnostics = .{}; const state = std.zon.parse.fromSliceAlloc(State, arena.allocator(), source, &diag, .{ .free_on_error = false }) catch |err| { const said = pardes.Messages.zonWhy(&diag, why[0 .. why.len - 1]); if (said.ptr != why.ptr) @memcpy(why[0..said.len], said); why[said.len] = 0; return err; }; try validate(state); } 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( p, 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; } // Zerox's twins: the text once, in the first; each other names it. for (p.panes, 0..) |slot, id| { const f = (slot orelse continue).file orelse continue; if (f.twin == 0) continue; const i = slot_to_pane[id] orelse continue; for (p.panes[0..id], 0..) |earlier, j_id| { const of = (earlier orelse continue).file orelse continue; if (of.twin != f.twin) continue; const j = slot_to_pane[j_id] orelse break; if (dump_panes[i].file) |*df| { df.twin_of = @intCast(j); df.content_b64 = ""; } break; } } 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), .incl = p.incl.items, }; 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, ' '); // A ThemeFile is a request the host answers by reading the file, as // a /ctl write of it is: asked again here, not dropped. if (std.mem.eql(u8, line[0 .. sp orelse line.len], "ThemeFile")) { if (sp) |s| colors.requestThemeFile(p, 0, line[s + 1 ..]); continue; } 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); replacement.pointRestoreAt(from); 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 p = try Pardes.create(gpa, opts); errdefer p.deinit(); if (previous) |old| { p.next_serial = old.next_serial; // Columns as panes: a restored one takes a fresh serial, never one // a column of the old session had, living or deleted. p.next_column_serial = old.next_column_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; // The workspace tag's Restore word names the dump it came from. if (old.last_dump) |d| p.last_dump = try gpa.dupe(u8, d); // Recent goes on across a Restore, the old session's own list. p.recent = old.recent.clone(p.recentGpa()); p.recent_loaded = old.recent_loaded; } else p.loadRecent(); 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); if (st.incl) |dirs| { for (p.incl.items) |dir| p.gpa.free(dir); p.incl.clearRetainingCapacity(); for (dirs) |dir| if (dir.len > 0 and dir[0] == '/' and std.mem.indexOfScalar(u8, dir, 0) == null) { const copy = p.gpa.dupe(u8, dir) catch continue; p.incl.append(p.gpa, copy) catch p.gpa.free(copy); }; } // A dump naming a ThemeFile asks the host to load it again: until it // has, and for good if it fails, the theme this session showed goes on // (a failed ThemeFile changes nothing, a Restore's included), unless // the dump names a Theme of its own. if (previous) |old| if (p.theme_file_path.get().len > 0) { const names_theme = for (st.settings) |line| { if (std.mem.startsWith(u8, line, "Theme ")) break true; } else false; try colors.carryTheme(p, old, !names_theme); }; 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 (revive) p.emitSpawn(i, restored.serial, t.cwd); if (t.petscii and pardes.panes.terminal.petscii(restored) == false) pardes.panes.terminal.togglePetscii(restored); break :terminal restored; }, .file => file: { const restored = try pardes.panes.File.restore(p, i, src); // A twin: on the earlier pane's buffer again. if (src.file.?.twin_of) |j| if (j < i) pardes.panes.File.relink(p, j, i, src.file.?.dot); break :file restored; }, .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, .last) catch null) |restored| { if (raw.len > 0) gpa.free(raw); raw = &.{}; // Its fit and tint as they were (PdfFit, PdfTint). const pv = &restored.pdf.?; if (std.meta.stringToEnum(@TypeOf(pv.fit), im.pdf_fit)) |fit| if (fit != pv.fit) pardes.panes.Pdf.toggleFit(restored); if (std.meta.stringToEnum(@TypeOf(pv.tint), im.pdf_tint)) |tint| while (pv.tint != tint) pardes.panes.Pdf.toggleTint(restored); 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 std.testing.expect(p.executeBuiltinLine(p.active, "JumpScope all")); try dumpState(p); try std.testing.expect(std.mem.indexOf(u8, p.dump_out.?, "\"Placement pardes\"") != null); try std.testing.expect(std.mem.indexOf(u8, p.dump_out.?, "\"JumpScope all\"") != 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); try std.testing.expectEqual(config.Runtime.JumpScope.all, restored.settings.jump_scope); } test "a session with every column it may hold dumps and restores them all" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 1200, .rows = 24 }); defer p.deinit(); while (p.ncol < MAX_COLS) { layout.compute(p); var widest: usize = 0; for (0..p.ncol) |c| if (p.col_w[c] > p.col_w[widest]) { widest = c; }; _ = layout.insertColumn(p, widest, false) orelse return error.NoColumn; } try dumpState(p); const restored = try initFromDump(gpa, .{ .tty_only = true, .cols = 1200, .rows = 24 }, p.dump_out.?); defer restored.deinit(); try std.testing.expectEqual(@as(usize, MAX_COLS), restored.ncol); } test "a restored session's column and workspace tags take more than one selection, and its 9P files have a time" { const th = @import("ninep/testing.zig"); const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); try dumpState(p); const restored = try initFromDump(gpa, .{ .tty_only = true }, p.dump_out.?); defer restored.deinit(); try std.testing.expect(restored.col_tags[0].reserveSels()); try std.testing.expect(restored.global_tag.reserveSels()); try std.testing.expect(restored.fs.started != 0); const stat = th.call(restored, .{ .tag = 1, .op = .getattr, .node = @intFromEnum(pardes.ctlfs.TopFile.index) }); try std.testing.expect(stat.reply.attr.mtime != 0); } 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); } test "a pane restored with unsaved text is asked about by Exit, Restore, Del and get" { const gpa = std.testing.allocator; const p = try Pardes.init(gpa, .{ .tty_only = true }); defer p.deinit(); const pane = try p.setTestFile("saved\n"); _ = ctlfs.pane.spliceBody(p, pane, 0, 0, "unsaved ").?; try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision); try dumpState(p); // Restore reads its dump before it warns: one it can read. var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "d.zon", .data = p.dump_out.? }); var dir_buf: [4096]u8 = undefined; const dir = dir_buf[0..try tmp.dir.realPath(std.testing.io, &dir_buf)]; var restore_line: [4200]u8 = undefined; for ([_][]const u8{ "Exit", "Restore", "Del", "get" }) |word| { const restored = try initFromDump(gpa, .{ .tty_only = true }, p.dump_out.?); defer restored.deinit(); const id = for (restored.panes, 0..) |slot, i| { if (slot) |candidate| if (candidate.file != null and candidate.file.?.output == null) break i; } else return error.FileLost; const th = @import("ninep/testing.zig"); const node = if (std.mem.eql(u8, word, "Del") or std.mem.eql(u8, word, "get")) ctlfs.Node.of(restored.panes[id].?.serial, .ctl) else @intFromEnum(ctlfs.TopFile.ctl); const line = if (std.mem.eql(u8, word, "Restore")) try std.fmt.bufPrint(&restore_line, "Restore {s}/d.zon\n", .{dir}) else if (std.mem.eql(u8, word, "Exit")) "Exit\n" else if (std.mem.eql(u8, word, "Del")) "Del\n" else "get\n"; const answer = th.wr(restored, node, line); try std.testing.expectEqual(ctlfs.Status.err, answer.reply.status); try std.testing.expect(std.mem.indexOf(u8, answer.reply.ename, "Modified") != null); try std.testing.expect(!restored.quit and restored.panes[id] != null); } }