From 9cb2e7bc35170a29fcda6f548a3c9cad1b012c76 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 20 Feb 2026 11:36:31 +0100 Subject: [PATCH 01/12] feat: add file_watcher module --- build.zig | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/build.zig b/build.zig index 8d3c126e..c6469fc9 100644 --- a/build.zig +++ b/build.zig @@ -604,6 +604,16 @@ pub fn build_exe( }, }); + const file_watcher_mod = b.createModule(.{ + .root_source_file = b.path("src/file_watcher.zig"), + .imports = &.{ + .{ .name = "soft_root", .module = soft_root_mod }, + .{ .name = "log", .module = log_mod }, + .{ .name = "cbor", .module = cbor_mod }, + .{ .name = "thespian", .module = thespian_mod }, + }, + }); + const project_manager_mod = b.createModule(.{ .root_source_file = b.path("src/project_manager.zig"), .imports = &.{ @@ -619,6 +629,7 @@ pub fn build_exe( .{ .name = "fuzzig", .module = fuzzig_dep.module("fuzzig") }, .{ .name = "git", .module = git_mod }, .{ .name = "VcsStatus", .module = VcsStatus_mod }, + .{ .name = "file_watcher", .module = file_watcher_mod }, }, }); From b4d29984254f8410e7f415bfaeecda351816a3bc Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 20 Feb 2026 11:39:54 +0100 Subject: [PATCH 02/12] refactor: add file watcher to project_manager and watch project --- src/project_manager.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/project_manager.zig b/src/project_manager.zig index 9d3a59a0..a3e9eb59 100644 --- a/src/project_manager.zig +++ b/src/project_manager.zig @@ -3,6 +3,7 @@ const tp = @import("thespian"); const cbor = @import("cbor"); const log = @import("log"); const tracy = @import("tracy"); +const file_watcher = @import("file_watcher"); const file_type_config = @import("file_type_config"); const lsp_config = @import("lsp_config"); const root = @import("soft_root").root; @@ -520,6 +521,7 @@ const Process = struct { self.logger.print("{s} error: {s}", .{ tag, message }); } else if (try cbor.match(m.buf, .{"shutdown"})) { self.persist_projects(); + file_watcher.shutdown(); from.send(.{ "project_manager", "shutdown" }) catch return error.ClientFailed; return error.ExitNormal; } else if (try cbor.match(m.buf, .{ "exit", "normal" })) { @@ -548,6 +550,7 @@ const Process = struct { try self.projects.put(self.allocator, try self.allocator.dupe(u8, project_directory), project); self.restore_project(project) catch |e| self.logger.err("restore_project", e); project.query_git(); + file_watcher.watch(project_directory) catch |e| self.logger.err("file_watcher.watch", e); } } @@ -558,6 +561,7 @@ const Process = struct { kv.value.deinit(); self.allocator.destroy(kv.value); self.logger.print("closed: {s}", .{project_directory}); + file_watcher.unwatch(project_directory) catch |e| self.logger.err("file_watcher.unwatch", e); } } From 863c9aade5623f79ccfb9d3c69718bd12a8846c2 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 20 Feb 2026 11:40:12 +0100 Subject: [PATCH 03/12] refactor: update project file index on watcher events --- src/Project.zig | 34 ++++++++++++++++++++++++++++++++++ src/project_manager.zig | 25 ++++++++++++++++++++++++- 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/Project.zig b/src/Project.zig index 817ada81..6103d113 100644 --- a/src/Project.zig +++ b/src/Project.zig @@ -777,6 +777,40 @@ fn loaded(self: *Self, parent: tp.pid_ref) OutOfMemoryError!void { parent.send(.{ "PRJ", "open_done", self.name, self.longest_file_path, self.files.items.len }) catch {}; } +pub fn file_added(self: *Self, file_path: []const u8) OutOfMemoryError!void { + for (self.files.items) |file| + if (std.mem.eql(u8, file.path, file_path)) return; + const file_type, const file_icon, const file_color = guess_file_type(file_path); + (try self.files.addOne(self.allocator)).* = .{ + .path = try self.allocator.dupe(u8, file_path), + .type = file_type, + .icon = file_icon, + .color = file_color, + .mtime = std.time.nanoTimestamp(), + }; + self.longest_file_path = @max(self.longest_file_path, file_path.len); + self.sort_files_by_mtime(); +} + +pub fn file_modified(self: *Self, file_path: []const u8) void { + for (self.files.items) |*file| { + if (!std.mem.eql(u8, file.path, file_path)) continue; + file.mtime = std.time.nanoTimestamp(); + self.sort_files_by_mtime(); + return; + } +} + +pub fn file_deleted(self: *Self, file_path: []const u8) void { + for (self.files.items, 0..) |file, i| { + if (!std.mem.eql(u8, file.path, file_path)) continue; + self.allocator.free(file.path); + _ = self.files.swapRemove(i); + self.sort_files_by_mtime(); + return; + } +} + pub fn update_mru(self: *Self, file_path: []const u8, row: usize, col: usize) OutOfMemoryError!void { defer self.sort_files_by_mtime(); try self.update_mru_internal(file_path, std.time.nanoTimestamp(), row, col); diff --git a/src/project_manager.zig b/src/project_manager.zig index a3e9eb59..bd56f5ee 100644 --- a/src/project_manager.zig +++ b/src/project_manager.zig @@ -421,8 +421,11 @@ const Process = struct { var vcs_id: []const u8 = undefined; var eol_mode: Buffer.EolModeTag = @intFromEnum(Buffer.EolMode.lf); + var event_type: file_watcher.EventType = undefined; - if (try cbor.match(m.buf, .{ "walk_tree_entry", tp.extract(&project_directory), tp.more })) { + if (try cbor.match(m.buf, .{ "FW", "change", tp.extract(&path), tp.extract(&event_type) })) { + self.handle_file_watch_event(path, event_type); + } else if (try cbor.match(m.buf, .{ "walk_tree_entry", tp.extract(&project_directory), tp.more })) { if (self.projects.get(project_directory)) |project| project.walk_tree_entry(m) catch |e| self.logger.err("walk_tree_entry", e); } else if (try cbor.match(m.buf, .{ "walk_tree_done", tp.extract(&project_directory) })) { @@ -539,6 +542,26 @@ const Process = struct { } } + fn handle_file_watch_event(self: *Process, abs_path: []const u8, event_type: file_watcher.EventType) void { + std.log.debug("file_watch_event: {s} {s}", .{ @tagName(event_type), abs_path }); + var it = self.projects.iterator(); + while (it.next()) |entry| { + const dir = entry.key_ptr.*; + if (!std.mem.startsWith(u8, abs_path, dir)) continue; + if (abs_path.len <= dir.len or abs_path[dir.len] != std.fs.path.sep) continue; + const rel_path = abs_path[dir.len + 1 ..]; + const project = entry.value_ptr.*; + switch (event_type) { + .created => project.file_added(rel_path) catch |e| self.logger.err("file_watcher.file_added", e), + .modified => project.file_modified(rel_path), + .deleted => project.file_deleted(rel_path), + .renamed => project.file_deleted(rel_path), + } + return; + } + self.parent.send(.{ "FW", "change", abs_path, event_type }) catch {}; + } + fn open(self: *Process, project_directory: []const u8) (SpawnError || std.fs.Dir.OpenError)!void { if (self.projects.get(project_directory)) |project| { project.last_used = std.time.nanoTimestamp(); From 682188f70433b13eab31d0ba51ec5929b15aa137 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 20 Feb 2026 11:40:32 +0100 Subject: [PATCH 04/12] refactor: ignore watcher events in tui (for now) --- src/tui/tui.zig | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/tui/tui.zig b/src/tui/tui.zig index 4ba86c09..d99b9a2c 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -622,6 +622,9 @@ fn receive_safe(self: *Self, from: tp.pid_ref, m: tp.message) !void { if (try m.match(.{ "line_number_mode", tp.more })) // drop broadcast messages return; + if (try m.match(.{ "FW", "change", tp.more })) // file watcher events + return; + return tp.unexpected(m); } From c46d910c87ee2378637f95b30e01b9d64d23e500 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 20 Feb 2026 10:23:15 +0100 Subject: [PATCH 05/12] fix: watch all directories within the project tree --- src/Project.zig | 5 +++++ src/project_manager.zig | 13 +++++++++++++ src/walk_tree.zig | 32 ++++++++++++++++++++++---------- 3 files changed, 40 insertions(+), 10 deletions(-) diff --git a/src/Project.zig b/src/Project.zig index 6103d113..7ea611ce 100644 --- a/src/Project.zig +++ b/src/Project.zig @@ -587,6 +587,10 @@ pub fn query_recent_files(self: *Self, from: tp.pid_ref, max: usize, query_: []c return @min(max, matches.items.len); } +fn walk_tree_dir_callback(parent: tp.pid_ref, root_path: []const u8, dir_path: []const u8) error{Exit}!void { + try parent.send(.{ "walk_tree_dir", root_path, dir_path }); +} + fn walk_tree_entry_callback(parent: tp.pid_ref, root_path: []const u8, file_path: []const u8, mtime_high: i64, mtime_low: i64) error{Exit}!void { const file_type: []const u8, const file_icon: []const u8, const file_color: u24 = guess_file_type(file_path); try parent.send(.{ "walk_tree_entry", root_path, file_path, mtime_high, mtime_low, file_type, file_icon, file_color }); @@ -2830,6 +2834,7 @@ fn start_walker(self: *Self) void { .follow_directory_symlinks = tp.env.get().is("follow_directory_symlinks"), .maximum_symlink_depth = @intCast(tp.env.get().num("maximum_symlink_depth")), .log_ignored_links = tp.env.get().is("log_ignored_links"), + .dir_callback = walk_tree_dir_callback, }) catch blk: { self.state.walk_tree = .failed; break :blk null; diff --git a/src/project_manager.zig b/src/project_manager.zig index bd56f5ee..68a018c8 100644 --- a/src/project_manager.zig +++ b/src/project_manager.zig @@ -425,6 +425,10 @@ const Process = struct { if (try cbor.match(m.buf, .{ "FW", "change", tp.extract(&path), tp.extract(&event_type) })) { self.handle_file_watch_event(path, event_type); + } else if (try cbor.match(m.buf, .{ "walk_tree_dir", tp.extract(&project_directory), tp.extract(&path) })) { + var abs_buf: [std.fs.max_path_bytes]u8 = undefined; + const abs_path = std.fmt.bufPrint(&abs_buf, "{s}{c}{s}", .{ project_directory, std.fs.path.sep, path }) catch return; + file_watcher.watch(abs_path) catch |e| self.logger.err("file_watcher.watch_dir", e); } else if (try cbor.match(m.buf, .{ "walk_tree_entry", tp.extract(&project_directory), tp.more })) { if (self.projects.get(project_directory)) |project| project.walk_tree_entry(m) catch |e| self.logger.err("walk_tree_entry", e); @@ -440,6 +444,15 @@ const Process = struct { } else if (try cbor.match(m.buf, .{ "git", tp.extract(&context), "blame", tp.more })) { const request: *Project.GitBlameRequest = @ptrFromInt(context); request.project.process_git_response(self.parent.ref(), m) catch |e| self.logger.err("git-blame", e); + } else if (try cbor.match(m.buf, .{ "git", tp.extract(&context), "workspace_files", tp.extract(&path) })) { + const project: *Project = @ptrFromInt(context); + const dir_path = std.fs.path.dirname(path) orelse ""; + if (dir_path.len > 0) blk: { + var abs_buf: [std.fs.max_path_bytes]u8 = undefined; + const abs_path = std.fmt.bufPrint(&abs_buf, "{s}{c}{s}", .{ project.name, std.fs.path.sep, dir_path }) catch break :blk; + file_watcher.watch(abs_path) catch |e| self.logger.err("file_watcher.watch_dir", e); + } + project.process_git(self.parent.ref(), m) catch {}; } else if (try cbor.match(m.buf, .{ "git", tp.extract(&context), tp.more })) { const project: *Project = @ptrFromInt(context); project.process_git(self.parent.ref(), m) catch {}; diff --git a/src/walk_tree.zig b/src/walk_tree.zig index 3565ceee..40e716bf 100644 --- a/src/walk_tree.zig +++ b/src/walk_tree.zig @@ -9,11 +9,13 @@ const OutOfMemoryError = error{OutOfMemory}; pub const EntryCallBack = *const fn (parent: tp.pid_ref, root_path: []const u8, path: []const u8, mtime_high: i64, mtime_low: i64) error{Exit}!void; pub const DoneCallBack = *const fn (parent: tp.pid_ref, root_path: []const u8) error{Exit}!void; +pub const DirCallBack = *const fn (parent: tp.pid_ref, root_path: []const u8, path: []const u8) error{Exit}!void; pub const Options = struct { follow_directory_symlinks: bool = false, maximum_symlink_depth: usize = 1, log_ignored_links: bool = false, + dir_callback: ?DirCallBack = null, }; pub fn start(a_: std.mem.Allocator, root_path_: []const u8, entry_handler: EntryCallBack, done_handler: DoneCallBack, options: Options) (SpawnError || std.fs.Dir.OpenError)!tp.pid { @@ -78,7 +80,13 @@ pub fn start(a_: std.mem.Allocator, root_path_: []const u8, entry_handler: Entry } fn next(self: *tree_walker) !void { - if (try self.walker.next()) |path| { + if (try self.walker.next()) |entry| { + if (entry.kind == .dir) { + if (self.options.dir_callback) |cb| + cb(self.parent.ref(), self.root_path, entry.path) catch {}; + return tp.self_pid().send(.{"next"}); + } + const path = entry.path; const stat = self.dir.statFile(path) catch { try self.entry_handler(self.parent.ref(), self.root_path, path, 0, 0); return tp.self_pid().send(.{"next"}); @@ -123,6 +131,8 @@ const FilteredWalker = struct { name_buffer: std.ArrayListUnmanaged(u8), options: Options, + const Kind = enum { file, dir }; + const Entry = struct { path: []const u8, kind: Kind }; const Path = []const u8; const StackItem = struct { @@ -160,7 +170,7 @@ const FilteredWalker = struct { self.name_buffer.deinit(self.allocator); } - fn next(self: *FilteredWalker) OutOfMemoryError!?Path { + fn next(self: *FilteredWalker) OutOfMemoryError!?Entry { while (self.stack.items.len != 0) { var top = &self.stack.items[self.stack.items.len - 1]; var containing = top; @@ -182,17 +192,17 @@ const FilteredWalker = struct { switch (base.kind) { .directory => { _ = try self.next_directory(&base, &top, &containing, top.symlink_depth); - continue; + return .{ .path = self.name_buffer.items, .kind = .dir }; }, - .file => return self.name_buffer.items, + .file => return .{ .path = self.name_buffer.items, .kind = .file }, .sym_link => { if (top.symlink_depth == 0) { if (self.options.log_ignored_links) std.log.warn("TOO MANY LINKS! ignoring symlink: {s}", .{base.name}); continue; } - if (try self.next_sym_link(&base, &top, &containing, top.symlink_depth -| 1)) |file| - return file + if (try self.next_sym_link(&base, &top, &containing, top.symlink_depth -| 1)) |entry| + return entry else continue; }, @@ -229,15 +239,17 @@ const FilteredWalker = struct { return; } - fn next_sym_link(self: *FilteredWalker, base: *const std.fs.Dir.Entry, top: **StackItem, containing: **StackItem, symlink_depth: usize) !?[]const u8 { + fn next_sym_link(self: *FilteredWalker, base: *const std.fs.Dir.Entry, top: **StackItem, containing: **StackItem, symlink_depth: usize) !?Entry { const st = top.*.iter.dir.statFile(base.name) catch return null; switch (st.kind) { .directory => { - if (self.options.follow_directory_symlinks) - _ = try self.next_directory(base, top, containing, symlink_depth); + if (self.options.follow_directory_symlinks) { + try self.next_directory(base, top, containing, symlink_depth); + return .{ .path = self.name_buffer.items, .kind = .dir }; + } return null; }, - .file => return self.name_buffer.items, + .file => return .{ .path = self.name_buffer.items, .kind = .file }, .sym_link => { if (symlink_depth == 0) { if (self.options.log_ignored_links) From b88e98f4e270436aa56c55e2c226893b7e7448c4 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 20 Feb 2026 10:44:00 +0100 Subject: [PATCH 06/12] refactor: add support for atomic rename file watcher events on linux --- src/Project.zig | 14 ++++++++++ src/project_manager.zig | 59 +++++++++++++++++++++++++++++++---------- src/tui/tui.zig | 3 +++ 3 files changed, 62 insertions(+), 14 deletions(-) diff --git a/src/Project.zig b/src/Project.zig index 7ea611ce..547aadeb 100644 --- a/src/Project.zig +++ b/src/Project.zig @@ -805,6 +805,20 @@ pub fn file_modified(self: *Self, file_path: []const u8) void { } } +pub fn file_renamed(self: *Self, from_path: []const u8, to_path: []const u8) OutOfMemoryError!void { + for (self.files.items) |*file| { + if (!std.mem.eql(u8, file.path, from_path)) continue; + const new_path = try self.allocator.dupe(u8, to_path); + self.allocator.free(file.path); + file.path = new_path; + file.mtime = std.time.nanoTimestamp(); + self.longest_file_path = @max(self.longest_file_path, to_path.len); + self.sort_files_by_mtime(); + return; + } + return self.file_added(to_path); +} + pub fn file_deleted(self: *Self, file_path: []const u8) void { for (self.files.items, 0..) |file, i| { if (!std.mem.eql(u8, file.path, file_path)) continue; diff --git a/src/project_manager.zig b/src/project_manager.zig index 68a018c8..9765546a 100644 --- a/src/project_manager.zig +++ b/src/project_manager.zig @@ -422,12 +422,15 @@ const Process = struct { var eol_mode: Buffer.EolModeTag = @intFromEnum(Buffer.EolMode.lf); var event_type: file_watcher.EventType = undefined; + var from_path: []const u8 = undefined; - if (try cbor.match(m.buf, .{ "FW", "change", tp.extract(&path), tp.extract(&event_type) })) { + if (try cbor.match(m.buf, .{ "FW", "rename", tp.extract(&from_path), tp.extract(&path) })) { + self.handle_file_watch_rename(from_path, path); + } else if (try cbor.match(m.buf, .{ "FW", "change", tp.extract(&path), tp.extract(&event_type) })) { self.handle_file_watch_event(path, event_type); } else if (try cbor.match(m.buf, .{ "walk_tree_dir", tp.extract(&project_directory), tp.extract(&path) })) { var abs_buf: [std.fs.max_path_bytes]u8 = undefined; - const abs_path = std.fmt.bufPrint(&abs_buf, "{s}{c}{s}", .{ project_directory, std.fs.path.sep, path }) catch return; + const abs_path = std.fmt.bufPrint(&abs_buf, "{s}{c}{s}", .{ project_directory, std.fs.path.sep, path }) catch project_directory; file_watcher.watch(abs_path) catch |e| self.logger.err("file_watcher.watch_dir", e); } else if (try cbor.match(m.buf, .{ "walk_tree_entry", tp.extract(&project_directory), tp.more })) { if (self.projects.get(project_directory)) |project| @@ -555,24 +558,52 @@ const Process = struct { } } - fn handle_file_watch_event(self: *Process, abs_path: []const u8, event_type: file_watcher.EventType) void { - std.log.debug("file_watch_event: {s} {s}", .{ @tagName(event_type), abs_path }); + fn project_for_path(self: *Process, abs_path: []const u8) ?struct { project: *Project, rel_path: []const u8 } { var it = self.projects.iterator(); while (it.next()) |entry| { const dir = entry.key_ptr.*; if (!std.mem.startsWith(u8, abs_path, dir)) continue; if (abs_path.len <= dir.len or abs_path[dir.len] != std.fs.path.sep) continue; - const rel_path = abs_path[dir.len + 1 ..]; - const project = entry.value_ptr.*; - switch (event_type) { - .created => project.file_added(rel_path) catch |e| self.logger.err("file_watcher.file_added", e), - .modified => project.file_modified(rel_path), - .deleted => project.file_deleted(rel_path), - .renamed => project.file_deleted(rel_path), - } - return; + return .{ .project = entry.value_ptr.*, .rel_path = abs_path[dir.len + 1 ..] }; + } + return null; + } + + fn handle_file_watch_rename(self: *Process, abs_from: []const u8, abs_to: []const u8) void { + std.log.debug("file_watch_event: rename {s} -> {s}", .{ abs_from, abs_to }); + const src = self.project_for_path(abs_from); + const dst = self.project_for_path(abs_to); + + if (src) |s| { + if (dst) |d| { + if (s.project == d.project) { + s.project.file_renamed(s.rel_path, d.rel_path) catch |e| self.logger.err("file_watcher.file_renamed", e); + } else { + s.project.file_deleted(s.rel_path); + d.project.file_added(d.rel_path) catch |e| self.logger.err("file_watcher.file_added", e); + } + } else { + s.project.file_deleted(s.rel_path); + } + } else if (dst) |d| { + d.project.file_added(d.rel_path) catch |e| self.logger.err("file_watcher.file_added", e); + } else { + self.parent.send(.{ "FW", "rename", abs_from, abs_to }) catch {}; + } + } + + fn handle_file_watch_event(self: *Process, abs_path: []const u8, event_type: file_watcher.EventType) void { + std.log.debug("file_watch_event: {s} {s}", .{ @tagName(event_type), abs_path }); + if (self.project_for_path(abs_path)) |match| { + switch (event_type) { + .created => match.project.file_added(match.rel_path) catch |e| self.logger.err("file_watcher.file_added", e), + .modified => match.project.file_modified(match.rel_path), + .deleted => match.project.file_deleted(match.rel_path), + .renamed => match.project.file_deleted(match.rel_path), + } + } else { + self.parent.send(.{ "FW", "change", abs_path, event_type }) catch {}; } - self.parent.send(.{ "FW", "change", abs_path, event_type }) catch {}; } fn open(self: *Process, project_directory: []const u8) (SpawnError || std.fs.Dir.OpenError)!void { diff --git a/src/tui/tui.zig b/src/tui/tui.zig index d99b9a2c..54270bba 100644 --- a/src/tui/tui.zig +++ b/src/tui/tui.zig @@ -625,6 +625,9 @@ fn receive_safe(self: *Self, from: tp.pid_ref, m: tp.message) !void { if (try m.match(.{ "FW", "change", tp.more })) // file watcher events return; + if (try m.match(.{ "FW", "rename", tp.more })) // file watcher rename events + return; + return tp.unexpected(m); } From bc06118995006796661a0b2b07293786317e69eb Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 20 Feb 2026 19:02:37 +0100 Subject: [PATCH 07/12] fix: prevent duplicate files in project file index --- src/Project.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Project.zig b/src/Project.zig index 547aadeb..0869a691 100644 --- a/src/Project.zig +++ b/src/Project.zig @@ -784,6 +784,8 @@ fn loaded(self: *Self, parent: tp.pid_ref) OutOfMemoryError!void { pub fn file_added(self: *Self, file_path: []const u8) OutOfMemoryError!void { for (self.files.items) |file| if (std.mem.eql(u8, file.path, file_path)) return; + for (self.pending.items) |file| + if (std.mem.eql(u8, file.path, file_path)) return; const file_type, const file_icon, const file_color = guess_file_type(file_path); (try self.files.addOne(self.allocator)).* = .{ .path = try self.allocator.dupe(u8, file_path), From 7614eea30eb790d878bba90e11540c63fa871320 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 20 Feb 2026 19:11:28 +0100 Subject: [PATCH 08/12] refactor: add an FSEvents based watcher for macos --- build.zig | 6 ++++++ build.zig.zon | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/build.zig b/build.zig index c6469fc9..0ef31769 100644 --- a/build.zig +++ b/build.zig @@ -734,6 +734,12 @@ pub fn build_exe( exe.root_module.addImport("version", b.createModule(.{ .root_source_file = version_file })); exe.root_module.addImport("version_info", b.createModule(.{ .root_source_file = version_info_file })); + if (target.result.os.tag == .macos) { + exe.addFrameworkPath(b.dependency("xcode-frameworks", .{}).path("Frameworks")); + exe.linkFramework("CoreServices"); + exe.linkFramework("CoreFoundation"); + } + if (target.result.os.tag == .windows) { exe.addWin32ResourceFile(.{ .file = b.path("src/win32/flow.rc"), diff --git a/build.zig.zon b/build.zig.zon index bd7db6ae..ba037299 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -46,6 +46,10 @@ .url = "git+https://github.com/ziglibs/diffz.git#fbdf690b87db6b1142bbce6d4906f90b09ce60bb", .hash = "diffz-0.0.1-G2tlIezMAQBwGNGDs7Hn_N25dWSjEzgR_FAx9GFAvCuZ", }, + .@"xcode-frameworks" = .{ + .url = "git+https://github.com/hexops/xcode-frameworks?ref=main#8a1cfb373587ea4c9bb1468b7c986462d8d4e10e", + .hash = "N-V-__8AALShqgXkvqYU6f__FrA22SMWmi2TXCJjNTO1m8XJ", + }, }, .paths = .{ "include", From 311209649f419701f7f3e2a578ac1b66fe0bd15b Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 20 Feb 2026 19:53:43 +0100 Subject: [PATCH 09/12] fixup: FSEvents watcher --- build.zig | 1 + 1 file changed, 1 insertion(+) diff --git a/build.zig b/build.zig index 0ef31769..b0707686 100644 --- a/build.zig +++ b/build.zig @@ -736,6 +736,7 @@ pub fn build_exe( if (target.result.os.tag == .macos) { exe.addFrameworkPath(b.dependency("xcode-frameworks", .{}).path("Frameworks")); + exe.addLibraryPath(b.dependency("xcode-frameworks", .{}).path("lib")); exe.linkFramework("CoreServices"); exe.linkFramework("CoreFoundation"); } From 7dce0b0572a8c75ffc1057750476c0f176048a8c Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 20 Feb 2026 20:36:17 +0100 Subject: [PATCH 10/12] refactor: add watches for newly created directories --- src/project_manager.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/project_manager.zig b/src/project_manager.zig index 9765546a..8f9e3cb1 100644 --- a/src/project_manager.zig +++ b/src/project_manager.zig @@ -594,12 +594,17 @@ const Process = struct { fn handle_file_watch_event(self: *Process, abs_path: []const u8, event_type: file_watcher.EventType) void { std.log.debug("file_watch_event: {s} {s}", .{ @tagName(event_type), abs_path }); + if (event_type == .dir_created) { + file_watcher.watch(abs_path) catch |e| self.logger.err("file_watcher.watch(dir_created)", e); + return; + } if (self.project_for_path(abs_path)) |match| { switch (event_type) { .created => match.project.file_added(match.rel_path) catch |e| self.logger.err("file_watcher.file_added", e), .modified => match.project.file_modified(match.rel_path), .deleted => match.project.file_deleted(match.rel_path), .renamed => match.project.file_deleted(match.rel_path), + .dir_created => unreachable, } } else { self.parent.send(.{ "FW", "change", abs_path, event_type }) catch {}; From c56e713510b0abbf7feebd74c715a9291a0dea5a Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Fri, 20 Feb 2026 21:35:54 +0100 Subject: [PATCH 11/12] fix: renames of project files are deletes if the target already exists --- src/Project.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Project.zig b/src/Project.zig index 0869a691..2f3a5175 100644 --- a/src/Project.zig +++ b/src/Project.zig @@ -808,6 +808,11 @@ pub fn file_modified(self: *Self, file_path: []const u8) void { } pub fn file_renamed(self: *Self, from_path: []const u8, to_path: []const u8) OutOfMemoryError!void { + for (self.files.items) |*file| { + if (!std.mem.eql(u8, file.path, to_path)) continue; + self.file_deleted(from_path); + return; + } for (self.files.items) |*file| { if (!std.mem.eql(u8, file.path, from_path)) continue; const new_path = try self.allocator.dupe(u8, to_path); From 15f471f390c80df9a83d67406795399570c78f52 Mon Sep 17 00:00:00 2001 From: CJ van den Berg Date: Thu, 26 Feb 2026 12:50:22 +0100 Subject: [PATCH 12/12] WIPWIP --- build.zig | 9 ++- build.zig.zon | 4 + src/file_watcher.zig | 171 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 182 insertions(+), 2 deletions(-) create mode 100644 src/file_watcher.zig diff --git a/build.zig b/build.zig index b0707686..c155850f 100644 --- a/build.zig +++ b/build.zig @@ -349,6 +349,12 @@ pub fn build_exe( }); const syntax_mod = syntax_dep.module("syntax"); + const nightwatch_dep = b.dependency("nightwatch", .{ + .target = target, + .optimize = optimize, + }); + const nightwatch_mod = nightwatch_dep.module("nightwatch"); + const help_mod = b.createModule(.{ .root_source_file = b.path("help.md"), }); @@ -607,8 +613,7 @@ pub fn build_exe( const file_watcher_mod = b.createModule(.{ .root_source_file = b.path("src/file_watcher.zig"), .imports = &.{ - .{ .name = "soft_root", .module = soft_root_mod }, - .{ .name = "log", .module = log_mod }, + .{ .name = "nightwatch", .module = nightwatch_mod }, .{ .name = "cbor", .module = cbor_mod }, .{ .name = "thespian", .module = thespian_mod }, }, diff --git a/build.zig.zon b/build.zig.zon index ba037299..af33c623 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -50,6 +50,10 @@ .url = "git+https://github.com/hexops/xcode-frameworks?ref=main#8a1cfb373587ea4c9bb1468b7c986462d8d4e10e", .hash = "N-V-__8AALShqgXkvqYU6f__FrA22SMWmi2TXCJjNTO1m8XJ", }, + .nightwatch = .{ + .url = "git+https://git.flow-control.dev/neurocyte/nightwatch?ref=master#a1e5e3e9a5126d1ff7ce4959a823ea12f20ef0ae", + .hash = "nightwatch-0.1.0-uXzeH8WuAAC95apH6JQZQDzCGrXK2PRPq0rDPPxwUI3Z", + }, }, .paths = .{ "include", diff --git a/src/file_watcher.zig b/src/file_watcher.zig new file mode 100644 index 00000000..e94b59b7 --- /dev/null +++ b/src/file_watcher.zig @@ -0,0 +1,171 @@ +const std = @import("std"); +const tp = @import("thespian"); +const cbor = @import("cbor"); +const nightwatch = @import("nightwatch"); +const builtin = @import("builtin"); + +pid: tp.pid_ref, + +const Self = @This(); +const module_name = @typeName(Self); + +pub const EventType = nightwatch.EventType; + +pub const Error = error{ + FileWatcherSendFailed, + ThespianSpawnFailed, + OutOfMemory, +}; +const SpawnError = error{ OutOfMemory, ThespianSpawnFailed }; + +pub fn watch(path: []const u8) Error!void { + return send(.{ "watch", path }); +} + +pub fn unwatch(path: []const u8) Error!void { + return send(.{ "unwatch", path }); +} + +pub fn start() SpawnError!void { + _ = try get(); +} + +pub fn shutdown() void { + const pid = tp.env.get().proc(module_name); + if (pid.expired()) return; + pid.send(.{"shutdown"}) catch {}; +} + +fn get() SpawnError!Self { + const pid = tp.env.get().proc(module_name); + return if (pid.expired()) create() else .{ .pid = pid }; +} + +fn send(message: anytype) Error!void { + return (try get()).pid.send(message) catch error.FileWatcherSendFailed; +} + +fn create() SpawnError!Self { + const pid = try Process.create(); + defer pid.deinit(); + tp.env.get().proc_set(module_name, pid.ref()); + return .{ .pid = tp.env.get().proc(module_name) }; +} + +const Process = struct { + allocator: std.mem.Allocator, + parent: tp.pid, + receiver: Receiver, + nw: nightwatch, + fd_watcher: if (builtin.os.tag == .linux) tp.file_descriptor else void, + handler: nightwatch.Handler, + + const Receiver = tp.Receiver(*@This()); + + fn create() SpawnError!tp.pid { + const allocator = std.heap.c_allocator; + const self = try allocator.create(@This()); + errdefer allocator.destroy(self); + self.* = .{ + .allocator = allocator, + .parent = tp.self_pid().clone(), + .receiver = Receiver.init(@This().receive, self), + .nw = undefined, + .fd_watcher = if (builtin.os.tag == .linux) undefined else {}, + .handler = init_handler(), + }; + return tp.spawn_link(self.allocator, self, @This().start, module_name); + } + + fn deinit(self: *@This()) void { + if (builtin.os.tag == .linux) self.fd_watcher.deinit(); + self.nw.deinit(); + self.parent.deinit(); + self.allocator.destroy(self); + } + + pub fn init_handler() nightwatch.Handler { + return .{ + .vtable = &.{ + .change = handle_change, + .rename = handle_rename, + .wait_readable = if (builtin.os.tag == .linux) wait_readable else {}, + }, + }; + } + + fn start(self: *@This()) tp.result { + errdefer self.deinit(); + _ = tp.set_trap(true); + self.nw = nightwatch.init(self.allocator, &self.handler) catch |e| return tp.exit_error(e, @errorReturnTrace()); + if (builtin.os.tag == .linux) + self.fd_watcher = tp.file_descriptor.init(module_name, self.nw.backend.inotify_fd) catch |e| { + std.log.err("file_watcher.start: {}", .{e}); + return tp.exit_error(e, @errorReturnTrace()); + }; + tp.receive(&self.receiver); + } + + fn receive(self: *@This(), from: tp.pid_ref, m: tp.message) tp.result { + errdefer self.deinit(); + return self.receive_safe(from, m) catch |e| switch (e) { + error.ExitNormal => tp.exit_normal(), + else => { + const err = tp.exit_error(e, @errorReturnTrace()); + std.log.err("file_watcher.receive: {}", .{err}); + return err; + }, + }; + } + + fn receive_safe(self: *@This(), _: tp.pid_ref, m: tp.message) (error{ExitNormal} || cbor.Error)!void { + var path: []const u8 = undefined; + var tag: []const u8 = undefined; + var err_code: i64 = 0; + var err_msg: []const u8 = undefined; + + if (try cbor.match(m.buf, .{ "fd", tp.extract(&tag), "read_ready" })) { + // re-arm the file_discriptor + if (builtin.os.tag == .linux) { + self.fd_watcher.wait_read() catch |e| std.log.err("file_watcher wait_read: {}", .{e}); + self.nw.handle_read_ready() catch |e| std.log.err("file_watcher handle_read_ready: {}", .{e}); + } + } else if (try cbor.match(m.buf, .{ "fd", tp.extract(&tag), "read_error", tp.extract(&err_code), tp.extract(&err_msg) })) { + std.log.err("fd read error on {s}: ({d}) {s}", .{ tag, err_code, err_msg }); + } else if (try cbor.match(m.buf, .{ "watch", tp.extract(&path) })) { + self.nw.watch(path) catch |e| std.log.err("file_watcher watch: {s} -> {}", .{ path, e }); + } else if (try cbor.match(m.buf, .{ "unwatch", tp.extract(&path) })) { + self.nw.unwatch(path) catch |e| std.log.err("file_watcher unwatch: {s} -> {}", .{ path, e }); + } else if (try cbor.match(m.buf, .{"shutdown"})) { + return error.ExitNormal; + } else if (try cbor.match(m.buf, .{ "exit", tp.more })) { + return error.ExitNormal; + } else { + std.log.err("file_watcher.receive: {}", .{tp.unexpected(m)}); + } + } + + fn handle_change(handler: *nightwatch.Handler, path: []const u8, event_type: EventType) error{HandlerFailed}!void { + const self: *@This() = @alignCast(@fieldParentPtr("handler", handler)); + _ = self; + _ = path; + _ = event_type; + } + + fn handle_rename(handler: *nightwatch.Handler, src_path: []const u8, dst_path: []const u8) error{HandlerFailed}!void { + const self: *@This() = @alignCast(@fieldParentPtr("handler", handler)); + _ = self; + _ = src_path; + _ = dst_path; + } + + fn wait_readable(handler: *nightwatch.Handler) error{HandlerFailed}!nightwatch.ReadableStatus { + const self: *@This() = @alignCast(@fieldParentPtr("handler", handler)); + if (builtin.os.tag == .linux) + self.fd_watcher.wait_read() catch |e| { + std.log.err("file_watcher.wait_readable: {}", .{e}); + return error.HandlerFailed; + }; + return .will_notify; + } +};