From 4b4676a673b40bdda22f25838839f98328e1e3e8 Mon Sep 17 00:00:00 2001 From: Jonathan van Rij Date: Tue, 16 Jun 2026 12:11:26 +0200 Subject: [PATCH] fix git monitor --- CLAUDE.md | 1 + package-lock.json | 162 +++++++++++++++++++++++++++++--- package.json | 2 +- src/main/git-service.ts | 177 +++++++++++++++++++++++++++++------ src/renderer/src/project.tsx | 7 ++ 5 files changed, 304 insertions(+), 45 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 4be162b..15499a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,7 @@ A dark-only (no light mode, no theme toggle) Electron desktop code workbench for - **Renderer never touches the filesystem, git, or PTYs directly.** All FS (`fs` + `chokidar`), git (`git` / `simple-git`), search (`rg` + fuzzy), terminals (`node-pty` + `xterm.js`), and clipboard go through the main process via IPC / the preload bridge. The prototype keeps all state in the top `App` component; in the real app, lift FS/git/terminal state into main and stream over IPC. - **Prism PHP load order:** `prism-php` requires `prism-markup-templating` to be loaded **first**, or every `Prism.highlight` call throws and silently falls back to plain text. - **Preload must be CommonJS `index.cjs`** and `main` must load `../preload/index.cjs` (see `electron.vite.config.ts` preload `rollupOptions.output`). If they mismatch (or you let it build as `.mjs`), Electron silently loads no preload, `window.helder` is undefined, and the renderer silently falls back to mock data + the "terminal not available" message. Keep the filename and the `main` path in sync. +- **chokidar is pinned to v3 on purpose — do NOT bump to v4/v5.** chokidar ≥4 dropped the `fsevents` addon and watches recursively via libuv's native `fs.watch({recursive:true})`. On macOS that recursive watcher poisons the process's file descriptors, so every later `child_process.spawn` (i.e. every `git` call) fails with `spawn EBADF` (errno -9) and the git column silently stops updating. v3 uses the `fsevents` native addon instead and has no such conflict. If you must move to v4+, switch the main project watcher to `usePolling: true` (the only other config proven to avoid the EBADF here). - **Pass on to Agent uses bracketed paste.** Write inserts to the agent PTY wrapped in `\x1b[200~ … \x1b[201~` so the `claude` CLI treats it as *pasted, unsubmitted* input. Insert must never submit — it lands as a new line so the user can stack several references before sending. - **The four diff view modes (Original / Updated / Diff / Split) all derive from one original-text + updated-text pair per changed file.** The prototype computes this with an LCS line diff (`buildDiff()` in `design/src/data.js`); production should prefer real `git diff` output but keep the same four derived views and the same color language everywhere: **red = removed/changed-from, green = added/changed-to**, syntax highlighting on in all modes. - **The agent pane is just a terminal running the `claude` CLI** (`ai.command`, default `claude`, auto-launched when `ai.autoLaunch` is on). The bottom pane is a normal shell PTY. The prototype's simulated agent session (`agentSeed`, `runAgent`, `bootAgent` in `terminals.jsx`) exists only to show the visual style — keep the styling, drop the fakery. diff --git a/package-lock.json b/package-lock.json index 6035c36..f0d4131 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,7 @@ "@vscode/ripgrep": "^1.18.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", - "chokidar": "^5.0.0", + "chokidar": "^3.6.0", "node-pty": "^1.1.0", "prismjs": "^1.29.0", "simple-git": "^3.36.0" @@ -3387,6 +3387,31 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/anymatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/app-builder-lib": { "version": "26.15.3", "resolved": "https://registry.npmjs.org/app-builder-lib/-/app-builder-lib-26.15.3.tgz", @@ -3721,6 +3746,18 @@ "require-from-string": "^2.0.2" } }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bluebird": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", @@ -3750,6 +3787,18 @@ "node": "18 || 20 || >=22" } }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/browserslist": { "version": "4.28.2", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", @@ -3991,18 +4040,39 @@ } }, "node_modules/chokidar": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", - "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", "license": "MIT", "dependencies": { - "readdirp": "^5.0.0" + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" }, "engines": { - "node": ">= 20.19.0" + "node": ">= 8.10.0" }, "funding": { "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" } }, "node_modules/chownr": { @@ -5288,6 +5358,18 @@ "node": ">=10" } }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -5369,7 +5451,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -5858,11 +5939,22 @@ "dev": true, "license": "ISC" }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -5882,7 +5974,6 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", - "dev": true, "license": "MIT", "dependencies": { "is-extglob": "^2.1.1" @@ -5891,6 +5982,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, "node_modules/is-potential-custom-element-name": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", @@ -6811,6 +6911,15 @@ "node": "^20.17.0 || >=22.9.0" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/normalize-url": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-6.1.0.tgz", @@ -7330,16 +7439,27 @@ } }, "node_modules/readdirp": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", - "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/readdirp/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "license": "MIT", "engines": { - "node": ">= 20.19.0" + "node": ">=8.6" }, "funding": { - "type": "individual", - "url": "https://paulmillr.com/funding/" + "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/require-directory": { @@ -8024,6 +8144,18 @@ "tmp": "^0.2.0" } }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, "node_modules/tough-cookie": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", diff --git a/package.json b/package.json index 56d660e..0061743 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "@vscode/ripgrep": "^1.18.0", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", - "chokidar": "^5.0.0", + "chokidar": "^3.6.0", "node-pty": "^1.1.0", "prismjs": "^1.29.0", "simple-git": "^3.36.0" diff --git a/src/main/git-service.ts b/src/main/git-service.ts index 1888a99..23d40db 100644 --- a/src/main/git-service.ts +++ b/src/main/git-service.ts @@ -1,6 +1,9 @@ import { readFile, rm } from 'node:fs/promises' +import { existsSync } from 'node:fs' import { join } from 'node:path' -import { simpleGit, type SimpleGit } from 'simple-git' +import { spawn } from 'node:child_process' + +const delay = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) export type GitStatusLetter = 'A' | 'M' | 'D' | 'R' | 'U' @@ -17,8 +20,44 @@ export interface GitLoad { changes: GitChange[] } -function git(root: string): SimpleGit { - return simpleGit({ baseDir: root, maxConcurrentProcesses: 4 }) +interface GitResult { + code: number + stdout: string + stderr: string +} + +/** + * Run a git subcommand directly via child_process. + * + * We deliberately spawn `git` ourselves instead of going through simple-git: + * inside Electron's main process simple-git's spawn (which wires up a stdin + * pipe) trips `spawn EBADF` on macOS. Forcing stdin to 'ignore' — the child + * never reads input — sidesteps the bad-descriptor crash entirely. + */ +function runGit(root: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const child = spawn('git', args, { + cwd: root, + stdio: ['ignore', 'pipe', 'pipe'], + env: process.env, + windowsHide: true, + }) + let stdout = '' + let stderr = '' + child.stdout.setEncoding('utf8') + child.stderr.setEncoding('utf8') + child.stdout.on('data', (d: string) => { stdout += d }) + child.stderr.on('data', (d: string) => { stderr += d }) + child.on('error', reject) + child.on('close', (code) => resolve({ code: code ?? -1, stdout, stderr })) + }) +} + +/** Run git, rejecting on a non-zero exit. */ +async function git(root: string, args: string[]): Promise { + const { code, stdout, stderr } = await runGit(root, args) + if (code !== 0) throw new Error(`git ${args.join(' ')} failed (${code}): ${stderr.trim()}`) + return stdout } /** Map a porcelain code pair to our display letter + staged flag. */ @@ -36,9 +75,45 @@ export function classify(index: string, working: string): { letter: GitStatusLet return { letter, staged } } -async function headText(g: SimpleGit, path: string): Promise { +/** Parse a `## ...` porcelain branch header into a display branch name. */ +function parseBranch(header: string): string { + // e.g. "main...origin/main [ahead 1]", "main", "No commits yet on main", + // "HEAD (no branch)". + const noCommits = header.match(/^No commits yet on (.+)$/) + if (noCommits) return noCommits[1].trim() + if (header.startsWith('HEAD ')) return 'HEAD' + const upstream = header.indexOf('...') + const head = upstream >= 0 ? header.slice(0, upstream) : header + return head.split(' ')[0].trim() || 'HEAD' +} + +interface StatusEntry { index: string; working: string; path: string } + +interface ParsedStatus { branch: string; files: StatusEntry[] } + +/** Parse `git status --porcelain -b -z` output. NUL-separated, never quoted. */ +export function parseStatus(raw: string): ParsedStatus { + const parts = raw.split('\0') + let branch = 'HEAD' + const files: StatusEntry[] = [] + for (let i = 0; i < parts.length; i++) { + const p = parts[i] + if (!p) continue + if (p.startsWith('## ')) { branch = parseBranch(p.slice(3)); continue } + const index = p[0] + const working = p[1] + const path = p.slice(3) // skip "XY " + // For renames/copies the original path follows as its own NUL field; the + // destination (this entry's path) is what we display, so just skip it. + if (index === 'R' || index === 'C' || working === 'R' || working === 'C') i++ + files.push({ index, working, path }) + } + return { branch, files } +} + +async function headText(root: string, path: string): Promise { try { - return await g.show([`HEAD:${path}`]) + return await git(root, ['show', `HEAD:${path}`]) } catch { return '' } @@ -57,29 +132,75 @@ async function diskText(root: string, path: string): Promise { export async function isRepo(root: string): Promise { try { - return await git(root).checkIsRepo() + const { code, stdout } = await runGit(root, ['rev-parse', '--is-inside-work-tree']) + if (code === 0 && stdout.trim() === 'true') return true } catch { - return false + /* git missing / transient — fall back to disk. */ } + // An external branch switch briefly rewrites .git; trust its presence on + // disk rather than blanking the whole panel on a momentary probe failure. + return existsSync(join(root, '.git')) } -export async function load(root: string): Promise { - const g = git(root) +// Coalesce overlapping loads per root: a branch switch or rapid edits fire +// several watcher pings, each triggering a reload. We keep at most one read in +// flight plus one trailing read (which captures whatever changed during the +// first), so the panel always settles on fresh state without a spawn pile-up. +const loadInFlight = new Map>() +const loadPending = new Map>() + +export function load(root: string): Promise { + const running = loadInFlight.get(root) + if (running) { + let pending = loadPending.get(root) + if (!pending) { + pending = running.catch(() => {}).then(() => { + loadPending.delete(root) + return startLoad(root) + }) + loadPending.set(root, pending) + } + return pending + } + return startLoad(root) +} + +function startLoad(root: string): Promise { + const p = doLoad(root).finally(() => { + if (loadInFlight.get(root) === p) loadInFlight.delete(root) + }) + loadInFlight.set(root, p) + return p +} + +async function doLoad(root: string): Promise { if (!(await isRepo(root))) return null - const status = await g.status() - const branch = status.current || 'HEAD' + // A branch switch / checkout from an external tool (Sublime Merge, the CLI) + // rewrites .git and briefly holds .git/index.lock; a status that lands in + // that window fails. Retry so we settle on the real new state instead of + // rejecting (which would leave the git column stale or blank). + let raw: string | null = null + for (let attempt = 0; attempt < 6; attempt++) { + try { + raw = await git(root, ['status', '--porcelain', '-b', '--untracked-files=all', '-z']) + break + } catch (err) { + if (attempt === 5) throw err + await delay(120) + } + } + if (raw == null) return null + const { branch, files } = parseStatus(raw) const changes: GitChange[] = [] - for (const f of status.files) { - // simple-git uses path "from -> to" for renames; take the destination. - const path = f.path.includes(' -> ') ? f.path.split(' -> ').pop()! : f.path - const { letter, staged } = classify(f.index, f.working_dir) + for (const f of files) { + const { letter, staged } = classify(f.index, f.working) const isNew = f.index === '?' || f.index === 'A' const isDeleted = letter === 'D' - const original = isNew ? '' : await headText(g, path) - const updated = isDeleted ? '' : await diskText(root, path) - changes.push({ path, status: letter, staged, original, updated }) + const original = isNew ? '' : await headText(root, f.path) + const updated = isDeleted ? '' : await diskText(root, f.path) + changes.push({ path: f.path, status: letter, staged, original, updated }) } return { branch, changes } @@ -87,20 +208,19 @@ export async function load(root: string): Promise { export async function stage(root: string, paths: string[]): Promise { // `git add` stages modifications, additions AND deletions of the given paths. - await git(root).add(paths) + await git(root, ['add', '--', ...paths]) } export async function unstage(root: string, paths: string[]): Promise { - try { - await git(root).reset(['--', ...paths]) - } catch { + const { code } = await runGit(root, ['reset', '--', ...paths]) + if (code !== 0) { // empty repo (no HEAD yet): fall back to removing from the index. - await git(root).raw(['rm', '--cached', '-r', '--', ...paths]) + await git(root, ['rm', '--cached', '-r', '--', ...paths]) } } export async function commit(root: string, message: string): Promise { - await git(root).commit(message) + await git(root, ['commit', '-m', message]) } /** @@ -109,13 +229,12 @@ export async function commit(root: string, message: string): Promise { * - not in HEAD → a new file (staged or untracked): unstage + delete from disk */ export async function discard(root: string, paths: string[]): Promise { - const g = git(root) for (const p of paths) { - const inHead = await g.raw(['cat-file', '-e', `HEAD:${p}`]).then(() => true).catch(() => false) - if (inHead) { - await g.checkout(['HEAD', '--', p]) + const { code } = await runGit(root, ['cat-file', '-e', `HEAD:${p}`]) + if (code === 0) { + await git(root, ['checkout', 'HEAD', '--', p]) } else { - try { await g.raw(['reset', '-q', 'HEAD', '--', p]) } catch { /* no HEAD / not staged */ } + await runGit(root, ['reset', '-q', 'HEAD', '--', p]) // no HEAD / not staged — ignore result await rm(join(root, p), { force: true }) } } diff --git a/src/renderer/src/project.tsx b/src/renderer/src/project.tsx index 8c01508..2dca158 100644 --- a/src/renderer/src/project.tsx +++ b/src/renderer/src/project.tsx @@ -79,9 +79,15 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re const [data, setData] = useState(emptyData) const dataRef = useRef(data) dataRef.current = data + const loadSeq = useRef(0) async function loadReal(): Promise { if (!bridge) return + // A branch switch fires several watcher pings in quick succession, so + // multiple loadReal() calls overlap. Tag each one and only let the newest + // apply its result — otherwise a slow/transient mid-checkout read can + // resolve last and clobber the correct settled state. + const seq = ++loadSeq.current const [cur, tree, files, git, config, theme] = await Promise.all([ bridge.project.current(), bridge.fs.tree(), @@ -90,6 +96,7 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re bridge.config.get(), bridge.config.theme(), ]) + if (seq !== loadSeq.current) return applyTheme(theme) const changes: Change[] = [] const diffs: Record = {}