diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-29 10:44:30 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:16 -0300 |
| commit | d5743f2a58a03539d3c34bee3b2ad18b96b2da55 (patch) | |
| tree | e2726a5605b4f42dae3de00c75d13d327c27b40d | |
| parent | 5fd713889b6bdeb27c58941b77266c9336bf7b7d (diff) | |
| download | pardes-d5743f2a58a03539d3c34bee3b2ad18b96b2da55.tar.gz pardes-d5743f2a58a03539d3c34bee3b2ad18b96b2da55.zip | |
A refusal over unsaved panes keeps its list in +Unsaved and shows a short notice
Exit's refusal (and Restore's, Del's, Delcol's, get's) put its full text
on a notice that went: a long list of names, gone before it was read. The
list now goes into one +Unsaved pane, filled again by each refusal, a row
a pane (`<name>: Modified`) and the `Exit again to discard` line; the
notice is short, `3 unsaved panes — Exit again to discard`. The 9P
`unsaved` records and the write's err are as they were.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
| -rw-r--r-- | .agents/skills/pardes-9p/SKILL.md | 3 | ||||
| -rw-r--r-- | docs/fs.md | 11 | ||||
| -rw-r--r-- | docs/tags.md | 5 | ||||
| -rw-r--r-- | src/Output.zig | 29 | ||||
| -rw-r--r-- | src/builtins.zig | 55 | ||||
| -rw-r--r-- | src/config.zig | 1 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 13 |
7 files changed, 92 insertions, 25 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index 4678d55a..1fe2aed8 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -86,7 +86,8 @@ $m/ctl the settings, one a line as a write takes them; write a setting or matching a command line's first word; Exit and Restore refuse once, logging `unsaved <serial> <name>` for each unsaved pane, then failing the write with `<name>: 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, + again to discard)` for more (Restore's says `Restore again`); on screen the list stays + in the one +Unsaved pane under a short notice (`3 unsaved panes — Exit again to discard`); 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` @@ -193,9 +193,11 @@ through a `ctl` write or a click: first one `/log` record per pane, `unsaved <serial> <name>`, then the write fails with one line that is never a list cut short, `<name>: 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, a -`+New` scratch with its serial, as scratches share a name: `/dir/+New -(pane 12): Modified (Exit again to discard)`. Restore, +the `err` record says the same. On screen the notice is short, `3 unsaved +panes — Exit again to discard`, and the whole list stays in one `+Unsaved` +pane, filled again by each refusal: a row a pane, `<name>: Modified` (a +`+New` scratch with its serial, as scratches share a name, `/dir/+New +(pane 12): Modified`), then `Exit again to discard`. 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 @@ -580,7 +582,8 @@ length, a reserved zero, the dirty flag, the width in cells, the font and the tab width — followed by rio's `current` or `notcurrent` (rio(4), `wctl`): whether the pane has the keyboard. It takes the pane's builtins (below), `get`, which reloads the buffer from the name it -carries (unsaved edits are refused once, `<name>: Modified (get again to +carries (unsaved edits are refused once, listed in `+Unsaved` with a short +notice as Exit's are, the write failing `<name>: Modified (get again to discard)`, as acme's get asks winclean, exec.c:513). A file that changes on disk reloads by itself only into a buffer with no unsaved edits; one with them keeps its text and stays dirty, says `<name> changed on disk (get diff --git a/docs/tags.md b/docs/tags.md index 018536e6..d79c64b5 100644 --- a/docs/tags.md +++ b/docs/tags.md @@ -86,8 +86,9 @@ to its `ctl`, they step the body back and forward through its edits, as the `Del` closes a pane and gives its rows to one neighbor; the rest of the column keeps its heights. A pane with unsaved text is refused once, as -acme's Del warns (exec.c del, wind.c winclean): `<name>: Modified (Del again -to discard)`, and the same `Del` again, nothing edited since, closes it and +acme's Del warns (exec.c del, wind.c winclean): a short notice, `1 unsaved +pane — Del again to discard`, the pane listed in a kept `+Unsaved` (`<name>: +Modified`, then `Del again to discard`), and the same `Del` again, nothing edited since, closes it and throws the text away; `Delcol` refuses a column holding such a pane the same way (exec.c delcol). Each word warns on its own, from a key, a tag, `exec` or a ctl, where the refusal fails the write with EIO. `Del k` (or `DelAbove`) gives them to the nearest diff --git a/src/Output.zig b/src/Output.zig index a75718bf..c6012d06 100644 --- a/src/Output.zig +++ b/src/Output.zig @@ -29,6 +29,9 @@ pub const Origin = union(enum) { search, errors, pdf_links, + /// The panes a refusal (Exit, Restore, Del, Delcol, get) is over, kept + /// in +Unsaved rather than a notice that goes. + unsaved, }; pub const max_arg = dump.max_origin_arg; @@ -183,6 +186,7 @@ pub fn traits(o: Origin) Traits { .search => .{ .name = config.search_buffer, .steps = true, .locations = true }, .errors => .{ .name = config.errors_buffer, .doc = true }, .pdf_links => .{ .name = config.pdf_links_buffer, .steps = true, .locations = true }, + .unsaved => .{ .name = config.unsaved_buffer, .doc = true }, .cmd => |b| builtins.registry.outputTraits(b) orelse unreachable, .query => |k| switch (k) { .hover => .{ .name = config.hover_buffer }, @@ -242,6 +246,7 @@ pub fn word(o: Origin) []const u8 { .search => "/", .errors => config.errors_buffer, .pdf_links => config.pdf_links_buffer, + .unsaved => config.unsaved_buffer, }; } @@ -250,6 +255,7 @@ pub fn fromWord(w: []const u8) ?Origin { if (std.mem.eql(u8, w, "/")) return .search; if (std.mem.eql(u8, w, config.errors_buffer)) return .errors; if (std.mem.eql(u8, w, config.pdf_links_buffer)) return .pdf_links; + if (std.mem.eql(u8, w, config.unsaved_buffer)) return .unsaved; // A dump from before the rename names them by their old words. if (std.mem.eql(u8, w, "ThemeSel")) return .{ .cmd = .Themes }; if (builtins.capabilities.font_picker) if (std.mem.eql(u8, w, "FontSel")) return .{ .cmd = .Fonts }; @@ -315,7 +321,7 @@ fn sortResults(arena: std.mem.Allocator, from: Origin, content: []u8, anchor: ?u => {}, else => return anchor, }, - .errors, .pdf_links => return anchor, + .errors, .pdf_links, .unsaved => return anchor, } if (content.len == 0) return anchor; const path_only = std.meta.eql(from, Origin{ .cmd = .Find }); @@ -559,6 +565,27 @@ pub fn recentText(p: *Pardes, w: *std.Io.Writer, look_rows: bool) !void { } } +/// A refusal's list of unsaved panes, kept in the one +Unsaved: filled +/// again when it is there (as +Search is), else opened below `id`. Best +/// effort: with no room for it, the notice and the log still say it. +pub fn openUnsaved(p: *Pardes, id: usize, content: []u8) void { + for (p.panes) |slot| { + const rp = slot orelse continue; + const rf = if (rp.file) |*f| f else continue; + const o = rf.output orelse continue; + if (o.from != .unsaved) continue; + File.setContent(p, rf, content); + resetBody(p, rp); + return; + } + const pane = p.panes[id] orelse return p.gpa.free(content); + const dir = if (pane.file) |f| (std.fs.path.dirname(f.path) orelse "/") else pane.cwdSlice(); + const free = p.freeSlot() orelse return p.gpa.free(content); + _ = open(p, free, dir, .unsaved, "", content) catch return; + exec.placeNew(p, id, id, free, .doc); + p.active = id; +} + /// Recent: the one +Recent, filled again when it is there (as Find and /// Grep's +Search is), else opened below the asking pane. pub fn openRecent(p: *Pardes, id: usize) !void { diff --git a/src/builtins.zig b/src/builtins.zig index 041eb1b8..9025da6d 100644 --- a/src/builtins.zig +++ b/src/builtins.zig @@ -238,7 +238,7 @@ test "Exit asks once about unsaved text, and quits when asked again" { pane.file.?.saved_hash = 0; // edited: what it had is not what it has try std.testing.expect(p.executeBuiltinLine(p.active, "Exit")); try std.testing.expect(!p.quit); - try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified (Exit again to discard)")); + try std.testing.expect(std.mem.endsWith(u8, unsavedList(p), ": Modified\n\nExit again to discard\n")); // Told once, and nothing edited since: the second Exit quits. try std.testing.expect(p.executeBuiltinLine(p.active, "Exit")); try std.testing.expect(p.quit); @@ -267,6 +267,14 @@ test "Exit over a ctl write or a click names every pane it asks about, a second 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); + // The whole list stays in +Unsaved; the notice is short. + try std.testing.expect(std.mem.indexOf(u8, unsavedList(p), first.file.?.path) != null); + try std.testing.expect(std.mem.endsWith(u8, unsavedList(p), "\nExit again to discard\n")); + var noticed = false; + for (p.panes) |slot| if (slot) |pane| { + noticed = noticed or std.mem.eql(u8, pane.msg[0..pane.msg_len], "2 unsaved panes \u{2014} Exit again to discard"); + }; + try std.testing.expect(noticed); // 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)); @@ -274,6 +282,13 @@ test "Exit over a ctl write or a click names every pane it asks about, a second try std.testing.expect(!p.quit); const again = p.fs.failure[0..p.fs.failure_len]; try std.testing.expect(std.mem.indexOf(u8, again, first.file.?.path) == null); + // The one +Unsaved, filled again. + var kept: usize = 0; + for (p.panes) |slot| if (slot) |pane| if (pane.file) |f| if (f.output) |o| { + kept += @intFromBool(o.from == .unsaved); + }; + try std.testing.expectEqual(@as(usize, 1), kept); + try std.testing.expect(std.mem.indexOf(u8, unsavedList(p), first.file.?.path) == null); try std.testing.expect(std.mem.startsWith(u8, again, second.file.?.path)); p.fs.failure_len = 0; try std.testing.expect(p.executeBuiltinLine(p.active, "Exit")); @@ -281,6 +296,12 @@ test "Exit over a ctl write or a click names every pane it asks about, a second } } +/// What +Unsaved holds: the list a refusal over unsaved panes keeps. +fn unsavedList(p: *Pardes) []const u8 { + for (p.panes) |slot| if (slot) |pane| if (pane.file) |f| if (f.output) |o| if (o.from == .unsaved) return f.content; + return ""; +} + /// A dump file that exists, for a Restore to get past reading it. fn testDump(buf: []u8) ![]const u8 { var tmp = std.testing.tmpDir(.{}); @@ -304,7 +325,7 @@ test "Restore asks about unsaved text as Exit does, and restores when asked agai const line = try testDump(&line_buf); try std.testing.expect(p.executeBuiltinLine(p.active, line)); try std.testing.expect(p.restore_req == null); - try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified (Restore again to discard)")); + try std.testing.expect(std.mem.endsWith(u8, unsavedList(p), ": Modified\n\nRestore again to discard\n")); try std.testing.expect(p.executeBuiltinLine(p.active, line)); try std.testing.expectEqualStrings(line["Restore ".len..], p.restore_req.?); } @@ -336,7 +357,7 @@ test "a Restore's warning is not an Exit's: each word is warned on its own" { // The Restore warned; an Exit after it has not been, and asks. try std.testing.expect(p.executeBuiltinLine(p.active, "Exit")); try std.testing.expect(!p.quit); - try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified (Exit again to discard)")); + try std.testing.expect(std.mem.endsWith(u8, unsavedList(p), ": Modified\n\nExit again to discard\n")); try std.testing.expect(p.executeBuiltinLine(p.active, "Exit")); try std.testing.expect(p.quit); } @@ -460,7 +481,9 @@ pub const Exec = struct { /// 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, `<name>: Modified (Exit again to discard)`, +/// been told about is listed in +Unsaved, `<name>: Modified`, under it +/// `Exit again to discard`, with a short notice, `3 unsaved panes -- Exit +/// again to discard`, /// naming the word asking, since repeating it throws the text away, and is /// then clean for the asking, as winclean clears `dirty`; so the same word /// again, with nothing edited since, goes ahead. Only the same word: a @@ -482,6 +505,9 @@ fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool { var count: usize = 0; var one_buf: [limits.host_path_cap + 24]u8 = undefined; var one: []const u8 = ""; + // The whole list goes into +Unsaved, where it stays; the notice is short. + var listed: std.ArrayList(u8) = .empty; + const arena = c.p.scratch.allocator(); for (c.p.panes, 0..) |slot, id| { const pane = slot orelse continue; switch (which) { @@ -508,12 +534,17 @@ fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool { // Del, Delcol). 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); + listed.print(arena, "{s}: Modified\n", .{name}) catch {}; if (count == 0) one = std.fmt.bufPrint(&one_buf, "{s}", .{name}) catch name; count += 1; } + if (count > 0) { + listed.print(arena, "\n{s} again to discard\n", .{@tagName(asking)}) catch {}; + if (c.p.gpa.dupe(u8, listed.items)) |content| panes.Output.openUnsaved(c.p, c.id, content) else |_| {} + var short: [96]u8 = undefined; + c.p.reportFailure(c.id, std.fmt.bufPrint(&short, "{d} unsaved pane{s} \u{2014} {s} again to discard", .{ count, if (count == 1) "" else "s", @tagName(asking) }) catch "unsaved panes"); + } 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) @@ -1934,15 +1965,15 @@ test "Exit and Restore name a modified +New scratch as well as files" { try std.testing.expect(!p.quit); // 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); + try std.testing.expect(std.mem.indexOf(u8, unsavedList(p), "test.txt: Modified") != null); const scratch_pane = p.panes[p.active].?; var named: [64]u8 = undefined; - 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); + try std.testing.expect(std.mem.indexOf(u8, unsavedList(p), 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, 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); + try std.testing.expect(std.mem.indexOf(u8, unsavedList(p), "Delcol again to discard") != null); + try std.testing.expect(std.mem.indexOf(u8, unsavedList(p), 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" { @@ -1954,11 +1985,11 @@ test "Del and Delcol refuse a pane with unsaved text once, as acme's do, and clo const serial = pane.serial; try std.testing.expect(p.executeBuiltinLine(p.active, "Delcol")); try std.testing.expect(p.paneBySerial(serial) != null); - try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified (Delcol again to discard)")); + try std.testing.expect(std.mem.endsWith(u8, unsavedList(p), ": Modified\n\nDelcol again to discard\n")); // Each word is warned on its own: Del asks too, then closes. try std.testing.expect(p.executeBuiltinLine(p.active, "Del")); try std.testing.expect(p.paneBySerial(serial) != null); - try std.testing.expect(std.mem.endsWith(u8, pane.msg[0..pane.msg_len], ": Modified (Del again to discard)")); + try std.testing.expect(std.mem.endsWith(u8, unsavedList(p), ": Modified\n\nDel again to discard\n")); try std.testing.expect(p.executeBuiltinLine(p.active, "Del")); try std.testing.expect(p.paneBySerial(serial) == null); } diff --git a/src/config.zig b/src/config.zig index 2b5bf280..e96d73be 100644 --- a/src/config.zig +++ b/src/config.zig @@ -450,6 +450,7 @@ pub const config_buffer = "+Config"; pub const effect_code_buffer = "+EffectCode"; pub const jumps_buffer = "+Jumps"; pub const recent_buffer = "+Recent"; +pub const unsaved_buffer = "+Unsaved"; pub const themes_buffer = "+Themes"; pub const fonts_buffer = "+Fonts"; pub const pdf_sections_buffer = "+PdfSections"; diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 18a7eaa9..477a91fe 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -861,12 +861,15 @@ fn getRefused(p: *Pardes, pane: *Pane) ?[]const u8 { if (std.hash.Wyhash.hash(0, f.content) == saved) return null; if (pane.discard_warned) |w| if (w.revision == f.revision and w.by == .get) return null; pane.discard_warned = .{ .revision = f.revision, .by = .get }; - const said = std.fmt.bufPrint(&p.fs.ename, "{s}: Modified (get again to discard)", .{f.path}) catch "Modified (get again to discard)"; - // Shown; the write's err record logs it, not a msg too. + const id = p.paneBySerial(pane.serial).?; + // Kept in +Unsaved, as Exit's refusal is; the notice is short. The + // write's err record logs it, not a msg too. + const listed = std.fmt.allocPrint(p.gpa, "{s}: Modified\n\nget again to discard\n", .{f.path}) catch null; + if (listed) |content| panes.Output.openUnsaved(p, id, content); p.fs.unlogged = true; - defer p.fs.unlogged = false; - p.setMessage(p.paneBySerial(pane.serial).?, said); - return said; + p.setMessage(id, "1 unsaved pane \u{2014} get again to discard"); + p.fs.unlogged = false; + return std.fmt.bufPrint(&p.fs.ename, "{s}: Modified (get again to discard)", .{f.path}) catch "Modified (get again to discard)"; } fn get(p: *Pardes, pane: *Pane, failed: *anyerror) u16 { |
