summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 10:44:30 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commitd5743f2a58a03539d3c34bee3b2ad18b96b2da55 (patch)
treee2726a5605b4f42dae3de00c75d13d327c27b40d /src
parent5fd713889b6bdeb27c58941b77266c9336bf7b7d (diff)
downloadpardes-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]>
Diffstat (limited to 'src')
-rw-r--r--src/Output.zig29
-rw-r--r--src/builtins.zig55
-rw-r--r--src/config.zig1
-rw-r--r--src/ninep/ctl.zig13
4 files changed, 80 insertions, 18 deletions
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 {