From eefac04995ffad847a4098f16d2e82ccab16438b Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Fri, 31 Jul 2026 04:15:41 -0300 Subject: open files follow the disk, and undo is the merge strategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An external update is pushed onto the undo stack exactly like an edit the user typed, so unsaved work is one `u` away and pardes never has to merge anything. That is the design, not an implementation detail: the whole feature is pushUndo() then setContent(). One inotify instance in the tty shell, blocking in readVec through std.Io on a concurrent task started beside the pty readers — after loop.start(), so the forkpty ordering is untouched. It watches the containing DIRECTORY, because an editor rewrites by rename-over and a watch on the file would follow the dead inode, and it listens for CLOSE_WRITE rather than MODIFY, which is one event per finished writer and most of the debounce for free. Our own Save does not reach the undo stack: each watch keeps a hash of the bytes last seen on disk and save_file restamps it. A hash rather than mtime and size because the reload has to read the file anyway. The core stays sans-IO — one watch effect out, one file_changed event in, and a shell that cannot watch simply never sends the event, which is what the gui and the web platform do. Linux only; fanotify is what the build system uses and is rejected in a comment: it exists for thousands of directories across mounts, and sixteen panes of inotify is a third of the code with no kernel floor. New golden filewatch: edit without saving, overwrite from a shell in another column, watch it reload, undo, get the unsaved edit back. None moved. --- src/pardes.zig | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) (limited to 'src/pardes.zig') diff --git a/src/pardes.zig b/src/pardes.zig index 93ea1016..c1f8df44 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -555,6 +555,11 @@ pub const Event = union(enum) { /// stale answer (the pane was closed, or a newer query superseded it) and /// is dropped. lsp_resp: struct { id: u32, rows: []const u8 }, + /// a file the shell was asked to watch changed on disk; `bytes` are its + /// new contents, borrowed for this call exactly like `output`. A shell + /// with no filesystem (the browser) or no watcher simply never sends one — + /// nothing in the core waits for it. + file_changed: struct { pane: u8, bytes: []const u8 }, paste: []const u8, pinch: f32, touch_scroll: f32, @@ -582,6 +587,12 @@ pub const Effect = union(enum) { /// core (like save_file) and must SNAPSHOT them before the worker starts — /// the core keeps editing while this is in flight. lsp: struct { id: u32, kind: lsp.Kind, pane: u8, offset: u32, arg: Buf(128) }, + /// start (`on`) or stop watching this pane's file on disk. Starting, the + /// shell reads the path off the core exactly like save_file does; stopping + /// carries nothing, because by the time an `off` is drained the pane is + /// already freed — the shell remembers what it watches per pane id. + /// Only real files ask for this: an output buffer has no file behind it. + watch: struct { pane: u8, on: bool }, quit, fn Buf(comptime n: usize) type { @@ -1151,6 +1162,14 @@ pub const Pardes = struct { if (iv.grid.len > 0) p.gpa.free(iv.grid); } if (pane.file) |*f| { + // a watched file is going away: tell the shell to drop it. The id + // comes from the slot, which every caller still has pointing at + // `pane` (they null it right after) — deinitPane has never needed + // one before and threading it through seven call sites for this is + // worse than the scan. + if (f.output == null) for (p.panes, 0..) |slot, i| { + if (slot == pane) p.emit(.{ .watch = .{ .pane = @intCast(i), .on = false } }); + }; p.gpa.free(f.path); p.gpa.free(f.content); if (f.highlights.len > 0) p.gpa.free(f.highlights); @@ -1283,6 +1302,7 @@ pub const Pardes = struct { }, .eof => |e| p.removePane(e.pane), .lsp_resp => |r| p.lspResponse(r.id, r.rows), + .file_changed => |fc| p.fileChanged(fc.pane, fc.bytes), .key => |key| p.handleKey(key), .mouse => |m| p.handleMouse(m), .paste => |bytes| { @@ -4271,6 +4291,38 @@ pub const Pardes = struct { term_pane.restoreSnap(p, pane, pane.ed_redo.pop() orelse return); } + /// Something outside pardes rewrote a file we have open. Commit the buffer + /// we are holding — unsaved edits and all — onto the undo stack, then swap + /// in the bytes from disk. + /// + /// THAT IS THE WHOLE DESIGN, not an implementation detail. An external + /// update is pushed exactly like an edit the user typed, so `u` walks back + /// to the buffer as it was a keystroke ago and unsaved work is never lost. + /// It is also why the feature is nearly free: pardes never has to merge its + /// buffer against the new file, never has to ask "reload? keep? diff?", and + /// there is no third state between "what is on disk" and "what I typed" — + /// the undo stack, which already exists, IS the history of both. + fn fileChanged(p: *Pardes, id: u8, bytes: []const u8) void { + const pane = p.panes[id] orelse return; + if (pane.file == null) return; + const f = &pane.file.?; + // the disk arrived at what we already hold: an undo step that changes + // nothing is worse than no undo step + if (std.mem.eql(u8, f.content, bytes)) return; + const new = p.gpa.dupe(u8, bytes) catch return; + p.pushUndo(pane); + file_pane.setContent(p, f, new); + // keep the cursor where it was, clamped onto the new text: restoreSnap + // reads only the cursor and selection out of the snapshot it is handed, + // so handing it the pane's own is exactly "clamp what you have". + file_pane.restoreSnap(pane, f, .{ + .content = undefined, + .cur_row = pane.cur_row, + .cur_col = pane.cur_col, + .vsel = pane.vsel, + }); + } + fn handleMouse(p: *Pardes, m: Mouse) void { const mcol = @min(m.col, p.screen_w -| 1); const mrow = @min(m.row, p.screen_h -| 1); @@ -5506,6 +5558,13 @@ pub const Pardes = struct { pane.cur_row = @intCast(src.scroll); pane.cols = @max(1, src.cols); pane.rows = @max(1, src.rows); + // a restored file pane is watched like an opened one. Note + // its content is the DUMP's, which may already differ from + // disk; the shell stamps the watch from what it has, so the + // first external write reconciles the pane to disk and the + // dump's version lands one `u` away — which is the same + // bargain as any other external update. + if (out == null) p.emit(.{ .watch = .{ .pane = @intCast(i), .on = true } }); p.restoreTail(pane, src.tag); }, .image => { -- cgit v1.3