1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
|
//! 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);
}
|