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/tty/tty.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/tty/tty.zig')
| -rw-r--r-- | src/tty/tty.zig | 157 |
1 files changed, 155 insertions, 2 deletions
diff --git a/src/tty/tty.zig b/src/tty/tty.zig index b4bd4175..e2fe10e8 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -6,6 +6,7 @@ const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; const libc = std.c; +const linux = std.os.linux; const vaxis = @import("vaxis"); const pardes = @import("../pardes.zig"); const tracy = @import("../tracy.zig"); @@ -36,10 +37,32 @@ pub const Command = struct { paste: []const u8, /// a language query finished on a worker; rows are gpa-owned lsp_done: struct { id: u32, rows: []u8 }, + /// something happened in a watched directory (see watchFiles) + files_changed, } = .nop; }; const Loop = vaxis.Loop(@TypeOf(Command.value)); +/// The files on open panes, watched through ONE inotify instance. +/// +/// `wd` is the descriptor for the file's CONTAINING DIRECTORY, not for the +/// file: almost nothing rewrites a file in place. An editor, `git checkout`, a +/// formatter — they write a temp file and rename it over the target, which +/// swaps the inode, and a watch on the file itself keeps reporting on the old +/// unlinked one forever. The directory sees the new inode arrive (MOVED_TO / +/// CREATE). std.Build.Watch marks directories for exactly this reason; it uses +/// fanotify because it watches thousands of them across mount points and needs +/// FID-keyed dedup, whereas sixteen panes' worth of inotify is a third of the +/// code, hands us no privilege question, and has no kernel version floor. +/// +/// `hash` is what we last saw ON DISK. A reload only fires when it moves, so +/// pardes's own Save — which restamps the hash from the bytes it just wrote — +/// never looks like an external change and never touches the undo stack. +/// A hash rather than mtime+size because we have to read the file to hand the +/// core its bytes anyway, so the exact answer costs nothing extra and there is +/// no clock granularity to reason about. +const Watch = struct { wd: c_int, hash: u64 }; + /// One language query, owned by the worker that runs it. Everything the /// backend may read is copied in here before the worker starts: the core goes /// on editing the moment the effect is drained, so a borrowed slice would be a @@ -127,6 +150,13 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { var gens: [pardes.MAX_PANES]u32 = @splat(0); // the single in-flight language query (see the .lsp effect) var lsp_task: ?std.Io.Future(anyerror!void) = null; + // One inotify instance for every watched pane, opened here — before any + // thread exists — so the pre-loop effect drain below can already mark the + // file a positional path argument opened. -1 off linux: watchPane goes + // quiet and the core simply never gets a file_changed event. + var inotify_fd: c_int = if (builtin.os.tag == .linux) libc.inotify_init1(linux.IN.CLOEXEC) else -1; + var watches: [pardes.MAX_PANES]?Watch = @splat(null); + var watch_task: ?std.Io.Future(anyerror!void) = null; var loop: Loop = .init(io, &tty, &vx); defer { // reap the reader tasks (cancel interrupts a blocked read) before @@ -144,6 +174,16 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { t.cancel(io) catch {}; lsp_task = null; } + // same contract as the pty readers: cancel unblocks the watcher's + // read, and only then is the fd safe to close + if (watch_task) |*t| { + t.cancel(io) catch {}; + watch_task = null; + } + if (inotify_fd >= 0) { + _ = libc.close(inotify_fd); + inotify_fd = -1; + } while (loop.tryEvent() catch null) |ev| switch (ev) { .pty_read => |pr| gpa.free(pr.bytes), .paste => |b| gpa.free(@constCast(b)), @@ -154,7 +194,7 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { // Perform the initial spawns BEFORE any worker thread exists: forkpty from // a multithreaded process can wedge the child before exec. - drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, false); + drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, inotify_fd, &watches, false); try loop.start(); defer loop.stop(); @@ -207,7 +247,13 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { for (&ptys, 0..) |*slot, id| if (slot.*) |*pt| { pt.reader = try io.concurrent(readPty, .{ io, gpa, pt.file, id, gens[id], &loop }); }; + // ...and the one file watcher. Started here rather than lazily on the + // first watched pane because the fd already exists and an unwatched + // inotify instance just parks in read(2) — one thread for the process, + // however many panes come and go. + if (inotify_fd >= 0) watch_task = io.concurrent(watchFiles, .{ io, inotify_fd, &loop }) catch null; + var check_files = false; var pending: ?@TypeOf(Command.value) = .tick; while (!core.quit) { var event = if (pending) |ev| blk: { @@ -279,6 +325,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { core.update(.{ .paste = bytes }); gpa.free(@constCast(bytes)); }, + // Coalesced on purpose: a burst of writes (a formatter, a + // build, a `git checkout`) collapses into ONE pass below, so + // it cannot queue a reload — or an undo entry — per write. + .files_changed => check_files = true, .lsp_done => |d| { core.update(.{ .lsp_resp = .{ .id = d.id, .rows = d.rows } }); gpa.free(d.rows); @@ -297,7 +347,29 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { tz_event.end(); if (stop) break; - drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, true); + // A watched directory moved. The wake says only THAT something + // happened, so re-read every watched pane and hand the core the ones + // whose bytes actually changed — that comparison is what keeps our own + // Save, and any write that lands on identical content, out of the undo + // stack. Reading here rather than on the watcher thread because the + // core is the only thing that knows which pane a path belongs to, and + // opening a file pane already reads synchronously on this thread. + if (check_files) { + check_files = false; + for (&watches, 0..) |*slot, id| { + if (slot.* == null) continue; + const pane = core.panes[id] orelse continue; + const f = pane.file orelse continue; + const bytes = look.readFile(gpa, f.path) catch continue; + defer gpa.free(bytes); + const h = std.hash.Wyhash.hash(0, bytes); + if (h == slot.*.?.hash) continue; + slot.*.?.hash = h; + core.update(.{ .file_changed = .{ .pane = @intCast(id), .bytes = bytes } }); + } + } + + drainEffects(core, &ptys, &gens, io, gpa, &loop, &vx, &tty, &lsp_task, inotify_fd, &watches, true); if (core.quit) break; // Restore builtin: swap in a core rebuilt from the dump; the live @@ -316,6 +388,10 @@ pub fn run(init: std.process.Init, opts: pardes.Options) !void { slot.* = null; gens[pid] +%= 1; }; + // the replay core's pane ids mean new things, and the dying core's + // `watch off` effects go into a queue nobody drains — drop the lot + // here. The new core emits its own `on`s as it builds its panes. + for (0..watches.len) |wid| watchPane(inotify_fd, &watches, @intCast(wid), null, 0); kitty_handles = @splat(null); core.deinit(); core = nc; @@ -401,6 +477,8 @@ fn drainEffects( vx: *vaxis.Vaxis, tty: *vaxis.Tty, lsp_task: *?std.Io.Future(anyerror!void), + inotify_fd: c_int, + watches: *[pardes.MAX_PANES]?Watch, threads_ok: bool, ) void { while (core.nextEffect()) |effect| switch (effect) { @@ -454,6 +532,9 @@ fn drainEffects( if (fd < 0) continue; writeFd(fd, f.content); _ = libc.close(fd); + // our own write is about to come back as a watch event: restamp + // from the bytes we just put there so it reads as "no change" + if (watches[sf.pane]) |*w| w.hash = std.hash.Wyhash.hash(0, f.content); }, .write_dump => { const out = core.dump_out orelse continue; @@ -517,6 +598,17 @@ fn drainEffects( continue; }; }, + .watch => |w| { + // starting, the path and the on-disk bytes are read off the core + // (same split as save_file); stopping, the pane is already gone + var path: ?[]const u8 = null; + var hash: u64 = 0; + if (w.on) if (core.panes[w.pane]) |pane| if (pane.file) |f| { + path = f.path; + hash = std.hash.Wyhash.hash(0, f.content); + }; + watchPane(inotify_fd, watches, w.pane, path, hash); + }, .quit => {}, }; } @@ -545,6 +637,67 @@ fn lspWorker(gpa: std.mem.Allocator, job: *LspJob, loop: *Loop) anyerror!void { return; } +/// Mark or unmark one pane's file (`path` null = unmark). Linux only: on any +/// other OS this returns silently, the core never receives a file_changed +/// event, and the feature is simply off — a state the core has to tolerate +/// regardless, since the browser shell has no filesystem at all. +/// ponytail: darwin wants the FSEvents half of std.Build.Watch here. +fn watchPane(fd: c_int, watches: *[pardes.MAX_PANES]?Watch, id: u8, path: ?[]const u8, hash: u64) void { + if (comptime builtin.os.tag != .linux) return; + if (fd < 0) return; + if (watches[id]) |old| { + // inotify hands out ONE descriptor per directory, so two panes on + // files in the same directory share it: drop the mark only when the + // last of them lets go, or closing one blinds the other. + var shared = false; + for (watches, 0..) |other, i| { + const o = other orelse continue; + if (i != id and o.wd == old.wd) shared = true; + } + if (!shared) _ = libc.inotify_rm_watch(fd, old.wd); + watches[id] = null; + } + const p = path orelse return; + const dir = std.fs.path.dirname(p) orelse "."; + var dbuf: [4096:0]u8 = undefined; + if (dir.len >= dbuf.len) return; + @memcpy(dbuf[0..dir.len], dir); + dbuf[dir.len] = 0; + // CLOSE_WRITE, not MODIFY: one event when a writer is DONE rather than one + // per write(2), which is most of the debounce for free. MOVED_TO and + // CREATE catch the rename-over and the delete-then-recreate that are how + // files are actually replaced (see Watch). + const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.ONLYDIR; + const wd = libc.inotify_add_watch(fd, dbuf[0..dir.len :0], mask); + if (wd < 0) return; + watches[id] = .{ .wd = wd, .hash = hash }; +} + +/// Block on the inotify fd and wake the loop. Deliberately does NOT parse the +/// events: the loop re-reads every watched pane anyway, so the only thing an +/// event carries that we need is THAT something happened, and parsing would +/// mean sharing the watch table with the thread that mutates it. Same shape as +/// readPty — block off the loop, hand the loop an event, keep the core a state +/// machine that never blocks. Going through std.Io.File rather than a raw +/// read(2) is what lets the teardown `cancel` interrupt it. +/// +/// ponytail: churn in a watched directory that never touches the watched file +/// still costs a wake and a re-read per event. The ceiling is one directory +/// per open file pane; filter by basename here if it ever shows up in a +/// profile. +fn watchFiles(io: std.Io, fd: c_int, loop: *Loop) anyerror!void { + const file: std.Io.File = .{ .handle = fd, .flags = .{ .nonblocking = false } }; + var read_buf: [4096]u8 = undefined; + var reader = file.readerStreaming(io, &read_buf); + while (true) { + var buf: [4096]u8 = undefined; + var vec = [_][]u8{&buf}; + const n = reader.interface.readVec(&vec) catch break; + if (n == 0) break; + loop.postEvent(.files_changed) catch break; + } +} + /// Consume SIGWINCH synchronously (it is blocked in every thread) and post /// the new size as a winsize event from normal thread context — the one place /// vaxis's Io-backed queue is safe to touch on a resize. |
