//! Native file-watch state and the 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. //! //! Two kernels, one transaction: inotify on linux, kqueue on macos. `init`, //! `wait`, `stop`, `drain`, `markDir` and `unmarkDir` are the whole of that //! difference, and nothing above them knows which one it is on. const std = @import("std"); const builtin = @import("builtin"); const libc = std.c; const linux = std.os.linux; const pardes = @import("pardes.zig"); const filesystem = @import("fs.zig"); const message = pardes.Pardes.Message; 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 directory mark, plus what macos needs beside it. /// /// `wd` is an inotify wd on linux and an open EVTONLY directory descriptor on /// macos. Either way it marks the CONTAINING directory, because editor-style /// rename-over saves replace the file inode. /// /// `file_wd` and `kq` exist only on macos, and only because a kqueue directory /// filter reports changes to the DIRECTORY — entries added, removed, renamed — /// and never a write to a file already inside it. inotify's CLOSE_WRITE covers /// that from the directory mark; kqueue has no equivalent, so an in-place save /// needs a second filter on the file itself. `kq` is the queue both filters /// live on, kept here so `reloadPane` can re-arm the file filter after a /// rename-over gave the pathname a new inode, without every caller between it /// and a host having to carry the descriptor. /// /// `generation` is the reconciled disk snapshot; Restore establishes this /// baseline without replacing its saved buffer. Serial rejects reused slots. pub const Watch = struct { wd: c_int, file_wd: c_int = -1, kq: c_int = -1, 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; /// Whether this OS has a watcher at all. Off it every entry point below /// returns the inert answer: `watchPath` installs nothing, so `watches` stays /// empty and `reloadPane` returns false for every slot. The core then keeps its /// own record of what was asked and simply never gets a `file_changed`, which /// is what a host with no watcher has always done here. pub const supported = builtin.os.tag == .linux or builtin.os.tag == .macos; /// The wake filter's `ident` on macos. `ident` is a directory descriptor for /// every other filter on this queue, and no descriptor is `maxInt(usize)`, so /// nothing can collide with it. const wake_ident: usize = std.math.maxInt(usize); /// Create the descriptor a host waits on, and the ONE place the difference /// between the two kernels is spelled. /// /// linux: an inotify instance; a mark is a wd added to it. /// macos: a kqueue; a mark is an EVFILT_VNODE filter whose `ident` is an open /// directory descriptor, plus the one EVFILT_USER filter registered here that /// `stop` triggers to end a parked `wait`. /// /// `polled` is the only flag that survives the port. On linux it adds /// IN_NONBLOCK, because a host that finds this descriptor ready in its own /// `poll(2)` (detached/server.zig) must be able to stop draining it, while a /// host that parks a thread in `wait` (tty.zig, gui.zig) wants the blocking /// read. On macos neither host needs either flag: a kqueue is not inherited /// across `fork(2)`, so there is no CLOEXEC to ask for, and whether a wait /// blocks is the timeout argument to `kevent(2)` rather than a property of the /// queue. /// /// -1 when there is no watcher to make, which every entry point below reads as /// "mark nothing". pub fn init(polled: bool) c_int { switch (builtin.os.tag) { .linux => return libc.inotify_init1(if (polled) linux.IN.CLOEXEC | linux.IN.NONBLOCK else linux.IN.CLOEXEC), .macos => { const kq = libc.kqueue(); if (kq < 0) return -1; const changes = [_]libc.Kevent{.{ .ident = wake_ident, .filter = libc.EVFILT.USER, .flags = libc.EV.ADD | libc.EV.CLEAR, .fflags = 0, .data = 0, .udata = 0, }}; var events: [1]libc.Kevent = undefined; if (libc.kevent(kq, &changes, 1, &events, 0, null) < 0) { _ = libc.close(kq); return -1; } return kq; }, else => return -1, } } /// Park until a marked directory changes. False ends the watcher: `stop` was /// called, or the queue died. /// /// macos only, because it exists for the one thing a kqueue cannot do the way /// an inotify descriptor can — be read. The linux hosts keep reading theirs /// (tty.zig through `std.Io.File`, so teardown's `cancel` interrupts it), and /// this is the shape that replaces that where there is nothing to read. /// /// Deliberately does NOT report WHAT changed, for the reason the watcher /// threads in tty.zig and gui.zig already give: the loop re-reads every marked /// pane anyway. pub fn wait(fd: c_int) bool { if (comptime builtin.os.tag != .macos) return false; if (fd < 0) return false; const none = [_]libc.Kevent{}; var events: [8]libc.Kevent = undefined; while (true) { const n = libc.kevent(fd, &none, 0, &events, events.len, null); if (n < 0) { if (libc.errno(n) == .INTR) continue; return false; } if (n == 0) continue; for (events[0..@intCast(n)]) |event| { if (event.filter == libc.EVFILT.USER) return false; } return true; } } /// Wake a host parked in `wait` so it returns BEFORE the descriptor it is /// parked on is closed. A no-op on linux, where cancelling the future reading /// that descriptor is what ends the watcher. pub fn stop(fd: c_int) void { if (comptime builtin.os.tag != .macos) return; if (fd < 0) return; const changes = [_]libc.Kevent{.{ .ident = wake_ident, .filter = libc.EVFILT.USER, .flags = 0, .fflags = libc.NOTE.TRIGGER, .data = 0, .udata = 0, }}; var events: [1]libc.Kevent = undefined; _ = libc.kevent(fd, &changes, 1, &events, 0, null); } /// Consume every pending edge without blocking, for a host that found the /// descriptor ready in its own `poll(2)` rather than parking a thread on it. /// True when at least one of them was a directory change. /// /// DRAINED TO EMPTY on both kernels, and for the same reason: the descriptor is /// level-triggered, so a queue left partly full makes the next `poll` return /// ready immediately, and each of those rounds is a whole pump. pub fn drain(fd: c_int) bool { if (fd < 0) return false; switch (builtin.os.tag) { .linux => { var buf: [4096]u8 = undefined; var seen = false; while (libc.read(fd, &buf, buf.len) > 0) seen = true; return seen; }, .macos => { const none = [_]libc.Kevent{}; const now: libc.timespec = .{ .sec = 0, .nsec = 0 }; var events: [16]libc.Kevent = undefined; var seen = false; while (true) { const n = libc.kevent(fd, &none, 0, &events, events.len, &now); if (n <= 0) return seen; for (events[0..@intCast(n)]) |event| { if (event.filter != libc.EVFILT.USER) seen = true; } if (n < events.len) return seen; } }, else => return false, } } /// Install the kernel mark on `dir_z` and return its handle, or -1. /// /// The handle is an inotify wd on linux and an open directory descriptor on /// macos, and BOTH have to answer "the same directory twice is the same /// handle": `watchPath`'s sharing loop compares handles to decide when the last /// user of a mark is gone, and `Watch` keeps no path to compare instead. /// inotify does that deduplication itself. On macos it is done here, by device /// and inode, because each EVFILT_VNODE filter needs a descriptor of its own. fn markDir(fd: c_int, dir_z: [:0]const u8, watches: *const Table) c_int { switch (builtin.os.tag) { .linux => { // 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; return libc.inotify_add_watch(fd, dir_z, mask); }, .macos => { // EVTONLY is the point of the descriptor: it marks the directory // without counting as a reference that would keep an unmounting // volume busy. DIRECTORY is inotify's ONLYDIR. const dir_fd = libc.open(dir_z, .{ .ACCMODE = .RDONLY, .EVTONLY = true, .CLOEXEC = true, .DIRECTORY = true, }, @as(libc.mode_t, 0)); if (dir_fd < 0) return -1; var want: libc.Stat = undefined; if (libc.fstat(dir_fd, &want) != 0) { _ = libc.close(dir_fd); return -1; } for (watches) |other| { const candidate = other orelse continue; var have: libc.Stat = undefined; if (libc.fstat(candidate.wd, &have) != 0) continue; if (have.dev != want.dev or have.ino != want.ino) continue; _ = libc.close(dir_fd); return candidate.wd; } // What a DIRECTORY filter reports is its entries changing, which // is a rename-over or a delete-then-recreate — MOVED_TO and CREATE // above. It does NOT report a write to a file already inside it, // which is what CLOSE_WRITE covers there; `markFile` is that half. // RENAME and DELETE here are the marked directory itself going // away. // // EV_CLEAR is not optional: without it the filter stays triggered // once it has fired and every `wait` returns immediately forever. const changes = [_]libc.Kevent{.{ .ident = @intCast(dir_fd), .filter = libc.EVFILT.VNODE, .flags = libc.EV.ADD | libc.EV.CLEAR, .fflags = libc.NOTE.WRITE | libc.NOTE.RENAME | libc.NOTE.DELETE, .data = 0, .udata = 0, }}; var events: [1]libc.Kevent = undefined; if (libc.kevent(fd, &changes, 1, &events, 0, null) < 0) { _ = libc.close(dir_fd); return -1; } return dir_fd; }, else => return -1, } } /// The other half of a macos mark: the filter on the watched FILE, which is the /// only thing that reports an in-place save. -1 when there is no file there yet /// — a path that does not exist is still worth marking the directory for, and /// the create arrives on that mark. /// /// Not deduplicated, unlike the directory: two panes on the same file are two /// slots that each want their own wake, and a file filter has exactly one user. fn markFile(kq: c_int, path_z: [:0]const u8) c_int { if (comptime builtin.os.tag != .macos) return -1; const file_fd = libc.open(path_z, .{ .ACCMODE = .RDONLY, .EVTONLY = true, .CLOEXEC = true, }, @as(libc.mode_t, 0)); if (file_fd < 0) return -1; // EXTEND and ATTRIB beside WRITE because an append and a truncate-in-place // are both saves, and DELETE/RENAME because losing this inode is how a // rename-over reaches the pane whose file it replaced. const changes = [_]libc.Kevent{.{ .ident = @intCast(file_fd), .filter = libc.EVFILT.VNODE, .flags = libc.EV.ADD | libc.EV.CLEAR, .fflags = libc.NOTE.WRITE | libc.NOTE.EXTEND | libc.NOTE.ATTRIB | libc.NOTE.RENAME | libc.NOTE.DELETE | libc.NOTE.REVOKE, .data = 0, .udata = 0, }}; var events: [1]libc.Kevent = undefined; if (libc.kevent(kq, &changes, 1, &events, 0, null) < 0) { _ = libc.close(file_fd); return -1; } return file_fd; } /// Re-point a live mark's file filter at whatever the pathname is NOW. Called /// from `reloadPane`, because a rename-over leaves the old filter on an inode /// that no longer answers to this name and a later in-place save would then /// wake nobody. A no-op off macos, where the directory mark covers both cases /// by itself and there is no second filter to move. fn remarkFile(live: *Watch, path: ?[]const u8) void { if (comptime builtin.os.tag != .macos) return; if (live.kq < 0) return; const watched = filesystem.localPath(path orelse return) orelse return; var path_buf: [4096:0]u8 = undefined; if (watched.len >= path_buf.len) return; @memcpy(path_buf[0..watched.len], watched); path_buf[watched.len] = 0; // Mark first, compare second. There is no `stat` in std.c on this target — // only `fstat` — and opening the path is what tells us both whether it is // there and which inode it is now. const fresh = markFile(live.kq, path_buf[0..watched.len :0]); if (fresh < 0) return; // nothing at that name now; keep whatever we had if (live.file_wd >= 0) { var have: libc.Stat = undefined; var want: libc.Stat = undefined; // Same inode as the filter already on it? Then that one still reports // this file and the registration just made is a duplicate to drop. if (libc.fstat(live.file_wd, &have) == 0 and libc.fstat(fresh, &want) == 0 and have.dev == want.dev and have.ino == want.ino) { _ = libc.close(fresh); return; } _ = libc.close(live.file_wd); } live.file_wd = fresh; } /// Drop one slot's marks. The DIRECTORY mark goes only when its last slot is /// gone — several panes share it — while the file filter has exactly one user /// and always goes. On macos a filter dies with the descriptor it was /// registered against, so the close IS the removal. fn unmark(fd: c_int, old: Watch, drop_dir: bool) void { if (comptime builtin.os.tag == .macos) { if (old.file_wd >= 0) _ = libc.close(old.file_wd); } if (!drop_dir) return; switch (builtin.os.tag) { .linux => _ = libc.inotify_rm_watch(fd, old.wd), .macos => _ = libc.close(old.wd), else => {}, } } fn watchPath( fd: c_int, watches: *Table, slot: usize, path: ?[]const u8, serial: u32, generation: Generation, ) void { if (comptime !supported) 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; } watches[slot] = null; unmark(fd, old, !shared); } const declared_path = path orelse return; const watched_path = filesystem.localPath(declared_path) orelse return; const stat = std.Io.Dir.cwd().statFile(std.Io.Threaded.global_single_threaded.io(), watched_path, .{}) catch null; const dir = if (stat != null and stat.?.kind == .directory) watched_path else 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; const wd = markDir(fd, dir_buf[0..dir.len :0], watches); if (wd < 0) return; watches[slot] = .{ .wd = wd, .kq = fd, .serial = serial, .generation = generation }; // The file half, which only macos has and only for a path that exists yet. if (comptime builtin.os.tag == .macos) { if (watches[slot]) |*live| remarkFile(live, declared_path); } } /// 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, 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; // Re-point the macos file filter at whatever this pathname is now, BEFORE // deciding anything: a rename-over left the old filter on a dead inode, and // the next in-place save would otherwise wake nobody. A no-op elsewhere, // and a no-op here too when the inode has not moved. if (comptime builtin.os.tag == .macos) { if (watches[id]) |*live| remarkFile(live, if (pane.file) |f| f.path else pane.pdfPath()); } if (pane.file) |file| { const bytes = filesystem.read(core, file.path) catch return false; defer core.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, fd: c_int, watches: *Table, id: u8, on: bool, mode: pardes.WatchMode, ) 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); if (on and mode == .baseline_disk) if (watches[id]) |*watched| { const pane = core.panes[id] orelse return false; const file = pane.file orelse return reloadPane(core, io, watches, id, false); const bytes = filesystem.read(core, file.path) catch return false; defer core.gpa.free(bytes); watched.generation = .{ .text = std.hash.Wyhash.hash(0, bytes) }; return false; }; return on and watches[id] != null and reloadPane(core, io, 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, 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 = filesystem.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; // Same re-arm as reloadPane's, for the same reason: a .zon saved by rename // moves the inode the file filter is on. if (comptime builtin.os.tag == .macos) { if (watches[theme_slot]) |*entry| remarkFile(entry, request.path); } const bytes = filesystem.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 native = filesystem.localPath(path) orelse return error.NonLocalPath; const stat = try std.Io.Dir.cwd().statFile(io, native, .{}); 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 "restored file watches preserve snapshots and accept later disk changes" { if (comptime !supported or !filesystem.platform_has_fs) return; const io = std.testing.io; const gpa = std.testing.allocator; for ([_]struct { dirty: bool, exists: bool }{ .{ .dirty = false, .exists = true }, .{ .dirty = true, .exists = true }, .{ .dirty = true, .exists = false }, }) |case| { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); if (case.exists) try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "disk baseline\n" }); var path_buf: [256]u8 = undefined; const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/watched", .{tmp.sub_path}); const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); while (core.nextEffect()) |_| {} const id: u8 = @intCast(core.freeSlot().?); const pane = try pardes.panes.File.restore(core, id, .{ .kind = .file, .tag = "", .body = "", .file = .{ .path = path, .content = "restored snapshot\n", .dirty = case.dirty }, }); const fd = init(true); if (fd < 0) return error.NoWatcher; defer _ = libc.close(fd); var watches: Table = @splat(null); defer watchPane(fd, &watches, id, null, pane.serial, .{ .text = 0 }); var armed = false; while (core.nextEffect()) |effect| switch (effect) { .watch => |watch| if (watch.pane == id and watch.on) { try std.testing.expectEqual(.baseline_disk, watch.mode); try std.testing.expect(!applyEffect(core, io, fd, &watches, id, watch.on, watch.mode)); armed = true; }, else => {}, }; try std.testing.expect(armed and watches[id] != null); try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content); try std.testing.expectEqual(@as(usize, 0), pane.file.?.history.undo_len); try std.testing.expectEqual(case.dirty, pane.file.?.revision != pane.file.?.saved_revision); _ = reloadChanged(core, io, gpa, &watches); try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content); try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "later disk save\n" }); _ = reloadChanged(core, io, gpa, &watches); try std.testing.expectEqualStrings("later disk save\n", pane.file.?.content); pardes.panes.File.undo(core, pane); try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content); _ = reloadChanged(core, io, gpa, &watches); try std.testing.expectEqualStrings("restored snapshot\n", pane.file.?.content); } } test "ordinary file watches still reconcile changes between open and watch" { if (comptime !supported or !filesystem.platform_has_fs) return; const io = std.testing.io; const gpa = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "opened\n" }); var path_buf: [256]u8 = undefined; const path = try std.fmt.bufPrint(&path_buf, ".zig-cache/tmp/{s}/watched", .{tmp.sub_path}); const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); while (core.nextEffect()) |_| {} const id: u8 = @intCast(core.freeSlot().?); const pane = try pardes.panes.File.open(core, id, path, 0); try tmp.dir.writeFile(io, .{ .sub_path = "watched", .data = "changed before watch\n" }); const fd = init(true); if (fd < 0) return error.NoWatcher; defer _ = libc.close(fd); var watches: Table = @splat(null); defer watchPane(fd, &watches, id, null, pane.serial, .{ .text = 0 }); var armed = false; while (core.nextEffect()) |effect| switch (effect) { .watch => |watch| if (watch.pane == id and watch.on) { try std.testing.expectEqual(.reconcile, watch.mode); try std.testing.expect(!applyEffect(core, io, fd, &watches, id, watch.on, watch.mode)); armed = true; }, else => {}, }; try std.testing.expect(armed and watches[id] != null); try std.testing.expectEqualStrings("changed before watch\n", pane.file.?.content); pardes.panes.File.undo(core, pane); try std.testing.expectEqualStrings("opened\n", pane.file.?.content); } test "explicit OS directory watches refresh prefix-preserving listings" { if (comptime !supported or !filesystem.platform_has_fs) return; const gpa = std.testing.allocator; const core = try pardes.Pardes.init(gpa, .{ .tty_only = true }); defer core.deinit(); var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "first", .data = "first\n" }); var directory_buf: [4096]u8 = undefined; const directory = directory_buf[0..try tmp.dir.realPath(std.testing.io, &directory_buf)]; var path_buf: [4102]u8 = undefined; const path = try std.fmt.bufPrint(&path_buf, "/n/os{s}", .{directory}); const pane_id = core.freeSlot().?; const pane = try pardes.panes.File.open(core, pane_id, path, 0); const fd = init(true); if (fd < 0) return error.NoWatcher; defer _ = libc.close(fd); var watches: Table = @splat(null); defer watchPane(fd, &watches, @intCast(pane_id), null, pane.serial, .{ .text = 0 }); _ = applyEffect(core, std.testing.io, fd, &watches, @intCast(pane_id), true, .reconcile); try std.testing.expect(watches[pane_id] != null); try tmp.dir.writeFile(std.testing.io, .{ .sub_path = "second", .data = "second\n" }); _ = reloadChanged(core, std.testing.io, gpa, &watches); try std.testing.expect(std.mem.indexOf(u8, pane.file.?.content, "/second\n") != null); var rows = std.mem.tokenizeScalar(u8, pane.file.?.content, '\n'); while (rows.next()) |row| { try std.testing.expect(std.mem.startsWith(u8, row, path)); const bytes = try filesystem.read(core, row); defer gpa.free(bytes); try std.testing.expect(std.mem.endsWith(u8, bytes, "\n")); } } 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 !supported 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 = init(true); if (fd < 0) return error.WatchInitFailed; 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); }