From 24729dbbf8b32966a3f33c9bc20599b185856dd3 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 05:29:45 -0300 Subject: get and a deleted file say what happened: the path, changed deleted, reloaded get on a file gone from disk failed with a bare ENOENT; it now says get: : no such file. A watched file deleted on disk was silent; the pane keeps its text and says deleted on disk (Save writes it again) once, logged changed deleted (only a path the watch has read: a pane renamed to a name never on disk is not deleted). A get that throws a dirty buffer's state away logs changed reloaded, as the watch's reload does. Co-Authored-By: Claude Opus 5.5 --- src/File.zig | 18 ++++++++++++++++++ src/file_watch.zig | 12 +++++++++++- src/ninep/ctl.zig | 38 +++++++++++++++++++++++++++++++++++++- src/pardes.zig | 1 + 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/File.zig b/src/File.zig index 17399d8b..5f6d7097 100644 --- a/src/File.zig +++ b/src/File.zig @@ -64,6 +64,9 @@ pub const State = struct { /// Put does (exec.c:577, "modified since last read"). disk_newer: ?u64 = null, disk_newer_warned: bool = false, + /// Its file was deleted on disk: said once (`deleted`) until it is + /// written or found again. + disk_gone: 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. @@ -970,6 +973,20 @@ pub fn redo(p: *Pardes, pane: *Pane) void { restoreSnap(pane, f, next); } +/// A pane's file deleted on disk: the text stays, and it says so once, +/// ` deleted on disk (Save writes it again)`, logged `changed +/// deleted`. +pub fn deleted(p: *Pardes, id: u8) void { + const pane = p.panes[id] orelse return; + const f = if (pane.file) |*file| file else return; + if (f.disk_gone) return; + f.disk_gone = true; + var said: [limits.host_path_cap + 64]u8 = undefined; + p.setMessage(id, std.fmt.bufPrint(&said, "{s} deleted on disk (Save writes it again)", .{f.path}) catch "deleted on disk (Save writes it again)"); + var rec: [32]u8 = undefined; + pardes.ctlfs.events.notePath(p, "changed", std.fmt.bufPrint(&rec, "{d} deleted", .{pane.serial}) catch ""); +} + /// 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 @@ -981,6 +998,7 @@ pub fn redo(p: *Pardes, pane: *Pane) void { 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; + f.disk_gone = false; 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); diff --git a/src/file_watch.zig b/src/file_watch.zig index b262e8ce..71a4680d 100644 --- a/src/file_watch.zig +++ b/src/file_watch.zig @@ -60,6 +60,10 @@ pub const Watch = struct { kq: c_int = -1, serial: u32, generation: Generation, + /// The path last read while watched (its hash): its going away later is + /// a deletion to say, where a path never there (a pane renamed to a new + /// name) is not. + existed: ?u64 = null, }; /// The final slot is the one non-pane watched payload: the active ThemeFile. @@ -440,7 +444,12 @@ pub fn reloadPane( } if (pane.file) |file| { - const bytes = filesystem.read(core, file.path) catch return false; + const bytes = filesystem.read(core, file.path) catch |err| { + // Gone from under the pane: said once, the text kept. + if (err == error.FileNotFound and watched.existed == std.hash.Wyhash.hash(0, file.path)) pardes.panes.File.deleted(core, @intCast(id)); + return false; + }; + if (watches[id]) |*live| live.existed = std.hash.Wyhash.hash(0, file.path); defer core.gpa.free(bytes); const hash = std.hash.Wyhash.hash(0, bytes); switch (watched.generation) { @@ -517,6 +526,7 @@ pub fn applyEffect( const bytes = filesystem.read(core, file.path) catch return false; defer core.gpa.free(bytes); watched.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) }; + watched.existed = std.hash.Wyhash.hash(0, file.path); return false; }; return on and watches[id] != null and reloadPane(core, io, watches, id, false); diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 7d5f0ca0..112df099 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -722,7 +722,11 @@ pub fn writePane(p: *Pardes, req: Req, pane: *Pane) Reply { return .{ .tag = req.tag, .status = .err, .errno = E.IO, .ename = r.ename }; } const errno = get(p, pane); - if (errno != 0) return Reply.fail(req.tag, errno); + // Naming the path, in words 9ns reads as the errno. + if (errno != 0) { + const path = if (pane_files.fileOf(pane)) |f| f.path else ""; + return tree.failText(req.tag, errno, std.fmt.bufPrint(&p.fs.ename, "get: {s}: {s}", .{ path[0..@min(path.len, 256)], if (errno == E.NOENT) "no such file" else "cannot be read" }) catch "get: no such file"); + } } else if (std.mem.eql(u8, line, "lock")) { if (mine == null) return tree.failText(req.tag, E.INVAL, tree.e_bad_ctl); if (other) return tree.failText(req.tag, E.BUSY, e_locked); @@ -799,11 +803,19 @@ fn get(p: *Pardes, pane: *Pane) u16 { error.FileNotFound => E.NOENT, else => E.IO, }; + // Throwing unsaved edits away for it is a reload the log hears of, as a + // clean one from the watch is. + const discarded = f.revision != f.saved_revision; panes.File.pushUndo(p, pane); 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; + f.disk_gone = false; + if (discarded) { + var rec: [32]u8 = undefined; + pardes.ctlfs.events.notePath(p, "changed", std.fmt.bufPrint(&rec, "{d} reloaded", .{pane.serial}) catch ""); + } return 0; } @@ -1363,6 +1375,9 @@ test "get reloads the pane from disk and reports a missing file without losing d try testing.expectEqualStrings("from disk\n", pane.file.?.content); try testing.expect(!pane_files.dirtyOf(pane)); try testing.expect(pane.file.?.history.undo_len > 0); + // What it had (renamed, so dirty) was thrown away for it: logged. + var reloaded: [32]u8 = undefined; + try testing.expect(th.logHas(p, try std.fmt.bufPrint(&reloaded, "changed {d} reloaded\n", .{serial}))); _ = wr(p, Node.of(serial, .name), try std.fmt.bufPrint(&name, "{s}/missing.txt\n", .{directory})); _ = wr(p, Node.of(serial, .body), "unsaved\n"); @@ -1784,3 +1799,24 @@ test "Save on a terminal asked over 9P is an ask in the log, answered with a pat try testing.expect(p.panes[id].?.prompt == .none); try testing.expect(th.logHas(p, try std.fmt.bufPrint(&want, "answer {d} /tmp/pardes-ask-save.txt\n", .{serial}))); } + +test "get names the path it could not read, logs the edits it threw away, and a deleted file is said once" { + const p = try withFile(testing.allocator, "x\n"); + defer p.deinit(); + const serial = serialOf(p); + const f = &p.panes[0].?.file.?; + const path = f.path; + // Its file is not on disk: get names it. + const missing = wr(p, Node.of(serial, .ctl), "get\n"); + try testing.expectEqual(E.NOENT, missing.errno()); + var want: [512]u8 = undefined; + try testing.expectEqualStrings(try std.fmt.bufPrint(&want, "get: {s}: no such file", .{path}), missing.reply.ename); + // Deleted on disk: said once. + pardes.panes.File.deleted(p, 0); + pardes.panes.File.deleted(p, 0); + var rec: [32]u8 = undefined; + const log = @intFromEnum(tree.TopFile.log); + const h = call(p, .{ .tag = 1, .op = .open, .node = log }).reply.handle; + try testing.expectEqual(@as(usize, 1), std.mem.count(u8, call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes, try std.fmt.bufPrint(&rec, "changed {d} deleted\n", .{serial}))); + _ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h }); +} diff --git a/src/pardes.zig b/src/pardes.zig index 59e8670f..dd9d91c8 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -5277,6 +5277,7 @@ pub const Pardes = struct { // A save the host could not do (saveFailed) is its err // record alone, not a `save`. if (saved.saved_revision != saved.revision) return; + saved.disk_gone = false; ctlfs.events.noteLog(p, .save, saved_pane); if (!saved.watch_after_save) return; saved.watch_after_save = false; -- cgit v1.3