//! 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, }; /// The final slot is the one non-pane watched payload: the active ThemeFile. /// Sharing the table also shares directory descriptors correctly when a user /// happens to open that .zon as an ordinary document. pub const theme_slot = pardes.MAX_PANES; pub const Table = [pardes.MAX_PANES + 1]?Watch; fn watchPath( fd: c_int, watches: *Table, slot: usize, path: ?[]const u8, serial: u32, generation: Generation, ) void { if (comptime builtin.os.tag != .linux) return; if (fd < 0) return; if (watches[slot]) |old| { var shared = false; for (watches, 0..) |other, i| { const candidate = other orelse continue; if (i != slot and candidate.wd == old.wd) shared = true; } if (!shared) _ = libc.inotify_rm_watch(fd, old.wd); watches[slot] = 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[slot] = .{ .wd = wd, .serial = serial, .generation = generation }; } /// 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 { std.debug.assert(id < pardes.MAX_PANES); watchPath(fd, watches, id, path, serial, 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..pardes.MAX_PANES], 0..) |slot, id| { if (slot != null) retry = reloadPane(core, io, gpa, watches, id, true) or retry; } if (watches[theme_slot] != null) retry = reloadTheme(core, gpa, watches, true) or retry; return retry; } /// Stop the old theme watch, synchronously load the requested snapshot, then /// install its parent-directory mark and reconcile once to close the read -> /// watch race. Returns true only when that reconciliation wants another pass. pub fn applyThemeEffect( core: *pardes.Pardes, gpa: std.mem.Allocator, fd: c_int, watches: *Table, generation: u32, on: bool, animate: bool, ) bool { watchPath(fd, watches, theme_slot, null, 0, .{ .text = 0 }); if (!on) return false; const request = core.themeFileRequest(generation) orelse return false; const bytes = look.readFile(gpa, request.path) catch |err| { core.failThemeFile(generation, err); return false; }; defer gpa.free(bytes); if (!core.loadThemeFile(generation, bytes, animate)) return false; const hash = std.hash.Wyhash.hash(0, bytes); watchPath(fd, watches, theme_slot, request.path, generation, .{ .text = hash }); if (watches[theme_slot] == null) return false; return reloadTheme(core, gpa, watches, false); } /// Re-read the one active theme file after a coalesced directory edge. The /// generation advances only after a valid parse, so a malformed save never /// replaces the last good colors or blesses bad bytes as the new baseline. pub fn reloadTheme( core: *pardes.Pardes, gpa: std.mem.Allocator, watches: *Table, announce: bool, ) bool { const watched = watches[theme_slot] orelse return false; const request = core.themeFileRequest(watched.serial) orelse return false; const bytes = look.readFile(gpa, request.path) catch |err| { core.failThemeFile(watched.serial, err); 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 => unreachable, } if (!core.loadThemeFile(watched.serial, bytes, true)) return false; const live = if (watches[theme_slot]) |*entry| entry else return false; if (live.serial != watched.serial) return false; live.generation = .{ .text = hash }; if (announce) { var msg_buf: [256]u8 = undefined; core.setMessage(request.pane, message.stamp(&msg_buf, "reloaded theme", request.path)); } return false; } 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)); } test "theme watch reloads valid ZON and keeps the last theme across a bad save" { if (comptime builtin.os.tag != .linux or pardes.platform == .web) return; const io = std.testing.io; const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var base_buf: [std.fs.max_path_bytes]u8 = undefined; const base_len = try tmp.dir.realPath(io, &base_buf); const path = try std.fs.path.join(gpa, &.{ base_buf[0..base_len], "mine.zon" }); defer gpa.free(path); var first = pardes.themes[0]; first.name = "watched"; first.bg = .{ 1, 2, 3 }; var first_zon: std.Io.Writer.Allocating = .init(gpa); defer first_zon.deinit(); try std.zon.stringify.serialize(first, .{ .whitespace = true }, &first_zon.writer); try tmp.dir.writeFile(io, .{ .sub_path = "mine.zon", .data = first_zon.written() }); const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); while (core.nextEffect()) |_| {} core.requestThemeFile(0, path); const request = while (core.nextEffect()) |effect| switch (effect) { .theme_file => |theme_file| break theme_file, else => {}, } else return error.MissingThemeFileEffect; const fd = libc.inotify_init1(linux.IN.CLOEXEC | linux.IN.NONBLOCK); if (fd < 0) return error.InotifyInitFailed; defer _ = libc.close(fd); var watches: Table = @splat(null); defer _ = applyThemeEffect(core, gpa, fd, &watches, 0, false, false); try std.testing.expect(!applyThemeEffect( core, gpa, fd, &watches, request.generation, true, false, )); try std.testing.expectEqual(@as(?[3]u8, .{ 1, 2, 3 }), core.theme().bg); try std.testing.expect(watches[theme_slot] != null); var second = first; second.bg = .{ 9, 8, 7 }; var second_zon: std.Io.Writer.Allocating = .init(gpa); defer second_zon.deinit(); try std.zon.stringify.serialize(second, .{ .whitespace = true }, &second_zon.writer); try tmp.dir.writeFile(io, .{ .sub_path = "mine.zon", .data = second_zon.written() }); try std.testing.expect(!reloadTheme(core, gpa, &watches, false)); try std.testing.expectEqual(@as(?[3]u8, .{ 9, 8, 7 }), core.theme().bg); try tmp.dir.writeFile(io, .{ .sub_path = "mine.zon", .data = ".{ .name = " }); try std.testing.expect(!reloadTheme(core, gpa, &watches, false)); try std.testing.expectEqual(@as(?[3]u8, .{ 9, 8, 7 }), core.theme().bg); }