summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/gui/gui.zig148
1 files changed, 136 insertions, 12 deletions
diff --git a/src/gui/gui.zig b/src/gui/gui.zig
index 998573b5..8aaeb4ec 100644
--- a/src/gui/gui.zig
+++ b/src/gui/gui.zig
@@ -14,6 +14,7 @@ const std = @import("std");
const builtin = @import("builtin");
const posix = std.posix;
const libc = std.c;
+const linux = std.os.linux; // inotify constants; referenced only on linux
const vaxis = @import("vaxis"); // test modes only: the stdin escape-seq parser
const ghostty_vt = @import("ghostty-vt"); // 256-color palette for .index cells
const pardes = @import("../pardes.zig");
@@ -499,8 +500,18 @@ const Msg = union(enum) {
eof: struct { pane: u8, gen: u32, fd: c_int },
/// a language query finished on its own thread (see lspThread)
lsp: struct { id: u32, rows: []u8 },
+ /// something happened in a watched directory (see watchThread)
+ files_changed,
};
+/// The files on open panes, watched through ONE inotify instance. Same shape
+/// and same reasoning as tty.zig's Watch, which spells it out: the mark goes on
+/// the containing DIRECTORY because nothing rewrites a file in place — a
+/// formatter, a checkout, an editor all rename a temp file over the target and
+/// swap the inode — and `hash` is what we last saw ON DISK, so our own Save
+/// never reads as an external change.
+const Watch = struct { wd: c_int, hash: u64 };
+
/// One language query, owned by the thread running it — the gui twin of
/// tty.zig's LspJob, and copied for the same reason: the core edits on.
const LspJob = struct {
@@ -541,7 +552,7 @@ const Queue = struct {
switch (m) {
.output => |o| q.gpa.free(o.bytes),
.lsp => |l| q.gpa.free(l.rows),
- .eof => {},
+ .eof, .files_changed => {},
}
return;
}
@@ -550,7 +561,7 @@ const Queue = struct {
switch (m) {
.output => |o| q.gpa.free(o.bytes),
.lsp => |l| q.gpa.free(l.rows),
- .eof => {},
+ .eof, .files_changed => {},
}
return;
};
@@ -578,7 +589,7 @@ const Queue = struct {
for (q.items.items) |m| switch (m) {
.output => |o| q.gpa.free(o.bytes),
.lsp => |l| q.gpa.free(l.rows),
- .eof => {},
+ .eof, .files_changed => {},
};
q.items.deinit(q.gpa);
}
@@ -604,6 +615,79 @@ fn spawnReader(gpa: std.mem.Allocator, pt: Pty, pane: u8, gen: u32, q: *Queue) v
th.detach();
}
+/// Mark or unmark one pane's file (`path` null = unmark). Linux only: anywhere
+/// else this returns silently, the core never receives a file_changed event,
+/// and the feature is simply off — which the core already has to tolerate,
+/// since the browser build of this same 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). MOVED_TO and CREATE catch the rename-over and the
+ // delete-then-recreate that are how files are actually replaced.
+ 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. Detached like
+/// the pty readers, and ended the same way — teardown closes the fd, the read
+/// fails, the thread returns.
+fn watchThread(fd: c_int, q: *Queue) void {
+ var buf: [4096]u8 = undefined;
+ while (true) {
+ const n = libc.read(fd, &buf, buf.len);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ break;
+ }
+ if (n == 0) break;
+ q.push(.files_changed);
+ }
+}
+
+/// Hand the core every watched pane whose bytes moved on disk. The wake says
+/// only THAT something happened, so this re-reads the lot; the hash comparison
+/// is what keeps our own Save — and any write that lands on identical content
+/// — out of the undo stack. Read on the loop rather than on the watcher thread
+/// because the core is the only thing that knows which pane a path belongs to.
+fn reloadChanged(core: *pardes.Pardes, gpa: std.mem.Allocator, watches: *[pardes.MAX_PANES]?Watch) void {
+ 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 } });
+ }
+}
+
/// Answer a language query off the render loop and push the rows to the queue
/// — the async execution model, spelled in the plumbing this shell already has
/// (a detached thread and the mutex queue the pty readers use).
@@ -973,11 +1057,25 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
};
var queue: Queue = .{ .gpa = gpa, .sdl_wake = true };
defer queue.close();
+ // One inotify instance for every watched pane, opened here — before any
+ // thread exists — so the pre-loop 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;
+ defer if (inotify_fd >= 0) {
+ _ = libc.close(inotify_fd); // ends the detached watcher's read
+ inotify_fd = -1;
+ };
+ var watches: [pardes.MAX_PANES]?Watch = @splat(null);
// initial spawns BEFORE any worker thread exists: forkpty from a
// multithreaded process can wedge the child before exec (see tty.zig).
- drainEffects(core, &ptys, &gens, gpa, &queue, &g, false);
+ drainEffects(core, &ptys, &gens, gpa, &queue, &g, inotify_fd, &watches, false);
for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue);
+ // ...and the one file watcher. Started even with nothing marked yet: 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) if (std.Thread.spawn(.{}, watchThread, .{ inotify_fd, &queue })) |th| th.detach() else |_| {};
_ = c.SDL_StartTextInput(window);
@@ -1001,6 +1099,7 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
}
// 2. pty output from the reader threads
var msgs = queue.take();
+ var check_files = false;
for (msgs.items) |m| switch (m) {
.output => |o| {
if (gens[o.pane] == o.gen)
@@ -1018,12 +1117,17 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
core.update(.{ .lsp_resp = .{ .id = l.id, .rows = l.rows } });
gpa.free(l.rows);
},
+ // 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,
};
msgs.deinit(gpa);
+ if (check_files) reloadChanged(core, gpa, &watches);
// 3. steamdeck: poll gamepad axes into virtual cursor / wheel events
pollGamepad(&g, core);
// 4. effects
- drainEffects(core, &ptys, &gens, gpa, &queue, &g, true);
+ drainEffects(core, &ptys, &gens, gpa, &queue, &g, inotify_fd, &watches, true);
if (core.quit) break;
// Restore builtin: swap in a core rebuilt from the dump; kill the live
// shells (their detached readers wake on child death; gens bumped so
@@ -1040,6 +1144,10 @@ fn runNative(init: std.process.Init, opts_in: pardes.Options) !void {
slot.* = null;
};
for (&gens) |*g2| g2.* +%= 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);
core.deinit();
core = nc;
}
@@ -1396,7 +1504,11 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
};
var queue: Queue = .{ .gpa = gpa, .sdl_wake = false };
defer queue.close();
- drainEffects(core, &ptys, &gens, gpa, &queue, null, false);
+ // no inotify here on purpose: this mode's whole contract is one frame per
+ // scripted input event, and a reload that arrives on its own clock would
+ // put a frame in the stream nothing asked for. -1 makes watchPane a no-op.
+ var watches: [pardes.MAX_PANES]?Watch = @splat(null);
+ drainEffects(core, &ptys, &gens, gpa, &queue, null, -1, &watches, false);
for (&ptys, 0..) |*slot, id| if (slot.*) |pt| spawnReader(gpa, pt, @intCast(id), gens[id], &queue);
setStdinRaw() catch {}; // stdin may be a pipe, not a pty — best effort
@@ -1435,9 +1547,10 @@ fn runGrid(init: std.process.Init, opts_in: pardes.Options) !void {
gpa.free(l.rows);
n_events += 1;
},
+ .files_changed => {}, // unreachable: no watcher thread in this mode
};
msgs.deinit(gpa);
- drainEffects(core, &ptys, &gens, gpa, &queue, null, true);
+ drainEffects(core, &ptys, &gens, gpa, &queue, null, -1, &watches, true);
pollCwds(core, &ptys);
if (n_events == 0) continue; // idle tick: nothing changed, no frame
_ = frame_arena.reset(.retain_capacity);
@@ -2069,6 +2182,8 @@ fn drainEffects(
gpa: std.mem.Allocator,
queue: *Queue,
g: ?*Gui, // null in grid test mode (no SDL: clipboard effects are no-ops)
+ inotify_fd: c_int,
+ watches: *[pardes.MAX_PANES]?Watch,
threads_ok: bool,
) void {
while (core.nextEffect()) |effect| switch (effect) {
@@ -2118,6 +2233,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;
@@ -2137,11 +2255,17 @@ 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 => {},
+ .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 => {},
};
}