diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-06 20:43:40 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:07 -0300 |
| commit | 513938c1cfb216a3bc4a3559cf7731b8e44a952f (patch) | |
| tree | 7b711c30ea6ebc255a1d691223b2cb93cd7dedf2 | |
| parent | d0ba070a87c54c538e5f788485391b0af927d0fb (diff) | |
| download | pardes-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.
| -rw-r--r-- | next-steps.txt | 2 | ||||
| -rw-r--r-- | src/dump.zig | 31 | ||||
| -rw-r--r-- | src/gui.zig | 25 | ||||
| -rw-r--r-- | src/pardes.zig | 55 | ||||
| -rw-r--r-- | src/tty.zig | 28 | ||||
| -rw-r--r-- | test/snapshot.zig | 4 | ||||
| -rw-r--r-- | test/snapshots/restore.golden | 62 | ||||
| -rw-r--r-- | test/snapshots/restore.snap | 25 |
8 files changed, 221 insertions, 11 deletions
diff --git a/next-steps.txt b/next-steps.txt index 09a48f08..ef7b06a2 100644 --- a/next-steps.txt +++ b/next-steps.txt @@ -9,7 +9,7 @@ - the cursor's line number in the gutter gets a different bg/fg (tagline style) - opening a file at a specific line centers the line if possible - keyboard movement implements scroll_offset (vim/helix scrolloff) -- generalize n/N: look the next/prev tty line that yields a look result (search becomes a special case) +- REWORK n/N on tty: n/N only MOVE the selection between lookable whitespace-tokens (multiple per line, e.g. ls output: AAAA.txt -> BBBB.txt), opening nothing; Return looks the selected token (dir -> listed terminal, file -> pane); navigation continues with n/N+Return in what opened - dumps go to a per-user data dir (zig stdlib app-data path), not /tmp, with timestamped filenames so they never overwrite; new Restore builtin loads a dump file (first argument) - horizontal scroll: no scrollbar, just horizontal mouse wheel + modal movements (scrolloff applies); minimal, goal is only to read the whole text - cursor looks different in insert vs normal mode, in all pane types (tty and file); the tagline is always insert mode (normal makes no sense there) 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 diff --git a/test/snapshot.zig b/test/snapshot.zig index 10aa0c86..54ba0271 100644 --- a/test/snapshot.zig +++ b/test/snapshot.zig @@ -130,6 +130,10 @@ fn runScript(arena: std.mem.Allocator, exe_z: [:0]const u8, script_path: []const _ = setenv("HOME", try arena.dupeZ(u8, home), 1); _ = setenv("TERM", "xterm-256color", 1); _ = setenv("LC_ALL", "C", 1); + // pin the dump path: live runs use a timestamped file under the user data + // dir (dump.outPath), which would make the dump/load/restore goldens + // nondeterministic + _ = setenv("PARDES_DUMP", "/tmp/pardes-dump.zon", 1); if (chdirRc(try arena.dupeZ(u8, work)) < 0) return error.ChdirFailed; var out: std.ArrayList(u8) = .empty; diff --git a/test/snapshots/restore.golden b/test/snapshots/restore.golden new file mode 100644 index 00000000..6b8c4d7e --- /dev/null +++ b/test/snapshots/restore.golden @@ -0,0 +1,62 @@ +== snap dumped grid=100x30 cursor=4,5 +|Kill Newcol Tutor Debug Colors NextColor Dump Restore /tmp/pardes-dump.zon +| TTY /tmp/pardes-snap/restore/cwd Del +| $ ls +| $ echo KEEP''ME +| KEEPME +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +== snap restored grid=100x30 cursor=4,5 +|Kill Newcol Tutor Debug Colors NextColor Dump +| TTY /tmp/pardes-snap/restore/cwd Del +| $ ls +| $ echo KEEP''ME +| KEEPME +| $ +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| +| diff --git a/test/snapshots/restore.snap b/test/snapshots/restore.snap new file mode 100644 index 00000000..8a677877 --- /dev/null +++ b/test/snapshots/restore.snap @@ -0,0 +1,25 @@ +# Dump writes to the resolved dump path ($PARDES_DUMP here; a timestamped file +# under ~/.local/share/pardes in live runs) and the topbar grows Restore <path>; +# middle-click Restore loads that dump back into the live session: output on +# screen at Dump time survives (pty history), later output does not +start 30 100 +wait 8000 Kill Newcol +stable 700 20000 +key c-b +stable 600 8000 +text echo KEEP''ME +key enter +wait 8000 KEEPME +stable 700 10000 +press middle 42 1 +release middle 42 1 +stable 700 10000 +snap dumped +text echo GONE''SOON +key enter +wait 8000 GONESOON +stable 700 10000 +press middle 48 1 +release middle 48 1 +stable 900 15000 +snap restored |
