summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-06 20:43:40 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:07 -0300
commit513938c1cfb216a3bc4a3559cf7731b8e44a952f (patch)
tree7b711c30ea6ebc255a1d691223b2cb93cd7dedf2 /src
parentd0ba070a87c54c538e5f788485391b0af927d0fb (diff)
downloadpardes-513938c1cfb216a3bc4a3559cf7731b8e44a952f.tar.gz
pardes-513938c1cfb216a3bc4a3559cf7731b8e44a952f.zip
dumps: per-user data dir + timestamped names; Restore builtin loads a dump into the live session. dump.outPath resolves $PARDES_DUMP verbatim (the snapshot harness pins /tmp/pardes-dump.zon so dump/load/restore goldens stay deterministic), else $XDG_DATA_HOME|~/.local/share /pardes/pardes-YYYYMMDD-HHMMSS.zon (dir mkdir-ed; wall clock via libc clock_gettime — std.time.timestamp is gone in 0.16). After write_dump the shell reports the written path back (core.setLastDump) and the topbar grows "Restore <path>": middle-clicking Restore reloads the last dump, and "Restore <path>" executed anywhere takes the path as its first argument (onSelect prefix match). Core stays sans-IO: the builtin stashes restore_req; each shell consumes takeRestore() after drainEffects and swaps in initFromDump at the current screen size (pre-sized: dump panes never greet), tearing down the live ptys (tty: cancel reader + close + gen bump; gui: SIGKILL, the gen-guarded eof closes the old fds). Web ignores it. New restore.snap golden: KEEPME survives the restore, GONESOON echoed after Dump does not, the topbar word appears then resets. 29/29.
Diffstat (limited to 'src')
-rw-r--r--src/dump.zig31
-rw-r--r--src/gui.zig25
-rw-r--r--src/pardes.zig55
-rw-r--r--src/tty.zig28
4 files changed, 129 insertions, 10 deletions
diff --git a/src/dump.zig b/src/dump.zig
index 9c1dceec..bff8af21 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -1,7 +1,36 @@
const std = @import("std");
const builtin = @import("builtin");
-pub const default_path = "/tmp/pardes-dump.zon";
+/// 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-<utc>.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) ?[: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 = if (std.c.getenv("XDG_DATA_HOME")) |x|
+ std.fmt.bufPrint(&dir_buf, "{s}/pardes", .{std.mem.span(x)}) catch return null
+ else if (std.c.getenv("HOME")) |h|
+ std.fmt.bufPrint(&dir_buf, "{s}/.local/share/pardes", .{std.mem.span(h)}) catch return null
+ else
+ 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;
+}
pub const magic = "pardes-dump";
pub const version: u32 = 1;
pub const max_panes: usize = 16;
diff --git a/src/gui.zig b/src/gui.zig
index 2653a056..15631efa 100644
--- a/src/gui.zig
+++ b/src/gui.zig
@@ -614,7 +614,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
opts.cols = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(pw, 1))), cell_w)));
opts.rows = @intCast(@max(1, @divTrunc(@as(u32, @intCast(@max(ph, 1))), cell_h)));
}
- const core = if (opts.load_path) |lp| blk: {
+ var core = if (opts.load_path) |lp| blk: {
const bytes = try @import("look.zig").readFile(gpa, lp);
defer gpa.free(bytes);
break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes);
@@ -684,6 +684,24 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
// 4. effects
drainEffects(core, &ptys, &gens, gpa, &queue, &g, true);
if (core.quit) break;
+ // Restore builtin: swap in a core rebuilt from the dump; kill the live
+ // shells (their detached readers wake on child death; gens bumped so
+ // the stale eofs close the old fds without touching the replay panes)
+ if (core.takeRestore()) |rp| blk: {
+ const bytes = look.readFile(gpa, rp) catch break :blk;
+ defer gpa.free(bytes);
+ var o = core.opts;
+ o.cols = core.screen_w;
+ o.rows = core.screen_h; // pre-size: dump panes never greet
+ const nc = pardes.Pardes.initFromDump(gpa, o, bytes) catch break :blk;
+ for (&ptys) |*slot| if (slot.*) |pt| {
+ _ = libc.kill(pt.pid, libc.SIG.KILL);
+ slot.* = null;
+ };
+ for (&gens) |*g2| g2.* +%= 1;
+ core.deinit();
+ core = nc;
+ }
// 5. live cwd for tags/look: cheap /proc readlink per pane, per frame
pollCwds(core, &ptys);
// 6. the grid follows the window (covers WINDOW_RESIZED and test resizes)
@@ -1482,9 +1500,12 @@ fn drainEffects(
},
.write_dump => {
const out = core.dump_out orelse continue;
- const fd = libc.open(pardes.dump.default_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
+ var pbuf: [1024:0]u8 = undefined;
+ const path = pardes.dump.outPath(&pbuf) orelse continue;
+ const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
if (fd < 0) continue;
writeFd(fd, out);
+ core.setLastDump(path);
_ = libc.close(fd);
},
.set_clipboard => {
diff --git a/src/pardes.zig b/src/pardes.zig
index 9842362f..a2cfe1c6 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -44,7 +44,7 @@ const file_pane_tail = " " ++ file_pane_builtins_str;
// Builtins: executing the name (middle-click / Tab) runs the action through the
// ONE dispatcher (onSelect + actOnSelection), no matter where the name appears.
-const SelectAction = enum { none, send, newcol, delcol, del, save, tutor, kill, debug, colors, nextcolor, dump };
+const SelectAction = enum { none, send, newcol, delcol, del, save, tutor, kill, debug, colors, nextcolor, dump, restore };
const Builtin = struct { name: []const u8, action: SelectAction };
const builtins = [_]Builtin{
.{ .name = "Kill", .action = .kill },
@@ -54,6 +54,7 @@ const builtins = [_]Builtin{
.{ .name = "Colors", .action = .colors },
.{ .name = "NextColor", .action = .nextcolor },
.{ .name = "Dump", .action = .dump },
+ .{ .name = "Restore", .action = .restore },
.{ .name = "Save", .action = .save },
.{ .name = "Del", .action = .del },
.{ .name = "Delcol", .action = .delcol },
@@ -66,6 +67,8 @@ fn onSelect(button: Mouse.Button, text: []const u8) SelectAction {
inline for (builtins) |b| {
if (std.mem.eql(u8, cmd, b.name)) return b.action;
}
+ // Restore takes the dump path as its first argument
+ if (std.mem.startsWith(u8, cmd, "Restore ") or std.mem.startsWith(u8, cmd, "Restore\t")) return .restore;
return .send;
}
@@ -340,8 +343,9 @@ pub const Effect = union(enum) {
/// write this pane's file content to its path; the shell reads both off
/// the core (content is unbounded, effects are fixed-size values)
save_file: struct { pane: u8 },
- /// a serialized state dump is ready in core.dump_out; write it to
- /// dump.default_path (acme-style: another instance loads it with -l)
+ /// a serialized state dump is ready in core.dump_out; write it to the
+ /// path dump.outPath resolves (acme-style: another instance loads it
+ /// with -l, or the Restore builtin loads it into this one)
write_dump,
/// the yank register changed; the shell reads it off the core (OSC 52 out)
set_clipboard,
@@ -663,6 +667,13 @@ pub const Pardes = struct {
yank: ?[]u8 = null,
/// the last serialized dump (gpa-owned), read by the write_dump effect
dump_out: ?[]u8 = null,
+ /// where the shell wrote the last dump (shell reports back after
+ /// write_dump); shown in the topbar as `Restore <path>`
+ last_dump: ?[]const u8 = null,
+ /// a Restore builtin wants this dump loaded into the live session; the
+ /// shell consumes it via takeRestore each frame (core stays sans-IO)
+ restore_req: ?[]const u8 = null,
+ restore_buf: [1024]u8 = undefined,
surface: Surface = .{},
/// per-update scratch (paneCursorLines, selection text); reset each update
@@ -710,6 +721,7 @@ pub const Pardes = struct {
const gpa = p.gpa;
if (p.yank) |y| gpa.free(y);
if (p.dump_out) |d| gpa.free(d);
+ if (p.last_dump) |d| gpa.free(d);
if (p.chord_arg) |a| gpa.free(a);
p.effects.deinit(gpa);
p.scratch.deinit();
@@ -717,6 +729,29 @@ pub const Pardes = struct {
gpa.destroy(p);
}
+ /// shell feedback after write_dump: remember where the dump landed (the
+ /// topbar grows a `Restore <path>` word pointing at it)
+ pub fn setLastDump(p: *Pardes, path: []const u8) void {
+ const copy = p.gpa.dupe(u8, path) catch return;
+ if (p.last_dump) |old| p.gpa.free(old);
+ p.last_dump = copy;
+ }
+
+ /// the shell polls this each frame: a pending Restore's dump path, or null
+ pub fn takeRestore(p: *Pardes) ?[]const u8 {
+ const r = p.restore_req;
+ p.restore_req = null;
+ return r;
+ }
+
+ /// the topbar line: the fixed builtins, plus `Restore <path>` once a dump
+ /// exists — render and click dispatch must agree on this exact string
+ fn topbar(p: *Pardes, buf: []u8) []const u8 {
+ if (p.last_dump) |d|
+ return std.fmt.bufPrint(buf, topbar_str ++ " Restore {s}", .{d}) catch topbar_str;
+ return topbar_str;
+ }
+
fn deinitPane(p: *Pardes, pane: *Pane) void {
if (pane.image) |*iv| {
p.gpa.free(iv.path);
@@ -2374,7 +2409,8 @@ pub const Pardes = struct {
// global tagbar: MIDDLE runs the command under the click
// (a stray left click must never Kill)
if (m.button == .middle) {
- const word = wordAtCol(topbar_str, mcol);
+ var tb_buf: [1200]u8 = undefined;
+ const word = wordAtCol(p.topbar(&tb_buf), mcol);
if (word.len > 0) p.actOnSelection(.middle, p.active, word, null);
}
return;
@@ -2870,6 +2906,14 @@ pub const Pardes = struct {
.colors => p.colors_on = !p.colors_on,
.nextcolor => p.theme_idx = (p.theme_idx + 1) % themes.len,
.dump => p.dumpState() catch {},
+ .restore => {
+ var it = std.mem.tokenizeAny(u8, std.mem.trim(u8, txt, " \t\r\n"), " \t");
+ _ = it.next(); // the word "Restore"
+ const path = it.next() orelse (p.last_dump orelse return);
+ if (path.len > p.restore_buf.len) return;
+ @memcpy(p.restore_buf[0..path.len], path);
+ p.restore_req = p.restore_buf[0..path.len];
+ },
.kill => {
p.quit = true;
p.emit(.quit);
@@ -3335,7 +3379,8 @@ pub const Pardes = struct {
// global tagbar: full width, top row
s.fill(0, 0, s.cols, TOPBAR_H, .{ .bg = .{ .rgb = th.tag_bg } });
- _ = s.print(0, 0, s.cols, topbar_str, .{ .fg = .{ .rgb = th.tag_fg }, .bg = .{ .rgb = th.tag_bg } });
+ var tb_buf: [1200]u8 = undefined;
+ _ = s.print(0, 0, s.cols, p.topbar(&tb_buf), .{ .fg = .{ .rgb = th.tag_fg }, .bg = .{ .rgb = th.tag_bg } });
// resize-handle hint / drag previews: a dash overlay that keeps the
// underlying colors (border drags + hover), or the move indicator
diff --git a/src/tty.zig b/src/tty.zig
index 97f10a7a..c62ace3d 100644
--- a/src/tty.zig
+++ b/src/tty.zig
@@ -68,7 +68,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
pardes.image.start(io, gpa);
defer pardes.image.stop();
- const core = if (opts.load_path) |lp| blk: {
+ var core = if (opts.load_path) |lp| blk: {
const bytes = try look.readFile(gpa, lp);
defer gpa.free(bytes);
break :blk try pardes.Pardes.initFromDump(gpa, opts, bytes);
@@ -193,6 +193,27 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void {
drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, true);
if (core.quit) break;
+ // Restore builtin: swap in a core rebuilt from the dump; the live
+ // shells die with their masters (readers canceled, gens bumped so
+ // their late eofs never touch the replay panes)
+ if (core.takeRestore()) |rp| blk: {
+ const bytes = look.readFile(gpa, rp) catch break :blk;
+ defer gpa.free(bytes);
+ var o = core.opts;
+ o.cols = core.screen_w;
+ o.rows = core.screen_h; // pre-size: dump panes never greet
+ const nc = pardes.Pardes.initFromDump(gpa, o, bytes) catch break :blk;
+ for (&ptys, 0..) |*slot, pid| if (slot.*) |*pt| {
+ pt.reader.cancel(io) catch {};
+ _ = libc.close(pt.file.handle);
+ slot.* = null;
+ gens[pid] +%= 1;
+ };
+ kitty_handles = @splat(null);
+ core.deinit();
+ core = nc;
+ }
+
// live cwd for tags/look: cheap per-pane lookup, per frame
for (&ptys, 0..) |*slot, id| if (slot.*) |pt| {
var lbuf: [1024]u8 = undefined;
@@ -310,10 +331,13 @@ fn drainEffects(
},
.write_dump => {
const out = core.dump_out orelse continue;
- const fd = libc.open(pardes.dump.default_path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
+ var pbuf: [1024:0]u8 = undefined;
+ const path = pardes.dump.outPath(&pbuf) orelse continue;
+ const fd = libc.open(path, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
if (fd < 0) continue;
writeFd(fd, out);
_ = libc.close(fd);
+ core.setLastDump(path);
},
.set_clipboard => {
// mirror the core's yank register out via OSC 52