diff options
| author | Gabriel Schneider <[email protected]> | 2026-07-31 04:15:41 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-01 15:02:08 -0300 |
| commit | eefac04995ffad847a4098f16d2e82ccab16438b (patch) | |
| tree | 1c8e1e2f6e0474c1ebd7e5cddd7c60f523d5ed51 /src/pardes.zig | |
| parent | 32b4245962b75916be0ebe89225375430e03c36f (diff) | |
| download | pardes-eefac04995ffad847a4098f16d2e82ccab16438b.tar.gz pardes-eefac04995ffad847a4098f16d2e82ccab16438b.zip | |
open files follow the disk, and undo is the merge strategy
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.
Diffstat (limited to 'src/pardes.zig')
| -rw-r--r-- | src/pardes.zig | 59 |
1 files changed, 59 insertions, 0 deletions
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 => { |
