diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/file_pane.zig | 3 | ||||
| -rw-r--r-- | src/gui/gui.zig | 10 | ||||
| -rw-r--r-- | src/pardes.zig | 59 | ||||
| -rw-r--r-- | src/tty/tty.zig | 157 |
4 files changed, 225 insertions, 4 deletions
diff --git a/src/file_pane.zig b/src/file_pane.zig index 68fea40f..739d7b54 100644 --- a/src/file_pane.zig +++ b/src/file_pane.zig @@ -33,6 +33,9 @@ pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane { pane.file = .{ .path = path_copy, .content = content, .scroll = scroll }; pane.cur_pinned = true; pane.cur_row = @intCast(scroll); + // watches follow pane lifetime: this is the only place a real file is read + // off disk, and deinitPane is the only place one goes away + p.emit(.{ .watch = .{ .pane = @intCast(id), .on = true } }); return pane; } diff --git a/src/gui/gui.zig b/src/gui/gui.zig index d8086981..998573b5 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -2137,6 +2137,11 @@ fn drainEffects( _ = c.SDL_SetClipboardText(z.ptr); }, .lsp => |e| if (threads_ok) spawnLsp(core, gpa, queue, e), + // ponytail: the SDL shell does not watch files. Everything the core + // needs is already here (the effect and the file_changed event) — what + // is missing is the ~50 lines of inotify plumbing in tty.zig, and a + // shell that never delivers the event simply never reloads. + .watch => {}, .quit => {}, }; } @@ -2174,8 +2179,9 @@ fn drainEffectsWeb(core: *pardes.Pardes, gpa: std.mem.Allocator, g: *Gui) void { }, // look on a URL → a new tab .open_link => |url| openLinkWeb(gpa, url.slice()), - // nothing to spawn/write/resize/save/dump into — all no-ops - .spawn, .write, .resize_pty, .save_file, .write_dump, .lsp, .quit => {}, + // nothing to spawn/write/resize/save/dump into, and no filesystem to + // watch — all no-ops + .spawn, .write, .resize_pty, .save_file, .write_dump, .lsp, .watch, .quit => {}, }; } 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 => { 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. |
