* Singleton store for the TodoV2 task list. Owns the file watcher, timers, * and cached task list. Multiple hook instances (REPL, Spinner, * PromptInputFooterLeftSide) subscribe to one shared store instead of each * setting up their own fs.watch on the same directory. The Spinner mounts/ * unmou
| 27 | * Implements the useSyncExternalStore contract: subscribe/getSnapshot. |
| 28 | */ |
| 29 | class TasksV2Store { |
| 30 | /** Stable array reference; replaced only on fetch. undefined until started. */ |
| 31 | #tasks: Task[] | undefined = undefined |
| 32 | /** |
| 33 | * Set when the hide timer has elapsed (all tasks completed for >5s), or |
| 34 | * when the task list is empty. Starts false so the first fetch runs the |
| 35 | * "all completed → schedule 5s hide" path (matches original behavior: |
| 36 | * resuming a session with completed tasks shows them briefly). |
| 37 | */ |
| 38 | #hidden = false |
| 39 | #watcher: FSWatcher | null = null |
| 40 | #watchedDir: string | null = null |
| 41 | #hideTimer: ReturnType<typeof setTimeout> | null = null |
| 42 | #debounceTimer: ReturnType<typeof setTimeout> | null = null |
| 43 | #pollTimer: ReturnType<typeof setTimeout> | null = null |
| 44 | #unsubscribeTasksUpdated: (() => void) | null = null |
| 45 | #changed = createSignal() |
| 46 | #subscriberCount = 0 |
| 47 | #started = false |
| 48 | |
| 49 | /** |
| 50 | * useSyncExternalStore snapshot. Returns the same Task[] reference between |
| 51 | * updates (required for Object.is stability). Returns undefined when hidden. |
| 52 | */ |
| 53 | getSnapshot = (): Task[] | undefined => { |
| 54 | return this.#hidden ? undefined : this.#tasks |
| 55 | } |
| 56 | |
| 57 | subscribe = (fn: () => void): (() => void) => { |
| 58 | // Lazy init on first subscriber. useSyncExternalStore calls this |
| 59 | // post-commit, so I/O here is safe (no render-phase side effects). |
| 60 | // REPL.tsx keeps a subscription alive for the whole session, so |
| 61 | // Spinner mount/unmount churn never drives the count to zero. |
| 62 | const unsubscribe = this.#changed.subscribe(fn) |
| 63 | this.#subscriberCount++ |
| 64 | if (!this.#started) { |
| 65 | this.#started = true |
| 66 | this.#unsubscribeTasksUpdated = onTasksUpdated(this.#debouncedFetch) |
| 67 | // Fire-and-forget: subscribe is called post-commit (not in render), |
| 68 | // and the store notifies subscribers when the fetch resolves. |
| 69 | void this.#fetch() |
| 70 | } |
| 71 | let unsubscribed = false |
| 72 | return () => { |
| 73 | if (unsubscribed) return |
| 74 | unsubscribed = true |
| 75 | unsubscribe() |
| 76 | this.#subscriberCount-- |
| 77 | if (this.#subscriberCount === 0) this.#stop() |
| 78 | } |
| 79 | } |
| 80 | |
| 81 | #notify(): void { |
| 82 | this.#changed.emit() |
| 83 | } |
| 84 | |
| 85 | /** |
| 86 | * Point the file watcher at the current tasks directory. Called on start |
nothing calls this directly
no test coverage detected