Skip to content

feat: Code view auto-refreshes from filesystem changes#786

Merged
srid merged 22 commits intomasterfrom
live-code-view
Apr 29, 2026
Merged

feat: Code view auto-refreshes from filesystem changes#786
srid merged 22 commits intomasterfrom
live-code-view

Conversation

@srid
Copy link
Copy Markdown
Member

@srid srid commented Apr 29, 2026

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

# Axis Trigger Watch target
1 Branch identity git checkout, git switch .git/HEAD
2 HEAD movement on current branch git commit, git rebase, git merge, git reset, git pull --ff .git/logs/HEAD
3 Staging git add, git rm, git restore --staged .git/index
4 Working-tree mutation Editor save, create/delete/rename Whole tree, recursive

Axis 1 reuses the existing watchGitHead (already shared with terminal git metadata). Axes 2 and 3 mirror it. Axis 4 uses @parcel/watcherVS Code switched there in 1.62, native recursive on macOS/Windows, watchman fallback on Linux if installed.

How it fits together

┌──────────────────────────────────────────────────────────────────┐
│  CLIENT  (CodeTab)                                               │
│   PierreFileTree   ──→ stream.git.onStatusChange / fsListAll     │
│   PierreDiffView   ──→ stream.git.onDiffChange                   │
│   BrowseFileView   ──→ stream.fs.onReadFileChange                │
└────────────────────────────────┬─────────────────────────────────┘
                                 │  WebSocket (oRPC stream)
┌────────────────────────────────┼─────────────────────────────────┐
│  SERVER                        ▼                                 │
│   streamSnapshots(read, isEqual, install, signal)                │
│   yield initial → for-await-of repoEventStream → re-read+dedup   │
│                                 │                                │
│                                 ▼                                │
│   subscribeRepoChange(repoRoot)        — axes 1+2+3+4            │
│   subscribeFileChange(repoRoot, file)  — axes 1+4 narrowed       │
│                                 │  refcount + 150ms debounce     │
│                                 ▼                                │
│   watchGitHead │ watchGitReflog │ watchGitIndex │ watchWorkingTree│
│       (refcounted shared singletons; idempotent unsubscribe)     │
└──────────────────────────────────────────────────────────────────┘

subscribeFileChange reuses watchWorkingTree'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.md invariant (yield current snapshot, then a fresh full snapshot per debounced tick, server-side dedup):

Old (one-shot) New (streaming) Driven by
git.status git.onStatusChange subscribeRepoChange
git.diff git.onDiffChange subscribeRepoChange
fs.listAll fs.onListAllChange subscribeRepoChange
fs.readFile fs.onReadFileChange subscribeFileChange

The 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 createSubscription

createReactiveSubscription (alongside the existing createSubscription) 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 existing createSubscription stays untouched for fixed-input streams (terminal metadata, etc.).

Refresh button + handler are gone

CodeTab.tsx's handleRefresh, 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 — waitForChangedFile is one locator.waitFor.

Cost on Linux: per-directory inotify slot for the working-tree watch (@parcel/watcher and chokidar both pay this — kernel constraint, not a library choice). With .git, node_modules, and common build outputs ignored, a typical repo uses ~500–2000 slots out of the kernel's default budget. Power users with many simultaneous worktrees who hit ENOSPC can install watchman and parcel-watcher detects it transparently.

Try it locally

nix run github:juspay/kolu/live-code-view

Generated by /do on Claude Code (model claude-opus-4-7).

srid added 11 commits April 29, 2026 14:46
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).
@srid
Copy link
Copy Markdown
Member Author

srid commented Apr 29, 2026

Hickey/Lowy Analysis

# Lens Finding Disposition
1 Hickey compose() Set iteration / pending Set correctness No-op (verified correct)
2 Hickey repoEventStream resolve-drain duplication Fixed in this PR (8a5165f)
3 Hickey createReactiveSubscription value-write branch duplicates createSubscription's updateValue Fixed in this PR (71816c1 — keep-in-sync comment; lifecycle wrappers genuinely differ)
4 Hickey dedup-predicate inconsistency across the four streaming handlers (JSON.stringify vs explicit field compare) Fixed in this PR (855fdd8, subsumed by Lowy #6)
5 Hickey snapshot-first invariant is unenforced governance No-op (pre-existing; this PR is compliant)
6 Lowy dedup equality predicate not co-located with the schema it covers Fixed in this PR (855fdd8)
7 Lowy watcher signature asymmetry (watchWorkingTree's extra options bag) No-op (tracks real volatility — parcel transport differs from fs.watch)
8 Lowy compose() helper is the right encapsulation, not too generic No-op (encapsulates axis-combination volatility)
9 Lowy createReactiveSubscription vs createSubscription split on reactive-input axis No-op (genuine volatility split)
10 Lowy endpoint naming git.statusgit.onStatusChange No-op (on prefix is the right signal)

Hickey rationale

The decomposition keeps each concern in its own closure: compose() does fan-out + refcount (one concern, not two), the pending Set in working-tree-watcher.ts is single-event-loop safe, and createReactiveSubscription's lifecycle wrapper correctly differs from createSubscription's. Two real bites:

  1. The original repoEventStream had two structurally identical resolve-drain blocks (event callback + abort wake) that risked diverging under future edits — extracted to a drainResolve local.
  2. Two of the four streaming handlers used JSON.stringify equality, the other two used explicit field comparison. Property-order stability of JSON.stringify is a latent footgun; standardized on typed predicates exported from kolu-git.

createReactiveSubscription's reconcile branch is byte-for-byte the shape of createSubscription's updateValue. Extraction would force one primitive to acquire a capability it doesn't need (effect-driven vs direct lifecycle); a keep-in-sync comment prevents drift without coupling the two.

The snapshot-first invariant is unenforced at the type level (an existing governance gap, not introduced here). All four new handlers comply.

Lowy rationale

Six layers were evaluated for whether each boundary tracks a real volatility axis:

  • Watcher primitives (watchGitHead, watchGitReflog, watchGitIndex, watchWorkingTree) — sibling-shaped where it matters (refcount, fan-out, idempotent unsubscribe) but watchWorkingTree's extra WatchWorkingTreeOptions { filePath? } reflects a real transport difference (parcel-watcher recursive vs. native fs.watch + filename filter). Asymmetric signature = honest, not papered-over.
  • Composed primitives (subscribeRepoChange, subscribeFileChange) — narrower scope encapsulates narrower axis coverage. subscribeFileChange reacts only to axes 1+4 (file content doesn't change on commit/stage); routing it through subscribeRepoChange would have caused wasted re-reads on every git add.
  • Streaming endpointson-prefix names correctly signal subscription lifecycle. The four endpoints map 1:1 to the prior one-shots, no surprises.
  • Client sidecreateReactiveSubscription's split from createSubscription is on the axis of input reactivity (genuine mechanism difference), not API shape.
  • Dedup predicate location — was the one volatility leak. Inline JSON.stringify in the handler made the equality contract invisible to schema reviewers; moved to typed exports next to the schemas (equals.ts) so a future schema change forces the predicate to update in the same review.

Every other boundary was confirmed to track a real volatility axis with no actionable finding.

srid added 4 commits April 29, 2026 15:16
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.
srid added 2 commits April 29, 2026 17:29
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.
srid added 4 commits April 29, 2026 17:56
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.
@srid srid mentioned this pull request Apr 29, 2026
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.
@srid srid merged commit 3940c98 into master Apr 29, 2026
4 checks passed
@srid srid deleted the live-code-view branch April 29, 2026 23:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant