diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-29 04:18:04 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:15 -0300 |
| commit | 35e72930c11af5563da9d62df2b74a7439f36653 (patch) | |
| tree | be71dac5f6eea6629c0a40377a6b6fb2ad14c767 | |
| parent | 4b64d4b237b2bc5f8197e2232c84c0d1ed1a8fdb (diff) | |
| download | pardes-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.md | 8 | ||||
| -rw-r--r-- | src/File.zig | 28 | ||||
| -rw-r--r-- | src/exec.zig | 10 | ||||
| -rw-r--r-- | src/file_watch.zig | 7 | ||||
| -rw-r--r-- | src/ninep/ctl.zig | 27 | ||||
| -rw-r--r-- | test/snapshots/filewatch.golden | 31 | ||||
| -rw-r--r-- | test/snapshots/filewatch.snap | 19 |
7 files changed, 86 insertions, 44 deletions
@@ -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 |
