diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/CHANGELOG.md | 15 | ||||
| -rw-r--r-- | src/builtins.zig | 38 | ||||
| -rw-r--r-- | src/config.zig | 21 | ||||
| -rw-r--r-- | src/pardes.zig | 511 | ||||
| -rw-r--r-- | src/tutor.txt | 39 |
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 |
