summaryrefslogtreecommitdiff
path: root/src/pardes.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 08:00:41 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commitba0a0f82be43dde325012b3018b186bc808bdafc (patch)
treeb191c664840c11f8ab3a47c380a6a5f569ddb9b5 /src/pardes.zig
parent0b359e1f99f1a2630969c1c00b6c71db3b794ccd (diff)
downloadpardes-ba0a0f82be43dde325012b3018b186bc808bdafc.tar.gz
pardes-ba0a0f82be43dde325012b3018b186bc808bdafc.zip
A Save whose write fails changes nothing: not the name, not the dirty flag
A scratch took its new name before the host wrote it, so `Save /root/x.txt` failing with EACCES renamed it anyway; a failed write to another name marked a clean file dirty. A scratch is now named once its write is done (promoteSaved), and a failed write puts the saved revision back. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/pardes.zig')
-rw-r--r--src/pardes.zig67
1 files changed, 56 insertions, 11 deletions
diff --git a/src/pardes.zig b/src/pardes.zig
index 9ba8a2d3..002ff6f9 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -2321,6 +2321,9 @@ test "owned cwd Save promotion releases the former directory" {
const pane = p.panes[id].?;
try pane.setOwnedCwd("/old/directory");
exec.saveTo(p, id, "saved.txt");
+ // Named once the write is done (a failed one changes nothing).
+ try std.testing.expect(pane.file.?.output != null);
+ while (p.nextEffect()) |effect| p.perform(effect);
try std.testing.expect(pane.cwd == .none);
try std.testing.expect(pane.file.?.output == null);
try std.testing.expectEqualStrings("/old/directory/saved.txt", pane.file.?.path);
@@ -2348,16 +2351,16 @@ test "Save on a scratch asks for a path in its inherited dir and makes it a file
// typing the filename and submitting converts it into an ordinary file
edit.insertKey(p, &np.input, .{ .cp = 'n', .text = "note.txt" });
exec.submitSave(p, id);
- try std.testing.expect(np.file.?.output == null);
- try std.testing.expectEqualStrings("/tmp/pardes-save-dir/note.txt", np.file.?.path);
+ // The write asked for, the pane is the file once it is done.
var saved = false;
- while (p.nextEffect()) |effect| switch (effect) {
- .save_file => |sf| if (@as(usize, sf.pane) == id) {
- saved = true;
- },
- else => {},
- };
+ while (p.nextEffect()) |effect| {
+ if (effect == .save_text and @as(usize, effect.save_text.pane) == id) saved = true;
+ p.perform(effect);
+ }
try std.testing.expect(saved);
+ try std.testing.expect(np.file.?.output == null);
+ try std.testing.expectEqualStrings("/tmp/pardes-save-dir/note.txt", np.file.?.path);
+ try std.testing.expectEqual(np.file.?.revision, np.file.?.saved_revision);
}
test "Save on a terminal writes its plaintext scrollback and stays a terminal" {
@@ -3640,7 +3643,7 @@ 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 },
- save_text: struct { pane: u8, serial: u32, path: Buf(effect_path_cap) },
+ save_text: struct { pane: u8, serial: u32, path: Buf(effect_path_cap), promote: bool = false },
write_dump,
set_clipboard,
read_clipboard,
@@ -5207,6 +5210,26 @@ pub const Pardes = struct {
/// A write of `path` the host could not do: the pane stays dirty, the
/// message row says `Save <path>: <why>`, and the 9P write that asked
/// for it, waiting on the save, fails with that (`fs.late_failure`).
+ /// A scratch written to `path` is that file from here on: named, an
+ /// ordinary file pane, clean, and watched (exec.saveTo).
+ fn promoteSaved(p: *Pardes, id: u8, path: []const u8) void {
+ const pane = p.panes[id] orelse return;
+ const f = if (pane.file) |*file| file else return;
+ const owned = p.gpa.dupe(u8, path) catch return;
+ p.gpa.free(f.path);
+ f.path = owned;
+ f.output = null;
+ pane.clearCwd();
+ // re-derive the tag as a plain file's
+ if (pane.tag.own) |own| p.gpa.free(own);
+ pane.tag.own = null;
+ f.saved_revision = f.revision;
+ f.disk_gone = false;
+ ctlfs.events.noteLog(p, .save, pane);
+ if (filesystem.localPath(f.path) != null)
+ p.emit(.{ .watch = .{ .pane = id, .on = true } });
+ }
+
pub fn saveFailed(p: *Pardes, id: u8, path: []const u8, err: anyerror) void {
if (p.panes[id]) |pane| if (pane.file) |*f| {
// The `-%` spelling fs.zig already uses for "make this dirty".
@@ -5280,13 +5303,21 @@ pub const Pardes = struct {
const pane = p.panes[sf.pane] orelse return;
const f = if (pane.file) |*file| file else return;
const serial = pane.serial;
+ const was = f.saved_revision;
+ const failures = p.fs.failures;
f.saved_revision = f.revision;
p.hostWriteFile(sf.pane, f.path, f.content);
const saved_pane = p.panes[sf.pane] orelse return;
if (saved_pane.serial != serial) return;
const saved = if (saved_pane.file) |*file| file else return;
// A save the host could not do (saveFailed) is its err
- // record alone, not a `save`.
+ // record alone, not a `save`, and changes nothing: a clean
+ // file stays clean.
+ if (p.fs.failures != failures) {
+ saved.saved_revision = was;
+ return;
+ }
+ // Edited while it was written: what is on disk is not this.
if (saved.saved_revision != saved.revision) return;
saved.disk_gone = false;
ctlfs.events.noteLog(p, .save, saved_pane);
@@ -5298,7 +5329,21 @@ pub const Pardes = struct {
.save_text => |st| {
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.file) |*f| {
+ const was = f.saved_revision;
+ const failures = p.fs.failures;
+ p.hostWriteFile(st.pane, st.path.slice(), f.content);
+ const after = p.panes[st.pane] orelse return;
+ if (after.serial != st.serial) return;
+ const g = if (after.file) |*file| file else return;
+ // Failed, it changes nothing: not the name, not dirty.
+ if (p.fs.failures != failures) {
+ g.saved_revision = was;
+ return;
+ }
+ if (st.promote) p.promoteSaved(st.pane, st.path.slice());
+ return;
+ }
if (!pane.isTerminal()) return;
const text = panes.Terminal.screenTextAlloc(pane, p.gpa) catch return;
defer p.gpa.free(text);