diff options
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 511 |
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; |
