This commit is contained in:
@@ -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.
|
||||
|
||||
162
package-lock.json
generated
162
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<void> => 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<GitResult> {
|
||||
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<string> {
|
||||
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<string> {
|
||||
/** 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<string> {
|
||||
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<string> {
|
||||
|
||||
export async function isRepo(root: string): Promise<boolean> {
|
||||
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<GitLoad | null> {
|
||||
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<string, Promise<GitLoad | null>>()
|
||||
const loadPending = new Map<string, Promise<GitLoad | null>>()
|
||||
|
||||
export function load(root: string): Promise<GitLoad | null> {
|
||||
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<GitLoad | null> {
|
||||
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<GitLoad | null> {
|
||||
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<GitLoad | null> {
|
||||
|
||||
export async function stage(root: string, paths: string[]): Promise<void> {
|
||||
// `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<void> {
|
||||
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<void> {
|
||||
await git(root).commit(message)
|
||||
await git(root, ['commit', '-m', message])
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -109,13 +229,12 @@ export async function commit(root: string, message: string): Promise<void> {
|
||||
* - not in HEAD → a new file (staged or untracked): unstage + delete from disk
|
||||
*/
|
||||
export async function discard(root: string, paths: string[]): Promise<void> {
|
||||
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 })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,9 +79,15 @@ export function ProjectProvider({ children }: { children: React.ReactNode }): Re
|
||||
const [data, setData] = useState<ProjectData>(emptyData)
|
||||
const dataRef = useRef(data)
|
||||
dataRef.current = data
|
||||
const loadSeq = useRef(0)
|
||||
|
||||
async function loadReal(): Promise<void> {
|
||||
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<string, Diff> = {}
|
||||
|
||||
Reference in New Issue
Block a user