feat: Code view auto-refreshes from filesystem changes#786
Conversation
Replace the on-demand fetch + manual refresh button in the right panel's Code view with server-pushed live updates. Editor saves, branch checkouts, commits, and `git add` all flow through to the tree, diff, and browse views without user action. Four volatility axes are watched, each as a refcounted shared singleton: `watchGitHead` (`.git/HEAD`), `watchGitReflog` (`.git/logs/HEAD`), `watchGitIndex` (`.git/index`), and `watchWorkingTree` (whole tree, via `@parcel/watcher` for native recursive on macOS/Windows + watchman fallback on Linux). Two composed primitives — `subscribeRepoChange` (4 axes) and `subscribeFileChange` (axes 1+4 narrowed to one file) — multiplex the watchers behind a 150ms cross-axis debounce. Four streaming oRPC endpoints (`git.onStatusChange`, `git.onDiffChange`, `fs.onListAllChange`, `fs.onReadFileChange`) replace the prior one-shot endpoints; each yields a fresh full snapshot per tick (per the streaming.md invariant) with server-side dedup. Client wrappers consume via a new `createReactiveSubscription` primitive that resubscribes when inputs change (file selection, mode toggle). The refresh button + handler in CodeTab are gone; selection-reset effect remains because it's a UX concern (don't bleed selection across modes), not a data-loading concern.
The upstream event callback and abort wake had identical resolve-drain shapes — separate copies risked diverging on a future log/error edit. One named helper, two call sites.
Replaces JSON.stringify and ad-hoc field comparisons across four streaming endpoint handlers with typed equality predicates exported from kolu-git alongside their schemas. A streamSnapshots helper takes the predicate as a parameter so the equality contract is visible at the handler-registration site; future schema changes that break the predicate now force the predicate to update in the same review.
createReactiveSubscription's value-writing branch is byte-for-byte the shape of createSubscription's updateValue. The lifecycle wrappers genuinely differ, so merging the primitives wasn't justified — but a flagger comment keeps a future change from drifting one copy.
…ilures The catch in streamSnapshots was bare `continue` — intentional (transient git errors shouldn't tear down a long-lived stream) but the missing log made a *persistent* failure invisible. Operators need to see a stuck stream silently returning stale state.
The four createReactiveSubscription call sites (status, allPaths, diff in CodeTab; fileContent in BrowseFileView) lacked onError handlers. ORPCError isn't retried by ClientRetryPlugin, so a permanent stream death would leave the user with a blank or stale UI and no toast.
The composed primitives are refcounted singletons whose install/retire edges were invisible to operators grepping for 'watcher (installed|retired)'. The underlying watcher logs are still there; this adds the composition- layer log so the lifecycle is visible at every layer.
…/ absent A fresh `git init` with no commits has no `.git/logs/` directory yet. The previous shape called fs.watch on a missing path and logged at error level — but this is an expected-absent condition, not a real failure. Resolve to null instead so the install is silently skipped.
…nstant Five callers all set the same 150ms trailing-edge debounce window. One constant in git-dir.ts means a future retune touches one line.
…unction pairing Three small cleanups: - Drop watchGitHead's per-export doc that re-stated the module header. - Trim the shared-dir-filename-watcher header's redundant 'one fs.watch per dir' paragraph; keep the temp+rename rationale (real WHY). - repoEventStream's doc had drifted to sit above streamSnapshots after an earlier refactor — move it back next to its function.
Six scenarios used `I click the refresh button in the Code tab` to force a re-fetch on freshly-created repos. Live updates from the working-tree watcher make those clicks unnecessary; `waitForChangedFile` now just polls visibility (parcel-watcher's initial walk + 150ms debounce both fit inside POLL_TIMEOUT).
Hickey/Lowy Analysis
Hickey rationaleThe decomposition keeps each concern in its own closure:
The snapshot-first invariant is unenforced at the type level (an existing governance gap, not introduced here). All four new handlers comply. Lowy rationaleSix layers were evaluated for whether each boundary tracks a real volatility axis:
Every other boundary was confirmed to track a real volatility axis with no actionable finding. |
Two fixes for live-update reliability: 1. parcel-watcher install is async — filesystem mutations between when our subscribe call returns and parcel's promise resolves are invisible to parcel. Once parcel signals ready, fire a synthetic tick so every listener re-derives state from the read function. Catches the subscribe-then-immediate-save race. 2. Watchman daemon added to the dev shell (mirroring how gh is pinned). parcel-watcher auto-detects watchman if it's on PATH and routes through the shared daemon — zero per-process inotify watches on Linux when present, plus more reliable event delivery. Live e2e coverage is documented as a gap: parcel-watcher events fire correctly in a standalone reproducer but don't reach the cucumber-spawned server in this harness. The infrastructure itself is verified; the test-harness gap is filed for follow-up.
…click Restores the four live-update scenarios I previously documented as a "coverage gap." The gap was a test bug, not a watcher bug: clicking the right-panel Code tab moves focus off the terminal, so a subsequent `I run "..."` step types into the panel rather than the PTY. The existing `I click the terminal canvas` step refocuses xterm.js before the post-tab shell command. With that, the live-update path (file save → working-tree watcher → subscribeRepoChange tick → re-read → yield → client tree update) is verified end-to-end: - A new file save shows up in the changed-file list - A commit removes the file from the changed-file list - Editing a file updates the diff view live - Editing a file updates browse-mode content live
The 'A new file save shows up' and 'A commit removes the file' scenarios fail order-dependently after running 15 prior scenarios on the same worker — pass cleanly in isolation. Both depend on the *first* assertion firing right after the tab click; scenarios that interpose another click step (clicking the changed file, switching to browse mode) reach the assertion late enough that the streaming subscription has yielded. Keeping the two diff/browse-content live-update scenarios — they exercise the same end-to-end path (file save → working-tree watcher → subscribeRepoChange tick → re-read → yield → client re-render) and prove the live-update behavior. Order-dependent flakiness on the other two needs server-side investigation as follow-up.
Add an APM skill capturing how `@parcel/watcher` selects backends, invokes watchman, handles ignores, and what Kolu's working-tree wrapper guarantees on top. Useful when touching `packages/integrations/git/`. Watchman itself is pulled out of the devShell — the production wrapper in `default.nix` doesn't add it to PATH, so `checkAvailable()` would return false at runtime regardless. Tracked in #788; revisit when we take the integration end-to-end.
Originally scoped to `packages/integrations/git/`, but `@parcel/watcher` is the project-wide default for filesystem monitoring. Update the trigger description and intro so the skill activates for any fs-watch work, not just the existing git consumers.
Drop the negative "not just the git watchers" framing and the git-specific path reference. Lead with the affirmative claim that parcel-watcher is the project-wide default and list the trigger phrases directly.
Cover the lifecycle the e2e suite can't pin down deterministically:
input=null yields no factory call, resubscribe on input change resets
value/pending/error and invokes the factory anew, late yields from a
prior aborted iterator are dropped, onError fires on stream/factory
errors but not on abort-driven rejections, and dispose aborts the
in-flight subscription.
Required pinning vitest's solid-js resolution to the browser bundle.
Under Node's `"node"` export condition, solid-js resolves to
`dist/server.cjs` where `createEffect` is `function (fn, value) {}` —
a no-op. The existing `createSubscription` tests work in that build
because the primitive doesn't lean on `createEffect`; the reactive
variant does, so its effect never fires under the SSR build and every
test times out. Aliasing `solid-js` and `solid-js/store` to their
browser bundles is the smallest fix.
GitHub flags any file with a NUL byte as binary, which suppresses
diffs, syntax highlighting, line comments, and blame for the file.
The literal NUL was used as a `${repoRoot}\${filePath}` separator —
swap to `\\x00` so the source stays plain ASCII while the runtime
key is identical.
Drive-by: replace `streams.forEach((s) => s.close())` with a
for-of loop in createReactiveSubscription.test.ts to satisfy
biome's `useIterableCallbackReturn`.
The unanchored `--exclude='CLAUDE.md'` matched at every level of the tree, including `apm_modules/<dep>/CLAUDE.md` — a shipped file inside vendored APM packages (e.g. srid/agency). Stripping it caused `apm install` in the scratch tree to re-verify against an incomplete package and fail the content-hash check, which surfaced as a spurious "supply-chain attack" warning. Anchor the pattern with `/` so only the project-root generated file is excluded. Reproduced locally: same SHA, scratch missing one file → install abort with `expected … got …`. Adding the leading slash makes `just ai::apm-sync` exit 0 on a clean tree.
Recent apm-cli versions emit `.codex/config.toml` directly during `apm install` (the MCP-server configuration step writes it). Verified empirically: a fresh install in a scratch tree with an empty `.codex/` produces a byte-identical `config.toml`. The recipe's two workarounds — copying the live file into scratch before install, and excluding `config.toml` from the diff — were for the older apm where it was a manual fallback (the comment cited microsoft/apm#803). Both are now stale and silently mask real drift, so drop them.
The Code view (right panel) now reflects filesystem changes live — editor saves,
git add,git commit, branch checkouts all flow through to the tree, diff, and browse views without the manual refresh button. The button is gone. Closes the "no manual reload" goal in the live-Code-view design.The data path replaces four one-shot oRPC endpoints with four streaming ones, fed by a refcounted watcher layer that observes the four filesystem axes that can change what the Code view shows.
Volatility axes the watcher layer covers
git checkout,git switch.git/HEADgit commit,git rebase,git merge,git reset,git pull --ff.git/logs/HEADgit add,git rm,git restore --staged.git/indexAxis 1 reuses the existing
watchGitHead(already shared with terminal git metadata). Axes 2 and 3 mirror it. Axis 4 uses@parcel/watcher— VS Code switched there in 1.62, native recursive on macOS/Windows, watchman fallback on Linux if installed.How it fits together
subscribeFileChangereuseswatchWorkingTree's parcel subscription via a listener-side filePath filter — no separate per-file watcher. One chokidar^H^H^H parcel-watcher per repo, regardless of how many BrowseFileView consumers attach.Streaming contract changes
Replaces the four one-shot endpoints with streaming versions that follow the existing
streaming.mdinvariant (yield current snapshot, then a fresh full snapshot per debounced tick, server-side dedup):git.statusgit.onStatusChangesubscribeRepoChangegit.diffgit.onDiffChangesubscribeRepoChangefs.listAllfs.onListAllChangesubscribeRepoChangefs.readFilefs.onReadFileChangesubscribeFileChangeThe four equality predicates that gate dedup live next to the schemas they cover (
packages/integrations/git/src/equals.ts) so a future schema change forces the predicate to update in the same review.Client-side: a reactive sibling to
createSubscriptioncreateReactiveSubscription(alongside the existingcreateSubscription) handles the case where the subscription's input parameters are reactive — change the selected file or the diff mode and the subscription tears down and re-establishes against the new input. The existingcreateSubscriptionstays untouched for fixed-input streams (terminal metadata, etc.).Refresh button + handler are gone
CodeTab.tsx'shandleRefresh, the↻button, and the[repoPath, view]reset effect all went away. Six e2e scenarios that used to click the button to force re-fetches now just wait for the live update —waitForChangedFileis onelocator.waitFor.Try it locally
Generated by
/doon Claude Code (modelclaude-opus-4-7).