Per-board @Observable MainActor hub (Kanban/LiveStore/): watcher signals drive off-main tree walks with a generation guard, single-flight coalescing (strongest pending origin, watcher's merge rule), and the resilience contract — a failed reload never replaces a good snapshot, per-file breakage never locks editing, and a wholesale operation (performWholesale) arms a reload-must-succeed-or-lock floor so a failed post-bracket reload flips the board read-only until a good reload heals it. Selection is a pure UUID-set value re-resolved on every swap; liveness flips eject. performWrite brackets the watcher so Writer round-trips come back app-mediated. 14 store tests; full suite 279 tests in 54 suites green. Three findings filed on the Redesign board. Claude-Session: https://claude.ai/code/session_018BjQRYBR6jQja3jCRi5S3A
603 lines
30 KiB
Swift
603 lines
30 KiB
Swift
import CoreServices
|
|
import Foundation
|
|
|
|
// MARK: - Vocabulary
|
|
|
|
/// Why a reload is being asked for — the provenance of a `WatcherEvent.treeChanged`.
|
|
///
|
|
/// The consumer (`BoardStore`, a later milestone) reloads identically for all three; the origin
|
|
/// exists so the *policy* around a reload can differ — a reconciling sweep re-probes writability
|
|
/// (02-architecture.md § Write-failure surfacing, "Writability re-probes on every reconciling
|
|
/// reload"), an app-mediated reload is the one that ends a bracket's read-only lock (§
|
|
/// Live-reload resilience), and a foreign reload is the ordinary external-edit case. Every
|
|
/// reload is a full tree walk producing a value-type snapshot, so no origin is ever *less* safe
|
|
/// than another — an identical tree swaps in value-equal and costs nothing visible.
|
|
public enum WatchOrigin: String, Sendable, Equatable {
|
|
/// The change span was produced under an app bracket (`beginBracket()` … `endBracket()`) —
|
|
/// the app's own pull-rebase, branch switch, undo restore, or ordinary Writer mutation.
|
|
case appMediated
|
|
/// The change arrived from outside the app: an editor, an agent, `git` in a terminal.
|
|
case foreign
|
|
/// A reconciliation sweep rather than an observed change: wake-from-sleep, app re-activation,
|
|
/// an FSEvents flag admitting missed events, or a stream re-creation. "Never trusted blindly"
|
|
/// (§ Live-reload resilience) — every known blind window ends in one of these.
|
|
case reconciling
|
|
|
|
/// Precedence when two change spans coalesce into one delivery: `reconciling` outranks
|
|
/// `appMediated` outranks `foreign`. Higher means "makes the stronger claim about what the
|
|
/// reload has to cover" — a reconciling reload assumes nothing about the tree, an
|
|
/// app-mediated one is the tail of an operation the app ran, a foreign one is an observed
|
|
/// external edit.
|
|
var precedence: Int {
|
|
switch self {
|
|
case .foreign: 0
|
|
case .appMediated: 1
|
|
case .reconciling: 2
|
|
}
|
|
}
|
|
|
|
/// The origin a coalesced delivery carries: the stronger of the two, never the newer.
|
|
///
|
|
/// This is the *never downgrade* rule — a foreign event landing on a pending app-mediated or
|
|
/// reconciling delivery does not weaken it. See `FolderWatcher.schedule(_:)` for why the
|
|
/// resulting blur is accepted rather than engineered away.
|
|
///
|
|
/// Internal rather than `fileprivate`: `BoardStore` coalesces signals that arrive while a reload
|
|
/// is already running and owes the same never-downgrade guarantee on its side of the handoff.
|
|
/// Two coalescing points, one rule — the watcher's debounce and the store's pending-reload flag
|
|
/// must never disagree about which origin a merged span carries.
|
|
static func merged(_ existing: WatchOrigin?, _ incoming: WatchOrigin) -> WatchOrigin {
|
|
guard let existing else { return incoming }
|
|
return existing.precedence >= incoming.precedence ? existing : incoming
|
|
}
|
|
}
|
|
|
|
/// What the watcher tells its consumer. Deliberately two cases and no more: the watcher reports
|
|
/// *that* the tree changed, never *what* changed. Diffing is the loader's job, and a value-type
|
|
/// snapshot comparison is both cheaper and more trustworthy than trying to reconstruct a tree
|
|
/// mutation from a stream of paths that may have been coalesced, dropped, or reordered.
|
|
public enum WatcherEvent: Sendable, Equatable {
|
|
/// Debounced and coalesced: "the tree changed — reload". One event may stand for hundreds of
|
|
/// filesystem events; `WatchOrigin` says where the span came from.
|
|
case treeChanged(WatchOrigin)
|
|
/// The watched root itself vanished or changed identity (deleted, renamed, moved, or its
|
|
/// volume unmounted). The stream is already torn down when this arrives; the consumer
|
|
/// re-resolves its security-scoped bookmark and either calls `reattach(to:)` at the new
|
|
/// location or enters the vanished-root read-only lock (02-architecture.md §
|
|
/// Write-failure surfacing).
|
|
case rootChanged
|
|
}
|
|
|
|
// MARK: - FolderWatcher
|
|
|
|
/// The board's one watching path: an FSEvents stream over the board root, debounced and
|
|
/// coalesced into "reload now" (02-architecture.md § Layering ▸ Components).
|
|
///
|
|
/// There is deliberately **nothing else** watching — no `NSMetadataQuery` for iCloud Drive, no
|
|
/// polling fallback for network volumes. On those warned-against locations FSEvents delivery is
|
|
/// unreliable and live reload silently degrades, accepted per 07-sync-collab.md's
|
|
/// no-accommodations stance. This type therefore contains not one line of volume special-casing;
|
|
/// the only concession is that a stream which cannot be created is a `false` from `start()`
|
|
/// rather than a crash.
|
|
///
|
|
/// ### The three things this type actually does
|
|
///
|
|
/// 1. **Debounce.** Filesystem churn arrives in bursts — a `git checkout`, an agent writing
|
|
/// twenty cards, the app's own multi-file move. Events arriving while a delivery is pending
|
|
/// restart the timer (a trailing debounce), so a burst costs exactly one reload after quiet.
|
|
/// 2. **Brackets.** Operations the app runs itself suspend delivery for their duration and
|
|
/// finish with one full reload — half-checked-out trees are never rendered (§ Live-reload
|
|
/// resilience, "App-initiated git churn is bracketed").
|
|
/// 3. **Reconciliation.** Anything admitting a blind window — a missed-events flag, a stream
|
|
/// re-creation, a wake or activation the consumer reports — degrades to a reload rather than
|
|
/// trusting the gap. "A silently stale board is structurally excluded."
|
|
///
|
|
/// ### Lifetime contract
|
|
///
|
|
/// `start()` and `stop()` are explicit and the documented contract. `stop()` is idempotent, safe
|
|
/// to call on a watcher that never started, and guarantees nothing fires afterwards. The C-level
|
|
/// stream is owned by a small non-isolated handle whose `deinit` invalidates it, so a watcher
|
|
/// dropped without `stop()` still tears its stream down rather than leaking it — but a dropped
|
|
/// watcher is not a *quiet* watcher until that release happens, which is why `stop()` stays the
|
|
/// contract.
|
|
@MainActor
|
|
public final class FolderWatcher {
|
|
|
|
// MARK: Configuration
|
|
|
|
/// The path FSEvents was asked to watch, symlink-resolved — and the only form of the root
|
|
/// this type keeps. FSEvents reports canonical paths (`/private/var/...`), so the prefix
|
|
/// every incoming path is measured against has to be canonical too, or the `.git` filter
|
|
/// below would never match on a temp directory, which is exactly where it is tested. The
|
|
/// consumer owns the board's *identity* (its security-scoped bookmark); the watcher only
|
|
/// needs somewhere to point.
|
|
private var watchedPath: String
|
|
private let debounce: Duration
|
|
private let latency: TimeInterval
|
|
private let handler: @MainActor (WatcherEvent) -> Void
|
|
|
|
/// FSEvents callbacks land here before hopping to the main actor. Serial and private: the
|
|
/// stream's `info` box is released on this queue's tail by FSEvents itself, so an in-flight
|
|
/// callback can never outlive the box it reads from.
|
|
private let queue = DispatchQueue(label: "dev.rzen.indie.Kanban.FolderWatcher", qos: .utility)
|
|
|
|
// MARK: State
|
|
|
|
private var handle: StreamHandle?
|
|
|
|
/// Bumped on every stream creation and every teardown. A callback that has already hopped to
|
|
/// the main actor when `stop()` (or `reattach(to:)`) runs would otherwise deliver an event
|
|
/// from a stream that no longer exists; the generation it captured no longer matches and it
|
|
/// is dropped. This is the whole of the "nothing fires after `stop()`" guarantee on the
|
|
/// inbound side — the outbound side is the cancelled delivery task.
|
|
private var generation = 0
|
|
|
|
/// Nesting depth of `beginBracket()`/`endBracket()`. Brackets nest because the operations
|
|
/// that use them do: an undo restore inside a branch switch is one bracketed span, not two.
|
|
private var bracketDepth = 0
|
|
|
|
/// The origin a pending delivery will carry, merged per `WatchOrigin.merged(_:_:)`. Non-nil
|
|
/// means "a delivery is owed" — which is not the same as "a timer is running": while a
|
|
/// bracket is open the origin is remembered with no timer armed, and `endBracket()` merges
|
|
/// its `.appMediated` into it and arms the timer then.
|
|
private var pendingOrigin: WatchOrigin?
|
|
|
|
/// The debounce, as a cancellable `Task` on the main actor rather than a `Timer` or a
|
|
/// `DispatchSourceTimer`: the delivery has to happen where the handler and all the state it
|
|
/// touches already live, and `Task.sleep` gives cancellation for free at exactly the
|
|
/// granularity a trailing debounce needs — cancel, re-arm, done.
|
|
private var deliveryTask: Task<Void, Never>?
|
|
|
|
// MARK: Init
|
|
|
|
/// - Parameters:
|
|
/// - root: The board root to watch. Need not exist yet (see `start()`).
|
|
/// - debounce: Quiet period before a coalesced `.treeChanged` is delivered. The default is
|
|
/// the reload debounce: long enough to absorb a `git checkout`'s churn and most momentary
|
|
/// invalid states (§ Live-reload resilience, "the reload debounce already absorbs most
|
|
/// momentary invalid states before they surface"), short enough that an external edit
|
|
/// feels live.
|
|
/// - latency: FSEvents' own coalescing latency, in seconds. Kept small and paired with
|
|
/// `NoDefer` so the *first* event of a burst arrives promptly; this type's own debounce
|
|
/// does the real coalescing, where it can be reasoned about and tested.
|
|
/// - handler: Called on the main actor for every delivered event.
|
|
public init(
|
|
root: URL,
|
|
debounce: Duration = .milliseconds(200),
|
|
latency: TimeInterval = 0.05,
|
|
handler: @escaping @MainActor (WatcherEvent) -> Void
|
|
) {
|
|
self.watchedPath = Self.canonicalPath(of: root)
|
|
self.debounce = debounce
|
|
self.latency = latency
|
|
self.handler = handler
|
|
}
|
|
|
|
deinit {
|
|
// The stream is released with `self`; `StreamHandle.deinit` stops, invalidates, and
|
|
// releases it. Nothing main-actor-isolated can be touched from here, and nothing needs
|
|
// to be: a pending `deliveryTask` holds only a weak reference back and drops its event.
|
|
}
|
|
|
|
/// Whether a stream is currently attached and delivering.
|
|
public var isWatching: Bool { handle != nil }
|
|
|
|
// MARK: - Stream lifecycle
|
|
|
|
/// Creates and starts the FSEvents stream. Returns `false` if the stream could not be
|
|
/// created or started, in which case `isWatching` stays `false` and every other method
|
|
/// remains safe to call — brackets, `reconcile()`, and `reattach(to:)` all still behave, they
|
|
/// simply have no filesystem prompting them.
|
|
///
|
|
/// Calling `start()` on an already-watching watcher replaces the stream (the old one is torn
|
|
/// down first), which is what makes it safe to use as a retry.
|
|
///
|
|
/// **A nonexistent path is not an error to FSEvents**: the stream is created and started
|
|
/// happily, and with `WatchRoot` the eventual *creation* of that path is reported as a root
|
|
/// change. `start()` therefore returns `true` for a path that is not there — the honest
|
|
/// answer, since the stream really is live and really will report the path appearing. Callers
|
|
/// that need existence must check for it themselves; this type's job is watching, not
|
|
/// validating.
|
|
@discardableResult
|
|
public func start() -> Bool {
|
|
teardownStream()
|
|
|
|
generation += 1
|
|
let generation = self.generation
|
|
let box = CallbackBox { [weak self] paths, flags in
|
|
// The C callback runs on `queue`; the state it feeds is main-actor-isolated, so the
|
|
// hop is unavoidable — and harmless, because the delivery it feeds is debounced
|
|
// anyway. `weak self` so a watcher released between the callback and the hop simply
|
|
// has no one to tell.
|
|
Task { @MainActor in
|
|
self?.receive(paths: paths, flags: flags, generation: generation)
|
|
}
|
|
}
|
|
|
|
var context = FSEventStreamContext(
|
|
version: 0,
|
|
info: Unmanaged.passRetained(box).toOpaque(),
|
|
retain: nil,
|
|
// FSEvents calls this when the stream is deallocated, which it does only after it is
|
|
// finished with the callback — so the box outlives every callback that can read it
|
|
// without a single manual `release` on our side, and without a teardown race.
|
|
//
|
|
// It has to be a top-level function, not a closure literal: a closure written inside
|
|
// this main-actor-isolated method inherits that isolation, and FSEvents runs the
|
|
// release callback on the stream's dispatch queue during
|
|
// `_FSEventStreamDeallocate` — which trips the actor's `dispatch_assert_queue`
|
|
// check and traps. (Found the hard way; the crash is a `SIGTRAP` inside
|
|
// `swift_task_isCurrentExecutor`, not anything that looks like a memory bug.)
|
|
release: folderWatcherReleaseCallback,
|
|
copyDescription: nil
|
|
)
|
|
|
|
let flags = UInt32(
|
|
kFSEventStreamCreateFlagUseCFTypes // paths arrive as CFString, not char*
|
|
| kFSEventStreamCreateFlagFileEvents // per-path events, so `.git` can be filtered
|
|
| kFSEventStreamCreateFlagWatchRoot // the root vanishing is itself an event
|
|
| kFSEventStreamCreateFlagNoDefer // first event of a burst arrives promptly
|
|
)
|
|
|
|
guard let stream = FSEventStreamCreate(
|
|
kCFAllocatorDefault,
|
|
folderWatcherStreamCallback,
|
|
&context,
|
|
[watchedPath] as CFArray,
|
|
FSEventStreamEventId(kFSEventStreamEventIdSinceNow),
|
|
latency,
|
|
flags
|
|
) else {
|
|
// Creation failed, so FSEvents never took ownership of the box: release the retain
|
|
// taken above by hand, or it leaks.
|
|
if let info = context.info {
|
|
Unmanaged<CallbackBox>.fromOpaque(info).release()
|
|
}
|
|
return false
|
|
}
|
|
|
|
FSEventStreamSetDispatchQueue(stream, queue)
|
|
guard FSEventStreamStart(stream) else {
|
|
FSEventStreamInvalidate(stream)
|
|
FSEventStreamRelease(stream)
|
|
return false
|
|
}
|
|
|
|
handle = StreamHandle(stream: stream)
|
|
return true
|
|
}
|
|
|
|
/// Tears the stream down and cancels any pending delivery. Nothing is delivered after this
|
|
/// returns — not an in-flight FSEvents callback (its generation is stale), not a debounce
|
|
/// that was seconds from firing (its task is cancelled).
|
|
///
|
|
/// Bracket depth is deliberately *not* reset: `stop()` is about the stream, and an
|
|
/// unbalanced bracket is the caller's bug to keep, not this type's to paper over.
|
|
public func stop() {
|
|
teardownStream()
|
|
cancelPendingDelivery()
|
|
pendingOrigin = nil
|
|
}
|
|
|
|
/// Kills the current stream, attaches a fresh one at `newRoot`, and schedules a reconciling
|
|
/// reload — "streams die and are recreated, not merely kept", with the reconciling reload
|
|
/// that every stream re-creation owes (§ Live-reload resilience).
|
|
///
|
|
/// The consumer's path here is the settled rename story (§ Write-failure surfacing): on
|
|
/// `.rootChanged` it re-resolves its security-scoped bookmark, and if the bookmark resolves
|
|
/// to a new location the move is absorbed transparently — this call is that absorption.
|
|
///
|
|
/// The reconciling reload is scheduled **whether or not the new stream came up**. If the new
|
|
/// location is unwatchable, the reload is how the consumer finds out (its own load fails, or
|
|
/// its writability probe does); silently skipping it would leave a stale board on screen,
|
|
/// which is the one outcome this design excludes.
|
|
public func reattach(to newRoot: URL) {
|
|
stop()
|
|
watchedPath = Self.canonicalPath(of: newRoot)
|
|
_ = start()
|
|
schedule(.reconciling)
|
|
}
|
|
|
|
private func teardownStream() {
|
|
guard let handle else { return }
|
|
// Bump before invalidating: a callback that already hopped to the main actor and is
|
|
// queued behind this call must find its generation stale.
|
|
generation += 1
|
|
handle.invalidate()
|
|
self.handle = nil
|
|
}
|
|
|
|
// MARK: - Brackets
|
|
|
|
/// Opens a bracket. Nestable — brackets count, they do not toggle.
|
|
///
|
|
/// While any bracket is open **nothing is delivered**: incoming FSEvents are swallowed and
|
|
/// any already-armed debounce is disarmed, because a bracketed operation rewrites the tree
|
|
/// wholesale and a reload landing in the middle of it would render a half-checked-out tree
|
|
/// (§ Live-reload resilience).
|
|
///
|
|
/// A delivery that was already *owed* when the bracket opened is not thrown away — its origin
|
|
/// is kept and folds into the post-bracket reload through the ordinary merge. Losing it would
|
|
/// be harmless for the reload itself (the post-bracket reload is a full tree walk either way)
|
|
/// but would lose the *origin*, and a `.reconciling` that becomes an `.appMediated` is a
|
|
/// downgrade of provenance for no gain.
|
|
public func beginBracket() {
|
|
bracketDepth += 1
|
|
cancelPendingDelivery()
|
|
}
|
|
|
|
/// Closes a bracket. At depth 0 this **always** schedules the debounced
|
|
/// `.treeChanged(.appMediated)` — the mandatory single post-bracket reload — even if not one
|
|
/// filesystem event was seen inside the bracket. The bracket's contract is "finish with one
|
|
/// full reload"; making that conditional on having observed events would make correctness
|
|
/// depend on FSEvents delivery, which is precisely the thing this design refuses to trust.
|
|
///
|
|
/// FSEvents produced by the bracketed operation and still in flight when it closes (kernel
|
|
/// latency does not respect our brackets) simply coalesce into that pending delivery via the
|
|
/// origin merge, arriving as `.appMediated` rather than as a second `.foreign` reload.
|
|
///
|
|
/// An unbalanced call — `endBracket()` at depth 0 — is ignored rather than trapping: the
|
|
/// consumer's brackets wrap `do`/`catch` spans over git operations, and a bug there should
|
|
/// not take the app down.
|
|
public func endBracket() {
|
|
guard bracketDepth > 0 else { return }
|
|
bracketDepth -= 1
|
|
guard bracketDepth == 0 else { return }
|
|
schedule(.appMediated)
|
|
}
|
|
|
|
// MARK: - Reconciliation
|
|
|
|
/// Schedules a debounced reconciling reload with no filesystem prompt: the "self-reconciling,
|
|
/// never trusted blindly" rule made callable. The consumer drives it from wake-from-sleep and
|
|
/// app re-activation; the watcher drives it itself from missed-events flags and stream
|
|
/// re-creation.
|
|
///
|
|
/// Cheap by construction — an identical tree reloads to a value-equal snapshot and nothing
|
|
/// visible happens — so callers are meant to be liberal with it.
|
|
///
|
|
/// Respects brackets like everything else: called inside one, it is remembered and delivered
|
|
/// with the post-bracket reload rather than mid-operation.
|
|
public func reconcile() {
|
|
schedule(.reconciling)
|
|
}
|
|
|
|
// MARK: - Delivery
|
|
|
|
/// Records the origin a pending delivery owes and (re-)arms the debounce.
|
|
///
|
|
/// Two rules live here:
|
|
///
|
|
/// - **Trailing debounce**: every call cancels the armed task and arms a fresh one, so a
|
|
/// burst of events delivers once, `debounce` after the last of them.
|
|
/// - **Origin merge**: `reconciling > appMediated > foreign`. A foreign event folding into a
|
|
/// pending app-mediated (or reconciling) delivery does not downgrade it. **This blur is
|
|
/// accepted by design**: once two change spans have been coalesced into one reload, the
|
|
/// reload genuinely covers both, and the honest label for the merged span is the strongest
|
|
/// claim either half made. The alternative — splitting the delivery to keep origins pure —
|
|
/// would trade the coalescing that makes bursts affordable for a distinction no consumer
|
|
/// acts on differently.
|
|
private func schedule(_ origin: WatchOrigin) {
|
|
pendingOrigin = WatchOrigin.merged(pendingOrigin, origin)
|
|
|
|
// Inside a bracket the origin is banked but no timer is armed; `endBracket()` arms it.
|
|
guard bracketDepth == 0 else {
|
|
cancelPendingDelivery()
|
|
return
|
|
}
|
|
|
|
deliveryTask?.cancel()
|
|
deliveryTask = Task { [weak self, debounce] in
|
|
try? await Task.sleep(for: debounce)
|
|
guard !Task.isCancelled else { return }
|
|
self?.fire()
|
|
}
|
|
}
|
|
|
|
private func fire() {
|
|
deliveryTask = nil
|
|
// A bracket that opened during the sleep already cancelled this task, but the cancel and
|
|
// the wake can race; the depth check is the authority.
|
|
guard bracketDepth == 0, let origin = pendingOrigin else { return }
|
|
pendingOrigin = nil
|
|
handler(.treeChanged(origin))
|
|
}
|
|
|
|
private func cancelPendingDelivery() {
|
|
deliveryTask?.cancel()
|
|
deliveryTask = nil
|
|
}
|
|
|
|
// MARK: - Inbound events
|
|
|
|
/// The main-actor landing point for one FSEvents callback batch.
|
|
private func receive(paths: [String], flags: [FSEventStreamEventFlags], generation: Int) {
|
|
guard generation == self.generation, handle != nil else { return }
|
|
|
|
var sawRootChange = false
|
|
var sawMissedEvents = false
|
|
var sawRelevantPath = false
|
|
|
|
for index in paths.indices {
|
|
let flag = index < flags.count ? flags[index] : 0
|
|
|
|
if flag & FSEventStreamEventFlags(kFSEventStreamEventFlagRootChanged) != 0 {
|
|
sawRootChange = true
|
|
continue
|
|
}
|
|
if flag & Self.missedEventsFlags != 0 {
|
|
sawMissedEvents = true
|
|
}
|
|
if !isGitInternal(paths[index]) {
|
|
sawRelevantPath = true
|
|
}
|
|
}
|
|
|
|
if sawRootChange {
|
|
deliverRootChanged()
|
|
return
|
|
}
|
|
|
|
// Brackets swallow everything below this line, missed-events flags included. That is not
|
|
// a gap: the post-bracket reload is already a full tree walk, which covers any span the
|
|
// flag was warning about, so `.appMediated` stays the correct label for it (§ Live-reload
|
|
// resilience — recovery from any blind window is always the same act, reload).
|
|
guard bracketDepth == 0 else { return }
|
|
|
|
if sawMissedEvents {
|
|
// `MustScanSubDirs`, a dropped kernel or user queue, wrapped event ids: the stream is
|
|
// telling us it does not know what happened. Degrade to the reload rather than trust
|
|
// the gap. Note this bypasses the `.git` filter deliberately — a batch we cannot
|
|
// trust the contents of cannot be filtered by its contents either.
|
|
schedule(.reconciling)
|
|
return
|
|
}
|
|
|
|
// Nothing but `.git` churn in the whole batch: schedule nothing at all (see
|
|
// `isGitInternal`).
|
|
if sawRelevantPath {
|
|
schedule(.foreign)
|
|
}
|
|
}
|
|
|
|
/// The root vanished or changed identity. The stream is torn down (it is watching a path that
|
|
/// no longer means what it meant) and the event is delivered **immediately** — bypassing both
|
|
/// the debounce and any open bracket.
|
|
///
|
|
/// Bypassing the bracket is deliberate and is the one place the "nothing fires mid-bracket"
|
|
/// rule yields: a bracketed operation whose root disappeared underneath it cannot finish, and
|
|
/// its post-bracket reload would resolve against a path that is gone. The consumer must know
|
|
/// now, so it can re-resolve its bookmark and either `reattach(to:)` or enter the
|
|
/// vanished-root read-only lock (02-architecture.md § Write-failure surfacing).
|
|
///
|
|
/// Any pending debounced delivery is dropped: it was going to report on a tree that no longer
|
|
/// exists at that path, and whatever the consumer does next — `reattach(to:)` or the lock —
|
|
/// ends in a reload of its own.
|
|
private func deliverRootChanged() {
|
|
teardownStream()
|
|
cancelPendingDelivery()
|
|
pendingOrigin = nil
|
|
handler(.rootChanged)
|
|
}
|
|
|
|
// MARK: - .git filtering
|
|
|
|
/// Whether `path` lives inside the board root's `.git` directory.
|
|
///
|
|
/// **Why filter at all**: the app auto-commits a couple of seconds after every change
|
|
/// (06-history-undo.md), so every single edit the user makes is followed by a burst of writes
|
|
/// to `.git/index`, `.git/objects/…`, and `.git/refs/…` — a second wave of events for a
|
|
/// change already reloaded, arriving just late enough to miss the debounce and cost a
|
|
/// redundant full tree walk. None of that churn can alter the rendered tree: the loader walks
|
|
/// UUID-shaped folders and `index.md` files, and `.git` contains neither.
|
|
///
|
|
/// **Why it is safe**: this filters `.git`'s *internals*, not git's effects. An external
|
|
/// `git checkout`, `git pull`, or `git stash` rewrites working-tree files, and those events
|
|
/// arrive unfiltered in the same batch as the `.git` writes — the batch has a relevant path,
|
|
/// so it schedules. The one thing lost is the ability to notice a pure-history change (a
|
|
/// commit that touched no working-tree file), which changes nothing on screen anyway.
|
|
///
|
|
/// A path that does not sit under the watched root at all is *not* filtered — better a
|
|
/// redundant reload than a missed one.
|
|
private func isGitInternal(_ path: String) -> Bool {
|
|
guard path.hasPrefix(watchedPath) else { return false }
|
|
let relative = path.dropFirst(watchedPath.count)
|
|
guard relative.hasPrefix("/") else { return false }
|
|
return relative.dropFirst().hasPrefix(".git/") || relative.dropFirst() == ".git"
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private static let missedEventsFlags = FSEventStreamEventFlags(
|
|
kFSEventStreamEventFlagMustScanSubDirs
|
|
| kFSEventStreamEventFlagUserDropped
|
|
| kFSEventStreamEventFlagKernelDropped
|
|
| kFSEventStreamEventFlagEventIdsWrapped
|
|
)
|
|
|
|
/// FSEvents reports canonical, symlink-resolved paths with no trailing slash; every path
|
|
/// comparison this type makes has to be against the same shape or it silently never matches.
|
|
private static func canonicalPath(of url: URL) -> String {
|
|
let resolved = url.resolvingSymlinksInPath().standardizedFileURL.path
|
|
return resolved.count > 1 && resolved.hasSuffix("/") ? String(resolved.dropLast()) : resolved
|
|
}
|
|
}
|
|
|
|
// MARK: - C-level plumbing
|
|
|
|
/// Owns the `FSEventStreamRef` outside the main actor so that releasing a `FolderWatcher` — from
|
|
/// wherever the last reference happens to die — still tears the stream down.
|
|
///
|
|
/// `@unchecked Sendable` is honest here rather than a shrug: the handle is created and
|
|
/// invalidated only on the main actor, and the one call that can happen anywhere else is `deinit`
|
|
/// — which by definition runs after every other reference is gone, so there is no concurrent
|
|
/// reader to race with. FSEvents' own stop/invalidate/release are safe to call from any thread.
|
|
private final class StreamHandle: @unchecked Sendable {
|
|
private var stream: FSEventStreamRef?
|
|
|
|
init(stream: FSEventStreamRef) {
|
|
self.stream = stream
|
|
}
|
|
|
|
/// Idempotent: the second call finds `stream` already nil and does nothing, so an explicit
|
|
/// `stop()` followed by deallocation is not a double release.
|
|
func invalidate() {
|
|
guard let stream else { return }
|
|
self.stream = nil
|
|
FSEventStreamStop(stream)
|
|
FSEventStreamInvalidate(stream)
|
|
// Drops the last reference, which is what makes FSEvents run the context's release
|
|
// callback and free the `CallbackBox`.
|
|
FSEventStreamRelease(stream)
|
|
}
|
|
|
|
deinit {
|
|
invalidate()
|
|
}
|
|
}
|
|
|
|
/// The stream's `info` payload: a closure and nothing else.
|
|
///
|
|
/// Strict concurrency has no way to reason about a `void *` round-tripping through C, so the box
|
|
/// keeps the unchecked part as small as it can possibly be — one immutable `@Sendable` closure,
|
|
/// no mutable state, no reference to the watcher (the closure captures it weakly). Its lifetime
|
|
/// is FSEvents': retained into `FSEventStreamContext.info`, released by the context's release
|
|
/// callback when the stream is deallocated.
|
|
private final class CallbackBox: @unchecked Sendable {
|
|
let deliver: @Sendable ([String], [FSEventStreamEventFlags]) -> Void
|
|
|
|
init(deliver: @escaping @Sendable ([String], [FSEventStreamEventFlags]) -> Void) {
|
|
self.deliver = deliver
|
|
}
|
|
}
|
|
|
|
/// Balances the `Unmanaged.passRetained` that put the box into `FSEventStreamContext.info`.
|
|
/// Top-level and therefore non-isolated by construction — see the comment at the call site for
|
|
/// why that matters.
|
|
private func folderWatcherReleaseCallback(_ pointer: UnsafeRawPointer?) {
|
|
guard let pointer else { return }
|
|
Unmanaged<CallbackBox>.fromOpaque(pointer).release()
|
|
}
|
|
|
|
/// The `@convention(c)` trampoline. Captures nothing (it cannot), unpacks the `UseCFTypes` path
|
|
/// array and the parallel flags array, and hands both to the box.
|
|
private func folderWatcherStreamCallback(
|
|
_ stream: ConstFSEventStreamRef,
|
|
_ info: UnsafeMutableRawPointer?,
|
|
_ numEvents: Int,
|
|
_ eventPaths: UnsafeMutableRawPointer,
|
|
_ eventFlags: UnsafePointer<FSEventStreamEventFlags>,
|
|
_ eventIds: UnsafePointer<FSEventStreamEventId>
|
|
) {
|
|
guard let info, numEvents > 0 else { return }
|
|
let box = Unmanaged<CallbackBox>.fromOpaque(info).takeUnretainedValue()
|
|
|
|
// With `kFSEventStreamCreateFlagUseCFTypes` this is a CFArray of CFString — the documented
|
|
// shape, and the reason that flag is set: no manual `char *` decoding, no encoding guesswork
|
|
// on paths.
|
|
guard let paths = unsafeBitCast(eventPaths, to: NSArray.self) as? [String] else { return }
|
|
let flags = (0..<min(numEvents, paths.count)).map { eventFlags[$0] }
|
|
|
|
box.deliver(paths, flags)
|
|
}
|