From e3e698fd14c9c7eb6906c95beeeb8e12f08f8b94 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 07:09:14 -0300 Subject: A refusal over unsaved panes logs each as `unsaved `, and its err never cuts a list Exit's refusal joined every name into one err, cut at the record's cap in mid-name, so a script could not tell which panes held it up. Each pane is now an `unsaved` record before the err, and the err names the one pane or counts them: `4 unsaved panes: Modified (Exit again to discard)`. Restore, Del and Delcol refuse the same way. Co-Authored-By: Claude Opus 5.5 --- .agents/skills/pardes-9p/SKILL.md | 5 ++-- docs/fs.md | 15 +++++++---- src/builtins.zig | 53 +++++++++++++++++++++++---------------- src/ninep/events.zig | 4 ++- 4 files changed, 48 insertions(+), 29 deletions(-) diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index f9d9e34d..e88f7652 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -80,8 +80,9 @@ $m/ctl the settings, one a line as a write takes them; write a setting or (Newcol makes an empty column, Dump, Theme x; Exit QUITS the editor, Kill [word...] stops the commands pardes started (command panes, lines it typed into shells), a word matching a command line's first word; Exit and Restore refuse once, - naming every unsaved pane, `, : Modified (Exit again to discard)` (Restore's says - `Restore again`); a second refusal names only the panes edited since the last, + logging `unsaved ` for each unsaved pane, then failing the write with + `: Modified (Exit again to discard)` for one or `4 unsaved panes: Modified (Exit + again to discard)` for more (Restore's says `Restore again`); a second refusal names only the panes edited since the last, as acme's does, and the same word again with nothing edited since DISCARDS them all -- not a retry, unlike lock's `file in use`; Kill stops a command pane's whole line, `&` jobs included; of a line typed into a shell only the foreground job, and the shell decides the rest (of `sleep 30; echo done` diff --git a/docs/fs.md b/docs/fs.md index ffee9124..49f4e4f1 100644 --- a/docs/fs.md +++ b/docs/fs.md @@ -165,10 +165,14 @@ Each builtin declares its scope in src/builtins.zig (`scope = .session`; every setting is one, the rest act on a pane). `/ctl` takes the session's 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, a `+New` scratch of 100 bytes or more too, in one line, -`, : Modified (Exit again to discard)`, whether it came -through a `ctl` write or a click; an `Exit` after more editing refuses +which quits the editor as acme's does (it refuses once over the panes with +unsaved text, a `+New` scratch of 100 bytes or more too, whether it came +through a `ctl` write or a click: first one `/log` record per pane, +`unsaved `, then the write fails with one line that is +never a list cut short, `: Modified (Exit again to discard)` for one +pane, `4 unsaved panes: Modified (Exit again to discard)` for more, and +the `err` record says the same; each pane's message row names it. Restore, +Del and Delcol refuse the same way, with their own word; an `Exit` after more editing refuses again naming only the panes edited since the last refusal, as acme's does, and an `Exit` with nothing edited since quits, throwing all of it away; a scratch or a command's output under 100 bytes is not asked about, as acme's winclean @@ -710,7 +714,8 @@ scratch, opens its prompt for a path, answered `answer ` or `answer `answer ` when it is answered, by key or ctl, `-` for taken back or for its pane closing with the question standing, `changed ` when a pane's file changed on disk under its unsaved -edits (below), and `save `, +edits (below), `unsaved ` for each pane an Exit, Restore, +Del or Delcol refuses over (before that write's `err`), and `save `, `dump ` when a Dump is written and `restore ` in a Restore's new log after its panes' `new`s (a relative Restore path is looked for in `DumpDir`, then in the directory pardes started in; a bare Restore takes the diff --git a/src/builtins.zig b/src/builtins.zig index 13f04859..404e78f4 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -261,10 +261,12 @@ test "Exit over a ctl write or a click names every pane it asks about, a second defer p.fs.no_prompt = false; try std.testing.expect(p.executeBuiltinLine(p.active, "Exit")); try std.testing.expect(!p.quit); - const said = p.fs.failure[0..p.fs.failure_len]; - try std.testing.expect(std.mem.indexOf(u8, said, first.file.?.path) != null); - try std.testing.expect(std.mem.indexOf(u8, said, second.file.?.path) != null); - try std.testing.expect(std.mem.endsWith(u8, said, ": Modified (Exit again to discard)")); + // Two: counted, never a list cut short; `unsaved` records name them. + try std.testing.expectEqualStrings("2 unsaved panes: Modified (Exit again to discard)", p.fs.failure[0..p.fs.failure_len]); + const log = p.fs.log.buf.items; + var rec: [64]u8 = undefined; + for ([_]*const Pane{ first, second }) |pane| + try std.testing.expect(std.mem.indexOf(u8, log, try std.fmt.bufPrint(&rec, "unsaved {d} ", .{pane.serial})) != null); // Edited since: only that one is named again (acme's rule). p.fs.failure_len = 0; panes.File.setContent(p, &second.file.?, try std.testing.allocator.dupe(u8, "y" ** 120)); @@ -474,11 +476,12 @@ fn warnModified(c: Ctx, asking: Pane.Discarding) bool { const Asked = union(enum) { all, pane: usize, column: usize }; fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool { - var modified = false; - // Every pane asked about is named in the one refusal a ctl write gets, - // as acme's rowclean names them all (wind.c:511). - var all_buf: [1024]u8 = undefined; - var all = std.Io.Writer.fixed(&all_buf); + // Every pane asked about is an `unsaved ` record, as + // acme's rowclean names them all (wind.c:511); the refusal a write + // gets names the one, or counts them, never a list cut short. + var count: usize = 0; + var one_buf: [limits.host_path_cap + 24]u8 = undefined; + var one: []const u8 = ""; for (c.p.panes, 0..) |slot, id| { const pane = slot orelse continue; switch (which) { @@ -506,17 +509,22 @@ fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool { var name_buf: [limits.host_path_cap + 24]u8 = undefined; const name = if (scratch) std.fmt.bufPrint(&name_buf, "{s} (pane {d})", .{ f.path, pane.serial }) catch f.path else f.path; var buf: [limits.host_path_cap + 72]u8 = undefined; + pardes.ctlfs.events.noteLog(c.p, .unsaved, pane); c.p.reportFailure(id, std.fmt.bufPrint(&buf, "{s}: Modified ({s} again to discard)", .{ name, @tagName(asking) }) catch name); - all.print("{s}{s}", .{ if (modified) ", " else "", name }) catch {}; - modified = true; + if (count == 0) one = std.fmt.bufPrint(&one_buf, "{s}", .{name}) catch name; + count += 1; } - if (modified and (c.p.fs.no_prompt or c.p.fs.capturing)) { - all.print(": Modified ({s} again to discard)", .{@tagName(asking)}) catch {}; - const said = @import("Messages.zig").clip(all.buffered(), c.p.fs.failure.len); - @memcpy(c.p.fs.failure[0..said.len], said); - c.p.fs.failure_len = @intCast(said.len); + if (count > 0 and (c.p.fs.no_prompt or c.p.fs.capturing)) { + var said_buf: [limits.host_path_cap + 96]u8 = undefined; + const said = (if (count == 1) + std.fmt.bufPrint(&said_buf, "{s}: Modified ({s} again to discard)", .{ one, @tagName(asking) }) + else + std.fmt.bufPrint(&said_buf, "{d} unsaved panes: Modified ({s} again to discard)", .{ count, @tagName(asking) })) catch "unsaved panes: Modified"; + const kept = @import("Messages.zig").clip(said, c.p.fs.failure.len); + @memcpy(c.p.fs.failure[0..kept.len], kept); + c.p.fs.failure_len = @intCast(kept.len); } - return modified; + return count > 0; } /// Quit the editor, refusing once while a pane holds unsaved text: an Exit @@ -1884,14 +1892,17 @@ test "Exit and Restore name a modified +New scratch as well as files" { defer p.fs.no_prompt = false; try std.testing.expect(p.executeBuiltinLine(p.active, "Exit")); try std.testing.expect(!p.quit); - const said = p.fs.failure[0..p.fs.failure_len]; - try std.testing.expect(std.mem.indexOf(u8, said, "test.txt") != null); + // Each pane's message row names it; the write's refusal counts them. + try std.testing.expectEqualStrings("2 unsaved panes: Modified (Exit again to discard)", p.fs.failure[0..p.fs.failure_len]); + try std.testing.expect(std.mem.indexOf(u8, file.msg[0..file.msg_len], "test.txt") != null); + const scratch_pane = p.panes[p.active].?; var named: [64]u8 = undefined; - try std.testing.expect(std.mem.indexOf(u8, said, try std.fmt.bufPrint(&named, "{s} (pane {d})", .{ config.scratch_buffer, p.panes[p.active].?.serial })) != null); + try std.testing.expect(std.mem.indexOf(u8, scratch_pane.msg[0..scratch_pane.msg_len], try std.fmt.bufPrint(&named, "{s} (pane {d})", .{ config.scratch_buffer, scratch_pane.serial })) != null); // Delcol, Restore and Exit name it the same way. p.fs.failure_len = 0; try std.testing.expect(p.executeBuiltinLine(p.active, "Delcol")); - try std.testing.expect(std.mem.indexOf(u8, p.fs.failure[0..p.fs.failure_len], named[0 .. config.scratch_buffer.len + 7]) != null); + try std.testing.expect(std.mem.indexOf(u8, scratch_pane.msg[0..scratch_pane.msg_len], "(Delcol again") != null); + try std.testing.expect(std.mem.indexOf(u8, scratch_pane.msg[0..scratch_pane.msg_len], named[0 .. config.scratch_buffer.len + 7]) != null); } test "Del and Delcol refuse a pane with unsaved text once, as acme's do, and close on the second" { diff --git a/src/ninep/events.zig b/src/ninep/events.zig index 44ad560e..fb0141f4 100644 --- a/src/ninep/events.zig +++ b/src/ninep/events.zig @@ -110,7 +110,9 @@ pub fn readQueue(p: *Pardes, req: Req, q: *Queue) Reply { // ---- the editor-wide log ---- -pub const LogKind = enum { new, del, rename, save }; +/// `unsaved`: a pane a refusal (Exit, Restore, Del, Delcol) is about, +/// logged before the write's err so a script reads which, whole. +pub const LogKind = enum { new, del, rename, save, unsaved }; /// A pane leaving, before its state goes with it; one never announced /// leaves silently. -- cgit v1.3