//! 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)); }