// Per-path live reload for the native app. One source watches the file for // in-place writes; another watches its parent because editors normally save by // renaming a new inode over the old one. The file source is rearmed after a // quiet edge so it follows that replacement rather than the unlinked old inode. import Darwin import Dispatch import Foundation private final class WatchedPath { let pane: UInt8 let generation: UInt32 private let path: String private let directorySource: DispatchSourceFileSystemObject private var fileSource: DispatchSourceFileSystemObject? private var active = true private var eventSerial: UInt64 = 0 init?(pane: UInt8, generation: UInt32, path: String) { let directory = URL(fileURLWithPath: path) .deletingLastPathComponent().path let descriptor = directory.withCString { Darwin.open($0, O_EVTONLY | O_CLOEXEC) } guard descriptor >= 0 else { return nil } self.pane = pane self.generation = generation self.path = path directorySource = DispatchSource.makeFileSystemObjectSource( fileDescriptor: descriptor, eventMask: [.write, .delete, .rename, .extend, .attrib, .link, .revoke], queue: .main) directorySource.setCancelHandler { _ = Darwin.close(descriptor) } directorySource.setEventHandler { [weak self] in self?.pathChanged() } reopenFileSource() directorySource.activate() } /// The directory source catches rename-over saves, while the file source /// catches programs which truncate/write the existing inode. Reopen the /// latter after every quiet edge because an atomic save leaves it attached /// to the unlinked old inode forever otherwise. private func reopenFileSource() { if let old = fileSource { old.cancel() } fileSource = nil guard active else { return } let descriptor = path.withCString { Darwin.open($0, O_EVTONLY | O_CLOEXEC) } guard descriptor >= 0 else { return } let next = DispatchSource.makeFileSystemObjectSource( fileDescriptor: descriptor, eventMask: [.write, .delete, .rename, .extend, .attrib, .link, .revoke], queue: .main) next.setCancelHandler { _ = Darwin.close(descriptor) } next.setEventHandler { [weak self] in self?.pathChanged() } fileSource = next next.activate() } /// Vnode sources can emit several edges for one save. Reset a small quiet /// period on every edge; generation plus exact Zig-side identity checks /// remain authoritative, so this delay is latency control rather than /// correctness state. private func pathChanged() { guard active else { return } eventSerial &+= 1 let scheduled = eventSerial DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(45)) { [weak self] in guard let self, self.active, self.eventSerial == scheduled else { return } self.reopenFileSource() pardes_watch_changed(self.pane, self.generation) } } func cancel() { guard active else { return } active = false eventSerial &+= 1 if let old = fileSource { old.cancel() } fileSource = nil directorySource.cancel() } deinit { cancel() } } private final class FileWatchRegistry { static let shared = FileWatchRegistry() private var slots: [UInt8: WatchedPath] = [:] func set(pane: UInt8, generation: UInt32, path: String?) { if let old = slots.removeValue(forKey: pane) { old.cancel() } guard let path, let watched = WatchedPath( pane: pane, generation: generation, path: path) else { return } slots[pane] = watched } } /// Called only by libpardes's main-thread effect drain. A nil pointer stops the /// slot. The byte count avoids a second sentinel copy in Zig; Foundation paths /// are Unicode, so malformed UTF-8 is rejected rather than watched under a /// replacement spelling that could name a different file. @_cdecl("pardes_host_watch_file") func pardesHostWatchFile( _ pane: UInt8, _ generation: UInt32, _ path: UnsafePointer?, _ pathLength: Int ) { guard let path, pathLength > 0 else { FileWatchRegistry.shared.set(pane: pane, generation: generation, path: nil) return } let bytes = UnsafeBufferPointer(start: path, count: pathLength) guard let value = String(bytes: bytes, encoding: .utf8) else { FileWatchRegistry.shared.set(pane: pane, generation: generation, path: nil) return } FileWatchRegistry.shared.set(pane: pane, generation: generation, path: value) }