summaryrefslogtreecommitdiff
path: root/src
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
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')
-rw-r--r--src/CHANGELOG.md15
-rw-r--r--src/builtins.zig38
-rw-r--r--src/config.zig21
-rw-r--r--src/pardes.zig511
-rw-r--r--src/tutor.txt39
5 files changed, 506 insertions, 118 deletions
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index b2bc9583..3b5056b5 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -14,8 +14,19 @@
temporary file. Its directory is inherited from the pane it was opened from.
- `Save` on a scratch asks for a path, prefilled with that inherited
directory, and the buffer becomes an ordinary file once written.
-- `Save` on a terminal writes its plaintext scrollback to a prompted path and
- leaves the pane a terminal.
+- `Save` reaches every pane that holds text of its own and leads its tagline:
+ a terminal ("Save New Newtty Del Filter") and an output buffer like
+ `+Search` ("Save New Newtty Del") as well as a file. Images and PDFs keep
+ the plain tail — their bytes on disk already are what they are.
+- `Save` takes the path as an ARGUMENT (`Save notes.txt`, or a selection
+ chorded onto the word) and asks for one only when it was not given, the way
+ Find and Grep ask for a pattern. A relative path resolves against the
+ pane's own directory, with `.` and `..` normalized; a path that cannot be
+ made absolute is refused rather than written next to the process.
+- Writing to a path never rewrites the pane: a terminal stays a terminal, an
+ output 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 not a rename.
+ The scratch `+New` is still the one exception, becoming the file it names.
- A dropped pane's memory outlives its frame, so a pane pointer taken during
that frame stays valid until the next one repairs it.
- The core owns the event loop. Each platform is now a `Host` of optional
diff --git a/src/builtins.zig b/src/builtins.zig
index b4447289..5a6b6ea9 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -133,6 +133,11 @@ pub const OutputTraits = struct {
jumps: bool = false,
commands: bool = false,
doc: bool = false,
+ /// the buffer BECOMES an ordinary file once written (the New scratch, and a
+ /// real file, which is one already). Every other output buffer is a
+ /// RENDERING: Save writes its text out and the buffer stays what it is,
+ /// refillable and steppable, because a saved copy of a search is a copy of
+ /// a search and not the search.
saves: bool = false,
};
@@ -518,22 +523,27 @@ pub const ClipReplace = struct {
// ---- panes and columns ----
+/// Write this pane's text out. A pane with a real file behind it writes THAT
+/// file with no argument — acme's Put, what `:w<Tab>` has always meant — and
+/// that is the only pane Save can serve without being told where.
+///
+/// Everywhere else the path is REQUIRED, so a bare `Save` asks for one exactly
+/// the way Find and Grep ask for a pattern: the tag input arms prefilled with
+/// the pane's directory and Enter commits it. A terminal writes its plaintext
+/// scrollback and stays a terminal; an output buffer writes its rows and stays
+/// an output buffer, still refillable and still walked by n/N — with the one
+/// exception the New scratch has always been, an empty buffer whose whole
+/// purpose is to become the file you name (output traits: `saves`).
+///
+/// Images and PDFs hold nothing of their own that is unwritten, so the word is
+/// inert there and absent from their tag.
pub const Save = struct {
+ pub const takes_arg = true;
pub fn run(c: Ctx) void {
- if (c.pane.file) |*f| {
- if (f.output != null) {
- // an unsavable output (search/help) has nothing to write; a
- // scratch (savable output) asks for a path and becomes a file
- if (output_pane.fileTraits(f.output).saves) c.p.startSavePrompt(c.pane);
- return;
- }
- // a real file: write in place, and this edit is now on disk
- c.p.emit(.{ .save_file = .{ .pane = @intCast(c.id) } });
- f.saved_revision = f.revision;
- return;
- }
- // a terminal: write its plaintext scrollback to a prompted path
- if (c.pane.isTerminal()) c.p.startSavePrompt(c.pane);
+ const path = std.mem.trim(u8, c.arg orelse "", " \t\r\n");
+ if (path.len > 0) return c.p.saveTo(c.id, path);
+ if (c.pane.file) |file| if (file.output == null) return c.p.saveFile(c.id);
+ if (c.pane.file != null or c.pane.isTerminal()) c.p.startSavePrompt(c.pane);
}
};
diff --git a/src/config.zig b/src/config.zig
index 63f97db1..3e3f2cb0 100644
--- a/src/config.zig
+++ b/src/config.zig
@@ -418,14 +418,16 @@ pub const tty_blank: enum { prompt, prompt_and_input } = .prompt;
// pane's mode is consulted: Help works in tty mode, which earns its width.
pub const topbar_str = "New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill";
-/// the default editable tail of a pane's tag, per kind (an output buffer has
-/// no file to Save, so it gets the plain one). Terminals alone expose Filter,
-/// the pane-local theme-keyed colour projection. Save stays first on real
-/// files: `:` parks at the tail boundary, so the established `:w<Tab>`
-/// spelling still walks to Save while New remains visible on every pane.
+/// The default editable tail of a pane's tag, per kind. Save LEADS wherever the
+/// pane holds text of its own to write — a file, an output buffer, a terminal's
+/// scrollback — because `:` parks at the tail boundary and the established
+/// `:w<Tab>` spelling walks to the first word from there. Images and PDFs get
+/// the plain tail: their bytes on disk already are exactly what they are, so
+/// there is nothing of the pane's own left to save. Terminals alone expose
+/// Filter, the pane-local theme-keyed colour projection.
pub const pane_builtins_str = "New Newtty Del";
pub const file_pane_builtins_str = "Save New Newtty Del";
-pub const terminal_pane_builtins_str = "New Newtty Del Filter";
+pub const terminal_pane_builtins_str = "Save New Newtty Del Filter";
/// Columns kept clear to the RIGHT of a tagline's builtins. The path stays at
/// the left edge and the builtins are pushed over to end this far short of the
@@ -754,9 +756,10 @@ pub const find_marker = " Find /";
pub const grep_marker = " Grep /";
pub const rename_marker = " Rename /";
pub const symbol_marker = " WsSymbols /";
-/// Save on a scratch buffer or a terminal: the tail is the whole PATH to write
-/// (no `/` separator, since a path is made of them), prefilled with the pane's
-/// directory so only a filename need be typed.
+/// Save on a pane with no file of its own yet — an output buffer or a terminal:
+/// the tail is the whole PATH to write (no `/` separator, since a path is made
+/// of them), prefilled with the pane's directory so only a filename need be
+/// typed.
pub const save_marker = " Save ";
/// helix `s` / `S`. The only two markers whose word is NOT a builtin — there
/// is no Select/Split command to run from a tag, they name the key that armed
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;
diff --git a/src/tutor.txt b/src/tutor.txt
index 475cd04a..7dfac0bf 100644
--- a/src/tutor.txt
+++ b/src/tutor.txt
@@ -129,9 +129,12 @@
THE TAG: each pane has a one-line tag: its directory or file path +
builtins. (The mode is the box at the tag's left end, not a word in the
tag.) Every pane shows "New" for an empty temporary file in its column.
- File panes show "Save New Del" by default; an unsaved edit puts `*` after
- the filename. Save writes the current file
- to disk, Del closes the window. Clicking a tag
+ Anything holding TEXT leads with "Save". A file pane shows
+ "Save New Del" and Save writes that file (an unsaved edit puts `*` after
+ the filename); a pane with no file of its own — a terminal, an output
+ buffer — needs the path, so "Save notes.txt" writes it under the pane's
+ own directory and a bare "Save" arms a prompt already filled in with that
+ directory. Del closes the window. Clicking a tag
edits it in insert mode: type straight in, Enter looks / Tab executes
body focuses that same tag in NORMAL mode, parked on the FIRST WORD of
the tail rather than in the run of layout spaces before it — vim's
@@ -140,12 +143,14 @@
onto "Save", Tab to execute it, and you land back in the body where you
left off (the chord keys are the same here as anywhere else; `i` if you
would rather type a command than walk to one). A terminal shows
- "New Del Filter": Filter starts on, is local to that terminal, and
+ "Save New Del Filter": Save writes its plaintext scrollback to the path
+ you give and leaves it a terminal, and Filter starts on, is local to that
+ terminal, and
projects every rendered foreground and background ANSI, 256-colour and
truecolour value through keys made from the current Pardes theme; run it
to restore the program's original colours.
- Images, PDFs, and output buffers like "+Search" show the plain "New Del",
- because there is no file behind them to Save. An image
+ Images and PDFs show the plain "New Del", because their bytes on disk
+ already are exactly what they are. An image
tag leads with "img" and a PDF's with the word "pdf", its page out of
the count, and its three commands each preceded by its current setting
(part 4.1) — all of it live chrome, like the path.
@@ -205,9 +210,13 @@
That last part is nothing new to learn: it is the step `n` is and the
look Enter is, run for you in the list that has just answered. A pattern
that matched nothing opens its empty buffer and goes nowhere.
- That is an OUTPUT BUFFER: a file pane with no file behind it, so it has
- no Save, but everything else about it is an ordinary buffer you can read,
- edit, select and look in. It is not a document, though, and never takes a
+ That is an OUTPUT BUFFER: a file pane with no file behind it, so Save
+ there always takes a path and asks for one when you do not give it —
+ and writing it out leaves the buffer exactly where it was, still the
+ list the next search refills. Everything else about it is an ordinary
+ buffer you can read, edit, select and look in. The one that does become
+ a file is "+New", the empty scratch, which is what it was opened for.
+ It is not a document, though, and never takes a
column of its own: it opens BELOW the pane that asked for it, in that
pane's column, be that a file or a shell — and a file you then open from
its rows goes where files go, not under the list. `n`/`N` carry on down
@@ -794,9 +803,11 @@ abc
Every cursor is filtered in the same pass, and the whole thing is ONE
undo. `%` then `| sort` sorts a file.
- A real FILE pane, and only that: the gate is whether the pane has a file
- to Save, so `|` is inert in a terminal and in every output buffer, and
- the tag stays as it was.
+ A pane whose BYTES ARE ITS OWN, and only that: a real file, or the
+ scratch that is on its way to being one. So `|` is inert in a terminal
+ (shell output cannot be rewritten) and in a rendered output buffer like
+ "+Search", whose next refill would discard the filtered rows anyway, and
+ in both the tag stays as it was.
Not to be confused with EXECUTING a word, which sends it to the
pane's own shell and leaves the text alone. This one is a filter, and
@@ -1175,8 +1186,8 @@ abc
select-then-act: middle-drag, or v/x then Tab
chords: 1-2 cut 1-3 paste (both in one hold = snarf)
2-1 = middle-exec with the left selection as argument
- file tag = path + "Save New Del"; terminal = cwd +
- "New Del Filter"; other panes show "New Del"
+ every tag with text leads with Save (a terminal also
+ has Filter); images and PDFs show "New Del"
top bar = New Newcol Find Grep Help Tutor Dump ... Debug Kill
(keyboard: k off the topmost tagline)
WEB TOUCH: one-finger tap = LOOK; drag = natural scroll