summaryrefslogtreecommitdiff
path: root/src/file_watch.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/file_watch.zig')
-rw-r--r--src/file_watch.zig218
1 files changed, 218 insertions, 0 deletions
diff --git a/src/file_watch.zig b/src/file_watch.zig
new file mode 100644
index 00000000..b616d481
--- /dev/null
+++ b/src/file_watch.zig
@@ -0,0 +1,218 @@
+//! Native file-watch state and the Linux mark/reconcile transaction shared by
+//! the TTY and SDL hosts. Their event loops still own blocking waits, wake
+//! coalescing, and retry scheduling; this file owns the identical synchronous
+//! operation each wake performs. Text buffers already own the bytes whose hash
+//! is their generation. Large PDFs use metadata which changes for in-place and
+//! rename-over saves, avoiding a second whole-document allocation.
+const std = @import("std");
+const builtin = @import("builtin");
+const libc = std.c;
+const linux = std.os.linux;
+const pardes = @import("pardes.zig");
+const look = @import("look.zig");
+const message = @import("message.zig");
+
+pub const Identity = struct {
+ inode: std.Io.File.INode,
+ size: u64,
+ mtime_ns: i96,
+ ctime_ns: i96,
+
+ pub fn eql(a: Identity, b: Identity) bool {
+ return std.meta.eql(a, b);
+ }
+};
+
+/// The last disk generation accepted by a native shell. Text panes already
+/// need their bytes, so their exact content hash is cheapest. PDF panes reopen
+/// the pathname directly and use metadata, avoiding a second whole-document
+/// allocation merely to discover whether MuPDF should reopen it.
+pub const Generation = union(enum) {
+ text: u64,
+ pdf: ?Identity,
+};
+
+/// One native Linux directory mark. The descriptor belongs to the containing
+/// directory because editor-style rename-over saves replace the file inode.
+/// `generation` is the last snapshot the core accepted, not merely one a host
+/// observed; serial prevents a reused pane slot from committing stale data.
+pub const Watch = struct {
+ wd: c_int,
+ serial: u32,
+ generation: Generation,
+};
+
+pub const Table = [pardes.MAX_PANES]?Watch;
+
+/// Mark or unmark one pane pathname. Panes in the same directory share an
+/// inotify descriptor, so removing one pane drops the kernel mark only after
+/// its last user disappears.
+pub fn watchPane(
+ fd: c_int,
+ watches: *Table,
+ id: u8,
+ path: ?[]const u8,
+ serial: u32,
+ generation: Generation,
+) void {
+ if (comptime builtin.os.tag != .linux) return;
+ if (fd < 0) return;
+ if (watches[id]) |old| {
+ var shared = false;
+ for (watches, 0..) |other, i| {
+ const candidate = other orelse continue;
+ if (i != id and candidate.wd == old.wd) shared = true;
+ }
+ if (!shared) _ = libc.inotify_rm_watch(fd, old.wd);
+ watches[id] = null;
+ }
+ const watched_path = path orelse return;
+ const dir = std.fs.path.dirname(watched_path) orelse ".";
+ var dir_buf: [4096:0]u8 = undefined;
+ if (dir.len >= dir_buf.len) return;
+ @memcpy(dir_buf[0..dir.len], dir);
+ dir_buf[dir.len] = 0;
+ // CLOSE_WRITE coalesces one writer's writes; MOVED_TO and CREATE cover
+ // rename-over and delete-then-recreate saves.
+ const mask = linux.IN.CLOSE_WRITE | linux.IN.MOVED_TO | linux.IN.CREATE | linux.IN.ONLYDIR;
+ const wd = libc.inotify_add_watch(fd, dir_buf[0..dir.len :0], mask);
+ if (wd < 0) return;
+ watches[id] = .{ .wd = wd, .serial = serial, .generation = generation };
+}
+
+/// Reconcile one marked pane. A true result requests an immediate second pass:
+/// the PDF pathname changed between the stat before MuPDF reopened it and the
+/// stat after, so committing either identity would lose a generation.
+pub fn reloadPane(
+ core: *pardes.Pardes,
+ io: std.Io,
+ gpa: std.mem.Allocator,
+ watches: *Table,
+ id: usize,
+ announce: bool,
+) bool {
+ const watched = watches[id] orelse return false;
+ const pane = core.panes[id] orelse return false;
+ if (pane.serial != watched.serial) return false;
+
+ if (pane.file) |file| {
+ const bytes = look.readFile(gpa, file.path) catch return false;
+ defer gpa.free(bytes);
+ const hash = std.hash.Wyhash.hash(0, bytes);
+ switch (watched.generation) {
+ .text => |accepted| if (accepted == hash) return false,
+ .pdf => {},
+ }
+ if (!core.reloadWatchedFile(@intCast(id), bytes)) return false;
+ const current = core.panes[id] orelse return false;
+ if (current.serial != watched.serial) return false;
+ const current_file = current.file orelse return false;
+ if (!std.mem.eql(u8, current_file.content, bytes)) return false;
+ if (watches[id]) |*live| {
+ if (live.serial == watched.serial)
+ live.generation = .{ .text = hash };
+ }
+ if (announce) {
+ var msg_buf: [256]u8 = undefined;
+ core.setMessage(id, message.stamp(&msg_buf, "reloaded", current_file.path));
+ }
+ return false;
+ }
+
+ const path = pane.pdfPath() orelse return false;
+ const before = identify(io, path) catch return false;
+ switch (watched.generation) {
+ .pdf => |accepted| if (accepted) |identity| if (identity.eql(before)) return false,
+ .text => {},
+ }
+ if (!core.reloadWatchedFile(@intCast(id), &.{})) return false;
+ const current = core.panes[id] orelse return false;
+ if (current.serial != watched.serial) return false;
+ const current_path = current.pdfPath() orelse return false;
+ const after = identify(io, current_path) catch return true;
+ if (!before.eql(after)) return true;
+ if (watches[id]) |*live| {
+ if (live.serial == watched.serial)
+ live.generation = .{ .pdf = after };
+ }
+ if (announce) {
+ var msg_buf: [256]u8 = undefined;
+ core.setMessage(id, message.stamp(&msg_buf, "reloaded", current_path));
+ }
+ return false;
+}
+
+/// Install the directory mark before reconciling the open bytes/path. This
+/// closes the drain-time race without requiring another filesystem event.
+pub fn applyEffect(
+ core: *pardes.Pardes,
+ io: std.Io,
+ gpa: std.mem.Allocator,
+ fd: c_int,
+ watches: *Table,
+ id: u8,
+ on: bool,
+) bool {
+ var path: ?[]const u8 = null;
+ var serial: u32 = 0;
+ var generation: Generation = .{ .text = 0 };
+ if (on) if (core.panes[id]) |pane| {
+ serial = pane.serial;
+ if (pane.file) |file| {
+ path = file.path;
+ generation = .{ .text = std.hash.Wyhash.hash(0, file.content) };
+ } else if (pane.pdfPath()) |pdf_path| {
+ path = pdf_path;
+ generation = .{ .pdf = null };
+ }
+ };
+ watchPane(fd, watches, id, path, serial, generation);
+ return on and watches[id] != null and reloadPane(core, io, gpa, watches, id, false);
+}
+
+/// Reconcile every mark after a coalesced directory wake.
+pub fn reloadChanged(
+ core: *pardes.Pardes,
+ io: std.Io,
+ gpa: std.mem.Allocator,
+ watches: *Table,
+) bool {
+ var retry = false;
+ for (watches, 0..) |slot, id| {
+ if (slot != null) retry = reloadPane(core, io, gpa, watches, id, true) or retry;
+ }
+ return retry;
+}
+
+pub fn identify(io: std.Io, path: []const u8) !Identity {
+ const stat = try std.Io.Dir.cwd().statFile(io, path, .{});
+ if (stat.kind != .file) return error.NotFile;
+ return .{
+ .inode = stat.inode,
+ .size = stat.size,
+ .mtime_ns = stat.mtime.nanoseconds,
+ .ctime_ns = stat.ctime.nanoseconds,
+ };
+}
+
+test "file identity changes for in-place and rename-over writes" {
+ const io = std.testing.io;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "a" });
+ var path_buf: [256]u8 = undefined;
+ const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/watched", .{tmp.sub_path});
+ const before = try identify(io, path);
+ try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "longer" });
+ const after = try identify(io, path);
+ try std.testing.expect(!before.eql(after));
+
+ // The dominant editor save shape keeps size/content arbitrary but swaps
+ // the inode. Keep this replacement the same length to prove identity does
+ // not rely on size changing.
+ try tmp.dir.writeFile(io, .{ .sub_path = "replacement", .data = "swap!!" });
+ try tmp.dir.rename("replacement", tmp.dir, "watched", io);
+ const replaced = try identify(io, path);
+ try std.testing.expect(after.inode != replaced.inode);
+ try std.testing.expect(!after.eql(replaced));
+}