From 21d2240b9b86ae5ba0036988b0ed84fbcf18d3af Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 28 Sep 2026 12:24:04 -0300 Subject: Restore asks about unsaved text first, as Exit does Restore replaces every pane with a dump's, so a pane edited since its last save lost that text without a word, where Exit asks. acme's Load only adds a dump's windows and so never asks; Restore now asks what Exit asks (acme's rowclean and winclean, wind.c:511-529): each modified pane says so once, and Restore again with nothing edited since goes ahead. Exit and Restore share the check and its warned-at revision, as acme's winclean clears one dirty flag for any asker. Co-Authored-By: Claude Opus 5.5 --- docs/fs.md | 3 ++- src/builtins.zig | 56 +++++++++++++++++++++++++++++++++++++++----------------- src/panes.zig | 5 +++-- test/fs.py | 7 +++++++ 4 files changed, 51 insertions(+), 20 deletions(-) diff --git a/docs/fs.md b/docs/fs.md index 7d0e797c..2507ac93 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -106,7 +106,8 @@ builtins, one a line, at whichever pane has the keyboard as each runs -- `Newcol`, `Dump`, `Mount name dial`, `Theme ink`, `Verbose off`; `Exit`, which quits the editor as acme's does (it refuses once, naming each pane with unsaved text, `: Modified`, and a second `Exit` with nothing edited -since quits; a scratch under 100 bytes is not asked about); and `Kill`, which +since quits; a scratch under 100 bytes is not asked about; `Restore`, which +replaces every pane, asks the same first); and `Kill`, which does not quit but stops commands, as acme's does: bare, every command pardes started, and `Kill make ls`, those whose line begins with one of the words. A command pardes started is a line it typed into a terminal (a word written to diff --git a/src/builtins.zig b/src/builtins.zig index 93eae8ec..88c65704 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -226,6 +226,18 @@ test "Exit asks once about unsaved text, and quits when asked again" { try std.testing.expect(p.quit); } +test "Restore asks about unsaved text as Exit does, and restores when asked again" { + const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true }); + defer p.deinit(); + const pane = try p.setTestFile("saved\n"); + pane.file.?.saved_revision = pane.file.?.revision -% 1; // modified + try std.testing.expect(p.executeBuiltinLine(p.active, "Restore /tmp/some.dump.zon")); + try std.testing.expect(p.restore_req == null); + try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified")); + try std.testing.expect(p.executeBuiltinLine(p.active, "Restore /tmp/some.dump.zon")); + try std.testing.expectEqualStrings("/tmp/some.dump.zon", p.restore_req.?); +} + test "Kill stops the commands pardes typed, bare all and by name those that match, and never quits" { const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 80, .rows = 24 }); defer p.deinit(); @@ -340,26 +352,33 @@ pub const Exec = struct { // ---- session ---- -/// acme's Exit (editors/acme/exec.c, `exit` and rowclean): quit, unless a -/// pane holds unsaved text nobody has been told about. Each such pane says -/// so, `: Modified`, and the Exit goes no further; an Exit after that, -/// with nothing edited since, quits. A scratch of under 100 bytes is no -/// loss worth the question, as acme's winclean does not ask for one. +/// acme's rowclean (editors/acme/wind.c:511-529, winclean), asked before +/// what would throw unsaved text away: each pane holding some nobody has +/// been told about says so, `: Modified`, and is then clean for the +/// asking, as winclean clears `dirty`; so the same word again, with +/// nothing edited since, goes ahead. A scratch of under 100 bytes is no +/// loss worth the question, as winclean does not ask for one. +fn warnModified(c: Ctx) bool { + var modified = false; + for (c.p.panes, 0..) |slot, id| { + const pane = slot orelse continue; + const f = pane.file orelse continue; + if (f.revision == f.saved_revision or !panes.Output.fileTraits(f.output).saves) continue; + if (f.output != null and f.content.len < 100) continue; + if (pane.discard_warned == f.revision) continue; + pane.discard_warned = f.revision; + c.p.reportError(id, f.path, error.Modified); + modified = true; + } + return modified; +} + +/// acme's Exit (editors/acme/exec.c:892): quit, once warnModified has +/// nothing to say. pub const Exit = struct { pub const scope: Scope = .session; pub fn run(c: Ctx) void { - var modified = false; - for (c.p.panes, 0..) |slot, id| { - const pane = slot orelse continue; - const f = pane.file orelse continue; - if (f.revision == f.saved_revision or !panes.Output.fileTraits(f.output).saves) continue; - if (f.output != null and f.content.len < 100) continue; - if (pane.exit_warned == f.revision) continue; - pane.exit_warned = f.revision; - c.p.reportError(id, f.path, error.Modified); - modified = true; - } - if (modified) return; + if (warnModified(c)) return; c.p.quit = true; c.p.emit(.quit); } @@ -412,6 +431,9 @@ pub const Restore = struct { pub fn run(c: Ctx) void { const path = c.arg orelse (c.p.last_dump orelse return); if (path.len > c.p.restore_buf.len) return; + // acme's Load adds a dump's windows to the ones there; a Restore + // replaces them all, so it asks what Exit asks first. + if (warnModified(c)) return; @memcpy(c.p.restore_buf[0..path.len], path); c.p.restore_req = c.p.restore_buf[0..path.len]; } diff --git a/src/panes.zig b/src/panes.zig index 70b461eb..d4b336d7 100644 --- a/src/panes.zig +++ b/src/panes.zig @@ -99,8 +99,9 @@ pub const Pane = struct { /// middle click, a pty/run): its first word, which Kill matches, and /// the shell's command count its start (C mark) reaches. sent_command: ?struct { word: [64]u8 = undefined, len: u8 = 0, seq: u32 = 0 } = null, - /// The revision an Exit last warned this pane was modified at. - exit_warned: ?u32 = null, + /// The revision an Exit or a Restore last warned this pane was modified + /// at (builtins.zig warnModified). + discard_warned: ?u32 = null, /// Native host queues the Linux v9fs launcher in this pane's initial shell. v9fs_on_spawn: bool = false, file: ?File.State = null, diff --git a/test/fs.py b/test/fs.py index 2199a645..b90983d4 100644 --- a/test/fs.py +++ b/test/fs.py @@ -601,6 +601,13 @@ def test(binary, quic=False): assert old.read_fid(held_body) == b'dumped state\n' assert old.read_fid(held_screen, count=1) == b'{' old.write('/pane/1/body', b'changed after dump\n', truncate=True) + # Unsaved text is asked about once, as Exit asks; the second + # Restore goes ahead. + try: + execute(old, control, 'Restore ' + str(saved)) + except OSError as refused: + assert 'Modified' in str(refused), refused + assert old.read('/pane/1/body') == b'changed after dump\n' try: execute(old, control, 'Restore ' + str(saved)) except (EOFError, ConnectionResetError, BrokenPipeError): -- cgit v1.3