summaryrefslogtreecommitdiff
path: root/src/file_watch.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-18 14:21:38 -0300
committerGabriel Schneider <[email protected]>2026-08-18 23:45:30 -0300
commit382abe3dafb872b3e6c9792a9cc0abcf6267e180 (patch)
treee30f47d911561db9b02e714287a1658822c77a09 /src/file_watch.zig
parentc24a9e40215bc30c68c9db7675f1c6a07d9bbae3 (diff)
downloadpardes-382abe3dafb872b3e6c9792a9cc0abcf6267e180.tar.gz
pardes-382abe3dafb872b3e6c9792a9cc0abcf6267e180.zip
file_watch + builtins + config: richer watch semantics, new builtins, config docs
Diffstat (limited to 'src/file_watch.zig')
-rw-r--r--src/file_watch.zig158
1 files changed, 147 insertions, 11 deletions
diff --git a/src/file_watch.zig b/src/file_watch.zig
index b616d481..a5347844 100644
--- a/src/file_watch.zig
+++ b/src/file_watch.zig
@@ -42,29 +42,30 @@ pub const Watch = struct {
generation: Generation,
};
-pub const Table = [pardes.MAX_PANES]?Watch;
+/// 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;
-/// 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(
+fn watchPath(
fd: c_int,
watches: *Table,
- id: u8,
+ slot: usize,
path: ?[]const u8,
serial: u32,
generation: Generation,
) void {
if (comptime builtin.os.tag != .linux) return;
if (fd < 0) return;
- if (watches[id]) |old| {
+ if (watches[slot]) |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 (i != slot and candidate.wd == old.wd) shared = true;
}
if (!shared) _ = libc.inotify_rm_watch(fd, old.wd);
- watches[id] = null;
+ watches[slot] = null;
}
const watched_path = path orelse return;
const dir = std.fs.path.dirname(watched_path) orelse ".";
@@ -77,7 +78,22 @@ pub fn watchPane(
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 };
+ 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:
@@ -178,12 +194,73 @@ pub fn reloadChanged(
watches: *Table,
) bool {
var retry = false;
- for (watches, 0..) |slot, id| {
+ 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;
@@ -216,3 +293,62 @@ test "file identity changes for in-place and rename-over writes" {
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);
+}