diff --git a/src/main/git-service.ts b/src/main/git-service.ts index 23d40db..705e474 100644 --- a/src/main/git-service.ts +++ b/src/main/git-service.ts @@ -5,6 +5,22 @@ import { spawn } from 'node:child_process' const delay = (ms: number): Promise => new Promise((r) => setTimeout(r, ms)) +/** Map `fn` over `items` with at most `limit` running at once, preserving order. + * Keeps the per-file git/disk reads overlapping without spawning hundreds of + * subprocesses at once (macOS has a low default open-file limit). */ +async function mapLimit(items: T[], limit: number, fn: (item: T, index: number) => Promise): Promise { + const out = new Array(items.length) + let next = 0 + const worker = async (): Promise => { + while (next < items.length) { + const i = next++ + out[i] = await fn(items[i], i) + } + } + await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker)) + return out +} + export type GitStatusLetter = 'A' | 'M' | 'D' | 'R' | 'U' export interface GitChange { @@ -193,15 +209,19 @@ async function doLoad(root: string): Promise { if (raw == null) return null const { branch, files } = parseStatus(raw) - const changes: GitChange[] = [] - for (const f of files) { + // Each changed file needs its HEAD blob (a `git show` spawn) + its disk text. + // Done serially this is O(files) subprocess spawns in a row — staging one file + // re-reads ALL of them, which is the dominant cost of a reload. Run them with + // bounded concurrency instead so the spawns overlap (cap keeps us well under + // macOS's low default FD limit). Order is preserved by index. + const changes = await mapLimit(files, 12, async (f) => { const { letter, staged } = classify(f.index, f.working) const isNew = f.index === '?' || f.index === 'A' const isDeleted = letter === 'D' 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 { path: f.path, status: letter, staged, original, updated } as GitChange + }) return { branch, changes } } diff --git a/src/main/pty-service.ts b/src/main/pty-service.ts index 13b5fce..7d51e01 100644 --- a/src/main/pty-service.ts +++ b/src/main/pty-service.ts @@ -30,11 +30,21 @@ let seq = 0 * `npm_config_prefix` (e.g. "/opt/homebrew") into the child shell, which makes * nvm refuse to load ("nvm is not compatible with the npm_config_prefix * environment variable"). Strip it so the user's shell init runs cleanly. + * + * A macOS app launched from Spotlight/Finder (launchd GUI context) inherits NO + * `LANG`/`LC_*`, so the child shell falls back to the `C`/POSIX locale — not + * UTF-8. Anything multibyte the shell or `claude` emits then renders as high-byte + * mojibake in xterm. Launching from a terminal (`npm run dev`) inherits the + * terminal's UTF-8 locale, which is why dev looks fine and the packaged app does + * not. Default a UTF-8 locale when none is set so both paths match. */ function ptyEnv(): { [key: string]: string } { const env = { ...process.env } as { [key: string]: string } delete env.npm_config_prefix delete env.npm_config_globalconfig + if (process.platform !== 'win32' && !env.LC_ALL && !env.LC_CTYPE && !env.LANG) { + env.LANG = 'en_US.UTF-8' + } return env }