summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 04:18:04 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:15 -0300
commit35e72930c11af5563da9d62df2b74a7439f36653 (patch)
treebe71dac5f6eea6629c0a40377a6b6fb2ad14c767
parent4b64d4b237b2bc5f8197e2232c84c0d1ed1a8fdb (diff)
downloadpardes-35e72930c11af5563da9d62df2b74a7439f36653.tar.gz
pardes-35e72930c11af5563da9d62df2b74a7439f36653.zip
A file changed on disk never reloads over unsaved edits
A watched file rewritten on disk was loaded over a buffer's unsaved edits and its dirty flag cleared, so Exit and Del no longer asked about them. A clean buffer still reloads; one with edits keeps its text and stays dirty, says <file> changed on disk (get reloads it, Save overwrites it), logs changed <serial>, and its Save warns once before overwriting the newer file, as acme's Put refuses a file modified since read (exec.c:577); get asks as ever (winclean, exec.c:513). The restored-watch test's dirty case now keeps the snapshot, and the filewatch snapshot script (re-recorded by name) checks both halves: the edited pane keeps its edit and says so, the clean one reloads. Co-Authored-By: Claude Opus 5.5 <[email protected]>
-rw-r--r--docs/fs.md8
-rw-r--r--src/File.zig28
-rw-r--r--src/exec.zig10
-rw-r--r--src/file_watch.zig7
-rw-r--r--src/ninep/ctl.zig27
-rw-r--r--test/snapshots/filewatch.golden31
-rw-r--r--test/snapshots/filewatch.snap19
7 files changed, 86 insertions, 44 deletions
diff --git a/docs/fs.md b/docs/fs.md
index cf90aadc..3baac9c4 100644
--- a/docs/fs.md
+++ b/docs/fs.md
@@ -466,7 +466,13 @@ tab width — followed by rio's `current` or `notcurrent` (rio(4), `wctl`):
whether the pane has the keyboard. It takes the pane's builtins (below),
`get`, which reloads the buffer from the name it
carries (unsaved edits are refused once, `<name>: Modified (get again to
-discard)`, as acme's get asks winclean, exec.c:513), `answer <choice>`
+discard)`, as acme's get asks winclean, exec.c:513). A file that changes on
+disk reloads by itself only into a buffer with no unsaved edits; one with
+them keeps its text and stays dirty, says `<name> changed on disk (get
+reloads it, Save overwrites it)` and logs `changed <serial>`, and then its
+`Save` warns once, `<name> modified on disk since read (Save again to
+overwrite)`, as acme's Put does (exec.c:577) -- acme reloads nothing by
+itself. `answer <choice>`
for the question the pane asks on its notice band, which the log names as
`ask <serial> <what> <choices>` -- `ask 4 del k j` for Del's side from the
keyboard (`k` the pane above takes the rows, `j` the one below), `ask 4
diff --git a/src/File.zig b/src/File.zig
index 810bc766..4011301a 100644
--- a/src/File.zig
+++ b/src/File.zig
@@ -58,6 +58,12 @@ pub const State = struct {
/// as an undo to the save point is in acme.
saved_hash: ?u64 = null,
watch_after_save: bool = false,
+ /// The file changed on disk under unsaved edits, which were kept (see
+ /// `changed`): its hash, so the same change is said once, until a Save
+ /// or get settles it. A Save warns once before overwriting it, as acme's
+ /// Put does (exec.c:577, "modified since last read").
+ disk_newer: ?u64 = null,
+ disk_newer_warned: bool = false,
/// Seconds of the last content change, for the filesystem's stat; zero until edited.
mtime: u32 = 0,
/// Non-null for a generated output buffer rather than an on-disk file.
@@ -964,12 +970,30 @@ pub fn redo(p: *Pardes, pane: *Pane) void {
restoreSnap(pane, f, next);
}
-/// Commit an externally rewritten file onto the same undo history as typed
-/// edits. Unsaved work remains one `u` away; there is no third merge state.
+/// A file rewritten on disk. A clean buffer takes the new text, committed
+/// onto the same undo history as typed edits. A buffer with unsaved edits
+/// is never reloaded under them: it keeps its text and stays dirty, and
+/// says so once (`<file> changed on disk (get reloads it, Save overwrites
+/// it)`, logged `changed <serial>`); get then asks before throwing
+/// the edits away and Save before overwriting the newer file. acme reloads
+/// nothing by itself: its get asks (winclean) and its Put refuses a file
+/// modified since it was read (exec.c:577).
pub fn changed(p: *Pardes, id: u8, bytes: []const u8) void {
const pane = p.panes[id] orelse return;
const f = if (pane.file) |*file| file else return;
if (std.mem.eql(u8, f.content, bytes)) return;
+ if (f.saved_hash) |saved| if (f.revision != f.saved_revision and std.hash.Wyhash.hash(0, f.content) != saved) {
+ const disk = std.hash.Wyhash.hash(0, bytes);
+ if (f.disk_newer == disk) return;
+ f.disk_newer = disk;
+ f.disk_newer_warned = false;
+ var said: [limits.host_path_cap + 64]u8 = undefined;
+ p.setMessage(id, std.fmt.bufPrint(&said, "{s} changed on disk (get reloads it, Save overwrites it)", .{f.path}) catch "changed on disk (get reloads it, Save overwrites it)");
+ var rec: [16]u8 = undefined;
+ pardes.ctlfs.events.notePath(p, "changed", std.fmt.bufPrint(&rec, "{d}", .{pane.serial}) catch "");
+ return;
+ };
+ f.disk_newer = null;
const new = p.gpa.dupe(u8, bytes) catch return;
pushUndo(p, pane);
setContent(p, f, new);
diff --git a/src/exec.zig b/src/exec.zig
index b8312a21..953ae0f3 100644
--- a/src/exec.zig
+++ b/src/exec.zig
@@ -249,6 +249,16 @@ pub fn saveFile(p: *Pardes, id: usize) void {
const pane = p.panes[id] orelse return;
const f = if (pane.file) |*file| file else return;
if (f.output != null) return; // nothing behind it yet: saveTo, with a path
+ // Its file changed on disk under these edits: once, as acme's Put says
+ // "modified since last read" (exec.c:577), and the next Save writes.
+ if (f.disk_newer != null) {
+ if (!f.disk_newer_warned) {
+ f.disk_newer_warned = true;
+ var said: [Pardes.SavePath.cap + 64]u8 = undefined;
+ return p.reportFailure(id, std.fmt.bufPrint(&said, "{s} modified on disk since read (Save again to overwrite)", .{f.path}) catch "modified on disk since read (Save again to overwrite)");
+ }
+ f.disk_newer = null;
+ }
p.emit(.{ .save_file = .{ .pane = @intCast(id) } });
}
diff --git a/src/file_watch.zig b/src/file_watch.zig
index 5d998470..b262e8ce 100644
--- a/src/file_watch.zig
+++ b/src/file_watch.zig
@@ -665,6 +665,13 @@ test "restored file watches preserve snapshots and accept later disk changes" {
try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "later disk save\n" });
_ = reloadChanged(core, io, gpa, &watches);
+ // A snapshot restored with unsaved text keeps it: a later disk
+ // change is said, never loaded over it (File.changed).
+ if (case.dirty) {
+ try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content);
+ try std.testing.expect(pane.file.?.disk_newer != null);
+ continue;
+ }
try std.testing.expectEqualStrings("later disk save\n", pane.file.?.content);
pardes.panes.File.undo(core, pane);
try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content);
diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig
index 19ca949e..1b1288a1 100644
--- a/src/ninep/ctl.zig
+++ b/src/ninep/ctl.zig
@@ -713,6 +713,7 @@ fn get(p: *Pardes, pane: *Pane) u16 {
panes.File.setContent(p, f, bytes);
f.saved_revision = f.revision;
f.saved_hash = std.hash.Wyhash.hash(0, f.content);
+ f.disk_newer = null;
return 0;
}
@@ -1558,3 +1559,29 @@ test "a 10k-line Edit text block is taken in linear time and memory" {
try testing.expect(p.scratch.queryCapacity() < 50 * 1024 * 1024);
try testing.expectEqual(@as(usize, 10_001), std.mem.count(u8, p.panes[0].?.file.?.content, "\n"));
}
+
+test "a file changed on disk under unsaved edits is not reloaded: it says so, get asks, Save warns once" {
+ const p = try withFile(testing.allocator, "one\n");
+ defer p.deinit();
+ const serial = serialOf(p);
+ const f = &p.panes[0].?.file.?;
+ // Clean, it takes the new text.
+ pardes.panes.File.changed(p, 0, "two\n");
+ try testing.expectEqualStrings("two\n", f.content);
+ // Edited, it keeps its text and stays dirty.
+ _ = wr(p, Node.of(serial, .body), "mine\n");
+ pardes.panes.File.changed(p, 0, "three\n");
+ try testing.expectEqualStrings("two\nmine\n", f.content);
+ try testing.expect(f.revision != f.saved_revision);
+ try testing.expect(th.logHas(p, "changed on disk (get reloads it, Save overwrites it)"));
+ var want: [32]u8 = undefined;
+ try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "changed {d}\n", .{serial})));
+ // Exit and get still ask about the edits.
+ try testing.expectEqual(Status.err, wr(p, @intFromEnum(tree.TopFile.ctl), "Exit\n").reply.status);
+ try testing.expect(std.mem.indexOf(u8, wr(p, Node.of(serial, .ctl), "get\n").reply.ename, "Modified") != null);
+ // Save warns once before overwriting the newer file, then writes.
+ const first = wr(p, Node.of(serial, .ctl), "Save\n");
+ try testing.expectEqual(Status.err, first.reply.status);
+ try testing.expect(std.mem.indexOf(u8, first.reply.ename, "modified on disk since read") != null);
+ try testing.expect(wr(p, Node.of(serial, .ctl), "Save\n").saved);
+}
diff --git a/test/snapshots/filewatch.golden b/test/snapshots/filewatch.golden
index f19168b1..589cfc3a 100644
--- a/test/snapshots/filewatch.golden
+++ b/test/snapshots/filewatch.golden
@@ -28,20 +28,8 @@
| 7 line 7
| 8 line 8
| 9
-== snap reloaded grid=210x31 cursor=15,7
-|6: /tmp/pardes-snap/filewatch/cwd/w.txt Save Tty Collapse Del
-|7: 1 EXTERNAL --:--:-- reloaded /tmp/pardes-snap/filewatch/cwd/w.txt
-|8: 2
-|9:
-|10:
-|11:
-|12:
-|13:
-|14:
-|15:
-|16:
-|17:
-|18:
+== snap kept grid=210x31 cursor=15,7
+|7: 1 liMYEDITne 1 /tmp/pardes-snap/filewatch/cwd/w.txt changed on disk (get reloads it, Save overwrites it)
== snap reloaded_sub grid=210x31 cursor=15,7
|20: 1 RENAMED --:--:-- reloaded /tmp/pardes-snap/filewatch/cwd/sub/s.txt
|21: 2
@@ -52,18 +40,3 @@
|26:
|27:
|28:
-== snap undone grid=210x31 cursor=15,7
-|6: * /tmp/pardes-snap/filewatch/cwd/w.txt Save Tty Collapse Del
-|7: 1 liMYEDITne 1
-|8: 2 line 2
-|9: 3 /tmp/pardes-snap/filewatch/cwd/sub/s.txt
-|10: 4 line 4
-|11: 5 line 5
-|12: 6 line 6
-|13: 7 line 7
-|14: 8 line 8
-|15: 9 line 9
-|16: 10 line 10
-|17: 11 line 11
-|18: 12 line 12
-|20: 1 RENAMED
diff --git a/test/snapshots/filewatch.snap b/test/snapshots/filewatch.snap
index 9e6e6a13..6664519f 100644
--- a/test/snapshots/filewatch.snap
+++ b/test/snapshots/filewatch.snap
@@ -1,6 +1,6 @@
-# external file updates: a write from outside pardes lands in the open pane,
-# and Undo brings the unsaved edit back. That is the whole contract — the
-# update is committed like any other edit, so nothing has to be merged.
+# external file updates: a write from outside pardes lands in an open pane
+# that has no unsaved edits, committed like any other edit; a pane WITH
+# unsaved edits keeps them and says the file changed on disk (File.changed).
#
# The writer must be a STRANGER to the editor. This script used to have a
# pardes PANE run the write, which proved much less than it looked: that
@@ -54,18 +54,13 @@ settle 100
stable 400 5000
snap edited
# ---- from here to `snap reloaded_sub`, pardes is sent NOTHING ----
+# w.txt has the unsaved edit: it is kept, and the change is said
run printf 'EXTERNAL\n' > w.txt
-wait 10000 EXTERNAL
+wait 10000 changed on disk
stable 700 10000
-snap reloaded
+snap kept
+# sub/s.txt has none: it takes the new text
run printf 'RENAMED\n' > sub/t && mv sub/t sub/s.txt
wait 10000 RENAMED
stable 700 10000
snap reloaded_sub
-# back in the file pane: one undo and the unsaved edit is there again
-press left 10 8
-release left 10 8
-stable 400 5000
-key u
-stable 400 5000
-snap undone