summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-24 23:00:52 -0300
committerGabriel Schneider <[email protected]>2026-08-25 09:42:07 -0300
commit28c70cabb6ceb7e5fecfd74f6984f5a995269f01 (patch)
tree3785f3dde630e9d33725f4e76a37ea6e8803c634 /src/pardes.zig
parent08eadb3b46dc70a297ff89308dd1cd26237785d6 (diff)
downloadpardes-28c70cabb6ceb7e5fecfd74f6984f5a995269f01.tar.gz
pardes-28c70cabb6ceb7e5fecfd74f6984f5a995269f01.zip
builtins + config: Save reaches every pane holding text of its own, and takes a path argument
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig511
1 files changed, 432 insertions, 79 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 0a390161..b4a0b974 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -164,7 +164,10 @@ const legacy_file_pane_tail = " Save Del";
// so a dump written then upgrades instead of keeping the old word as a tail.
const prev_pane_tail = " New Del";
const prev_file_pane_tail = " Save New Del";
-const prev_terminal_pane_tail = " New Del Filter";
+const legacy_terminal_pane_tail = " New Del Filter";
+// ...and the terminal default from the release before Save reached it, when a
+// scrollback was not yet something you could write to a path.
+const prev_terminal_pane_tail = " New Newtty Del Filter";
// Builtins: executing the name (middle-click / Tab) runs it through the ONE
// dispatcher (runBuiltin, reached from execute), no matter where the name
@@ -221,6 +224,25 @@ fn nextPipeEffect(p: *Pardes) ?u32 {
return null;
}
+/// Perform every queued effect through the in-process host — what a real shell
+/// does with the drain — and report the path the last `.save_text` among them
+/// asked for, copied out of the effect into `buf`.
+fn drainForSavePath(p: *Pardes, buf: []u8) ?[]const u8 {
+ var len: ?usize = null;
+ while (p.nextEffect()) |effect| {
+ switch (effect) {
+ .save_text => |st| {
+ const path = st.path.slice();
+ @memcpy(buf[0..path.len], path);
+ len = path.len;
+ },
+ else => {},
+ }
+ p.perform(effect);
+ }
+ return if (len) |n| buf[0..n] else null;
+}
+
test "selection pipe prompt submits exact request and Escape cancels" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true });
@@ -654,7 +676,7 @@ test "pane-tag Exec prefers New and argument builtins before shell fallback" {
p.acknowledgeShell(0, "/bin/sh", false);
const pane = p.panes[0].?;
- try std.testing.expectEqualStrings(" New Newtty Del Filter", Pardes.curTail(pane));
+ try std.testing.expectEqualStrings(" Save New Newtty Del Filter", Pardes.curTail(pane));
const tag_x = p.rects[0].x + config.GUTTER;
const tag_y = p.rects[0].y;
@@ -770,7 +792,7 @@ test "an untouched tagline ends where its layout column's widest one does" {
const f = p.layoutFindTerm(p.active).?;
p.layoutInsert(f.col, f.idx + 1, below_id);
p.splitBelow(p.active, below);
- p.setCwd(below_id, "/a/deep/directory/whose/name/eats/the/right/pad/the/end/of/its/own/tagline");
+ p.setCwd(below_id, "/a/deep/dir/whose/name/eats/the/right/pad/the/end/of/its/own/tagline");
p.sync();
while (p.nextEffect()) |_| {}
@@ -782,7 +804,8 @@ test "an untouched tagline ends where its layout column's widest one does" {
// path, which is the whole point (equal at tw - tag_right_pad would prove
// nothing: that is where both sat before)
try std.testing.expectEqual(below_tag.len, above_tag.len);
- try std.testing.expectEqualStrings(" New Newtty Del Filter", above_tag[above_tag.len - " New Newtty Del Filter".len ..]);
+ const tail = " Save New Newtty Del Filter";
+ try std.testing.expectEqualStrings(tail, above_tag[above_tag.len - tail.len ..]);
try std.testing.expect(above_tag.len > @as(usize, p.rects[0].w) - config.GUTTER - config.tag_right_pad);
try std.testing.expect(above_tag.len <= @as(usize, p.rects[0].w) - config.GUTTER);
@@ -831,16 +854,25 @@ test "legacy default tag tails upgrade while custom tails remain owned" {
});
p.restoreTail(terminal, terminal_old);
try std.testing.expect(!terminal.tag_init);
- try std.testing.expectEqualStrings(" New Newtty Del Filter", Pardes.curTail(terminal));
+ try std.testing.expectEqualStrings(" Save New Newtty Del Filter", Pardes.curTail(terminal));
- // The immediately preceding release used the generic current default on
- // terminals. It upgrades too, including any saved layout padding.
+ // The two preceding releases used the generic current default and then the
+ // Filter tail without Save. Both upgrade, including any saved layout
+ // padding.
const terminal_previous = try std.fmt.allocPrint(p.scratch.allocator(), "{s} New Del", .{
try p.tagPrefix(terminal),
});
p.restoreTail(terminal, terminal_previous);
try std.testing.expect(!terminal.tag_init);
- try std.testing.expectEqualStrings(" New Newtty Del Filter", Pardes.curTail(terminal));
+ try std.testing.expectEqualStrings(" Save New Newtty Del Filter", Pardes.curTail(terminal));
+
+ const terminal_prefilter = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{
+ try p.tagPrefix(terminal),
+ prev_terminal_pane_tail,
+ });
+ p.restoreTail(terminal, terminal_prefilter);
+ try std.testing.expect(!terminal.tag_init);
+ try std.testing.expectEqualStrings(" Save New Newtty Del Filter", Pardes.curTail(terminal));
const terminal_custom = try std.fmt.allocPrint(p.scratch.allocator(), "{s} Keep Del", .{
try p.tagPrefix(terminal),
@@ -872,7 +904,7 @@ test "legacy default tag tails upgrade while custom tails remain owned" {
.terminal = .{ .cwd = "/historical/cwd" },
});
try std.testing.expect(!terminal.tag_init);
- try std.testing.expectEqualStrings(" New Newtty Del Filter", Pardes.curTail(terminal));
+ try std.testing.expectEqualStrings(" Save New Newtty Del Filter", Pardes.curTail(terminal));
// This happens to be the historical FILE default, but on a terminal it is
// user-owned text and must not be swallowed by migration.
@@ -1001,7 +1033,8 @@ test "an unsaved file marker sits between its path and builtins until Save" {
const reloaded = try p.tagText(p.scratch.allocator(), pane);
try std.testing.expect(std.mem.indexOf(u8, reloaded, "/hxcase.txt *") == null);
- // A generated output is file-shaped but has nothing it can save.
+ // A generated output is file-shaped and Save can write it to a path, but
+ // there is no file of its own for it to be dirty against.
pane.file.?.output = .{ .from = .search };
file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "result\n"));
const output = try p.tagText(p.scratch.allocator(), pane);
@@ -1396,15 +1429,275 @@ test "Save on a terminal writes its plaintext scrollback and stays a terminal" {
// the pane is untouched: still a terminal, no file behind it
try std.testing.expect(pane.isTerminal());
try std.testing.expect(pane.file == null);
- const st = p.save_text orelse return error.MissingSaveText;
- try std.testing.expectEqualStrings("/tmp/pardes-tty-save/log.txt", st.path);
- try std.testing.expect(std.mem.indexOf(u8, st.content, "hello scrollback") != null);
- var asked = false;
- while (p.nextEffect()) |effect| switch (effect) {
- .save_text => asked = true,
- else => {},
+
+ // the host performs it, reading the scrollback off the pane as it goes
+ var buf: [256]u8 = undefined;
+ try std.testing.expectEqualStrings(
+ "/tmp/pardes-tty-save/log.txt",
+ drainForSavePath(p, &buf) orelse return error.NoSaveAsked,
+ );
+ const written = p.fallback.get("/tmp/pardes-tty-save/log.txt") orelse return error.NothingWritten;
+ try std.testing.expect(std.mem.indexOf(u8, written, "hello scrollback") != null);
+}
+
+test "Save on an output buffer writes its rows out and leaves the buffer alone" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .shells = 3, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+
+ const source: usize = 2;
+ p.setCwd(source, "/tmp/pardes-output-save");
+ try std.testing.expect(p.executeBuiltinLine(source, "Help"));
+ p.sync(); // the frame boundary that gives the new pane its geometry
+ while (p.nextEffect()) |_| {}
+
+ const help: output_pane.Origin = .{ .cmd = .Help };
+ const id = blk: {
+ for (p.panes, 0..) |slot, i| {
+ const pane = slot orelse continue;
+ const f = pane.file orelse continue;
+ const o = f.output orelse continue;
+ if (std.meta.eql(o.from, help)) break :blk i;
+ }
+ return error.MissingHelpBuffer;
};
- try std.testing.expect(asked);
+ const out = p.panes[id].?;
+ const path = try gpa.dupe(u8, out.file.?.path);
+ defer gpa.free(path);
+ const rows = try gpa.dupe(u8, out.file.?.content);
+ defer gpa.free(rows);
+
+ // Save leads its tagline now, and the path is REQUIRED: a bare Save asks
+ try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(out));
+ try std.testing.expect(p.executeBuiltinLine(id, "Save"));
+ try std.testing.expect(out.hasSavePrompt());
+ try std.testing.expect(out.appendTag("help.txt"));
+ p.submitSave(id);
+
+ // the buffer itself is untouched: same origin, same name, still refillable
+ // and still walked by n/N, because a saved copy of a list is only a copy
+ try std.testing.expect(out.file.?.output != null);
+ try std.testing.expectEqual(help, out.file.?.output.?.from);
+ try std.testing.expectEqualStrings(path, out.file.?.path);
+
+ var buf: [256]u8 = undefined;
+ try std.testing.expectEqualStrings(
+ "/tmp/pardes-output-save/help.txt",
+ drainForSavePath(p, &buf) orelse return error.NoSaveAsked,
+ );
+ try std.testing.expectEqualStrings(
+ rows,
+ p.fallback.get("/tmp/pardes-output-save/help.txt") orelse return error.NothingWritten,
+ );
+}
+
+test "Save takes the path as an argument, relative to the pane's own directory" {
+ const p = try Pardes.init(std.testing.allocator, .{ .tty_only = true, .cols = 40, .rows = 8 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = p.panes[0].?;
+ p.setCwd(0, "/tmp/pardes-arg-save");
+ p.update(.{ .output = .{ .pane = 0, .bytes = "typed and gone\r\n" } });
+ while (p.nextEffect()) |_| {}
+
+ // an argument answers the question the prompt would have asked, so no
+ // prompt arms — and a bare name lands under the pane's directory, the way
+ // a relative word in a look resolves, not under the process's cwd
+ var buf: [256]u8 = undefined;
+ try std.testing.expect(p.executeBuiltinLine(0, "Save session.txt"));
+ try std.testing.expect(!pane.hasSavePrompt());
+ try std.testing.expectEqualStrings(
+ "/tmp/pardes-arg-save/session.txt",
+ drainForSavePath(p, &buf) orelse return error.NoSaveAsked,
+ );
+ const written = p.fallback.get("/tmp/pardes-arg-save/session.txt") orelse return error.NothingWritten;
+ try std.testing.expect(std.mem.indexOf(u8, written, "typed and gone") != null);
+
+ // `.` and `..` normalize with it, so this is that same file and not a
+ // second one spelled differently
+ try std.testing.expect(p.executeBuiltinLine(0, "Save ./sub/../session.txt"));
+ try std.testing.expectEqualStrings(
+ "/tmp/pardes-arg-save/session.txt",
+ drainForSavePath(p, &buf) orelse return error.NoSaveAsked,
+ );
+
+ try std.testing.expect(p.executeBuiltinLine(0, "Save /tmp/pardes-elsewhere/abs.txt"));
+ try std.testing.expectEqualStrings(
+ "/tmp/pardes-elsewhere/abs.txt",
+ drainForSavePath(p, &buf) orelse return error.NoSaveAsked,
+ );
+ try std.testing.expect(pane.isTerminal());
+
+ // ...and a path that cannot be made absolute is refused outright: a shell
+ // that has not reported a directory has nothing to resolve against, and
+ // the directory pardes was started in is not a guess worth making
+ p.panes[0].?.cwd = .none;
+ try std.testing.expect(p.executeBuiltinLine(0, "Save nowhere.txt"));
+ try std.testing.expect(drainForSavePath(p, &buf) == null);
+}
+
+test "Save elsewhere copies a file's bytes and keeps the pane on its own file" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ const pane = try p.hxOpenFileContent("before\n");
+ file_pane.setContent(p, &pane.file.?, try gpa.dupe(u8, "after\n"));
+ try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision);
+
+ var buf: [256]u8 = undefined;
+ try std.testing.expect(p.executeBuiltinLine(0, "Save /tmp/pardes-copy/other.txt"));
+ // a copy, never a rename: the pane keeps its file, and that file is still
+ // unsaved, so the marker stays where it was
+ try std.testing.expectEqualStrings("/hxcase.txt", pane.file.?.path);
+ try std.testing.expect(pane.file.?.revision != pane.file.?.saved_revision);
+ try std.testing.expectEqualStrings(
+ "/tmp/pardes-copy/other.txt",
+ drainForSavePath(p, &buf) orelse return error.NoSaveAsked,
+ );
+ try std.testing.expectEqualStrings(
+ "after\n",
+ p.fallback.get("/tmp/pardes-copy/other.txt") orelse return error.NothingWritten,
+ );
+
+ // its own path, spelled out, is the in-place write — nothing is asked of
+ // the host but save_file, and the pane comes clean
+ try std.testing.expect(p.executeBuiltinLine(0, "Save /hxcase.txt"));
+ try std.testing.expect(drainForSavePath(p, &buf) == null);
+ try std.testing.expectEqual(pane.file.?.revision, pane.file.?.saved_revision);
+}
+
+test "saves armed in one batch stay with their own panes" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .shells = 3, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+
+ const tty_id = p.freeSlot() orelse return error.NoPaneSlots;
+ const shell = try p.newShell(tty_id, "/tmp/pardes-batch");
+ p.setCwd(tty_id, "/tmp/pardes-batch");
+ p.update(.{ .output = .{ .pane = @intCast(tty_id), .bytes = "shell text\r\n" } });
+ const file = try p.hxOpenFileContent("file text\n");
+ const file_id = p.paneIdOf(file) orelse return error.MissingFilePane;
+ try std.testing.expect(shell.isTerminal());
+ try std.testing.expect(tty_id != file_id);
+ while (p.nextEffect()) |_| {}
+
+ // both armed before the host drains anything — the path rides inside its
+ // own effect, so neither can be written with the other pane's text
+ try std.testing.expect(p.executeBuiltinLine(tty_id, "Save tty.txt"));
+ try std.testing.expect(p.executeBuiltinLine(file_id, "Save /tmp/pardes-batch/file.txt"));
+ while (p.nextEffect()) |effect| p.perform(effect);
+ const tty_bytes = p.fallback.get("/tmp/pardes-batch/tty.txt") orelse return error.NothingWritten;
+ try std.testing.expect(std.mem.indexOf(u8, tty_bytes, "shell text") != null);
+ try std.testing.expectEqualStrings(
+ "file text\n",
+ p.fallback.get("/tmp/pardes-batch/file.txt") orelse return error.NothingWritten,
+ );
+}
+
+test "a save whose pane is gone before the drain writes nothing" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .shells = 3, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+
+ const doomed = p.freeSlot() orelse return error.NoPaneSlots;
+ const shell = try p.newShell(doomed, "/tmp/pardes-gone");
+ p.update(.{ .output = .{ .pane = @intCast(doomed), .bytes = "doomed text\r\n" } });
+ while (p.nextEffect()) |_| {}
+ try std.testing.expect(shell.isTerminal());
+
+ // Save, then close the pane, then let the host drain: the bytes are read
+ // off the pane at drain time, so there are none to read...
+ try std.testing.expect(p.executeBuiltinLine(doomed, "Save /tmp/pardes-gone/log.txt"));
+ try std.testing.expect(p.executeBuiltinLine(doomed, "Del"));
+ while (p.nextEffect()) |effect| p.perform(effect);
+ try std.testing.expect(p.fallback.get("/tmp/pardes-gone/log.txt") == null);
+
+ // ...and a pane that took the freed slot in the meantime is NOT the pane
+ // that asked, which is what the serial in the effect is for
+ try std.testing.expect(p.executeBuiltinLine(0, "Save /tmp/pardes-gone/recycled.txt"));
+ try std.testing.expect(p.executeBuiltinLine(0, "Del"));
+ const reused = p.freeSlot() orelse return error.NoPaneSlots;
+ _ = try p.newShell(reused, "/tmp/pardes-gone");
+ while (p.nextEffect()) |effect| p.perform(effect);
+ try std.testing.expect(p.fallback.get("/tmp/pardes-gone/recycled.txt") == null);
+}
+
+test "Save reaches every tagline with text behind it and no other" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+
+ const out = try p.hxOpenFileContent("build.zig:1:1 pub fn main\n");
+ out.file.?.output = .{ .from = .search };
+ try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(out));
+
+ // A dump written before Save reached output buffers carries the generic
+ // tail. That is a DEFAULT and not something typed, so it upgrades.
+ const dumped = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{
+ try p.tagPrefix(out),
+ pane_tail,
+ });
+ p.restoreTail(out, dumped);
+ try std.testing.expect(!out.tag_init);
+ try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(out));
+
+ // A scratch is an output buffer that has worn the FILE tails all along,
+ // so its own older default upgrades from that family too.
+ out.file.?.output = .{ .from = .{ .cmd = std.meta.stringToEnum(Builtin, "New").? } };
+ out.tag_tail_len = 0;
+ out.tag_init = false;
+ const scratch_dumped = try std.fmt.allocPrint(p.scratch.allocator(), "{s}{s}", .{
+ try p.tagPrefix(out),
+ prev_file_pane_tail,
+ });
+ p.restoreTail(out, scratch_dumped);
+ try std.testing.expect(!out.tag_init);
+ try std.testing.expectEqualStrings(" Save New Newtty Del", Pardes.curTail(out));
+
+ // An image's bytes on disk already are what they are: nothing of the
+ // pane's own is unwritten, so the word is absent and inert.
+ const img_id = p.freeSlot().?;
+ const img = try image_pane.create(p, img_id, "/tmp/pardes-tag.ppm", &.{});
+ try std.testing.expectEqualStrings(" New Newtty Del", Pardes.curTail(img));
+ try std.testing.expect(p.executeBuiltinLine(img_id, "Save"));
+ try std.testing.expect(!img.hasSavePrompt());
+ var buf: [256]u8 = undefined;
+ try std.testing.expect(drainForSavePath(p, &buf) == null);
+}
+
+test "a save path that names no file is refused before anything is rewritten" {
+ const p = try Pardes.init(std.testing.allocator, .{ .shells = 3, .cols = 100, .rows = 30 });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+
+ const source: usize = 2;
+ p.setCwd(source, "/tmp/pardes-refuse");
+ try std.testing.expect(p.executeBuiltinLine(source, "New"));
+ const id = p.active;
+ const scratch = p.panes[id].?;
+ p.sync();
+ while (p.nextEffect()) |_| {}
+
+ // Enter on the bare prefill: a directory is not a file to become, and the
+ // scratch must still be a scratch afterwards — the alternative is a buffer
+ // renamed onto a path whose write silently failed
+ try std.testing.expect(p.executeBuiltinLine(id, "Save"));
+ try std.testing.expect(scratch.hasSavePrompt());
+ p.submitSave(id);
+ var buf: [256]u8 = undefined;
+ try std.testing.expect(drainForSavePath(p, &buf) == null);
+ try std.testing.expect(scratch.file.?.output != null);
+ try std.testing.expectEqualStrings("/tmp/pardes-refuse/+New", scratch.file.?.path);
+
+ // ...and neither is a multi-line selection chorded onto the word
+ try std.testing.expect(p.executeBuiltinLine(id, "Save one\ntwo"));
+ try std.testing.expect(drainForSavePath(p, &buf) == null);
+ try std.testing.expect(scratch.file.?.output != null);
}
test "a host with no methods at all is a complete in-process pardes" {
@@ -3147,10 +3440,14 @@ 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 },
- /// write plaintext to a path WITHOUT converting the pane (a terminal's
- /// scrollback save): the bytes and path are stashed in core.save_text,
- /// read off the core exactly like save_file reads a file pane.
- save_text: struct { pane: u8 },
+ /// Write this pane's text to a path WITHOUT converting the pane — a
+ /// terminal's scrollback, a results buffer's rows, a file copied elsewhere.
+ /// The path travels HERE, bounded exactly like a spawn's cwd, so two saves
+ /// armed in one batch cannot cross; the BYTES are read off the pane when
+ /// this is performed, the way save_file reads a file pane. `serial` is the
+ /// pane it was armed for: a slot freed and reused before the drain writes
+ /// nothing rather than another pane's text to this path.
+ save_text: struct { pane: u8, serial: u32, path: Buf(256) },
/// 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)
@@ -3192,6 +3489,7 @@ pub const Effect = union(enum) {
return struct {
data: [n]u8 = undefined,
len: u16 = 0,
+ pub const cap = n;
pub fn slice(b: *const @This()) []const u8 {
return b.data[0..b.len];
}
@@ -5244,9 +5542,6 @@ pub const Pardes = struct {
clip_pending: ?ClipRequest = null,
/// the last serialized dump (gpa-owned), read by the write_dump effect
dump_out: ?[]u8 = null,
- /// A terminal scrollback save awaiting the host: bytes + path, gpa-owned,
- /// read by the save_text effect and cleared once written. At most one.
- save_text: ?struct { content: []const u8, path: []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,
@@ -5352,10 +5647,6 @@ 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.save_text) |s| {
- gpa.free(s.content);
- gpa.free(s.path);
- }
p.fallback.deinit();
if (p.last_dump) |d| gpa.free(d);
if (p.custom_theme) |theme_value| std.zon.parse.free(gpa, theme_value);
@@ -5970,9 +6261,13 @@ pub const Pardes = struct {
p.hostWriteFile(sf.pane, f.path, f.content);
},
.save_text => |st| {
- const s = p.save_text orelse return;
- p.hostWriteFile(st.pane, s.path, s.content);
- p.clearSaveText();
+ const pane = p.panes[st.pane] orelse return;
+ if (pane.serial != st.serial) return; // a recycled slot: not ours
+ if (pane.file) |f| return p.hostWriteFile(st.pane, st.path.slice(), f.content);
+ if (!pane.isTerminal()) return;
+ const text = pane.vt.screens.active.dumpStringAlloc(p.gpa, .{ .screen = .{} }) catch return;
+ defer p.gpa.free(text);
+ p.hostWriteFile(st.pane, st.path.slice(), text);
},
.write_dump => {
const out = p.dump_out orelse return;
@@ -6351,7 +6646,6 @@ pub const Pardes = struct {
}
/// the editable tail: the user's edited buffer once touched, else defaults
- /// (a buffer with nothing to Save gets the plain tail — the table decides)
fn curTail(pane: *Pane) []const u8 {
if (pane.tag_init) return pane.tagSlice();
return defaultTail(pane);
@@ -6360,8 +6654,12 @@ pub const Pardes = struct {
/// The untouched command tail for this pane class. `curTail` and tagGap
/// must ask the same question: otherwise a terminal renders Filter but
/// still votes for the shorter generic tail when a column is aligned.
+ ///
+ /// Save leads wherever the pane holds text of its own — every pane with a
+ /// file, an output buffer included, plus every terminal. What is left is an
+ /// image and a PDF: their bytes on disk already are what they are.
fn defaultTail(pane: *const Pane) []const u8 {
- if (pane.file) |f| if (output_pane.fileTraits(f.output).saves) return file_pane_tail;
+ if (pane.file != null) return file_pane_tail;
if (pane.isTerminal()) return terminal_pane_tail;
return pane_tail;
}
@@ -8795,6 +9093,9 @@ pub const Pardes = struct {
// ---- selection pipe (`|`): visible prompt, async shell, atomic edit ----
fn startPipe(p: *Pardes, pane: *Pane) void {
+ // A buffer that IS a file, or the scratch that becomes one: a filter
+ // rewrites bytes the pane owns. Never a terminal (shell output cannot
+ // be rewritten), never a rendering that its next refill would discard.
const f = pane.file orelse return;
if (!output_pane.fileTraits(f.output).saves) return;
p.seedTail(pane);
@@ -8971,9 +9272,10 @@ pub const Pardes = struct {
pane.tag_col = @intCast((p.tagPrefix(pane) catch return).len + pane.tag_tail_len);
}
- /// Save on a scratch buffer or a terminal arms a PATH input in the tag,
- /// prefilled with the pane's directory (an inherited scratch follows the
- /// pane it was opened from). submitSave writes it.
+ /// Save on a pane with no file of its own — an output buffer or a terminal
+ /// — arms a PATH input in the tag, prefilled with the pane's directory (an
+ /// inherited scratch follows the pane it was opened from). submitSave
+ /// hands what you type to saveTo.
pub fn startSavePrompt(p: *Pardes, pane: *Pane) void {
p.seedTail(pane);
if (!pane.tag_init) return;
@@ -9002,16 +9304,61 @@ pub const Pardes = struct {
if (!std.mem.startsWith(u8, armed, config.save_marker)) return;
const path = std.mem.trim(u8, armed[config.save_marker.len..], " \t\r\n");
if (path.len == 0) return;
- p.performSave(id, path);
+ p.saveTo(id, path);
}
- /// Commit a prompted path. A scratch buffer's own bytes go there and it
- /// BECOMES an ordinary file pane; a terminal's plaintext scrollback goes
- /// there and it stays a terminal.
- fn performSave(p: *Pardes, id: usize, path: []const u8) void {
+ /// Write the file this pane already has: acme's Put, and the one Save that
+ /// needs no argument because the path was never a question.
+ pub fn saveFile(p: *Pardes, id: usize) void {
const pane = p.panes[id] orelse return;
- if (pane.file) |*f| {
- const owned = p.gpa.dupe(u8, path) catch return;
+ const f = if (pane.file) |*file| file else return;
+ if (f.output != null) return; // nothing behind it yet: saveTo, with a path
+ p.emit(.{ .save_file = .{ .pane = @intCast(id) } });
+ f.saved_revision = f.revision;
+ }
+
+ /// Commit a path — prompted, typed after the word, or chorded onto it.
+ ///
+ /// The pane is left ALONE: a terminal stays a terminal, a results buffer
+ /// keeps its rows and its place in the n/N ring, and an open file keeps the
+ /// file it has, so `Save <elsewhere>` is a copy and never a rename. The one
+ /// pane that changes is the scratch New opened, which exists to become the
+ /// file you name and does (output traits: `saves`).
+ ///
+ /// A relative path resolves against the PANE's directory — the way a look
+ /// resolves a relative word — and never against whatever directory the
+ /// process happened to start in. `.`, `..` and doubled slashes normalize
+ /// with it, so `Save ./notes` and `Save notes` are one path and one answer
+ /// to "is this the file I already have open".
+ ///
+ /// What it will not do is guess. A path that names no FILE (empty, or
+ /// ending in `/` — the bare prompt prefill accepted with Enter), one that
+ /// carries a newline (a multi-line selection chorded onto the word), and
+ /// one that does not resolve ABSOLUTE (a terminal whose shell has not
+ /// reported a directory yet, where the alternative is writing into
+ /// whatever directory pardes was started in) are all refused, and say so
+ /// on the message row. That check comes FIRST because the scratch's branch
+ /// below rewrites the pane's identity: a host write can only fail silently
+ /// afterwards, so a buffer must never become a "file" that never existed.
+ pub fn saveTo(p: *Pardes, id: usize, path: []const u8) void {
+ const pane = p.panes[id] orelse return;
+ if (path.len == 0 or path[path.len - 1] == '/' or
+ std.mem.indexOfScalar(u8, path, '\n') != null)
+ return p.reportError(id, "save", error.NoFilename);
+ const full = std.fs.path.resolvePosix(
+ p.scratch.allocator(),
+ &.{ paneDir(pane), path },
+ ) catch return;
+ if (!std.fs.path.isAbsolute(full)) return p.reportError(id, "save", error.NoDirectory);
+ if (full.len > SavePath.cap) return p.reportError(id, "save", error.PathTooLong);
+ const f = if (pane.file) |*file| file else {
+ // a terminal writes its scrollback; an image and a PDF have nothing
+ // of their own that is unwritten, so they have nothing to write
+ if (pane.isTerminal()) p.askWrite(id, pane.serial, full);
+ return;
+ };
+ if (f.output != null and output_pane.fileTraits(f.output).saves) {
+ const owned = p.gpa.dupe(u8, full) catch return;
p.gpa.free(f.path);
f.path = owned;
f.output = null; // an ordinary file pane from here on
@@ -9023,35 +9370,26 @@ pub const Pardes = struct {
p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } });
return;
}
- p.saveScrollback(id, path);
+ // its own path, spelled out: the in-place write, so the pane comes clean
+ if (f.output == null and std.mem.eql(u8, f.path, full)) return p.saveFile(id);
+ p.askWrite(id, pane.serial, full);
}
- /// A terminal's plaintext scrollback to `path`, leaving the pane a terminal.
- /// The core cannot do IO, so it stashes the bytes and asks the host to
- /// write them (save_text) the same way save_file hands off a file pane.
- fn saveScrollback(p: *Pardes, id: usize, path: []const u8) void {
- const pane = p.panes[id] orelse return;
- if (!pane.isTerminal()) return;
- const content = pane.vt.screens.active.dumpStringAlloc(p.gpa, .{ .screen = .{} }) catch return;
- const owned = p.gpa.dupe(u8, path) catch {
- p.gpa.free(content);
- return;
- };
- if (p.save_text) |old| {
- p.gpa.free(old.content);
- p.gpa.free(old.path);
- }
- p.save_text = .{ .content = content, .path = owned };
- p.emit(.{ .save_text = .{ .pane = @intCast(id) } });
- }
+ /// The bound on a save path, which travels inside its effect: nothing is
+ /// stashed, so two saves armed in one batch cannot be confused for each
+ /// other and a whole buffer is never copied to write it.
+ const SavePath = Effect.Buf(256);
- /// The host has consumed the pending scrollback save: free and clear it.
- pub fn clearSaveText(p: *Pardes) void {
- if (p.save_text) |s| {
- p.gpa.free(s.content);
- p.gpa.free(s.path);
- }
- p.save_text = null;
+ /// Ask the host to write this pane's text at `path` without touching the
+ /// pane. The bytes are the pane's own, so the drain reads them back off it
+ /// (perform, .save_text) the way save_file does — with `serial` saying
+ /// which pane asked, since the slot may be freed before the drain.
+ fn askWrite(p: *Pardes, id: usize, serial: u32, path: []const u8) void {
+ p.emit(.{ .save_text = .{
+ .pane = @intCast(id),
+ .serial = serial,
+ .path = SavePath.from(path),
+ } });
}
/// Enter on an armed input: the pattern is everything past the marker's
@@ -13256,19 +13594,26 @@ pub const Pardes = struct {
p.restoreTailAt(pane, saved_tag, savedPrefix(saved_tag, pfx, class), class);
}
- const TailClass = enum { generic, savable_file, terminal };
+ /// Which family of historical defaults a saved tail is read against. An
+ /// output buffer is its own class rather than a file: it wears the file
+ /// tail today, but the tail it was DUMPED with was the generic one, and a
+ /// real file must not inherit that recognition (see restoreTailAt).
+ const TailClass = enum { generic, file, output, terminal };
fn tailClass(pane: *const Pane) TailClass {
- if (pane.file) |f| if (output_pane.fileTraits(f.output).saves) return .savable_file;
+ if (pane.file) |f| return if (f.output == null) .file else .output;
if (pane.isTerminal()) return .terminal;
return .generic;
}
/// A dirty marker may be present in the rendered compatibility tag of an
/// untouched file. It is live prefix chrome, not a custom command tail;
- /// consume it while recovering old dumps so it disappears after Save.
+ /// consume it while recovering old dumps so it disappears after Save. Only
+ /// a pane with a file to be dirty AGAINST ever rendered one — a real file,
+ /// or the scratch that is becoming one.
fn savedPrefix(saved_tag: []const u8, live: []const u8, class: TailClass) []const u8 {
- if (class != .savable_file or !std.mem.startsWith(u8, saved_tag, live)) return live;
+ if (class == .generic or class == .terminal) return live;
+ if (!std.mem.startsWith(u8, saved_tag, live)) return live;
const marked_len = live.len + dirty_marker.len;
if (saved_tag.len >= marked_len and std.mem.eql(u8, saved_tag[live.len..marked_len], dirty_marker))
return saved_tag[0..marked_len];
@@ -13292,11 +13637,19 @@ pub const Pardes = struct {
const said = std.mem.trimStart(u8, rest, " ");
const defaults: []const []const u8 = switch (class) {
.generic => &.{ pane_tail, prev_pane_tail, legacy_pane_tail },
- .savable_file => &.{ file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail },
- // Terminal dumps written before Filter used the generic current
- // tail, and the earliest version used just Del. All upgrade; other
- // pane classes still treat these as user text.
- .terminal => &.{ terminal_pane_tail, prev_terminal_pane_tail, pane_tail, prev_pane_tail, legacy_pane_tail },
+ .file => &.{ file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail },
+ // An output buffer wore the GENERIC default until Save reached it,
+ // and a terminal's has now been through three shapes; both upgrade
+ // from that family. The scratch is an output buffer that wore the
+ // FILE defaults all along (it was the one that could Save), so its
+ // row carries both. Recognition stays scoped per class: the
+ // generic default sitting on a real FILE is still text its owner
+ // typed and is still kept.
+ .output => &.{
+ file_pane_tail, prev_file_pane_tail, legacy_file_pane_tail,
+ pane_tail, prev_pane_tail, legacy_pane_tail,
+ },
+ .terminal => &.{ terminal_pane_tail, prev_terminal_pane_tail, legacy_terminal_pane_tail, pane_tail, prev_pane_tail, legacy_pane_tail },
};
for (defaults) |default|
if (std.mem.eql(u8, said, std.mem.trimStart(u8, default, " "))) return;