diff --git a/config/reliability-gates.jsonc b/config/reliability-gates.jsonc index 83de3128ecf..c36412c0383 100644 --- a/config/reliability-gates.jsonc +++ b/config/reliability-gates.jsonc @@ -13863,6 +13863,90 @@ "knownGaps": ["No manifest command yet.", "No Windows CJK/emoji repaint command is wired."], "demotionRule": "Cannot promote if the oracle is screenshot-only or environment-skipped." }, + { + "id": "terminal-render.foreground-repair-span", + "title": "A forced foreground repaint covers every row the write changed", + "maturity": "experimental", + "protection": "partial", + "owner": "terminal-rendering", + "layer": "renderer-unit", + "surfaces": [ + "foreground PTY output", + "in-place agent redraws", + "erase-in-line/display", + "alternate screen", + "scroll", + "wide glyphs" + ], + "platforms": ["macos", "linux", "windows"], + "providers": ["local", "daemon", "ssh", "remote-runtime"], + "coveredPlatforms": ["macos"], + "coveredProviders": [], + "coverageNotes": "Renderer-unit convergence corpus over a real xterm parser, plus manual CDP pixel evidence on the macOS WebGL renderer. The repaint span is provider-independent because it is computed from xterm's parse, not from the transport; SSH/WSL/remote were not exercised live.", + "motivatingLinks": [ + "https://github.com/stablyai/orca/pull/2669", + "https://github.com/stablyai/orca/pull/4669", + "https://github.com/stablyai/orca/pull/8178" + ], + "invariant": "The row span Orca asks xterm to repaint after a forced foreground refresh must cover every viewport row whose rendered content changed during that write, plus the cursor row before and after it; when the span cannot be established — unobservable parse, viewport scroll, or a normal/alternate buffer flip — the whole viewport must be repainted.", + "oracle": "A real @xterm/headless parser replays an adversarial corpus (in-place bottom-row redraws, standalone CR overwrite, backspace, erase-in-line, erase-in-display above and below the cursor, full clear, wide CJK, emoji, combining marks, ZWJ sequences, scroll-region insert/delete, reverse index, DEC 2026 frames, alternate-screen enter and exit, viewport scroll, narrow panes). Each viewport row is serialized cell-by-cell with its attributes before and after the write, and every row that differs must fall inside the span the settle path requested. A vacuity guard asserts each case actually moves the screen.", + "commands": [ + "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts" + ], + "testFiles": [ + "src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts" + ], + "assertionRefs": [ + { + "file": "src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts", + "assertions": [ + "every viewport row whose serialized cells changed lies inside the requested repaint span", + "the cursor row before and after the write is inside the requested repaint span", + "viewport scroll and alternate-screen transitions still request the whole grid", + "an unobservable parse span falls back to the whole grid", + "an in-place bottom-row redraw narrows well below the full grid" + ] + } + ], + "evidenceRuns": [ + { + "date": "2026-09-02", + "runner": "local", + "platform": "macos", + "result": "passed", + "command": "pnpm exec vitest run --config config/vitest.config.ts src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts", + "durationSeconds": 1, + "summary": "25 cases passed against a real xterm parser; paired CDP run on a 4-pane macOS WebGL dev build produced screenshots byte-identical to a forced full model rebuild." + } + ], + "runtimeBudget": { + "p95Seconds": 15, + "scope": "Renderer-unit convergence corpus" + }, + "flakeHistory": { + "status": "not-started", + "evidence": "New deterministic gate; no soak history yet." + }, + "redGreenEvidence": { + "status": "complete", + "evidence": "Narrowing the span to the cursor rows alone (dropping xterm's parse span) fails the claude-style in-place redraw and erase-in-display-above cases; reading buffer indices instead of viewport rows made the corpus vacuous and is now blocked by the changed-row guard." + }, + "performanceBudget": { + "required": true, + "evidence": "Measured on a focused, visible 4-pane macOS dev build under an agent-style in-place redraw load: rendered cells/s 157,708 -> 30,139 and forEachDecorationAtCell 320,868/s -> 60,652/s with render frames/s unchanged (59.8 -> 60.5)." + }, + "promotionCriteria": [ + "Add Windows DOM-renderer coverage for the synchronous repair branch.", + "Wire pixel or cell evidence for the alternate-screen and reflow paths into CI rather than manual CDP runs.", + "Keep a full-grid fallback assertion for every new span-narrowing condition." + ], + "knownGaps": [ + "No CI-wired pixel oracle; WebGL convergence evidence was collected manually over CDP.", + "Windows ConPTY synchronous repair path is covered only by the shared corpus, not on a Windows runner.", + "SSH/WSL/remote providers were not exercised live; the span is transport-independent by construction." + ], + "demotionRule": "Demote or block if a narrowing condition is added without a matching convergence case, if the corpus stops asserting that each case changes at least one row, or if a repaint regression is reported for in-place agent redraws." + }, { "id": "terminal-shell.windows-resolution-parity", "title": "Windows local and daemon providers resolve shells and startup commands consistently", diff --git a/docs/reference/windows-edr-posture.md b/docs/reference/windows-edr-posture.md index d44aaf33e38..06eb2d5ff9b 100644 --- a/docs/reference/windows-edr-posture.md +++ b/docs/reference/windows-edr-posture.md @@ -72,12 +72,20 @@ behavioural engine can be expected to score it low. ### Every process gets a handle, on a timer -`src/main/windows/windows-process-table.ts` takes a Toolhelp32 snapshot under one -of two flag sets. Identity (`None | CreationTime`) answers pid/ppid/name from the -snapshot alone and opens nothing; the detailed set adds `CommandLine`, which -costs one `OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION)` per process. `Memory` -is retired — it took a second handle carrying `PROCESS_VM_READ` and never read -through it. +`src/main/windows/windows-process-table.ts` takes a Toolhelp32 snapshot under +**one** flag set, `CommandLine | CreationTime`, shared by every caller. pid, ppid +and name come out of the snapshot itself and open nothing. `CommandLine` is what +opens a handle: the addon calls `GetProcessCommandLine` per process, which opens +`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` and walks the PEB with three +`ReadProcessMemory` calls (`src/process_commandline.cc:32,41-47` in the vendored +`@vscode/windows-process-tree` 0.8.0 source that `config/patches/` patches). + +`Memory` is retired as of this change, and that is a real reduction: it made +`GetProcessMemoryUsage` open a **second** `PROCESS_QUERY_INFORMATION | +PROCESS_VM_READ` handle per process for a `GetProcessMemoryInfo` call whose +result no caller read (`src/process.cc:47-63`). Dropping it halves the handles +opened per snapshot. It does not remove the remote memory read, because the +command line still performs one. It exists because seven independent readers used to fork `powershell.exe` for a `Get-CimInstance Win32_Process` scan. That cost, measured: a PowerShell @@ -90,23 +98,41 @@ panes multiplied it (#15036). The native snapshot answers the same question in See [`windows-process-enumeration.md`](./windows-process-enumeration.md). -Asking for fewer fields is cheaper, and since the split the module does: one -cache per flag set, so teardown identity and the session owner probe open no -handle at all (6.3 ms p50) while only the callers that read a command line pay -for one (12.3 ms p50, at 492 processes). Each cache still single-flights within -itself, and one gate serializes the native reads because the vendored wrapper -coalesces the flags of two overlapping calls. +Asking for fewer fields is cheaper, and the module now asks for the smallest set +that still answers every caller. There is **no** per-flag-set cache split: one +TTL-cached snapshot serves everyone, deliberately, because a split would restore +the per-pane fan-out the cache exists to remove — a 32-wide teardown has to +collapse into one scan. So the cheap identity-only read is not something any +caller can select; every read pays for `CommandLine`. An earlier revision of this +file described a two-cache design with 6.3 ms / 12.3 ms p50 figures at 492 +processes. That design is not in the tree and those numbers describe no code +path here; the figures that do apply are the module's own, in +[`windows-process-enumeration.md`](./windows-process-enumeration.md). **How an EDR reads it:** a cross-process handle plus a remote memory read against every process on the box, repeating on a cadence, is the read half of the telemetry that credential dumping and process injection produce. MDE surfaced it -as "suspicious memory activity". The memory read is gone: the command line now -comes from the kernel, through `NtQueryInformationProcess`'s -`ProcessCommandLineInformation` class, which needs only -`PROCESS_QUERY_LIMITED_INFORMATION`. `ReadProcessMemory` is absent from the -compiled addon, asserted against the binary's import table because the published -prebuild loads fine and emits byte-identical strings. What is left to declare to -administrators is the per-process handle itself. +as "suspicious memory activity". + +**That signal is still present.** An earlier revision of this file claimed the +command line "now comes from the kernel" through `NtQueryInformationProcess`'s +`ProcessCommandLineInformation` class, needing only +`PROCESS_QUERY_LIMITED_INFORMATION`, and that `ReadProcessMemory` was absent from +the compiled addon. None of that is true of the code we ship. +`process_commandline.cc` calls `NtQueryInformationProcess` with +`ProcessBasicInformation` only — to locate the PEB — and then issues three +`ReadProcessMemory` calls against a `PROCESS_VM_READ` handle to read the PEB, the +`RTL_USER_PROCESS_PARAMETERS`, and the command-line buffer. Nothing asserts an +import table, and no such assertion would pass. + +What this change did remove is the `Memory` flag's second handle and its +`GetProcessMemoryInfo` call, so the per-process handle count per snapshot halves. +What remains to declare to administrators is unchanged in kind: one +`PROCESS_QUERY_INFORMATION | PROCESS_VM_READ` handle and a PEB read against every +process on the box, at the shared snapshot's cadence. Moving to +`ProcessCommandLineInformation` (Windows 8.1+, `PROCESS_QUERY_LIMITED_INFORMATION` +only) would genuinely retire the remote read, but it is an addon patch nobody has +written; treat it as unclaimed work, not as shipped. ### Encoded, policy-bypassing PowerShell diff --git a/docs/reference/windows-process-enumeration.md b/docs/reference/windows-process-enumeration.md index ef1faf5237c..87ac2a97fb1 100644 --- a/docs/reference/windows-process-enumeration.md +++ b/docs/reference/windows-process-enumeration.md @@ -40,6 +40,15 @@ Measured on Windows 11 with 1050 processes (p50 / p95): | + memory + command line | 30.6 ms | 33.7 ms | | `Get-CimInstance` via PowerShell | 706 ms | 723 ms | +Those are the module's published figures. The flag set this module actually +requests is `CommandLine | CreationTime` — **not** `Memory`, which cost a second +`OpenProcess(PROCESS_QUERY_INFORMATION | PROCESS_VM_READ)` plus +`GetProcessMemoryInfo` per process (`src/process.cc:47-63`) for a value nothing +read. Dropping it halves the handles a snapshot opens. The remaining set sits +between the two rows above and has not been measured separately; on a real +Windows host, `Get-Counter '\Process(Orca)\Handle Count'` sampled across a +snapshot cadence is the check. + Those CIM numbers are from a 1050-process host. The scan scales with process count: on a 1486-process Windows SSH host it measured **1.36 s** and produced **4.8 MiB** of JSON, against the fallback's 3 s and 8 MiB limits. Both limits @@ -220,11 +229,13 @@ ownership, and CPU accounting in the memory collector — still reads it through its own query. Those callers are not migrated. Committed private bytes have no equivalent either, and the one memory value the -snapshot does carry is unusable for the sizes Orca now sees: `process.cc` stores +snapshot _can_ carry is unusable for the sizes Orca now sees: `process.cc` stores `pmc.WorkingSetSize` into a `DWORD`, so anything above 4 GB wraps. That is the second reason `windows-process-resource-collector.ts` still runs its own `Get-CimInstance` sweep — it needs `PageFileUsage` (commit) and the CPU-time -counters in the same pass. Migrating it to the native table would cost both. +counters in the same pass. Migrating it to the native table would cost both, and +it is why this module no longer sets the `Memory` flag at all: the field had no +reader, and asking for it opened a handle per process on every snapshot. Start time is a proxy for identity, not identity. The durable answer for the process trees Orca itself spawns is an inherited handle: a job object names the diff --git a/src/main/durable-file-write.ts b/src/main/durable-file-write.ts index 2f40b0881e8..83b0eabc5ed 100644 --- a/src/main/durable-file-write.ts +++ b/src/main/durable-file-write.ts @@ -187,10 +187,15 @@ export async function removeStaleDurableWriteTempFiles( } /** Synchronous counterpart for quit and crash paths that cannot await. */ -export function writeFileDurableSync(tmpPath: string, finalPath: string, payload: string): void { +export function writeFileDurableSync( + tmpPath: string, + finalPath: string, + payload: string | Uint8Array +): void { let renamed = false try { - writeFileSync(tmpPath, payload, 'utf-8') + // A Uint8Array payload is written verbatim; a string still defaults to UTF-8. + writeFileSync(tmpPath, payload) const fd = openSync(tmpPath, 'r+') try { fsyncSync(fd) diff --git a/src/main/git/command-runner/gh-exec-file-deadline.test.ts b/src/main/git/command-runner/gh-exec-file-deadline.test.ts new file mode 100644 index 00000000000..3775b67a7ed --- /dev/null +++ b/src/main/git/command-runner/gh-exec-file-deadline.test.ts @@ -0,0 +1,94 @@ +import { EventEmitter } from 'node:events' +import type { ChildProcess } from 'node:child_process' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileMock, spawnMock, killSpawnedCommandTreeMock } = vi.hoisted(() => ({ + execFileMock: vi.fn(), + spawnMock: vi.fn(), + killSpawnedCommandTreeMock: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('node:child_process', async (importOriginal) => ({ + ...(await importOriginal()), + execFile: execFileMock, + spawn: spawnMock +})) +vi.mock('./spawned-command-tree-kill', () => ({ + killSpawnedCommandTree: killSpawnedCommandTreeMock +})) + +import { ghExecFileAsync } from './gh-exec-file' + +function mockChild(pid = 4321): ChildProcess { + const child = new EventEmitter() as EventEmitter & Record + child.pid = pid + child.kill = vi.fn(() => true) + child.stdin = Object.assign(new EventEmitter(), { end: vi.fn() }) + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + return child as unknown as ChildProcess +} + +/** + * The contract the star check depends on after #18234: a `gh` that never exits + * is killed at the deadline, tree and all, rather than running forever. + */ +describe('gh exec deadline', () => { + beforeEach(() => { + vi.useFakeTimers() + execFileMock.mockReset() + spawnMock.mockReset() + killSpawnedCommandTreeMock.mockClear() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('kills the process tree and rejects when gh never exits', async () => { + const child = mockChild() + // Why never invoking the callback: this is exactly the stuck child from + // #18234 — spawned, spinning, and never reporting an exit. + execFileMock.mockReturnValue(child) + + const pending = ghExecFileAsync(['api', '--include', 'user/starred/stablyai/orca'], { + timeout: 15_000 + }) + const rejection = expect(pending).rejects.toThrow('timed out') + await vi.waitFor(() => expect(execFileMock).toHaveBeenCalledOnce()) + + // Not yet: the deadline has not elapsed. + expect(killSpawnedCommandTreeMock).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(15_000) + await rejection + + expect(killSpawnedCommandTreeMock).toHaveBeenCalledWith(child) + }) + + it('spawns with hidden console and captured stdio, never an inherited or shell stdio', async () => { + const child = mockChild() + execFileMock.mockImplementation( + ( + _command: string, + _args: string[], + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void + ) => { + callback(null, 'HTTP/2.0 204 No Content\r\n', '') + return child + } + ) + + await ghExecFileAsync(['api', '--include', 'user/starred/stablyai/orca'], { timeout: 15_000 }) + + const [command, args, options] = execFileMock.mock.calls[0] + expect(command).toBe('gh') + expect(args).toEqual(['api', '--include', 'user/starred/stablyai/orca']) + // `execFile` captures stdout/stderr over pipes and never inherits Orca's; + // `shell` is never set, and the console stays hidden on Windows. + expect(options.windowsHide).toBe(true) + expect(options.stdio).toBeUndefined() + expect(options.shell).toBeUndefined() + }) +}) diff --git a/src/main/git/command-runner/gh-spawn-boundary.test.ts b/src/main/git/command-runner/gh-spawn-boundary.test.ts new file mode 100644 index 00000000000..b6d83daaa95 --- /dev/null +++ b/src/main/git/command-runner/gh-spawn-boundary.test.ts @@ -0,0 +1,71 @@ +import { readFileSync, readdirSync, statSync } from 'node:fs' +import { join, relative, resolve } from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * Guard the gh chokepoint the way `child-process-import-boundary` guards spawn. + * + * `ghExecFileAsync` is what gives a gh invocation a deadline, a process-tree + * kill, transient-error retry, the rate-limit breaker, and WSL/host routing. + * Two call sites quietly opted out of all of it by reaching for the legacy + * `execFileAsync('gh', …)`, and one of them left `gh` children spinning at 100% + * CPU forever while permanently exhausting the GitHub concurrency semaphore + * (#18234). Nothing about those call sites looked wrong locally — which is why + * this is a tree-level rule rather than a review habit. + * + * The allowlist is empty and may only stay empty. + */ +const GH_SPAWN_PATTERN = + /(?:execFileAsync|commandExecFileAsync|execFileCapture|runProcess|spawnProcess|execFile|spawnSync|spawn)\s*\(\s*(['"`])gh\1|program:\s*(['"`])gh\2/ + +// Why trailing slash: a sibling like command-runner-extras.ts is scanned, not exempted. +const OWNER_DIRECTORY = 'src/main/git/command-runner/' +const SCANNED_EXTENSIONS = ['.ts', '.tsx'] +const IGNORED_DIRECTORIES = new Set([ + 'node_modules', + 'dist', + 'out', + 'build', + '.git', + '__fixtures__' +]) + +function isTestFile(path: string): boolean { + return /\.(?:test|spec)\.tsx?$/.test(path) || path.includes('/__tests__/') +} + +function collectSourceFiles(root: string): string[] { + let found: string[] = [] + let entries: string[] + try { + entries = readdirSync(root) + } catch { + return found + } + for (const entry of entries) { + if (IGNORED_DIRECTORIES.has(entry)) { + continue + } + const full = join(root, entry) + if (statSync(full).isDirectory()) { + found = found.concat(collectSourceFiles(full)) + continue + } + if (SCANNED_EXTENSIONS.some((extension) => full.endsWith(extension))) { + found.push(full) + } + } + return found +} + +describe('gh spawn boundary', () => { + it('routes every gh invocation through ghExecFileAsync', () => { + const repoRoot = resolve(__dirname, '..', '..', '..', '..') + const offenders = collectSourceFiles(join(repoRoot, 'src')) + .map((path) => relative(repoRoot, path).split('\\').join('/')) + .filter((path) => !isTestFile(path) && !path.startsWith(OWNER_DIRECTORY)) + .filter((path) => GH_SPAWN_PATTERN.test(readFileSync(join(repoRoot, path), 'utf8'))) + + expect(offenders).toEqual([]) + }) +}) diff --git a/src/main/git/exact-ref-probe.ts b/src/main/git/exact-ref-probe.ts index 96bb421b8e8..ce89fac3f69 100644 --- a/src/main/git/exact-ref-probe.ts +++ b/src/main/git/exact-ref-probe.ts @@ -157,7 +157,11 @@ export async function probeAnyExactRefBatched( } catch { return { found: false, unknown: true } } - const lines = stdout.split('\n').filter((line) => line.trim().length > 0) + // Trim per line so a CRLF-translating host's `\r` does not become part of the type. + const lines = stdout + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) // One line per input, in order; a short read means the batch never answered for the rest. if (lines.length !== safeRefs.length) { return { found: false, unknown: true } diff --git a/src/main/git/remote.test.ts b/src/main/git/remote.test.ts index 11ac2c21264..feb237eb18a 100644 --- a/src/main/git/remote.test.ts +++ b/src/main/git/remote.test.ts @@ -193,6 +193,17 @@ describe('git remote operations', () => { if (args[0] === 'remote' && args[1] === 'get-url' && args[2] === 'pr-pynickle-orca') { return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' } } + if (args[0] === 'remote' && args[1] === '-v') { + return { + stdout: [ + 'origin\thttps://github.com/stablyai/orca.git (fetch)', + 'origin\thttps://github.com/stablyai/orca.git (push)', + 'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (fetch)', + 'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (push)' + ].join('\n'), + stderr: '' + } + } if (args[0] === 'remote') { return { stdout: 'origin\npr-pynickle-orca\n', stderr: '' } } @@ -207,6 +218,54 @@ describe('git remote operations', () => { ) }) + // Regression: normalizing a URL-valued push remote used to run `git remote` and then a + // serial `git remote get-url` per remote -- 59 subprocesses on a 58-remote repo. + it('normalizes a URL-valued push remote from one remote table read at 58 remotes', async () => { + const remotes = [ + { name: 'origin', url: 'https://github.com/stablyai/orca.git' }, + ...Array.from({ length: 56 }, (_, index) => ({ + name: `pr-user${index}-orca`, + url: `https://github.com/user${index}/orca.git` + })), + { name: 'pr-pynickle-orca', url: 'https://github.com/pynickle/orca.git' } + ] + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'symbolic-ref') { + return { stdout: 'imp/chinese-translation\n', stderr: '' } + } + if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.remote')) { + return { stdout: 'https://github.com/pynickle/orca.git\n', stderr: '' } + } + if (args[0] === 'config' && args.includes('branch.imp/chinese-translation.merge')) { + return { stdout: 'refs/heads/imp/chinese-translation\n', stderr: '' } + } + if (args[0] === 'config') { + throw new Error(`config key is not set: ${args.join(' ')}`) + } + if (args[0] === 'remote' && args[1] === '-v') { + return { + stdout: remotes + .flatMap(({ name, url }) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join('\n'), + stderr: '' + } + } + if (args[0] === 'remote') { + throw new Error(`unexpected remote scan: ${args.join(' ')}`) + } + return { stdout: '', stderr: '' } + }) + + await gitPush('/repo', false) + + const remoteReads = gitExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'remote') + expect(remoteReads.map(([args]) => args)).toEqual([['remote', '-v']]) + expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( + ['push', '--set-upstream', 'pr-pynickle-orca', 'HEAD:imp/chinese-translation'], + { cwd: '/repo' } + ) + }) + it('uses an explicit push target even when it differs from the local branch name', async () => { gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '', stderr: '' }) diff --git a/src/main/git/remote.ts b/src/main/git/remote.ts index 2baf3b77137..20cf8415d04 100644 --- a/src/main/git/remote.ts +++ b/src/main/git/remote.ts @@ -4,6 +4,7 @@ import { } from '../../shared/git-remote-error' import { resolveEffectiveGitUpstream } from '../../shared/git-effective-upstream' import { gitRefTargetsBranchOnRemote } from '../../shared/git-remote-branch-name' +import { findGitRemoteNameByFetchUrl } from '../../shared/git-remote-url-index' import type { GitPushTarget } from '../../shared/worktree/types' import type { GitRuntimeOptions } from './git-runtime-options' import { gitOptionsForWorktree } from './git-runtime-options' @@ -84,6 +85,8 @@ type ConfiguredPushRemote = { branchRemote: string | null } +// One `git remote -v` instead of `git remote` plus a serial `git remote get-url` +// per remote; both print the same insteadOf-expanded fetch URL. async function findRemoteNameForUrl( worktreePath: string, remoteUrl: string, @@ -91,30 +94,13 @@ async function findRemoteNameForUrl( ): Promise { try { const { stdout } = await gitExecFileAsync( - ['remote'], + ['remote', '-v'], gitOptionsForWorktree(worktreePath, options) ) - const remotes = stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - for (const remoteName of remotes) { - try { - const { stdout: urlStdout } = await gitExecFileAsync( - ['remote', 'get-url', remoteName], - gitOptionsForWorktree(worktreePath, options) - ) - if (urlStdout.trim() === remoteUrl) { - return remoteName - } - } catch { - // Ignore a remote that disappeared or has no fetch URL. - } - } + return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => candidateUrl === remoteUrl) } catch { return null } - return null } async function normalizePushRemote( @@ -141,11 +127,17 @@ async function getConfiguredPushRemote( if (!remote) { return null } + const normalizedRemote = await normalizePushRemote(worktreePath, remote, options) + // The two usually name the same URL; resolving it twice reads the remote table twice. + if (!branchRemote) { + return { remote: normalizedRemote, branchRemote: null } + } return { - remote: await normalizePushRemote(worktreePath, remote, options), - branchRemote: branchRemote - ? await normalizePushRemote(worktreePath, branchRemote, options) - : null + remote: normalizedRemote, + branchRemote: + branchRemote === remote + ? normalizedRemote + : await normalizePushRemote(worktreePath, branchRemote, options) } } diff --git a/src/main/git/repo-branch-conflict-batched-probe.test.ts b/src/main/git/repo-branch-conflict-batched-probe.test.ts new file mode 100644 index 00000000000..e6e672c65a2 --- /dev/null +++ b/src/main/git/repo-branch-conflict-batched-probe.test.ts @@ -0,0 +1,102 @@ +// Why: the batched `cat-file --batch-check` conflict probe decides from stdout, so a +// WSL login-shell fallback that prints the distro banner onto that stream desynchronizes +// the one-line-per-ref contract. Every batch then came back undecided and fell through to +// one `show-ref` subprocess per remote -- the cost the batch exists to remove. These tests +// pin the fence request and the resulting subprocess count at 58 remotes. + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { gitExecFileAsyncMock } = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn() })) + +vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) + +import { getBranchConflictKind } from './repo-branch-conflict' + +const REMOTES = Array.from({ length: 58 }, (_, index) => `r${index}`) +const BRANCH = 'user/feature' +const WSL_BANNER = + 'Welcome to Ubuntu 24.04.1 LTS (GNU/Linux 5.15.167.4-microsoft-standard-WSL2 x86_64)\n' + + 'To run a command as administrator (user "root"), use "sudo ".\n' + +type GitExecOptions = { stdin?: string; captureWslLoginShellOutput?: boolean } + +/** + * Stand-in for a WSL-routed runner: the login shell prepends its banner to stdout unless + * the caller asked for the fenced form, which slices the payload back out. + */ +function installLoginShellRunner(): { argv: string[][] } { + const argv: string[][] = [] + gitExecFileAsyncMock.mockImplementation(async (args: string[], options: GitExecOptions = {}) => { + argv.push(args) + if (args[0] === 'rev-parse') { + throw new Error('local branch is absent') + } + if (args[0] === 'remote') { + return { stdout: `${WSL_BANNER}${REMOTES.join('\n')}\n`, stderr: '' } + } + if (args[0] === 'show-ref') { + throw Object.assign(new Error('missing ref'), { code: 1, stderr: '' }) + } + if (args[0] === 'cat-file') { + const payload = `${(options.stdin ?? '') + .split('\n') + .filter(Boolean) + .map((ref) => `${ref} missing`) + .join('\n')}\n` + return { + stdout: options.captureWslLoginShellOutput ? payload : `${WSL_BANNER}${payload}`, + stderr: '' + } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + return { argv } +} + +function countSubcommand(argv: readonly string[][], subcommand: string): number { + return argv.filter((args) => args[0] === subcommand).length +} + +describe('getBranchConflictKind batched remote probe', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + }) + + it('asks the WSL login shell to fence the batch payload it parses', async () => { + installLoginShellRunner() + + await getBranchConflictKind('/repo', BRANCH) + + const batchCall = gitExecFileAsyncMock.mock.calls.find(([args]) => args[0] === 'cat-file') + expect(batchCall?.[1]).toMatchObject({ captureWslLoginShellOutput: true }) + }) + + it('answers from one batched subprocess instead of one show-ref per remote', async () => { + const { argv } = installLoginShellRunner() + + await expect(getBranchConflictKind('/repo', BRANCH)).resolves.toBeNull() + + expect(countSubcommand(argv, 'cat-file')).toBe(1) + expect(countSubcommand(argv, 'show-ref')).toBe(0) + }) + + it('still falls back to per-ref probes when the batch itself fails', async () => { + gitExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'rev-parse') { + throw new Error('local branch is absent') + } + if (args[0] === 'remote') { + return { stdout: `${REMOTES.join('\n')}\n`, stderr: '' } + } + if (args[0] === 'cat-file') { + throw new Error('cat-file is unavailable on this host') + } + if (args[0] === 'show-ref') { + return { stdout: '', stderr: '' } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + }) + + await expect(getBranchConflictKind('/repo', BRANCH)).resolves.toBe('remote') + }) +}) diff --git a/src/main/git/repo-branch-conflict.ts b/src/main/git/repo-branch-conflict.ts index c799d3770be..5c6e03b94b0 100644 --- a/src/main/git/repo-branch-conflict.ts +++ b/src/main/git/repo-branch-conflict.ts @@ -147,10 +147,12 @@ export function getBranchConflictKind( const execOptions = gitExecOptions(path, options) const runLocalGit = ( argv: string[], - commandOptions?: ExactRefProbeExecOptions & { stdin?: string } + commandOptions?: ExactRefProbeExecOptions & { stdin?: string }, + captureWslLoginShellOutput = false ): Promise<{ stdout: string }> => gitExecFileAsync(argv, { ...execOptions, + ...(captureWslLoginShellOutput ? { captureWslLoginShellOutput: true } : {}), ...(commandOptions?.maxBuffer === undefined ? {} : { maxBuffer: commandOptions.maxBuffer }), ...(commandOptions?.timeoutMs === undefined ? {} : { timeout: commandOptions.timeoutMs }), ...(commandOptions?.stdin === undefined ? {} : { stdin: commandOptions.stdin }) @@ -160,7 +162,13 @@ export function getBranchConflictKind( branchName, allowedBaseRef, {}, - (argv, commandOptions) => runLocalGit(argv, commandOptions) + // Why fenced: the batch decides from stdout, and a WSL login-shell fallback writes + // the distro's rc/motd banner to that same stream. The extra lines break the + // one-line-per-ref contract, so every batch came back undecided and fell through to + // one `show-ref` subprocess per remote -- the exact cost the batch exists to remove. + // `show-ref --verify --quiet` prints nothing and is read by exit code, so it needs + // no fence; the capture wrapper preserves the payload's exit status either way. + (argv, commandOptions) => runLocalGit(argv, commandOptions, true) ) } diff --git a/src/main/git/upstream.test.ts b/src/main/git/upstream.test.ts index 701808a8a31..b7644ad5543 100644 --- a/src/main/git/upstream.test.ts +++ b/src/main/git/upstream.test.ts @@ -363,6 +363,16 @@ describe('getUpstreamStatus', () => { if (args[0] === 'remote' && args[1] === 'get-url' && args[2] === 'pr-pynickle-orca') { return Promise.resolve({ stdout: 'https://github.com/pynickle/orca.git\n' }) } + if (args[0] === 'remote' && args[1] === '-v') { + return Promise.resolve({ + stdout: [ + 'origin\thttps://github.com/stablyai/orca.git (fetch)', + 'origin\thttps://github.com/stablyai/orca.git (push)', + 'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (fetch)', + 'pr-pynickle-orca\thttps://github.com/pynickle/orca.git (push)' + ].join('\n') + }) + } if (args[0] === 'remote') { return Promise.resolve({ stdout: 'origin\npr-pynickle-orca\n' }) } diff --git a/src/main/github/client-starred.test.ts b/src/main/github/client-starred.test.ts index 4b118ad8afb..2997aef883a 100644 --- a/src/main/github/client-starred.test.ts +++ b/src/main/github/client-starred.test.ts @@ -26,47 +26,146 @@ vi.mock('./github-api-repository', async (importOriginal) => ) ) -import { checkOrcaStarred } from './client' +import { __resetOrcaStarCheckForTests, checkOrcaStarred, starOrca } from './client' import { resetOriginRepositoryCache } from './client-test-harness' -const { execFileAsyncMock, acquireMock, releaseMock } = clientMocks +const { execFileAsyncMock, ghExecFileAsyncMock, acquireMock, releaseMock } = clientMocks + +/** Let the coalesced check reach its `await acquire()` continuation and spawn gh. */ +async function flushMicrotasks(): Promise { + for (let i = 0; i < 5; i += 1) { + await Promise.resolve() + } +} describe('checkOrcaStarred', () => { - beforeEach(() => { + beforeEach(async () => { resetOriginRepositoryCache() execFileAsyncMock.mockReset() + ghExecFileAsyncMock.mockReset() acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) + __resetOrcaStarCheckForTests() }) it('returns true only for an included successful GitHub response', async () => { - execFileAsyncMock.mockResolvedValueOnce({ stdout: 'HTTP/2.0 204 No Content\r\n', stderr: '' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'HTTP/2.0 204 No Content\r\n', stderr: '' }) await expect(checkOrcaStarred()).resolves.toBe(true) - expect(execFileAsyncMock).toHaveBeenCalledWith( - 'gh', + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['api', '--include', 'user/starred/stablyai/orca'], - { encoding: 'utf-8' } + expect.objectContaining({ encoding: 'utf-8' }) ) }) it('returns true for an HTTP 200 starred response', async () => { - execFileAsyncMock.mockResolvedValueOnce({ stdout: 'HTTP/2.0 200 OK\r\n', stderr: '' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'HTTP/2.0 200 OK\r\n', stderr: '' }) await expect(checkOrcaStarred()).resolves.toBe(true) }) it('returns false for GitHub 404 not starred responses', async () => { - execFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 404: Not Found')) + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 404: Not Found')) await expect(checkOrcaStarred()).resolves.toBe(false) }) it('returns null when gh exits successfully without response headers', async () => { - execFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) await expect(checkOrcaStarred()).resolves.toBe(null) }) + + // ── #18234: an unbounded, unreaped, un-deduped star check ────────────── + + it('never spawns gh directly, so the spawn carries a deadline and a tree kill', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'HTTP/2.0 204 No Content\r\n', stderr: '' }) + + await checkOrcaStarred() + + // Why: the raw execFileAsync has no timeout, so a `gh` that never exits ran + // forever at 100% CPU and was never reaped. ghExecFileAsync bounds the child + // and kills its process tree on the deadline. + expect(execFileAsyncMock).not.toHaveBeenCalled() + const [, options] = ghExecFileAsyncMock.mock.calls[0] + expect(typeof options.timeout).toBe('number') + expect(options.timeout).toBeGreaterThan(0) + expect(Number.isFinite(options.timeout)).toBe(true) + }) + + it('coalesces concurrent checks onto one gh child', async () => { + let resolveGh: (value: { stdout: string; stderr: string }) => void = () => {} + ghExecFileAsyncMock.mockReturnValueOnce( + new Promise((resolve) => { + resolveGh = resolve + }) + ) + + const first = checkOrcaStarred() + const second = checkOrcaStarred() + const third = checkOrcaStarred() + await flushMicrotasks() + + // Why: five call sites can ask at once; without coalescing each forked its + // own `gh` and four stuck children exhausted the GitHub semaphore. + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(acquireMock).toHaveBeenCalledTimes(1) + + resolveGh({ stdout: 'HTTP/2.0 204 No Content\r\n', stderr: '' }) + await expect(Promise.all([first, second, third])).resolves.toEqual([true, true, true]) + }) + + it('starts a fresh check once the previous one has settled', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: 'HTTP/2.0 404 Not Found\r\n', stderr: '' }) + .mockResolvedValueOnce({ stdout: 'HTTP/2.0 204 No Content\r\n', stderr: '' }) + + await checkOrcaStarred() + await expect(checkOrcaStarred()).resolves.toBe(true) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('releases its GitHub concurrency slot when gh fails or times out', async () => { + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('gh timed out.')) + + await expect(checkOrcaStarred()).resolves.toBe(null) + + // Why: a leaked slot is permanent — four of them wedge every GitHub feature + // in the app for the rest of the session. + expect(releaseMock).toHaveBeenCalledTimes(1) + expect(acquireMock).toHaveBeenCalledTimes(1) + }) +}) + +describe('starOrca', () => { + beforeEach(async () => { + resetOriginRepositoryCache() + execFileAsyncMock.mockReset() + ghExecFileAsyncMock.mockReset() + acquireMock.mockReset() + releaseMock.mockReset() + acquireMock.mockResolvedValue(undefined) + __resetOrcaStarCheckForTests() + }) + + it('stars through the bounded gh runner and releases its slot', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await expect(starOrca()).resolves.toBe(true) + + expect(execFileAsyncMock).not.toHaveBeenCalled() + const [args, options] = ghExecFileAsyncMock.mock.calls[0] + expect(args).toEqual(['api', '-X', 'PUT', 'user/starred/stablyai/orca']) + expect(options.timeout).toBeGreaterThan(0) + expect(releaseMock).toHaveBeenCalledTimes(1) + }) + + it('reports failure and still releases its slot when gh times out', async () => { + ghExecFileAsyncMock.mockRejectedValueOnce(new Error('gh timed out.')) + + await expect(starOrca()).resolves.toBe(false) + expect(releaseMock).toHaveBeenCalledTimes(1) + }) }) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 3adec83a13a..fa331749c8b 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -8,7 +8,7 @@ export { __resetTrackedUpstreamBranchCacheForTests } from './client/lookup/tracked-upstream-cache' export { addPRReviewComment, addPRReviewCommentReply } from './client/create/add-pr-review-comment' -export { checkOrcaStarred, starOrca } from './client/fetch/orca-star' +export { __resetOrcaStarCheckForTests, checkOrcaStarred, starOrca } from './client/fetch/orca-star' export { countWorkItems } from './client/list/count-work-items' export { createGitHubPullRequest } from './client/create/create-github-pull-request' export { getAuthenticatedViewer } from './client/fetch/authenticated-viewer' diff --git a/src/main/github/client/fetch/authenticated-viewer.ts b/src/main/github/client/fetch/authenticated-viewer.ts index dd297addd67..551ffe81377 100644 --- a/src/main/github/client/fetch/authenticated-viewer.ts +++ b/src/main/github/client/fetch/authenticated-viewer.ts @@ -1,14 +1,17 @@ import type { GitHubViewer } from '../../../../shared/github/pull-request-types' -import { execFileAsync, acquire, release } from '../../gh-utils' +import { ghExecFileAsync, acquire, release } from '../../gh-utils' /** * Get the authenticated GitHub viewer when gh is available and logged in. * Returns null when gh is unavailable, unauthenticated, or the lookup fails. + * + * Runs through `ghExecFileAsync` for its deadline and tree kill: a `gh` that + * never exits would otherwise hold one of the four GitHub concurrency slots + * forever (#18234). */ export async function getAuthenticatedViewer(): Promise { await acquire() try { - const { stdout } = await execFileAsync( - 'gh', + const { stdout } = await ghExecFileAsync( ['api', 'user', '--jq', '{login: .login, email: .email}'], { encoding: 'utf-8' } ) diff --git a/src/main/github/client/fetch/orca-star.ts b/src/main/github/client/fetch/orca-star.ts index ddf63d71c17..a9006ed544a 100644 --- a/src/main/github/client/fetch/orca-star.ts +++ b/src/main/github/client/fetch/orca-star.ts @@ -1,17 +1,45 @@ -import { execFileAsync, acquire, release } from '../../gh-utils' +import { ghExecFileAsync, acquire, release } from '../../gh-utils' export const ORCA_REPO = 'stablyai/orca' +/** + * Deadline for the two star-nag gh calls. + * + * Why bounded at all: these are the only gh call sites that used the raw + * `execFileAsync`, so a `gh` that never exits blocked forever, never released + * its GitHub concurrency slot, and left the child running (#18234). Why shorter + * than the 30s gh default: nothing here is user-visible work — the nag falls + * back to the browser button — so a slow answer is worth less than a bounded one. + */ +const STAR_GH_TIMEOUT_MS = 15_000 + +let inFlightStarCheck: Promise | null = null + /** * Check if the authenticated user has starred the Orca repo. * Returns true if starred, false if not, null if unable to determine (gh unavailable). */ -export async function checkOrcaStarred(): Promise { +export function checkOrcaStarred(): Promise { + // Why: five independent callers (landing button, settings section, threshold + // nag, agent-value moment, force-show) can ask at once and none of them knows + // about the others. Without coalescing, each forks its own `gh`, and four + // stuck children exhaust the 4-wide GitHub semaphore for the app's lifetime. + inFlightStarCheck ??= runOrcaStarredCheck().finally(() => { + inFlightStarCheck = null + }) + return inFlightStarCheck +} + +/** @internal Drop any coalesced check so suites cannot inherit one another's. */ +export function __resetOrcaStarCheckForTests(): void { + inFlightStarCheck = null +} + +async function runOrcaStarredCheck(): Promise { await acquire() try { - const { stdout, stderr } = await execFileAsync( - 'gh', + const { stdout, stderr } = await ghExecFileAsync( ['api', '--include', `user/starred/${ORCA_REPO}`], - { encoding: 'utf-8' } + { encoding: 'utf-8', timeout: STAR_GH_TIMEOUT_MS } ) const response = `${stdout ?? ''}\n${stderr ?? ''}` if (/HTTP\/\S+\s+(?:200|204)\b/.test(response)) { @@ -24,7 +52,7 @@ export async function checkOrcaStarred(): Promise { if (message.includes('HTTP 404')) { return false } - // Anything else (gh not installed, not authenticated, network issue) + // Anything else (gh not installed, not authenticated, network issue, timeout) return null } finally { release() @@ -37,8 +65,9 @@ export async function checkOrcaStarred(): Promise { export async function starOrca(): Promise { await acquire() try { - await execFileAsync('gh', ['api', '-X', 'PUT', `user/starred/${ORCA_REPO}`], { - encoding: 'utf-8' + await ghExecFileAsync(['api', '-X', 'PUT', `user/starred/${ORCA_REPO}`], { + encoding: 'utf-8', + timeout: STAR_GH_TIMEOUT_MS }) return true } catch { diff --git a/src/main/ipc/worktree-push-target-reconciliation.ts b/src/main/ipc/worktree-push-target-reconciliation.ts index e59da7bdfe7..b28ae36b6f4 100644 --- a/src/main/ipc/worktree-push-target-reconciliation.ts +++ b/src/main/ipc/worktree-push-target-reconciliation.ts @@ -13,7 +13,7 @@ import { listWorktrees } from '../git/worktree' import type { SshGitProvider } from '../providers/ssh-git-provider' import type { GitPushTarget } from '../../shared/worktree/types' import { WORKTREE_ID_SEPARATOR, worktreeIdComparisonKey } from '../../shared/worktree/id' -import { iterateProcessOutputLines } from '../../shared/process-output-field-scanner' +import { parseGitRemoteFetchUrls } from '../../shared/git-remote-url-index' import { findWorktreeMetaReferencingRemote, hasBranchConfigUsingRemote, @@ -44,26 +44,9 @@ async function listPrRemoteCandidates( } catch { return [] } - const candidates = new Map() - for (const line of iterateProcessOutputLines(stdout)) { - const parsed = parseRemoteVerboseLine(line) - if (parsed?.direction === 'fetch' && isOrcaGeneratedPrRemoteName(parsed.name)) { - candidates.set(parsed.name, parsed.url) - } - } - return [...candidates.entries()].map(([name, url]) => ({ name, url })) -} - -function parseRemoteVerboseLine( - line: string -): { name: string; url: string; direction: 'fetch' | 'push' } | null { - const tabIndex = line.indexOf('\t') - if (tabIndex === -1) { - return null - } - const name = line.slice(0, tabIndex) - const match = /^(.*) \((fetch|push)\)$/.exec(line.slice(tabIndex + 1).trim()) - return match ? { name, url: match[1], direction: match[2] as 'fetch' | 'push' } : null + return [...parseGitRemoteFetchUrls(stdout)] + .filter(([name]) => isOrcaGeneratedPrRemoteName(name)) + .map(([name, url]) => ({ name, url })) } async function shouldReclaimPrRemote( diff --git a/src/main/ipc/worktree-push-target-remote-scan.test.ts b/src/main/ipc/worktree-push-target-remote-scan.test.ts new file mode 100644 index 00000000000..f2392c5e71a --- /dev/null +++ b/src/main/ipc/worktree-push-target-remote-scan.test.ts @@ -0,0 +1,178 @@ +// Why: `findRemoteForUrl` used to run `git remote` and then one serial +// `git remote get-url` per remote. These tests pin both halves of the fix: the +// subprocess count at 58 remotes, and result-for-result parity with the old scan +// across the remote shapes a real repo produces. + +import { describe, expect, it } from 'vitest' +import { parseGitHubOwnerRepo } from '../github/gh-utils' +import { findRemoteForUrl } from './worktree-push-target-setup' +import type { GitRemoteExec } from './worktree-push-target-cleanup' + +const SSH_FORK = 'git@github.com:contributor/orca.git' +const HTTPS_FORK = 'https://github.com/contributor/orca.git' +const GITLAB_FORK = 'https://gitlab.com/contributor/orca.git' +const UPSTREAM = 'https://github.com/stablyai/orca.git' + +type RemoteRow = { name: string; fetchUrl: string; pushUrl?: string } + +type CountingExec = GitRemoteExec & { spawns: string[][] } + +function makeExec(remotes: readonly RemoteRow[]): CountingExec { + const spawns: string[][] = [] + const exec: GitRemoteExec = async (args: string[]) => { + spawns.push(args) + if (args[0] === 'remote' && args.length === 1) { + return { stdout: `${remotes.map((remote) => remote.name).join('\n')}\n` } + } + if (args[0] === 'remote' && args[1] === '-v') { + return { + stdout: remotes + .flatMap((remote) => [ + `${remote.name}\t${remote.fetchUrl} (fetch)`, + `${remote.name}\t${remote.pushUrl ?? remote.fetchUrl} (push)` + ]) + .join('\n') + } + } + if (args[0] === 'remote' && args[1] === 'get-url') { + const match = remotes.find((remote) => remote.name === args[2]) + if (!match) { + throw new Error(`No such remote ${args[2]}`) + } + return { stdout: `${match.fetchUrl}\n` } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + } + return Object.assign(exec, { spawns }) +} + +/** The pre-fix scan, kept as the oracle the batched form must reproduce exactly. */ +async function findRemoteForUrlPerRemote( + execGit: GitRemoteExec, + repoPath: string, + remoteUrl: string +): Promise { + const target = parseGitHubOwnerRepo(remoteUrl) + try { + const { stdout } = await execGit(['remote'], repoPath) + for (const remote of stdout + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean)) { + try { + const { stdout: urlStdout } = await execGit(['remote', 'get-url', remote], repoPath) + const candidateUrl = urlStdout.trim() + const candidate = parseGitHubOwnerRepo(candidateUrl) + if ( + target && + candidate && + target.owner.toLowerCase() === candidate.owner.toLowerCase() && + target.repo.toLowerCase() === candidate.repo.toLowerCase() + ) { + return remote + } + if (candidateUrl === remoteUrl) { + return remote + } + } catch { + // Ignore a remote that disappeared or has no fetch URL. + } + } + } catch { + return null + } + return null +} + +const fiftyEightRemotes: RemoteRow[] = [ + { name: 'origin', fetchUrl: UPSTREAM }, + ...Array.from({ length: 56 }, (_, index) => ({ + name: `pr-user${index}-orca`, + fetchUrl: `https://github.com/user${index}/orca.git` + })), + { name: 'pr-contributor-orca', fetchUrl: SSH_FORK } +] + +const matrix: { name: string; remotes: RemoteRow[]; lookupUrl: string }[] = [ + { name: 'no remotes', remotes: [], lookupUrl: SSH_FORK }, + { + name: 'one matching remote', + remotes: [{ name: 'origin', fetchUrl: SSH_FORK }], + lookupUrl: SSH_FORK + }, + { + name: 'one non-matching remote', + remotes: [{ name: 'origin', fetchUrl: UPSTREAM }], + lookupUrl: SSH_FORK + }, + { name: '58 remotes, match last', remotes: fiftyEightRemotes, lookupUrl: SSH_FORK }, + { + name: '58 remotes, no match', + remotes: fiftyEightRemotes, + lookupUrl: 'https://github.com/nobody/other.git' + }, + { + name: 'duplicate URLs on two remotes', + remotes: [ + { name: 'origin', fetchUrl: UPSTREAM }, + { name: 'fork-a', fetchUrl: SSH_FORK }, + { name: 'fork-b', fetchUrl: SSH_FORK } + ], + lookupUrl: SSH_FORK + }, + { + name: 'fetch and push URLs differ', + remotes: [{ name: 'split', fetchUrl: SSH_FORK, pushUrl: HTTPS_FORK }], + lookupUrl: SSH_FORK + }, + { + name: 'SSH-form lookup against an HTTPS-form remote', + remotes: [ + { name: 'origin', fetchUrl: UPSTREAM }, + { name: 'fork', fetchUrl: HTTPS_FORK } + ], + lookupUrl: SSH_FORK + }, + { + name: 'HTTPS-form lookup against an SSH-form remote', + remotes: [ + { name: 'origin', fetchUrl: UPSTREAM }, + { name: 'fork', fetchUrl: SSH_FORK } + ], + lookupUrl: HTTPS_FORK + }, + { + name: 'non-GitHub provider matches only on the exact URL', + remotes: [{ name: 'gitlab-fork', fetchUrl: GITLAB_FORK }], + lookupUrl: GITLAB_FORK + }, + { + name: 'non-GitHub provider with a different host does not match', + remotes: [{ name: 'gitlab-fork', fetchUrl: GITLAB_FORK }], + lookupUrl: 'https://bitbucket.org/contributor/orca.git' + } +] + +describe('findRemoteForUrl', () => { + it.each(matrix)('matches the per-remote scan for $name', async ({ remotes, lookupUrl }) => { + const expected = await findRemoteForUrlPerRemote(makeExec(remotes), '/repo', lookupUrl) + await expect(findRemoteForUrl(makeExec(remotes), '/repo', lookupUrl)).resolves.toBe(expected) + }) + + it('answers from one subprocess at 58 remotes instead of one per remote', async () => { + const legacyExec = makeExec(fiftyEightRemotes) + await findRemoteForUrlPerRemote(legacyExec, '/repo', 'https://github.com/nobody/other.git') + expect(legacyExec.spawns).toHaveLength(fiftyEightRemotes.length + 1) + + const exec = makeExec(fiftyEightRemotes) + await findRemoteForUrl(exec, '/repo', 'https://github.com/nobody/other.git') + expect(exec.spawns).toEqual([['remote', '-v']]) + }) + + it('returns null when the remote table cannot be read', async () => { + const failing: GitRemoteExec = async () => { + throw new Error('not a git repository') + } + await expect(findRemoteForUrl(failing, '/repo', SSH_FORK)).resolves.toBeNull() + }) +}) diff --git a/src/main/ipc/worktree-push-target-setup.test.ts b/src/main/ipc/worktree-push-target-setup.test.ts index 2d9670c2f9b..ef1e44aa923 100644 --- a/src/main/ipc/worktree-push-target-setup.test.ts +++ b/src/main/ipc/worktree-push-target-setup.test.ts @@ -16,6 +16,13 @@ const REPO = '/repo-root' const FORK_SSH = 'git@github.com:contributor/orca.git' const FORK_HTTPS = 'https://github.com/contributor/orca.git' +/** Real `git remote -v` shape: a fetch row and a push row per remote, tab-separated. */ +export function renderRemoteVerbose(remotes: Record): string { + return Object.entries(remotes) + .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join('\n') +} + // A stateful fake git: `remotes` maps name -> url. `remote add` mutates it so // later lookups see the new remote, matching real git behavior. Defaults // `symbolic-ref --short HEAD` to a real branch name, since a worktree's HEAD @@ -31,6 +38,9 @@ function makeRepoExec( if (args[0] === 'remote' && args.length === 1) { return { stdout: Object.keys(remotes).join('\n'), stderr: '' } } + if (args[0] === 'remote' && args[1] === '-v' && args.length === 2) { + return { stdout: renderRemoteVerbose(remotes), stderr: '' } + } if (args[0] === 'remote' && args[1] === 'get-url') { const url = remotes[args[2]!] if (!url) { diff --git a/src/main/ipc/worktree-push-target-setup.ts b/src/main/ipc/worktree-push-target-setup.ts index 59ef4c8fea5..c4a82d94933 100644 --- a/src/main/ipc/worktree-push-target-setup.ts +++ b/src/main/ipc/worktree-push-target-setup.ts @@ -5,53 +5,33 @@ // repo. The store-aware ownership decision stays with the caller via a predicate. import type { GitPushTarget } from '../../shared/worktree/types' -import { parseGitHubOwnerRepo } from '../github/gh-utils' -import type { GitRemoteExec } from './worktree-push-target-cleanup' +import { findGitRemoteNameByFetchUrl } from '../../shared/git-remote-url-index' +import { sameGitHubRemoteUrl, type GitRemoteExec } from './worktree-push-target-cleanup' import { buildNarrowForkFetchRefspec, ensureRemoteTracksBranchNarrowly } from '../git/fork-remote-refspec' +// One `git remote -v` replaces `git remote` plus a serial `git remote get-url` per +// remote -- 59 subprocesses at 58 remotes, on every push-target resolution (#17914). export async function findRemoteForUrl( execGit: GitRemoteExec, repoPath: string, remoteUrl: string ): Promise { - const target = parseGitHubOwnerRepo(remoteUrl) try { - const { stdout } = await execGit(['remote'], repoPath) - for (const remote of stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean)) { - try { - const { stdout: urlStdout } = await execGit(['remote', 'get-url', remote], repoPath) - const candidateUrl = urlStdout.trim() - const candidate = parseGitHubOwnerRepo(candidateUrl) - if ( - target && - candidate && - target.owner.toLowerCase() === candidate.owner.toLowerCase() && - target.repo.toLowerCase() === candidate.repo.toLowerCase() - ) { - return remote - } - if (candidateUrl === remoteUrl) { - return remote - } - } catch { - // Ignore a remote that disappeared or has no fetch URL. - } - } + const { stdout } = await execGit(['remote', '-v'], repoPath) + return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => + sameGitHubRemoteUrl(candidateUrl, remoteUrl) + ) } catch { return null } - return null } // O(1) probe used before materializing on demand (push/pull/fetch/fast-forward): -// a single `remote get-url ` avoids the O(remotes) `findRemoteForUrl` scan -// once a fork remote already exists under its expected name (#17828). +// a single `remote get-url ` skips the whole-remote-table read once a fork +// remote already exists under its expected name (#17828). export async function remoteAlreadyMatchesUrl( execGit: GitRemoteExec, repoPath: string, @@ -60,18 +40,7 @@ export async function remoteAlreadyMatchesUrl( ): Promise { try { const { stdout } = await execGit(['remote', 'get-url', remoteName], repoPath) - const candidateUrl = stdout.trim() - if (candidateUrl === remoteUrl) { - return true - } - const target = parseGitHubOwnerRepo(remoteUrl) - const candidate = parseGitHubOwnerRepo(candidateUrl) - return Boolean( - target && - candidate && - target.owner.toLowerCase() === candidate.owner.toLowerCase() && - target.repo.toLowerCase() === candidate.repo.toLowerCase() - ) + return sameGitHubRemoteUrl(stdout.trim(), remoteUrl) } catch { return false } diff --git a/src/main/ipc/worktree-remote-push-target-materialization.test.ts b/src/main/ipc/worktree-remote-push-target-materialization.test.ts index 68ed98ab35c..695baa10dec 100644 --- a/src/main/ipc/worktree-remote-push-target-materialization.test.ts +++ b/src/main/ipc/worktree-remote-push-target-materialization.test.ts @@ -553,6 +553,17 @@ describe('materializeWorktreePushTargetRemoteSsh', () => { } throw new Error('No such remote') } + if (args[0] === 'remote' && args[1] === '-v') { + return { + stdout: [ + 'origin\thttps://github.com/stablyai/orca.git (fetch)', + 'origin\thttps://github.com/stablyai/orca.git (push)', + `${SIBLING_REMOTE}\t${FORK_URL} (fetch)`, + `${SIBLING_REMOTE}\t${FORK_URL} (push)` + ].join('\n'), + stderr: '' + } + } if (args[0] === 'remote' && args.length === 1) { return { stdout: `origin\n${SIBLING_REMOTE}\n`, stderr: '' } } diff --git a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts index 2286e40d54b..39d625c525a 100644 --- a/src/main/persistence/loading-store/normalize-loaded-profile-state.ts +++ b/src/main/persistence/loading-store/normalize-loaded-profile-state.ts @@ -35,6 +35,8 @@ export function normalizeLoadedProfileState( const { defaults, migratedExternalVisibility, osc52ClipboardNoticePending } = terminal const { normalizedOnboarding, normalizedProjectGroups, loadedCompactWorktreeCards } = profile const projectCatalog = normalizeLoadedProjectCatalog(parsed, markNeedsSave) + // Ordered: the host partitions drop the global fields this slice already owns. + const workspaceSession = normalizeLoadedLocalSession(parsed, defaults, markNeedsSave) return { ...defaults, @@ -69,9 +71,14 @@ export function normalizeLoadedProfileState( markNeedsSave ), // Why: volatile schema; zod-validate workspaceSession at read so a bad payload falls to defaults, not a renderer crash. - workspaceSession: normalizeLoadedLocalSession(parsed, defaults, markNeedsSave), + workspaceSession, // Why: per-host session partitions, validated independently; 'local' stays in workspaceSession for downgrade compat. - workspaceSessionsByHostId: normalizeLoadedHostSessions(parsed, defaults, markNeedsSave), + workspaceSessionsByHostId: normalizeLoadedHostSessions( + parsed, + defaults, + workspaceSession, + markNeedsSave + ), sshTargets: (parsed.sshTargets ?? []).map(normalizeSshTarget), deletedSshConfigAliases: Array.isArray(parsed.deletedSshConfigAliases) ? parsed.deletedSshConfigAliases.filter((alias): alias is string => typeof alias === 'string') diff --git a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts index 2b133d486b7..16c5b424def 100644 --- a/src/main/persistence/loading-store/normalize-loaded-state-collections.ts +++ b/src/main/persistence/loading-store/normalize-loaded-state-collections.ts @@ -41,11 +41,13 @@ export function normalizeLoadedLocalSession( export function normalizeLoadedHostSessions( parsed: PersistedState, defaults: PersistedState, + localSession: WorkspaceSessionState, markNeedsSave: () => void ): PersistedState['workspaceSessionsByHostId'] { const { partitions, repaired } = parseWorkspaceSessionsByHostId( parsed.workspaceSessionsByHostId, - defaults.workspaceSession + defaults.workspaceSession, + localSession ) if (repaired) { // Why: salvage repairs only the in-memory partitions; without a save the corrupt entries stay on disk and get re-dropped every launch. diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts index 9d6e476ebe5..c112b4a9ba0 100644 --- a/src/main/persistence/loading-store/primary-state-writes.ts +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -161,7 +161,8 @@ export async function writeToDiskAsync(owner: PrimaryStateWriteOperations): Prom // Why: fsync before rename, then fsync the directory; see writeFileDurable. const handle = await open(tmpFile, 'w') try { - await handle.writeFile(payload, 'utf-8') + // Already UTF-8 bytes: passing the string here would re-encode the whole state on the main thread. + await handle.writeFile(payload) await handle.sync() } finally { await handle.close() diff --git a/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts b/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts new file mode 100644 index 00000000000..e58d0280bae --- /dev/null +++ b/src/main/persistence/loading-store/secret-sentinel-substitution.test.ts @@ -0,0 +1,184 @@ +/** + * The bar for this change is "the bytes on disk did not move". Every case below runs the exact + * loop `applySecretSentinelSubstitutions` replaced — reproduced in `previousImplementation` — and + * compares payload bytes and guard hash, because a drifting hash silently disables the no-op write + * guard and a drifting payload is corrupted persisted state. + */ +import { createHash, randomUUID } from 'node:crypto' +import { describe, expect, it } from 'vitest' +import { + applySecretSentinelSubstitutions, + type SecretSentinelSubstitution +} from './secret-sentinel-substitution' + +/** Verbatim from state-serialization-secret-handling.ts before this change. */ +function previousImplementation( + serialized: string, + secretSubs: readonly SecretSentinelSubstitution[], + degradedPrefix: string +): { payload: Buffer; stateHash: string } { + let payload = serialized + let hashInput = serialized + for (const { sentinel, blob, hashValue } of secretSubs) { + const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) + payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1)) + hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1)) + } + const stateHash = createHash('sha1').update(degradedPrefix).update(hashInput).digest('hex') + // `handle.writeFile(payload, 'utf-8')` is what turned the string into bytes. + return { payload: Buffer.from(payload, 'utf8'), stateHash } +} + +function expectIdenticalToPrevious( + serialized: string, + subs: readonly SecretSentinelSubstitution[], + degradedPrefix = '' +): void { + const before = previousImplementation(serialized, subs, degradedPrefix) + const after = applySecretSentinelSubstitutions(serialized, subs, degradedPrefix) + expect(after.payload.equals(before.payload)).toBe(true) + expect(after.stateHash).toBe(before.stateHash) +} + +function sentinel(): string { + return `orca-secret-slot-${randomUUID()}` +} + +describe('applySecretSentinelSubstitutions', () => { + it('produces bytes and a hash identical to the previous implementation', () => { + const subs: SecretSentinelSubstitution[] = [ + { sentinel: sentinel(), blob: 'djEwY2lwaGVy', hashValue: 'cookie-value' }, + { + sentinel: sentinel(), + // Regex-special *and* JSON-escapable, which is the pair that breaks a naive rewrite: + // `$&` would splice the match back in under string-form replace, and the backslash and + // quote have to survive `JSON.stringify(...).slice(1, -1)` unchanged. + blob: 'A+/=$&$1$`\\x "quoted" |.*?[](){}^', + hashValue: 'http://proxy.example:8080/?a=b&c=$&' + }, + { sentinel: sentinel(), blob: '', hashValue: 'https://kagi.com/session?t=abc' } + ] + const state = { + settings: { opencodeSessionCookie: subs[0].sentinel, httpProxyUrl: subs[1].sentinel }, + ui: { browserKagiSessionLink: subs[2].sentinel }, + // Adjacent content that must not shift: a near-miss prefix, and JSON escapes either side. + noise: ['orca-secret-slot-', 'a\\b"c\n\t', subs[0].sentinel.slice(0, -1)] + } + expectIdenticalToPrevious(JSON.stringify(state), subs) + }) + + it('stays identical when the state holds multi-byte and escaped characters', () => { + const subs: SecretSentinelSubstitution[] = [ + { sentinel: sentinel(), blob: 'blob-é', hashValue: 'plain-é' }, + { sentinel: sentinel(), blob: '😀', hashValue: '中文' } + ] + const state = { + // Segment boundaries land next to these, so a wrong split would corrupt the encode. + before: 'é中文😀', + a: subs[0].sentinel, + between: '😀

', + b: subs[1].sentinel, + after: '😀' + } + expectIdenticalToPrevious(JSON.stringify(state), subs) + }) + + it('stays identical with no substitutions and with the degraded-storage prefix', () => { + const state = JSON.stringify({ settings: { httpProxyUrl: '' }, big: 'x'.repeat(4096) }) + expectIdenticalToPrevious(state, []) + expectIdenticalToPrevious(state, [], 'safeStorage-degraded\0') + + const subs = [{ sentinel: sentinel(), blob: 'b', hashValue: 'h' }] + expectIdenticalToPrevious( + JSON.stringify({ s: subs[0].sentinel }), + subs, + 'safeStorage-degraded\0' + ) + }) + + it('escapes regex metacharacters in the sentinel itself', () => { + // Not reachable from a UUID sentinel, but the alternation must not be able to become a pattern. + const subs = [{ sentinel: 'a.b*c(d)|e[f]', blob: 'BLOB', hashValue: 'HASH' }] + const serialized = JSON.stringify({ real: subs[0].sentinel, decoy: 'axbxxcXdX_eXfX' }) + expectIdenticalToPrevious(serialized, subs) + expect( + applySecretSentinelSubstitutions(serialized, subs, '').payload.toString('utf8') + ).toContain('axbxxcXdX_eXfX') + }) + + it('substitutes every occurrence when a sentinel repeats', () => { + // Cannot happen today (a sentinel is a UUID minted after the state is assembled, so it appears + // exactly once), but the old first-match-only `String.replace` would have written a raw + // sentinel to disk in place of a secret if it ever did. The alternation is global instead. + const subs = [{ sentinel: sentinel(), blob: 'CIPHER', hashValue: 'PLAIN' }] + const serialized = JSON.stringify({ a: subs[0].sentinel, b: subs[0].sentinel }) + const { payload } = applySecretSentinelSubstitutions(serialized, subs, '') + expect(payload.toString('utf8')).toBe(JSON.stringify({ a: 'CIPHER', b: 'CIPHER' })) + expect(payload.toString('utf8')).not.toContain(subs[0].sentinel) + }) + + it('copies and UTF-8 encodes the full state once, not once per sentinel per side', () => { + const subs: SecretSentinelSubstitution[] = Array.from({ length: 3 }, () => ({ + sentinel: sentinel(), + blob: 'CIPHERTEXT', + hashValue: 'plaintext' + })) + const serialized = JSON.stringify({ + pad: 'x'.repeat(200_000), + a: subs[0].sentinel, + b: subs[1].sentinel, + c: subs[2].sentinel + }) + const FULL_STATE = 100_000 + + // Both costs are observable at their sources: a `String.replace` whose receiver is the whole + // state allocates another copy of it, and every string handed to `Buffer.from` or `hash.update` + // is one full UTF-8 encode pass on the main thread. + const counted = (run: () => unknown): { fullStateReplaces: number; encodedChars: number } => { + const realReplace = String.prototype.replace + const realBufferFrom = Buffer.from + const hashProto = Object.getPrototypeOf(createHash('sha1')) as { + update: (...args: unknown[]) => unknown + } + const realUpdate = hashProto.update + const counts = { fullStateReplaces: 0, encodedChars: 0 } + String.prototype.replace = function (this: string, ...args: unknown[]) { + if (this.length >= FULL_STATE) { + counts.fullStateReplaces++ + } + return realReplace.apply(this, args as never) + } as typeof String.prototype.replace + Buffer.from = function (...args: unknown[]) { + if (typeof args[0] === 'string') { + counts.encodedChars += args[0].length + } + return (realBufferFrom as (...a: unknown[]) => Buffer).apply(Buffer, args) + } as typeof Buffer.from + hashProto.update = function (this: unknown, ...args: unknown[]) { + if (typeof args[0] === 'string') { + counts.encodedChars += args[0].length + } + return realUpdate.apply(this, args) + } + try { + run() + } finally { + String.prototype.replace = realReplace + Buffer.from = realBufferFrom + hashProto.update = realUpdate + } + return counts + } + + const before = counted(() => previousImplementation(serialized, subs, '')) + const after = counted(() => applySecretSentinelSubstitutions(serialized, subs, '')) + + // Two `String.replace` calls over the whole state per sentinel — payload and hash input. + expect(before.fullStateReplaces).toBe(subs.length * 2) + expect(after.fullStateReplaces).toBe(0) + // The old path encoded the state twice: once for sha1, once for the file write. + expect(before.encodedChars).toBeGreaterThan(serialized.length * 1.9) + expect(after.encodedChars).toBeLessThan(serialized.length * 1.1) + expect(after.encodedChars).toBeGreaterThan(serialized.length * 0.9) + }) +}) diff --git a/src/main/persistence/loading-store/secret-sentinel-substitution.ts b/src/main/persistence/loading-store/secret-sentinel-substitution.ts new file mode 100644 index 00000000000..afcdddafa77 --- /dev/null +++ b/src/main/persistence/loading-store/secret-sentinel-substitution.ts @@ -0,0 +1,78 @@ +import { createHash } from 'node:crypto' +import { escapeRegex } from '../../../shared/string-utils' + +export type SecretSentinelSubstitution = { + /** The `orca-secret-slot-` placeholder standing in the serialized state. */ + sentinel: string + /** What the on-disk payload gets: the ciphertext. */ + blob: string + /** What the guard hash gets: a value stable across non-deterministic encryption. */ + hashValue: string +} + +/** + * Replace every secret sentinel in `serialized` in ONE pass, producing the on-disk bytes and the + * guard hash from the same encoded segments. + * + * Why not the obvious `payload.replace(...)` / `hashInput.replace(...)` loop it replaces: each + * `String.replace` returns a rope that the *next* `replace` has to flatten before it can search, so + * N sentinels cost 2N-1 flattened copies of the whole multi-MB state, plus one more per side when + * `hash.update` and the file write finally consume them. Measured on a 4.65 MB store with three + * sentinels: 7 full-state string allocations, 62 MB of V8 heap, 27 MB of it in large_object_space. + * + * Here the state is walked once, each literal run is UTF-8 encoded exactly once, and those same + * buffers feed both the payload and the hash — 1 full-state string, 1 encode. + * + * Byte-for-byte identical output to the loop: both sides read the sentinel in its JSON-escaped + * form, the replacements are the JSON-escaped `blob`/`hashValue`, and the hash sees the same byte + * sequence it saw when it was handed one concatenated string. + */ +export function applySecretSentinelSubstitutions( + serialized: string, + substitutions: readonly SecretSentinelSubstitution[], + degradedPrefix: string +): { payload: Buffer; stateHash: string } { + const hash = createHash('sha1').update(degradedPrefix) + if (substitutions.length === 0) { + const payload = Buffer.from(serialized, 'utf8') + return { payload, stateHash: hash.update(payload).digest('hex') } + } + + const replacementBySentinel = new Map() + const alternatives: string[] = [] + for (const { sentinel, blob, hashValue } of substitutions) { + // Preserved from the loop this replaces: both the search key and the replacements are the + // JSON-escaped forms, because that is what `serialized` actually contains. + const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) + if (replacementBySentinel.has(escapedSentinel)) { + continue + } + alternatives.push(escapeRegex(escapedSentinel)) + replacementBySentinel.set(escapedSentinel, { + blob: Buffer.from(JSON.stringify(blob).slice(1, -1), 'utf8'), + hashValue: Buffer.from(JSON.stringify(hashValue).slice(1, -1), 'utf8') + }) + } + + // Global, though a sentinel is a UUID minted after the state was assembled and so occurs exactly + // once: a single pass that substitutes every occurrence cannot leave one behind on disk. + const pattern = new RegExp(alternatives.join('|'), 'g') + const chunks: Buffer[] = [] + let cursor = 0 + let match: RegExpExecArray | null + while ((match = pattern.exec(serialized)) !== null) { + // Non-null: the alternation is built from exactly the map's keys. + const replacement = replacementBySentinel.get(match[0])! + // A sliced substring, so this does not copy the state; the encode below is its only pass. + const literal = Buffer.from(serialized.slice(cursor, match.index), 'utf8') + chunks.push(literal, replacement.blob) + hash.update(literal) + hash.update(replacement.hashValue) + cursor = match.index + match[0].length + } + const tail = Buffer.from(serialized.slice(cursor), 'utf8') + chunks.push(tail) + hash.update(tail) + + return { payload: Buffer.concat(chunks), stateHash: hash.digest('hex') } +} diff --git a/src/main/persistence/loading-store/state-serialization-secret-handling.ts b/src/main/persistence/loading-store/state-serialization-secret-handling.ts index c9d81f071a5..61689ea0f13 100644 --- a/src/main/persistence/loading-store/state-serialization-secret-handling.ts +++ b/src/main/persistence/loading-store/state-serialization-secret-handling.ts @@ -1,4 +1,4 @@ -import { createHash, randomUUID } from 'node:crypto' +import { randomUUID } from 'node:crypto' import type { PersistedState } from '../../../shared/persisted-state-types' import { collectFolderWorkspaceDiffComments } from '../../folder-workspace-diff-comments' import { @@ -8,6 +8,10 @@ import { } from '../../protected-secret-persistence' import { stripRetiredGlobalSettings } from '../applying-settings/terminal-settings-migrations' +import { + applySecretSentinelSubstitutions, + type SecretSentinelSubstitution +} from './secret-sentinel-substitution' import type { StoreRuntimeState } from './store-runtime-state' type StateSerializationSecretHandlingOperationsRuntime = Pick< @@ -24,7 +28,7 @@ export class StateSerializationSecretHandlingOperations { } buildStateToSave(): { - payload: string + payload: Buffer stateHash: string protectedSecretUpdates: ProtectedSecretRetentionUpdate[] } { @@ -37,7 +41,7 @@ export class StateSerializationSecretHandlingOperations { // on deterministic-IV platforms (macOS/legacy-Linux OSCrypt). A per-slot // random UUID can't occur anywhere else in the serialized state (the user // sets their data before it is minted), so it appears exactly once. - const secretSubs: { sentinel: string; blob: string; hashValue: string }[] = [] + const secretSubs: SecretSentinelSubstitution[] = [] const protectedSecretUpdates: ProtectedSecretRetentionUpdate[] = [] let protectedStorageDegraded = false const encryptToSentinel = (slot: string, plaintext: string): string => { @@ -105,21 +109,14 @@ export class StateSerializationSecretHandlingOperations { // Why compact: ~20% fewer bytes and less serialize time; all readers JSON.parse so formatting is irrelevant. // One full-state stringify; secret slots currently hold sentinels. const serialized = JSON.stringify(stateToSave) - // Substitute each unique sentinel exactly once: ciphertext for the on-disk - // payload, a stable normalized value for the guard hash. Function-form - // replacement keeps `$` inert; both sides read the sentinel as JSON-escaped - // in `serialized`, so each replace is byte-for-byte position-exact. - let payload = serialized - let hashInput = serialized - for (const { sentinel, blob, hashValue } of secretSubs) { - const escapedSentinel = JSON.stringify(sentinel).slice(1, -1) - payload = payload.replace(escapedSentinel, () => JSON.stringify(blob).slice(1, -1)) - hashInput = hashInput.replace(escapedSentinel, () => JSON.stringify(hashValue).slice(1, -1)) - } - const stateHash = createHash('sha1') - .update(protectedStorageDegraded ? 'safeStorage-degraded\0' : '') - .update(hashInput) - .digest('hex') + // Substitute each unique sentinel: ciphertext for the on-disk payload, a stable normalized + // value for the guard hash. One pass builds both, so the multi-MB state is never copied per + // sentinel and never encoded twice. + const { payload, stateHash } = applySecretSentinelSubstitutions( + serialized, + secretSubs, + protectedStorageDegraded ? 'safeStorage-degraded\0' : '' + ) return { payload, stateHash, protectedSecretUpdates } } } diff --git a/src/main/persistence/loading-store/state-write-round-trip.test.ts b/src/main/persistence/loading-store/state-write-round-trip.test.ts new file mode 100644 index 00000000000..ee7f26feb3e --- /dev/null +++ b/src/main/persistence/loading-store/state-write-round-trip.test.ts @@ -0,0 +1,131 @@ +/** + * The write path now hands the file a Buffer it built in one pass instead of a string it rebuilt + * per secret. Drives the real `Store` end to end — encrypted settings, a local session and a remote + * host partition — and reloads from the file it actually wrote, because the failure this guards + * against (a mis-sliced segment, a re-encoded payload, a dropped sentinel) is invisible until + * something reads the bytes back. + */ +import { mkdtempSync, readFileSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' + +vi.mock('electron', () => ({ + app: { + getPath: () => tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on: () => {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + // Encryption ON, so the secret slots really do mint sentinels and the substitution pass runs. + isEncryptionAvailable: () => true, + encryptString: (value: string) => Buffer.from(`enc:${value}`), + decryptString: (value: Buffer) => value.toString().slice(4) + }, + ipcMain: { on: () => {}, handle: () => {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +const { Store } = await import('./store') + +const HOST_ID = 'ssh:user@host' + +const stores: InstanceType[] = [] +afterEach(() => { + for (const store of stores.splice(0)) { + store.flush() + } + vi.restoreAllMocks() +}) + +function openStore(dataFile: string): InstanceType { + const store = new Store({ dataFile }) + stores.push(store) + return store +} + +function session(activeTabId: string): WorkspaceSessionState { + return { + activeRepoId: 'repo-1', + // Left null: the load path's deregistered-repo sweep nulls an active worktree whose repo is + // not registered, which would mask what this test is actually about. + activeWorktreeId: null, + activeTabId, + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + // Non-ASCII on purpose: a byte-offset mistake in the encode shows up here first. + browserUrlHistory: [ + { + url: 'https://example.test/é😀', + normalizedUrl: 'https://example.test/é😀', + title: '中文 title', + lastVisitedAt: 17, + visitCount: 3 + } + ] + } as WorkspaceSessionState +} + +describe('persisted state survives a save/load round trip', () => { + it('reloads settings, secrets and both session partitions unchanged', () => { + const dataFile = join( + realpathSync(mkdtempSync(join(tmpdir(), 'orca-store-round-trip-'))), + 'orca-data.json' + ) + const written = openStore(dataFile) + written.updateSettings({ + // Three secret slots, i.e. three sentinels in one save — the case the old loop paid 7 copies for. + opencodeSessionCookie: 'cookie-é-value', + httpProxyUrl: 'http://proxy.example:8080/?a=b&c=$&' + }) + written.updateUI({ browserKagiSessionLink: 'https://kagi.com/session?t=abc' }) + written.setWorkspaceSession(session('local-tab')) + written.setWorkspaceSession(session('remote-tab'), HOST_ID) + written.flush() + + const before = { + settings: written.getSettings(), + ui: written.getUI(), + local: written.getWorkspaceSession(), + remote: written.getWorkspaceSession(HOST_ID) + } + + // The file is valid UTF-8 JSON and holds ciphertext, not the plaintext secrets. + const bytes = readFileSync(dataFile) + const onDisk = JSON.parse(bytes.toString('utf8')) + expect(onDisk.settings.opencodeSessionCookie).not.toBe('cookie-é-value') + expect(Buffer.from(onDisk.settings.opencodeSessionCookie, 'base64').toString('utf8')).toContain( + 'cookie-é-value' + ) + expect(bytes.toString('utf8')).not.toContain('orca-secret-slot-') + + const reloaded = openStore(dataFile) + expect(reloaded.getSettings().opencodeSessionCookie).toBe(before.settings.opencodeSessionCookie) + expect(reloaded.getSettings().httpProxyUrl).toBe(before.settings.httpProxyUrl) + expect(reloaded.getUI().browserKagiSessionLink).toBe(before.ui.browserKagiSessionLink) + // `toMatchObject`: the load path spreads session defaults over what was written, so the + // reloaded slice is a superset. Exact deep equality is asserted on the second trip below. + expect(reloaded.getWorkspaceSession()).toMatchObject(before.local) + // The remote partition keeps everything it owns; only globals local already holds are dropped, + // and `browserUrlHistory` comes back at its default from the same spread as before. + expect(reloaded.getWorkspaceSession(HOST_ID).activeTabId).toBe('remote-tab') + expect(reloaded.getWorkspaceSession(HOST_ID).browserUrlHistory).toEqual([]) + + // Deep equality of the whole reloaded state, taken across a second round trip so the assertion + // is not comparing against the first load's one-time settings migrations. + reloaded.flush() + const bytesAfterReload = readFileSync(dataFile) + const again = openStore(dataFile) + expect(again.getSettings()).toEqual(reloaded.getSettings()) + expect(again.getUI()).toEqual(reloaded.getUI()) + expect(again.getWorkspaceSession()).toEqual(reloaded.getWorkspaceSession()) + expect(again.getWorkspaceSession(HOST_ID)).toEqual(reloaded.getWorkspaceSession(HOST_ID)) + // ...and the bytes are stable, so a quiet app is not rewriting a 4 MB file with new content. + again.flush() + expect(readFileSync(dataFile).equals(bytesAfterReload)).toBe(true) + }) +}) diff --git a/src/main/persistence/loading-store/workspace-session-partitions.test.ts b/src/main/persistence/loading-store/workspace-session-partitions.test.ts new file mode 100644 index 00000000000..f9697f77997 --- /dev/null +++ b/src/main/persistence/loading-store/workspace-session-partitions.test.ts @@ -0,0 +1,141 @@ +/** + * Global session fields live in the 'local' slice. Copies of them inside a non-local host partition + * are legacy residue: the split never writes them there and the merge never reads them from there + * unless local has nothing. These tests pin the drop to exactly that condition, keep the renderer's + * merge landing on the same value either way, and re-check the two safety gates that decide which + * global fields may be dropped at all. + */ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { BrowserHistoryEntry } from '../../../shared/browser-workspace-types' +import type { WorkspaceDocHistoryEntry } from '../../../shared/workspace-doc-history' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from '../../../shared/workspace-session-host-field-ownership' +import { WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND } from '../restoring-sessions/session-worktree-ownership' +import { + HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS, + parseWorkspaceSessionsByHostId +} from './workspace-session-partitions' + +const HOST = 'ssh:target-1' + +function history(url: string): BrowserHistoryEntry[] { + return [{ url, normalizedUrl: url, title: url, lastVisitedAt: 1, visitCount: 1 }] +} + +function docEntry(filePath: string): WorkspaceDocHistoryEntry { + return { + docLocation: { kind: 'workspace-doc', worktreeId: 'repo-1::/tmp/a', filePath }, + title: filePath, + lastVisitedAt: 2, + visitCount: 1 + } +} + +function localSession(overrides: Partial): WorkspaceSessionState { + return { ...getDefaultWorkspaceSession(), ...overrides } +} + +function parse( + raw: Record, + local?: WorkspaceSessionState +): Partial> { + return parseWorkspaceSessionsByHostId(raw, getDefaultWorkspaceSession(), local).partitions +} + +describe('HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS', () => { + it('only lists fields that are global AND that no worktree-ownership pass follows', () => { + for (const field of HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS) { + // Gate 1: the renderer's split/merge treat it as local-owned, so a non-local copy is dead. + expect(WORKSPACE_SESSION_FIELD_OWNERSHIP[field]).toBe('global') + // Gate 2: `collectPersistedSessionWorktreeOwners` and the deregistered-repo residue sweep + // walk EVERY partition through this table. Anything but 'none' means dropping the field + // could un-own a worktree and get its metadata pruned. + expect(WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND[field]).toBe('none') + } + }) +}) + +describe('parseWorkspaceSessionsByHostId global-field residue', () => { + it('drops a non-local global field the local slice already owns', () => { + const local = localSession({ browserUrlHistory: history('https://local.test') }) + const partitions = parse( + { + [HOST]: { + ...getDefaultWorkspaceSession(), + browserUrlHistory: history('https://stale.test') + } + }, + local + ) + // Back to the default from the spread, not the 65 KB stale replica. The merge reads this field + // from local whenever local has it, so the renderer still sees `https://local.test` + // (`workspace-session-host-split.test.ts` pins that half of the contract). + expect(partitions[HOST]?.browserUrlHistory).toEqual([]) + }) + + it('retains a non-local global field the local slice does NOT have', () => { + // `workspaceDocHistory` is optional and absent from the defaults, so local can genuinely lack + // it and the merge's fallback to another slice is live. + const local = localSession({}) + expect(local.workspaceDocHistory).toBeUndefined() + const docs = [docEntry('/repo/remote.md')] + const partitions = parse( + { [HOST]: { ...getDefaultWorkspaceSession(), workspaceDocHistory: docs } }, + local + ) + // Retained, so the merge's "fall back to any slice that has it" path still finds a value. + expect(partitions[HOST]?.workspaceDocHistory).toEqual(docs) + }) + + it('drops that same field once the local slice does have it', () => { + const localDocs = [docEntry('/repo/local.md')] + const local = localSession({ workspaceDocHistory: localDocs }) + const partitions = parse( + { + [HOST]: { + ...getDefaultWorkspaceSession(), + workspaceDocHistory: [docEntry('/repo/stale.md')] + } + }, + local + ) + expect(partitions[HOST]).not.toHaveProperty('workspaceDocHistory') + expect(local.workspaceDocHistory).toEqual(localDocs) + }) + + it('leaves worktree-referencing globals and host-owned fields alone', () => { + const local = localSession({ + browserUrlHistory: history('https://local.test'), + activeWorktreeId: 'repo-1::/tmp/local', + activeTabId: 'local-tab' + }) + const tabs = { 'repo-1::/tmp/a': [] } + const partitions = parse( + { + [HOST]: { + ...getDefaultWorkspaceSession(), + // A `'direct'` worktree reference the residue sweep reads out of every partition. + activeWorktreeId: 'repo-1::/tmp/a', + // Read on a partition by the mobile terminal projection. + activeTabId: 'remote-tab', + tabsByWorktree: tabs, + terminalTopologyRevisionByRepoId: { 'repo-1': 4 } + } + }, + local + ) + expect(partitions[HOST]?.activeWorktreeId).toBe('repo-1::/tmp/a') + expect(partitions[HOST]?.activeTabId).toBe('remote-tab') + expect(partitions[HOST]?.tabsByWorktree).toEqual(tabs) + expect(partitions[HOST]?.terminalTopologyRevisionByRepoId).toEqual({ 'repo-1': 4 }) + }) + + it('is a no-op when no local slice is supplied', () => { + const stale = history('https://stale.test') + const partitions = parse({ + [HOST]: { ...getDefaultWorkspaceSession(), browserUrlHistory: stale } + }) + expect(partitions[HOST]?.browserUrlHistory).toEqual(stale) + }) +}) diff --git a/src/main/persistence/loading-store/workspace-session-partitions.ts b/src/main/persistence/loading-store/workspace-session-partitions.ts index 50e9d4ae8cc..b6f78479dbf 100644 --- a/src/main/persistence/loading-store/workspace-session-partitions.ts +++ b/src/main/persistence/loading-store/workspace-session-partitions.ts @@ -17,11 +17,49 @@ export function workspaceSessionSalvageLogDetails(result: { } } +/** + * Global fields belong to the 'local' slice: the split writes them only there and the merge reads + * them only from there. A copy inside a non-local partition is legacy residue no read can reach — + * stale `browserUrlHistory` replicas alone were 589 KB, 12.7% of a 4.65 MB store, rewritten on + * every save and reparsed on every launch. + * + * Deliberately NOT every field in `GLOBAL_WORKSPACE_SESSION_FIELDS`. Two separate gates disqualify + * the rest, and both are load-bearing: + * - `activeWorktreeId` and `activeWorkspaceKey` are `'direct'` in + * `WORKSPACE_SESSION_WORKTREE_REFERENCE_KIND`, and both `collectPersistedSessionWorktreeOwners` + * and the deregistered-repo residue sweep read them out of EVERY partition. Dropping one + * un-owns a worktree, and an un-owned worktree gets its metadata pruned. + * - `activeTabId`, `activeConnectionIdsAtShutdown` and `activeRepoId` have live main-side readers + * on a partition: `isPersistedTerminalLeafActive` falls back to `activeTabId` for the mobile + * projection, and the runtime attach-window handoff unions `activeConnectionIdsAtShutdown`. + * + * `workspace-session-partitions.test.ts` re-checks both gates for every field listed here. + */ +export const HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS = [ + 'browserUrlHistory', + 'workspaceDocHistory' +] as const satisfies readonly (keyof WorkspaceSessionState)[] + +/** Dropped only where local already holds the field — exactly when the merge's fallback to another + * slice cannot fire. Runs before the defaults spread, so a field the type requires comes back at + * its default rather than going missing. */ +function dropRedundantGlobalFields( + slice: Partial, + local: WorkspaceSessionState | undefined +): void { + for (const field of HOST_PARTITION_REDUNDANT_GLOBAL_FIELDS) { + if (local?.[field] !== undefined) { + delete slice[field] + } + } +} + /** Normalize non-'local' host partitions; 'local' (the legacy workspaceSession blob) is dropped so the two surfaces never diverge. * Each partition is zod-validated independently, so one corrupt host drops to defaults without taking out the others. Idempotent. */ export function parseWorkspaceSessionsByHostId( raw: unknown, - defaults: WorkspaceSessionState + defaults: WorkspaceSessionState, + localSession?: WorkspaceSessionState ): { partitions: Partial>; repaired: boolean } { if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { return { partitions: {}, repaired: raw !== undefined } @@ -50,6 +88,7 @@ export function parseWorkspaceSessionsByHostId( ) repaired = true } + dropRedundantGlobalFields(result.value, localSession) partitions[hostId] = { ...defaults, ...result.value } } return { partitions, repaired } diff --git a/src/main/providers/agent-foreground-process.ts b/src/main/providers/agent-foreground-process.ts index d244e0100dc..2000d35261c 100644 --- a/src/main/providers/agent-foreground-process.ts +++ b/src/main/providers/agent-foreground-process.ts @@ -1,7 +1,9 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process-recognition' import { resolveOuterWrapperForegroundProcess } from '../../shared/foreground-wrapper-agent' import { + collectDescendantsFromIndex, getFreshProcessTableSnapshot, + getProcessTableIndex, getProcessTableSnapshot, type ProcessTableRow } from '../../shared/process-table-snapshot' @@ -43,29 +45,6 @@ type ShellForegroundConfirmationOptions = { | Promise | null> } -function collectDescendants( - rows: Row[], - rootPid: number -): (Row & { depth: number })[] { - const childrenByParent = new Map() - for (const row of rows) { - const children = childrenByParent.get(row.ppid) ?? [] - children.push(row) - childrenByParent.set(row.ppid, children) - } - - const descendants: (Row & { depth: number })[] = [] - const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 })) - while (stack.length > 0) { - const { row, depth } = stack.pop()! - descendants.push({ ...row, depth }) - for (const child of childrenByParent.get(row.pid) ?? []) { - stack.push({ row: child, depth: depth + 1 }) - } - } - return descendants -} - function commandExecutable(command: string): string { const trimmed = command.trim().replace(/^[-]/, '') if (trimmed.startsWith('"') || trimmed.startsWith("'")) { @@ -97,12 +76,12 @@ export async function confirmShellForegroundProcess( } } try { - const rows = await getFreshProcessTableSnapshot() - if (!rows.some((row) => row.pid === shellPid)) { + const index = getProcessTableIndex(await getFreshProcessTableSnapshot()) + const root = index.byPid.get(shellPid) + if (!root) { return false } - const root = rows.find((row) => row.pid === shellPid)! - const tree = [{ ...root, depth: 0 }, ...collectDescendants(rows, shellPid)] + const tree = [{ ...root, depth: 0 }, ...collectDescendantsFromIndex(index, shellPid)] const spawnedShellBasename = executableBasename(spawnedShellProcess) const foregroundShell = tree .filter( @@ -172,7 +151,7 @@ export async function resolveAgentForegroundProcessWithAvailability( const rows = options.fresh ? await getFreshProcessTableSnapshot() : await getProcessTableSnapshot() - if (options.fresh && !rows.some((row) => row.pid === shellPid)) { + if (options.fresh && !getProcessTableIndex(rows).byPid.has(shellPid)) { return { available: false, processName: fallbackProcess } } return { @@ -186,11 +165,13 @@ export async function resolveAgentForegroundProcessWithAvailability( } export function resolveAgentForegroundProcessFromPs( - rows: ProcessTableRow[], + rows: readonly ProcessTableRow[], shellPid: number ): string | null { - const shellRow = rows.find((row) => row.pid === shellPid) - const candidates = collectDescendants(rows, shellPid) + // Memoized per snapshot identity, so the caller's own index build is reused. + const index = getProcessTableIndex(rows) + const shellRow = index.byPid.get(shellPid) + const candidates = collectDescendantsFromIndex(index, shellPid) // Why: `+` in `ps stat` marks the process holding the terminal foreground. // The root shell can hold it after Ctrl-Z, so use the whole PTY tree as the // foreground gate; otherwise a stopped agent child still masquerades as live. diff --git a/src/main/providers/windows-foreground-process-inspection-cost.test.ts b/src/main/providers/windows-foreground-process-inspection-cost.test.ts new file mode 100644 index 00000000000..f08d1b690bb --- /dev/null +++ b/src/main/providers/windows-foreground-process-inspection-cost.test.ts @@ -0,0 +1,156 @@ +// Regression guard on the per-inspection cost of Windows agent foreground +// inspection — the Windows analogue of the POSIX index memo (#6288). +// +// The shared TTL cache already collapses N panes into one Toolhelp32 snapshot +// (windows-agent-foreground-process-scan-volume.test.ts). What it never +// collapsed is the work each pane does ON that snapshot: a full +// `native.map(toProcessRow)` projection, a `childrenByPpid` Map rebuilt from +// scratch, and two linear scans. This file counts that work at a realistic +// table size and pane count, and pins the flag set the snapshot asks for. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { __setWindowsProcessTreeLoaderForTests } from '../windows/windows-process-table' +import { + queryWindowsPaneProcessInventory, + resetWindowsProcessRowsSnapshotForTests +} from './windows-foreground-process-rows' + +// 1050 processes is the host measured in windows-process-enumeration.md; 11 +// panes is the fan-out the shared snapshot exists to serve. +const TABLE_SIZE = 1050 +const PANE_COUNT = 11 + +const SELF_ROW = { pid: process.pid, ppid: 0, name: 'vitest.exe', commandLine: 'vitest' } + +const shellPid = (pane: number): number => 10_000 + pane * 10 +const agentPid = (pane: number): number => shellPid(pane) + 1 +/** A row every pane can look up, so distinct results == distinct projections. */ +const PROBE_PID = 900_000 + TABLE_SIZE - 1 + +/** One shell + one agent child per pane, padded out to a real table size. */ +function buildNativeTable(): { pid: number; ppid: number; name: string; commandLine: string }[] { + const rows = [SELF_ROW] + for (let pane = 0; pane < PANE_COUNT; pane += 1) { + rows.push({ pid: shellPid(pane), ppid: 4, name: 'cmd.exe', commandLine: 'cmd.exe' }) + rows.push({ + pid: agentPid(pane), + ppid: shellPid(pane), + name: 'node.exe', + commandLine: 'node C:/Users/dev/AppData/codex/bin/codex.js' + }) + } + for (let filler = rows.length; filler < TABLE_SIZE; filler += 1) { + rows.push({ pid: 900_000 + filler, ppid: 4, name: 'svchost.exe', commandLine: 'svchost.exe' }) + } + return rows +} + +const NATIVE_TABLE = buildNativeTable() + +/** + * Count `Map.prototype.set` calls — the primitive both the old per-call + * `childrenByPpid` rebuild and the shared index build are made of. Patched for + * one awaited region and restored in `finally`, so nothing else observes it. + */ +async function countMapInsertions(run: () => Promise): Promise { + const original = Map.prototype.set + let insertions = 0 + Map.prototype.set = function patched(this: Map, key: unknown, value: unknown) { + insertions += 1 + return original.call(this, key, value) + } as typeof Map.prototype.set + try { + await run() + } finally { + Map.prototype.set = original + } + return insertions +} + +describe('windows foreground inspection cost per pane', () => { + const getAllProcesses = vi.fn() + let platform: PropertyDescriptor | undefined + let flagsSeen: number[] = [] + + beforeEach(() => { + flagsSeen = [] + getAllProcesses.mockReset() + getAllProcesses.mockImplementation((cb: (rows: unknown) => void, flags: number) => { + flagsSeen.push(flags) + cb(NATIVE_TABLE) + }) + platform = Object.getOwnPropertyDescriptor(process, 'platform') + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + __setWindowsProcessTreeLoaderForTests(() => ({ + ProcessDataFlag: { None: 0, Memory: 1, CommandLine: 2, CreationTime: 4 }, + getAllProcesses + })) + resetWindowsProcessRowsSnapshotForTests() + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(0) + }) + + afterEach(() => { + vi.useRealTimers() + __setWindowsProcessTreeLoaderForTests() + if (platform) { + Object.defineProperty(process, 'platform', platform) + } + }) + + async function sweepPanes(): Promise<(number | undefined)[]> { + const resolved: (number | undefined)[] = [] + for (let pane = 0; pane < PANE_COUNT; pane += 1) { + const inventory = await queryWindowsPaneProcessInventory(shellPid(pane), { + anchorPid: agentPid(pane) + }) + expect(inventory?.candidates).toHaveLength(1) + resolved.push(inventory?.candidates[0]?.pid) + } + return resolved + } + + it('never sets the Memory flag on the snapshot', async () => { + await queryWindowsPaneProcessInventory(shellPid(0)) + expect(flagsSeen).toHaveLength(1) + // Memory is bit 0, and it costs the addon a second OpenProcess per process + // carrying PROCESS_VM_READ (process.cc `GetProcessMemoryUsage`). + expect(flagsSeen[0]! & 1).toBe(0) + // CommandLine (2) | CreationTime (4). + expect(flagsSeen[0]).toBe(6) + }) + + it('projects the shared snapshot once for the whole pane fan-out', async () => { + const probeRows: unknown[] = [] + for (let pane = 0; pane < PANE_COUNT; pane += 1) { + const inventory = await queryWindowsPaneProcessInventory(shellPid(pane), { + anchorPid: PROBE_PID + }) + probeRows.push(inventory?.anchorRow) + } + expect(probeRows.filter(Boolean)).toHaveLength(PANE_COUNT) + // One projection produced every pane's row object. Pre-fix each pane ran + // its own `native.map(toProcessRow)` over all 1050 rows, so this set held + // PANE_COUNT distinct objects and the sweep allocated PANE_COUNT * 1050. + expect(new Set(probeRows).size).toBe(1) + }) + + it('indexes the shared snapshot once for the whole pane fan-out', async () => { + // Prime the TTL cache and the index so the snapshot read is not in the count. + await queryWindowsPaneProcessInventory(shellPid(0), { anchorPid: agentPid(0) }) + + const insertions = await countMapInsertions(async () => { + await sweepPanes() + }) + + // Pre-fix every pane rebuilt a whole-table `childrenByPpid`, so this was + // >= PANE_COUNT * (rows with a distinct ppid). One shared index makes the + // whole sweep cost no table-sized Map build at all. + expect(insertions).toBeLessThan(TABLE_SIZE) + }) + + it('resolves the same foreground child for every pane as an unshared scan would', async () => { + const resolved = await sweepPanes() + expect(resolved).toEqual(Array.from({ length: PANE_COUNT }, (_, pane) => agentPid(pane))) + }) +}) diff --git a/src/main/providers/windows-foreground-process-rows.ts b/src/main/providers/windows-foreground-process-rows.ts index d702103871e..4ee6a9dabac 100644 --- a/src/main/providers/windows-foreground-process-rows.ts +++ b/src/main/providers/windows-foreground-process-rows.ts @@ -1,3 +1,7 @@ +import { + collectDescendantsFromIndex, + getProcessTableIndex +} from '../../shared/process-table-snapshot' import { readWindowsProcessTable, readWindowsProcessTableFresh, @@ -25,15 +29,40 @@ function toProcessRow(row: NativeWindowsProcessRow): WindowsProcessRow { } } +/** + * One projection per snapshot identity, mirroring `getProcessTableIndex`. + * + * The TTL cache already gives every pane the same native rows array; without + * this each of them still rebuilt ~1050 row objects, which also handed + * `getProcessTableIndex` a new array each time and defeated its memo by + * construction. Keyed weakly, so a projection dies with its snapshot. Rows are + * shared, never mutated: descendants are copied with their depth, and + * `anchorRow` is read-only to every caller. + */ +const projectedRows = new WeakMap() + +function projectProcessRows(native: readonly NativeWindowsProcessRow[]): WindowsProcessRow[] { + const cached = projectedRows.get(native) + if (cached) { + return cached + } + const rows = native.map(toProcessRow) + projectedRows.set(native, rows) + return rows +} + /** * Rows from a scan that starts after this call. * * PID-identity checks in teardown must not reuse a cached row — it can predate * the very recycle it is meant to detect. Rejects when the table is unreadable, * so "unavailable" stays distinguishable from "nothing is running". + * + * `readonly` because the projection is shared with every other reader of the + * same snapshot. */ -export async function queryWindowsProcessRowsFresh(): Promise { - return (await readWindowsProcessTableFresh()).map(toProcessRow) +export async function queryWindowsProcessRowsFresh(): Promise { + return projectProcessRows(await readWindowsProcessTableFresh()) } export async function queryWindowsProcessDescendants( @@ -63,21 +92,22 @@ export async function queryWindowsPaneProcessInventory( options.fresh === true ? await readWindowsProcessTableFresh() : await readWindowsProcessTable() - rows = native.map(toProcessRow) + rows = projectProcessRows(native) } catch { return null } + // One index per snapshot, shared by every pane inspecting inside the TTL + // window: `byPid` answers both lookups that used to be linear scans, and + // `childrenByPpid` replaces a per-call Map rebuild over the whole table. + const index = getProcessTableIndex(rows) // Why: a snapshot that omitted the PTY root may be stale or permission- // filtered; only an observed root can authoritatively have no descendants. - if (!rows.some((row) => row.pid === rootPid)) { + if (!index.byPid.has(rootPid)) { return null } return { - candidates: collectDescendants(rows, rootPid).sort((a, b) => b.depth - a.depth), - anchorRow: - options.anchorPid !== undefined - ? (rows.find((row) => row.pid === options.anchorPid) ?? null) - : null + candidates: collectDescendantsFromIndex(index, rootPid).sort((a, b) => b.depth - a.depth), + anchorRow: options.anchorPid !== undefined ? (index.byPid.get(options.anchorPid) ?? null) : null } } @@ -104,26 +134,3 @@ export function windowsDescendantsFromRows( - rows: Row[], - rootPid: number -): (Row & { depth: number })[] { - const childrenByParent = new Map() - for (const row of rows) { - const children = childrenByParent.get(row.ppid) ?? [] - children.push(row) - childrenByParent.set(row.ppid, children) - } - - const descendants: (Row & { depth: number })[] = [] - const stack = (childrenByParent.get(rootPid) ?? []).map((row) => ({ row, depth: 1 })) - while (stack.length > 0) { - const { row, depth } = stack.pop()! - descendants.push({ ...row, depth }) - for (const child of childrenByParent.get(row.pid) ?? []) { - stack.push({ row: child, depth: depth + 1 }) - } - } - return descendants -} diff --git a/src/main/runtime/decorative-title-fact-emission.test.ts b/src/main/runtime/decorative-title-fact-emission.test.ts new file mode 100644 index 00000000000..609f2e06084 --- /dev/null +++ b/src/main/runtime/decorative-title-fact-emission.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vitest' +import { + DECORATIVE_TITLE_FACT_HEARTBEAT_MS, + shouldEmitTitleFactForFrame +} from './decorative-title-fact-emission' + +const base = { + decorativeOnly: true, + staleWorkingTitleClear: false, + lastEmittedAtMs: 1_000, + nowMs: 1_000 +} + +describe('shouldEmitTitleFactForFrame', () => { + it('always emits a frame that is not a decorative repeat', () => { + expect(shouldEmitTitleFactForFrame({ ...base, decorativeOnly: false })).toBe(true) + }) + + it('emits the first frame of a pane', () => { + expect(shouldEmitTitleFactForFrame({ ...base, lastEmittedAtMs: null })).toBe(true) + }) + + it('suppresses a decorative repeat inside the heartbeat window', () => { + expect( + shouldEmitTitleFactForFrame({ ...base, nowMs: 1_000 + DECORATIVE_TITLE_FACT_HEARTBEAT_MS - 1 }) + ).toBe(false) + }) + + it('lets a decorative repeat through once the heartbeat window elapses', () => { + expect( + shouldEmitTitleFactForFrame({ ...base, nowMs: 1_000 + DECORATIVE_TITLE_FACT_HEARTBEAT_MS }) + ).toBe(true) + }) + + it('never throttles a timer-synthesized stale-working clear', () => { + // Why: it carries a staleWorkingTitleClear flag no earlier repeat can stand in for. + expect(shouldEmitTitleFactForFrame({ ...base, staleWorkingTitleClear: true })).toBe(true) + }) + + it('emits after a backwards clock step instead of parking until it catches up', () => { + expect(shouldEmitTitleFactForFrame({ ...base, nowMs: 900 })).toBe(true) + }) + + it('keeps at least three frames inside the renderer hook-done quiet window', () => { + // Why: observeTitle's arriving working title is what cancels a Pi/OMP milestone `done` + // scheduled with HOOK_DONE_QUIET_MS = 1500. Losing that would mint a false completion. + expect(DECORATIVE_TITLE_FACT_HEARTBEAT_MS * 3).toBeLessThanOrEqual(1_500) + }) +}) diff --git a/src/main/runtime/decorative-title-fact-emission.ts b/src/main/runtime/decorative-title-fact-emission.ts new file mode 100644 index 00000000000..d8248dc12c5 --- /dev/null +++ b/src/main/runtime/decorative-title-fact-emission.ts @@ -0,0 +1,38 @@ +/** + * Why: an agent spinner re-emits a semantically identical OSC title ~12.5x/sec (Orca's own + * synthetic frame timer, Pi/OMP, Claude Code, Grok), and main ships every frame to the renderer + * as its own `pty:sideEffect` message. Both renderer store writes already discard those frames + * via `isDecorativeAgentTitleFrameChange`, so the message is pure cross-process cost. + * + * Why a heartbeat and not a hard drop: `agentCompletionCoordinator.observeTitle` treats an + * arriving *working* title as "still working" and cancels a scheduled hook-`done` completion + * inside `HOOK_DONE_QUIET_MS` (1500ms). That is exactly how a Pi/OMP milestone `done` emitted + * mid-turn is stopped from minting a completion notification, and the frames that carry it are + * decorative repeats. 500ms keeps 3 frames inside that window. + */ +export const DECORATIVE_TITLE_FACT_HEARTBEAT_MS = 500 + +export type DecorativeTitleFactEmissionInput = { + /** The frame's decorative gate key matches the previous frame's. */ + decorativeOnly: boolean + /** Timer-synthesized stale-working clear — carries a flag no repeat can stand in for. */ + staleWorkingTitleClear: boolean + lastEmittedAtMs: number | null + nowMs: number +} + +export function shouldEmitTitleFactForFrame({ + decorativeOnly, + staleWorkingTitleClear, + lastEmittedAtMs, + nowMs +}: DecorativeTitleFactEmissionInput): boolean { + if (!decorativeOnly || staleWorkingTitleClear) { + return true + } + if (lastEmittedAtMs === null) { + return true + } + // A backwards clock step must not park the heartbeat until it catches up. + return nowMs < lastEmittedAtMs || nowMs - lastEmittedAtMs >= DECORATIVE_TITLE_FACT_HEARTBEAT_MS +} diff --git a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts index 38bae372176..3ece144bc24 100644 --- a/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts +++ b/src/main/runtime/orca-runtime-get-unpersisted-tracked-title-for-pty.ts @@ -1,6 +1,7 @@ // @ts-nocheck -- mechanically split from OrcaRuntimeService; behavior is covered by AST equivalence and characterization tests. import { OrcaRuntimeWithEmitDaemonPtyTransientFact } from './orca-runtime-emit-daemon-pty-transient-fact' import { getDecorativeAgentTitleSignature } from '../../shared/agent-decorative-title-signature' +import { shouldEmitTitleFactForFrame } from './decorative-title-fact-emission' import type { RuntimePtyTitleTrackerEntry } from './runtime-terminal-state-records' import { createTerminalTitleTracker } from '../../shared/terminal-output-side-effects' import { detectAgentStatusFromTitle } from '../../shared/agent-detection' @@ -64,20 +65,36 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime const tracker = createTerminalTitleTracker( { onTitle: (normalizedTitle, rawTitle, meta) => { - this.recordTerminalSideEffectFact(ptyId, { - kind: 'title', - normalizedTitle, - rawTitle, - ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) - }) - const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta) - const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta) const live = this.ptyTitleTrackersByPtyId.get(ptyId) const gateKey = this.makeDecorativeTitleGateKey(rawTitle, normalizedTitle) const decorativeOnly = live?.lastMobileTitleGateKey === gateKey if (live) { live.lastMobileTitleGateKey = gateKey } + // Why: the same gate the mobile fan-out below already uses, applied one hop earlier — + // a spinner frame the renderer store discards should not cost a pty:sideEffect message + // at all. See decorative-title-fact-emission.ts for why repeats still heartbeat. + const nowMs = Date.now() + if ( + shouldEmitTitleFactForFrame({ + decorativeOnly, + staleWorkingTitleClear: meta?.staleWorkingTitleClear === true, + lastEmittedAtMs: live?.lastTitleFactAtMs ?? null, + nowMs + }) + ) { + if (live) { + live.lastTitleFactAtMs = nowMs + } + this.recordTerminalSideEffectFact(ptyId, { + kind: 'title', + normalizedTitle, + rawTitle, + ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) + }) + } + const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle, meta) + const identityOnlyTitle = this.isLiveCursorNativeTitle(rawTitle, meta) const tracksReplicatedStatus = live?.applyingChunk === true && this.mobileSessionTabListeners.size > 0 const titleStatus = tracksReplicatedStatus ? detectAgentStatusFromTitle(rawTitle) : null @@ -151,6 +168,7 @@ export class OrcaRuntimeWithGetUnpersistedTrackedTitleForPty extends OrcaRuntime tracker, applyingChunk: false, lastMobileTitleGateKey: null, + lastTitleFactAtMs: null, chunkTouchedSessionTabs: false, pendingFacts: [], // Why: command-code facts exist only for the pty:sideEffect channel — diff --git a/src/main/runtime/orca-runtime-tests/decorative-title-fact-throttle.spec.ts b/src/main/runtime/orca-runtime-tests/decorative-title-fact-throttle.spec.ts new file mode 100644 index 00000000000..a2e35baa4aa --- /dev/null +++ b/src/main/runtime/orca-runtime-tests/decorative-title-fact-throttle.spec.ts @@ -0,0 +1,110 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalSideEffectBatch } from '../../../shared/terminal-side-effect-facts' +import { syncSinglePty } from '../orca-runtime-test-fixtures.spec' +import { createSideEffectRuntime } from '../orca-runtime-test-scenario-builders.spec' +import { DECORATIVE_TITLE_FACT_HEARTBEAT_MS } from '../decorative-title-fact-emission' + +// Orca's own synthetic agent spinner: one frame per pane every 80ms while an agent works. +const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] +const SPINNER_INTERVAL_MS = 80 +const EPOCH = 1_700_000_000_000 + +type TitleFact = { kind: 'title'; normalizedTitle: string; rawTitle: string } + +function titleFacts(batches: TerminalSideEffectBatch[]): TitleFact[] { + return batches.flatMap((batch) => + batch.facts.filter((fact): fact is TitleFact => fact.kind === 'title') + ) +} + +describe('decorative title fact throttle', () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }) + vi.setSystemTime(new Date(EPOCH)) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('collapses spinner ticks with an unchanged underlying title to the heartbeat rate', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + const ticks = 125 // 10s of Orca's 80ms synthetic spinner timer + for (let tick = 0; tick < ticks; tick += 1) { + vi.setSystemTime(new Date(EPOCH + tick * SPINNER_INTERVAL_MS)) + runtime.ingestSyntheticTitleFrame( + 'pty-1', + `\x1b]0;${SPINNER_FRAMES[tick % SPINNER_FRAMES.length]} Claude Code\x07` + ) + } + + const facts = titleFacts(batches) + // Every frame carried the same underlying title, so the renderer learns nothing new past + // the heartbeat: 125 pty:sideEffect messages collapse to one per heartbeat window. + const elapsedMs = ticks * SPINNER_INTERVAL_MS + expect(facts.length).toBeLessThanOrEqual( + Math.ceil(elapsedMs / DECORATIVE_TITLE_FACT_HEARTBEAT_MS) + ) + expect(facts.length).toBeLessThan(ticks / 5) + // The heartbeat must not thin out below what the renderer's 1500ms hook-done quiet window + // needs to cancel a milestone `done` — three working frames per window. + expect(facts.length).toBeGreaterThanOrEqual(Math.floor(elapsedMs / 1_500) * 3) + for (const fact of facts) { + expect(fact.normalizedTitle.endsWith('Claude Code')).toBe(true) + } + }) + + it('propagates a real title change on the tick it arrives, mid-heartbeat', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07') + // Two more decorative ticks — still well inside the heartbeat window, so they are dropped. + vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS)) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Claude Code\x07') + vi.setSystemTime(new Date(EPOCH + 2 * SPINNER_INTERVAL_MS)) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠹ Claude Code\x07') + expect(titleFacts(batches)).toHaveLength(1) + + const beforeChange = batches.length + vi.setSystemTime(new Date(EPOCH + 3 * SPINNER_INTERVAL_MS)) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;✳ Claude Code\x07') + + expect(batches.length).toBeGreaterThan(beforeChange) + expect(titleFacts(batches.slice(beforeChange))).toEqual([ + { kind: 'title', normalizedTitle: '✳ Claude Code', rawTitle: '✳ Claude Code' } + ]) + }) + + it('propagates a changed working label immediately even while the spinner rotates', () => { + // Why: only the spinner glyph is decoration. Grok/Pi-style label churn is real content. + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07') + vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS)) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Reviewing diff — Claude Code\x07') + + expect(titleFacts(batches).map((fact) => fact.rawTitle)).toEqual([ + '⠋ Claude Code', + '⠙ Reviewing diff — Claude Code' + ]) + }) + + it('keeps main-side tracked title state current for every suppressed frame', () => { + // Why: mobile/remote snapshots read the tracked record, not the fact stream — suppressing + // the fact must not freeze what a phone or a paired client is shown. + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude Code\x07') + vi.setSystemTime(new Date(EPOCH + SPINNER_INTERVAL_MS)) + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠙ Claude Code\x07') + + expect(runtime.getTerminalSideEffectSnapshot('pty-1')?.facts).toEqual([ + { kind: 'title', normalizedTitle: '⠙ Claude Code', rawTitle: '⠙ Claude Code' } + ]) + }) +}) diff --git a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts index fb07f8a3d0e..79feca3268c 100644 --- a/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts +++ b/src/main/runtime/orca-runtime-tests/terminal-side-effect-facts.spec.ts @@ -8,6 +8,7 @@ import { syncSinglePty } from '../orca-runtime-test-fixtures.spec' import { createSideEffectRuntime } from '../orca-runtime-test-scenario-builders.spec' +import { DECORATIVE_TITLE_FACT_HEARTBEAT_MS } from '../decorative-title-fact-emission' describe('terminal side-effect fact channel', () => { it('defers desktop-only output scanners until a headless runtime is promoted', () => { @@ -70,53 +71,66 @@ describe('terminal side-effect fact channel', () => { expect(events).toHaveLength(1) }) - it('bounds decorative title delivery per paired client without reducing local frames', () => { - const { runtime, batches } = createSideEffectRuntime() - const firstClientEvents: RuntimeClientEvent[] = [] - runtime.attachWindow(1) - runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) - runtime.onClientEvent((event) => firstClientEvents.push(event)) + it('bounds decorative title delivery per paired client below the local heartbeat', () => { + // Why the clock steps: main throttles decorative repeats on the local fact stream, so each + // round must clear that heartbeat for the per-client gate to be what collapses them here. + vi.useFakeTimers({ toFake: ['Date'] }) + try { + const { runtime, batches } = createSideEffectRuntime() + const firstClientEvents: RuntimeClientEvent[] = [] + runtime.attachWindow(1) + runtime.syncWindowGraph(1, { tabs: [], leaves: [] }) + runtime.onClientEvent((event) => firstClientEvents.push(event)) - const ptyIds = Array.from({ length: 64 }, (_, index) => `pty-remote-${index}`) - const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] - for (const ptyId of ptyIds) { - runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`) - } - firstClientEvents.length = 0 - - for (const frame of frames.slice(1)) { - for (const ptyId of ptyIds) { - runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frame} Cursor Agent\x07`) + const ptyIds = Array.from({ length: 64 }, (_, index) => `pty-remote-${index}`) + const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] + const stepPastHeartbeat = (): void => { + vi.setSystemTime(new Date(Date.now() + DECORATIVE_TITLE_FACT_HEARTBEAT_MS)) } + for (const ptyId of ptyIds) { + runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`) + } + firstClientEvents.length = 0 + + for (const frame of frames.slice(1)) { + stepPastHeartbeat() + for (const ptyId of ptyIds) { + runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frame} Cursor Agent\x07`) + } + } + + expect(firstClientEvents).toEqual([]) + expect(batches).toHaveLength(ptyIds.length * frames.length) + + const bellChunk = `\x1b]0;${frames.at(-1)} Cursor Agent\x07\x07` + runtime.onPtyData(ptyIds[0], bellChunk, 1) + expect(firstClientEvents).toEqual([ + expect.objectContaining({ + type: 'terminalSideEffects', + batch: expect.objectContaining({ facts: [{ kind: 'bell' }] }) + }) + ]) + firstClientEvents.length = 0 + + const secondClientEvents: RuntimeClientEvent[] = [] + runtime.onClientEvent((event) => secondClientEvents.push(event)) + stepPastHeartbeat() + for (const ptyId of ptyIds) { + runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`) + } + + expect(firstClientEvents).toEqual([]) + expect(secondClientEvents).toHaveLength(ptyIds.length) + + // A real title change is never throttled — no clock step needed. + for (const ptyId of ptyIds) { + runtime.ingestSyntheticTitleFrame(ptyId, '\x1b]0;Cursor ready\x07') + } + expect(firstClientEvents).toHaveLength(ptyIds.length) + expect(secondClientEvents).toHaveLength(ptyIds.length * 2) + } finally { + vi.useRealTimers() } - - expect(firstClientEvents).toEqual([]) - expect(batches).toHaveLength(ptyIds.length * frames.length) - - const bellChunk = `\x1b]0;${frames.at(-1)} Cursor Agent\x07\x07` - runtime.onPtyData(ptyIds[0], bellChunk, 1) - expect(firstClientEvents).toEqual([ - expect.objectContaining({ - type: 'terminalSideEffects', - batch: expect.objectContaining({ facts: [{ kind: 'bell' }] }) - }) - ]) - firstClientEvents.length = 0 - - const secondClientEvents: RuntimeClientEvent[] = [] - runtime.onClientEvent((event) => secondClientEvents.push(event)) - for (const ptyId of ptyIds) { - runtime.ingestSyntheticTitleFrame(ptyId, `\x1b]0;${frames[0]} Cursor Agent\x07`) - } - - expect(firstClientEvents).toEqual([]) - expect(secondClientEvents).toHaveLength(ptyIds.length) - - for (const ptyId of ptyIds) { - runtime.ingestSyntheticTitleFrame(ptyId, '\x1b]0;Cursor ready\x07') - } - expect(firstClientEvents).toHaveLength(ptyIds.length) - expect(secondClientEvents).toHaveLength(ptyIds.length * 2) }) it('omits terminalSideEffects from non-consuming listeners while other events still flow', () => { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 054d7f09316..f8b12ba4a9d 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -30,6 +30,7 @@ await import('./orca-runtime-tests/pty-title-status.spec') await import('./orca-runtime-tests/terminal-side-effect-facts.spec') await import('./orca-runtime-tests/terminal-side-effect-facts-part-02.spec') await import('./orca-runtime-tests/terminal-side-effect-facts-part-03.spec') +await import('./orca-runtime-tests/decorative-title-fact-throttle.spec') await import('./orca-runtime-tests/headless-snapshots.spec') await import('./orca-runtime-tests/headless-snapshots-part-02.spec') await import('./orca-runtime-tests/agent-status-and-waits.spec') diff --git a/src/main/runtime/runtime-terminal-state-records.ts b/src/main/runtime/runtime-terminal-state-records.ts index 68be05b8ad1..6ccb4ed82bb 100644 --- a/src/main/runtime/runtime-terminal-state-records.ts +++ b/src/main/runtime/runtime-terminal-state-records.ts @@ -89,6 +89,8 @@ export type RuntimePtyTitleTrackerEntry = { tracker: TerminalTitleTracker applyingChunk: boolean lastMobileTitleGateKey: string | null + /** When the last title fact was emitted — throttles decorative-only repeats. */ + lastTitleFactAtMs: number | null chunkTouchedSessionTabs: boolean pendingFacts: TerminalSideEffectFact[] commandCodeDetector: { observe: (data: string) => boolean } | null diff --git a/src/main/runtime/structured-tui-process-identity.test.ts b/src/main/runtime/structured-tui-process-identity.test.ts index e85294611f3..4324f8c5a26 100644 --- a/src/main/runtime/structured-tui-process-identity.test.ts +++ b/src/main/runtime/structured-tui-process-identity.test.ts @@ -60,8 +60,18 @@ describe('structured TUI process identity', () => { platform: 'darwin', readPosixRows: async () => [ { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, - { pid: 101, ppid: 100, stat: 'S+', command: 'node /opt/codex/bin/codex resume abc' }, - { pid: 102, ppid: 101, stat: 'S+', command: '/opt/codex/vendor/codex' } + { + pid: 101, + ppid: 100, + stat: 'S+', + command: 'node /opt/codex/bin/codex resume abc' + }, + { + pid: 102, + ppid: 101, + stat: 'S+', + command: '/opt/codex/vendor/codex' + } ], readStartTime }) @@ -83,9 +93,27 @@ describe('structured TUI process identity', () => { agent: 'codex', platform: 'win32', readWindowsRows: async () => [ - { pid: 100, ppid: 1, name: 'pwsh.exe', command: 'pwsh.exe', executablePath: '' }, - { pid: 101, ppid: 100, name: 'codex.exe', command: 'codex resume a', executablePath: '' }, - { pid: 102, ppid: 100, name: 'codex.exe', command: 'codex resume b', executablePath: '' } + { + pid: 100, + ppid: 1, + name: 'pwsh.exe', + command: 'pwsh.exe', + executablePath: '' + }, + { + pid: 101, + ppid: 100, + name: 'codex.exe', + command: 'codex resume a', + executablePath: '' + }, + { + pid: 102, + ppid: 100, + name: 'codex.exe', + command: 'codex resume b', + executablePath: '' + } ], timeoutMs: 0 }) @@ -107,7 +135,14 @@ describe('structured TUI process identity', () => { return [ { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, ...(snapshots >= 3 - ? [{ pid: 101, ppid: 100, stat: 'S+', command: 'codex resume session-1' }] + ? [ + { + pid: 101, + ppid: 100, + stat: 'S+', + command: 'codex resume session-1' + } + ] : []) ] }, @@ -128,6 +163,81 @@ describe('structured TUI process identity', () => { expect(delays).toEqual([25, 25]) }) + // Each poll forks a whole-machine `ps` (~0.065 CPU-s at 1,460 processes), so the + // capture COUNT per identification is the cost, not the 5s wall ceiling. + function countCapturesForIdentification(input: { + captureCostMs: number + childAppearsAtMs: number | null + }): Promise<{ + captures: number + identifiedAtMs: number | null + elapsedMs: number + }> { + let clockMs = 0 + let captures = 0 + return readStructuredTuiProcessIdentity({ + hostId: 'local', + rootPid: 100, + spawnToken: 'spawn-cost', + agent: 'codex', + platform: 'darwin', + readPosixRows: async () => { + captures += 1 + clockMs += input.captureCostMs + return [ + { pid: 100, ppid: 1, stat: 'Ss', command: '/bin/zsh' }, + ...(input.childAppearsAtMs !== null && clockMs >= input.childAppearsAtMs + ? [ + { + pid: 101, + ppid: 100, + stat: 'S+', + command: 'codex resume session-1' + } + ] + : []) + ] + }, + readStartTime: async () => 1_700_000_000_000, + now: () => clockMs, + sleep: async (delayMs) => { + clockMs += delayMs + } + }).then( + () => ({ captures, identifiedAtMs: clockMs, elapsedMs: clockMs }), + () => ({ captures, identifiedAtMs: null, elapsedMs: clockMs }) + ) + } + + it('does not spend a hundred ps captures on an identification that never resolves', async () => { + const { captures, identifiedAtMs, elapsedMs } = await countCapturesForIdentification({ + captureCostMs: 55, + childAppearsAtMs: null + }) + + expect(identifiedAtMs).toBeNull() + // The 5s ceiling is unchanged; only the captures inside it are. + expect(elapsedMs).toBeGreaterThanOrEqual(5_000) + // A flat 50ms poll spends ~48 captures here. + expect(captures).toBeLessThanOrEqual(20) + }) + + it('keeps identification latency identical while a child can still plausibly appear', async () => { + // The backoff must not touch the window a real spawn lands in: same capture + // count and same detection time as the flat 50ms poll. + for (const childAppearsAtMs of [0, 200, 500, 900]) { + const flatPollCaptures = Math.max(1, Math.ceil(childAppearsAtMs / (55 + 50)) + 1) + const { captures, identifiedAtMs } = await countCapturesForIdentification({ + captureCostMs: 55, + childAppearsAtMs + }) + + expect(identifiedAtMs).not.toBeNull() + expect(captures).toBeLessThanOrEqual(flatPollCaptures) + expect(identifiedAtMs!).toBeLessThanOrEqual(childAppearsAtMs + 55 + 50) + } + }) + it('fails closed when the process snapshot omitted the PTY root', async () => { await expect( readStructuredTuiProcessIdentity({ diff --git a/src/main/runtime/structured-tui-process-identity.ts b/src/main/runtime/structured-tui-process-identity.ts index 031b29f2f0f..d973f6c3f85 100644 --- a/src/main/runtime/structured-tui-process-identity.ts +++ b/src/main/runtime/structured-tui-process-identity.ts @@ -15,6 +15,13 @@ type ProcessRow = { pid: number; ppid: number; command: string; foreground: bool const STRUCTURED_TUI_PROCESS_WAIT_MS = 5_000 const STRUCTURED_TUI_PROCESS_POLL_MS = 50 +// Why: every poll forks a whole-machine `ps` (~0.065 CPU-s at 1,460 processes), +// and the 5s ceiling is only reached when the child never appears at all — so the +// tight interval buys nothing there. Hold it for the window in which a spawning +// child plausibly lands (detection latency byte-identical), then widen. Past the +// window the added latency is bounded by one interval. +const STRUCTURED_TUI_PROCESS_FAST_POLL_WINDOW_MS = 1_000 +const STRUCTURED_TUI_PROCESS_MAX_POLL_MS = 500 function descendants(rows: ProcessRow[], rootPid: number): (ProcessRow & { depth: number })[] { const children = new Map() @@ -153,7 +160,9 @@ export async function readStructuredTuiProcessIdentity(input: { const platform = input.platform ?? process.platform const now = input.now ?? Date.now const sleep = input.sleep ?? ((delayMs) => new Promise((resolve) => setTimeout(resolve, delayMs))) - const deadline = now() + (input.timeoutMs ?? STRUCTURED_TUI_PROCESS_WAIT_MS) + const startedAtMs = now() + const deadline = startedAtMs + (input.timeoutMs ?? STRUCTURED_TUI_PROCESS_WAIT_MS) + let pollDelayMs = input.pollIntervalMs ?? STRUCTURED_TUI_PROCESS_POLL_MS while (true) { const rows: ProcessRow[] = @@ -194,6 +203,13 @@ export async function readStructuredTuiProcessIdentity(input: { const label = input.agent === 'codex' ? 'Codex' : 'Claude' throw new Error(`The resumed terminal did not expose one exact ${label} child process.`) } - await sleep(Math.min(input.pollIntervalMs ?? STRUCTURED_TUI_PROCESS_POLL_MS, remainingMs)) + await sleep(Math.min(pollDelayMs, remainingMs)) + if (now() - startedAtMs >= STRUCTURED_TUI_PROCESS_FAST_POLL_WINDOW_MS) { + // Never below the caller's interval, so an explicitly slow poll stays slow. + pollDelayMs = Math.max( + pollDelayMs, + Math.min(pollDelayMs * 2, STRUCTURED_TUI_PROCESS_MAX_POLL_MS) + ) + } } } diff --git a/src/main/startup/desktop-startup-ordering.test.ts b/src/main/startup/desktop-startup-ordering.test.ts index e432ed383d2..fc381f15714 100644 --- a/src/main/startup/desktop-startup-ordering.test.ts +++ b/src/main/startup/desktop-startup-ordering.test.ts @@ -183,6 +183,48 @@ describe('startup ordering', () => { ) }) + it('keeps the git-environment barrier off the PTY startup services', () => { + const barrierSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-ipc-bootstrap.ts'), + 'utf8' + ) + const launchSource = readFileSync( + join(process.cwd(), 'src/main/startup/main-process-runtime-launch.ts'), + 'utf8' + ) + const gitBarrierStart = barrierSource.indexOf( + "ipcMain.handle('app:awaitGitEnvironmentStartupBarrier'" + ) + const gitBarrierEnd = barrierSource.indexOf( + "'app:prepareTerminalStartupRestoration'", + gitBarrierStart + ) + expect(gitBarrierStart).toBeGreaterThanOrEqual(0) + expect(gitBarrierEnd).toBeGreaterThan(gitBarrierStart) + const gitBarrier = barrierSource.slice(gitBarrierStart, gitBarrierEnd) + // The git environment fence is shell PATH + WSL registration; a daemon PTY provider or a + // hook-server bind here puts terminal startup back in front of worktree hydration. + expect(gitBarrier).toContain('state.shellPathReady') + expect(gitBarrier).toContain('state.managedWslCliStartupBarrierReady') + expect(gitBarrier).not.toContain('firstWindowStartupServicesReady') + // The published promise must be the same one the terminal startup services wait on. + expect(launchSource).toContain('state.shellPathReady = shellPathReady') + expect(launchSource.indexOf('state.shellPathReady = shellPathReady')).toBeLessThan( + launchSource.indexOf('await launchDesktopMode(') + ) + // Terminal restoration itself must still fence on the first-window services. + const restorationStart = barrierSource.indexOf( + "ipcMain.handle('app:prepareTerminalStartupRestoration'" + ) + const restorationEnd = barrierSource.indexOf( + "'app:recoverLegacyWorkerTerminalsForRendererStartup'", + restorationStart + ) + expect(barrierSource.slice(restorationStart, restorationEnd)).toContain( + 'state.firstWindowStartupServicesReady' + ) + }) + it('reconciles retained Codex homes after authoritative daemon inventory', () => { const source = readFileSync( join(process.cwd(), 'src/main/startup/main-process-pty-startup.ts'), diff --git a/src/main/startup/main-process-ipc-bootstrap.ts b/src/main/startup/main-process-ipc-bootstrap.ts index 89be84d2119..918fd844364 100644 --- a/src/main/startup/main-process-ipc-bootstrap.ts +++ b/src/main/startup/main-process-ipc-bootstrap.ts @@ -11,6 +11,13 @@ export function registerMainProcessIpcHandlers(): void { state.managedWslCliStartupBarrierReady ]) }) + // Why separate from the first-window barrier: host Git needs the shell-PATH + // generation and the managed WSL CLI registration, not a daemon PTY provider + // or a hook-server bind. Bundling them made worktree hydration wait on a + // terminal service it never calls. + ipcMain.handle('app:awaitGitEnvironmentStartupBarrier', async () => { + await Promise.all([state.shellPathReady, state.managedWslCliStartupBarrierReady]) + }) ipcMain.handle('app:prepareTerminalStartupRestoration', async () => { await Promise.all([ state.firstWindowStartupServicesReady, diff --git a/src/main/startup/main-process-quit.ts b/src/main/startup/main-process-quit.ts index 61203bc3164..a4149e13ba7 100644 --- a/src/main/startup/main-process-quit.ts +++ b/src/main/startup/main-process-quit.ts @@ -24,6 +24,7 @@ import { shutdownObservability } from '../observability' import { isQuittingForUpdate } from '../updater' import { recordUpdaterLifecycle } from '../updater-lifecycle-diagnostics' import { stopTccPromptNotice } from '../macos-tcc-prompt-notice' +import { cancelHistoryGc } from '../terminal-history-gc' import { shouldQuitWhenAllWindowsClosed } from './window-all-closed-quit-policy' import { mainProcessState as state } from './main-process-state' import { isDevParentShutdownRequested } from './configure-process' @@ -82,6 +83,9 @@ function installBeforeQuitHandler(): void { state.repoMaintenanceShutdown = awaitPackedRefsLockRelease() // Why: defer PTY cleanup to will-quit so the renderer captures scrollback before PTY-exit events unmount TerminalPane (dropping its capture callbacks). state.rateLimits?.stop() + // Why safe on a vetoed quit: background history GC is idempotent and re-scheduled next launch, + // so abandoning the walk here only costs one deferred sweep, never a half-applied prune. + cancelHistoryGc() }) } diff --git a/src/main/startup/main-process-runtime-launch.ts b/src/main/startup/main-process-runtime-launch.ts index 7e5f278fdc5..9df28ecea8c 100644 --- a/src/main/startup/main-process-runtime-launch.ts +++ b/src/main/startup/main-process-runtime-launch.ts @@ -289,6 +289,9 @@ export async function initializeMainProcessRuntimeLaunch( state.serveOptions = serveOptions const runtimeRpc = installRuntimeRpc(runtime, serveOptions) const shellPathReady = shellPathHydration.whenReady() + // Why published: the renderer's git-environment barrier must fence on the same + // generation the terminal startup services wait for, not a later re-read. + state.shellPathReady = shellPathReady let desktopWindow: BrowserWindow | null = null if (process.platform === 'win32' && app.isPackaged && !serveOptions) { const desktopStartup = startWindowsDesktopBeforeShellPathReady({ diff --git a/src/main/terminal-history-gc.test.ts b/src/main/terminal-history-gc.test.ts new file mode 100644 index 00000000000..ece0d9af25f --- /dev/null +++ b/src/main/terminal-history-gc.test.ts @@ -0,0 +1,444 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + rmSync, + statSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { basename, join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup' + +const { removeHostTreeMock } = vi.hoisted(() => ({ + removeHostTreeMock: vi.fn<(dir: string) => Promise>() +})) + +// Why intercept rather than no-op: the tombstone path each prune produces is the decision this +// suite reads, but the drain re-queues any tombstone still on disk after a "successful" removal, +// so the stub has to really delete or the queue never terminates. +vi.mock('./host-tree-removal', () => ({ + removeHostTree: removeHostTreeMock +})) + +import { readHistoryMeta } from './terminal-history' +import { + cancelPendingHistoryTreeRemovalRetries, + flushPendingWorktreeHistoryDeletions +} from './terminal-history-deletion' +import { cancelHistoryGc, runHistoryGc, scheduleHistoryGc } from './terminal-history-gc' + +const GC_MIN_AGE_MS = 5 * 60 * 1000 +const PENDING_DELETE_DIR_NAME = '.pending-delete' +const LIVE_WORKTREE_ID = 'repo-1::/path/live-wt' +const DEAD_WORKTREE_ID = 'repo-1::/path/dead-wt' + +let userDataDir: string +let historyRoot: string +let originalXdgDataHome: string | undefined + +/** + * The enumeration this exercises used to be a synchronous walk. Its replacement is an async + * fixed-worker pass, so the whole safety net is that both reach the same prune decision over a + * realistic tree: over-pruning here destroys scrollback the user still expects to have. + * + * A verbatim port of the pre-change decision logic, reporting names instead of deleting. + */ +function referenceSyncPruneDecisions(root: string, liveWorktreeIds: Set): string[] { + const decisions: string[] = [] + if (!existsSync(root)) { + return decisions + } + const now = Date.now() + for (const entry of readdirSync(root)) { + if (entry === PENDING_DELETE_DIR_NAME) { + continue + } + const entryPath = join(root, entry) + try { + const stats = statSync(entryPath) + if (!stats.isDirectory()) { + continue + } + try { + for (const file of readdirSync(entryPath)) { + statSync(join(entryPath, file)) + } + } catch { + // Skip size estimation on error. + } + if (!existsSync(join(entryPath, 'meta.json'))) { + continue + } + const meta = readHistoryMeta(entryPath) + if (!meta?.worktreeId) { + continue + } + if (!liveWorktreeIds.has(meta.worktreeId)) { + if (meta.createdAt && now - new Date(meta.createdAt).getTime() < GC_MIN_AGE_MS) { + continue + } + decisions.push(entry) + } + } catch { + // Skip individual entries that fail. + } + } + return decisions +} + +function seedDir(name: string, files: Record): string { + const dir = join(historyRoot, name) + mkdirSync(dir, { recursive: true }) + for (const [file, contents] of Object.entries(files)) { + writeFileSync(join(dir, file), contents) + } + return dir +} + +function meta(worktreeId: string | undefined, ageMs: number | null): string { + return JSON.stringify({ + ...(worktreeId === undefined ? {} : { worktreeId }), + ...(ageMs === null ? {} : { createdAt: new Date(Date.now() - ageMs).toISOString() }) + }) +} + +const OLD = GC_MIN_AGE_MS * 2 + +/** Every decision shape the walk has to get right, including the ones that must never prune. */ +function seedDecisionMatrix(): void { + seedDir('live-old', { 'meta.json': meta(LIVE_WORKTREE_ID, OLD), zsh_history: 'a' }) + seedDir('live-young', { 'meta.json': meta(LIVE_WORKTREE_ID, 0) }) + seedDir('orphan-old', { 'meta.json': meta(DEAD_WORKTREE_ID, OLD), zsh_history: 'b' }) + seedDir('orphan-no-createdat', { 'meta.json': meta(DEAD_WORKTREE_ID, null) }) + seedDir('orphan-unparseable-createdat', { + 'meta.json': JSON.stringify({ worktreeId: DEAD_WORKTREE_ID, createdAt: 'not-a-date' }) + }) + seedDir('orphan-young', { 'meta.json': meta(DEAD_WORKTREE_ID, 1_000) }) + seedDir('no-meta', { zsh_history: 'c' }) + seedDir('malformed-meta', { 'meta.json': '{ this is not json' }) + seedDir('truncated-meta', { 'meta.json': `{"worktreeId":"${DEAD_WORKTREE_ID}` }) + seedDir('empty-meta', { 'meta.json': '{}' }) + seedDir('array-meta', { 'meta.json': `["${DEAD_WORKTREE_ID}"]` }) + seedDir('null-meta', { 'meta.json': 'null' }) + seedDir('no-worktree-id', { 'meta.json': meta(undefined, OLD) }) + seedDir('oversize-meta', { + 'meta.json': JSON.stringify({ + worktreeId: DEAD_WORKTREE_ID, + createdAt: new Date(Date.now() - OLD).toISOString(), + pad: 'x'.repeat(64 * 1024) + }) + }) + // meta.json as a directory: stat succeeds, the read does not. + mkdirSync(join(historyRoot, 'meta-is-a-dir', 'meta.json'), { recursive: true }) + seedDir('empty-dir', {}) + // A plain file at the root is not a history directory. + writeFileSync(join(historyRoot, 'stray-file'), 'x') + mkdirSync(join(historyRoot, PENDING_DELETE_DIR_NAME), { recursive: true }) +} + +/** Enough entries to run several worker batches and cross the cooperative-yield boundary. */ +function seedBulk(count: number, orphanEvery: number): void { + for (let i = 0; i < count; i++) { + const orphan = i % orphanEvery === 0 + seedDir(`bulk-${i}`, { + 'meta.json': meta(orphan ? `${DEAD_WORKTREE_ID}-${i}` : LIVE_WORKTREE_ID, OLD), + zsh_history: `entry-${i}`, + bash_history: `entry-${i}` + }) + } +} + +function survivingDirs(): Set { + return new Set( + readdirSync(historyRoot).filter( + (entry) => + entry !== PENDING_DELETE_DIR_NAME && statSync(join(historyRoot, entry)).isDirectory() + ) + ) +} + +beforeEach(() => { + userDataDir = mkdtempSync(join(tmpdir(), 'orca-history-gc-')) + historyRoot = join(userDataDir, 'terminal-history') + mkdirSync(historyRoot, { recursive: true }) + installFakeAppEnvironment({ getPath: () => userDataDir }) + // Why: the fish sweep resolves a real user data dir otherwise, and would delete the + // developer's own orca fish history files while this suite runs. + originalXdgDataHome = process.env.XDG_DATA_HOME + process.env.XDG_DATA_HOME = userDataDir + removeHostTreeMock.mockReset() + removeHostTreeMock.mockImplementation(async (dir) => { + rmSync(dir, { recursive: true, force: true }) + }) +}) + +/** Tombstone paths the pass condemned, with the `..` rename suffix stripped. */ +function tombstonedNames(): Set { + return new Set( + removeHostTreeMock.mock.calls.map(([dir]) => basename(dir).split('.').slice(0, -2).join('.')) + ) +} + +afterEach(async () => { + cancelHistoryGc() + vi.useRealTimers() + await flushPendingWorktreeHistoryDeletions() + cancelPendingHistoryTreeRemovalRetries() + if (originalXdgDataHome === undefined) { + delete process.env.XDG_DATA_HOME + } else { + process.env.XDG_DATA_HOME = originalXdgDataHome + } + rmSync(userDataDir, { recursive: true, force: true }) +}) + +describe('history GC prune decisions', () => { + it('prunes exactly the set the synchronous walk chose', async () => { + seedDecisionMatrix() + seedBulk(200, 7) + const live = new Set([LIVE_WORKTREE_ID]) + + const before = survivingDirs() + const expected = new Set(referenceSyncPruneDecisions(historyRoot, live)) + + await runHistoryGc(live) + + const after = survivingDirs() + const actual = new Set([...before].filter((entry) => !after.has(entry))) + + expect(expected.size).toBeGreaterThan(0) + expect([...actual].sort()).toEqual([...expected].sort()) + }) + + it('keeps every directory whose ownership cannot be established', async () => { + seedDecisionMatrix() + + await runHistoryGc(new Set([LIVE_WORKTREE_ID])) + + const after = survivingDirs() + for (const kept of [ + 'live-old', + 'live-young', + 'orphan-young', + 'no-meta', + 'malformed-meta', + 'truncated-meta', + 'empty-meta', + 'array-meta', + 'null-meta', + 'no-worktree-id', + 'oversize-meta', + 'meta-is-a-dir', + 'empty-dir' + ]) { + expect(after.has(kept)).toBe(true) + } + expect(after.has('orphan-old')).toBe(false) + expect(after.has('orphan-no-createdat')).toBe(false) + expect(after.has('orphan-unparseable-createdat')).toBe(false) + expect(existsSync(join(historyRoot, 'stray-file'))).toBe(true) + }) + + it('refuses to prune anything when the live set is empty', async () => { + seedDecisionMatrix() + + await runHistoryGc(new Set()) + + expect(survivingDirs().has('orphan-old')).toBe(true) + expect(readdirSync(join(historyRoot, PENDING_DELETE_DIR_NAME))).toEqual([]) + }) + + it('does not throw when the history root does not exist', async () => { + rmSync(historyRoot, { recursive: true, force: true }) + await expect(runHistoryGc(new Set([LIVE_WORKTREE_ID]))).resolves.toBeUndefined() + }) + + it('tombstones orphans instead of removing them on the calling thread', async () => { + seedDecisionMatrix() + + await runHistoryGc(new Set([LIVE_WORKTREE_ID])) + + // The recursive rm only ever sees a path already renamed into the tombstone queue. + for (const [dir] of removeHostTreeMock.mock.calls) { + expect(dir).toContain(PENDING_DELETE_DIR_NAME) + } + expect(tombstonedNames().has('orphan-old')).toBe(true) + }) + + it('drains pre-existing tombstones without scanning them as worktrees', async () => { + seedDecisionMatrix() + const leftover = join(historyRoot, PENDING_DELETE_DIR_NAME, 'abc123.1700000000000.deadbeef') + mkdirSync(leftover, { recursive: true }) + + await runHistoryGc(new Set([LIVE_WORKTREE_ID])) + + expect(removeHostTreeMock).toHaveBeenCalledWith(expect.stringContaining('abc123.1700000000000')) + }) + + it('continues the pass after one orphan tombstone fails', async () => { + seedDir('orphan-a', { 'meta.json': meta(`${DEAD_WORKTREE_ID}-a`, OLD) }) + seedDir('orphan-b', { 'meta.json': meta(`${DEAD_WORKTREE_ID}-b`, OLD) }) + // A file where the tombstone root must be makes the first rename fail; mkdir cannot replace it. + writeFileSync(join(historyRoot, PENDING_DELETE_DIR_NAME), 'not a directory') + + await expect(runHistoryGc(new Set([LIVE_WORKTREE_ID]))).resolves.toBeUndefined() + + // Nothing could be tombstoned, and both entries survive for a later pass to reclaim. + expect(survivingDirs()).toEqual(new Set(['orphan-a', 'orphan-b'])) + }) +}) + +describe('history GC concurrency behaviour', () => { + it('joins a second call to the in-flight pass instead of walking twice', async () => { + seedDecisionMatrix() + const live = new Set([LIVE_WORKTREE_ID]) + + const first = runHistoryGc(live) + const second = runHistoryGc(live) + expect(second).toBe(first) + + await first + // Each rename produces its own tombstone, so a second overlapping walk would condemn twice. + const orphanRemovals = removeHostTreeMock.mock.calls.filter(([dir]) => + basename(dir).startsWith('orphan-old.') + ) + expect(orphanRemovals).toHaveLength(1) + }) + + it('starts a fresh pass once the previous one has settled', async () => { + seedDecisionMatrix() + const live = new Set([LIVE_WORKTREE_ID]) + await runHistoryGc(live) + const second = runHistoryGc(live) + await expect(second).resolves.toBeUndefined() + }) + + it('stops an in-flight walk on cancel without pruning', async () => { + seedDecisionMatrix() + seedBulk(300, 3) + const before = survivingDirs() + + const pass = runHistoryGc(new Set([LIVE_WORKTREE_ID])) + // Cancelling before the root listing resolves means no entry is ever visited. + cancelHistoryGc() + await pass + + expect(survivingDirs()).toEqual(before) + }) + + it('does not run a scheduled pass that was cancelled while resolving live worktrees', async () => { + seedDecisionMatrix() + vi.useFakeTimers() + let resolveLiveIds: (ids: Set) => void = () => {} + scheduleHistoryGc( + () => + new Promise>((resolve) => { + resolveLiveIds = resolve + }) + ) + + await vi.advanceTimersByTimeAsync(10_000) + cancelHistoryGc() + resolveLiveIds(new Set([LIVE_WORKTREE_ID])) + await vi.advanceTimersByTimeAsync(0) + + expect(survivingDirs().has('orphan-old')).toBe(true) + }) + + it('coalesces duplicate scheduled startup GC calls', async () => { + vi.useFakeTimers() + const getLiveWorktreeIds = vi.fn().mockResolvedValue(new Set()) + + scheduleHistoryGc(getLiveWorktreeIds) + scheduleHistoryGc(getLiveWorktreeIds) + await vi.advanceTimersByTimeAsync(10_000) + + expect(getLiveWorktreeIds).toHaveBeenCalledTimes(1) + }) +}) + +describe('history GC races an async walk introduces', () => { + it('survives a directory removed while the walk is in flight', async () => { + seedDecisionMatrix() + seedBulk(300, 5) + const live = new Set([LIVE_WORKTREE_ID]) + const vanishing = ['bulk-11', 'bulk-77', 'bulk-201'] + + const pass = runHistoryGc(live) + for (const name of vanishing) { + rmSync(join(historyRoot, name), { recursive: true, force: true }) + } + await expect(pass).resolves.toBeUndefined() + + // Every live directory the racer did not touch is still there. + expect(survivingDirs().has('live-old')).toBe(true) + expect(survivingDirs().has('bulk-1')).toBe(true) + for (const name of vanishing) { + expect(existsSync(join(historyRoot, name))).toBe(false) + } + }) + + it('never prunes a directory whose meta.json is half-written when the walk reads it', async () => { + seedDir('being-written', {}) + seedBulk(200, 5) + const live = new Set([LIVE_WORKTREE_ID]) + + const pass = runHistoryGc(live) + writeFileSync( + join(historyRoot, 'being-written', 'meta.json'), + `{"worktreeId":"${DEAD_WORKTREE_ID}","created` + ) + await pass + + expect(survivingDirs().has('being-written')).toBe(true) + }) + + it('prunes a directory whose meta.json arrived after the directory did', async () => { + seedDir('late-meta', {}) + writeFileSync( + join(historyRoot, 'late-meta', 'meta.json'), + meta(`${DEAD_WORKTREE_ID}-late`, OLD) + ) + + await runHistoryGc(new Set([LIVE_WORKTREE_ID])) + + expect(survivingDirs().has('late-meta')).toBe(false) + }) +}) + +describe('history GC main-thread occupancy', () => { + it('yields to timers throughout the walk instead of blocking on it', async () => { + seedBulk(1_200, 40) + const live = new Set([LIVE_WORKTREE_ID]) + + const ticks = { sync: 0, async: 0 } + let maxAsyncGapMs = 0 + + // The pre-change walk is the control: a synchronous pass over the same tree cannot tick at all. + const syncTimer = setInterval(() => { + ticks.sync += 1 + }, 4) + referenceSyncPruneDecisions(historyRoot, live) + clearInterval(syncTimer) + + let last = performance.now() + const asyncTimer = setInterval(() => { + const now = performance.now() + maxAsyncGapMs = Math.max(maxAsyncGapMs, now - last - 4) + last = now + ticks.async += 1 + }, 4) + await runHistoryGc(live) + clearInterval(asyncTimer) + + // A synchronous pass cannot tick at all, however long it takes. + expect(ticks.sync).toBe(0) + expect(ticks.async).toBeGreaterThan(5) + // Generous because shared CI runners stall an idle timer by tens of ms on their own; the + // failure this guards against is a whole-walk block, which is seconds. + expect(maxAsyncGapMs).toBeLessThan(2_000) + }) +}) diff --git a/src/main/terminal-history-gc.ts b/src/main/terminal-history-gc.ts index d67e3a7dd5c..37467c89423 100644 --- a/src/main/terminal-history-gc.ts +++ b/src/main/terminal-history-gc.ts @@ -1,5 +1,5 @@ import { join } from 'node:path' -import { existsSync, readdirSync, statSync } from 'node:fs' +import { readdir, stat } from 'node:fs/promises' import { getHistoryRoot, listWslHistoryRoots, @@ -9,9 +9,11 @@ import { schedulePendingHistoryTreeRemovals, scheduleWorktreeHistoryTreeDeletion } from './terminal-history-deletion' -import { readHistoryMeta } from './terminal-history' +import { readHistoryMetaAsync } from './terminal-history' import { resolveFishHistoryDir, sweepOrphanedFishHistoryFiles } from './fish-history-session' import { hashWorktreeId } from './terminal-history-id' +import { forEachWithConcurrency } from '../shared/map-with-concurrency' +import { yieldToEventLoop } from '../shared/event-loop-yield' // Why 5 minutes: GC runs ~10s after startup, and the live-worktree snapshot is // taken just before. A worktree created between the snapshot and GC execution @@ -20,100 +22,136 @@ import { hashWorktreeId } from './terminal-history-id' // to cover any realistic snapshot-to-scan delay. const GC_MIN_AGE_MS = 5 * 60 * 1000 -let scheduledHistoryGcTimer: ReturnType | null = null -let historyGcRunning = false +// Why a fixed worker pool over a frontier and not per-entry promise fan-out: a real +// history root holds thousands of directories, and starting every one at once queues +// tens of thousands of libuv requests before the first completes. 16 is deep enough to +// keep the default 4-thread pool saturated without monopolising the disk during startup. +const HISTORY_GC_SCAN_CONCURRENCY = 16 +// Why yield at all when every step already awaits I/O: a fully cached root resolves each +// await in a microtask, which never returns to the macrotask queue. This bounds that run. +const HISTORY_GC_YIELD_EVERY = 32 -/** Scan a single history root directory, pruning orphaned entries. - * Returns { totalDirs, orphaned, pruned, totalSizeKB }. */ -function gcScanRoot( - root: string, - liveWorktreeIds: Set -): { +let scheduledHistoryGcTimer: ReturnType | null = null +let historyGcStarting = false +let historyGcCancelled = false +let activeHistoryGc: Promise | null = null +let activeHistoryGcAbort: AbortController | null = null + +type GcRootScan = { totalDirs: number orphaned: number pruned: number totalSizeKB: number /** Every fish data dir a meta.json in this root names, for the orphan sweep. */ fishHistoryDirs: Set -} { - const result = { +} + +/** Inspect one history directory, tombstoning it when its worktree is gone. */ +async function gcScanEntry( + root: string, + entry: string, + liveWorktreeIds: Set, + now: number, + result: GcRootScan +): Promise { + const entryPath = join(root, entry) + try { + const stats = await stat(entryPath) + if (!stats.isDirectory()) { + return + } + result.totalDirs++ + + // Estimate directory size from meta.json + history files. + // Why a local accumulator: `result.totalSizeKB += ` + // reads the field before suspending and writes back a stale sum once workers interleave. + let dirSizeKB = 0 + try { + for (const file of await readdir(entryPath)) { + dirSizeKB += Math.ceil((await stat(join(entryPath, file))).size / 1024) + } + } catch { + // Skip size estimation on error, keeping whatever was measured first. + } + result.totalSizeKB += dirSizeKB + + // A missing, truncated, oversized or malformed meta.json reads back as null, and a + // null meta is never pruned — an entry whose ownership we cannot establish is kept. + const meta = await readHistoryMetaAsync(entryPath) + if (meta?.fishHistoryDir) { + result.fishHistoryDirs.add(meta.fishHistoryDir) + } + if (!meta?.worktreeId) { + return + } + + if (!liveWorktreeIds.has(meta.worktreeId)) { + // Why: avoid a TOCTOU race where a worktree is created after the + // live-ID snapshot but before GC runs. Directories younger than + // GC_MIN_AGE_MS are presumed still live and skipped. + if (meta.createdAt) { + const ageMs = now - new Date(meta.createdAt).getTime() + if (ageMs < GC_MIN_AGE_MS) { + return + } + } + + result.orphaned++ + // Why: a large orphaned tree recursive-rm'd here would stall the main process ~10s after + // launch — the same freeze the explicit-delete path already tombstones its way out of. + if (scheduleWorktreeHistoryTreeDeletion(entryPath, root)) { + result.pruned++ + console.log(`[pty:history:gc] Pruned orphaned history: ${meta.worktreeId}`) + } + } + } catch { + // Skip individual entries that fail. + } +} + +/** Scan a single history root directory, pruning orphaned entries. */ +async function gcScanRoot( + root: string, + liveWorktreeIds: Set, + signal: AbortSignal +): Promise { + const result: GcRootScan = { totalDirs: 0, orphaned: 0, pruned: 0, totalSizeKB: 0, fishHistoryDirs: new Set() } - if (!existsSync(root)) { + + let entries: string[] + try { + entries = await readdir(root) + } catch { + // Absent or unreadable root: nothing to collect. return result } const now = Date.now() + // Why: pending-delete is a tombstone queue drained asynchronously, not a live worktree hash. + const frontier = entries.filter((entry) => entry !== PENDING_DELETE_DIR_NAME) - for (const entry of readdirSync(root)) { - // Why: pending-delete is a tombstone queue drained asynchronously, not a live worktree hash. - if (entry === PENDING_DELETE_DIR_NAME) { - continue + await forEachWithConcurrency( + frontier, + HISTORY_GC_SCAN_CONCURRENCY, + async (entry, index): Promise => { + if (signal.aborted) { + return + } + await gcScanEntry(root, entry, liveWorktreeIds, now, result) + if (index % HISTORY_GC_YIELD_EVERY === HISTORY_GC_YIELD_EVERY - 1) { + await yieldToEventLoop() + } } - const entryPath = join(root, entry) - try { - const stat = statSync(entryPath) - if (!stat.isDirectory()) { - continue - } - result.totalDirs++ - - // Estimate directory size from meta.json + history files. - try { - for (const file of readdirSync(entryPath)) { - result.totalSizeKB += Math.ceil(statSync(join(entryPath, file)).size / 1024) - } - } catch { - // Skip size estimation on error. - } - - const metaPath = join(entryPath, 'meta.json') - if (!existsSync(metaPath)) { - // No meta.json — can't determine ownership, skip. - continue - } - - const meta = readHistoryMeta(entryPath) - if (meta?.fishHistoryDir) { - result.fishHistoryDirs.add(meta.fishHistoryDir) - } - if (!meta?.worktreeId) { - continue - } - - if (!liveWorktreeIds.has(meta.worktreeId)) { - // Why: avoid a TOCTOU race where a worktree is created after the - // live-ID snapshot but before GC runs. Directories younger than - // GC_MIN_AGE_MS are presumed still live and skipped. - if (meta.createdAt) { - const ageMs = now - new Date(meta.createdAt).getTime() - if (ageMs < GC_MIN_AGE_MS) { - continue - } - } - - result.orphaned++ - // Why: a large orphaned tree recursive-rm'd here would stall the main process ~10s after - // launch — the same freeze the explicit-delete path already tombstones its way out of. - if (scheduleWorktreeHistoryTreeDeletion(entryPath, root)) { - result.pruned++ - console.log(`[pty:history:gc] Pruned orphaned history: ${meta.worktreeId}`) - } - } - } catch { - // Skip individual entries that fail. - } - } + ) return result } -/** Run background GC to prune history directories for worktrees that are no - * longer in Orca's known live-worktree set. */ -export function runHistoryGc(liveWorktreeIds: Set): void { +async function executeHistoryGc(liveWorktreeIds: Set, signal: AbortSignal): Promise { try { // Why: finish tombstones left by quit mid-rm before scanning live worktree hashes. // Safe ahead of the guard below: these entries were already condemned by a @@ -130,14 +168,17 @@ export function runHistoryGc(liveWorktreeIds: Set): void { console.log('[pty:history:gc] Skipped: live worktree set is empty') return } - const main = gcScanRoot(getHistoryRoot(), liveWorktreeIds) + const main = await gcScanRoot(getHistoryRoot(), liveWorktreeIds, signal) // Also scan WSL history directories (each distro has its own subdirectory). const wslTotals = { totalDirs: 0, orphaned: 0, pruned: 0, totalSizeKB: 0 } const liveFishHistoryDirs = new Set(main.fishHistoryDirs) for (const distroRoot of listWslHistoryRoots()) { + if (signal.aborted) { + break + } schedulePendingHistoryTreeRemovals(distroRoot) - const r = gcScanRoot(distroRoot, liveWorktreeIds) + const r = await gcScanRoot(distroRoot, liveWorktreeIds, signal) wslTotals.totalDirs += r.totalDirs wslTotals.orphaned += r.orphaned wslTotals.pruned += r.pruned @@ -147,6 +188,11 @@ export function runHistoryGc(liveWorktreeIds: Set): void { } } + if (signal.aborted) { + console.log('[pty:history:gc] Cancelled mid-scan') + return + } + // Why a sweep on top of per-worktree deletion: a fish history file lives in // the user's fish data dir, so it outlives the directory that names it. A // crash between tombstone and removal, or a hand-deleted history dir, leaves @@ -178,28 +224,62 @@ export function runHistoryGc(liveWorktreeIds: Set): void { } } +/** Run background GC to prune history directories for worktrees that are no + * longer in Orca's known live-worktree set. Resolves when the pass finishes. */ +export function runHistoryGc(liveWorktreeIds: Set): Promise { + // Why join instead of starting a second pass: two walks would race each other's + // tombstone renames, and the loser's `scheduleWorktreeHistoryTreeDeletion` would + // report a failure for a directory the winner already condemned. + if (activeHistoryGc) { + return activeHistoryGc + } + const controller = new AbortController() + activeHistoryGcAbort = controller + activeHistoryGc = executeHistoryGc(liveWorktreeIds, controller.signal).finally(() => { + activeHistoryGc = null + activeHistoryGcAbort = null + }) + return activeHistoryGc +} + +/** Drop a pending GC and stop an in-flight walk at its next entry. */ +export function cancelHistoryGc(): void { + if (scheduledHistoryGcTimer !== null) { + clearTimeout(scheduledHistoryGcTimer) + scheduledHistoryGcTimer = null + } + // Why a flag as well: the timer has already fired while the live-worktree lookup is + // in flight, and there is no controller to abort until the scan itself starts. + historyGcCancelled = true + activeHistoryGcAbort?.abort() +} + /** Schedule GC after a delay so it runs after workspace hydration completes. * `getLiveWorktreeIds` should use already-known IDs, not probe repo paths. */ export function scheduleHistoryGc(getLiveWorktreeIds: () => Promise>): void { // Why: main-window services can reattach during reload/reactivation; one // pending/running disk GC is enough and avoids duplicate startup I/O. - if (scheduledHistoryGcTimer !== null || historyGcRunning) { + if (scheduledHistoryGcTimer !== null || historyGcStarting || activeHistoryGc !== null) { return } + historyGcCancelled = false // Why 10s: avoids competing with startup-critical I/O while still running // early enough to clean up before the user notices disk usage (§7.6). scheduledHistoryGcTimer = setTimeout(async () => { scheduledHistoryGcTimer = null - historyGcRunning = true + historyGcStarting = true try { const liveIds = await getLiveWorktreeIds() - runHistoryGc(liveIds) + if (historyGcCancelled) { + return + } + await runHistoryGc(liveIds) } catch (err) { console.warn( `[pty:history:gc] Failed to enumerate live worktrees for GC: ${err instanceof Error ? err.message : String(err)}` ) } finally { - historyGcRunning = false + historyGcStarting = false } }, 10_000) } diff --git a/src/main/terminal-history.test.ts b/src/main/terminal-history.test.ts index 1cbb5f83438..8dd25ea7ddf 100644 --- a/src/main/terminal-history.test.ts +++ b/src/main/terminal-history.test.ts @@ -96,8 +96,6 @@ import { flushPendingWorktreeHistoryDeletions } from './terminal-history-deletion' -import { runHistoryGc, scheduleHistoryGc } from './terminal-history-gc' - const OTHER_WORKTREE_HASH = hashWorktreeId('repo-1::/path/other-wt') describe('terminal-history', () => { @@ -688,179 +686,6 @@ describe('terminal-history', () => { }) }) - describe('runHistoryGc', () => { - it('coalesces duplicate scheduled startup GC calls', async () => { - vi.useFakeTimers() - existsSyncMock.mockReturnValue(false) - const getLiveWorktreeIds = vi.fn().mockResolvedValue(new Set()) - - scheduleHistoryGc(getLiveWorktreeIds) - scheduleHistoryGc(getLiveWorktreeIds) - - await vi.advanceTimersByTimeAsync(10_000) - - expect(getLiveWorktreeIds).toHaveBeenCalledTimes(1) - }) - - it('prunes orphaned directories', () => { - existsSyncMock.mockImplementation((p: string) => { - // WSL root doesn't exist, so GC skips it - if (p.includes('terminal-history-wsl')) { - return false - } - return true - }) - readdirSyncMock.mockImplementation((dir: string) => { - if (dir.endsWith('terminal-history')) { - return ['dir1', 'dir2'] - } - return ['meta.json'] - }) - statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) - readFileSyncMock.mockImplementation((p: string) => { - // Use a createdAt old enough to pass the GC age threshold - const oldDate = new Date(Date.now() - 10 * 60 * 1000).toISOString() - if (p.includes('dir1')) { - return JSON.stringify({ worktreeId: 'live-wt', createdAt: oldDate }) - } - return JSON.stringify({ worktreeId: 'dead-wt', createdAt: oldDate }) - }) - - const liveIds = new Set(['live-wt']) - runHistoryGc(liveIds) - - // Should only prune dir2 (dead-wt), not dir1 (live-wt), and never recursive-rm on the main thread. - expect(rmSyncMock).not.toHaveBeenCalled() - expect(renameSyncMock).toHaveBeenCalledTimes(1) - expect(renameSyncMock).toHaveBeenCalledWith( - expect.stringContaining('dir2'), - expect.stringContaining(`.pending-delete${sep}dir2.`) - ) - expect(rmAsyncMock).toHaveBeenCalledWith( - expect.stringContaining(`.pending-delete${sep}dir2.`), - expect.objectContaining({ recursive: true, force: true }) - ) - }) - - // Why: an empty live set is what a store that fell back to default state - // looks like, and it is indistinguishable from a user with no worktrees — - // who has no history to collect either. Treating it as "everything is - // orphaned" turns a recoverable bad load into deleted shell history. - it('refuses to prune anything when the live set is empty', () => { - existsSyncMock.mockImplementation((p: string) => !p.includes('terminal-history-wsl')) - readdirSyncMock.mockImplementation((dir: string) => { - if (dir.endsWith('.pending-delete')) { - return [] - } - if (dir.endsWith('terminal-history')) { - return ['dir1', 'dir2'] - } - return ['meta.json'] - }) - statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) - readFileSyncMock.mockReturnValue( - JSON.stringify({ - worktreeId: 'some-wt', - createdAt: new Date(Date.now() - 10 * 60 * 1000).toISOString() - }) - ) - - runHistoryGc(new Set()) - - expect(renameSyncMock).not.toHaveBeenCalled() - expect(rmSyncMock).not.toHaveBeenCalled() - expect(rmAsyncMock).not.toHaveBeenCalled() - }) - - it('continues GC after one orphan tombstone fails', async () => { - existsSyncMock.mockImplementation((path: string) => !path.includes('terminal-history-wsl')) - readdirSyncMock.mockImplementation((dir: string) => { - if (dir.endsWith('.pending-delete')) { - return [] - } - if (dir.endsWith('terminal-history')) { - return ['broken', 'healthy'] - } - return ['meta.json'] - }) - statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) - readFileSyncMock.mockReturnValue( - JSON.stringify({ - worktreeId: 'orphan', - createdAt: new Date(Date.now() - 10 * 60 * 1000).toISOString() - }) - ) - renameSyncMock.mockImplementationOnce(() => { - throw new Error('busy') - }) - - expect(() => runHistoryGc(new Set(['live-wt']))).not.toThrow() - expect(renameSyncMock).toHaveBeenCalledTimes(2) - expect(rmAsyncMock).toHaveBeenCalledTimes(1) - await flushPendingWorktreeHistoryDeletions() - }) - - it('skips recently-created directories to avoid TOCTOU race', () => { - existsSyncMock.mockImplementation((p: string) => { - if (p.includes('terminal-history-wsl')) { - return false - } - return true - }) - readdirSyncMock.mockImplementation((dir: string) => { - if (dir.endsWith('terminal-history')) { - return ['fresh-dir'] - } - return ['meta.json'] - }) - statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) - // createdAt is just now — younger than the 5-minute GC threshold - readFileSyncMock.mockReturnValue( - JSON.stringify({ worktreeId: 'unknown-wt', createdAt: new Date().toISOString() }) - ) - - runHistoryGc(new Set(['live-wt'])) - - // Should NOT prune because the directory is too young - expect(rmSyncMock).not.toHaveBeenCalled() - expect(renameSyncMock).not.toHaveBeenCalled() - }) - - it('does not throw when history root does not exist', () => { - existsSyncMock.mockReturnValue(false) - expect(() => runHistoryGc(new Set(['live-wt']))).not.toThrow() - expect(readdirSyncMock).not.toHaveBeenCalledWith('/fake/userData/terminal-history') - }) - - it('drains delete tombstones asynchronously instead of scanning them as worktrees', async () => { - let tombstonePresent = true - existsSyncMock.mockImplementation((p: string) => !String(p).includes('terminal-history-wsl')) - readdirSyncMock.mockImplementation((dir: string) => { - if (String(dir).endsWith('.pending-delete')) { - return tombstonePresent ? ['abc123.1700000000000.deadbeef'] : [] - } - if (String(dir).endsWith('terminal-history')) { - return ['.pending-delete'] - } - return ['meta.json'] - }) - statSyncMock.mockReturnValue({ isDirectory: () => true, size: 100 }) - rmAsyncMock.mockImplementation(async () => { - tombstonePresent = false - }) - - runHistoryGc(new Set(['live-wt'])) - - // The tombstone queue is drained off-thread; GC must never rmSync it or count it as a worktree. - expect(rmSyncMock).not.toHaveBeenCalled() - expect(rmAsyncMock).toHaveBeenCalledWith( - expect.stringContaining('abc123.1700000000000.deadbeef'), - expect.objectContaining({ recursive: true, force: true }) - ) - await flushPendingWorktreeHistoryDeletions() - }) - }) - describe('WSL path conversion', () => { it('converts HISTFILE to Linux path for WSL cwd', () => { const originalPlatform = process.platform diff --git a/src/main/terminal-history.ts b/src/main/terminal-history.ts index 04c772b2fc7..07ad8f12c93 100644 --- a/src/main/terminal-history.ts +++ b/src/main/terminal-history.ts @@ -1,5 +1,6 @@ import { join, basename } from 'node:path' import { mkdirSync, existsSync, readFileSync, statSync, writeFileSync } from 'node:fs' +import { readFile, stat } from 'node:fs/promises' import { dropInheritedOrcaFishHistory, fishHistorySessionName, @@ -131,7 +132,29 @@ export function readHistoryMeta(dir: string): HistoryDirMeta | null { if (statSync(metaPath).size > MAX_HISTORY_META_BYTES) { return null } - const raw: unknown = JSON.parse(readFileSync(metaPath, 'utf-8')) + return parseHistoryMeta(dir, readFileSync(metaPath, 'utf-8')) + } catch { + return null + } +} + +/** `readHistoryMeta` off the main thread, for scans that walk thousands of directories. */ +export async function readHistoryMetaAsync(dir: string): Promise { + try { + const metaPath = join(dir, 'meta.json') + // Why stat before read: the cap must reject an oversized meta.json without loading it. + if ((await stat(metaPath)).size > MAX_HISTORY_META_BYTES) { + return null + } + return parseHistoryMeta(dir, await readFile(metaPath, 'utf-8')) + } catch { + return null + } +} + +function parseHistoryMeta(dir: string, contents: string): HistoryDirMeta | null { + try { + const raw: unknown = JSON.parse(contents) if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { return null } diff --git a/src/main/windows/windows-process-table-cim-scan.ts b/src/main/windows/windows-process-table-cim-scan.ts index 898f0c36f33..213f157f63b 100644 --- a/src/main/windows/windows-process-table-cim-scan.ts +++ b/src/main/windows/windows-process-table-cim-scan.ts @@ -75,8 +75,6 @@ export function parseWindowsCimProcessRows(stdout: string): WindowsProcessRow[] return [] } const name = fieldAsString(row.Name) - // memoryBytes stays undefined: Win32_Process reports WorkingSetSize, but no - // caller reads it off this table and asking widens an already costly scan. return [{ pid, ppid, name, command: fieldAsString(row.CommandLine) || name }] }) } diff --git a/src/main/windows/windows-process-table.test.ts b/src/main/windows/windows-process-table.test.ts index 609de009820..360dae4ec14 100644 --- a/src/main/windows/windows-process-table.test.ts +++ b/src/main/windows/windows-process-table.test.ts @@ -52,21 +52,24 @@ describe('windows process table', () => { it('maps native rows, defaulting an unreadable command line to empty', async () => { const rows = await readWindowsProcessTableFresh() expect(rows).toEqual([ - { pid: process.pid, ppid: 0, name: 'vitest.exe', command: '', memoryBytes: undefined }, + { pid: process.pid, ppid: 0, name: 'vitest.exe', command: '' }, { pid: 100, ppid: 4, name: 'orca.exe', command: '"C:/a b/orca.exe" --x', - memoryBytes: 4096, creationTimeMs: 1_700_000_000_000 } ]) }) - it('requests memory and command line together', async () => { + it('requests the command line and creation time, never memory', async () => { await readWindowsProcessTableFresh() - expect(getAllProcesses.mock.calls[0]?.[1]).toBe(7) + // CommandLine (2) | CreationTime (4). The Memory bit (1) stays clear: the + // addon opens a second PROCESS_VM_READ handle per process to serve it and + // nothing reads a working set off this table. + expect(getAllProcesses.mock.calls[0]?.[1]).toBe(6) + expect((getAllProcesses.mock.calls[0]?.[1] as number) & 1).toBe(0) }) it('only advertises PID-safe ownership when the native creation-time field exists', () => { @@ -400,20 +403,19 @@ describe('resolving the native reader', () => { }) const rows = await readWindowsProcessTableFresh() expect(rows).toEqual([ - { pid: process.pid, ppid: 0, name: 'vitest.exe', command: '', memoryBytes: undefined }, + { pid: process.pid, ppid: 0, name: 'vitest.exe', command: '' }, { pid: 100, ppid: 4, name: 'orca.exe', command: '"C:/a b/orca.exe" --x', - memoryBytes: 4096, creationTimeMs: 1_700_000_000_000 } ]) expect(isWindowsProcessTableAvailable()).toBe(true) }) - it('asks the addon for memory and command line, as the package path does', async () => { + it('asks the addon for the command line but not memory, as the package path does', async () => { const addon = addonReturning(NATIVE) __setWindowsProcessTreeRequireForTests((specifier: string) => { if (specifier === ADDON_SPECIFIER) { @@ -422,9 +424,10 @@ describe('resolving the native reader', () => { throw new Error('MODULE_NOT_FOUND') }) await readWindowsProcessTableFresh() - // Memory | CommandLine. A bare snapshot would silently drop the command - // line every agent-recognition caller matches on first. - expect(addon.getProcessList).toHaveBeenCalledWith(expect.any(Function), 3) + // CommandLine only: a bare snapshot would silently drop the command line + // every agent-recognition caller matches on first, and the relay addon + // exposes no CreationTime bit to add. + expect(addon.getProcessList).toHaveBeenCalledWith(expect.any(Function), 2) }) it('reaches the CIM scan when neither the package nor the addon is present', async () => { diff --git a/src/main/windows/windows-process-table.ts b/src/main/windows/windows-process-table.ts index 9308c64a9f0..42d48f4fc79 100644 --- a/src/main/windows/windows-process-table.ts +++ b/src/main/windows/windows-process-table.ts @@ -23,6 +23,10 @@ import { readWindowsProcessRowsWithCim } from './windows-process-table-cim-scan' * pid+ppid+name 15.9 / 17.5 ms * +memory +commandLine 30.6 / 33.7 ms * PowerShell CIM 706 / 723 ms + * + * Those are the module's published figures for both extra fields together; the + * only flag set this module asks for is `CommandLine` (+ `CreationTime`, free), + * which sits between the two rows and has not been separately measured. */ export type WindowsProcessRow = { @@ -31,8 +35,6 @@ export type WindowsProcessRow = { name: string /** Full command line. Empty when the process denied a query handle. */ command: string - /** Working set in bytes, or undefined when not requested/queryable. */ - memoryBytes?: number /** Process creation time in Unix milliseconds, when the native snapshot provides it. */ creationTimeMs?: number } @@ -41,7 +43,6 @@ type NativeProcessInfo = { pid: number ppid: number name: string - memory?: number commandLine?: string creationTimeMs?: number } @@ -49,7 +50,6 @@ type NativeProcessInfo = { type WindowsProcessTreeModule = { ProcessDataFlag: { None: number - Memory: number CommandLine: number CreationTime?: number } @@ -82,7 +82,10 @@ type WindowsProcessTreeAddon = { ) => void } -/** Mirrors the package's enum; the addon takes the raw bit field. */ +/** + * Mirrors the package's enum; the addon takes the raw bit field. `Memory` (1) + * is listed for completeness and is deliberately never set — see `flags` below. + */ const PROCESS_DATA_FLAG = { None: 0, Memory: 1, CommandLine: 2 } as const /** Staged beside the relay bundle by build-relay; see RELAY_ARTIFACTS. */ @@ -190,16 +193,16 @@ function readNativeRows(): Promise { } const readId = ++readSequence const readerEpoch = nativeReaderEpoch - // Why always both flags: each adds an OpenProcess per process (Memory a - // GetProcessMemoryInfo, CommandLine a PEB read), so asking for less would be - // cheaper -- 15.9ms p50 versus 30.6ms at 1050 processes. But every read shares - // one snapshot so a 32-wide teardown collapses into a single scan, and that - // snapshot has to satisfy every caller. Splitting the cache per field set - // would restore exactly the fan-out it exists to prevent. - const flags = - native.ProcessDataFlag.Memory | - native.ProcessDataFlag.CommandLine | - (native.ProcessDataFlag.CreationTime ?? 0) + // Why CommandLine but not Memory: each flag costs one OpenProcess per process + // inside the addon (process.cc), and every caller of this table matches on + // `command`, while nothing reads a working set off it -- the Resource Manager + // runs its own CIM sweep because it needs commit and CPU time in one pass, and + // `process.cc` truncates the working set into a DWORD anyway. Dropping Memory + // halves the per-snapshot handle count; the remaining flags stay in ONE flag + // set because every read shares one snapshot, so a 32-wide teardown collapses + // into a single scan. Splitting the cache per field set would restore exactly + // the fan-out it exists to prevent. + const flags = native.ProcessDataFlag.CommandLine | (native.ProcessDataFlag.CreationTime ?? 0) return new Promise((resolve, reject) => { // Hoisted so a synchronous throw from getAllProcesses can clear it. An // orphaned timer would otherwise fire later and wedge a reader that had @@ -241,7 +244,6 @@ function readNativeRows(): Promise { ppid: row.ppid, name: row.name, command: row.commandLine ?? '', - memoryBytes: row.memory, ...(typeof row.creationTimeMs === 'number' ? { creationTimeMs: row.creationTimeMs } : {}) diff --git a/src/preload/api/app-api.ts b/src/preload/api/app-api.ts index 88cb86dc32b..b2d6eed966c 100644 --- a/src/preload/api/app-api.ts +++ b/src/preload/api/app-api.ts @@ -38,6 +38,9 @@ export type AppApi = { /** Resolves when the daemon PTY provider and hook receiver have either * started or failed open for the first BrowserWindow. */ awaitFirstWindowStartupServices: () => Promise + /** Resolves when host Git can run: shell-PATH generation is published and the + * managed WSL CLI registration has reconciled. Does not wait on PTY services. */ + awaitGitEnvironmentStartupBarrier: () => Promise /** Inventories retained PTYs and restores durable structured ownership before renderer adoption. */ prepareTerminalStartupRestoration: () => Promise /** Reconciles legacy worker authority around persisted terminal reconnect. */ diff --git a/src/preload/api/app-bridge.ts b/src/preload/api/app-bridge.ts index 22d46cc40d2..2a0d50e9de4 100644 --- a/src/preload/api/app-bridge.ts +++ b/src/preload/api/app-bridge.ts @@ -43,6 +43,8 @@ export const appApi = { awaitBeforeUnloadCheckpoint: () => awaitBeforeUnloadCheckpoint(), awaitFirstWindowStartupServices: (): Promise => ipcRenderer.invoke('app:awaitFirstWindowStartupServices'), + awaitGitEnvironmentStartupBarrier: (): Promise => + ipcRenderer.invoke('app:awaitGitEnvironmentStartupBarrier'), prepareTerminalStartupRestoration: (): Promise => ipcRenderer.invoke('app:prepareTerminalStartupRestoration'), recoverLegacyWorkerTerminalsForRendererStartup: (): Promise => diff --git a/src/preload/api/platform-bridge.test.ts b/src/preload/api/platform-bridge.test.ts new file mode 100644 index 00000000000..5c8bdde34f9 --- /dev/null +++ b/src/preload/api/platform-bridge.test.ts @@ -0,0 +1,55 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { platformApi } from './platform-bridge' + +const mocks = vi.hoisted(() => ({ getLinuxDisplayServer: vi.fn(() => null) })) + +vi.mock('../preload-runtime-support', () => ({ + getLinuxDisplayServer: mocks.getLinuxDisplayServer +})) + +// Electron declares getSystemVersion as required on NodeJS.Process; Node does not have it. +const mutableProcess = process as unknown as { getSystemVersion?: () => string } + +async function loadPlatformApi(): Promise { + vi.resetModules() + return (await import('./platform-bridge')).platformApi +} + +describe('platformApi.get', () => { + beforeEach(() => { + mocks.getLinuxDisplayServer.mockClear() + }) + + afterEach(() => { + delete mutableProcess.getSystemVersion + }) + + it('resolves the immutable payload once and returns the identical object', async () => { + const platformApi = await loadPlatformApi() + const getSystemVersion = vi.fn(() => '25.3.0') + mutableProcess.getSystemVersion = getSystemVersion + + const first = platformApi.get() + for (let index = 0; index < 100; index += 1) { + expect(platformApi.get()).toBe(first) + } + + expect(getSystemVersion).toHaveBeenCalledTimes(1) + expect(mocks.getLinuxDisplayServer).toHaveBeenCalledTimes(1) + expect(first.platform).toBe(process.platform) + expect(first.arch).toBe(process.arch) + expect(first.osRelease).toBe('25.3.0') + }) + + it('freezes the payload so no consumer can corrupt the shared instance', async () => { + const platformApi = await loadPlatformApi() + + expect(Object.isFrozen(platformApi.get())).toBe(true) + }) + + it('resolves nothing before the first get, keeping preload startup free', async () => { + await loadPlatformApi() + + expect(mocks.getLinuxDisplayServer).not.toHaveBeenCalled() + }) +}) diff --git a/src/preload/api/platform-bridge.ts b/src/preload/api/platform-bridge.ts index 8e47a4386cf..fb1aad5de4e 100644 --- a/src/preload/api/platform-bridge.ts +++ b/src/preload/api/platform-bridge.ts @@ -1,8 +1,14 @@ import { getLinuxDisplayServer } from '../preload-runtime-support' import type { PreloadApi } from '../api-types' -export const platformApi = { - get: () => ({ +type PlatformInfo = ReturnType + +// Why: the renderer reads this on its render cadence, and every field below is fixed +// for the process lifetime, so resolve once and hand back the same frozen payload. +let platformInfo: PlatformInfo | undefined + +function resolvePlatformInfo(): PlatformInfo { + return Object.freeze({ platform: process.platform, // Why: sandboxed preload cannot require node:os; Electron exposes the OS // version on process.getSystemVersion when available. @@ -14,4 +20,9 @@ export const platformApi = { shell: process.env.SHELL?.trim() || process.env.ComSpec?.trim() || '', displayServer: getLinuxDisplayServer() }) +} + +export const platformApi = { + // Why: resolved lazily so preload startup keeps paying nothing for it. + get: () => (platformInfo ??= resolvePlatformInfo()) } satisfies PreloadApi['platform'] diff --git a/src/relay/git-handler-push-target.test.ts b/src/relay/git-handler-push-target.test.ts index c040a645a16..b6fe5e96ad0 100644 --- a/src/relay/git-handler-push-target.test.ts +++ b/src/relay/git-handler-push-target.test.ts @@ -46,6 +46,17 @@ function gitForConfig(config: { } return { stdout: `${config.base ?? ''}\n`, stderr: '' } } + if (args[0] === 'remote' && args[1] === '-v') { + return { + stdout: (config.remotes ?? []) + .flatMap((name) => { + const url = config.remoteUrls?.[name] ?? '' + return [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`] + }) + .join('\n'), + stderr: '' + } + } if (args[0] === 'remote' && args.length === 1) { return { stdout: `${config.remotes?.join('\n') ?? ''}\n`, stderr: '' } } diff --git a/src/relay/git-handler-push-target.ts b/src/relay/git-handler-push-target.ts index 40765491c56..d81111d45d3 100644 --- a/src/relay/git-handler-push-target.ts +++ b/src/relay/git-handler-push-target.ts @@ -1,5 +1,6 @@ import { assertGitPushTargetShape } from '../shared/git-push-target-validation' import { gitRefTargetsBranchOnRemote } from '../shared/git-remote-branch-name' +import { findGitRemoteNameByFetchUrl } from '../shared/git-remote-url-index' import type { GitPushTarget } from '../shared/worktree/types' type RelayGit = (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> @@ -67,31 +68,19 @@ type ConfiguredPushRemote = { branchRemote: string | null } +// Host-side twin of `src/main/git/remote.ts`: one `git remote -v` instead of +// `git remote` plus a serial `git remote get-url` per remote. async function findRemoteNameForUrl( git: RelayGit, worktreePath: string, remoteUrl: string ): Promise { try { - const { stdout } = await git(['remote'], worktreePath) - const remotes = stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - for (const remoteName of remotes) { - try { - const { stdout: urlStdout } = await git(['remote', 'get-url', remoteName], worktreePath) - if (urlStdout.trim() === remoteUrl) { - return remoteName - } - } catch { - // Ignore a remote that disappeared or has no fetch URL. - } - } + const { stdout } = await git(['remote', '-v'], worktreePath) + return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => candidateUrl === remoteUrl) } catch { return null } - return null } async function normalizePushRemote( @@ -120,9 +109,17 @@ async function getConfiguredPushRemote( if (!remote) { return null } + const normalizedRemote = await normalizePushRemote(git, worktreePath, remote) + // The two usually name the same URL; resolving it twice reads the remote table twice. + if (!branchRemote) { + return { remote: normalizedRemote, branchRemote: null } + } return { - remote: await normalizePushRemote(git, worktreePath, remote), - branchRemote: branchRemote ? await normalizePushRemote(git, worktreePath, branchRemote) : null + remote: normalizedRemote, + branchRemote: + branchRemote === remote + ? normalizedRemote + : await normalizePushRemote(git, worktreePath, branchRemote) } } diff --git a/src/renderer/src/app-shell/app-command-handlers.ts b/src/renderer/src/app-shell/app-command-handlers.ts index 99915597e67..bb60f898130 100644 --- a/src/renderer/src/app-shell/app-command-handlers.ts +++ b/src/renderer/src/app-shell/app-command-handlers.ts @@ -5,6 +5,7 @@ import { requestScrollToCurrentWorkspaceRevealAndRename } from '@/lib/scroll-to- import { showTerminalShortcutCaptureNotification } from '@/lib/terminal-shortcut-capture-notification' import { shouldShowWorktreeHistoryControls } from '../lib/titlebar-worktree-history-controls' import { TOGGLE_WORKSPACE_BOARD_EVENT } from '../components/sidebar/useWorkspaceBoardPanel' +import { requestTerminalTabRename } from '../components/tab-bar/terminal-tab-rename-request' import { deleteHoveredWorkspaceImmediately, resolveHoveredWorkspaceDeleteTarget @@ -180,7 +181,7 @@ export function createAppCommandHandlers( ) { return false } - return claim('tab.rename', () => store.setRenamingTabId(store.activeTabId!)) + return claim('tab.rename', () => requestTerminalTabRename(store.activeTabId!)) } ], [ diff --git a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts index f4940db6ab5..cf33aed8e3e 100644 --- a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts +++ b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.test.ts @@ -2,13 +2,14 @@ import { describe, expect, it, vi } from 'vitest' import { reconcileHydratedWorkspaceTabModels } from './reconcile-hydrated-workspace-tab-models' describe('reconcileHydratedWorkspaceTabModels', () => { - it('reconciles every workspace the session hydrated, in session order', () => { + it('reconciles every workspace the session hydrated, in session order, in one call', () => { const reconcile = vi.fn() const reconciled = reconcileHydratedWorkspaceTabModels( { tabsByWorktree: { 'wt-a': [], 'wt-b': [], 'wt-c': [] } }, reconcile ) - expect(reconcile.mock.calls.map((call) => call[0])).toEqual(['wt-a', 'wt-b', 'wt-c']) + expect(reconcile).toHaveBeenCalledTimes(1) + expect(reconcile.mock.calls[0]?.[0]).toEqual(['wt-a', 'wt-b', 'wt-c']) expect(reconciled).toEqual(['wt-a', 'wt-b', 'wt-c']) }) diff --git a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts index 84704b3eb2d..3da45244660 100644 --- a/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts +++ b/src/renderer/src/app-shell/reconcile-hydrated-workspace-tab-models.ts @@ -3,12 +3,13 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st /** Reconcile every workspace loaded during boot so stale unified-tab subsets converge. */ export function reconcileHydratedWorkspaceTabModels( session: Pick, - reconcileWorktreeTabModel: (worktreeId: string) => unknown + // Why batched: one store write for the whole session instead of one per + // workspace, each fanning out to every non-React store subscriber. + reconcileWorktreeTabModels: (worktreeIds: readonly string[]) => void ): string[] { - const reconciled: string[] = [] - for (const worktreeId of Object.keys(session.tabsByWorktree)) { - reconcileWorktreeTabModel(worktreeId) - reconciled.push(worktreeId) + const reconciled = Object.keys(session.tabsByWorktree) + if (reconciled.length > 0) { + reconcileWorktreeTabModels(reconciled) } return reconciled } diff --git a/src/renderer/src/app-shell/startup-actions-selector.test.ts b/src/renderer/src/app-shell/startup-actions-selector.test.ts index 1d559eb0dcb..45a1b9cf5df 100644 --- a/src/renderer/src/app-shell/startup-actions-selector.test.ts +++ b/src/renderer/src/app-shell/startup-actions-selector.test.ts @@ -30,6 +30,7 @@ function makeActions(): StartupActions { reconnectPersistedTerminals: vi.fn(), setTerminalStartupRestorationReady: vi.fn(), setDeferredSshReconnectTargets: vi.fn(), + removeDeferredSshReconnectTarget: vi.fn(), setSshConnectionState: vi.fn(), hydratePersistedUI: vi.fn(), setHydrationSucceeded: vi.fn(), diff --git a/src/renderer/src/app-shell/startup-actions-selector.ts b/src/renderer/src/app-shell/startup-actions-selector.ts index c7dca311a03..ac18349909a 100644 --- a/src/renderer/src/app-shell/startup-actions-selector.ts +++ b/src/renderer/src/app-shell/startup-actions-selector.ts @@ -22,6 +22,7 @@ export type StartupActions = Pick< | 'reconnectPersistedTerminals' | 'setTerminalStartupRestorationReady' | 'setDeferredSshReconnectTargets' + | 'removeDeferredSshReconnectTarget' | 'setSshConnectionState' | 'hydratePersistedUI' | 'setHydrationSucceeded' @@ -59,6 +60,8 @@ export function selectStartupActions(state: StartupActions): StartupActions { cachedStartupActions.setTerminalStartupRestorationReady === state.setTerminalStartupRestorationReady && cachedStartupActions.setDeferredSshReconnectTargets === state.setDeferredSshReconnectTargets && + cachedStartupActions.removeDeferredSshReconnectTarget === + state.removeDeferredSshReconnectTarget && cachedStartupActions.setSshConnectionState === state.setSshConnectionState && cachedStartupActions.hydratePersistedUI === state.hydratePersistedUI && cachedStartupActions.setHydrationSucceeded === state.setHydrationSucceeded && @@ -91,6 +94,7 @@ export function selectStartupActions(state: StartupActions): StartupActions { reconnectPersistedTerminals: state.reconnectPersistedTerminals, setTerminalStartupRestorationReady: state.setTerminalStartupRestorationReady, setDeferredSshReconnectTargets: state.setDeferredSshReconnectTargets, + removeDeferredSshReconnectTarget: state.removeDeferredSshReconnectTarget, setSshConnectionState: state.setSshConnectionState, hydratePersistedUI: state.hydratePersistedUI, setHydrationSucceeded: state.setHydrationSucceeded, diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index 447fbfb3780..91db232081c 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -19,6 +19,7 @@ import { } from '../startup/startup-diagnostics' import { recoverFromDegradedStartup } from '../startup/startup-degraded-recovery' import { restoreSshConnectionsForStartup } from '../startup/startup-ssh-connection-restore' +import { collectActiveWorkspaceSshTargetIds } from '../startup/active-workspace-ssh-targets' import { publishTerminalViewAttributesAtAppStart } from '../components/terminal-pane/terminal-appearance' import { getSystemPrefersDark } from '../lib/terminal-theme' import { @@ -155,9 +156,12 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta // Why: disconnected SSH repos hydrate from local metadata; only runtime-owned repos use placeholders. parseExecutionHostId(getRepoExecutionHostId(repo))?.kind !== 'runtime' ) - // Why: worktree refresh can spawn host Git; wait for main's shell-PATH generation fence first. - await timeRendererStartupStep('first-window-services-await', () => - window.api.app.awaitFirstWindowStartupServices() + // Why this barrier and not the first-window one: worktree refresh can spawn host Git, + // which needs the shell-PATH generation and the managed WSL CLI registration. It never + // needs the daemon PTY provider or the hook-server bind, and `prepare-terminal-startup-restoration` + // below still fences those before any terminal is restored. + await timeRendererStartupStep('git-environment-barrier-await', () => + window.api.app.awaitGitEnvironmentStartupBarrier() ) await timeRendererStartupStep('fetch-hydration-worktrees', () => mapWithConcurrency(hydrationRepos, WORKTREE_REFRESH_CONCURRENCY, (repo) => @@ -198,7 +202,7 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta actions.hydrateBrowserSession(sessionRead.session, sessionHydrationOptions) reconcileHydratedWorkspaceTabModels( sessionRead.session, - useAppStore.getState().reconcileWorktreeTabModel + useAppStore.getState().reconcileWorktreeTabModels ) }) await timeRendererStartupStep('prepare-terminal-startup-restoration', () => @@ -213,9 +217,14 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta actions.pruneLastVisitedTimestamps() actions.seedActiveWorktreeLastVisitedIfMissing() }) - await timeRendererStartupStep('fetch-browser-session-profiles', () => + // Why started here but not awaited: on a remote runtime this is an RPC with a 15s + // timeout, and nothing between here and terminal restoration reads the profile list — + // awaiting it put that timeout on the terminal-restoration gate. Starting it at the + // original point keeps the profiles landing no later than they did before; the action + // swallows its own failures, so the `.catch` only marks the timing wrapper handled. + void timeRendererStartupStep('fetch-browser-session-profiles', () => actions.fetchBrowserSessionProfiles() - ) + ).catch(() => {}) const onboardingState = await onboardingPromise if (!cancelled) { onOnboardingLoadedRef.current(onboardingState) @@ -228,9 +237,17 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta ) if (connectionIds.length > 0) { try { + // Why scoped: an unreachable host used to hold every restored terminal — local ones + // included — for the full reconnect timeout. Only the targets whose panes mount as + // soon as the gate opens are worth waiting for; the rest reattach on tab focus. + const blockingConnectionIds = collectActiveWorkspaceSshTargetIds( + useAppStore.getState() + ) await restoreSshConnectionsForStartup({ connectionIds, + blockingConnectionIds, setDeferredSshReconnectTargets: actions.setDeferredSshReconnectTargets, + removeDeferredSshReconnectTarget: actions.removeDeferredSshReconnectTarget, publishSshConnectionState: actions.setSshConnectionState }) } catch (err) { @@ -240,7 +257,8 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta logRendererStartupDiagnostic('ssh-reconnect-skipped', { connectionIds: 0 }) } - // first-window-services-await already fenced worktree hydration; terminal recovery reuses that ready state. + // Why no explicit barrier here: prepare-terminal-startup-restoration above already awaited + // the first-window services, and main re-awaits them inside this handler anyway. await timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect', () => window.api.app.recoverLegacyWorkerTerminalsForRendererStartup() ) diff --git a/src/renderer/src/app-startup-routing.test.ts b/src/renderer/src/app-startup-routing.test.ts index 24a9cc75120..d1aad35829a 100644 --- a/src/renderer/src/app-startup-routing.test.ts +++ b/src/renderer/src/app-startup-routing.test.ts @@ -68,8 +68,11 @@ describe('renderer startup runtime routing', () => { const hydrationWorktreesIndex = source.indexOf( "timeRendererStartupStep('fetch-hydration-worktrees'" ) - const servicesIndex = source.indexOf( - "timeRendererStartupStep('first-window-services-await'", + // Why this barrier: worktree hydration can spawn host Git, so it must sit behind the + // shell-PATH + managed-WSL fence. On packaged Windows the window opens before + // shellPathReady resolves, so this really is the fence, not a formality. + const gitEnvironmentBarrierIndex = source.indexOf( + "timeRendererStartupStep('git-environment-barrier-await'", sessionIndex ) const fullWorktreesIndex = source.indexOf('await actions.fetchAllWorktrees()') @@ -89,8 +92,11 @@ describe('renderer startup runtime routing', () => { expect(localReposIndex).toBeLessThan(localGroupsIndex) expect(localGroupsIndex).toBeLessThan(localFoldersIndex) expect(localReposIndex).toBeLessThan(sessionIndex) - expect(sessionIndex).toBeLessThan(servicesIndex) - expect(servicesIndex).toBeLessThan(hydrationWorktreesIndex) + expect(sessionIndex).toBeLessThan(gitEnvironmentBarrierIndex) + expect(gitEnvironmentBarrierIndex).toBeLessThan(hydrationWorktreesIndex) + expect(source.slice(gitEnvironmentBarrierIndex, hydrationWorktreesIndex)).toContain( + 'window.api.app.awaitGitEnvironmentStartupBarrier()' + ) const hydrationWorktreeBlock = source.slice( hydrationWorktreesIndex, source.indexOf('await keybindingsPromise') @@ -180,7 +186,13 @@ describe('renderer startup runtime routing', () => { it('waits for first-window startup services before terminal reconnect', () => { const source = readSource(STARTUP_HYDRATION_PATH) - const servicesIndex = source.indexOf("timeRendererStartupStep('first-window-services-await'") + // Why this step: `app:prepareTerminalStartupRestoration` awaits + // firstWindowStartupServicesReady + managedWslCliStartupBarrierReady in main before it + // does anything else, so it is the renderer-side position of that fence. + // `desktop-startup-ordering.test.ts` pins the main-side await itself. + const servicesIndex = source.indexOf( + "timeRendererStartupStep('prepare-terminal-startup-restoration'" + ) const preReconnectRecoveryIndex = source.indexOf( "timeRendererStartupStep('recover-legacy-worker-terminals-pre-reconnect'" ) @@ -193,6 +205,9 @@ describe('renderer startup runtime routing', () => { ) expect(servicesIndex).toBeGreaterThanOrEqual(0) + expect(source.slice(servicesIndex)).toContain( + 'window.api.app.prepareTerminalStartupRestoration()' + ) expect(preReconnectRecoveryIndex).toBeGreaterThan(servicesIndex) expect(capabilityRefreshIndex).toBeGreaterThan(preReconnectRecoveryIndex) expect(reconnectIndex).toBeGreaterThan(capabilityRefreshIndex) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 1b5f40ccdb4..8187fe496c7 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -1956,7 +1956,9 @@ html.native-shell .app-layout { transform 120ms cubic-bezier(0.2, 0.8, 0.2, 1), width 120ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 80ms ease-out; - will-change: transform, width, opacity; + /* Why no `width`: it is not compositable, so hinting it only pins a layer that + has to be re-rastered every frame of the transition anyway. */ + will-change: transform, opacity; } [data-workspace-board-card-drop-indicator='true']::before, diff --git a/src/renderer/src/components/Landing.tsx b/src/renderer/src/components/Landing.tsx index 99ed596b98f..d03614b63dd 100644 --- a/src/renderer/src/components/Landing.tsx +++ b/src/renderer/src/components/Landing.tsx @@ -16,6 +16,7 @@ import logo from '../../../../resources/logo.svg' import { translate } from '@/i18n/i18n' import { hasGitHubBackedProject, type PreflightIssue } from './landing-preflight-issues' import { useLandingPreflightRuntime } from './landing-preflight-runtime' +import { useLandingOrcaStarState, type LandingStarState } from './landing-github-star-state' type ShortcutItem = { id: string @@ -26,31 +27,21 @@ type ShortcutItem = { // Do not deep-link to /stargazers: GitHub 404s that page for users without repo write access. const ORCA_GITHUB_URL = 'https://github.com/stablyai/orca' -type StarState = 'loading' | 'starred' | 'not-starred' | 'web-fallback' | 'hidden' +type StarButtonProps = { + hasRepos: boolean + state: LandingStarState + setState: React.Dispatch> +} -function GitHubStarButton({ hasRepos }: { hasRepos: boolean }): React.JSX.Element | null { - const [state, setState] = useState('loading') +function GitHubStarButton({ + hasRepos, + state, + setState +}: StarButtonProps): React.JSX.Element | null { const [menuOpen, setMenuOpen] = useState(false) const wrapperRef = useRef(null) const mountedRef = useMountedRef() - useEffect(() => { - let cancelled = false - void window.api.gh.checkOrcaStarred().then((result) => { - if (cancelled) { - return - } - if (result === null) { - setState('web-fallback') - } else { - setState(result ? 'starred' : 'not-starred') - } - }) - return () => { - cancelled = true - } - }, []) - useEffect(() => { if (!menuOpen) { return @@ -237,6 +228,7 @@ export default function Landing(): React.JSX.Element { // Why: the runtime-aware slice probes the active remote host instead of the renderer host. const { preflightIssues } = useLandingPreflightRuntime() + const [starState, setStarState] = useLandingOrcaStarState() const createWorktreeShortcut = useShortcutKeyDetails('workspace.create') const previousWorktreeShortcut = useShortcutKeyDetails('worktree.navigateUp') @@ -318,7 +310,7 @@ export default function Landing(): React.JSX.Element { {showGitHubSupportFooter && (
- 0} /> + 0} state={starState} setState={setStarState} />
)} diff --git a/src/renderer/src/components/activity/use-agent-pane-threads.ts b/src/renderer/src/components/activity/use-agent-pane-threads.ts index 01eb0475a02..1934488439e 100644 --- a/src/renderer/src/components/activity/use-agent-pane-threads.ts +++ b/src/renderer/src/components/activity/use-agent-pane-threads.ts @@ -121,8 +121,10 @@ export function useAgentPaneThreads(args: { // identities across rebuilds, so a status write to one agent leaves every other row's // memo bail-out and cached search text intact. Rebuilds are deterministic, so a repeated // (StrictMode/deferred) memo invocation returns identical objects from the cache. - const eventBuildCacheRef = useRef(createActivityEventBuildCache()) - const threadReuseCacheRef = useRef(createAgentPaneThreadReuseCache()) + const eventBuildCacheRef = useRef>(undefined!) + eventBuildCacheRef.current ??= createActivityEventBuildCache() + const threadReuseCacheRef = useRef>(undefined!) + threadReuseCacheRef.current ??= createAgentPaneThreadReuseCache() const { events: allEvents, liveAgentByPaneKey } = useMemo( () => diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts index a8de0e1e254..72859a415b5 100644 --- a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-webview-lifecycle.ts @@ -132,7 +132,8 @@ export function useBrowserPageWebviewLifecycle({ const addBrowserHistoryEntryRef = useRef(addBrowserHistoryEntry) const createBrowserTab = useAppStore((s) => s.createBrowserTab) const isPaintableRef = useRef(isPaintable) - const annotationViewportBridgeTokenRef = useRef(createBrowserUuid().replaceAll('-', '')) + const annotationViewportBridgeTokenRef = useRef(undefined!) + annotationViewportBridgeTokenRef.current ??= createBrowserUuid().replaceAll('-', '') const isActiveRef = useRef(isActive) const pendingAnnotationPayloadRef = useRef(pendingAnnotationPayload) const browserAnnotations = useAppStore( diff --git a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-zoom-feedback.ts b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-zoom-feedback.ts index 368d66d5e8d..09474d21418 100644 --- a/src/renderer/src/components/browser-pane/host-guest/use-browser-page-zoom-feedback.ts +++ b/src/renderer/src/components/browser-pane/host-guest/use-browser-page-zoom-feedback.ts @@ -28,9 +28,9 @@ export function useBrowserPageZoomFeedback(browserTabId: string): { // tab's zoom through the shared setting. Why the module-level lookup: the guest webview outlives // this component (worktree switch, Settings visit), so re-seeding on remount would let a later // Settings change retroactively hijack a tab the user already zoomed. - const paneZoomLevelRef = useRef( + const paneZoomLevelRef = useRef(undefined!) + paneZoomLevelRef.current ??= getExplicitBrowserPageZoomLevel(browserTabId) ?? normalizedBrowserDefaultZoomLevel - ) const [browserZoomPercent, setBrowserZoomPercent] = useState(browserDefaultZoomPercent) const [browserZoomFeedbackVisible, setBrowserZoomFeedbackVisible] = useState(false) const browserZoomFeedbackTimerRef = useRef>(undefined) diff --git a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-input.ts b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-input.ts index 64002a1208a..c58a5c56a1a 100644 --- a/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-input.ts +++ b/src/renderer/src/components/browser-pane/stream-remote/use-remote-browser-page-input.ts @@ -28,7 +28,8 @@ export function useRemoteBrowserPageInputQueue(): { remoteWheelFrameRef: React.MutableRefObject remoteWheelInFlightRef: React.MutableRefObject } { - const remoteInputQueueRef = useRef>(Promise.resolve()) + const remoteInputQueueRef = useRef>(undefined!) + remoteInputQueueRef.current ??= Promise.resolve() const pendingRemoteWheelRef = useRef(null) const remoteWheelFrameRef = useRef(null) const remoteWheelInFlightRef = useRef(false) diff --git a/src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.lazy-ref.test.tsx b/src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.lazy-ref.test.tsx new file mode 100644 index 00000000000..d68cd200c21 --- /dev/null +++ b/src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.lazy-ref.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment happy-dom + +/** + * The annotation viewport bridge token used to sit in a `useRef(...)` argument, so every render of + * a doc preview minted a fresh `crypto.randomUUID()` and threw it away — only the mount-time token + * was ever read. Pin the mint count to the mount count. + */ +import { useState } from 'react' +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const browserUuidCalls = vi.hoisted(() => ({ count: 0 })) + +vi.mock('@/lib/browser-uuid', () => ({ + createBrowserUuid: () => { + browserUuidCalls.count += 1 + return `00000000-0000-4000-8000-${String(browserUuidCalls.count).padStart(12, '0')}` + } +})) + +vi.mock('@/hooks/useShortcutLabel', () => ({ useShortcutLabel: () => 'Cmd+G' })) +vi.mock('@/components/browser-pane/annotate/guest-annotation-viewport-bridge', () => ({ + syncGuestAnnotationViewportBridge: vi.fn() +})) +vi.mock('@/components/browser-pane/annotate/use-browser-page-annotation-send', () => ({ + useBrowserPageAnnotationSend: () => ({ + browserAnnotations: [], + setBrowserAnnotationTrayOpen: vi.fn() + }) +})) +vi.mock('@/components/browser-pane/annotate/use-browser-page-grab-annotations', () => ({ + useBrowserPageGrabAnnotations: () => ({}) +})) +vi.mock('@/components/browser-pane/annotate/use-browser-page-markup-capture', () => ({ + useBrowserPageMarkupCapture: () => ({}) +})) +vi.mock('@/components/browser-pane/annotate/useGrabMode', () => ({ + useGrabMode: () => ({ active: false }) +})) + +const { useDocPreviewGuestTools } = await import('./use-doc-preview-guest-tools') + +let bumpRender: (() => void) | null = null + +function Host(): null { + const [, setTick] = useState(0) + bumpRender = () => setTick((tick) => tick + 1) + useDocPreviewGuestTools({ + previewId: 'preview-1', + worktreeId: 'wt-1', + grantId: 'grant-1', + webviewRef: { current: null }, + containerRef: { current: null }, + toolsReady: true + } as unknown as Parameters[0]) + return null +} + +afterEach(() => { + cleanup() + browserUuidCalls.count = 0 + bumpRender = null +}) + +describe('useDocPreviewGuestTools annotation bridge token', () => { + it('mints the bridge token once per mount, not once per render', () => { + render() + expect(browserUuidCalls.count).toBe(1) + + for (let i = 0; i < 20; i += 1) { + act(() => bumpRender?.()) + } + + expect(browserUuidCalls.count).toBe(1) + }) +}) diff --git a/src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.ts b/src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.ts index 9eec9b8affe..83706aa3fcd 100644 --- a/src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.ts +++ b/src/renderer/src/components/browser-pane/workspace-doc/use-doc-preview-guest-tools.ts @@ -41,7 +41,8 @@ export function useDocPreviewGuestTools({ // Why still empty before the first grant: the page is only a tool target once a document is on // screen, and useGrabMode needs a stable identity every render rather than one to guess with. const toolTargetId = grantId === null ? '' : previewId - const annotationViewportBridgeTokenRef = useRef(createBrowserUuid().replaceAll('-', '')) + const annotationViewportBridgeTokenRef = useRef(undefined!) + annotationViewportBridgeTokenRef.current ??= createBrowserUuid().replaceAll('-', '') const [browserOverlayViewport, setBrowserOverlayViewport] = useState({ scrollX: 0, scrollY: 0, diff --git a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts index 60df4995e56..0e8c2881244 100644 --- a/src/renderer/src/components/dashboard/useAgentBucketCounts.ts +++ b/src/renderer/src/components/dashboard/useAgentBucketCounts.ts @@ -51,7 +51,8 @@ export function useAgentBucketCounts(): AgentBucketCounts { // Why a per-hook cache: unrelated status/title writes change one worktree's inputs; // the cache keeps every other worktree's counts without rerunning its row pipeline. - const cacheRef = useRef(createDashboardBucketCountsCache()) + const cacheRef = useRef>(undefined!) + cacheRef.current ??= createDashboardBucketCountsCache() return useMemo(() => { return buildDashboardBucketCounts( { diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts index 227cf4e3f8b..52270167ef9 100644 --- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts +++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.ts @@ -11,7 +11,8 @@ import { createWorktreeAgentRowsCache } from './worktree-agent-rows-cache' * is no relay, so we derive it here from the same builder the bridge uses. */ export function useLiveDashboardSnapshot(): DashboardSnapshot { - const rowsCacheRef = useRef(createWorktreeAgentRowsCache()) + const rowsCacheRef = useRef>(undefined!) + rowsCacheRef.current ??= createWorktreeAgentRowsCache() const repos = useAppStore((s) => s.repos) const worktreesByRepo = useAppStore((s) => s.worktreesByRepo) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) diff --git a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.commentable-lines.test.tsx b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.commentable-lines.test.tsx new file mode 100644 index 00000000000..d954bafa62b --- /dev/null +++ b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.commentable-lines.test.tsx @@ -0,0 +1,284 @@ +// @vitest-environment happy-dom +import { renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { editor as MonacoEditor, IDisposable } from 'monaco-editor' +import type { DecoratedDiffComment } from './decorated-diff-comment' +import type * as ReactDomClientModule from 'react-dom/client' +import type * as DiffCommentZoneCardModule from './diff-comment-zone-card' + +const storeFixture = vi.hoisted(() => ({ + activeGroupIdByWorktree: {}, + clearDeliveredDiffComments: vi.fn() +})) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: typeof storeFixture) => unknown) => selector(storeFixture) +})) + +// Stub only the card render: this suite is about zone/root lifecycle, not card markup. +vi.mock('./diff-comment-zone-card', async (importOriginal) => ({ + ...(await importOriginal()), + renderDiffCommentZoneCard: vi.fn() +})) + +const rootCounts = vi.hoisted(() => ({ created: 0, unmounted: 0 })) + +// Count only roots the decorator creates for its zones — @testing-library/react creates its own. +vi.mock('react-dom/client', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + createRoot: (container: Element, options?: Parameters[1]) => { + const isZoneRoot = container.classList?.contains('orca-diff-comment-inline') ?? false + if (isZoneRoot) { + rootCounts.created += 1 + } + const root = actual.createRoot(container, options) + return { + render: (node: Parameters[0]) => root.render(node), + unmount: () => { + if (isZoneRoot) { + rootCounts.unmounted += 1 + } + root.unmount() + } + } + } + } +}) + +import { useDiffCommentDecorator } from './useDiffCommentDecorator' + +type FakeEditor = { + editor: MonacoEditor.ICodeEditor + domNode: HTMLElement + zones: Map + emitMouseMove: (lineNumber: number) => void +} + +function createFakeEditor(): FakeEditor { + const domNode = document.createElement('div') + document.body.appendChild(domNode) + const zones = new Map() + let nextZoneId = 0 + const mouseMoveListeners: ((e: { target: { position: { lineNumber: number } } }) => void)[] = [] + const noopDisposable: IDisposable = { dispose: () => {} } + + const editor = { + getDomNode: () => domNode, + getModel: () => ({}), + getOption: () => 19, + getTopForLineNumber: () => 0, + getScrollTop: () => 0, + getLayoutInfo: () => ({ height: 400 }), + setScrollTop: () => {}, + deltaDecorations: () => [], + getTargetAtClientPoint: () => null, + onMouseMove: (listener: (e: { target: { position: { lineNumber: number } } }) => void) => { + mouseMoveListeners.push(listener) + return noopDisposable + }, + onMouseLeave: () => noopDisposable, + onDidScrollChange: () => noopDisposable, + changeViewZones: (callback: (accessor: MonacoEditor.IViewZoneChangeAccessor) => void) => + callback({ + addZone: (zone: MonacoEditor.IViewZone) => { + const id = `zone-${(nextZoneId += 1)}` + zones.set(id, zone) + return id + }, + removeZone: (id: string) => { + zones.delete(id) + }, + layoutZone: () => {} + } as unknown as MonacoEditor.IViewZoneChangeAccessor) + } as unknown as MonacoEditor.ICodeEditor + + return { + editor, + domNode, + zones, + emitMouseMove: (lineNumber) => { + for (const listener of mouseMoveListeners) { + listener({ target: { position: { lineNumber } } }) + } + } + } +} + +const FILE_PATH = 'src/index.ts' +const REVIEW_SURFACE_ID = 'pr:acme/widgets:42' + +function reviewNote(index: number): DecoratedDiffComment { + return { + id: `review-note-${index}`, + worktreeId: REVIEW_SURFACE_ID, + filePath: FILE_PATH, + lineNumber: 10 + index, + body: `Please rename this (${index}).`, + createdAt: index, + side: 'modified', + author: 'octocat' + } +} + +// Every refresh of remote review data yields a fresh-but-equal array, exactly as the main process ships it. +function freshCommentableLines(): readonly number[] { + return [10, 11, 12, 13, 14, 15, 16] +} + +// Root teardown is deferred through queueMicrotask, so drain before asserting on unmount counts. +async function flushDeferredUnmounts(): Promise { + await Promise.resolve() + await Promise.resolve() +} + +// Asserted as one object so a failure reports every lifecycle number at once. +function lifecycleTotals(fake: FakeEditor): Record { + return { + createRootCalls: rootCounts.created, + rootUnmounts: rootCounts.unmounted, + monacoViewZones: fake.zones.size + } +} + +function isAddButtonVisible(domNode: HTMLElement): boolean { + const button = domNode.querySelector('.orca-diff-comment-add-btn') + return button != null && button.style.display !== 'none' +} + +type DecoratorProps = { + commentableLineNumbers: readonly number[] + comments: readonly DecoratedDiffComment[] +} + +function renderDecorator(fake: FakeEditor, initialProps: DecoratorProps) { + return renderHook( + ({ commentableLineNumbers, comments }: DecoratorProps) => + useDiffCommentDecorator({ + editor: fake.editor, + filePath: FILE_PATH, + worktreeId: REVIEW_SURFACE_ID, + comments, + commentableLineNumbers, + onAddCommentClick: vi.fn(), + onDeleteComment: vi.fn() + }), + { initialProps } + ) +} + +beforeEach(() => { + rootCounts.created = 0 + rootCounts.unmounted = 0 +}) + +afterEach(() => { + document.body.replaceChildren() + vi.clearAllMocks() +}) + +describe('useDiffCommentDecorator commentable-line churn', () => { + it('keeps every comment root and view zone alive across value-equal review refreshes', async () => { + const fake = createFakeEditor() + const comments = [reviewNote(1), reviewNote(2), reviewNote(3)] + const hook = renderDecorator(fake, { + commentableLineNumbers: freshCommentableLines(), + comments + }) + + expect(rootCounts.created).toBe(3) + expect(fake.zones.size).toBe(3) + + for (let refresh = 0; refresh < 5; refresh += 1) { + hook.rerender({ commentableLineNumbers: freshCommentableLines(), comments }) + } + await flushDeferredUnmounts() + + expect(lifecycleTotals(fake)).toEqual({ + createRootCalls: 3, + rootUnmounts: 0, + monacoViewZones: 3 + }) + + // Still tracked, so dropping a note reclaims its vertical space instead of leaving a blank gap. + hook.rerender({ commentableLineNumbers: freshCommentableLines(), comments: comments.slice(1) }) + expect(fake.zones.size).toBe(2) + }) + + it('does not accumulate orphan zones when refreshes interleave with new review comments', async () => { + const fake = createFakeEditor() + let comments = [reviewNote(1)] + const hook = renderDecorator(fake, { + commentableLineNumbers: freshCommentableLines(), + comments + }) + + for (let refresh = 2; refresh <= 6; refresh += 1) { + comments = [...comments, reviewNote(refresh)] + hook.rerender({ commentableLineNumbers: freshCommentableLines(), comments }) + } + await flushDeferredUnmounts() + + // 6 live notes => 6 roots, 6 zones, nothing stranded. + expect(lifecycleTotals(fake)).toEqual({ + createRootCalls: 6, + rootUnmounts: 0, + monacoViewZones: 6 + }) + }) + + it('rebuilds the add-button overlay when the commentable lines really change', async () => { + const fake = createFakeEditor() + const comments = [reviewNote(1)] + const hook = renderDecorator(fake, { + commentableLineNumbers: [10, 11, 12] as readonly number[], + comments + }) + + fake.emitMouseMove(40) + expect(isAddButtonVisible(fake.domNode)).toBe(false) + + hook.rerender({ commentableLineNumbers: [10, 11, 12, 40], comments }) + await flushDeferredUnmounts() + + // Decorator is not stale: the widened set is live... + fake.emitMouseMove(40) + expect(isAddButtonVisible(fake.domNode)).toBe(true) + // ...and the existing note's zone/root was neither orphaned nor rebuilt. + expect(lifecycleTotals(fake)).toEqual({ + createRootCalls: 1, + rootUnmounts: 0, + monacoViewZones: 1 + }) + }) + + it('removes its zones from Monaco when the model swaps under a retained editor', async () => { + const fake = createFakeEditor() + const comments = [reviewNote(1)] + const hook = renderHook( + ({ monacoModelIdentity }) => + useDiffCommentDecorator({ + editor: fake.editor, + monacoModelIdentity, + filePath: FILE_PATH, + worktreeId: REVIEW_SURFACE_ID, + comments, + commentableLineNumbers: freshCommentableLines(), + onAddCommentClick: vi.fn(), + onDeleteComment: vi.fn() + }), + { initialProps: { monacoModelIdentity: 'modified-v1' } } + ) + const firstZoneIds = [...fake.zones.keys()] + + hook.rerender({ monacoModelIdentity: 'modified-v2' }) + await flushDeferredUnmounts() + + // Stale zone ids are gone rather than left as untracked blank gaps, and the note was rebuilt. + expect(fake.zones.size).toBe(1) + expect([...fake.zones.keys()]).not.toEqual(firstZoneIds) + expect(rootCounts.created).toBe(2) + expect(rootCounts.unmounted).toBe(1) + }) +}) diff --git a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx index 8e7c484bb09..d1f26d24765 100644 --- a/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx +++ b/src/renderer/src/components/diff-comments/useDiffCommentDecorator.tsx @@ -80,11 +80,17 @@ export function useDiffCommentDecorator({ scrollToZoneFrameRef.current = null }, []) + // Key on the values, not the array identity: review surfaces re-fetch PR/MR file data on every + // refresh and hand us a fresh-but-equal number[], which would otherwise churn every consumer. + const commentableLineKey = commentableLineNumbers?.join(',') const commentableLineSet = useMemo( () => (commentableLineNumbers ? new Set(commentableLineNumbers) : null), - [commentableLineNumbers] + // eslint-disable-next-line react-hooks/exhaustive-deps + [commentableLineKey] ) + // Add-button overlay only: it captures commentableLineSet/addButtonLabel, so it must be rebuilt when + // either changes. Kept apart from the zone teardown below, whose deps must mirror the zone-creating effect. useEffect(() => { if (!editor) { return @@ -95,8 +101,7 @@ export function useDiffCommentDecorator({ return } - const zones = zonesRef.current - const disposeAddButtonOverlay = installDiffCommentAddButtonOverlay({ + return installDiffCommentAddButtonOverlay({ editor, editorDomNode, addButtonLabel, @@ -105,15 +110,33 @@ export function useDiffCommentDecorator({ disposablesRef, onAddCommentClickRef }) + }, [addButtonLabel, commentableLineSet, editor, monacoModelIdentity]) + + // Deps must stay a subset of the zone-creating effect's, or a teardown here is never followed by a rebuild. + useEffect(() => { + if (!editor) { + return + } + + const zones = zonesRef.current return () => { - disposeAddButtonOverlay() // Editor swapped/torn down: unmount roots and clear tracking so the next mount starts known-empty. // Defer unmount via queueMicrotask: a sync unmount during React's commit triggers React 19's "unmount while rendering" warning; clear zones synchronously. const rootsToUnmount = Array.from(zones.values(), (z) => { z.disposeMouseDownStopper() return z.root }) + // Drop the zones from Monaco too: clearing our map alone would strand them as untracked blank gaps + // in a still-live editor. No-op when the model already swapped (Monaco dropped them) or the editor is disposed. + if (zones.size > 0) { + const zoneIds = Array.from(zones.values(), (z) => z.zoneId) + editor.changeViewZones((accessor) => { + for (const zoneId of zoneIds) { + accessor.removeZone(zoneId) + } + }) + } zones.clear() if (rootsToUnmount.length > 0) { queueMicrotask(() => { @@ -127,7 +150,7 @@ export function useDiffCommentDecorator({ pendingScrollRef.current = null scrollToZoneRef.current = null } - }, [addButtonLabel, cancelScrollToZoneFrame, commentableLineSet, editor, monacoModelIdentity]) + }, [cancelScrollToZoneFrame, editor, monacoModelIdentity]) useEffect(() => { if (!editor) { diff --git a/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.test.ts b/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.test.ts index 1b975d81339..9d3b7ff042d 100644 --- a/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.test.ts +++ b/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.test.ts @@ -1,13 +1,56 @@ import { describe, expect, it } from 'vitest' import type { PdfViewPosition } from '@/lib/scroll-cache' -import { sweepClosedPdfViewPositions } from './closed-editor-tab-cache-sweep' +import { + deletePaneScopedCacheEntries, + sweepClosedPdfViewPositions +} from './closed-editor-tab-cache-sweep' const position = (pageNumber: number): PdfViewPosition => ({ pageNumber, top: 0, left: 0 }) +describe('deletePaneScopedCacheEntries', () => { + it('does not delete an owner whose id merely extends a closed owner id', () => { + const cache = new Map([ + ['tab-1::pane-1', 1], + ['tab-10::pane-1', 2], + ['tab-1x::pane-1', 3] + ]) + deletePaneScopedCacheEntries(cache, ['tab-1']) + expect([...cache.keys()]).toEqual(['tab-10::pane-1', 'tab-1x::pane-1']) + }) + + it('matches an owner that ends at the second `::` of a `:::` run', () => { + // Locks the `boundary + 1` advance in hasPaneScopeOwner: `a:` ends at index 2, which only the + // second `::` of the run exposes. A `+ 2` advance would skip it and leak the entry. + const cache = new Map([ + ['a:::b', 1], + ['a::b', 2], + ['ab:::c', 3] + ]) + deletePaneScopedCacheEntries(cache, ['a:']) + expect([...cache.keys()]).toEqual(['a::b', 'ab:::c']) + }) + + it('sweeps every owner in the batch in one pass', () => { + const cache = new Map([ + ['tab-1::pane-1', 1], + ['tab-2::pane-1', 2], + ['tab-3::pane-1', 3] + ]) + deletePaneScopedCacheEntries(cache, ['tab-1', 'tab-3']) + expect([...cache.keys()]).toEqual(['tab-2::pane-1']) + }) + + it('is a no-op for an empty owner batch', () => { + const cache = new Map([['tab-1::pane-1', 1]]) + deletePaneScopedCacheEntries(cache, []) + expect(cache.size).toBe(1) + }) +}) + describe('sweepClosedPdfViewPositions', () => { it('deletes the unscoped :pdf entry', () => { const cache = new Map([['/a.pdf:pdf', position(4)]]) - sweepClosedPdfViewPositions(cache, '/a.pdf') + sweepClosedPdfViewPositions(cache, ['/a.pdf']) expect(cache.size).toBe(0) }) @@ -17,7 +60,7 @@ describe('sweepClosedPdfViewPositions', () => { ['/a.pdf::tab-2:pdf', position(9)], ['/a.pdf::tab-3:pdf', position(11)] ]) - sweepClosedPdfViewPositions(cache, '/a.pdf') + sweepClosedPdfViewPositions(cache, ['/a.pdf']) expect(cache.size).toBe(0) }) @@ -27,7 +70,7 @@ describe('sweepClosedPdfViewPositions', () => { ['/b.pdf:pdf', position(7)], ['/b.pdf::tab-2:pdf', position(8)] ]) - sweepClosedPdfViewPositions(cache, '/a.pdf') + sweepClosedPdfViewPositions(cache, ['/a.pdf']) expect([...cache.keys()]).toEqual(['/b.pdf:pdf', '/b.pdf::tab-2:pdf']) }) @@ -36,13 +79,13 @@ describe('sweepClosedPdfViewPositions', () => { ['/report.pdf:pdf', position(2)], ['/report.pdf.bak:pdf', position(3)] ]) - sweepClosedPdfViewPositions(cache, '/report.pdf') + sweepClosedPdfViewPositions(cache, ['/report.pdf']) expect([...cache.keys()]).toEqual(['/report.pdf.bak:pdf']) }) it('is a no-op when the file has no cached position', () => { const cache = new Map([['/b.pdf:pdf', position(7)]]) - sweepClosedPdfViewPositions(cache, '/a.pdf') + sweepClosedPdfViewPositions(cache, ['/a.pdf']) expect(cache.size).toBe(1) }) }) diff --git a/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.ts b/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.ts index d53f0212723..a553625c838 100644 --- a/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.ts +++ b/src/renderer/src/components/editor/closed-editor-tab-cache-sweep.ts @@ -1,23 +1,53 @@ import type { PdfViewPosition } from '@/lib/scroll-cache' -function deleteCacheEntriesByPrefix(cache: Map, prefix: string): void { +/** + * Drops every pane-scoped (`::…`) entry belonging to any of `owners` in one pass over + * the cache, rather than one pass per owner. Split out from the cleanup hook so it is testable + * without pulling in the hook's `monaco-editor` import. + */ +export function deletePaneScopedCacheEntries( + cache: Map, + owners: readonly string[] +): void { + if (owners.length === 0) { + return + } + + const ownerSet = new Set(owners) for (const key of cache.keys()) { - if (key.startsWith(prefix)) { + if (hasPaneScopeOwner(key, ownerSet)) { cache.delete(key) } } } -/** - * Release the PDF positions a closed edit tab owns. Split out from the cleanup - * hook so it is testable without pulling in the hook's `monaco-editor` import. - */ +/** Equivalent to `key.startsWith(`${owner}::`)` for any owner in the set, probing `::` boundaries. */ +function hasPaneScopeOwner(key: string, owners: ReadonlySet): boolean { + for ( + let boundary = key.indexOf('::'); + boundary !== -1; + // `+ 1`, not `+ 2`: in a `:::` run the second `::` starts one char after the first, and it can + // be the only boundary an owner ends at (owner `a:` against key `a:::b`). Skipping to `+ 2` + // steps over it and silently leaks that entry. + boundary = key.indexOf('::', boundary + 1) + ) { + if (owners.has(key.slice(0, boundary))) { + return true + } + } + + return false +} + +/** Release the PDF positions closed edit tabs own. */ export function sweepClosedPdfViewPositions( cache: Map, - filePath: string + filePaths: readonly string[] ): void { // Why: the `::`-scoped sweep does not cover the single-colon suffix, so the // unscoped key needs its own delete (same shape as :rich / :preview). - cache.delete(`${filePath}:pdf`) - deleteCacheEntriesByPrefix(cache, `${filePath}::`) + for (const filePath of filePaths) { + cache.delete(`${filePath}:pdf`) + } + deletePaneScopedCacheEntries(cache, filePaths) } diff --git a/src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts b/src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts new file mode 100644 index 00000000000..f23876fcf35 --- /dev/null +++ b/src/renderer/src/components/editor/closed-editor-tab-disposal.test.ts @@ -0,0 +1,259 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + diffViewStateCache, + editorSelectionCache, + pdfViewPositionCache, + scrollTopCache +} from '@/lib/scroll-cache' +import type { OpenFile } from '@/store/slices/editor' +import { disposeClosedEditorTabs } from './closed-editor-tab-disposal' +import { + getDiffViewerMonacoModelPaths, + getDiffViewerMonacoModelPathPrefixes, + type MonacoModelRegistry +} from './diff-monaco-model-disposal' + +const CLOSED_DIFF_TAB_COUNT = 100 +const RETAINED_MODEL_COUNT = 320 + +type FakeModel = { + path: string + attached: boolean + disposed: boolean + dispose: () => void + isAttachedToEditor: () => boolean + uri: { toString: (skipEncoding?: boolean) => string } +} + +type FakeRegistry = MonacoModelRegistry & { + models: FakeModel[] + counters: { getModelsCalls: number; uriToStringCalls: number } +} + +function createRegistry(models: FakeModel[]): FakeRegistry { + const counters = { getModelsCalls: 0, uriToStringCalls: 0 } + const byPath = new Map(models.map((model) => [model.path, model])) + for (const model of models) { + model.uri.toString = () => { + counters.uriToStringCalls += 1 + return model.path + } + } + return { + models, + counters, + Uri: { parse: (value: string) => value }, + editor: { + getModel: (uri: unknown) => byPath.get(String(uri)) ?? null, + getModels: () => { + counters.getModelsCalls += 1 + return models + } + } + } +} + +function createModel(path: string, attached = false): FakeModel { + const model: FakeModel = { + path, + attached, + disposed: false, + dispose: () => { + model.disposed = true + }, + isAttachedToEditor: () => model.attached, + uri: { toString: () => path } + } + return model +} + +function diffTab(id: string): OpenFile { + return { id, mode: 'diff', filePath: `/repo/${id}.ts` } as OpenFile +} + +/** The pre-fix shape: one full registry scan, with both URI renderings, per owned prefix. */ +function disposeByPrefixPerTab(registry: FakeRegistry, prefixes: readonly string[]): void { + for (const prefix of prefixes) { + for (const model of registry.editor.getModels()) { + const uriString = model.uri.toString(true) + const encodedUriString = model.uri.toString() + if ( + uriString === prefix || + uriString.startsWith(`${prefix}:`) || + encodedUriString === prefix || + encodedUriString.startsWith(`${prefix}:`) + ) { + if (!model.isAttachedToEditor()) { + model.dispose() + } + } + } + } +} + +/** + * 100 closed diff tabs, of which 60 still hold retained models (some with a large-diff generation + * suffix, some attached), plus 200 unrelated retained models from other tabs. + */ +function buildScenario(): { + closedTabs: OpenFile[] + models: FakeModel[] + prefixes: string[] +} { + const closedTabs = Array.from({ length: CLOSED_DIFF_TAB_COUNT }, (_, i) => diffTab(`tab-${i}`)) + const models: FakeModel[] = [] + + for (let i = 0; i < 60; i += 1) { + const base = getDiffViewerMonacoModelPaths({ + modelKey: `tab-${i}`, + generationSuffix: '' + }) + models.push(createModel(base.originalModelPath, i % 10 === 0)) + models.push(createModel(base.modifiedModelPath)) + if (i % 3 === 0) { + const regenerated = getDiffViewerMonacoModelPaths({ + modelKey: `tab-${i}`, + generationSuffix: ':large-diff-generation:2' + }) + models.push(createModel(regenerated.originalModelPath)) + } + } + + // Still-open tabs and plain edit models the sweep must not touch. + for (let i = 0; models.length < RETAINED_MODEL_COUNT; i += 1) { + const stillOpen = getDiffViewerMonacoModelPaths({ + modelKey: `open-tab-${i}`, + generationSuffix: '' + }) + models.push(createModel(stillOpen.originalModelPath)) + models.push(createModel(`/repo/src/file-${i}.ts`)) + } + + const prefixes = closedTabs.flatMap((tab) => { + const { originalModelPathPrefix, modifiedModelPathPrefix } = + getDiffViewerMonacoModelPathPrefixes(tab.id) + return [originalModelPathPrefix, modifiedModelPathPrefix] + }) + + return { closedTabs, models, prefixes } +} + +beforeEach(() => { + scrollTopCache.clear() + editorSelectionCache.clear() + diffViewStateCache.clear() + pdfViewPositionCache.clear() +}) + +describe('disposeClosedEditorTabs', () => { + it('scans the model registry once per batch instead of twice per closed diff tab', () => { + const batched = buildScenario() + const batchedRegistry = createRegistry(batched.models) + disposeClosedEditorTabs(batchedRegistry, batched.closedTabs) + + const perTab = buildScenario() + const perTabRegistry = createRegistry(perTab.models) + disposeByPrefixPerTab(perTabRegistry, perTab.prefixes) + + // Pre-fix: 2 scans per closed tab, each rendering both URI forms for every retained model. + expect(perTabRegistry.counters.getModelsCalls).toBe(CLOSED_DIFF_TAB_COUNT * 2) + expect(perTabRegistry.counters.uriToStringCalls).toBe( + CLOSED_DIFF_TAB_COUNT * 2 * perTab.models.length * 2 + ) + + expect(batchedRegistry.counters.getModelsCalls).toBe(1) + expect(batchedRegistry.counters.uriToStringCalls).toBeLessThanOrEqual(batched.models.length * 2) + }) + + it('disposes exactly the models the per-tab sweep disposed', () => { + const batched = buildScenario() + disposeClosedEditorTabs(createRegistry(batched.models), batched.closedTabs) + + const perTab = buildScenario() + disposeByPrefixPerTab(createRegistry(perTab.models), perTab.prefixes) + + const disposedPaths = (models: FakeModel[]): string[] => + models + .filter((m) => m.disposed) + .map((m) => m.path) + .sort() + + expect(disposedPaths(batched.models)).toEqual(disposedPaths(perTab.models)) + expect(disposedPaths(batched.models).length).toBeGreaterThan(0) + // Attached models survive, as does everything owned by a still-open tab. + expect(batched.models.filter((m) => m.attached).every((m) => !m.disposed)).toBe(true) + expect( + batched.models.filter((m) => m.path.includes('open-tab-')).every((m) => !m.disposed) + ).toBe(true) + }) + + it('sweeps pane-scoped cache entries for closed edit tabs in one pass per cache', () => { + scrollTopCache.set('/repo/a.ts', 10) + scrollTopCache.set('/repo/a.ts::pane-1', 20) + scrollTopCache.set('/repo/a.ts:rich', 30) + scrollTopCache.set('/repo/b.ts::pane-1', 40) + editorSelectionCache.set('/repo/a.ts::pane-2', [] as never) + pdfViewPositionCache.set('/repo/a.ts:pdf', { + pageNumber: 1, + top: 0, + left: 0 + }) + pdfViewPositionCache.set('/repo/a.ts::pane-1:pdf', { + pageNumber: 2, + top: 0, + left: 0 + }) + + disposeClosedEditorTabs(createRegistry([]), [ + { id: '/repo/a.ts', mode: 'edit', filePath: '/repo/a.ts' } as OpenFile + ]) + + expect([...scrollTopCache.keys()]).toEqual(['/repo/b.ts::pane-1']) + expect(editorSelectionCache.size).toBe(0) + expect(pdfViewPositionCache.size).toBe(0) + }) + + it('drops diff view state and preview scroll entries for closed diff tabs', () => { + diffViewStateCache.set('tab-1', {} as never) + diffViewStateCache.set('tab-1::pane-1', {} as never) + diffViewStateCache.set('tab-10', {} as never) + scrollTopCache.set('tab-1:preview', 5) + scrollTopCache.set('tab-1::pane-1', 6) + + disposeClosedEditorTabs(createRegistry([]), [diffTab('tab-1')]) + + expect([...diffViewStateCache.keys()]).toEqual(['tab-10']) + expect(scrollTopCache.size).toBe(0) + }) + + // Why this is not covered by the parity test above: `buildScenario` closes tab-0..tab-99, so + // tab-10 is in the closed batch too. Prefix bleed from tab-1 would dispose tab-10's models, but + // the per-tab oracle disposes them as well via tab-10's own prefix, so the two agree and the + // assertion still passes. Isolating it needs a still-OPEN tab whose id extends a closed one. + it('does not dispose a still-open tab whose id extends a closed tab id', () => { + const closed = getDiffViewerMonacoModelPaths({ modelKey: 'tab-1', generationSuffix: '' }) + const stillOpen = getDiffViewerMonacoModelPaths({ modelKey: 'tab-10', generationSuffix: '' }) + const models = [ + createModel(closed.originalModelPath), + createModel(closed.modifiedModelPath), + createModel(stillOpen.originalModelPath), + createModel(stillOpen.modifiedModelPath) + ] + + // Batched entry point on purpose: the owned prefixes become a Set probed at the URI's own `:` + // boundaries, which is a different predicate from the pre-batch per-prefix `startsWith`. + disposeClosedEditorTabs(createRegistry(models), [diffTab('tab-1'), diffTab('tab-2')]) + + expect(models.filter((m) => m.disposed).map((m) => m.path)).toEqual([ + closed.originalModelPath, + closed.modifiedModelPath + ]) + }) + + it('is a no-op when nothing closed', () => { + const registry = createRegistry([createModel('diff:original:tab-1:tab-1')]) + disposeClosedEditorTabs(registry, []) + expect(registry.counters.getModelsCalls).toBe(0) + expect(registry.models[0].disposed).toBe(false) + }) +}) diff --git a/src/renderer/src/components/editor/closed-editor-tab-disposal.ts b/src/renderer/src/components/editor/closed-editor-tab-disposal.ts new file mode 100644 index 00000000000..ddde3a74502 --- /dev/null +++ b/src/renderer/src/components/editor/closed-editor-tab-disposal.ts @@ -0,0 +1,86 @@ +import type { OpenFile } from '@/store/slices/editor' +import { + editorSelectionCache, + diffViewStateCache, + pdfViewPositionCache, + scrollTopCache +} from '@/lib/scroll-cache' +import { + disposeUnattachedMonacoModelsByPathPrefixes, + getDiffViewerMonacoModelPathPrefixes, + type MonacoModelRegistry +} from './diff-monaco-model-disposal' +import { + deletePaneScopedCacheEntries, + sweepClosedPdfViewPositions +} from './closed-editor-tab-cache-sweep' + +/** + * Releases the Monaco models and view-state cache entries owned by a batch of closed tabs. + * + * Why the batch shape: every prefix sweep here is a full scan of a shared registry or cache, so + * doing one per closed tab makes "close all"/worktree-switch quadratic in retained models. Takes + * the monaco namespace as an argument so it stays testable without importing `monaco-editor`. + */ +export function disposeClosedEditorTabs( + monacoRegistry: MonacoModelRegistry, + closedFiles: readonly OpenFile[] +): void { + if (closedFiles.length === 0) { + return + } + + const diffModelPathPrefixes: string[] = [] + const scrollTopOwners: string[] = [] + const editorSelectionOwners: string[] = [] + const diffViewStateOwners: string[] = [] + const closedPdfFilePaths: string[] = [] + + for (const closedFile of closedFiles) { + switch (closedFile.mode) { + case 'edit': + // Why: the edit model URI is constructed via monaco.Uri.parse(filePath) + // to match @monaco-editor/react's `path` prop convention. + monacoRegistry.editor.getModel(monacoRegistry.Uri.parse(closedFile.filePath))?.dispose() + scrollTopCache.delete(closedFile.filePath) + // Why: markdown and mermaid surfaces keep mode-scoped scroll positions. + scrollTopCache.delete(`${closedFile.filePath}:rich`) + scrollTopCache.delete(`${closedFile.filePath}:preview`) + scrollTopCache.delete(`${closedFile.filePath}:mermaid-diagram`) + editorSelectionCache.delete(closedFile.filePath) + scrollTopOwners.push(closedFile.filePath) + editorSelectionOwners.push(closedFile.filePath) + // Why: only 'edit' tabs ever get a PDF scroll key (see EditorContent). + closedPdfFilePaths.push(closedFile.filePath) + break + case 'markdown-preview': + // Why: preview tabs own pane-scoped preview scroll cache entries even + // though they do not retain Monaco models. + scrollTopCache.delete(`${closedFile.id}:preview`) + scrollTopOwners.push(closedFile.id) + break + case 'diff': { + // Why: kept diff models are keyed by tab id, and fallback recovery can + // append generation suffixes; closing the tab owns that whole namespace. + const { originalModelPathPrefix, modifiedModelPathPrefix } = + getDiffViewerMonacoModelPathPrefixes(closedFile.id) + diffModelPathPrefixes.push(originalModelPathPrefix, modifiedModelPathPrefix) + diffViewStateCache.delete(closedFile.id) + diffViewStateOwners.push(closedFile.id) + scrollTopCache.delete(`${closedFile.id}:preview`) + scrollTopOwners.push(closedFile.id) + break + } + case 'conflict-review': + break + case 'check-details': + break + } + } + + disposeUnattachedMonacoModelsByPathPrefixes(monacoRegistry, diffModelPathPrefixes) + deletePaneScopedCacheEntries(scrollTopCache, scrollTopOwners) + deletePaneScopedCacheEntries(editorSelectionCache, editorSelectionOwners) + deletePaneScopedCacheEntries(diffViewStateCache, diffViewStateOwners) + sweepClosedPdfViewPositions(pdfViewPositionCache, closedPdfFilePaths) +} diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-render.test.tsx b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-render.test.tsx new file mode 100644 index 00000000000..8b5361cb8f0 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-render.test.tsx @@ -0,0 +1,182 @@ +// @vitest-environment happy-dom + +import React, { act, useCallback, useMemo, useState, type ReactElement } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types' +import type { CombinedDiffFileTreeRow as CombinedDiffFileTreeRowComponent } from './combined-diff-file-tree-row' + +const rowRenders = vi.hoisted(() => ({ count: 0 })) +const mountedRows = vi.hoisted(() => ({ count: 0 })) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ combinedDiffFileTreeWidth: 420, setCombinedDiffFileTreeWidth: () => {} }) +})) + +vi.mock('./combined-diff-file-tree-row', async (importOriginal) => { + const actual = (await importOriginal()) as { + CombinedDiffFileTreeRow: typeof CombinedDiffFileTreeRowComponent + } + const react = await import('react') + const Row = actual.CombinedDiffFileTreeRow + // Why: memo with React's default shallow compare, so this counts exactly the render commits + // the real memo'd row would have performed. + const CountingRow = react.memo((props: React.ComponentProps) => { + rowRenders.count += 1 + react.useEffect(() => { + mountedRows.count += 1 + return () => { + mountedRows.count -= 1 + } + }, []) + return react.createElement(Row, props) + }) + return { ...actual, CombinedDiffFileTreeRow: CountingRow } +}) + +const { CombinedDiffFileTree } = await import('./combined-diff-file-tree') +const { createCombinedDiffSectionIndexMap } = + await import('../resolve-changes/combined-diff-section-identity') +const { useCombinedDiffSectionIndexMap } = + await import('../resolve-changes/use-combined-diff-section-index-map') +const { getCombinedDiffBranchEntriesInTreeOrder } = await import('./combined-diff-file-tree-filter') + +const EMPTY_VIEWED_KEYS: ReadonlySet = new Set() + +class NoopResizeObserver implements ResizeObserver { + observe(): void {} + unobserve(): void {} + disconnect(): void {} +} + +let host: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + rowRenders.count = 0 + mountedRows.count = 0 + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) + vi.stubGlobal('ResizeObserver', NoopResizeObserver) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +type TestSection = { key: string; loading: boolean } + +/** `fileCount` files spread over `directoryCount` directories, in the viewer's own tree order. */ +function buildEntries(fileCount: number, directoryCount: number): GitBranchChangeEntry[] { + const raw: GitBranchChangeEntry[] = Array.from({ length: fileCount }, (_, index) => ({ + path: `src/dir${String(index % directoryCount).padStart(2, '0')}/file-${String(index).padStart(4, '0')}.ts`, + status: 'modified' + })) + return getCombinedDiffBranchEntriesInTreeOrder('commit', raw) +} + +function buildSections(entries: readonly GitBranchChangeEntry[]): TestSection[] { + return entries.map((entry) => ({ key: `combined-commit:${entry.path}`, loading: true })) +} + +let loadSectionAt: (index: number) => void = () => {} + +/** + * Mirrors how both PR viewers feed the tree: an on-demand section load replaces the sections + * array, and the navigate callback closes over the section index map. + */ +function TreeHarness({ + entries, + initialSections, + stableSectionIndexMap +}: { + entries: readonly GitBranchChangeEntry[] + initialSections: TestSection[] + stableSectionIndexMap: boolean +}): ReactElement { + const [sections, setSections] = useState(initialSections) + loadSectionAt = (index) => { + setSections((prev) => + prev.map((section, sectionIndex) => + sectionIndex === index ? { ...section, loading: false } : section + ) + ) + } + const rebuiltMap = useMemo(() => createCombinedDiffSectionIndexMap(sections), [sections]) + const cachedMap = useCombinedDiffSectionIndexMap({ entrySignature: 'pr-1', sections }) + const sectionIndexByKey = stableSectionIndexMap ? cachedMap : rebuiltMap + const onNavigate = useCallback(() => { + void sectionIndexByKey + }, [sectionIndexByKey]) + + return ( + {}} + onNavigate={onNavigate} + /> + ) +} + +function mountedRowCount(): number { + return mountedRows.count +} + +function renderHarness( + entries: readonly GitBranchChangeEntry[], + sections: TestSection[], + stableSectionIndexMap: boolean +): void { + act(() => { + root.render( + + ) + }) +} + +/** One section load per commit, the way lazy loads land while the user scrolls. */ +function runScrollPass(loadCount: number): number { + rowRenders.count = 0 + for (let index = 0; index < loadCount; index += 1) { + act(() => loadSectionAt(index)) + } + return rowRenders.count +} + +describe('combined diff file tree re-renders on section loads', () => { + const SMALL_FILE_COUNT = 30 + const SCROLL_PASS_LOADS = 10 + + it('re-renders every row per section load when the section index map is rebuilt', () => { + const entries = buildEntries(SMALL_FILE_COUNT, 3) + renderHarness(entries, buildSections(entries), false) + const rowCount = mountedRowCount() + expect(rowCount).toBeGreaterThan(SMALL_FILE_COUNT) + + expect(runScrollPass(SCROLL_PASS_LOADS)).toBe(rowCount * SCROLL_PASS_LOADS) + }) + + it('re-renders no rows per section load when the section index map keeps its identity', () => { + const entries = buildEntries(SMALL_FILE_COUNT, 3) + renderHarness(entries, buildSections(entries), true) + expect(mountedRowCount()).toBeGreaterThan(SMALL_FILE_COUNT) + + expect(runScrollPass(SCROLL_PASS_LOADS)).toBe(0) + }) +}) diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.ts b/src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.ts index a237bc24c0d..fdf3cb78539 100644 --- a/src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.ts +++ b/src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.ts @@ -2,10 +2,8 @@ import React, { useCallback, useRef, useState } from 'react' import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types' import type { GitStatusEntry } from '../../../../../../shared/git-status-types' import type { DiffSection } from '../../diff-section-types' -import { - createCombinedDiffSectionIndexMap, - type CombinedDiffFileTreeMode -} from '../resolve-changes/combined-diff-section-identity' +import type { CombinedDiffFileTreeMode } from '../resolve-changes/combined-diff-section-identity' +import { useCombinedDiffSectionIndexMap } from '../resolve-changes/use-combined-diff-section-index-map' import { handleCombinedDiffFileTreeNavigation } from './combined-diff-file-tree-navigation' import { isCombinedDiffSectionViewed } from './combined-diff-file-tree-filter' @@ -37,33 +35,7 @@ export function useCombinedDiffTreeNavigation({ toggleSection: (index: number) => void treeMode: CombinedDiffFileTreeMode }): CombinedDiffTreeNavigation { - const sectionIndexCacheRef = useRef<{ - entrySignature: string - sectionCount: number - map: Map - keys: string[] - } | null>(null) - const sectionIndexByKey = React.useMemo(() => { - const previous = sectionIndexCacheRef.current - // Section content/loading updates preserve entry order and keys. The entry signature and - // count usually change when the navigable structure changes, but compare keys as a guard for - // same-sized/reused signatures (and to keep this cache correct if a caller rebuilds sections). - if ( - previous?.entrySignature === entrySignature && - previous.sectionCount === sections.length && - sections.every((section, index) => previous.keys[index] === section.key) - ) { - return previous.map - } - const map = createCombinedDiffSectionIndexMap(sections) - sectionIndexCacheRef.current = { - entrySignature, - sectionCount: sections.length, - map, - keys: sections.map((section) => section.key) - } - return map - }, [entrySignature, sections]) + const sectionIndexByKey = useCombinedDiffSectionIndexMap({ entrySignature, sections }) const sectionIndexByKeyRef = useRef>(sectionIndexByKey) sectionIndexByKeyRef.current = sectionIndexByKey diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-registry.ts b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-registry.ts index c74d03d7692..370dbd6ebf0 100644 --- a/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-registry.ts +++ b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-registry.ts @@ -47,11 +47,10 @@ export function useCombinedDiffSectionLoadRegistry( const loadSectionRef = useRef<(index: number) => Promise>(async () => {}) const retrySectionRef = useRef<(index: number) => void>(() => {}) const requestSectionReloadRef = useRef<(index: number) => void>(() => {}) - const loadSchedulerRef = useRef( - createCombinedDiffLoadScheduler({ - loadSection: (index) => loadSectionRef.current(index) - }) - ) + const loadSchedulerRef = useRef>(undefined!) + loadSchedulerRef.current ??= createCombinedDiffLoadScheduler({ + loadSection: (index) => loadSectionRef.current(index) + }) sectionsRef.current = sections useEffect(() => { diff --git a/src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.ts b/src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.ts index 9f657e3abd0..7fcc833df91 100644 --- a/src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.ts +++ b/src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.ts @@ -1,4 +1,4 @@ -import { useCallback, useLayoutEffect, useRef } from 'react' +import { useCallback, useLayoutEffect, useRef, useState } from 'react' import type React from 'react' import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' import type { GitStatusEntry } from '../../../../../../shared/git-status-types' @@ -65,13 +65,16 @@ export function useCombinedDiffViewRestore({ sectionLoadTokensRef } = registry - const scrollOffsetRef = useRef(combinedDiffScrollTopCache.get(viewStateKey) ?? 0) - const scrollAnchorRef = useRef( - combinedDiffScrollAnchorCache.get(viewStateKey) ?? null - ) - const latestDomScrollAnchorRef = useRef( - combinedDiffScrollAnchorCache.get(viewStateKey) ?? null - ) + // Why useState and not `useRef(expr)`: the latter re-reads all three caches on every render and + // throws the result away, and an anchor seeds legitimately to null so a nullish guard would keep + // re-reading. useState's initializer runs once without writing a ref during render. + const [restoreSeed] = useState<{ offset: number; anchor: VirtualizedScrollAnchor }>(() => ({ + offset: combinedDiffScrollTopCache.get(viewStateKey) ?? 0, + anchor: combinedDiffScrollAnchorCache.get(viewStateKey) ?? null + })) + const scrollOffsetRef = useRef(restoreSeed.offset) + const scrollAnchorRef = useRef(restoreSeed.anchor) + const latestDomScrollAnchorRef = useRef(restoreSeed.anchor) // Why: tab/worktree switches unmount this viewer; cache by pane key so remount restores sections+scroll before repaint. const initializedEntryStateRef = useRef<{ diff --git a/src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-section-index-map.test.tsx b/src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-section-index-map.test.tsx new file mode 100644 index 00000000000..cc008efb694 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-section-index-map.test.tsx @@ -0,0 +1,63 @@ +// @vitest-environment happy-dom + +import { renderHook } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { useCombinedDiffSectionIndexMap } from './use-combined-diff-section-index-map' + +type Section = { key: string; loading: boolean } + +function sectionsFor(keys: readonly string[], loadedKeys: readonly string[] = []): Section[] { + return keys.map((key) => ({ key, loading: !loadedKeys.includes(key) })) +} + +describe('useCombinedDiffSectionIndexMap', () => { + const keys = ['combined-commit:a.ts', 'combined-commit:b.ts', 'combined-commit:c.ts'] + + it('keeps the map identity across a section load that leaves the keys alone', () => { + const { result, rerender } = renderHook( + ({ sections }: { sections: Section[] }) => + useCombinedDiffSectionIndexMap({ entrySignature: 'pr-1', sections }), + { initialProps: { sections: sectionsFor(keys) } } + ) + const first = result.current + + // An on-demand load replaces the array and one section object; keys are untouched. + rerender({ sections: sectionsFor(keys, ['combined-commit:b.ts']) }) + + expect(result.current).toBe(first) + expect([...result.current]).toEqual([ + ['combined-commit:a.ts', 0], + ['combined-commit:b.ts', 1], + ['combined-commit:c.ts', 2] + ]) + }) + + it('rebuilds when a section key changes in place', () => { + const { result, rerender } = renderHook( + ({ sections }: { sections: Section[] }) => + useCombinedDiffSectionIndexMap({ entrySignature: 'pr-1', sections }), + { initialProps: { sections: sectionsFor(keys) } } + ) + const first = result.current + + rerender({ sections: sectionsFor(['combined-commit:a.ts', 'combined-commit:renamed.ts']) }) + + expect(result.current).not.toBe(first) + expect(result.current.get('combined-commit:renamed.ts')).toBe(1) + expect(result.current.has('combined-commit:b.ts')).toBe(false) + }) + + it('rebuilds when the entry set changes even if the keys happen to match', () => { + const { result, rerender } = renderHook( + ({ entrySignature }: { entrySignature: string }) => + useCombinedDiffSectionIndexMap({ entrySignature, sections: sectionsFor(keys) }), + { initialProps: { entrySignature: 'pr-1' } } + ) + const first = result.current + + rerender({ entrySignature: 'pr-2' }) + + expect(result.current).not.toBe(first) + expect([...result.current]).toEqual([...first]) + }) +}) diff --git a/src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-section-index-map.ts b/src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-section-index-map.ts new file mode 100644 index 00000000000..0cf8555425f --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-section-index-map.ts @@ -0,0 +1,49 @@ +import { useLayoutEffect, useMemo, useRef } from 'react' +import { createCombinedDiffSectionIndexMap } from './combined-diff-section-identity' + +type CombinedDiffSectionIndexCache = { + entrySignature: string + sections: readonly { key: string }[] + map: Map +} + +/** + * Section-key to section-index map that keeps its identity while the section keys do. + * + * On-demand section loads replace the sections array on every fetch, so a freshly built Map would + * be a memo miss for every consumer — the file tree would re-render all of its rows continuously + * while the user scrolls a large diff. + */ +export function useCombinedDiffSectionIndexMap({ + entrySignature, + sections +}: { + entrySignature: string + sections: readonly { key: string }[] +}): Map { + const cacheRef = useRef(null) + const sectionIndexByKey = useMemo(() => { + const previous = cacheRef.current + // Section content/loading updates preserve entry order and keys. The entry signature usually + // changes when the navigable structure changes, but compare keys as a guard for reused + // signatures (and to keep this cache correct if a caller rebuilds sections). + if ( + previous !== null && + previous.entrySignature === entrySignature && + previous.sections.length === sections.length && + sections.every((section, index) => previous.sections[index]?.key === section.key) + ) { + return previous.map + } + return createCombinedDiffSectionIndexMap(sections) + }, [entrySignature, sections]) + + // Why a committed write and not a render-phase one: React can discard a render, and a cache + // seeded from abandoned work would hand a later render a map for sections that never existed. + // Layout, not passive, so a synchronous re-render inside the same commit still sees this cache. + useLayoutEffect(() => { + cacheRef.current = { entrySignature, sections, map: sectionIndexByKey } + }, [entrySignature, sectionIndexByKey, sections]) + + return sectionIndexByKey +} diff --git a/src/renderer/src/components/editor/diff-monaco-model-disposal.test.ts b/src/renderer/src/components/editor/diff-monaco-model-disposal.test.ts index dcefc61195d..75ccc46eff2 100644 --- a/src/renderer/src/components/editor/diff-monaco-model-disposal.test.ts +++ b/src/renderer/src/components/editor/diff-monaco-model-disposal.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest' import { disposeUnattachedDiffViewerMonacoModels, disposeUnattachedMonacoModelPaths, - disposeUnattachedMonacoModelsByPathPrefix, + disposeUnattachedMonacoModelsByPathPrefixes, getDiffViewerMonacoModelPathPrefixes, getDiffViewerMonacoModelPaths } from './diff-monaco-model-disposal' @@ -139,7 +139,7 @@ describe('diff Monaco model disposal', () => { const monacoRegistry = createRegistry(models) const { originalModelPathPrefix } = getDiffViewerMonacoModelPathPrefixes('tab-1') - disposeUnattachedMonacoModelsByPathPrefix(monacoRegistry, originalModelPathPrefix) + disposeUnattachedMonacoModelsByPathPrefixes(monacoRegistry, [originalModelPathPrefix]) expect(baseDispose).toHaveBeenCalledOnce() expect(generatedDispose).toHaveBeenCalledOnce() @@ -177,7 +177,7 @@ describe('diff Monaco model disposal', () => { const monacoRegistry = createRegistry(models) const { originalModelPathPrefix } = getDiffViewerMonacoModelPathPrefixes('foo') - disposeUnattachedMonacoModelsByPathPrefix(monacoRegistry, originalModelPathPrefix) + disposeUnattachedMonacoModelsByPathPrefixes(monacoRegistry, [originalModelPathPrefix]) expect(ownedDispose).toHaveBeenCalledOnce() expect(siblingDispose).not.toHaveBeenCalled() diff --git a/src/renderer/src/components/editor/diff-monaco-model-disposal.ts b/src/renderer/src/components/editor/diff-monaco-model-disposal.ts index 8252abc8381..2a1b5ceed5d 100644 --- a/src/renderer/src/components/editor/diff-monaco-model-disposal.ts +++ b/src/renderer/src/components/editor/diff-monaco-model-disposal.ts @@ -16,7 +16,7 @@ type DisposableMonacoModel = Pick, + bounds: { shortestPrefixLength: number; longestPrefixLength: number } +): boolean { + if (ownedPrefixes.has(uriString)) { + return true + } + + for ( + let boundary = uriString.indexOf(':'); + boundary !== -1 && boundary <= bounds.longestPrefixLength; + boundary = uriString.indexOf(':', boundary + 1) + ) { + if ( + boundary >= bounds.shortestPrefixLength && + ownedPrefixes.has(uriString.slice(0, boundary)) + ) { + return true + } + } + + return false +} + function disposeUnattachedMonacoModel(model: DisposableMonacoModel | null): void { if (!model || model.isAttachedToEditor()) { return diff --git a/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts b/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts index 56e25045353..e86fbbc43f6 100644 --- a/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts +++ b/src/renderer/src/components/editor/useClosedEditorTabCleanup.ts @@ -1,80 +1,22 @@ import { useEffect, useRef } from 'react' import * as monaco from 'monaco-editor' import type { OpenFile } from '@/store/slices/editor' -import { - editorSelectionCache, - diffViewStateCache, - pdfViewPositionCache, - scrollTopCache -} from '@/lib/scroll-cache' -import { - disposeUnattachedMonacoModelsByPathPrefix, - getDiffViewerMonacoModelPathPrefixes -} from './diff-monaco-model-disposal' -import { sweepClosedPdfViewPositions } from './closed-editor-tab-cache-sweep' - -function deleteCacheEntriesByPrefix(cache: Map, prefix: string): void { - for (const key of cache.keys()) { - if (key.startsWith(prefix)) { - cache.delete(key) - } - } -} +import { disposeClosedEditorTabs } from './closed-editor-tab-disposal' export function useClosedEditorTabCleanup(openFiles: OpenFile[]): void { const prevOpenFilesRef = useRef>(new Map()) useEffect(() => { const currentFilesById = new Map(openFiles.map((f) => [f.id, f])) + const closedFiles: OpenFile[] = [] for (const [prevId, prevFile] of prevOpenFilesRef.current) { if (!currentFilesById.has(prevId)) { - disposeClosedEditorTab(prevId, prevFile) + closedFiles.push(prevFile) } } + // Why one call for the whole removal batch: each sweep scans a shared registry/cache, so + // per-tab sweeps make a "close all" quadratic in retained models. + disposeClosedEditorTabs(monaco, closedFiles) prevOpenFilesRef.current = currentFilesById }, [openFiles]) } - -function disposeClosedEditorTab(prevId: string, prevFile: OpenFile): void { - switch (prevFile.mode) { - case 'edit': - // Why: the edit model URI is constructed via monaco.Uri.parse(filePath) - // to match @monaco-editor/react's `path` prop convention. - monaco.editor.getModel(monaco.Uri.parse(prevFile.filePath))?.dispose() - scrollTopCache.delete(prevFile.filePath) - deleteCacheEntriesByPrefix(scrollTopCache, `${prevFile.filePath}::`) - // Why: markdown and mermaid surfaces keep mode-scoped scroll positions. - scrollTopCache.delete(`${prevFile.filePath}:rich`) - scrollTopCache.delete(`${prevFile.filePath}:preview`) - scrollTopCache.delete(`${prevFile.filePath}:mermaid-diagram`) - editorSelectionCache.delete(prevFile.filePath) - deleteCacheEntriesByPrefix(editorSelectionCache, `${prevFile.filePath}::`) - // Why: only 'edit' tabs ever get a PDF scroll key (see EditorContent). - sweepClosedPdfViewPositions(pdfViewPositionCache, prevFile.filePath) - break - case 'markdown-preview': - // Why: preview tabs own pane-scoped preview scroll cache entries even - // though they do not retain Monaco models. - scrollTopCache.delete(`${prevFile.id}:preview`) - deleteCacheEntriesByPrefix(scrollTopCache, `${prevFile.id}::`) - break - case 'diff': - // Why: kept diff models are keyed by tab id, and fallback recovery can - // append generation suffixes; closing the tab owns that whole namespace. - { - const { originalModelPathPrefix, modifiedModelPathPrefix } = - getDiffViewerMonacoModelPathPrefixes(prevId) - disposeUnattachedMonacoModelsByPathPrefix(monaco, originalModelPathPrefix) - disposeUnattachedMonacoModelsByPathPrefix(monaco, modifiedModelPathPrefix) - } - diffViewStateCache.delete(prevId) - deleteCacheEntriesByPrefix(diffViewStateCache, `${prevId}::`) - scrollTopCache.delete(`${prevId}:preview`) - deleteCacheEntriesByPrefix(scrollTopCache, `${prevId}::`) - break - case 'conflict-review': - break - case 'check-details': - break - } -} diff --git a/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts b/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts index 2ac04a9ca99..c696374526e 100644 --- a/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts +++ b/src/renderer/src/components/emulator-pane/use-emulator-pane-session.ts @@ -39,11 +39,13 @@ export function useEmulatorPaneSession({ const configuredDefaultUdid = useAppStore( (state) => state.settings?.mobileEmulatorDefaultDeviceUdid ?? null ) - const prelaunchedSessionRef = useRef( + // Why the lazy initializer: the consume deletes the handoff entry, and a `useRef(expr)` argument + // re-runs every render — so a prelaunch registered after mount was consumed and then discarded. + const [prelaunchedSession] = useState(() => consumePrelaunchedSimulatorSession(worktreeId) ) const prelaunchedState = buildPrelaunchedEmulatorSessionState( - prelaunchedSessionRef.current, + prelaunchedSession, configuredDefaultUdid ) const [selectedUdid, setSelectedUdid] = useState(prelaunchedState.selectedUdid) diff --git a/src/renderer/src/components/feature-wall/use-feature-wall-session-depth.ts b/src/renderer/src/components/feature-wall/use-feature-wall-session-depth.ts index c4d57043410..9ac9fcaa48f 100644 --- a/src/renderer/src/components/feature-wall/use-feature-wall-session-depth.ts +++ b/src/renderer/src/components/feature-wall/use-feature-wall-session-depth.ts @@ -47,13 +47,14 @@ export function useFeatureWallSessionDepth( visitedWorkbenchSteps: Set visitedReviewSteps: Set lastGroupId: FeatureWallWorkflowId | null - }>({ + }>(undefined!) + sessionDepthRef.current ??= { visitedWorkflows: new Set(), visitedAgentSteps: new Set(), visitedWorkbenchSteps: new Set(), visitedReviewSteps: new Set(), lastGroupId: null - }) + } const getTourDepthSummary = useCallback((): FeatureWallTourDepthSummary => { const session = sessionDepthRef.current diff --git a/src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.ts b/src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.ts index 7f98c90520a..23134e2a4a6 100644 --- a/src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.ts +++ b/src/renderer/src/components/feature-wall/use-feature-wall-tour-telemetry.ts @@ -77,7 +77,8 @@ export function useFeatureWallTourTelemetry(args: { getDepthSummary: () => FeatureWallTourDepthSummary }): { markExitAction: (exitAction: FeatureWallExitAction) => void } { const { isOpen, source, getDepthSummary } = args - const telemetryRef = useRef(createFeatureWallTourTelemetryState()) + const telemetryRef = useRef(undefined!) + telemetryRef.current ??= createFeatureWallTourTelemetryState() const sourceRef = useRef(source) const getDepthSummaryRef = useRef(getDepthSummary) // Why: close telemetry may emit from stable callbacks; keep the payload diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx index 3593643b50c..3e4de4227e7 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.shortcuts.test.tsx @@ -11,6 +11,10 @@ import { type FloatingPanelStoreState } from './floating-terminal-panel-test-fixtures' import { mocks, setupFloatingTerminalPanelTest } from './floating-terminal-panel-test-harness' +import { + RENAME_TERMINAL_TAB_EVENT, + type RenameTerminalTabDetail +} from '@/components/tab-bar/terminal-tab-rename-request' import { attachRef, bindFocusedFloatingPanelKeydown, @@ -159,6 +163,15 @@ vi.mock('@/components/ShortcutKeyCombo', async () => { return (await import('./floating-terminal-panel-component-stubs')).createShortcutKeyComboModule() }) +/** Tab ids the panel asked to rename, in dispatch order. */ +function dispatchedRenameTabIds(): string[] { + return vi + .mocked(window.dispatchEvent) + .mock.calls.map(([event]) => event as CustomEvent) + .filter((event) => event.type === RENAME_TERMINAL_TAB_EVENT) + .map((event) => event.detail.tabId) +} + describe('FloatingTerminalPanel close behavior', () => { beforeEach(setupFloatingTerminalPanelTest) @@ -434,7 +447,7 @@ describe('FloatingTerminalPanel close behavior', () => { expect(preventDefault).toHaveBeenCalledWith() expect(stopPropagation).toHaveBeenCalledWith() expect(stopImmediatePropagation).toHaveBeenCalledWith() - expect(mocks.setRenamingTabId).toHaveBeenCalledWith('tab-1') + expect(dispatchedRenameTabIds()).toEqual(['tab-1']) expect(mocks.setTabCustomTitle).not.toHaveBeenCalled() }) @@ -607,7 +620,7 @@ describe('FloatingTerminalPanel close behavior', () => { }) ) - expect(mocks.setRenamingTabId).not.toHaveBeenCalled() + expect(dispatchedRenameTabIds()).toEqual([]) }) it('leaves focused floating xterm tab index shortcuts to terminal-first terminals', async () => { diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-fixtures.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-fixtures.ts index ac6fc0c1cf4..b51cb9a6d78 100644 --- a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-fixtures.ts +++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-fixtures.ts @@ -15,7 +15,6 @@ export type FloatingPanelStoreState = { activeGroupIdByWorktree: Record activeTabIdByWorktree: Record expandedPaneByTabId: Record - renamingTabId: string | null createTab: ( worktreeId: string, groupId?: string, @@ -42,7 +41,6 @@ export type FloatingPanelStoreState = { activateTab: (tabId: string) => void setActiveTab: (tabId: string) => void setTabCustomTitle: (tabId: string, title: string | null) => void - setRenamingTabId: (tabId: string | null) => void setTabColor: (tabId: string, color: string | null) => void setTabPaneExpanded: (tabId: string, expanded: boolean) => void makePreviewFilePermanent: (fileId: string, tabId?: string) => void diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-harness.ts b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-harness.ts index 392a7bd224c..183508fa9aa 100644 --- a/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-harness.ts +++ b/src/renderer/src/components/floating-terminal/floating-terminal-panel-test-harness.ts @@ -59,7 +59,6 @@ export type FloatingTerminalPanelMocks = { pinFile: Mock setFloatingFocus: Mock<(state: { panelFocused: boolean; terminalFocused: boolean }) => void> setActiveTab: Mock - setRenamingTabId: Mock setTabColor: Mock setTabCustomTitle: Mock setTabPaneExpanded: Mock @@ -106,7 +105,6 @@ export const mocks: FloatingTerminalPanelMocks = { pinFile: vi.fn(), setFloatingFocus: vi.fn(), setActiveTab: vi.fn(), - setRenamingTabId: vi.fn(), setTabColor: vi.fn(), setTabCustomTitle: vi.fn(), setTabPaneExpanded: vi.fn(), @@ -133,7 +131,6 @@ function resetStore(tabs: TerminalTab[] = []): void { activeGroupIdByWorktree: {}, activeTabIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs[0]?.id ?? null }, expandedPaneByTabId: {}, - renamingTabId: null, activateTab: mocks.activateTab, closeBrowserTab: mocks.closeBrowserTab, closeFile: mocks.closeFile, @@ -147,7 +144,6 @@ function resetStore(tabs: TerminalTab[] = []): void { pinFile: mocks.pinFile, setActiveTab: mocks.setActiveTab, setTabCustomTitle: mocks.setTabCustomTitle, - setRenamingTabId: mocks.setRenamingTabId, setTabColor: mocks.setTabColor, setTabPaneExpanded: mocks.setTabPaneExpanded, browserDefaultUrl: 'about:blank', @@ -196,6 +192,7 @@ export async function setupFloatingTerminalPanelTest(): Promise { } vi.stubGlobal('window', { addEventListener: vi.fn(), + dispatchEvent: vi.fn(), api: { app: { getFloatingMarkdownDirectory: mocks.getFloatingMarkdownDirectory, diff --git a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts index b597810c0fd..ee1979451cf 100644 --- a/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts +++ b/src/renderer/src/components/floating-terminal/use-floating-terminal-panel-shortcuts.ts @@ -7,6 +7,7 @@ import { } from '@/lib/floating-workspace-shortcut-policy' import { isFloatingWorkspaceTerminalInputTarget } from '@/lib/floating-workspace-terminal-actions' import { getShortcutPlatform } from '@/lib/shortcut-platform' +import { requestTerminalTabRename } from '@/components/tab-bar/terminal-tab-rename-request' import { useAppStore } from '@/store' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import type { KeybindingContext, KeybindingMatchOptions } from '../../../../shared/keybindings' @@ -168,7 +169,7 @@ export function useFloatingTerminalPanelShortcuts({ return 'unmatched' } consume() - useAppStore.getState().setRenamingTabId(activeTab.id) + requestTerminalTabRename(activeTab.id) return 'handled' } consume() diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-section-index.test.tsx b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-section-index.test.tsx new file mode 100644 index 00000000000..b42adae565b --- /dev/null +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-section-index.test.tsx @@ -0,0 +1,108 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitHubPRFile } from '../../../../../shared/github/pull-request-types' +import type { PRFilesCombinedDiffViewerProps } from '@/components/github/pr-file-diff-mapping' + +const capturedSectionIndexMaps = vi.hoisted(() => ({ list: [] as ReadonlyMap[] })) +const loaders = vi.hoisted(() => ({ loadSection: (_index: number) => {} })) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ settings: { theme: 'dark' } }) +})) + +vi.mock('./pr-files-combined-diff-body', () => ({ + PRFilesCombinedDiffBody: (props: { + sectionIndexByKey: ReadonlyMap + loadSection: (index: number) => void + }) => { + capturedSectionIndexMaps.list.push(props.sectionIndexByKey) + loaders.loadSection = props.loadSection + return null + } +})) + +vi.mock('./pr-files-combined-diff-load', () => ({ + addPRFilesCombinedDiffLineComment: () => Promise.resolve(), + // Why: stands in for the network fetch, keeping only the state shape a real load produces — + // a new sections array with one patched section and untouched keys. + loadPRFilesCombinedDiffSection: ({ + index, + setSections + }: { + index: number + setSections: (updater: (prev: { key: string; loading: boolean }[]) => unknown) => void + }) => { + setSections((prev) => + prev.map((section, sectionIndex) => + sectionIndex === index ? { ...section, loading: false } : section + ) + ) + }, + retryPRFilesCombinedDiffSection: () => {}, + setAllPRFilesCombinedDiffSectionsCollapsed: () => {}, + togglePRFilesCombinedDiffSection: () => {} +})) + +const { PRFilesCombinedDiffViewer } = await import('./pr-files-combined-diff-viewer') + +let host: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + capturedSectionIndexMaps.list = [] + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + vi.restoreAllMocks() +}) + +function prFiles(count: number): GitHubPRFile[] { + return Array.from({ length: count }, (_, index) => ({ + path: `src/file-${String(index).padStart(3, '0')}.ts`, + status: 'modified' as const, + additions: 1, + deletions: 1, + isBinary: false + })) +} + +const viewerProps: PRFilesCombinedDiffViewerProps = { + files: prFiles(6), + comments: [], + repoPath: '/repo', + repoId: 'repo-1', + prNumber: 7, + prUrl: 'https://example.test/pr/7', + headSha: 'head', + baseSha: 'base', + pendingViewedPaths: new Set(), + onCommentAdded: () => {}, + onViewedChange: () => Promise.resolve(true) +} + +describe('inspect-pull-request combined diff section index map', () => { + it('keeps one map identity across on-demand section loads', () => { + act(() => { + root.render() + }) + const initialMap = capturedSectionIndexMaps.list.at(-1) + expect(initialMap?.size).toBe(viewerProps.files.length) + + for (let index = 0; index < viewerProps.files.length; index += 1) { + act(() => loaders.loadSection(index)) + } + + expect(capturedSectionIndexMaps.list.length).toBeGreaterThan(viewerProps.files.length) + expect(new Set(capturedSectionIndexMaps.list).size).toBe(1) + }) +}) diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-viewer.tsx b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-viewer.tsx index 43c460469cd..4ab38698994 100644 --- a/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-viewer.tsx +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-viewer.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from ' import { useVirtualizer } from '@tanstack/react-virtual' import type { editor as monacoEditor } from 'monaco-editor' import type { DecoratedDiffComment } from '@/components/diff-comments/decorated-diff-comment' -import { createCombinedDiffSectionIndexMap } from '../../editor/combined-diff/resolve-changes/combined-diff-section-identity' +import { useCombinedDiffSectionIndexMap } from '../../editor/combined-diff/resolve-changes/use-combined-diff-section-index-map' import { handleCombinedDiffFileTreeNavigation } from '../../editor/combined-diff/browse-files/combined-diff-file-tree-navigation' import { getDiffSectionRowEstimatedHeight } from '@/components/editor/diff-section-layout' import type { DiffSection } from '@/components/editor/diff-section-types' @@ -30,6 +30,7 @@ import { } from './pr-files-combined-diff-load' type PRFilesCombinedDiffSectionsProps = PRFilesCombinedDiffViewerProps & { + signature: string sideBySide: boolean setSideBySide: React.Dispatch> fileTreeCollapsed: boolean @@ -61,6 +62,7 @@ export function PRFilesCombinedDiffViewer( 0 && sections.every((section) => section.collapsed) - const sectionIndexByKey = useMemo(() => createCombinedDiffSectionIndexMap(sections), [sections]) + const sectionIndexByKey = useCombinedDiffSectionIndexMap({ entrySignature: signature, sections }) const viewedSectionKeys = useMemo( () => new Set(files.filter(isPRFileViewed).map((file) => getPRFileSectionKey(file.path))), [files] diff --git a/src/renderer/src/components/landing-github-star-state.ts b/src/renderer/src/components/landing-github-star-state.ts new file mode 100644 index 00000000000..098646b00f5 --- /dev/null +++ b/src/renderer/src/components/landing-github-star-state.ts @@ -0,0 +1,39 @@ +import { useEffect, useState, type Dispatch, type SetStateAction } from 'react' + +export type LandingStarState = 'loading' | 'starred' | 'not-starred' | 'web-fallback' | 'hidden' + +/** + * Resolve the viewer's Orca star state once per Landing mount. + * + * Why it lives here and not in the star button: the button renders inside a + * footer that is conditionally mounted on whether `repos` currently carries a + * GitHub provider identity, and `repos` is rewritten wholesale on every + * repo-catalog push. Every flicker of that condition re-ran the button's mount + * effect and forked another `gh api user/starred/...` (#18234). Landing itself + * only mounts when the user navigates, so the check runs once per visit. + */ +export function useLandingOrcaStarState(): [ + LandingStarState, + Dispatch> +] { + const [state, setState] = useState('loading') + + useEffect(() => { + let cancelled = false + void window.api.gh.checkOrcaStarred().then((result) => { + if (cancelled) { + return + } + if (result === null) { + setState('web-fallback') + } else { + setState(result ? 'starred' : 'not-starred') + } + }) + return () => { + cancelled = true + } + }, []) + + return [state, setState] +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx index 9aed41a0cdc..8e939401a47 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-context-menu.tsx @@ -56,6 +56,8 @@ export type NativeChatContextMenuActions = { onSetTitle: () => void onCopyTerminalId: () => void onCopyPaneId: () => void + canCopyAgentSessionId: boolean + onCopyAgentSessionId: () => void canClosePane: boolean onClosePane: () => void } @@ -75,6 +77,8 @@ export const emptyNativeChatContextMenuActions: Omit {}, onCopyTerminalId: () => {}, onCopyPaneId: () => {}, + canCopyAgentSessionId: false, + onCopyAgentSessionId: () => {}, canClosePane: false, onClosePane: () => {} } @@ -219,6 +223,15 @@ export function useNativeChatContextMenu({ rootRef, actions }: UseNativeChatCont 'Set Title…' )} + {actions.canCopyAgentSessionId ? ( + + + {translate( + 'components.terminalPane.TerminalContextMenu.copySessionId', + 'Copy Session ID' + )} + + ) : null} {translate( diff --git a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts index 59c4b80e389..9335965bdb8 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-live-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-live-session.ts @@ -117,7 +117,8 @@ export function useNativeChatLiveSession( // Appended messages accumulate separately from the snapshot so pagination doesn't lose in-flight appends; merged by id and capped to the read window (#6). const [appended, setAppended] = useState([]) // Id-dedup merger backing `appended`; caches the id→index map so each live frame costs O(incoming), not O(existing) (#18). - const appendMergerRef = useRef(createNativeChatMerger(NATIVE_CHAT_SOURCE_PRIORITY)) + const appendMergerRef = useRef>(undefined!) + appendMergerRef.current ??= createNativeChatMerger(NATIVE_CHAT_SOURCE_PRIORITY) const [hookState, hookStateStartedAt, hookHasWorkingSubagents] = useNativeChatHookStatus(paneKey) diff --git a/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts b/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts index 6d176f2ac55..eda95b074cc 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts @@ -22,7 +22,8 @@ export function useNativeChatRetainedSession( args.transcriptPath ?? null ]) const activeIdentityRef = useRef(identity) - const retentionRef = useRef(createNativeChatTranscriptRetention()) + const retentionRef = useRef>(undefined!) + retentionRef.current ??= createNativeChatTranscriptRetention() const sessionMatchesIdentity = activeIdentityRef.current === identity const readPhase = sessionMatchesIdentity ? session.readPhase : 'loading' diff --git a/src/renderer/src/components/pull-request-page/files/combined-diff-section-index.test.tsx b/src/renderer/src/components/pull-request-page/files/combined-diff-section-index.test.tsx new file mode 100644 index 00000000000..9c7c7e11a34 --- /dev/null +++ b/src/renderer/src/components/pull-request-page/files/combined-diff-section-index.test.tsx @@ -0,0 +1,110 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitHubPRFile } from '../../../../../shared/github/pull-request-types' +import type { PRFilesCombinedDiffViewerProps } from '@/components/github/pr-file-diff-mapping' + +const capturedSectionIndexMaps = vi.hoisted(() => ({ list: [] as ReadonlyMap[] })) +const loaders = vi.hoisted(() => ({ loadSection: (_index: number) => {} })) + +vi.mock('@/store', () => ({ + useAppStore: (selector: (state: Record) => unknown) => + selector({ settings: { theme: 'dark' } }) +})) + +vi.mock('../../editor/combined-diff/browse-files/combined-diff-file-tree', () => ({ + CombinedDiffFileTree: (props: { sectionIndexByKey: ReadonlyMap }) => { + capturedSectionIndexMaps.list.push(props.sectionIndexByKey) + return null + } +})) + +vi.mock('@/components/editor/DiffSectionItem', () => ({ DiffSectionItem: () => null })) +vi.mock('./toolbar', () => ({ PRFilesDiffToolbar: () => null })) +vi.mock('./view-restore', () => ({ usePRFilesDiffViewPersistence: () => {} })) + +vi.mock('./section-loader', () => ({ + usePRFileSectionLoader: ({ + setSections + }: { + setSections: (updater: (prev: { key: string; loading: boolean }[]) => unknown) => void + }) => { + // Why: stands in for the network fetch, keeping only the state shape a real load produces — + // a new sections array with one patched section and untouched keys. + loaders.loadSection = (index: number) => { + setSections((prev) => + prev.map((section, sectionIndex) => + sectionIndex === index ? { ...section, loading: false } : section + ) + ) + } + return { + loadSection: loaders.loadSection, + retrySection: () => {}, + toggleSection: () => {}, + setAllSectionsCollapsed: () => {} + } + } +})) + +const { PRFilesCombinedDiffViewer } = await import('./combined-diff-viewer') + +let host: HTMLDivElement +let root: Root + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true + capturedSectionIndexMaps.list = [] + host = document.createElement('div') + document.body.appendChild(host) + root = createRoot(host) +}) + +afterEach(() => { + act(() => root.unmount()) + host.remove() + vi.restoreAllMocks() +}) + +function prFiles(count: number): GitHubPRFile[] { + return Array.from({ length: count }, (_, index) => ({ + path: `src/file-${String(index).padStart(3, '0')}.ts`, + status: 'modified' as const, + additions: 1, + deletions: 1, + isBinary: false + })) +} + +const viewerProps: PRFilesCombinedDiffViewerProps = { + files: prFiles(6), + comments: [], + repoPath: '/repo', + repoId: 'repo-1', + prNumber: 7, + prUrl: 'https://example.test/pr/7', + headSha: 'head', + baseSha: 'base', + pendingViewedPaths: new Set(), + onCommentAdded: () => {}, + onViewedChange: () => Promise.resolve(true) +} + +describe('pull request page combined diff section index map', () => { + it('keeps one map identity across on-demand section loads', () => { + act(() => { + root.render() + }) + capturedSectionIndexMaps.list = [] + + for (let index = 0; index < viewerProps.files.length; index += 1) { + act(() => loaders.loadSection(index)) + } + + expect(capturedSectionIndexMaps.list.length).toBe(viewerProps.files.length) + expect(capturedSectionIndexMaps.list[0]?.size).toBe(viewerProps.files.length) + expect(new Set(capturedSectionIndexMaps.list).size).toBe(1) + }) +}) diff --git a/src/renderer/src/components/pull-request-page/files/combined-diff-viewer.tsx b/src/renderer/src/components/pull-request-page/files/combined-diff-viewer.tsx index 3ef4d097ab5..2ba0fbbf621 100644 --- a/src/renderer/src/components/pull-request-page/files/combined-diff-viewer.tsx +++ b/src/renderer/src/components/pull-request-page/files/combined-diff-viewer.tsx @@ -4,7 +4,7 @@ import type { editor as monacoEditor } from 'monaco-editor' import { useAppStore } from '@/store' import { DiffSectionItem } from '@/components/editor/DiffSectionItem' import { CombinedDiffFileTree } from '../../editor/combined-diff/browse-files/combined-diff-file-tree' -import { createCombinedDiffSectionIndexMap } from '../../editor/combined-diff/resolve-changes/combined-diff-section-identity' +import { useCombinedDiffSectionIndexMap } from '../../editor/combined-diff/resolve-changes/use-combined-diff-section-index-map' import { handleCombinedDiffFileTreeNavigation } from '../../editor/combined-diff/browse-files/combined-diff-file-tree-navigation' import { getDiffSectionRowEstimatedHeight } from '@/components/editor/diff-section-layout' import type { DiffSection } from '@/components/editor/diff-section-types' @@ -180,7 +180,7 @@ export function PRFilesCombinedDiffViewer({ }) const allSectionsCollapsed = sections.length > 0 && sections.every((section) => section.collapsed) - const sectionIndexByKey = useMemo(() => createCombinedDiffSectionIndexMap(sections), [sections]) + const sectionIndexByKey = useCombinedDiffSectionIndexMap({ entrySignature, sections }) const visibleActiveTreeSectionKey = activeTreeSectionKey && sectionIndexByKey.has(activeTreeSectionKey) ? activeTreeSectionKey diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index b1f72178b24..d8655a560d3 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -61,7 +61,8 @@ export function useAiVaultSessionRefresh( const sessions = scanResult?.sessions ?? EMPTY_AI_VAULT_SESSIONS const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - const requestTokenRef = useRef(crypto.randomUUID()) + const requestTokenRef = useRef(undefined!) + requestTokenRef.current ??= crypto.randomUUID() const refreshIdRef = useRef(0) const refreshInFlightRef = useRef(false) const pendingRefreshRef = useRef(false) @@ -69,7 +70,8 @@ export function useAiVaultSessionRefresh( const pendingBackgroundRef = useRef(true) const lastAppliedScanRef = useRef<{ scopeKey: string; scannedAt: string } | null>(null) const mountedRef = useRef(true) - const publicationGateRef = useRef(new AiVaultSessionPublicationGate()) + const publicationGateRef = useRef(undefined!) + publicationGateRef.current ??= new AiVaultSessionPublicationGate() const scanScopeKey = `${aiVaultSessionResultCacheKey(executionHostScope, scopePaths)}\n${sessionLimit}` const scopePathsRef = useRef(scopePaths) scopePathsRef.current = scopePaths diff --git a/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx b/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx index 6869daaa89c..40a3966047f 100644 --- a/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx +++ b/src/renderer/src/components/right-sidebar/right-sidebar-titlebar-drag-regions.render.test.tsx @@ -11,6 +11,7 @@ import { RIGHT_SIDEBAR_WINDOWS_TOP_ACTIVITY_STRIP_CLASS_NAME } from './right-sidebar-titlebar-drag-regions' import type { ActiveRightSidebarTab } from '@/store/slices/editor' +import { resetRendererAppPlatformCacheForTests } from '@/lib/renderer-app-platform' const mockAppState = vi.hoisted(() => ({ rightSidebarOpen: true, @@ -198,6 +199,7 @@ function expectNoDrag(tag: string): void { } function setRendererPlatform(platform: NodeJS.Platform): void { + resetRendererAppPlatformCacheForTests() Object.defineProperty(window, 'api', { configurable: true, value: { diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.store-subscriptions.test.tsx b/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.store-subscriptions.test.tsx new file mode 100644 index 00000000000..dca92f903f1 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.store-subscriptions.test.tsx @@ -0,0 +1,166 @@ +// @vitest-environment happy-dom + +import { act, useState, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it } from 'vitest' +import { useAppStore } from '@/store' +import { readStoreListenerCount } from '@/store/store-listener-census' +import { useSourceControlStoreActions, type SourceControlStoreActions } from './use-store-actions' + +const originalState = useAppStore.getState() + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function mount(node: ReactNode): void { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root?.render(node)) +} + +function unmount(): void { + if (root) { + act(() => root?.unmount()) + } + root = null + container?.remove() + container = null +} + +function listenerCount(): number { + const count = readStoreListenerCount() + if (count === null) { + throw new Error('store listener census unavailable') + } + return count +} + +afterEach(() => { + unmount() + useAppStore.setState(originalState, true) +}) + +/** Everything the hook returns that is a store action rather than subscribed state. */ +const ACTION_KEYS = Object.keys(originalState).filter( + (key) => typeof (originalState as Record)[key] === 'function' +) + +describe('useSourceControlStoreActions store subscriptions', () => { + it('keeps only the two generation-record maps subscribed', () => { + const baseline = listenerCount() + + function Probe(): null { + useSourceControlStoreActions() + return null + } + mount() + + // Why 2: `pullRequestGenerationRecords` and `commitMessageGenerationRecords` are the only + // entries that are state; the other 40 are actions read through getState(). + expect(listenerCount() - baseline).toBe(2) + + unmount() + expect(listenerCount()).toBe(baseline) + }) + + it('returns the same object across an unrelated store write and re-render', () => { + let latest: SourceControlStoreActions | null = null + let rerender: (() => void) | null = null + + function Probe(): null { + const [, setTick] = useState(0) + rerender = () => setTick((t) => t + 1) + latest = useSourceControlStoreActions() + return null + } + mount() + + const first = latest + expect(first).not.toBeNull() + + act(() => { + useAppStore.setState({ + rightSidebarOpen: !originalState.rightSidebarOpen + }) + }) + act(() => rerender?.()) + + expect(latest).toBe(first) + }) + + it('still tracks the generation-record maps it subscribes to', () => { + let latest: SourceControlStoreActions | null = null + function Probe(): null { + latest = useSourceControlStoreActions() + return null + } + function read(): SourceControlStoreActions { + if (!latest) { + throw new Error('probe did not render') + } + return latest + } + mount() + + const before = read() + const record = { status: 'pending' } as never + act(() => { + useAppStore.setState({ + pullRequestGenerationRecords: { 'wt-1': record } + }) + }) + + expect(read()).not.toBe(before) + expect(read().prGenerationRecords).toEqual({ 'wt-1': record }) + + const afterPr = read() + act(() => { + useAppStore.setState({ + commitMessageGenerationRecords: { 'wt-1': record } + }) + }) + expect(read()).not.toBe(afterPr) + expect(read().commitMessageGenerationRecords).toEqual({ 'wt-1': record }) + }) + + it('hands back the live store action references', () => { + let latest: SourceControlStoreActions | null = null + function Probe(): null { + latest = useSourceControlStoreActions() + return null + } + mount() + + const state = useAppStore.getState() as unknown as Record + const returned = latest as unknown as Record + const returnedActionKeys = Object.keys(returned).filter( + (key) => typeof returned[key] === 'function' + ) + + expect(returnedActionKeys.length).toBe(40) + for (const key of returnedActionKeys) { + expect(returned[key]).toBe(state[key]) + } + }) + + it('never reassigns a store action, which is what makes getState() safe here', () => { + const before = useAppStore.getState() as unknown as Record + const snapshot = new Map(ACTION_KEYS.map((key) => [key, before[key]])) + + // Drive real writes through several slices, then confirm no action identity moved. + act(() => { + useAppStore.getState().setRightSidebarOpen(true) + useAppStore.getState().setRightSidebarTab('source-control') + useAppStore.getState().allocatePullRequestGenerationRequestId() + useAppStore.getState().setPullRequestGenerationRecord('wt-1', { status: 'pending' } as never) + useAppStore.getState().setCommitMessageGenerationRecord('wt-1', { + status: 'pending' + } as never) + }) + + const after = useAppStore.getState() as unknown as Record + const moved = ACTION_KEYS.filter((key) => after[key] !== snapshot.get(key)) + expect(moved).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.ts b/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.ts index 230cc4aebeb..d85c7cd04d8 100644 --- a/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.ts +++ b/src/renderer/src/components/right-sidebar/source-control/listing/use-store-actions.ts @@ -1,105 +1,70 @@ +import { useMemo } from 'react' import { useAppStore } from '@/store' /** - * Binds every store action the Source Control panel dispatches. Each entry keeps its own selector so - * the returned references stay stable and can be used directly in downstream dependency arrays. + * Binds every store action the Source Control panel dispatches. + * + * Why `getState()` and not one selector each: zustand action identities are fixed when the store is + * built and no slice ever puts one in a `set()` payload, so subscribing to them can never fire. The + * 40 action subscriptions only added 40 live listeners and 40 selector runs to every store write + * while the panel was mounted. The two generation-record maps are real state, so they stay + * subscribed. + * + * Reference stability is preserved and slightly stronger than before: each action keeps the single + * identity it was created with, and the returned object itself is now stable until one of the two + * subscribed maps changes, so downstream dependency arrays keep working. */ export function useSourceControlStoreActions() { - const updateSettings = useAppStore((s) => s.updateSettings) - const openSettingsTarget = useAppStore((s) => s.openSettingsTarget) - const openSettingsPage = useAppStore((s) => s.openSettingsPage) - const fetchHostedReviewForBranch = useAppStore((s) => s.fetchHostedReviewForBranch) - const getHostedReviewCreationEligibility = useAppStore( - (s) => s.getHostedReviewCreationEligibility - ) - const createHostedReview = useAppStore((s) => s.createHostedReview) - const createStackedHostedReview = useAppStore((s) => s.createStackedHostedReview) - const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta) - const openModal = useAppStore((s) => s.openModal) - const fetchPRForBranch = useAppStore((s) => s.fetchPRForBranch) - const enqueueGitHubPRRefresh = useAppStore((s) => s.enqueueGitHubPRRefresh) - const updateRepo = useAppStore((s) => s.updateRepo) - const setGitStatus = useAppStore((s) => s.setGitStatus) - const updateWorktreeGitIdentity = useAppStore((s) => s.updateWorktreeGitIdentity) - const beginGitBranchCompareRequest = useAppStore((s) => s.beginGitBranchCompareRequest) - const setGitBranchCompareResult = useAppStore((s) => s.setGitBranchCompareResult) - const fetchUpstreamStatus = useAppStore((s) => s.fetchUpstreamStatus) - const ensureHostedReviewPushTarget = useAppStore((s) => s.ensureHostedReviewPushTarget) - const setUpstreamStatus = useAppStore((s) => s.setUpstreamStatus) - const pushBranch = useAppStore((s) => s.pushBranch) - const pullBranch = useAppStore((s) => s.pullBranch) - const fastForwardBranch = useAppStore((s) => s.fastForwardBranch) - const syncBranch = useAppStore((s) => s.syncBranch) - const rebaseFromBase = useAppStore((s) => s.rebaseFromBase) - const fetchBranch = useAppStore((s) => s.fetchBranch) - const revealInExplorer = useAppStore((s) => s.revealInExplorer) - const openConflictReview = useAppStore((s) => s.openConflictReview) - const openAllDiffs = useAppStore((s) => s.openAllDiffs) - const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs) - const deleteDiffComment = useAppStore((s) => s.deleteDiffComment) - const clearDiffComments = useAppStore((s) => s.clearDiffComments) - const clearDiffCommentsForFile = useAppStore((s) => s.clearDiffCommentsForFile) - const setRightSidebarOpen = useAppStore((s) => s.setRightSidebarOpen) - const setRightSidebarTab = useAppStore((s) => s.setRightSidebarTab) const prGenerationRecords = useAppStore((s) => s.pullRequestGenerationRecords) - const allocatePullRequestGenerationRequestId = useAppStore( - (s) => s.allocatePullRequestGenerationRequestId - ) - const setPullRequestGenerationRecord = useAppStore((s) => s.setPullRequestGenerationRecord) - const updatePullRequestGenerationRecord = useAppStore((s) => s.updatePullRequestGenerationRecord) const commitMessageGenerationRecords = useAppStore((s) => s.commitMessageGenerationRecords) - const allocateCommitMessageGenerationRequestId = useAppStore( - (s) => s.allocateCommitMessageGenerationRequestId - ) - const setCommitMessageGenerationRecord = useAppStore((s) => s.setCommitMessageGenerationRecord) - const updateCommitMessageGenerationRecord = useAppStore( - (s) => s.updateCommitMessageGenerationRecord - ) - return { - allocateCommitMessageGenerationRequestId, - allocatePullRequestGenerationRequestId, - beginGitBranchCompareRequest, - clearDiffComments, - clearDiffCommentsForFile, - commitMessageGenerationRecords, - createHostedReview, - createStackedHostedReview, - deleteDiffComment, - enqueueGitHubPRRefresh, - ensureHostedReviewPushTarget, - fastForwardBranch, - fetchBranch, - fetchHostedReviewForBranch, - fetchPRForBranch, - fetchUpstreamStatus, - getHostedReviewCreationEligibility, - openAllDiffs, - openBranchAllDiffs, - openConflictReview, - openModal, - openSettingsPage, - openSettingsTarget, - prGenerationRecords, - pullBranch, - pushBranch, - rebaseFromBase, - revealInExplorer, - setCommitMessageGenerationRecord, - setGitBranchCompareResult, - setGitStatus, - setPullRequestGenerationRecord, - setRightSidebarOpen, - setRightSidebarTab, - setUpstreamStatus, - syncBranch, - updateCommitMessageGenerationRecord, - updatePullRequestGenerationRecord, - updateRepo, - updateSettings, - updateWorktreeGitIdentity, - updateWorktreeMeta - } + return useMemo(() => { + const state = useAppStore.getState() + return { + allocateCommitMessageGenerationRequestId: state.allocateCommitMessageGenerationRequestId, + allocatePullRequestGenerationRequestId: state.allocatePullRequestGenerationRequestId, + beginGitBranchCompareRequest: state.beginGitBranchCompareRequest, + clearDiffComments: state.clearDiffComments, + clearDiffCommentsForFile: state.clearDiffCommentsForFile, + commitMessageGenerationRecords, + createHostedReview: state.createHostedReview, + createStackedHostedReview: state.createStackedHostedReview, + deleteDiffComment: state.deleteDiffComment, + enqueueGitHubPRRefresh: state.enqueueGitHubPRRefresh, + ensureHostedReviewPushTarget: state.ensureHostedReviewPushTarget, + fastForwardBranch: state.fastForwardBranch, + fetchBranch: state.fetchBranch, + fetchHostedReviewForBranch: state.fetchHostedReviewForBranch, + fetchPRForBranch: state.fetchPRForBranch, + fetchUpstreamStatus: state.fetchUpstreamStatus, + getHostedReviewCreationEligibility: state.getHostedReviewCreationEligibility, + openAllDiffs: state.openAllDiffs, + openBranchAllDiffs: state.openBranchAllDiffs, + openConflictReview: state.openConflictReview, + openModal: state.openModal, + openSettingsPage: state.openSettingsPage, + openSettingsTarget: state.openSettingsTarget, + prGenerationRecords, + pullBranch: state.pullBranch, + pushBranch: state.pushBranch, + rebaseFromBase: state.rebaseFromBase, + revealInExplorer: state.revealInExplorer, + setCommitMessageGenerationRecord: state.setCommitMessageGenerationRecord, + setGitBranchCompareResult: state.setGitBranchCompareResult, + setGitStatus: state.setGitStatus, + setPullRequestGenerationRecord: state.setPullRequestGenerationRecord, + setRightSidebarOpen: state.setRightSidebarOpen, + setRightSidebarTab: state.setRightSidebarTab, + setUpstreamStatus: state.setUpstreamStatus, + syncBranch: state.syncBranch, + updateCommitMessageGenerationRecord: state.updateCommitMessageGenerationRecord, + updatePullRequestGenerationRecord: state.updatePullRequestGenerationRecord, + updateRepo: state.updateRepo, + updateSettings: state.updateSettings, + updateWorktreeGitIdentity: state.updateWorktreeGitIdentity, + updateWorktreeMeta: state.updateWorktreeMeta + } + }, [commitMessageGenerationRecords, prGenerationRecords]) } export type SourceControlStoreActions = ReturnType diff --git a/src/renderer/src/components/right-sidebar/source-control/sync/git-history-panel.tsx b/src/renderer/src/components/right-sidebar/source-control/sync/git-history-panel.tsx index 35751a428d1..71de9872649 100644 --- a/src/renderer/src/components/right-sidebar/source-control/sync/git-history-panel.tsx +++ b/src/renderer/src/components/right-sidebar/source-control/sync/git-history-panel.tsx @@ -92,7 +92,8 @@ export function GitHistoryPanel({ const loadedCommitsRef = useRef<{ result: GitHistoryResult | undefined ids: Set - }>({ result, ids: new Set() }) + }>(undefined!) + loadedCommitsRef.current ??= { result, ids: new Set() } // A new history result can reorder or replace commits, so drop any expansion // and cached file lists rather than risk showing stale files under a row. diff --git a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts index 18e1c4c999d..fbb0508ee24 100644 --- a/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts +++ b/src/renderer/src/components/right-sidebar/useCreatePullRequestDialogFields.ts @@ -58,9 +58,8 @@ export function useCreatePullRequestDialogFields({ const generationRequestIdRef = useRef(0) const generationSeedRef = useRef(null) const restoredExternalGenerationSeedRef = useRef(null) - const fieldRevisionsRef = useRef( - createInitialPullRequestFieldRevisions() - ) + const fieldRevisionsRef = useRef(undefined!) + fieldRevisionsRef.current ??= createInitialPullRequestFieldRevisions() const [base, setBase] = useState('') const [title, setTitle] = useState('') const [body, setBody] = useState('') diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts index d6f695c2ac3..4c8311f8dfa 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerTree.ts @@ -45,7 +45,8 @@ export function useFileExplorerTree( const [rootError, setRootError] = useState(null) const dirCacheRef = useRef(dirCache) dirCacheRef.current = dirCache - const dirLoadTrackerRef = useRef(createFileExplorerDirLoadTracker()) + const dirLoadTrackerRef = useRef>(undefined!) + dirLoadTrackerRef.current ??= createFileExplorerDirLoadTracker() // Why: a ref, not state — the expansion effect must read the mark set by a refresh that landed // after the effect's render, and a state write would only be visible one render too late. const staleDirsRef = useRef(new Set()) diff --git a/src/renderer/src/components/settings/AppearancePane.test.tsx b/src/renderer/src/components/settings/AppearancePane.test.tsx index 4a89d66cd03..01573d65b49 100644 --- a/src/renderer/src/components/settings/AppearancePane.test.tsx +++ b/src/renderer/src/components/settings/AppearancePane.test.tsx @@ -5,6 +5,7 @@ import { createRoot, type Root } from 'react-dom/client' import { I18nextProvider } from 'react-i18next' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { i18n } from '@/i18n/i18n' +import { resetRendererAppPlatformCacheForTests } from '@/lib/renderer-app-platform' import { getDefaultSettings } from '../../../../shared/constants' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { StatusBarItem } from '../../../../shared/ui-chrome-types' @@ -216,6 +217,7 @@ describe('AppearancePane', () => { beforeEach(() => { vi.clearAllMocks() + resetRendererAppPlatformCacheForTests() mocks.state.availableStatusBarToggles = [] mocks.state.appPlatform = 'linux' mocks.state.settingsSearchQuery = 'automations' diff --git a/src/renderer/src/components/settings/CommitMessageAiPane.tsx b/src/renderer/src/components/settings/CommitMessageAiPane.tsx index d14ff189c72..096ccd34d51 100644 --- a/src/renderer/src/components/settings/CommitMessageAiPane.tsx +++ b/src/renderer/src/components/settings/CommitMessageAiPane.tsx @@ -104,7 +104,8 @@ export function CommitMessageAiPane({ const searchQuery = settingsSearchQuery ?? storeSearchQuery const config = readSettings(settings) const ownership = getSettingOwnershipSummary('sourceControlAiDefaults') - const settingsWriteQueueRef = useRef>(Promise.resolve()) + const settingsWriteQueueRef = useRef>(undefined!) + settingsWriteQueueRef.current ??= Promise.resolve() const localWriteConfig = (patch: SourceControlAiSettingsPatch): Promise => { const next = settingsWriteQueueRef.current diff --git a/src/renderer/src/components/settings/repository-runtime-session-summary.ts b/src/renderer/src/components/settings/repository-runtime-session-summary.ts index 5dffcd3b1e2..62bfd7053e8 100644 --- a/src/renderer/src/components/settings/repository-runtime-session-summary.ts +++ b/src/renderer/src/components/settings/repository-runtime-session-summary.ts @@ -1,5 +1,6 @@ import type { AppState } from '../../store/types' import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id' +import { getTabIdToWorktreeId } from '../sidebar/worktree-agent-row-selectors' export type ProjectRuntimeSessionSummary = { liveTerminalCount: number @@ -11,16 +12,26 @@ type RuntimeSessionSummaryState = Pick< 'tabsByWorktree' | 'ptyIdsByTabId' | 'agentStatusByPaneKey' > +type SessionSummaryCache = { + ptyIdsByTabId: AppState['ptyIdsByTabId'] + agentStatusByPaneKey: AppState['agentStatusByPaneKey'] + byRepoId: Map +} + +// Why: one RepositoryPane per project reruns this on every store write, and each +// run walked every worktree bucket plus every agent-status pane. Key on the three +// input slices so unrelated writes reuse the answer instead of rescanning. +const sessionSummaryCache = new WeakMap() + function getTabIdFromPaneKey(paneKey: string): string | null { const separator = paneKey.indexOf(':') return separator > 0 ? paneKey.slice(0, separator) : null } -export function getProjectRuntimeSessionSummary( +function computeProjectRuntimeSessionSummary( state: RuntimeSessionSummaryState, repoId: string ): ProjectRuntimeSessionSummary { - const tabWorktreeIds = new Map() const projectWorktreeIds = new Set() let liveTerminalCount = 0 @@ -31,7 +42,6 @@ export function getProjectRuntimeSessionSummary( projectWorktreeIds.add(worktreeId) for (const tab of tabs) { - tabWorktreeIds.set(tab.id, worktreeId) const livePtyIds = new Set(state.ptyIdsByTabId[tab.id] ?? []) if (tab.ptyId) { livePtyIds.add(tab.ptyId) @@ -40,6 +50,9 @@ export function getProjectRuntimeSessionSummary( } } + // Rows outside this project resolve to a worktree the checks below reject, so + // the shared index answers the same question the repo-scoped map used to. + const tabWorktreeIds = getTabIdToWorktreeId(state.tabsByWorktree) let activeTaskCount = 0 for (const [paneKey, entry] of Object.entries(state.agentStatusByPaneKey)) { if (entry.state === 'done') { @@ -57,3 +70,29 @@ export function getProjectRuntimeSessionSummary( return { liveTerminalCount, activeTaskCount } } + +export function getProjectRuntimeSessionSummary( + state: RuntimeSessionSummaryState, + repoId: string +): ProjectRuntimeSessionSummary { + let cache = sessionSummaryCache.get(state.tabsByWorktree) + if ( + !cache || + cache.ptyIdsByTabId !== state.ptyIdsByTabId || + cache.agentStatusByPaneKey !== state.agentStatusByPaneKey + ) { + cache = { + ptyIdsByTabId: state.ptyIdsByTabId, + agentStatusByPaneKey: state.agentStatusByPaneKey, + byRepoId: new Map() + } + sessionSummaryCache.set(state.tabsByWorktree, cache) + } + const cached = cache.byRepoId.get(repoId) + if (cached) { + return cached + } + const summary = computeProjectRuntimeSessionSummary(state, repoId) + cache.byRepoId.set(repoId, summary) + return summary +} diff --git a/src/renderer/src/components/settings/repository-source-control-ai-global-ux.ts b/src/renderer/src/components/settings/repository-source-control-ai-global-ux.ts index a27db245e6d..3a13f6186ca 100644 --- a/src/renderer/src/components/settings/repository-source-control-ai-global-ux.ts +++ b/src/renderer/src/components/settings/repository-source-control-ai-global-ux.ts @@ -83,25 +83,24 @@ export function useRepositorySourceControlAiGlobalUx({ const lastSyncedRepoIdRef = useRef(repoId) const pendingWritesRef = useRef(0) - const queueRef = useRef( - createRepoAiPersistQueue({ - getRepoId: () => repoIdRef.current, - getPersisted: () => persistedRef.current, - setPersisted: (value) => { - persistedRef.current = value - if (mountedRef.current) { - setBaselineRepoAiRef.current(value) - } - }, - updateRepo: (id, updates) => updateRepoRef.current(id, updates), - isMounted: () => mountedRef.current, - onError: (message) => { - if (mountedRef.current) { - setSaveError(message) - } + const queueRef = useRef>(undefined!) + queueRef.current ??= createRepoAiPersistQueue({ + getRepoId: () => repoIdRef.current, + getPersisted: () => persistedRef.current, + setPersisted: (value) => { + persistedRef.current = value + if (mountedRef.current) { + setBaselineRepoAiRef.current(value) } - }) - ) + }, + updateRepo: (id, updates) => updateRepoRef.current(id, updates), + isMounted: () => mountedRef.current, + onError: (message) => { + if (mountedRef.current) { + setSaveError(message) + } + } + }) useEffect(() => { const repoChanged = lastSyncedRepoIdRef.current !== repoId diff --git a/src/renderer/src/components/settings/use-settings-interaction-controller.ts b/src/renderer/src/components/settings/use-settings-interaction-controller.ts index bfd24c7e5d8..6d949004749 100644 --- a/src/renderer/src/components/settings/use-settings-interaction-controller.ts +++ b/src/renderer/src/components/settings/use-settings-interaction-controller.ts @@ -39,7 +39,8 @@ export function useSettingsInteractionController(model: SettingsStoreModel) { const pendingScrollTargetWatchRef = useRef(null) const repoHooksRequestSeqRef = useRef(0) const shortcutsEscapeConfirmUntilRef = useRef(0) - const sourceControlAiWriteQueueRef = useRef>(Promise.resolve()) + const sourceControlAiWriteQueueRef = useRef>(undefined!) + sourceControlAiWriteQueueRef.current ??= Promise.resolve() const hasUnsavedSourceControlAiPromptChanges = hasUnsavedCommitPromptChanges || hasUnsavedBranchPromptChanges diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 5d27ec0f23f..9153ff4e39e 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -116,7 +116,7 @@ const WorktreeList = React.memo(function WorktreeList({ sortedIds, repoMap, worktreeLineageById, - settings, + defaultHostId, agentSendTargetWorktreeId }) const effectiveCollapsedGroups = useEffectiveCollapsedGroups({ diff --git a/src/renderer/src/components/sidebar/truncated-sidebar-label.test.tsx b/src/renderer/src/components/sidebar/truncated-sidebar-label.test.tsx index 27ed816c53a..1de1fac0140 100644 --- a/src/renderer/src/components/sidebar/truncated-sidebar-label.test.tsx +++ b/src/renderer/src/components/sidebar/truncated-sidebar-label.test.tsx @@ -105,4 +105,40 @@ describe('TruncatedSidebarLabel', () => { expect(container.textContent).toContain('feature/really-long-branch-name') expect(container.querySelector('[data-tooltip-content]')).toBeNull() }) + + // Why: worktree titles change on the hot store-write path. Remounting the + // span per text change tore down and rebuilt its ResizeObserver every time. + it('keeps one ResizeObserver across a label text change', async () => { + const originalResizeObserver = globalThis.ResizeObserver + let constructed = 0 + let disconnected = 0 + class CountingResizeObserver { + constructor(_callback: ResizeObserverCallback) { + constructed += 1 + } + observe(): void {} + unobserve(): void {} + disconnect(): void { + disconnected += 1 + } + } + globalThis.ResizeObserver = CountingResizeObserver as unknown as typeof ResizeObserver + + try { + await act(async () => { + root.render() + }) + expect(constructed).toBe(1) + + await act(async () => { + root.render() + }) + + expect(container.textContent).toBe('fix/short') + expect(constructed).toBe(1) + expect(disconnected).toBe(0) + } finally { + globalThis.ResizeObserver = originalResizeObserver + } + }) }) diff --git a/src/renderer/src/components/sidebar/truncated-sidebar-label.tsx b/src/renderer/src/components/sidebar/truncated-sidebar-label.tsx index c7afaa2e9b0..80955b5bfe8 100644 --- a/src/renderer/src/components/sidebar/truncated-sidebar-label.tsx +++ b/src/renderer/src/components/sidebar/truncated-sidebar-label.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useState } from 'react' +import React, { useCallback, useLayoutEffect, useState } from 'react' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { cn } from '@/lib/utils' @@ -23,6 +23,7 @@ export function TruncatedSidebarLabel({ tooltipSide = 'right', tooltipSideOffset = 8 }: TruncatedSidebarLabelProps): React.JSX.Element { + const nodeRef = React.useRef(null) const resizeObserverRef = React.useRef(null) const removeResizeListenerRef = React.useRef<(() => void) | null>(null) const [truncated, setTruncated] = useState(false) @@ -39,6 +40,7 @@ export function TruncatedSidebarLabel({ removeResizeListenerRef.current?.() removeResizeListenerRef.current = null + nodeRef.current = node if (!node) { measureTruncated(null) return @@ -60,14 +62,15 @@ export function TruncatedSidebarLabel({ [measureTruncated] ) + // Why: ResizeObserver does not fire when only the rendered text changes, but + // scrollWidth can. Remeasure in place rather than remounting the span, which + // would tear down and rebuild the observer on every title update. + useLayoutEffect(() => { + measureTruncated(nodeRef.current) + }, [measureTruncated, text]) + const label = ( - + {text} ) diff --git a/src/renderer/src/components/sidebar/use-workspace-kanban-column-resize.ts b/src/renderer/src/components/sidebar/use-workspace-kanban-column-resize.ts index a48e940a3db..17547d5cd56 100644 --- a/src/renderer/src/components/sidebar/use-workspace-kanban-column-resize.ts +++ b/src/renderer/src/components/sidebar/use-workspace-kanban-column-resize.ts @@ -20,7 +20,8 @@ export function useWorkspaceKanbanColumnResize( clampWorkspaceBoardColumnWidth(committedWidth) ) const [isResizingColumn, setIsResizingColumn] = useState(false) - const committedWidthRef = useRef(clampWorkspaceBoardColumnWidth(committedWidth)) + const nextCommittedWidth = clampWorkspaceBoardColumnWidth(committedWidth) + const committedWidthRef = useRef(nextCommittedWidth) const commitWidthRef = useRef(onCommitWidth) const resizingRef = useRef(false) const startXRef = useRef(0) @@ -29,7 +30,6 @@ export function useWorkspaceKanbanColumnResize( const frameRef = useRef(null) commitWidthRef.current = onCommitWidth - const nextCommittedWidth = clampWorkspaceBoardColumnWidth(committedWidth) if (committedWidthRef.current !== nextCommittedWidth) { committedWidthRef.current = nextCommittedWidth if (!resizingRef.current) { diff --git a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts index b2f45a8a9a0..366bce5e5ed 100644 --- a/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts +++ b/src/renderer/src/components/sidebar/useWorktreeAgentRows.ts @@ -5,22 +5,33 @@ import { applyAgentRowLineage } from '@/components/dashboard/agent-row-lineage' import { migrationUnsupportedToAgentStatusEntry } from '@/lib/migration-unsupported-agent-entry' import { useAppStore } from '@/store' import { + EMPTY_LIVE_PTY_IDS, + EMPTY_RUNTIME_PANE_TITLES, selectLivePtyIdsForWorktree, selectRuntimePaneTitlesForWorktree } from './worktree-card-status-inputs' import { buildWorktreeAgentRows } from './worktree-agent-rows' import { + EMPTY_LIVE_ENTRIES, + EMPTY_MIGRATION_UNSUPPORTED_ENTRIES, + EMPTY_RETAINED, + EMPTY_TERMINAL_LAYOUTS, selectLiveAgentStatusEntriesForWorktree, selectMigrationUnsupportedEntriesForWorktree, selectRuntimeAgentOrchestrationForWorktree, selectRetainedAgentEntriesForWorktree, selectTerminalLayoutsForWorktree } from './worktree-agent-row-selectors' +import { EMPTY_WORKTREE_AGENT_ORCHESTRATION } from './worktree-agent-orchestration-index' +import { EMPTY_TABS } from './WorktreeCardHelpers' import { createWorktreeAgentFreshnessSelector, EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE } from './worktree-agent-freshness-selector' +// Why frozen: shared by every inactive card, and callers only read these rows. +const EMPTY_AGENT_ROWS = Object.freeze([]) as unknown as DashboardAgentRow[] + export { buildWorktreeAgentRows } from './worktree-agent-rows' export { selectLiveAgentStatusEntriesForWorktree, @@ -44,35 +55,51 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa () => createWorktreeAgentFreshnessSelector(worktreeId), [worktreeId] ) - const tabs = useAppStore((s) => (active ? s.tabsByWorktree[worktreeId] : undefined)) + const tabs = useAppStore((s) => (active ? s.tabsByWorktree[worktreeId] : EMPTY_TABS)) // Why: narrow the subscriptions to only THIS worktree's entries via // useShallow. Subscribing to the whole agentStatusByPaneKey map would make // every on-screen card re-render on any agent-status update anywhere — // O(worktrees²) render amplification. Pre-filtering here means the card // only re-renders when something relevant to THIS worktree changes. const liveEntries = useAppStore( - useShallow((s) => (active ? selectLiveAgentStatusEntriesForWorktree(s, worktreeId) : [])) + useShallow((s) => + active ? selectLiveAgentStatusEntriesForWorktree(s, worktreeId) : EMPTY_LIVE_ENTRIES + ) ) // Why: keep the store selector limited to stable raw records. Converting // migration entries creates fresh objects with Date.now(), which breaks // useSyncExternalStore's cached-snapshot contract and can blank Electron. const migrationUnsupported = useAppStore( - useShallow((s) => (active ? selectMigrationUnsupportedEntriesForWorktree(s, worktreeId) : [])) + useShallow((s) => + active + ? selectMigrationUnsupportedEntriesForWorktree(s, worktreeId) + : EMPTY_MIGRATION_UNSUPPORTED_ENTRIES + ) ) const retained = useAppStore( - useShallow((s) => (active ? selectRetainedAgentEntriesForWorktree(s, worktreeId) : [])) + useShallow((s) => + active ? selectRetainedAgentEntriesForWorktree(s, worktreeId) : EMPTY_RETAINED + ) ) const runtimePaneTitlesByTabId = useAppStore( - useShallow((s) => (active ? selectRuntimePaneTitlesForWorktree(s, worktreeId) : {})) + useShallow((s) => + active ? selectRuntimePaneTitlesForWorktree(s, worktreeId) : EMPTY_RUNTIME_PANE_TITLES + ) ) const ptyIdsByTabId = useAppStore( - useShallow((s) => (active ? selectLivePtyIdsForWorktree(s, worktreeId) : {})) + useShallow((s) => (active ? selectLivePtyIdsForWorktree(s, worktreeId) : EMPTY_LIVE_PTY_IDS)) ) const terminalLayoutsByTabId = useAppStore( - useShallow((s) => (active ? selectTerminalLayoutsForWorktree(s, worktreeId) : {})) + useShallow((s) => + active ? selectTerminalLayoutsForWorktree(s, worktreeId) : EMPTY_TERMINAL_LAYOUTS + ) ) const runtimeAgentOrchestrationByPaneKey = useAppStore( - useShallow((s) => (active ? selectRuntimeAgentOrchestrationForWorktree(s, worktreeId) : {})) + useShallow((s) => + active + ? selectRuntimeAgentOrchestrationForWorktree(s, worktreeId) + : EMPTY_WORKTREE_AGENT_ORCHESTRATION + ) ) const agentFreshnessSignature = useAppStore((s) => active ? selectAgentFreshness(s) : EMPTY_WORKTREE_AGENT_FRESHNESS_SIGNATURE @@ -80,7 +107,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa return useMemo(() => { if (!active) { - return [] + return EMPTY_AGENT_ROWS } // Why: Date.now() is read inside the memo so stale-decay recalculates when // this worktree's freshness signature changes, even without new PTY data. @@ -97,7 +124,7 @@ export function useWorktreeAgentRows(worktreeId: string, active = true): Dashboa : liveEntries return applyAgentRowLineage( buildWorktreeAgentRows({ - tabs: tabs ?? [], + tabs: tabs ?? EMPTY_TABS, entries, retained, runtimePaneTitlesByTabId, diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts index cbd3da64212..3e4e6defc6d 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.test.ts @@ -9,10 +9,15 @@ import type { RetainedAgentEntry } from '@/store/slices/agent-status' import { makePaneKey } from '../../../../shared/stable-pane-id' import { getLiveEntriesFullRebuildCountForTests } from './worktree-agent-live-index-patch' import { + EMPTY_LIVE_ENTRIES, + EMPTY_MIGRATION_UNSUPPORTED_ENTRIES, + EMPTY_RETAINED, + EMPTY_TERMINAL_LAYOUTS, selectLiveAgentStatusEntriesForWorktree, selectMigrationUnsupportedEntriesForWorktree, selectRuntimeAgentOrchestrationForWorktree, - selectRetainedAgentEntriesForWorktree + selectRetainedAgentEntriesForWorktree, + selectTerminalLayoutsForWorktree } from './worktree-agent-row-selectors' const PANE_KEY_1 = makePaneKey('tab-1', '22222222-2222-4222-8222-222222222222') @@ -94,8 +99,14 @@ describe('selectMigrationUnsupportedEntriesForWorktree', () => { describe('selectLiveAgentStatusEntriesForWorktree', () => { it('reuses unaffected worktree arrays when another worktree receives a same-state ping', () => { - const wt1Entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', prompt: 'first' }) - const wt2Entry = makeEntry(PANE_KEY_2, 1000, { state: 'working', prompt: 'first' }) + const wt1Entry = makeEntry(PANE_KEY_1, 1000, { + state: 'working', + prompt: 'first' + }) + const wt2Entry = makeEntry(PANE_KEY_2, 1000, { + state: 'working', + prompt: 'first' + }) const state = { tabsByWorktree: { 'wt-1': [makeTab('tab-1')], @@ -192,8 +203,14 @@ describe('selectLiveAgentStatusEntriesForWorktree', () => { }) it('patches instead of full-rebuilding across within-state pings, and stays correct on transitions', () => { - const wt1Entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', prompt: 'wt1 prompt' }) - const wt2Entry = makeEntry(PANE_KEY_2, 1000, { state: 'working', prompt: 'wt2 prompt' }) + const wt1Entry = makeEntry(PANE_KEY_1, 1000, { + state: 'working', + prompt: 'wt1 prompt' + }) + const wt2Entry = makeEntry(PANE_KEY_2, 1000, { + state: 'working', + prompt: 'wt2 prompt' + }) const baseState = { tabsByWorktree: { 'wt-1': [makeTab('tab-1')], @@ -256,7 +273,10 @@ describe('selectLiveAgentStatusEntriesForWorktree', () => { }) it('falls back to a full rebuild when a within-map update changes worktree attribution', () => { - const entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', worktreeId: 'wt-1' }) + const entry = makeEntry(PANE_KEY_1, 1000, { + state: 'working', + worktreeId: 'wt-1' + }) const state = { // No tab membership: bucketing comes from entry.worktreeId attribution. tabsByWorktree: { 'wt-1': [], 'wt-2': [] }, @@ -276,7 +296,10 @@ describe('selectLiveAgentStatusEntriesForWorktree', () => { }) it('falls back to a full rebuild when a live entry completes with its tab gone', () => { - const entry = makeEntry(PANE_KEY_1, 1000, { state: 'working', worktreeId: 'wt-1' }) + const entry = makeEntry(PANE_KEY_1, 1000, { + state: 'working', + worktreeId: 'wt-1' + }) const state = { tabsByWorktree: { 'wt-1': [] }, agentStatusByPaneKey: { [PANE_KEY_1]: entry }, @@ -427,3 +450,79 @@ describe('selectRetainedAgentEntriesForWorktree', () => { expect(secondWt2[0]?.startedAt).toBe(1100) }) }) + +describe('selectTerminalLayoutsForWorktree', () => { + const layout = { + root: { type: 'leaf', leafId: '44444444-4444-4444-8444-444444444444' }, + activeLeafId: '44444444-4444-4444-8444-444444444444', + expandedLeafId: null, + ptyIdsByLeafId: {} + } as const + + // Why: every visible card re-runs this selector on every store write; a fresh + // record per call is an allocation per card per write. + it('returns one identity per store generation', () => { + const state = { + tabsByWorktree: { 'wt-1': [makeTab('tab-1')] }, + terminalLayoutsByTabId: { 'tab-1': layout } + } + + expect(selectTerminalLayoutsForWorktree(state, 'wt-1')).toBe( + selectTerminalLayoutsForWorktree(state, 'wt-1') + ) + }) + + it('returns the shared frozen empty for a worktree with no tabs', () => { + const state = { tabsByWorktree: {}, terminalLayoutsByTabId: {} } + + expect(selectTerminalLayoutsForWorktree(state, 'missing')).toBe(EMPTY_TERMINAL_LAYOUTS) + }) +}) + +// Why: the inline-agents hook short-circuits on `active`; the off branch has to +// hand back a shared identity or the gate allocates on every store write. +describe('inactive-card empty constants', () => { + it('are frozen and shared', () => { + for (const empty of [ + EMPTY_LIVE_ENTRIES, + EMPTY_MIGRATION_UNSUPPORTED_ENTRIES, + EMPTY_RETAINED, + EMPTY_TERMINAL_LAYOUTS + ]) { + expect(Object.isFrozen(empty)).toBe(true) + } + expect( + selectLiveAgentStatusEntriesForWorktree( + { + agentStatusByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + retainedAgentsByPaneKey: {}, + tabsByWorktree: {} + }, + 'missing' + ) + ).toBe(EMPTY_LIVE_ENTRIES) + expect( + selectMigrationUnsupportedEntriesForWorktree( + { + agentStatusByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + retainedAgentsByPaneKey: {}, + tabsByWorktree: {} + }, + 'missing' + ) + ).toBe(EMPTY_MIGRATION_UNSUPPORTED_ENTRIES) + expect( + selectRetainedAgentEntriesForWorktree( + { + agentStatusByPaneKey: {}, + migrationUnsupportedByPtyId: {}, + retainedAgentsByPaneKey: {}, + tabsByWorktree: {} + }, + 'missing' + ) + ).toBe(EMPTY_RETAINED) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts index 84588c4296c..06ba3979ffe 100644 --- a/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts +++ b/src/renderer/src/components/sidebar/worktree-agent-row-selectors.ts @@ -13,11 +13,18 @@ import { recordLiveEntriesFullRebuild } from './worktree-agent-live-index-patch' import { selectWorktreeAgentOrchestration } from './worktree-agent-orchestration-index' +import { createWorktreeRecordSelector } from './worktree-record-selector-cache' import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' -const EMPTY_LIVE_ENTRIES: AgentStatusEntry[] = [] -const EMPTY_MIGRATION_UNSUPPORTED_ENTRIES: MigrationUnsupportedPtyEntry[] = [] -const EMPTY_RETAINED: RetainedAgentEntry[] = [] +// Why frozen and exported: card hooks return these from their inactive branch, +// so the identity has to be shared app-wide and safe from stray writes. +export const EMPTY_LIVE_ENTRIES = Object.freeze([]) as unknown as AgentStatusEntry[] +export const EMPTY_MIGRATION_UNSUPPORTED_ENTRIES = Object.freeze( + [] +) as unknown as MigrationUnsupportedPtyEntry[] +export const EMPTY_RETAINED = Object.freeze([]) as unknown as RetainedAgentEntry[] +export const EMPTY_TERMINAL_LAYOUTS: Record = + Object.freeze({}) // Why: selector unit tests often pass partial store mocks; production state // owns these maps, but missing mock maps should behave like empty slices. const EMPTY_RECORD = {} @@ -73,7 +80,10 @@ export function reuseArrayIfEqual(previous: T[] | undefined, next: T[]): T[] return previous } -function getTabIdToWorktreeId( +// Why exported: the Settings -> Repositories runtime summary needs the same +// tab -> worktree index, and rebuilding it there would re-walk every tab bucket +// on each store write. +export function getTabIdToWorktreeId( tabsByWorktree: WorktreeAgentRowsState['tabsByWorktree'] ): Map { if (tabWorktreeIndexCache?.tabsByWorktree === tabsByWorktree) { @@ -280,13 +290,20 @@ export function selectRuntimeAgentOrchestrationForWorktree( return selectWorktreeAgentOrchestration(state, worktreeId) } -export function selectTerminalLayoutsForWorktree( - state: Pick, - worktreeId: string -): Record { - const out: Record = {} - for (const tab of (state.tabsByWorktree ?? EMPTY_RECORD)[worktreeId] ?? []) { - out[tab.id] = (state.terminalLayoutsByTabId ?? EMPTY_RECORD)[tab.id] +export const selectTerminalLayoutsForWorktree = createWorktreeRecordSelector< + Pick, + Record +>({ + readSources: (state) => [ + state.tabsByWorktree ?? EMPTY_RECORD, + state.terminalLayoutsByTabId ?? EMPTY_RECORD + ], + empty: EMPTY_TERMINAL_LAYOUTS, + build: (state, worktreeId) => { + const out: Record = {} + for (const tab of (state.tabsByWorktree ?? EMPTY_RECORD)[worktreeId] ?? []) { + out[tab.id] = (state.terminalLayoutsByTabId ?? EMPTY_RECORD)[tab.id] + } + return out } - return out -} +}) diff --git a/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts b/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts index 7d366b845e8..59675017b2a 100644 --- a/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts +++ b/src/renderer/src/components/sidebar/worktree-card-status-inputs.test.ts @@ -6,6 +6,9 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { + EMPTY_LIVE_PTY_IDS, + EMPTY_RUNTIME_PANE_TITLES, + EMPTY_TERMINAL_LAYOUT_ROOTS, selectLivePtyIdsForWorktree, selectTerminalLayoutRootsForWorktree, selectTerminalLayoutRootsForWorktrees, @@ -145,4 +148,75 @@ describe('worktree card status input selectors', () => { ) ).toBe(true) }) + + // Why: zustand re-runs every mounted card's selector on every store write, so + // a fresh record per call multiplies by (visible cards x writes/sec). + it('returns one identity per store generation instead of rebuilding per call', () => { + const worktreeId = 'repo1::/path/wt1' + const state: SelectorState & LayoutRootSelectorState = { + tabsByWorktree: { + [worktreeId]: [makeTab('tab-1', worktreeId)] + }, + runtimePaneTitlesByTabId: { 'tab-1': { 0: 'codex [working]' } }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] }, + terminalLayoutsByTabId: { + 'tab-1': makeLayout( + { type: 'leaf', leafId: '11111111-1111-4111-8111-111111111111' }, + 'pty-1' + ) + } + } + + expect(selectRuntimePaneTitlesForWorktree(state, worktreeId)).toBe( + selectRuntimePaneTitlesForWorktree(state, worktreeId) + ) + expect(selectLivePtyIdsForWorktree(state, worktreeId)).toBe( + selectLivePtyIdsForWorktree(state, worktreeId) + ) + expect(selectTerminalLayoutRootsForWorktree(state, worktreeId)).toBe( + selectTerminalLayoutRootsForWorktree(state, worktreeId) + ) + }) + + it('carries the same identity across unrelated pane-title and PTY churn', () => { + const worktreeId = 'repo1::/path/wt1' + const state: SelectorState = { + tabsByWorktree: { + [worktreeId]: [makeTab('tab-1', worktreeId)] + }, + runtimePaneTitlesByTabId: { 'tab-1': { 0: 'codex [working]' } }, + ptyIdsByTabId: { 'tab-1': ['pty-1'] } + } + const unrelatedUpdate: SelectorState = { + ...state, + runtimePaneTitlesByTabId: { + ...state.runtimePaneTitlesByTabId, + 'other-tab': { 0: 'claude [permission]' } + }, + ptyIdsByTabId: { ...state.ptyIdsByTabId, 'other-tab': ['pty-other'] } + } + + expect(selectRuntimePaneTitlesForWorktree(state, worktreeId)).toBe( + selectRuntimePaneTitlesForWorktree(unrelatedUpdate, worktreeId) + ) + expect(selectLivePtyIdsForWorktree(state, worktreeId)).toBe( + selectLivePtyIdsForWorktree(unrelatedUpdate, worktreeId) + ) + }) + + it('returns the shared frozen empty for a worktree with no tabs', () => { + const state: SelectorState & LayoutRootSelectorState = { + tabsByWorktree: {}, + runtimePaneTitlesByTabId: {}, + ptyIdsByTabId: {}, + terminalLayoutsByTabId: {} + } + + expect(selectRuntimePaneTitlesForWorktree(state, 'missing')).toBe(EMPTY_RUNTIME_PANE_TITLES) + expect(selectLivePtyIdsForWorktree(state, 'missing')).toBe(EMPTY_LIVE_PTY_IDS) + expect(selectTerminalLayoutRootsForWorktree(state, 'missing')).toBe(EMPTY_TERMINAL_LAYOUT_ROOTS) + expect(Object.isFrozen(EMPTY_RUNTIME_PANE_TITLES)).toBe(true) + expect(Object.isFrozen(EMPTY_LIVE_PTY_IDS)).toBe(true) + expect(Object.isFrozen(EMPTY_TERMINAL_LAYOUT_ROOTS)).toBe(true) + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts b/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts index 128afafe03d..cd9f5c4f7c5 100644 --- a/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts +++ b/src/renderer/src/components/sidebar/worktree-card-status-inputs.ts @@ -1,9 +1,19 @@ import type { AppState } from '@/store/types' import type { TerminalPaneLayoutNode } from '../../../../shared/terminal-tab-types' +import { createWorktreeRecordSelector } from './worktree-record-selector-cache' // Why: these selectors return fresh maps whose top-level values preserve // underlying per-tab references, so callers must compare them shallowly. +// Why frozen: one instance is shared by every card, so a stray write would leak +// across worktrees instead of failing locally. +export const EMPTY_RUNTIME_PANE_TITLES: Record> = Object.freeze({}) +export const EMPTY_LIVE_PTY_IDS: Record = Object.freeze({}) +export const EMPTY_TERMINAL_LAYOUT_ROOTS: Record< + string, + TerminalPaneLayoutNode | null | undefined +> = Object.freeze({}) + type WorktreeCardStatusInputState = Pick & { tabsByWorktree: Record } @@ -12,44 +22,56 @@ type WorktreeCardLayoutRootInputState = Pick tabsByWorktree: Record } -export function selectRuntimePaneTitlesForWorktree( - state: WorktreeCardStatusInputState, - worktreeId: string -): Record> { - const out: Record> = {} - for (const tab of state.tabsByWorktree[worktreeId] ?? []) { - const paneTitles = state.runtimePaneTitlesByTabId[tab.id] - if (paneTitles) { - out[tab.id] = paneTitles +export const selectRuntimePaneTitlesForWorktree = createWorktreeRecordSelector< + WorktreeCardStatusInputState, + Record> +>({ + readSources: (state) => [state.tabsByWorktree, state.runtimePaneTitlesByTabId], + empty: EMPTY_RUNTIME_PANE_TITLES, + build: (state, worktreeId) => { + const out: Record> = {} + for (const tab of state.tabsByWorktree[worktreeId] ?? []) { + const paneTitles = state.runtimePaneTitlesByTabId[tab.id] + if (paneTitles) { + out[tab.id] = paneTitles + } } + return out } - return out -} +}) -export function selectLivePtyIdsForWorktree( - state: WorktreeCardStatusInputState, - worktreeId: string -): Record { - const out: Record = {} - for (const tab of state.tabsByWorktree[worktreeId] ?? []) { - const ids = state.ptyIdsByTabId[tab.id] - if (ids && ids.length > 0) { - out[tab.id] = ids +export const selectLivePtyIdsForWorktree = createWorktreeRecordSelector< + WorktreeCardStatusInputState, + Record +>({ + readSources: (state) => [state.tabsByWorktree, state.ptyIdsByTabId], + empty: EMPTY_LIVE_PTY_IDS, + build: (state, worktreeId) => { + const out: Record = {} + for (const tab of state.tabsByWorktree[worktreeId] ?? []) { + const ids = state.ptyIdsByTabId[tab.id] + if (ids && ids.length > 0) { + out[tab.id] = ids + } } + return out } - return out -} +}) -export function selectTerminalLayoutRootsForWorktree( - state: WorktreeCardLayoutRootInputState, - worktreeId: string -): Record { - const out: Record = {} - for (const tab of state.tabsByWorktree[worktreeId] ?? []) { - out[tab.id] = state.terminalLayoutsByTabId[tab.id]?.root +export const selectTerminalLayoutRootsForWorktree = createWorktreeRecordSelector< + WorktreeCardLayoutRootInputState, + Record +>({ + readSources: (state) => [state.tabsByWorktree, state.terminalLayoutsByTabId], + empty: EMPTY_TERMINAL_LAYOUT_ROOTS, + build: (state, worktreeId) => { + const out: Record = {} + for (const tab of state.tabsByWorktree[worktreeId] ?? []) { + out[tab.id] = state.terminalLayoutsByTabId[tab.id]?.root + } + return out } - return out -} +}) export function selectTerminalLayoutRootsForWorktrees( state: WorktreeCardLayoutRootInputState, diff --git a/src/renderer/src/components/sidebar/worktree-lineage-projection.test.ts b/src/renderer/src/components/sidebar/worktree-lineage-projection.test.ts new file mode 100644 index 00000000000..8d2ebcdb517 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-lineage-projection.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it } from 'vitest' +import type { WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { + getCyclicProjectedWorktreeLineageIds, + getLineageRenderInfo, + getProjectedWorktreeLineageChildrenByParentId +} from './worktree-lineage-projection' + +function makeWorktree(id: string): Worktree { + return { + id, + repoId: 'repo-1', + instanceId: `${id}-instance`, + path: `/tmp/${id}`, + branch: id, + isMainWorktree: false + } as unknown as Worktree +} + +function makeLineage(childId: string, parentId: string): WorktreeLineage { + return { + worktreeId: childId, + worktreeInstanceId: `${childId}-instance`, + parentWorktreeId: parentId, + parentWorktreeInstanceId: `${parentId}-instance` + } as unknown as WorktreeLineage +} + +/** + * A sidebar-scale fixture: one root with many children, mirroring the shape the + * row builder scans on every store write. + */ +function buildFixture(childCount: number): { + lineageById: Record + worktreeMap: Map +} { + const worktreeMap = new Map() + const lineageById: Record = {} + worktreeMap.set('root', makeWorktree('root')) + for (let index = 0; index < childCount; index += 1) { + const id = `child-${index}` + worktreeMap.set(id, makeWorktree(id)) + lineageById[id] = makeLineage(id, 'root') + } + return { lineageById, worktreeMap } +} + +describe('worktree lineage projection cache', () => { + it('reuses the cyclic-id scan for an unchanged input pair', () => { + const { lineageById, worktreeMap } = buildFixture(8) + const first = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + const second = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + expect(second).toBe(first) + }) + + it('reuses the children projection for an unchanged input pair', () => { + const { lineageById, worktreeMap } = buildFixture(8) + const first = getProjectedWorktreeLineageChildrenByParentId(lineageById, worktreeMap) + const second = getProjectedWorktreeLineageChildrenByParentId(lineageById, worktreeMap) + expect(second).toBe(first) + expect(first.get('root')?.map((worktree) => worktree.id)).toEqual([ + 'child-0', + 'child-1', + 'child-2', + 'child-3', + 'child-4', + 'child-5', + 'child-6', + 'child-7' + ]) + }) + + it('rescans when either input is replaced', () => { + const { lineageById, worktreeMap } = buildFixture(4) + const baseline = getProjectedWorktreeLineageChildrenByParentId(lineageById, worktreeMap) + + const replacedLineage = { ...lineageById } + expect(getProjectedWorktreeLineageChildrenByParentId(replacedLineage, worktreeMap)).not.toBe( + baseline + ) + + const replacedWorktrees = new Map(worktreeMap) + expect(getProjectedWorktreeLineageChildrenByParentId(lineageById, replacedWorktrees)).not.toBe( + baseline + ) + }) + + it('reflects a removed lineage edge as soon as the record is replaced', () => { + const { lineageById, worktreeMap } = buildFixture(2) + expect( + getProjectedWorktreeLineageChildrenByParentId(lineageById, worktreeMap).get('root') + ).toHaveLength(2) + + const withoutFirstChild = { ...lineageById } + delete withoutFirstChild['child-0'] + const reprojected = getProjectedWorktreeLineageChildrenByParentId( + withoutFirstChild, + worktreeMap + ) + expect(reprojected.get('root')?.map((worktree) => worktree.id)).toEqual(['child-1']) + expect( + getLineageRenderInfo( + worktreeMap.get('child-0') as Worktree, + withoutFirstChild, + worktreeMap, + getCyclicProjectedWorktreeLineageIds(withoutFirstChild, worktreeMap) + ).state + ).toBe('none') + }) + + it('still reports cycles from the cached scan', () => { + const worktreeMap = new Map([ + ['a', makeWorktree('a')], + ['b', makeWorktree('b')] + ]) + const lineageById: Record = { + a: makeLineage('a', 'b'), + b: makeLineage('b', 'a') + } + const cyclic = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) + expect([...cyclic].sort()).toEqual(['a', 'b']) + expect(getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap)).toBe(cyclic) + expect(getProjectedWorktreeLineageChildrenByParentId(lineageById, worktreeMap).size).toBe(0) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-lineage-projection.ts b/src/renderer/src/components/sidebar/worktree-lineage-projection.ts index 337140f586c..b8748f287c5 100644 --- a/src/renderer/src/components/sidebar/worktree-lineage-projection.ts +++ b/src/renderer/src/components/sidebar/worktree-lineage-projection.ts @@ -22,10 +22,49 @@ export function getProjectedWorktreeLineage( return (worktree as WorktreeWithResolvedLineage).lineage } +type LineageProjection = { + cyclicLineageIds?: Set + childrenByParentId?: Map +} + +/** + * Why: both projections are O(worktrees) scans that the sidebar row builder and + * the pinned/attached-children readers re-run several times per pass, and + * zustand re-runs those on every store write. Both are pure in the two inputs, + * and both inputs are immutable store-derived collections that are REPLACED + * rather than mutated, so their identity pair is a sound cache key. Weak on both + * levels so a superseded lineage record or worktree index is not pinned. + */ +const projectionByLineageAndWorktreeMap = new WeakMap< + Readonly>, + WeakMap, LineageProjection> +>() + +function getLineageProjection( + lineageById: Readonly>, + worktreeMap: ReadonlyMap +): LineageProjection { + let byWorktreeMap = projectionByLineageAndWorktreeMap.get(lineageById) + if (!byWorktreeMap) { + byWorktreeMap = new WeakMap() + projectionByLineageAndWorktreeMap.set(lineageById, byWorktreeMap) + } + let projection = byWorktreeMap.get(worktreeMap) + if (!projection) { + projection = {} + byWorktreeMap.set(worktreeMap, projection) + } + return projection +} + export function getCyclicProjectedWorktreeLineageIds( lineageById: Readonly>, worktreeMap: ReadonlyMap ): Set { + const projection = getLineageProjection(lineageById, worktreeMap) + if (projection.cyclicLineageIds) { + return projection.cyclicLineageIds + } const validLineageByChildId = new Map() for (const worktree of worktreeMap.values()) { const lineage = getProjectedWorktreeLineage(worktree, lineageById) @@ -37,7 +76,9 @@ export function getCyclicProjectedWorktreeLineageIds( validLineageByChildId.set(worktree.id, lineage) } } - return getCyclicWorktreeLineageChildIds(validLineageByChildId) + const cyclicLineageIds = getCyclicWorktreeLineageChildIds(validLineageByChildId) + projection.cyclicLineageIds = cyclicLineageIds + return cyclicLineageIds } export function getLineageRenderInfo( @@ -65,6 +106,10 @@ export function getProjectedWorktreeLineageChildrenByParentId( lineageById: Readonly>, worktreeMap: ReadonlyMap ): Map { + const projection = getLineageProjection(lineageById, worktreeMap) + if (projection.childrenByParentId) { + return projection.childrenByParentId + } const cyclicLineageIds = getCyclicProjectedWorktreeLineageIds(lineageById, worktreeMap) const childrenByParentId = new Map() for (const worktree of worktreeMap.values()) { @@ -76,6 +121,7 @@ export function getProjectedWorktreeLineageChildrenByParentId( children.push(worktree) childrenByParentId.set(lineage.parent.id, children) } + projection.childrenByParentId = childrenByParentId return childrenByParentId } diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.test.ts b/src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.test.ts new file mode 100644 index 00000000000..d45a7bf392f --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' +import type { AppState } from '@/store/types' +import { + EMPTY_PENDING_WORKTREE_CREATION_KEYS, + selectPendingWorktreeCreationKeys +} from './pending-worktree-creation-keys' + +type PendingCreations = AppState['pendingWorktreeCreations'] + +function makePending(creationId: string, repoId: string): PendingCreations[string] { + return { + creationId, + request: { repoId } + } as unknown as PendingCreations[string] +} + +describe('selectPendingWorktreeCreationKeys', () => { + // Why: this runs inside an always-mounted sidebar subscriber, so zustand + // re-evaluates it on every store write in the app. + it('returns the shared frozen empty when nothing is pending', () => { + const empty: PendingCreations = {} + + expect(selectPendingWorktreeCreationKeys(empty)).toBe(EMPTY_PENDING_WORKTREE_CREATION_KEYS) + expect(selectPendingWorktreeCreationKeys({})).toBe(EMPTY_PENDING_WORKTREE_CREATION_KEYS) + expect(selectPendingWorktreeCreationKeys(undefined)).toBe(EMPTY_PENDING_WORKTREE_CREATION_KEYS) + expect(Object.isFrozen(EMPTY_PENDING_WORKTREE_CREATION_KEYS)).toBe(true) + }) + + it('builds the key list once per slice identity', () => { + const pending: PendingCreations = { + 'creation-1': makePending('creation-1', 'repo with space') + } + + const first = selectPendingWorktreeCreationKeys(pending) + expect(first).toEqual(['creation-1 repo with space']) + expect(selectPendingWorktreeCreationKeys(pending)).toBe(first) + }) + + it('rebuilds when the slice is replaced', () => { + const before: PendingCreations = { + 'creation-1': makePending('creation-1', 'repo-1') + } + const after: PendingCreations = { + ...before, + 'creation-2': makePending('creation-2', 'repo-2') + } + + expect(selectPendingWorktreeCreationKeys(after)).toEqual([ + 'creation-1 repo-1', + 'creation-2 repo-2' + ]) + }) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.ts b/src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.ts new file mode 100644 index 00000000000..857edd9c3c9 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/listing/pending-worktree-creation-keys.ts @@ -0,0 +1,39 @@ +import type { AppState } from '@/store/types' + +// Why frozen: the sidebar row model is always mounted and this list is empty +// almost always, so one shared identity serves every read. +export const EMPTY_PENDING_WORKTREE_CREATION_KEYS: string[] = Object.freeze( + [] +) as unknown as string[] + +const keysBySource = new WeakMap() + +/** + * Flat `" "` keys for the pending-creation sidebar rows. + * + * Why identity-cached: this runs inside an always-mounted subscriber, so an + * unmemoized `Object.values(...).map(...)` allocated an array plus one template + * string per pending creation on every store write in the app. Keyed on the + * slice reference, so it only rebuilds when the slice itself is replaced. + * + * Split on the first space — creationId is a UUID (no space) so a + * space-containing repoId stays intact. + */ +export function selectPendingWorktreeCreationKeys( + pendingWorktreeCreations: AppState['pendingWorktreeCreations'] | undefined +): string[] { + if (!pendingWorktreeCreations) { + return EMPTY_PENDING_WORKTREE_CREATION_KEYS + } + const cached = keysBySource.get(pendingWorktreeCreations) + if (cached) { + return cached + } + const creations = Object.values(pendingWorktreeCreations) + const keys = + creations.length === 0 + ? EMPTY_PENDING_WORKTREE_CREATION_KEYS + : creations.map((creation) => `${creation.creationId} ${creation.request.repoId}`) + keysBySource.set(pendingWorktreeCreations, keys) + return keys +} diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts index 16899dbc146..ddd91a23653 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts @@ -19,6 +19,7 @@ import { getEmptyProjectPlaceholderRepoIds } from '../../empty-project-placehold import { addHostSectionRows } from '../../host-section-rows' import { orderHostSectionOptions } from '../../host-section-order' import { buildSidebarHostOptions } from '../../sidebar-host-options' +import { selectPendingWorktreeCreationKeys } from './pending-worktree-creation-keys' type SectionRowsArgs = { groupBy: WorktreeGroupBy @@ -96,19 +97,17 @@ export function useSidebarSectionRows(args: SectionRowsArgs) { ) // Why: subscribe on a flat key array (useShallow) so progress ticks don't rebuild the whole row model. - // Split on first space — creationId is a UUID (no space) so a space-containing repoId stays intact. const pendingCreationKeys = useAppStore( - useShallow((s) => - Object.values(s.pendingWorktreeCreations ?? {}).map( - (creation) => `${creation.creationId} ${creation.request.repoId}` - ) - ) + useShallow((s) => selectPendingWorktreeCreationKeys(s.pendingWorktreeCreations)) ) const pendingCreations = useMemo( () => pendingCreationKeys.map((key) => { const separator = key.indexOf(' ') - return { creationId: key.slice(0, separator), repoId: key.slice(separator + 1) } + return { + creationId: key.slice(0, separator), + repoId: key.slice(separator + 1) + } }), [pendingCreationKeys] ) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.test.tsx b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.test.tsx index d385f29d6c4..422a14b3693 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.test.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.test.tsx @@ -1,11 +1,27 @@ // @vitest-environment happy-dom -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, renderHook } from '@testing-library/react' import { useAppStore } from '@/store' +import { LOCAL_EXECUTION_HOST_ID } from '../../../../../../shared/execution-host' import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity' import { makeRepo, makeWorktree } from '../../../worktree-jump-palette-test-fixtures' import { useVisibleSidebarWorktrees } from './use-visible-worktrees' +import type * as visibleWorktreesModule from '../../visible-worktrees' + +const computeVisibleWorktreesCalls = { count: 0 } +vi.mock('../../visible-worktrees', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + computeVisibleWorktrees: ( + ...args: Parameters + ): ReturnType => { + computeVisibleWorktreesCalls.count += 1 + return actual.computeVisibleWorktrees(...args) + } + } +}) const initialState = useAppStore.getInitialState() @@ -43,7 +59,7 @@ describe('useVisibleSidebarWorktrees', () => { sortedIds: [local.id, ssh.id], repoMap: new Map([[repo.id, repo]]), worktreeLineageById: {}, - settings: useAppStore.getState().settings, + defaultHostId: LOCAL_EXECUTION_HOST_ID, agentSendTargetWorktreeId: null }) ) @@ -78,7 +94,7 @@ describe('useVisibleSidebarWorktrees', () => { sortedIds: [local.id, ssh.id], repoMap: new Map([[repo.id, repo]]), worktreeLineageById: {}, - settings: useAppStore.getState().settings, + defaultHostId: LOCAL_EXECUTION_HOST_ID, agentSendTargetWorktreeId: null }) ) @@ -87,4 +103,60 @@ describe('useVisibleSidebarWorktrees', () => { getWorktreeHostIdentity(ssh) ]) }) + it('does not rescan every worktree when a settings write leaves the focused host unchanged', () => { + const repo = makeRepo() + const worktree = makeWorktree('alpha', 'Alpha workspace', { hostId: 'local' }) + useAppStore.setState({ worktreesByRepo: { [repo.id]: [worktree] } }) + + const baseArgs = { + filterState: { + showSleepingWorkspaces: true, + filterRepoIds: [], + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + hideCliCreatedWorkspaces: false, + hideDetachedHeadWorkspaces: false, + hideWorkspacesFromOtherDevices: false, + alwaysShowDefaultBranchWorkspace: true, + visibleWorkspaceHostIds: null, + workspaceHostScope: 'all' + }, + sortBy: 'recent', + sortedIds: [worktree.id], + repoMap: new Map([[repo.id, repo]]), + worktreeLineageById: {}, + defaultHostId: LOCAL_EXECUTION_HOST_ID, + agentSendTargetWorktreeId: null + } as Parameters[0] + // Why the extra `settings`: it is the pre-fix memo key. Passing it keeps + // this test red against the old hook, which re-keyed the whole scan on the + // settings object identity. + const withSettings = ( + settings: ReturnType['settings'] + ): Parameters[0] => Object.assign({}, baseArgs, { settings }) + + computeVisibleWorktreesCalls.count = 0 + const { result, rerender } = renderHook( + (args: Parameters[0]) => useVisibleSidebarWorktrees(args), + { initialProps: withSettings(useAppStore.getState().settings) } + ) + const initialVisible = result.current.visibleWorktrees + const callsAfterFirstRender = computeVisibleWorktreesCalls.count + expect(callsAfterFirstRender).toBe(1) + + // A settings write that does not move the focused execution host. + const nextSettings = { + ...useAppStore.getState().settings, + sidebarWidth: 321 + } as ReturnType['settings'] + useAppStore.setState({ settings: nextSettings }) + rerender(withSettings(nextSettings)) + + expect(computeVisibleWorktreesCalls.count).toBe(callsAfterFirstRender) + expect(result.current.visibleWorktrees).toBe(initialVisible) + + // A write that does move it still recomputes. + rerender(Object.assign({}, withSettings(nextSettings), { defaultHostId: 'runtime:other' })) + expect(computeVisibleWorktreesCalls.count).toBe(callsAfterFirstRender + 1) + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts index e04d57ccff9..804d5118605 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts @@ -2,10 +2,9 @@ import { useMemo } from 'react' import { useAppStore } from '@/store' import { getAgentStatusEpochNow } from '@/lib/agent-status-epoch-clock' import { getWorktreeIdsWithLiveAgent } from '@/lib/worktree-activity-state' -import type { AppState } from '@/store/types' import type { Repo } from '../../../../../../shared/repo-types' import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types' -import { getSettingsFocusedExecutionHostId } from '../../../../../../shared/execution-host' +import type { ExecutionHostId } from '../../../../../../shared/execution-host' import { computeVisibleWorktrees } from '../../visible-worktrees' import { EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, @@ -29,10 +28,12 @@ export function useVisibleSidebarWorktrees(args: { sortedIds: string[] repoMap: Map worktreeLineageById: Record - settings: AppState['settings'] + /** Pre-derived focused host; the whole `settings` object would re-key this + * 423-workspace scan on every unrelated settings write. */ + defaultHostId: ExecutionHostId agentSendTargetWorktreeId: string | null }) { - const { filterState, sortBy, sortedIds, repoMap, worktreeLineageById, settings } = args + const { filterState, sortBy, sortedIds, repoMap, worktreeLineageById, defaultHostId } = args const { showSleepingWorkspaces, filterRepoIds, @@ -98,7 +99,7 @@ export function useVisibleSidebarWorktrees(args: { repoMap, workspaceHostScope, visibleWorkspaceHostIds, - defaultHostId: getSettingsFocusedExecutionHostId(settings), + defaultHostId, worktreeLineageById, forcedVisibleWorktreeIds: args.agentSendTargetWorktreeId ? [args.agentSendTargetWorktreeId] @@ -118,7 +119,7 @@ export function useVisibleSidebarWorktrees(args: { alwaysShowDefaultBranchWorkspace, workspaceHostScope, visibleWorkspaceHostIds, - settings, + defaultHostId, repoMap, tabsByWorktree, ptyIdsByTabId, diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.ts index d732aa71cec..8c88d812746 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/use-scroll-to-top.ts @@ -42,7 +42,8 @@ export function useWorktreeListScrollToTop({ showScrollToTop: boolean scrollToTop: () => void } { - const detectorRef = useRef(createHardScrollUpDetectorState()) + const detectorRef = useRef(undefined!) + detectorRef.current ??= createHardScrollUpDetectorState() const [showScrollToTop, setShowScrollToTop] = useState(false) const showScrollToTopRef = useRef(false) const idleTimerRef = useRef(null) diff --git a/src/renderer/src/components/sidebar/worktree-record-selector-cache.ts b/src/renderer/src/components/sidebar/worktree-record-selector-cache.ts new file mode 100644 index 00000000000..220d2d074bf --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-record-selector-cache.ts @@ -0,0 +1,63 @@ +import { shallow } from 'zustand/shallow' + +type WorktreeRecordGeneration = { + sources: readonly unknown[] + carried: ReadonlyMap | null + byWorktreeId: Map +} + +function sameSources(previous: readonly unknown[], next: readonly unknown[]): boolean { + if (previous.length !== next.length) { + return false + } + for (let index = 0; index < next.length; index += 1) { + if (previous[index] !== next[index]) { + return false + } + } + return true +} + +/** + * Wraps a per-worktree record selector in a store-identity-keyed cache. + * + * Zustand re-runs every mounted subscriber's selector on every store write, so + * an unmemoized build allocates one record per visible card per write even when + * nothing it reads changed. Gating on the source slice identities collapses that + * to one build per worktree per generation; carrying the previous generation + * forward keeps the reference stable when a rebuild produces equal contents, so + * downstream `useShallow`/`useMemo` gates short-circuit on identity. + * + * The returned records are shared by every caller and must never be mutated. + */ +export function createWorktreeRecordSelector(options: { + readSources: (state: TState) => readonly unknown[] + build: (state: TState, worktreeId: string) => TValue + empty: TValue +}): (state: TState, worktreeId: string) => TValue { + let generation: WorktreeRecordGeneration | null = null + return (state, worktreeId) => { + const sources = options.readSources(state) + if (!generation || !sameSources(generation.sources, sources)) { + generation = { + sources, + carried: generation?.byWorktreeId ?? null, + byWorktreeId: new Map() + } + } + const cached = generation.byWorktreeId.get(worktreeId) + if (cached !== undefined) { + return cached + } + const built = options.build(state, worktreeId) + const carried = generation.carried?.get(worktreeId) + let value = built + if (Object.keys(built).length === 0) { + value = options.empty + } else if (carried && shallow(carried, built)) { + value = carried + } + generation.byWorktreeId.set(worktreeId, value) + return value + } +} diff --git a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts index 25d87c50801..08c0d27b9e7 100644 --- a/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-title-derived-agent-rows.ts @@ -215,6 +215,12 @@ function buildTitleDerivedAgentRow(args: { agentType, rowSource: 'live', state: rowState, + // Load-bearing zero, not a placeholder: `dashboardRowBucketProjection` reads `startedAt === 0` + // as "title-derived" and short-circuits `unseen`. That is the ONLY reason the `args.now` stamps + // on `entry` above (updatedAt / stateStartedAt / observation) cannot move this row's bucket. + // Dashboard bucket caches key their invalidation on the freshness boundary in + // `isExplicitAgentStatusFresh` alone; give this a real timestamp and every one of them starts + // serving stale counts, with no test failing at the point of the change. startedAt: 0 } } diff --git a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx index fd275935f77..2e04247dd9b 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.rename-shortcut.test.tsx @@ -1,4 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { requestTerminalTabRename } from './terminal-tab-rename-request' + +const windowListeners = new Map void>>() const reactHookRuntime = vi.hoisted(() => ({ states: [] as unknown[], @@ -11,10 +14,8 @@ const storeState = vi.hoisted( clearTabLaunchAgent: ReturnType ptyIdsByTabId: Record retainedAgentsByPaneKey: Record - renamingTabId: string | null keybindings: Record repos: unknown[] - setRenamingTabId: ReturnType terminalLayoutsByTabId: Record worktreesByRepo: Record unreadTerminalTabs: Record @@ -23,12 +24,8 @@ const storeState = vi.hoisted( clearTabLaunchAgent: vi.fn(), ptyIdsByTabId: {} as Record, retainedAgentsByPaneKey: {}, - renamingTabId: null as string | null, keybindings: {}, repos: [], - setRenamingTabId: vi.fn((tabId: string | null) => { - storeState.renamingTabId = tabId - }), terminalLayoutsByTabId: {}, worktreesByRepo: {}, unreadTerminalTabs: {} as Record @@ -340,13 +337,24 @@ describe('SortableTab rename shortcut signal', () => { beforeEach(() => { reactHookRuntime.states = [] reactHookRuntime.index = 0 - storeState.renamingTabId = 'terminal-tab-1' storeState.unreadTerminalTabs = {} storeState.clearTabLaunchAgent.mockClear() - storeState.setRenamingTabId.mockClear() + windowListeners.clear() vi.stubGlobal('window', { - addEventListener: vi.fn(), - removeEventListener: vi.fn() + addEventListener: vi.fn((type: string, listener: (event: Event) => void) => { + const listeners = windowListeners.get(type) ?? new Set<(event: Event) => void>() + listeners.add(listener) + windowListeners.set(type, listeners) + }), + removeEventListener: vi.fn((type: string, listener: (event: Event) => void) => { + windowListeners.get(type)?.delete(listener) + }), + dispatchEvent: vi.fn((event: Event) => { + for (const listener of windowListeners.get(event.type) ?? []) { + listener(event) + } + return true + }) }) vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0) @@ -355,22 +363,30 @@ describe('SortableTab rename shortcut signal', () => { vi.stubGlobal('cancelAnimationFrame', vi.fn()) }) - it('opens the inline rename input and consumes the matching store signal', async () => { + it('opens the inline rename input for a request that targets this tab', async () => { await renderSortableTab() + requestTerminalTabRename('terminal-tab-1') const rerender = expandNode(await renderSortableTab()) const inputs = findElementsByType(rerender, 'input') - expect(storeState.setRenamingTabId).toHaveBeenCalledWith(null) - expect(storeState.renamingTabId).toBeNull() expect(inputs).toHaveLength(1) expect(inputs[0].props.value).toBe('Runtime terminal title') expect(inputs[0].props['data-tab-rename-input']).toBe('true') }) + it('ignores a rename request aimed at a different tab', async () => { + await renderSortableTab() + requestTerminalTabRename('terminal-tab-2') + const rerender = expandNode(await renderSortableTab()) + + expect(findElementsByType(rerender, 'input')).toHaveLength(0) + }) + it('ignores IME composition Enter before committing the custom tab title', async () => { const onSetCustomTitle = vi.fn() await renderSortableTab({ onSetCustomTitle }) + requestTerminalTabRename('terminal-tab-1') let rerender = expandNode(await renderSortableTab({ onSetCustomTitle })) let input = findElementsByType(rerender, 'input')[0] ;(input.props.onChange as (event: { target: { value: string } }) => void)({ diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index 70935abb29a..cb5966d1e23 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -17,6 +17,10 @@ import { type DropIndicator } from './drop-indicator' import { preventMiddleButtonDefault } from './middle-button-default-guard' +import { + RENAME_TERMINAL_TAB_EVENT, + type RenameTerminalTabDetail +} from './terminal-tab-rename-request' import { SortableTabContextMenu } from './SortableTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' @@ -97,8 +101,6 @@ export default function SortableTab({ terminalLayout: s.terminalLayoutsByTabId?.[tab.id] }) ) - const renamingTabId = useAppStore((s) => s.renamingTabId) - const setRenamingTabId = useAppStore((s) => s.setRenamingTabId) // Why: shellOverride is stamped at create time, so changing the default shell later won't repaint existing tabs. const shellForIcon = tab.shellOverride @@ -166,14 +168,25 @@ export default function SortableTab({ }) }, []) - // Why: the tab.rename shortcut routes through store renamingTabId; open the editor and clear it so it fires once. + // Why the ref: keeps the listener subscribed to tab.id alone, so OSC title churn can't + // resubscribe it mid-edit. Written from an Effect, not in render -- a render React discards + // must not leave a stale handler behind for the next commit to fire. + const handleRenameOpenRef = useRef(handleRenameOpen) useEffect(() => { - if (renamingTabId !== tab.id) { - return + handleRenameOpenRef.current = handleRenameOpen + }, [handleRenameOpen]) + + useEffect(() => { + const onRenameRequest = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (detail?.tabId !== tab.id) { + return + } + handleRenameOpenRef.current() } - handleRenameOpen() - setRenamingTabId(null) - }, [renamingTabId, tab.id, handleRenameOpen, setRenamingTabId]) + window.addEventListener(RENAME_TERMINAL_TAB_EVENT, onRenameRequest) + return () => window.removeEventListener(RENAME_TERMINAL_TAB_EVENT, onRenameRequest) + }, [tab.id]) useEffect(() => { const closeMenu = (): void => setMenuOpen(false) diff --git a/src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx b/src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx index acbd7ce38bc..39cacfb7825 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.update-depth-probe.test.tsx @@ -18,6 +18,7 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { TabDragItemData } from '../tab-group/useTabDragSplit' import { useAppStore } from '../../store' import SortableTab from './SortableTab' +import { requestTerminalTabRename } from './terminal-tab-rename-request' type ProbeState = { unreadTerminalTabs: Record @@ -27,9 +28,7 @@ type ProbeState = { runtimePaneTitlesByTabId: Record> ptyIdsByTabId: Record terminalLayoutsByTabId: Record - renamingTabId: string | null keybindings: Record - setRenamingTabId: (tabId: string | null) => void } type StoreApiWithHook = { @@ -44,7 +43,7 @@ async function createProbeStore(): Promise { const globals = globalThis as Record if (!globals[globalKey]) { const { create } = await import('zustand') - globals[globalKey] = create((set) => ({ + globals[globalKey] = create(() => ({ unreadTerminalTabs: {}, unreadAgentCompletionPanes: {}, agentStatusByPaneKey: {}, @@ -52,9 +51,7 @@ async function createProbeStore(): Promise { runtimePaneTitlesByTabId: {}, ptyIdsByTabId: {}, terminalLayoutsByTabId: {}, - renamingTabId: null, - keybindings: {}, - setRenamingTabId: (tabId) => set({ renamingTabId: tabId }) + keybindings: {} })) } return globals[globalKey] as StoreApiWithHook @@ -101,6 +98,15 @@ vi.mock('@/components/ui/input', () => ({ Input: (props: Record) => })) +// Counts SortableTab's own renders: it is rendered unconditionally inside the tab body, so one +// stub render == one SortableTab render, which the Harness counter above cannot see. +vi.mock('./TerminalTabLeadingIcon', () => ({ + TerminalTabLeadingIcon: () => { + tabRenderCount += 1 + return + } +})) + vi.mock('./shell-icons', () => ({ ShellIcon: () => })) vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => })) vi.mock('../sidebar/WorktreeCardHelpers', () => ({ FilledBellIcon: () => })) @@ -127,6 +133,7 @@ const dragData: TabDragItemData = { const probeStore = useAppStore as unknown as StoreApiWithHook let renderCount = 0 +let tabRenderCount = 0 function Harness({ tab }: { tab: TerminalTab }): ReactElement { renderCount += 1 @@ -170,25 +177,52 @@ function ChurningHarness({ titles }: { titles: string[] }): ReactElement { afterEach(() => { cleanup() - probeStore.setState({ renamingTabId: null, unreadTerminalTabs: {}, agentStatusEpoch: 0 }) + probeStore.setState({ unreadTerminalTabs: {}, agentStatusEpoch: 0 }) renderCount = 0 + tabRenderCount = 0 }) describe('SortableTab update-depth probe', () => { - it('settles when the rename shortcut arms renamingTabId', () => { - probeStore.setState({ renamingTabId: 'terminal-tab-1' }) + it('settles when the rename shortcut targets this tab', () => { const { container } = render() + act(() => requestTerminalTabRename('terminal-tab-1')) expect(container.querySelector('[data-tab-rename-input]')).not.toBeNull() - expect(probeStore.getState().renamingTabId).toBeNull() expect(renderCount).toBeLessThan(20) }) it('settles under title churn while the rename editor is open', () => { - probeStore.setState({ renamingTabId: 'terminal-tab-1' }) render() + act(() => requestTerminalTabRename('terminal-tab-1')) expect(renderCount).toBeLessThan(40) }) + // Regression: the shortcut used to arm a store field every tab subscribed to, so one rename + // re-rendered every mounted tab twice — once to notice the id, once when the tab cleared it. + it('re-renders only the targeted tab, once, per rename request', () => { + render( + <> + + + + + ) + const mountRenders = tabRenderCount + act(() => requestTerminalTabRename('terminal-tab-1')) + expect(tabRenderCount - mountRenders).toBe(1) + }) + + it('does not re-render any tab for a rename request no mounted tab owns', () => { + render( + <> + + + + ) + const mountRenders = tabRenderCount + act(() => requestTerminalTabRename('terminal-tab-9')) + expect(tabRenderCount).toBe(mountRenders) + }) + it('settles under a store write storm', () => { render() const before = renderCount @@ -201,12 +235,12 @@ describe('SortableTab update-depth probe', () => { }) it('settles in StrictMode double-invoked effects', () => { - probeStore.setState({ renamingTabId: 'terminal-tab-1' }) - render( + const { container } = render( ) - expect(probeStore.getState().renamingTabId).toBeNull() + act(() => requestTerminalTabRename('terminal-tab-1')) + expect(container.querySelector('[data-tab-rename-input]')).not.toBeNull() }) }) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx index e51c599f669..9d3e506e3f1 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.test.tsx @@ -292,60 +292,4 @@ describe('SortableTabContextMenu', () => { expect(container.textContent).not.toContain('Move Tab to Split') expect(container.textContent).toContain('Split terminal right') }) - - describe('copy session id', () => { - const LEAF = '11111111-1111-4111-8111-111111111111' - - function withLiveAgent(sessionId: string | null): void { - storeMock.state = { - ...storeMock.state, - terminalLayoutsByTabId: { - 'term-1': { root: { type: 'leaf', leafId: LEAF }, activeLeafId: LEAF } - }, - agentStatusByPaneKey: { - [`term-1:${LEAF}`]: { - state: 'done', - prompt: '', - updatedAt: 1, - stateStartedAt: 1, - paneKey: `term-1:${LEAF}`, - agentType: 'claude', - stateHistory: [], - ...(sessionId ? { providerSession: { key: 'session_id', id: sessionId } } : {}) - } - }, - paneForegroundAgentByPaneKey: {} - } - } - - it('omits the item for a tab with no agent', () => { - const { container } = renderMenu() - - expect(container.textContent).not.toContain('Copy Session ID') - }) - - it('omits the item until the active agent reports a session id', () => { - withLiveAgent(null) - const { container } = renderMenu() - - expect(container.textContent).not.toContain('Copy Session ID') - }) - - it('copies the active pane session id', async () => { - const writeClipboardText = vi.fn().mockResolvedValue(undefined) - Object.assign(window, { api: { ui: { writeClipboardText } } }) - withLiveAgent('session-abc') - const { container } = renderMenu() - - act(() => getButton(container, 'Copy Session ID').click()) - await vi.waitFor(() => expect(writeClipboardText).toHaveBeenCalledWith('session-abc')) - }) - - it('does not resolve a session id while the menu is closed', () => { - withLiveAgent('session-abc') - const { container } = renderMenu({ open: false }) - - expect(container.textContent).not.toContain('Copy Session ID') - }) - }) }) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index b298072f9f6..53b31012d39 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -11,8 +11,6 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { useAppStore } from '../../store' import { formatShortcutLabel, useOptionalShortcutLabel } from '@/hooks/useShortcutLabel' import { translate } from '@/i18n/i18n' -import { TabAgentSessionIdMenuItem } from './TabAgentSessionIdMenuItem' -import { resolveTabAgentSessionId } from './tab-agent-session-id' import { TerminalTabSplitMenuSection } from './TerminalTabSplitMenuSection' import { TAB_CONTEXT_MENU_CONTENT_CLASS } from './tab-context-menu-sizing' @@ -123,10 +121,6 @@ export function SortableTabContextMenu({ onTogglePin }: SortableTabContextMenuProps): React.JSX.Element { const keybindings = useAppStore((state) => state.keybindings) - // The id is a primitive, so unchanged sessions stay referentially stable without a cache. - const agentSessionId = useAppStore((state) => - open ? resolveTabAgentSessionId(state, tab.id) : null - ) const splitRightShortcut = formatShortcutLabel('terminal.splitRight', keybindings) const splitDownShortcut = formatShortcutLabel('terminal.splitDown', keybindings) @@ -194,7 +188,6 @@ export function SortableTabContextMenu({ {translate('auto.components.tab.bar.SortableTabContextMenu.2f697b3c31', 'Change Title')} {renameShortcut ? {renameShortcut} : null} -
{translate('auto.components.tab.bar.SortableTabContextMenu.35e8892fd0', 'Tab Color')} diff --git a/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.test.tsx b/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.test.tsx deleted file mode 100644 index da857ec57bf..00000000000 --- a/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.test.tsx +++ /dev/null @@ -1,79 +0,0 @@ -/** - * @vitest-environment happy-dom - */ -import { act, type ReactNode } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { TabAgentSessionIdMenuItem } from './TabAgentSessionIdMenuItem' - -const toastMock = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })) - -vi.mock('@/components/ui/dropdown-menu', () => ({ - DropdownMenuItem: ({ - children, - disabled, - onSelect, - 'aria-label': ariaLabel - }: { - children?: ReactNode - disabled?: boolean - onSelect?: () => void - 'aria-label'?: string - }) => ( - - ) -})) - -vi.mock('lucide-react', () => ({ Copy: () => null })) -vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) -vi.mock('sonner', () => ({ toast: toastMock })) - -const mounted: { container: HTMLDivElement; root: Root }[] = [] - -function render(sessionId: string | null): HTMLDivElement { - const container = document.createElement('div') - document.body.appendChild(container) - const root = createRoot(container) - act(() => root.render()) - mounted.push({ container, root }) - return container -} - -afterEach(() => { - for (const { container, root } of mounted.splice(0)) { - act(() => root.unmount()) - container.remove() - } - toastMock.success.mockReset() - toastMock.error.mockReset() -}) - -describe('TabAgentSessionIdMenuItem', () => { - it('renders nothing when no session id is available', () => { - expect(render(null).textContent).toBe('') - }) - - it('copies on select when an id is known', async () => { - const writeClipboardText = vi.fn().mockResolvedValue(undefined) - Object.assign(window, { api: { ui: { writeClipboardText } } }) - const container = render('abc-123') - - const button = container.querySelector('button') - expect(button?.disabled).toBe(false) - act(() => button?.click()) - await vi.waitFor(() => expect(writeClipboardText).toHaveBeenCalledWith('abc-123')) - }) - - it('reports clipboard failures', async () => { - const writeClipboardText = vi.fn().mockRejectedValue(new Error('clipboard unavailable')) - Object.assign(window, { api: { ui: { writeClipboardText } } }) - const button = render('abc-123').querySelector('button') - - act(() => button?.click()) - await vi.waitFor(() => - expect(toastMock.error).toHaveBeenCalledWith('Failed to copy Session ID') - ) - }) -}) diff --git a/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.tsx b/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.tsx deleted file mode 100644 index 73f3f128e5d..00000000000 --- a/src/renderer/src/components/tab-bar/TabAgentSessionIdMenuItem.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { Copy } from 'lucide-react' -import { toast } from 'sonner' -import { DropdownMenuItem } from '@/components/ui/dropdown-menu' -import { translate } from '@/i18n/i18n' - -async function copySessionId(sessionId: string): Promise { - try { - await window.api.ui.writeClipboardText(sessionId) - toast.success( - translate( - 'components.tab.bar.SortableTabContextMenu.copySessionIdSuccess', - 'Session ID copied' - ) - ) - } catch { - toast.error( - translate( - 'components.tab.bar.SortableTabContextMenu.copySessionIdError', - 'Failed to copy Session ID' - ) - ) - } -} - -/** Copies the active pane's provider session id when one is available. */ -export function TabAgentSessionIdMenuItem({ - sessionId -}: { - sessionId: string | null -}): React.JSX.Element | null { - if (sessionId === null) { - return null - } - const label = translate( - 'components.tab.bar.SortableTabContextMenu.copySessionId', - 'Copy Session ID' - ) - return ( - { - void copySessionId(sessionId) - }} - > - - {label} - - ) -} diff --git a/src/renderer/src/components/tab-bar/tab-agent-session-id.test.ts b/src/renderer/src/components/tab-bar/tab-agent-session-id.test.ts deleted file mode 100644 index 2f06f0a8db7..00000000000 --- a/src/renderer/src/components/tab-bar/tab-agent-session-id.test.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import { resolveTabAgentSessionId, type TabAgentSessionIdState } from './tab-agent-session-id' - -const LEAF_A = '11111111-1111-4111-8111-111111111111' -const LEAF_B = '22222222-2222-4222-8222-222222222222' - -function entry(overrides: Partial = {}): AgentStatusEntry { - return { - state: 'done', - prompt: '', - updatedAt: 1, - stateStartedAt: 1, - paneKey: `tab-1:${LEAF_A}`, - agentType: 'claude', - stateHistory: [], - ...overrides - } -} - -function state(overrides: Partial = {}): TabAgentSessionIdState { - return { - terminalLayoutsByTabId: { - 'tab-1': { - root: { type: 'leaf', leafId: LEAF_A }, - activeLeafId: LEAF_A, - expandedLeafId: null - } - }, - agentStatusByPaneKey: {}, - paneForegroundAgentByPaneKey: {}, - ...overrides - } -} - -describe('resolveTabAgentSessionId', () => { - it('is absent when the pane has no agent row', () => { - expect(resolveTabAgentSessionId(state(), 'tab-1')).toBeNull() - }) - - it('is absent for a tab with no layout', () => { - expect(resolveTabAgentSessionId(state(), 'tab-missing')).toBeNull() - }) - - it('reads the id reported by the active pane', () => { - const resolved = resolveTabAgentSessionId( - state({ - agentStatusByPaneKey: { - [`tab-1:${LEAF_A}`]: entry({ providerSession: { key: 'session_id', id: 'abc-123' } }) - } - }), - 'tab-1' - ) - expect(resolved).toBe('abc-123') - }) - - it('is absent until the agent reports an id', () => { - const resolved = resolveTabAgentSessionId( - state({ agentStatusByPaneKey: { [`tab-1:${LEAF_A}`]: entry() } }), - 'tab-1' - ) - expect(resolved).toBeNull() - }) - - describe('liveness', () => { - it('is absent for a hydrated row with no live hook since restore', () => { - const resolved = resolveTabAgentSessionId( - state({ - agentStatusByPaneKey: { - [`tab-1:${LEAF_A}`]: entry({ - restoredUnconfirmed: true, - providerSession: { key: 'session_id', id: 'abc-123' } - }) - } - }), - 'tab-1' - ) - expect(resolved).toBeNull() - }) - - it('is absent once the pane is proven back at the shell', () => { - const resolved = resolveTabAgentSessionId( - state({ - agentStatusByPaneKey: { - [`tab-1:${LEAF_A}`]: entry({ providerSession: { key: 'session_id', id: 'abc-123' } }) - }, - paneForegroundAgentByPaneKey: { - [`tab-1:${LEAF_A}`]: { agent: null, shellForeground: true } - } - }), - 'tab-1' - ) - expect(resolved).toBeNull() - }) - - it('keeps a session whose foreground evidence is only that an agent runs', () => { - const resolved = resolveTabAgentSessionId( - state({ - agentStatusByPaneKey: { - [`tab-1:${LEAF_A}`]: entry({ providerSession: { key: 'session_id', id: 'abc-123' } }) - }, - paneForegroundAgentByPaneKey: { - [`tab-1:${LEAF_A}`]: { agent: 'claude', shellForeground: false } - } - }), - 'tab-1' - ) - expect(resolved).toBe('abc-123') - }) - - it('keeps a working session that reported a session boundary', () => { - // Why: sessionBoundary marks a resume/clear landing idle — a session start, - // not a session end, and exactly when the first id arrives. - const resolved = resolveTabAgentSessionId( - state({ - agentStatusByPaneKey: { - [`tab-1:${LEAF_A}`]: entry({ - sessionBoundary: true, - providerSession: { key: 'session_id', id: 'fresh-1' } - }) - } - }), - 'tab-1' - ) - expect(resolved).toBe('fresh-1') - }) - }) - - describe('split tabs', () => { - const splitState = (activeLeafId: string): TabAgentSessionIdState => - state({ - terminalLayoutsByTabId: { - 'tab-1': { - root: { - type: 'split', - direction: 'vertical', - first: { type: 'leaf', leafId: LEAF_A }, - second: { type: 'leaf', leafId: LEAF_B } - }, - activeLeafId, - expandedLeafId: null - } - }, - agentStatusByPaneKey: { - [`tab-1:${LEAF_A}`]: entry({ providerSession: { key: 'session_id', id: 'left' } }), - [`tab-1:${LEAF_B}`]: entry({ providerSession: { key: 'session_id', id: 'right' } }) - } - }) - - it('reads the active pane, not a sibling', () => { - expect(resolveTabAgentSessionId(splitState(LEAF_B), 'tab-1')).toBe('right') - }) - - it('is absent when the active leaf id no longer exists in the layout', () => { - const stale = '33333333-3333-4333-8333-333333333333' - expect(resolveTabAgentSessionId(splitState(stale), 'tab-1')).toBeNull() - }) - }) -}) diff --git a/src/renderer/src/components/tab-bar/tab-agent-session-id.ts b/src/renderer/src/components/tab-bar/tab-agent-session-id.ts deleted file mode 100644 index e0d804bc4c5..00000000000 --- a/src/renderer/src/components/tab-bar/tab-agent-session-id.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { AgentStatusEntry } from '../../../../shared/agent-status-types' -import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types' -import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent' -import { resolveNativeChatActiveLayoutLeafId } from '../native-chat/native-chat-leaf-routing' - -export type TabAgentSessionIdState = { - agentStatusByPaneKey?: Record - terminalLayoutsByTabId?: Record - paneForegroundAgentByPaneKey?: Record -} - -/** Returns the active pane's provider session id when its agent is still live. */ -export function resolveTabAgentSessionId( - state: TabAgentSessionIdState, - tabId: string -): string | null { - const leafId = resolveNativeChatActiveLayoutLeafId(state.terminalLayoutsByTabId?.[tabId]) - if (!leafId) { - return null - } - const paneKey = `${tabId}:${leafId}` - const entry = state.agentStatusByPaneKey?.[paneKey] - // Hydrated rows may describe a session that ended while no receiver was up. - if (!entry?.agentType || entry.restoredUnconfirmed === true) { - return null - } - // OSC 133;D proves the pane is back at the shell, regardless of the last hook state. - if (state.paneForegroundAgentByPaneKey?.[paneKey]?.shellForeground === true) { - return null - } - return entry.providerSession?.id ?? null -} diff --git a/src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx b/src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx index 54f762d4611..c81eae348dd 100644 --- a/src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx +++ b/src/renderer/src/components/tab-bar/tab-context-menu-consistency.test.tsx @@ -13,7 +13,6 @@ import { const TAB_MENU_SOURCES = [ 'EditorFileTabContextMenu.tsx', 'SortableTabContextMenu.tsx', - 'TabAgentSessionIdMenuItem.tsx', 'BrowserTab.tsx', 'TabWorkspaceLayoutMenuSection.tsx', 'TerminalTabSplitMenuSection.tsx' diff --git a/src/renderer/src/components/tab-bar/terminal-tab-rename-request.ts b/src/renderer/src/components/tab-bar/terminal-tab-rename-request.ts new file mode 100644 index 00000000000..4559ad0ea95 --- /dev/null +++ b/src/renderer/src/components/tab-bar/terminal-tab-rename-request.ts @@ -0,0 +1,19 @@ +/** + * Targeted rename dispatch for the `tab.rename` shortcut. + * + * Why an event and not store state: a store field is read by every mounted tab, so arming it + * re-rendered the whole strip, and the consuming tab then had to clear it — a second pass over + * every tab. The event reaches only the tab that owns the id. Mirrors the terminal-pane + * TOGGLE_TERMINAL_PANE_EXPAND_EVENT / FOCUS_TERMINAL_PANE_EVENT dispatch. + */ +export const RENAME_TERMINAL_TAB_EVENT = 'orca-rename-terminal-tab' + +export type RenameTerminalTabDetail = { + tabId: string +} + +export function requestTerminalTabRename(tabId: string): void { + window.dispatchEvent( + new CustomEvent(RENAME_TERMINAL_TAB_EVENT, { detail: { tabId } }) + ) +} diff --git a/src/renderer/src/components/terminal-cold-activation.ts b/src/renderer/src/components/terminal-cold-activation.ts index 8273e1536e6..d57cb82d766 100644 --- a/src/renderer/src/components/terminal-cold-activation.ts +++ b/src/renderer/src/components/terminal-cold-activation.ts @@ -36,7 +36,8 @@ export function applyTerminalColdActivation(controller: TerminalParkingFoundatio terminalParkingEnabled, terminalTitleSnapshotAuthorityEnabled, workspaceSessionReady, - workspaceSurfaces + workspaceSurfaceIds, + workspaceSurfaceIdSet } = controller if ( renderedActiveWorktreeId && @@ -150,16 +151,15 @@ export function applyTerminalColdActivation(controller: TerminalParkingFoundatio tabsByWorktree, activationDeferredMountTabIdsByWorktreeRef.current ) - const allWorktreeIds = new Set(workspaceSurfaces.map((workspace) => workspace.id)) for (const id of mountedWorktreeIdsRef.current) { - if (!allWorktreeIds.has(id)) { + if (!workspaceSurfaceIdSet.has(id)) { mountedWorktreeIdsRef.current.delete(id) backgroundMountTabIdsByWorktreeRef.current.delete(id) activationDeferredMountTabIdsByWorktreeRef.current.delete(id) } } const anyMountedWorktreeHasLayout = computeAnyMountedWorktreeHasLayout( - workspaceSurfaces.map((workspace) => workspace.id), + workspaceSurfaceIds, mountedWorktreeIdsRef.current, layoutByWorktree, groupsByWorktree, diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx index bd2ca330f5c..d8dc54c954b 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx @@ -92,6 +92,8 @@ function renderMenu(overrides: Record = {}): string { canClearPaneTitle: false, onCopyTerminalId: vi.fn(), onCopyPaneId: vi.fn(), + canCopyAgentSessionId: false, + onCopyAgentSessionId: vi.fn(), ...overrides } return renderToStaticMarkup(React.createElement(TerminalContextMenu, props)) @@ -146,6 +148,29 @@ describe('TerminalContextMenu', () => { expect(items.list.some((item) => childrenText(item.children).includes('Switch to'))).toBe(false) }) + it('shows Copy Session ID only for panes with provider identity', () => { + const onCopyAgentSessionId = vi.fn() + renderMenu({ canCopyAgentSessionId: true, onCopyAgentSessionId }) + + const item = items.list.find( + (candidate) => childrenText(candidate.children) === 'Copy Session ID' + ) + expect(item).toBeDefined() + expect( + items.list + .map((candidate) => childrenText(candidate.children)) + .filter((label) => ['Copy Session ID', 'Copy Terminal ID', 'Copy Pane ID'].includes(label)) + ).toEqual(['Copy Session ID', 'Copy Terminal ID', 'Copy Pane ID']) + item?.onSelect?.() + expect(onCopyAgentSessionId).toHaveBeenCalledTimes(1) + + items.list = [] + renderMenu({ canCopyAgentSessionId: false }) + expect( + items.list.some((candidate) => childrenText(candidate.children) === 'Copy Session ID') + ).toBe(false) + }) + it('shows one shortcut per terminal menu action on Windows', () => { vi.stubGlobal('navigator', { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index 178ab0a3247..8311fb3df9d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -66,6 +66,8 @@ type TerminalContextMenuProps = { canClearPaneTitle: boolean onCopyTerminalId: () => void onCopyPaneId: () => void + canCopyAgentSessionId: boolean + onCopyAgentSessionId: () => void } export default function TerminalContextMenu({ @@ -101,7 +103,9 @@ export default function TerminalContextMenu({ onClearPaneTitle, canClearPaneTitle, onCopyTerminalId, - onCopyPaneId + onCopyPaneId, + canCopyAgentSessionId, + onCopyAgentSessionId }: TerminalContextMenuProps): React.JSX.Element { // Why: one primary binding prevents Windows/Linux shortcut labels from forcing row wraps. const shortcuts = useMemo( @@ -276,6 +280,15 @@ export default function TerminalContextMenu({ ) : null} ) : null} + {canCopyAgentSessionId ? ( + + + {translate( + 'components.terminalPane.TerminalContextMenu.copySessionId', + 'Copy Session ID' + )} + + ) : null} {translate( diff --git a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx index dcb838007b9..8b627a9651f 100644 --- a/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalErrorToast.tsx @@ -179,6 +179,11 @@ export function TerminalErrorToast({ const showIssueLink = !ssh && !paneOwnerUnverified && !showDaemonRestart && !isExplainedTerminalError(error) const displayError = humanizeTerminalError(error) + const tint = paneOwnerUnverified + ? null + : ssh + ? 'color-mix(in srgb, var(--color-amber-500) 20%, var(--popover))' + : 'color-mix(in srgb, var(--destructive) 20%, var(--popover))' const [retrying, setRetrying] = useState(false) const [retryFailed, setRetryFailed] = useState(false) const [environmentFooter, setEnvironmentFooter] = useState<{ @@ -231,17 +236,14 @@ export function TerminalErrorToast({ zIndex: 50, padding: '10px 14px', borderRadius: 6, - background: paneOwnerUnverified - ? 'var(--popover)' - : ssh - ? 'rgba(234, 179, 8, 0.12)' - : 'rgba(220, 38, 38, 0.15)', + background: 'var(--popover)', + backgroundImage: tint ? `linear-gradient(${tint}, ${tint})` : undefined, border: paneOwnerUnverified ? '1px solid var(--color-amber-500)' : ssh ? '1px solid rgba(234, 179, 8, 0.35)' : '1px solid rgba(220, 38, 38, 0.4)', - color: paneOwnerUnverified ? 'var(--popover-foreground)' : ssh ? '#fde68a' : '#fca5a5', + color: 'var(--popover-foreground)', fontSize: 12, fontFamily: 'monospace', whiteSpace: 'pre-wrap', @@ -268,7 +270,7 @@ export function TerminalErrorToast({ )}{' '} {translate( 'auto.components.terminal.pane.TerminalErrorToast.a7e2fd2699', @@ -326,7 +328,7 @@ export function TerminalErrorToast({ style={{ background: 'none', border: 'none', - color: paneOwnerUnverified ? 'var(--popover-foreground)' : ssh ? '#fde68a' : '#fca5a5', + color: 'inherit', cursor: 'pointer', fontSize: 14, padding: '0 0 0 8px', diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx index 73c4220dec5..1b5e94b4a11 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneNativeChatPortal.tsx @@ -3,6 +3,8 @@ import NativeChatView from '../native-chat/NativeChatView' import { makePaneKey } from '../../../../shared/stable-pane-id' import { canContinueAgentSessionInNewSession } from './terminal-agent-session-continuation' import type { TerminalPaneController } from './use-terminal-pane-controller' +import { useAppStore } from '@/store' +import { resolvePaneAgentSessionId } from './pane-agent-session-id' export function TerminalPaneNativeChatPortal({ controller @@ -30,6 +32,11 @@ export function TerminalPaneNativeChatPortal({ tabId, unifiedTabId } = controller + const chatPaneSessionId = useAppStore((state) => + effectiveChatViewMode && chatPane + ? resolvePaneAgentSessionId(state, makePaneKey(tabId, chatPane.leafId)) + : null + ) if (!effectiveChatViewMode || !chatPane?.container) { return null } @@ -78,6 +85,9 @@ export function TerminalPaneNativeChatPortal({ onCopyTerminalId: () => void contextMenu.runForPane(chatPane.id, contextMenu.onCopyTerminalId), onCopyPaneId: () => void contextMenu.runForPane(chatPane.id, contextMenu.onCopyPaneId), + canCopyAgentSessionId: chatPaneSessionId !== null, + onCopyAgentSessionId: () => + void contextMenu.runForPane(chatPane.id, contextMenu.onCopyAgentSessionId), canClosePane: managedPanes.length > 1, onClosePane: () => contextMenu.runForPane(chatPane.id, contextMenu.onClosePane) }} diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx index fff316dabce..fc33cab3f18 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx @@ -59,6 +59,7 @@ export function TerminalPaneSurface({ keybindings, managedPanes, managerRef, + menuAgentSessionId, menuPaneHasCustomTitle, openDiskSpaceAnalyzer, openQuickCommandEditor, @@ -247,6 +248,8 @@ export function TerminalPaneSurface({ canClearPaneTitle={menuPaneHasCustomTitle} onCopyTerminalId={() => void contextMenu.onCopyTerminalId()} onCopyPaneId={contextMenu.onCopyPaneId} + canCopyAgentSessionId={menuAgentSessionId !== null} + onCopyAgentSessionId={() => void contextMenu.onCopyAgentSessionId()} /> { + it('returns the live provider session for the exact pane', () => { + expect(resolvePaneAgentSessionId(state(live('live-session')), PANE_KEY)).toBe('live-session') + }) + + it('returns the pane-owned durable session after its live status row is cleared', () => { + expect( + resolvePaneAgentSessionId(state(undefined, sleeping('sleeping-session')), PANE_KEY) + ).toBe('sleeping-session') + }) + + it('does not reuse an older durable session while a newer live row lacks identity', () => { + expect(resolvePaneAgentSessionId(state(live(), sleeping('old-session')), PANE_KEY)).toBeNull() + }) + + it('falls back from an unconfirmed restored row to durable pane identity', () => { + expect( + resolvePaneAgentSessionId( + state(live('unconfirmed-session', true), sleeping('confirmed-session')), + PANE_KEY + ) + ).toBe('confirmed-session') + }) + + describe('liveness', () => { + it('is absent once the pane is proven back at the shell', () => { + expect( + resolvePaneAgentSessionId(state(live('live-session'), undefined, true), PANE_KEY) + ).toBe(null) + }) + + it('is absent at the shell even when a durable record survives the exit', () => { + expect( + resolvePaneAgentSessionId(state(undefined, sleeping('sleeping-session'), true), PANE_KEY) + ).toBeNull() + }) + + it('keeps a session whose foreground evidence is only that an agent runs', () => { + expect( + resolvePaneAgentSessionId(state(live('live-session'), undefined, false), PANE_KEY) + ).toBe('live-session') + }) + + it('keeps a session for a pane with no foreground evidence at all', () => { + expect( + resolvePaneAgentSessionId( + { + agentStatusByPaneKey: { [PANE_KEY]: live('live-session') }, + sleepingAgentSessionsByPaneKey: {}, + paneForegroundAgentByPaneKey: {} + }, + PANE_KEY + ) + ).toBe('live-session') + }) + }) + + it('does not read identity from a sibling pane', () => { + const sibling = 'tab-1:22222222-2222-4222-8222-222222222222' + expect(resolvePaneAgentSessionId(state(undefined, sleeping('session-1')), sibling)).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pane-agent-session-id.ts b/src/renderer/src/components/terminal-pane/pane-agent-session-id.ts new file mode 100644 index 00000000000..59ab6f38e86 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pane-agent-session-id.ts @@ -0,0 +1,27 @@ +import type { AgentStatusEntry } from '../../../../shared/agent-status-types' +import type { SleepingAgentSessionRecord } from '../../../../shared/agent-session-resume' +import type { PaneForegroundAgentEntry } from '../../store/slices/pane-foreground-agent' + +export type PaneAgentSessionIdState = { + agentStatusByPaneKey: Record + sleepingAgentSessionsByPaneKey: Record + paneForegroundAgentByPaneKey: Record +} + +/** Resolves the provider session owned by one exact terminal pane, while its agent is still live. */ +export function resolvePaneAgentSessionId( + state: PaneAgentSessionIdState, + paneKey: string +): string | null { + // OSC 133;D proves the pane is back at the shell. The durable record outlives that exit on + // purpose (cold restore resumes from it), so gate it here too — otherwise the gate would only + // hold for panes whose agent has no resumable record. + if (state.paneForegroundAgentByPaneKey[paneKey]?.shellForeground === true) { + return null + } + const live = state.agentStatusByPaneKey[paneKey] + if (live && live.restoredUnconfirmed !== true) { + return live.providerSession?.id ?? null + } + return state.sleepingAgentSessionsByPaneKey[paneKey]?.providerSession.id ?? null +} diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index bb61a50fbbc..a5aa36568ca 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -21,6 +21,7 @@ import { resolveTerminalCursorInactiveStyle } from '@/lib/pane-manager/pane-terminal-options' import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' +import { setTerminalCursorBlinkOption } from '@/lib/pane-manager/pane-cursor-blink-suspension' import type { PtyTransport } from './pty-transport' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' import { HEX_COLOR_RE } from '../../../../shared/color-validation' @@ -180,7 +181,9 @@ export function applyTerminalAppearance( const cursorStyle = settings.terminalCursorStyle ?? 'block' pane.terminal.options.cursorStyle = cursorStyle pane.terminal.options.cursorInactiveStyle = resolveTerminalCursorInactiveStyle(cursorStyle) - pane.terminal.options.cursorBlink = settings.terminalCursorBlink + // Why not a direct write: a suspended (hidden) pane parks the value instead, so a + // settings change mid-hide cannot re-arm its blink timer behind the hidden surface. + setTerminalCursorBlinkOption(pane.terminal, settings.terminalCursorBlink) const paneSize = paneFontSizes.get(pane.id) const metricOptions = { fontSize: paneSize ?? settings.terminalFontSize, diff --git a/src/renderer/src/components/terminal-pane/terminal-copy-rejection-guards.ts b/src/renderer/src/components/terminal-pane/terminal-copy-rejection-guards.ts index 6debb3e3d5f..9a13d026d08 100644 --- a/src/renderer/src/components/terminal-pane/terminal-copy-rejection-guards.ts +++ b/src/renderer/src/components/terminal-pane/terminal-copy-rejection-guards.ts @@ -1,7 +1,7 @@ // Why: writeClipboardText resolved unconditionally in the web client until the // insecure-context copy fallback landed. It can now reject (insecure origin with -// no live user gesture), so these two menu actions need explicit outcomes: -// the copy must never leave the pane unfocused, and Copy Pane ID must not toast +// no live user gesture), so identity-copy actions need explicit outcomes: +// the copy must never leave the pane unfocused, and identity actions must not toast // success for a copy that did not happen. Extracted so both are testable without // mounting the whole context-menu hook. @@ -22,15 +22,15 @@ export async function runTerminalCopy(args: { } } -export async function runCopyPaneId(args: { - paneKey: string +export async function runTerminalIdentityCopy(args: { + text: string writeClipboardText: (text: string) => Promise onSuccess: () => void onError: () => void focus: () => void }): Promise { try { - await args.writeClipboardText(args.paneKey) + await args.writeClipboardText(args.text) args.onSuccess() } catch { args.onError() diff --git a/src/renderer/src/components/terminal-pane/terminal-copy-rejection-handling.test.ts b/src/renderer/src/components/terminal-pane/terminal-copy-rejection-handling.test.ts index d6aac273ed8..1b14b379f2a 100644 --- a/src/renderer/src/components/terminal-pane/terminal-copy-rejection-handling.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-copy-rejection-handling.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { runTerminalCopy, runCopyPaneId } from './terminal-copy-rejection-guards' +import { runTerminalCopy, runTerminalIdentityCopy } from './terminal-copy-rejection-guards' // Why this file exists: web-preload-api's writeClipboardText used to resolve // unconditionally, so the terminal copy surfaces call it without a rejection @@ -42,15 +42,15 @@ describe('runTerminalCopy', () => { }) }) -describe('runCopyPaneId', () => { +describe('runTerminalIdentityCopy', () => { it('reports failure instead of claiming success when the write rejects', async () => { const onSuccess = vi.fn() const onError = vi.fn() const focus = vi.fn() await expect( - runCopyPaneId({ - paneKey: 'tab:leaf', + runTerminalIdentityCopy({ + text: 'tab:leaf', writeClipboardText: vi.fn().mockRejectedValue(REJECTION), onSuccess, onError, @@ -68,8 +68,8 @@ describe('runCopyPaneId', () => { const onError = vi.fn() const focus = vi.fn() - await runCopyPaneId({ - paneKey: 'tab:leaf', + await runTerminalIdentityCopy({ + text: 'tab:leaf', writeClipboardText: vi.fn().mockResolvedValue(undefined), onSuccess, onError, diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts index 206a31e7177..c42bbd31919 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts @@ -8,9 +8,9 @@ import { describe, expect, it } from 'vitest' const TERMINAL_PANE_HOOK_SOURCE_PATTERN = /^(?:TerminalPane\.tsx|use-terminal-pane-(?:chat-state|close-actions|context-actions|controller|foundation|global-listeners|layout-bindings|layout-persistence|lifecycle-stage|mobile-actions|paste-listeners|process-exit-actions|projection|reconciliation|startup-actions|store-bindings|title-effects|title-state)\.ts)$/ // Rebased onto main after the workbench surface-per-workspace and deferred -// split-cwd changes; this hash is from that main's pre-split TerminalPane (229 hooks). +// split-cwd changes; the pane session-ID projection adds one render hook (230 hooks). const PRE_REFACTOR_HOOK_ORDER_SHA256 = - 'be2366fffb992e082fd9e4641b7543a0db75cb7bc4ef6e0eb0deda41beaac358' + '77adcf8272ddc6f903920f12b2b652ec26c570987f452076ae6460c67d3741f3' const sourceFiles = readdirSync(__dirname) .filter((name) => TERMINAL_PANE_HOOK_SOURCE_PATTERN.test(name)) @@ -75,7 +75,7 @@ function readFlattenedHookOrder(): string[] { describe('TerminalPane refactor hook parity', () => { it('preserves the recursively flattened render hook order', () => { const hooks = readFlattenedHookOrder() - expect(hooks).toHaveLength(229) + expect(hooks).toHaveLength(230) expect(hooks.filter((hook) => hook === 'useMemo')).toHaveLength(4) expect(createHash('sha256').update(hooks.join('\n')).digest('hex')).toBe( PRE_REFACTOR_HOOK_ORDER_SHA256 diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts b/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts index 62051f84f26..417f920af5c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-menu-copy-actions.ts @@ -3,7 +3,7 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import { makePaneKey } from '../../../../shared/stable-pane-id' import { translate } from '@/i18n/i18n' import { copyTerminalHandleForPane } from './terminal-handle-copy' -import { runCopyPaneId, runTerminalCopy } from './terminal-copy-rejection-guards' +import { runTerminalCopy, runTerminalIdentityCopy } from './terminal-copy-rejection-guards' export const copyTerminalPaneMenuSelection = async (pane: ManagedPane | null): Promise => { if (!pane) { @@ -27,10 +27,10 @@ export const copyTerminalPaneMenuPaneId = async ( if (!pane) { return } - await runCopyPaneId({ + await runTerminalIdentityCopy({ // Why: orchestration targets use ORCA_PANE_KEY, which survives renderer // remounts; the numeric PaneManager id is only a local runtime handle. - paneKey: makePaneKey(tabId, pane.leafId), + text: makePaneKey(tabId, pane.leafId), writeClipboardText: window.api.ui.writeTerminalClipboardText, onSuccess: () => toast.success( @@ -83,3 +83,35 @@ export const copyTerminalPaneMenuTerminalId = async ( pane.terminal.focus() } } + +export const copyTerminalPaneMenuAgentSessionId = async ( + pane: ManagedPane | null, + sessionId: string | null +): Promise => { + if (!pane) { + return + } + if (!sessionId) { + pane.terminal.focus() + return + } + await runTerminalIdentityCopy({ + text: sessionId, + writeClipboardText: window.api.ui.writeTerminalClipboardText, + onSuccess: () => + toast.success( + translate( + 'components.terminalPane.TerminalContextMenu.copySessionIdSuccess', + 'Session ID copied' + ) + ), + onError: () => + toast.error( + translate( + 'components.terminalPane.TerminalContextMenu.copySessionIdError', + 'Unable to copy session ID' + ) + ), + focus: () => pane.terminal.focus() + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers-batch-sync.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers-batch-sync.test.ts new file mode 100644 index 00000000000..8bb0dca14ff --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers-batch-sync.test.ts @@ -0,0 +1,540 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher' + +type StartEvent = { + kind: 'start' + worktreeId: string + tabId: string + ptyId: string + paneId: number + leafId: string + drivesTabTitle: boolean + restoreTitleOnRegister: boolean +} +type DisposeEvent = { kind: 'dispose'; worktreeId: string; tabId: string; ptyId: string } +type ClearTitleEvent = { kind: 'clearTitle'; tabId: string; paneId: number } +type DiscardEvent = { kind: 'discard'; ptyId: string } +type WatcherEvent = StartEvent | DisposeEvent | ClearTitleEvent | DiscardEvent + +let events: WatcherEvent[] = [] + +const startParkedTerminalByteWatcher = vi.fn((options: ParkedTerminalByteWatcherOptions) => { + events.push({ + kind: 'start', + worktreeId: options.worktreeId, + tabId: options.tabId, + ptyId: options.ptyId, + paneId: options.paneId, + leafId: options.leafId, + drivesTabTitle: options.drivesTabTitle === true, + restoreTitleOnRegister: options.restoreTitleOnRegister === true + }) + return () => { + events.push({ + kind: 'dispose', + worktreeId: options.worktreeId, + tabId: options.tabId, + ptyId: options.ptyId + }) + } +}) + +vi.mock('./parked-terminal-byte-watcher', () => ({ + startParkedTerminalByteWatcher: (options: ParkedTerminalByteWatcherOptions) => + startParkedTerminalByteWatcher(options) +})) + +vi.mock('./pty-dispatcher', () => ({ + subscribeToPtyExit: () => () => {} +})) + +vi.mock('./pty-pre-handler-buffer', () => ({ + discardPreHandlerPtyState: (ptyId: string) => { + events.push({ kind: 'discard', ptyId }) + }, + hasPreHandlerPtyExit: () => false +})) + +vi.mock('../terminal/terminal-tab-actions', () => ({ + closeTerminalTab: () => {} +})) + +type TabModel = { id: string; ptyId: string | null } +type MockStoreState = { + tabsByWorktree: Record + terminalLayoutsByTabId: Record< + string, + { + root: unknown + activeLeafId: string | null + expandedLeafId: string | null + ptyIdsByLeafId?: Record + } + > + runtimePaneTitlesByTabId: Record> + settings: { terminalSshViewParking?: boolean } | null + runtimeStatusByEnvironmentId: Map + clearRuntimePaneTitle: (tabId: string, paneId: number) => void + setRuntimePaneTitle: () => void + clearTabLaunchAgent: () => void + setTabLayout: () => void + updateTabTitle: () => void + markUnverifiedPtyLoss: () => void + isPtyShutdownPending: () => boolean + suppressedPtyExitIds: Record +} + +let mockStoreState: MockStoreState + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => mockStoreState } +})) + +import { + clearTerminalProviderSnapshotCapabilities, + synchronizeTerminalProviderSnapshotCapabilities +} from '../terminal/terminal-provider-snapshot-capability' +import { + captureParkedTerminalPaneCandidates, + pruneParkedTerminalWatchers, + syncParkedTerminalTabWatchers, + syncParkedTerminalTabWatchersForWorkspaces, + type ParkedTerminalTabWatcherSyncEntry +} from './terminal-parked-tab-watchers' +import { capturedPanesByTabId, parkedWatchersByTabId } from './terminal-parked-watcher-registry' + +const leafId = (index: number): string => + `${index.toString(16).padStart(8, '0')}-1111-4111-8111-111111111111` + +function makeStore(): MockStoreState { + return { + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + runtimePaneTitlesByTabId: {}, + settings: null, + runtimeStatusByEnvironmentId: new Map(), + clearRuntimePaneTitle: (tabId: string, paneId: number) => { + events.push({ kind: 'clearTitle', tabId, paneId }) + }, + setRuntimePaneTitle: () => {}, + clearTabLaunchAgent: () => {}, + setTabLayout: () => {}, + updateTabTitle: () => {}, + markUnverifiedPtyLoss: () => {}, + isPtyShutdownPending: () => false, + suppressedPtyExitIds: {} + } +} + +/** Counts entries visited by `for...of` over the module-level registries. */ +function instrumentRegistryIteration(): { count: number; restore: () => void } { + const counter = { count: 0, restore: () => {} } + const patched: Map[] = [ + parkedWatchersByTabId as Map, + capturedPanesByTabId as Map + ] + for (const map of patched) { + Object.defineProperty(map, Symbol.iterator, { + configurable: true, + writable: true, + value: function* (this: Map) { + for (const entry of Map.prototype.entries.call(this)) { + counter.count += 1 + yield entry + } + } + }) + } + counter.restore = () => { + for (const map of patched) { + Reflect.deleteProperty(map, Symbol.iterator) + } + } + return counter +} + +describe('parked terminal watcher batch synchronization', () => { + beforeEach(() => { + events = [] + mockStoreState = makeStore() + clearTerminalProviderSnapshotCapabilities() + }) + + afterEach(() => { + pruneParkedTerminalWatchers(new Set()) + capturedPanesByTabId.clear() + events = [] + vi.clearAllMocks() + clearTerminalProviderSnapshotCapabilities() + }) + + describe('registry scan cost at the real-profile scale', () => { + // The user's profile: 423 workspace surfaces, 382 terminal tabs. + const WORKSPACE_COUNT = 423 + const TAB_COUNT = 382 + + function seedRegistries(): { + workspaceIds: string[] + tabsByWorktreeId: Map + } { + const workspaceIds = Array.from( + { length: WORKSPACE_COUNT }, + (_, index) => `repo::/worktree-${index}` + ) + const tabsByWorktreeId = new Map( + workspaceIds.map((workspaceId) => [workspaceId, [] as TabModel[]]) + ) + for (let index = 0; index < TAB_COUNT; index += 1) { + const worktreeId = workspaceIds[index % WORKSPACE_COUNT] + const tabId = `tab-${index}` + const ptyId = `${worktreeId}@@session-${index}` + tabsByWorktreeId.get(worktreeId)!.push({ id: tabId, ptyId }) + // A live, already-parked tab: present in both registries, nothing to + // dispose and nothing to start, so the pass is a pure registry scan. + parkedWatchersByTabId.set(tabId, { + worktreeId, + tabPtyId: ptyId, + paneIdByPtyId: new Map([[ptyId, 1]]), + disposersByPtyId: new Map() + }) + captureParkedTerminalPaneCandidates(tabId, worktreeId, [ + { ptyId, paneId: 1, leafId: leafId(index), drivesTabTitle: true } + ]) + } + return { workspaceIds, tabsByWorktreeId } + } + + it('collapses surfaces x registry scans into one scan of each registry', () => { + const { workspaceIds, tabsByWorktreeId } = seedRegistries() + const registryRows = parkedWatchersByTabId.size + capturedPanesByTabId.size + expect(registryRows).toBe(TAB_COUNT * 2) + + const perSurface = instrumentRegistryIteration() + for (const workspaceId of workspaceIds) { + syncParkedTerminalTabWatchers({ + worktreeId: workspaceId, + tabs: tabsByWorktreeId.get(workspaceId)!, + parkedTabIds: new Set() + }) + } + const perSurfaceVisits = perSurface.count + perSurface.restore() + + const batched = instrumentRegistryIteration() + const entries = new Map( + workspaceIds.map((workspaceId) => [ + workspaceId, + { tabs: tabsByWorktreeId.get(workspaceId)!, parkedTabIds: new Set() } + ]) + ) + syncParkedTerminalTabWatchersForWorkspaces(entries) + const batchedVisits = batched.count + batched.restore() + + // Old shape: every surface re-walks both registries in full. + expect(perSurfaceVisits).toBe(WORKSPACE_COUNT * registryRows) + // New shape: each registry is walked exactly once for the whole pass. + expect(batchedVisits).toBe(registryRows) + expect(perSurfaceVisits / batchedVisits).toBeGreaterThan(400) + }) + }) + + describe('start/dispose decisions match the per-surface path', () => { + type Scenario = { + name: string + workspaces: { + worktreeId: string + tabs: TabModel[] + parkedTabIds: string[] + restoreTitleOnStartTabIds?: string[] + }[] + /** Watcher rows already in the registry when the pass runs. */ + preParkedTabs: { worktreeId: string; tabId: string; ptyId: string; withDisposer: boolean }[] + /** Captures for tabs that may or may not still be live. */ + preCapturedTabs: { worktreeId: string; tabId: string; ptyId: string | null }[] + } + + const WORKTREE_A = 'repo::/alpha' + const WORKTREE_B = 'repo::/beta' + const WORKTREE_C = 'repo::/gamma' + + const pty = (worktreeId: string, index: number): string => `${worktreeId}@@session-${index}` + + const scenarios: Scenario[] = [ + { + name: 'cold start: nothing parked yet, two workspaces park every tab', + workspaces: [ + { + worktreeId: WORKTREE_A, + tabs: [ + { id: 'a1', ptyId: pty(WORKTREE_A, 1) }, + { id: 'a2', ptyId: pty(WORKTREE_A, 2) } + ], + parkedTabIds: ['a1', 'a2'] + }, + { + worktreeId: WORKTREE_B, + tabs: [{ id: 'b1', ptyId: pty(WORKTREE_B, 1) }], + parkedTabIds: ['b1'] + }, + { worktreeId: WORKTREE_C, tabs: [], parkedTabIds: [] } + ], + preParkedTabs: [], + preCapturedTabs: [] + }, + { + name: 'reveal: a parked workspace drops out of the parked set', + workspaces: [ + { + worktreeId: WORKTREE_A, + tabs: [ + { id: 'a1', ptyId: pty(WORKTREE_A, 1) }, + { id: 'a2', ptyId: pty(WORKTREE_A, 2) } + ], + parkedTabIds: [] + }, + { + worktreeId: WORKTREE_B, + tabs: [{ id: 'b1', ptyId: pty(WORKTREE_B, 1) }], + parkedTabIds: ['b1'] + } + ], + preParkedTabs: [ + { worktreeId: WORKTREE_A, tabId: 'a1', ptyId: pty(WORKTREE_A, 1), withDisposer: true }, + { worktreeId: WORKTREE_A, tabId: 'a2', ptyId: pty(WORKTREE_A, 2), withDisposer: true } + ], + preCapturedTabs: [ + { worktreeId: WORKTREE_A, tabId: 'a1', ptyId: pty(WORKTREE_A, 1) }, + { worktreeId: WORKTREE_A, tabId: 'a2', ptyId: pty(WORKTREE_A, 2) } + ] + }, + { + name: 'closed tabs: registry rows and captures outlive their tabs', + workspaces: [ + { + worktreeId: WORKTREE_A, + tabs: [{ id: 'a1', ptyId: pty(WORKTREE_A, 1) }], + parkedTabIds: ['a1'] + }, + { worktreeId: WORKTREE_B, tabs: [], parkedTabIds: [] } + ], + preParkedTabs: [ + { + worktreeId: WORKTREE_A, + tabId: 'a-closed', + ptyId: pty(WORKTREE_A, 9), + withDisposer: true + }, + { + worktreeId: WORKTREE_B, + tabId: 'b-closed', + ptyId: pty(WORKTREE_B, 9), + withDisposer: true + } + ], + preCapturedTabs: [ + { worktreeId: WORKTREE_A, tabId: 'a-closed', ptyId: pty(WORKTREE_A, 9) }, + { worktreeId: WORKTREE_B, tabId: 'b-closed', ptyId: pty(WORKTREE_B, 9) }, + { worktreeId: WORKTREE_A, tabId: 'a1', ptyId: pty(WORKTREE_A, 1) } + ] + }, + { + name: 're-minted pty: a parked tab wakes with a fresh pty id', + workspaces: [ + { + worktreeId: WORKTREE_A, + tabs: [{ id: 'a1', ptyId: pty(WORKTREE_A, 2) }], + parkedTabIds: ['a1'], + restoreTitleOnStartTabIds: ['a1'] + }, + { + worktreeId: WORKTREE_B, + tabs: [{ id: 'b1', ptyId: pty(WORKTREE_B, 1) }], + parkedTabIds: ['b1'] + } + ], + preParkedTabs: [ + { worktreeId: WORKTREE_A, tabId: 'a1', ptyId: pty(WORKTREE_A, 1), withDisposer: true } + ], + preCapturedTabs: [{ worktreeId: WORKTREE_A, tabId: 'a1', ptyId: pty(WORKTREE_A, 2) }] + }, + { + name: 'unknown worktree rows: registry holds a workspace absent from this pass', + workspaces: [ + { + worktreeId: WORKTREE_A, + tabs: [{ id: 'a1', ptyId: pty(WORKTREE_A, 1) }], + parkedTabIds: ['a1'] + } + ], + preParkedTabs: [ + { worktreeId: WORKTREE_C, tabId: 'c1', ptyId: pty(WORKTREE_C, 1), withDisposer: true } + ], + preCapturedTabs: [{ worktreeId: WORKTREE_C, tabId: 'c1', ptyId: pty(WORKTREE_C, 1) }] + }, + { + name: 'tombstone rows: a pinned-close entry with no live disposers', + workspaces: [ + { + worktreeId: WORKTREE_A, + tabs: [{ id: 'a1', ptyId: pty(WORKTREE_A, 1) }], + parkedTabIds: [] + }, + { + worktreeId: WORKTREE_B, + tabs: [{ id: 'b1', ptyId: pty(WORKTREE_B, 1) }], + parkedTabIds: ['b1'] + } + ], + preParkedTabs: [ + { worktreeId: WORKTREE_A, tabId: 'a1', ptyId: pty(WORKTREE_A, 1), withDisposer: false } + ], + preCapturedTabs: [{ worktreeId: WORKTREE_B, tabId: 'b1', ptyId: pty(WORKTREE_B, 1) }] + } + ] + + type Outcome = { + eventsByWorktree: Record + registry: readonly (readonly [ + string, + { worktreeId: string; tabPtyId: string | null; ptyIds: string[] } + ])[] + captures: string[] + } + + async function seedAndRun( + scenario: Scenario, + run: (scenario: Scenario) => void + ): Promise { + pruneParkedTerminalWatchers(new Set()) + capturedPanesByTabId.clear() + clearTerminalProviderSnapshotCapabilities() + mockStoreState = makeStore() + const allPtyIds = new Set() + for (const workspace of scenario.workspaces) { + for (const tab of workspace.tabs) { + if (tab.ptyId) { + allPtyIds.add(tab.ptyId) + } + } + } + for (const row of [...scenario.preParkedTabs, ...scenario.preCapturedTabs]) { + if (row.ptyId) { + allPtyIds.add(row.ptyId) + } + } + await synchronizeTerminalProviderSnapshotCapabilities(Array.from(allPtyIds), async (ids) => + ids.map((id) => ({ id, authoritative: true })) + ) + let paneOrdinal = 0 + for (const row of scenario.preParkedTabs) { + paneOrdinal += 1 + parkedWatchersByTabId.set(row.tabId, { + worktreeId: row.worktreeId, + tabPtyId: row.ptyId, + paneIdByPtyId: new Map([[row.ptyId, paneOrdinal]]), + disposersByPtyId: row.withDisposer + ? new Map([ + [ + row.ptyId, + () => { + events.push({ + kind: 'dispose', + worktreeId: row.worktreeId, + tabId: row.tabId, + ptyId: row.ptyId + }) + } + ] + ]) + : new Map() + }) + } + for (const [index, row] of scenario.preCapturedTabs.entries()) { + captureParkedTerminalPaneCandidates(row.tabId, row.worktreeId, [ + { ptyId: row.ptyId, paneId: 100 + index, leafId: leafId(index + 1), drivesTabTitle: true } + ]) + } + events = [] + run(scenario) + + const eventsByWorktree: Record = {} + for (const event of events) { + const key = 'worktreeId' in event ? event.worktreeId : 'shared' + ;(eventsByWorktree[key] ??= []).push(event) + } + // Why per-worktree, not one global sequence: batching deliberately hoists + // every workspace's dispose sweep ahead of every workspace's start pass. + // Registry rows are tab-id keyed and a tab belongs to exactly one + // worktree, so that reorder crosses only disjoint tab sets. `shared` + // events (pane-title clears, pre-handler discards) are compared as a + // multiset for the same reason. + for (const key of Object.keys(eventsByWorktree)) { + if (key === 'shared') { + eventsByWorktree[key] = [...eventsByWorktree[key]].sort((left, right) => + JSON.stringify(left).localeCompare(JSON.stringify(right)) + ) + } + } + return { + eventsByWorktree, + registry: Array.from( + parkedWatchersByTabId, + ([tabId, entry]) => + [ + tabId, + { + worktreeId: entry.worktreeId, + tabPtyId: entry.tabPtyId, + ptyIds: Array.from(entry.disposersByPtyId.keys()).sort() + } + ] as const + ).sort((left, right) => left[0].localeCompare(right[0])), + captures: Array.from(capturedPanesByTabId.keys()).sort() + } + } + + const runPerSurface = (scenario: Scenario): void => { + for (const workspace of scenario.workspaces) { + syncParkedTerminalTabWatchers({ + worktreeId: workspace.worktreeId, + tabs: workspace.tabs, + parkedTabIds: new Set(workspace.parkedTabIds), + ...(workspace.restoreTitleOnStartTabIds + ? { restoreTitleOnStartTabIds: new Set(workspace.restoreTitleOnStartTabIds) } + : {}) + }) + } + } + + const runBatched = (scenario: Scenario): void => { + syncParkedTerminalTabWatchersForWorkspaces( + new Map( + scenario.workspaces.map((workspace) => [ + workspace.worktreeId, + { + tabs: workspace.tabs, + parkedTabIds: new Set(workspace.parkedTabIds), + ...(workspace.restoreTitleOnStartTabIds + ? { restoreTitleOnStartTabIds: new Set(workspace.restoreTitleOnStartTabIds) } + : {}) + } + ]) + ) + ) + } + + for (const scenario of scenarios) { + it(`decides identically — ${scenario.name}`, async () => { + const perSurface = await seedAndRun(scenario, runPerSurface) + const batched = await seedAndRun(scenario, runBatched) + expect(batched).toEqual(perSurface) + // Guard against a vacuous comparison of two empty outcomes. + expect( + Object.values(perSurface.eventsByWorktree).some((list) => list.length > 0) || + perSurface.registry.length > 0 + ).toBe(true) + }) + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts index f834944607b..d088fbe41ad 100644 --- a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -244,6 +244,93 @@ function disposeClosedParkedTabWatchers( disposeParkedTabWatchers(tabId) } +/** One workspace's rendered parked verdict, as the batch pass consumes it. */ +export type ParkedTerminalTabWatcherSyncEntry = { + tabs: readonly ParkableTerminalTabModel[] + parkedTabIds: ReadonlySet + /** Parked-equivalent tabs whose pane has not restored the current title. */ + restoreTitleOnStartTabIds?: ReadonlySet +} + +function startOrReconcileParkedTabWatchers( + worktreeId: string, + entry: ParkedTerminalTabWatcherSyncEntry +): void { + for (const tab of entry.tabs) { + if (!entry.parkedTabIds.has(tab.id)) { + continue + } + const watcherEntry = parkedWatchersByTabId.get(tab.id) + const restoreTitleOnRegister = entry.restoreTitleOnStartTabIds?.has(tab.id) === true + if (watcherEntry) { + reconcileParkedTabWatchers(worktreeId, tab, watcherEntry, restoreTitleOnRegister) + } else { + startParkedTabWatchers(worktreeId, tab, restoreTitleOnRegister) + } + } +} + +/** + * Reconciles watchers for every rendered workspace in one pass. + * + * Why batched: the per-worktree entry point scans both registries in full, so + * the terminal host calling it once per workspace made a single effect fire + * cost surfaces x registry map iterations. Walking each registry once and then + * doing the per-tab start/reconcile pass is O(registry + tabs) instead. + * + * The dispose/start decisions are identical: registry entries are keyed by tab + * id and every tab belongs to exactly one worktree, so hoisting the dispose and + * capture-cleanup sweeps ahead of every start only reorders work across + * disjoint tab sets. + */ +export function syncParkedTerminalTabWatchersForWorkspaces( + entriesByWorktreeId: ReadonlyMap +): void { + if (entriesByWorktreeId.size === 0) { + return + } + // Why lazy: only worktrees that actually own a registry row need the id set, + // so an idle profile allocates none of them. + const liveTabIdsByWorktreeId = new Map>() + const liveTabIdsFor = (worktreeId: string): ReadonlySet | null => { + const cached = liveTabIdsByWorktreeId.get(worktreeId) + if (cached) { + return cached + } + const entry = entriesByWorktreeId.get(worktreeId) + if (!entry) { + return null + } + const liveTabIds = new Set(entry.tabs.map((tab) => tab.id)) + liveTabIdsByWorktreeId.set(worktreeId, liveTabIds) + return liveTabIds + } + for (const [tabId, watcherEntry] of parkedWatchersByTabId) { + const entry = entriesByWorktreeId.get(watcherEntry.worktreeId) + if (!entry) { + continue + } + const liveTabIds = liveTabIdsFor(watcherEntry.worktreeId) + if (!liveTabIds?.has(tabId)) { + disposeClosedParkedTabWatchers(tabId, watcherEntry) + continue + } + if (!entry.parkedTabIds.has(tabId) && watcherEntry.disposersByPtyId.size > 0) { + disposeParkedTabWatchers(tabId) + } + } + // Why: closed tabs never park/reveal again; drop captures to keep the registry bounded. + for (const [tabId, capture] of capturedPanesByTabId) { + const liveTabIds = liveTabIdsFor(capture.worktreeId) + if (liveTabIds && !liveTabIds.has(tabId)) { + capturedPanesByTabId.delete(tabId) + } + } + for (const [worktreeId, entry] of entriesByWorktreeId) { + startOrReconcileParkedTabWatchers(worktreeId, entry) + } +} + /** * Reconciles watchers for one worktree against its rendered parked set. * Run from an effect keyed on committed render state so disposal shares the @@ -256,35 +343,18 @@ export function syncParkedTerminalTabWatchers(args: { /** Parked-equivalent tabs whose pane has not restored the current title. */ restoreTitleOnStartTabIds?: ReadonlySet }): void { - const liveTabIds = new Set(args.tabs.map((tab) => tab.id)) - for (const [tabId, entry] of parkedWatchersByTabId) { - if (entry.worktreeId !== args.worktreeId) { - continue - } - if (!liveTabIds.has(tabId)) { - disposeClosedParkedTabWatchers(tabId, entry) - continue - } - if (!args.parkedTabIds.has(tabId) && entry.disposersByPtyId.size > 0) { - disposeParkedTabWatchers(tabId) - } - } - // Why: closed tabs never park/reveal again; drop captures to keep the registry bounded. - for (const [tabId, capture] of capturedPanesByTabId) { - if (capture.worktreeId === args.worktreeId && !liveTabIds.has(tabId)) { - capturedPanesByTabId.delete(tabId) - } - } - for (const tab of args.tabs) { - if (!args.parkedTabIds.has(tab.id)) { - continue - } - const entry = parkedWatchersByTabId.get(tab.id) - const restoreTitleOnRegister = args.restoreTitleOnStartTabIds?.has(tab.id) === true - if (entry) { - reconcileParkedTabWatchers(args.worktreeId, tab, entry, restoreTitleOnRegister) - } else { - startParkedTabWatchers(args.worktreeId, tab, restoreTitleOnRegister) - } - } + syncParkedTerminalTabWatchersForWorkspaces( + new Map([ + [ + args.worktreeId, + { + tabs: args.tabs, + parkedTabIds: args.parkedTabIds, + ...(args.restoreTitleOnStartTabIds + ? { restoreTitleOnStartTabIds: args.restoreTitleOnStartTabIds } + : {}) + } + ] + ]) + ) } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index a6f04fca5f3..155482f2163 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -11,6 +11,7 @@ import type { PreparedAgentSessionFork } from './terminal-agent-session-fork' import type { AgentSessionContinuationRequest } from '@/lib/agent-session-continuation' import { pasteTerminalPaneMenuClipboard } from './terminal-pane-menu-paste' import { + copyTerminalPaneMenuAgentSessionId, copyTerminalPaneMenuPaneId, copyTerminalPaneMenuSelection, copyTerminalPaneMenuTerminalId @@ -22,6 +23,9 @@ import { } from './terminal-pane-menu-agent-session-actions' import { useTerminalPaneSplitActions } from './use-terminal-pane-split-actions' import { useTerminalContextMenuTrigger } from './use-terminal-context-menu-trigger' +import { useAppStore } from '@/store' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { resolvePaneAgentSessionId } from './pane-agent-session-id' type UseTerminalPaneContextMenuDeps = { managerRef: React.RefObject @@ -57,6 +61,7 @@ type TerminalMenuState = { onSelectAll: () => void onCopyTerminalId: () => Promise onCopyPaneId: () => Promise + onCopyAgentSessionId: () => Promise onPaste: () => Promise onSplitRight: () => void onSplitDown: () => void @@ -170,6 +175,14 @@ export function useTerminalPaneContextMenu({ const onCopyTerminalId = async (): Promise => copyTerminalPaneMenuTerminalId(resolveMenuPane(), tabId) + const onCopyAgentSessionId = async (): Promise => { + const pane = resolveMenuPane() + const sessionId = pane + ? resolvePaneAgentSessionId(useAppStore.getState(), makePaneKey(tabId, pane.leafId)) + : null + return copyTerminalPaneMenuAgentSessionId(pane, sessionId) + } + const onPaste = async (): Promise => pasteResolvedPane('context-menu') const onEqualizePaneSizes = (): void => { @@ -275,6 +288,7 @@ export function useTerminalPaneContextMenu({ onSelectAll, onCopyTerminalId, onCopyPaneId, + onCopyAgentSessionId, onPaste, onSplitRight, onSplitDown, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts index 3e4ff6f6efc..ea179b6925e 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts @@ -16,6 +16,9 @@ import { } from '../native-chat/native-chat-leaf-routing' import { canContinueAgentSessionInNewSession } from './terminal-agent-session-continuation' import type { TerminalPaneMobileController } from './use-terminal-pane-mobile-actions' +import { useAppStore } from '@/store' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { resolvePaneAgentSessionId } from './pane-agent-session-id' export function useTerminalPaneProjection(controller: TerminalPaneMobileController) { const { @@ -40,6 +43,7 @@ export function useTerminalPaneProjection(controller: TerminalPaneMobileControll shouldMeasureHiddenStartup, structuredSessionAgent, structuredSessionId, + tabId, sshReconnectOwnsTerminalErrors, systemPrefersDark, tabAgentTypeByLeaf, @@ -100,6 +104,11 @@ export function useTerminalPaneProjection(controller: TerminalPaneMobileControll ) const menuPaneHasCustomTitle = contextMenu.menuPaneId !== null && Boolean(paneTitles[contextMenu.menuPaneId]) + const menuAgentSessionId = useAppStore((state) => + contextMenu.open && contextMenuLeafId + ? resolvePaneAgentSessionId(state, makePaneKey(tabId, contextMenuLeafId)) + : null + ) const chatLeafStillMounted = chatLeafId ? managedPanes.some((pane) => pane.leafId === chatLeafId) : false @@ -181,6 +190,7 @@ export function useTerminalPaneProjection(controller: TerminalPaneMobileControll showSshReconnectOverlay, visibleTerminalError, menuPaneHasCustomTitle, + menuAgentSessionId, chatLeafStillMounted, chatPane, chatPanePtyId, diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts index e28637097f7..dfc6ffc9614 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -120,7 +120,10 @@ export function useTerminalTabColdParking(args: { ) const terminalTabHiddenSinceRef = useRef(new Map()) // Why: view switches hide every tab at once, so the park clock cannot rank them. - const terminalTabActivationOrderRef = useRef(createTerminalTabActivationOrder()) + const terminalTabActivationOrderRef = useRef>( + undefined! + ) + terminalTabActivationOrderRef.current ??= createTerminalTabActivationOrder() // Why (shared measure-clock contract with Terminal.tsx): tab hiddenSince // survives a background-measure window so per-tab park deadlines stay in // sync with the worktree retention/TTL clock, and a post-measure cool-down diff --git a/src/renderer/src/components/terminal-parking-pass-candidates.ts b/src/renderer/src/components/terminal-parking-pass-candidates.ts index 1f7d85d89de..98f9ff5e5cd 100644 --- a/src/renderer/src/components/terminal-parking-pass-candidates.ts +++ b/src/renderer/src/components/terminal-parking-pass-candidates.ts @@ -27,7 +27,8 @@ export function collectTerminalParkingPassCandidates(controller: TerminalParking terminalWorktreeHiddenSinceRef, terminalWorktreeParkCooldownUntilRef, terminalWorktreeParkingTimersRef, - workspaceSurfaces + workspaceSurfaceIds, + workspaceSurfaceIdSet } = controller const parkingTimers = terminalWorktreeParkingTimersRef.current for (const timer of parkingTimers.values()) { @@ -38,9 +39,8 @@ export function collectTerminalParkingPassCandidates(controller: TerminalParking const nowMs = Date.now() const overrides = getTerminalParkingPolicyOverrides() const portalWorktreeIds = new Set(activityTerminalPortals.map((portal) => portal.worktreeId)) - const currentWorktreeIds = new Set(workspaceSurfaces.map((workspace) => workspace.id)) for (const worktreeId of Array.from(terminalWorktreeHiddenSinceRef.current.keys())) { - if (!currentWorktreeIds.has(worktreeId) || !mountedWorktreeIdsRef.current.has(worktreeId)) { + if (!workspaceSurfaceIdSet.has(worktreeId) || !mountedWorktreeIdsRef.current.has(worktreeId)) { terminalWorktreeHiddenSinceRef.current.delete(worktreeId) measuringTerminalWorktreeIdsRef.current.delete(worktreeId) terminalWorktreeParkCooldownUntilRef.current.delete(worktreeId) @@ -48,8 +48,7 @@ export function collectTerminalParkingPassCandidates(controller: TerminalParking } const retentionCandidates: TerminalWorktreeColdParkCandidate[] = [] - for (const workspace of workspaceSurfaces) { - const worktreeId = workspace.id + for (const worktreeId of workspaceSurfaceIds) { if (!mountedWorktreeIdsRef.current.has(worktreeId)) { terminalWorktreeHiddenSinceRef.current.delete(worktreeId) measuringTerminalWorktreeIdsRef.current.delete(worktreeId) diff --git a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx index c0d5a26c2e1..9593c23d276 100644 --- a/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx +++ b/src/renderer/src/components/terminal-quick-commands/TerminalQuickCommandDialog.tsx @@ -76,7 +76,10 @@ export function TerminalQuickCommandDialog({ const [draft, setDraft] = useState(command) const wasOpenRef = useRef(open) const syncedCommandRef = useRef(command) - const draftMemoryRef = useRef(createTerminalQuickCommandDialogDraftMemory(command, fallbackAgent)) + const draftMemoryRef = useRef>( + undefined! + ) + draftMemoryRef.current ??= createTerminalQuickCommandDialogDraftMemory(command, fallbackAgent) const initialScope = getTerminalQuickCommandScope(command) const lastRepoScopeIdRef = useRef( initialScope.type === 'repo' ? initialScope.repoId : null diff --git a/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx b/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx new file mode 100644 index 00000000000..770b1cfc47b --- /dev/null +++ b/src/renderer/src/components/terminal-workspace-surface-ids.test.tsx @@ -0,0 +1,184 @@ +// @vitest-environment happy-dom + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, renderHook } from '@testing-library/react' +import { useEffect } from 'react' +import { useAppStore } from '@/store' +import { makeRepo, makeWorktree } from './worktree-jump-palette-test-fixtures' +import { useTerminalWorkspaceFoundation } from './use-terminal-workspace-foundation' +import { applyTerminalColdActivation } from './terminal-cold-activation' +import { collectTerminalParkingPassCandidates } from './terminal-parking-pass-candidates' +import type { WorkspaceSurface } from './workspace-surface-projection' +import type { TerminalParkingFoundation } from './use-terminal-parking-foundation' + +const initialState = useAppStore.getInitialState() + +/** An id-only surface array that reports how often a caller re-derived its ids. */ +function countingSurfaces(ids: string[]): { surfaces: WorkspaceSurface[]; mapCalls: () => number } { + let mapCalls = 0 + const surfaces = ids.map((id) => ({ id, path: `/tmp/${id}` })) + Object.defineProperty(surfaces, 'map', { + configurable: true, + value(this: WorkspaceSurface[], ...args: Parameters) { + mapCalls += 1 + return Array.prototype.map.apply(this, args) + } + }) + return { surfaces, mapCalls: () => mapCalls } +} + +describe('workspace surface ids', () => { + beforeEach(() => { + useAppStore.setState(initialState, true) + }) + + afterEach(() => { + cleanup() + useAppStore.setState(initialState, true) + vi.restoreAllMocks() + }) + + it('keeps the id array and id set stable when a worktree write re-identifies the surfaces', () => { + const repo = makeRepo() + const first = makeWorktree('alpha', 'Alpha') + const second = makeWorktree('beta', 'Beta') + useAppStore.setState({ worktreesByRepo: { [repo.id]: [first, second] } }) + + const { result, rerender } = renderHook(() => useTerminalWorkspaceFoundation()) + const initialSurfaces = result.current.workspaceSurfaces + const initialIds = result.current.workspaceSurfaceIds + const initialIdSet = result.current.workspaceSurfaceIdSet + expect(initialIds).toEqual([first.id, second.id]) + + // A worktree write that changes no id: fresh row objects, fresh array. + useAppStore.setState({ + worktreesByRepo: { + [repo.id]: [ + { ...first, lastActivityAt: Date.now() }, + { ...second, lastActivityAt: Date.now() } + ] + } + }) + rerender() + + expect(result.current.workspaceSurfaces).not.toBe(initialSurfaces) + expect(result.current.workspaceSurfaceIds).toBe(initialIds) + expect(result.current.workspaceSurfaceIdSet).toBe(initialIdSet) + + // A real membership change still re-identifies both. + useAppStore.setState({ worktreesByRepo: { [repo.id]: [first] } }) + rerender() + expect(result.current.workspaceSurfaceIds).not.toBe(initialIds) + expect(result.current.workspaceSurfaceIdSet).not.toBe(initialIdSet) + expect(result.current.workspaceSurfaceIds).toEqual([first.id]) + }) + + it('does not rebuild a surface-id array or set on a cold-activation render', () => { + const ids = Array.from({ length: 423 }, (_, index) => `repo::/worktree-${index}`) + const { surfaces, mapCalls } = countingSurfaces(ids) + const controller = { + activationDeferredMountTabIdsByWorktreeRef: { current: new Map() }, + activeGroupIdByWorktree: {}, + activeTabId: null, + activeTabIdByWorktree: {}, + activeWorktreeDeferralHostId: null, + activityTerminalPortals: [], + backgroundMountTabIdsByWorktreeRef: { current: new Map() }, + groupsByWorktree: {}, + hydrationSucceeded: false, + lastActivationWorktreeIdRef: { current: null }, + layoutByWorktree: {}, + mountedWorktreeIdsRef: { current: new Set(['repo::/worktree-0', 'repo::/gone']) }, + pairedRuntimeParkingEnvironmentIds: new Set(), + pendingStartupByTabId: {}, + renderedActiveWorktreeId: null, + startupWorktreeRefreshCompleted: false, + tabsByWorktree: {}, + terminalParkingEnabled: true, + terminalTitleSnapshotAuthorityEnabled: true, + workspaceSessionReady: false, + workspaceSurfaces: surfaces, + workspaceSurfaceIds: ids, + workspaceSurfaceIdSet: new Set(ids) + } as unknown as TerminalParkingFoundation + + applyTerminalColdActivation(controller) + applyTerminalColdActivation(controller) + + // Pre-fix this was two 423-element id arrays (plus a 423-entry Set) per render. + expect(mapCalls()).toBe(0) + expect(controller.mountedWorktreeIdsRef.current.has('repo::/gone')).toBe(false) + expect(controller.mountedWorktreeIdsRef.current.has('repo::/worktree-0')).toBe(true) + }) + + it('does not rebuild a surface-id array or set on a parking pass', () => { + const ids = Array.from({ length: 423 }, (_, index) => `repo::/worktree-${index}`) + const { surfaces, mapCalls } = countingSurfaces(ids) + const hiddenSince = new Map([ + ['repo::/worktree-0', 1], + ['repo::/stale', 2] + ]) + const controller = { + activeView: 'terminal', + activityTerminalPortals: [], + measurableBackgroundWorktreeIdsRef: { current: new Set() }, + measuringTerminalWorktreeIdsRef: { current: new Set() }, + mountedWorktreeIdsRef: { current: new Set(['repo::/worktree-0']) }, + pairedRuntimeParkingEnvironmentIds: new Set(), + pendingStartupByTabId: {}, + renderedActiveWorktreeId: 'repo::/worktree-0', + tabsByWorktree: {}, + terminalParkingEnabled: true, + terminalSshParkingEnabled: true, + terminalWorktreeHiddenSinceRef: { current: hiddenSince }, + terminalWorktreeParkCooldownUntilRef: { current: new Map() }, + terminalWorktreeParkingTimersRef: { current: new Map() }, + workspaceSurfaces: surfaces, + workspaceSurfaceIds: ids, + workspaceSurfaceIdSet: new Set(ids) + } as unknown as TerminalParkingFoundation + + const pass = collectTerminalParkingPassCandidates(controller) + + // Pre-fix this allocated a 423-element id array and a 423-entry Set per fire. + expect(mapCalls()).toBe(0) + expect(pass.retentionCandidates.map((candidate) => candidate.worktreeId)).toEqual([ + 'repo::/worktree-0' + ]) + expect(hiddenSince.has('repo::/stale')).toBe(false) + }) + it('stops idle worktree writes from re-firing surface-keyed terminal effects', () => { + const repo = makeRepo() + const worktrees = Array.from({ length: 20 }, (_, index) => + makeWorktree(`wt-${index}`, `Workspace ${index}`) + ) + useAppStore.setState({ worktreesByRepo: { [repo.id]: worktrees } }) + + const fires = { surfaceKeyed: 0, idKeyed: 0 } + renderHook(() => { + const foundation = useTerminalWorkspaceFoundation() + useEffect(() => { + fires.surfaceKeyed += 1 + }, [foundation.workspaceSurfaces]) + useEffect(() => { + fires.idKeyed += 1 + }, [foundation.workspaceSurfaceIds]) + }) + expect(fires).toEqual({ surfaceKeyed: 1, idKeyed: 1 }) + + // 100 worktree writes that touch no workspace id — the idle shape (activity + // timestamps, status refreshes) that dominates a large profile. + for (let write = 0; write < 100; write += 1) { + act(() => { + useAppStore.setState({ + worktreesByRepo: { + [repo.id]: worktrees.map((worktree) => ({ ...worktree, lastActivityAt: write })) + } + }) + }) + } + + expect(fires.surfaceKeyed).toBe(101) + expect(fires.idKeyed).toBe(1) + }) +}) diff --git a/src/renderer/src/components/use-terminal-browser-retention.ts b/src/renderer/src/components/use-terminal-browser-retention.ts index cbc2dff078c..34d3b0e1a80 100644 --- a/src/renderer/src/components/use-terminal-browser-retention.ts +++ b/src/renderer/src/components/use-terminal-browser-retention.ts @@ -20,7 +20,8 @@ export function useTerminalBrowserRetention(controller: TerminalParkingFoundatio mountedWorktreeIdsRef, renderedActiveWorktreeId, setBrowserGuestRetentionRevision, - workspaceSurfaces + workspaceSurfaceIds, + workspaceSurfaceIdSet } = controller useEffect(() => { @@ -42,9 +43,8 @@ export function useTerminalBrowserRetention(controller: TerminalParkingFoundatio } const recency = browserGuestWorktreeRecencyRef.current touchBrowserGuestWorktreeRecency(recency, renderedActiveWorktreeId) - const surfaceIds = new Set(workspaceSurfaces.map((workspace) => workspace.id)) for (let index = recency.length - 1; index >= 0; index--) { - if (!surfaceIds.has(recency[index])) { + if (!workspaceSurfaceIdSet.has(recency[index])) { recency.splice(index, 1) } } @@ -55,7 +55,7 @@ export function useTerminalBrowserRetention(controller: TerminalParkingFoundatio const recencyIds = new Set(recency) const orderedWorktreeIds = [ ...recency, - ...workspaceSurfaces.map((workspace) => workspace.id).filter((id) => !recencyIds.has(id)) + ...workspaceSurfaceIds.filter((id) => !recencyIds.has(id)) ] const evictedWorktreeIds = selectBrowserGuestEvictionWorktreeIds({ orderedWorktreeIds, @@ -82,7 +82,7 @@ export function useTerminalBrowserRetention(controller: TerminalParkingFoundatio // oxlint-disable-next-line react-hooks/exhaustive-deps -- controller refs preserve their original stable identities. }, [ renderedActiveWorktreeId, - workspaceSurfaces, + workspaceSurfaceIds, browserGuestRetentionBudgetEnabled, browserGuestRetentionRevision ]) diff --git a/src/renderer/src/components/use-terminal-parking-pass.ts b/src/renderer/src/components/use-terminal-parking-pass.ts index 01733c3c09c..1ce5eab03ff 100644 --- a/src/renderer/src/components/use-terminal-parking-pass.ts +++ b/src/renderer/src/components/use-terminal-parking-pass.ts @@ -41,7 +41,7 @@ export function useTerminalParkingPass(controller: TerminalParkingFoundation): v terminalProviderSnapshotCapabilityRevision, terminalRetentionBudgetEnabled, terminalSshParkingEnabled, - workspaceSurfaces + workspaceSurfaceIds } = controller useEffect(() => { @@ -182,6 +182,6 @@ export function useTerminalParkingPass(controller: TerminalParkingFoundation): v terminalProviderSnapshotCapabilityRevision, terminalRetentionBudgetEnabled, terminalSshParkingEnabled, - workspaceSurfaces + workspaceSurfaceIds ]) } diff --git a/src/renderer/src/components/use-terminal-watcher-effects.ts b/src/renderer/src/components/use-terminal-watcher-effects.ts index 9b80e964747..9e82b821c31 100644 --- a/src/renderer/src/components/use-terminal-watcher-effects.ts +++ b/src/renderer/src/components/use-terminal-watcher-effects.ts @@ -5,8 +5,9 @@ import { canWatcherCoverParkedTerminalTab, disposeAllParkedTerminalWatchers, pruneParkedTerminalWatchers, - syncParkedTerminalTabWatchers, - terminalWatcherLiveWorkspaceIds + syncParkedTerminalTabWatchersForWorkspaces, + terminalWatcherLiveWorkspaceIds, + type ParkedTerminalTabWatcherSyncEntry } from './terminal-pane/terminal-parked-tab-watchers' import { useAppStore } from '@/store' import { gateWorktreeAgentActivation } from '@/lib/worktree-agent-activation-gate' @@ -40,36 +41,35 @@ export function useTerminalWatcherEffects(controller: TerminalColdActivationCont terminalStartupRestorationReady, terminalTitleSnapshotAuthorityEnabled, workspaceSessionReady, - workspaceSurfaces + workspaceSurfaceIds } = controller useEffect(() => { - pruneParkedTerminalWatchers( - terminalWatcherLiveWorkspaceIds(workspaceSurfaces.map((workspace) => workspace.id)) - ) - for (const workspace of workspaceSurfaces) { + pruneParkedTerminalWatchers(terminalWatcherLiveWorkspaceIds(workspaceSurfaceIds)) + const syncEntriesByWorktreeId = new Map() + for (const workspaceId of workspaceSurfaceIds) { if ( anyMountedWorktreeHasLayout && - mountedWorktreeIdsRef.current.has(workspace.id) && - getEffectiveLayoutForWorktree(workspace.id) + mountedWorktreeIdsRef.current.has(workspaceId) && + getEffectiveLayoutForWorktree(workspaceId) ) { continue } - const tabs = tabsByWorktree[workspace.id] ?? [] + const tabs = tabsByWorktree[workspaceId] ?? [] const parkedTabIds = new Set() let deferredTabIds: ReadonlySet | null = null - if (!anyMountedWorktreeHasLayout && mountedWorktreeIdsRef.current.has(workspace.id)) { - const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId + if (!anyMountedWorktreeHasLayout && mountedWorktreeIdsRef.current.has(workspaceId)) { + const isVisible = activeView === 'terminal' && workspaceId === renderedActiveWorktreeId const shouldMeasureHiddenWorktree = - !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) + !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspaceId) const parked = !isVisible && !shouldMeasureHiddenWorktree && - effectiveParkedTerminalWorktreeIds.has(workspace.id) + effectiveParkedTerminalWorktreeIds.has(workspaceId) if (parked) { for (const tab of tabs) { const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, { - worktreeId: workspace.id, + worktreeId: workspaceId, tabId: tab.id }) if (!activityTerminalPortal && !evictionExemptTerminalTabIds.has(tab.id)) { @@ -77,15 +77,14 @@ export function useTerminalWatcherEffects(controller: TerminalColdActivationCont } } } - deferredTabIds = - activationDeferredMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null + deferredTabIds = activationDeferredMountTabIdsByWorktreeRef.current.get(workspaceId) ?? null for (const tab of tabs) { if ( deferredTabIds?.has(tab.id) && !parkedTabIds.has(tab.id) && - canWatcherCoverParkedTerminalTab(workspace.id, tab) && + canWatcherCoverParkedTerminalTab(workspaceId, tab) && !findActivityTerminalPortal(activityTerminalPortals, { - worktreeId: workspace.id, + worktreeId: workspaceId, tabId: tab.id }) ) { @@ -93,13 +92,13 @@ export function useTerminalWatcherEffects(controller: TerminalColdActivationCont } } } - syncParkedTerminalTabWatchers({ - worktreeId: workspace.id, + syncEntriesByWorktreeId.set(workspaceId, { tabs, parkedTabIds, ...(deferredTabIds ? { restoreTitleOnStartTabIds: deferredTabIds } : {}) }) } + syncParkedTerminalTabWatchersForWorkspaces(syncEntriesByWorktreeId) // oxlint-disable-next-line react-hooks/exhaustive-deps -- controller refs preserve their original stable identities. }, [ activeTabId, @@ -118,7 +117,7 @@ export function useTerminalWatcherEffects(controller: TerminalColdActivationCont terminalParkingEnabled, terminalTitleSnapshotAuthorityEnabled, workspaceSessionReady, - workspaceSurfaces + workspaceSurfaceIds ]) useEffect(() => () => disposeAllParkedTerminalWatchers(), []) diff --git a/src/renderer/src/components/use-terminal-window-lifecycle.lazy-ref.test.tsx b/src/renderer/src/components/use-terminal-window-lifecycle.lazy-ref.test.tsx new file mode 100644 index 00000000000..ce99e46db27 --- /dev/null +++ b/src/renderer/src/components/use-terminal-window-lifecycle.lazy-ref.test.tsx @@ -0,0 +1,86 @@ +// @vitest-environment happy-dom + +/** + * `collectBrowserWebviewIds` walks every browser page and tab across every worktree. It used to sit + * in a `useRef(...)` argument, so `Terminal` paid for the whole walk on every render and threw the + * result away. Pin the invocation count to the mount count, not the render count. + */ +import { useState } from 'react' +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const collectBrowserWebviewIdsCalls = vi.hoisted(() => ({ count: 0 })) + +vi.mock('../store', () => { + const state = { + browserTabsByWorktree: {}, + browserPagesByWorkspace: {}, + openFiles: [] + } + const useAppStore = Object.assign(() => undefined, { + getState: () => state, + subscribe: () => () => {} + }) + return { useAppStore } +}) + +vi.mock('../store/slices/browser-webview-cleanup', () => ({ + collectBrowserWebviewIds: (...args: unknown[]) => { + collectBrowserWebviewIdsCalls.count += 1 + void args + return new Set() + }, + destroyRemovedBrowserWebview: vi.fn() +})) + +vi.mock('@/lib/updater-beforeunload', () => ({ + isIntentionalAppRestartInProgress: () => false +})) +vi.mock('@/lib/shutdown-checkpoint-guard', () => ({ + preventUnloadAndScheduleShutdownCheckpointReset: vi.fn() +})) +vi.mock('./window-close-request-coordinator', () => ({ + setWindowCloseRequestHandler: vi.fn() +})) + +const { useTerminalWindowLifecycle } = await import('./use-terminal-window-lifecycle') + +const controller = { + activeBrowserTabId: null, + activeTabType: 'terminal', + activeWorktreeBrowserTabIdsKey: '', + proceedToNativeWindowClose: () => {}, + queueEditorCloseRequests: () => {}, + renderedActiveWorktreeId: null, + setActiveBrowserTab: () => {}, + setActiveTabType: () => {}, + windowCloseAfterDirtyRef: { current: false } +} as unknown as Parameters[0] + +let bumpRender: (() => void) | null = null + +function Host(): null { + const [, setTick] = useState(0) + bumpRender = () => setTick((tick) => tick + 1) + useTerminalWindowLifecycle(controller) + return null +} + +afterEach(() => { + cleanup() + collectBrowserWebviewIdsCalls.count = 0 + bumpRender = null +}) + +describe('useTerminalWindowLifecycle browser-webview id seed', () => { + it('collects the id set once per mount, not once per render', () => { + render() + expect(collectBrowserWebviewIdsCalls.count).toBe(1) + + for (let i = 0; i < 20; i += 1) { + act(() => bumpRender?.()) + } + + expect(collectBrowserWebviewIdsCalls.count).toBe(1) + }) +}) diff --git a/src/renderer/src/components/use-terminal-window-lifecycle.ts b/src/renderer/src/components/use-terminal-window-lifecycle.ts index a252422de9f..23cdb930535 100644 --- a/src/renderer/src/components/use-terminal-window-lifecycle.ts +++ b/src/renderer/src/components/use-terminal-window-lifecycle.ts @@ -58,11 +58,12 @@ export function useTerminalWindowLifecycle(controller: TerminalActivationControl // oxlint-disable-next-line react-hooks/exhaustive-deps -- controller refs preserve their original stable identities. }, [proceedToNativeWindowClose, queueEditorCloseRequests]) - const prevBrowserWebviewIdsRef = useRef>( - collectBrowserWebviewIds( - useAppStore.getState().browserTabsByWorktree, - useAppStore.getState().browserPagesByWorkspace - ) + // Why lazy: a `useRef(expr)` argument re-runs on every render and is thrown away, and this + // walks every browser page and tab across all worktrees on a component that renders constantly. + const prevBrowserWebviewIdsRef = useRef>(undefined!) + prevBrowserWebviewIdsRef.current ??= collectBrowserWebviewIds( + useAppStore.getState().browserTabsByWorktree, + useAppStore.getState().browserPagesByWorkspace ) useEffect(() => { let prevBrowserTabs = useAppStore.getState().browserTabsByWorktree diff --git a/src/renderer/src/components/use-terminal-workspace-foundation.ts b/src/renderer/src/components/use-terminal-workspace-foundation.ts index 9b365e9d885..61726fa9643 100644 --- a/src/renderer/src/components/use-terminal-workspace-foundation.ts +++ b/src/renderer/src/components/use-terminal-workspace-foundation.ts @@ -6,6 +6,7 @@ import { useWorktreeMap } from '../store/selectors' import { getResolvedExecutionHostIdForWorktree } from '@/lib/resolved-worktree-execution-host' import type { WorktreeTabBucketProjection } from '@/lib/worktree-tab-bucket-projection' import { projectWorkspaceSurfaces } from './workspace-surface-projection' +import { useReusedArrayIdentity } from './sidebar/worktree-list/listing/use-reused-array-identity' import { selectPairedRuntimeParkingEnvironmentIds } from './terminal-pane/terminal-hidden-view-parking' import { createTerminalWorktreeTopologyProjection } from './terminal-pane/terminal-hidden-worktree-retention' import { isMainTerminalSideEffectAuthorityForPty } from './terminal-pane/terminal-side-effect-facts-handler' @@ -46,6 +47,17 @@ export function useTerminalWorkspaceFoundation() { }), [worktreesById, folderWorkspaces, renderedActiveWorktreeId, activeFolderSurfaceHostId] ) + // Why split the ids out: every mount/park/activation pass reads only `.id`, but + // the surface array is re-identified on any worktree write. Reusing the previous + // id-array identity keeps those effects and their per-fire Sets from re-firing + // when the workspace set itself did not change. + const workspaceSurfaceIds = useReusedArrayIdentity( + useMemo(() => workspaceSurfaces.map((workspace) => workspace.id), [workspaceSurfaces]) + ) + const workspaceSurfaceIdSet = useMemo>( + () => new Set(workspaceSurfaceIds), + [workspaceSurfaceIds] + ) const activeView = useAppStore((state) => state.activeView) // Why: terminal titles are leaf chrome. The root host only subscribes to // mount/parking semantics; a real transition publishes fresh tab objects, @@ -88,6 +100,8 @@ export function useTerminalWorkspaceFoundation() { terminalWorktreeParkingTimersRef, folderWorkspaces, workspaceSurfaces, + workspaceSurfaceIds, + workspaceSurfaceIdSet, activeWorktreeId, renderedActiveWorktreeId, activeWorktreeDeferralHostId, diff --git a/src/renderer/src/hooks/composer-state/async-composer-state.ts b/src/renderer/src/hooks/composer-state/async-composer-state.ts index 2980576e3f7..f84baa51469 100644 --- a/src/renderer/src/hooks/composer-state/async-composer-state.ts +++ b/src/renderer/src/hooks/composer-state/async-composer-state.ts @@ -128,14 +128,13 @@ export function useComposerAsyncState(input: ComposerAsyncStateInput) { const [linkDirectLoading, setLinkDirectLoading] = useState(false) - const lastAutoNameRef = useRef( - getInitialAutoManagedWorkspaceName({ - draftName: persistDraft ? newWorkspaceDraft?.name : null, - draftLinkedWorkItem: persistDraft ? draftLinkedWorkItemSeed : null, - initialName, - initialLinkedWorkItem: initialLinkedWorkItemSeed - }) - ) + const lastAutoNameRef = useRef(undefined!) + lastAutoNameRef.current ??= getInitialAutoManagedWorkspaceName({ + draftName: persistDraft ? newWorkspaceDraft?.name : null, + draftLinkedWorkItem: persistDraft ? draftLinkedWorkItemSeed : null, + initialName, + initialLinkedWorkItem: initialLinkedWorkItemSeed + }) const nameRef = useRef(name) @@ -153,12 +152,18 @@ export function useComposerAsyncState(input: ComposerAsyncStateInput) { }, [name, note]) // Why: PR checkout refs resolve async, so submit can still see the linked PR as a checkout source if Create fires before the resolver settles. + // Why useState and not `useRef(expr)`: the seed legitimately resolves to null, so a nullish + // guard would keep re-running it; useState's initializer runs once without writing in render. + const [initialSmartGitHubPrStartPointSelection] = + useState(() => + getInitialGitHubPrStartPointSelection({ + item: initialGitHubWorkItem, + linkedWorkItem: initialLinkedWorkItemSeed, + repoId: selectedRepo?.id ?? initialRepoId + }) + ) const smartGitHubPrStartPointSelectionRef = useRef( - getInitialGitHubPrStartPointSelection({ - item: initialGitHubWorkItem, - linkedWorkItem: initialLinkedWorkItemSeed, - repoId: selectedRepo?.id ?? initialRepoId - }) + initialSmartGitHubPrStartPointSelection ) useEffect(() => { diff --git a/src/renderer/src/hooks/composer-state/composer-drop-listener.ts b/src/renderer/src/hooks/composer-state/composer-drop-listener.ts index 145fe1a3c35..5cca0d4b073 100644 --- a/src/renderer/src/hooks/composer-state/composer-drop-listener.ts +++ b/src/renderer/src/hooks/composer-state/composer-drop-listener.ts @@ -10,7 +10,8 @@ export function useComposerDropListener( useEffect(() => { applyDropRef.current = applyDrop }, [applyDrop]) - const instanceIdRef = useRef(Symbol('composer')) + const instanceIdRef = useRef(undefined!) + instanceIdRef.current ??= Symbol('composer') useEffect(() => { const instanceId = instanceIdRef.current diff --git a/src/renderer/src/hooks/shortcut-label-cache.test.tsx b/src/renderer/src/hooks/shortcut-label-cache.test.tsx new file mode 100644 index 00000000000..9c7f32a632e --- /dev/null +++ b/src/renderer/src/hooks/shortcut-label-cache.test.tsx @@ -0,0 +1,174 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, render, screen } from '@testing-library/react' +import type * as KeybindingsModule from '../../../shared/keybindings' +import type { KeybindingOverrides } from '../../../shared/keybindings' + +const counters = vi.hoisted(() => ({ effective: 0, formatList: 0, formatBinding: 0 })) +const platformRef = vi.hoisted(() => ({ current: 'darwin' as NodeJS.Platform })) + +vi.mock('../lib/shortcut-platform', () => ({ + getShortcutPlatform: () => platformRef.current +})) + +vi.mock('../../../shared/keybindings', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + getEffectiveKeybindingsForAction: ( + ...args: Parameters + ) => { + counters.effective++ + return actual.getEffectiveKeybindingsForAction(...args) + }, + formatKeybindingList: (...args: Parameters) => { + counters.formatList++ + return actual.formatKeybindingList(...args) + }, + formatKeybinding: (...args: Parameters) => { + counters.formatBinding++ + return actual.formatKeybinding(...args) + } + } +}) + +const { + formatOptionalShortcutLabel, + formatPrimaryShortcutLabel, + formatShortcutKeyComboDetails, + formatShortcutLabel, + useShortcutLabel +} = await import('./useShortcutLabel') +const { useAppStore } = await import('@/store') + +function resetCounters(): void { + counters.effective = 0 + counters.formatList = 0 + counters.formatBinding = 0 +} + +// A fresh overrides object per test keeps each case on its own cache entry, exactly as a real edit does. +function overridesFor(binding: string): KeybindingOverrides { + return { 'tab.close': [binding] } +} + +describe('shortcut label memoization', () => { + beforeEach(() => { + platformRef.current = 'darwin' + resetCounters() + }) + + it('computes a label once no matter how many times it is asked for', () => { + const overrides = overridesFor('Mod+Shift+K') + const first = formatShortcutLabel('tab.close', overrides) + resetCounters() + for (let index = 0; index < 200; index++) { + expect(formatShortcutLabel('tab.close', overrides)).toBe(first) + } + expect(counters.effective).toBe(0) + expect(counters.formatList).toBe(0) + }) + + it('memoizes each label shape separately and keeps their values correct', () => { + const overrides = overridesFor('Mod+Shift+K') + expect(formatShortcutLabel('tab.close', overrides)).toBe( + formatShortcutLabel('tab.close', overrides) + ) + expect(formatPrimaryShortcutLabel('tab.close', overrides)).toBe( + formatPrimaryShortcutLabel('tab.close', overrides) + ) + expect(formatOptionalShortcutLabel('tab.close', overrides)).toBe( + formatOptionalShortcutLabel('tab.close', overrides) + ) + expect(formatShortcutKeyComboDetails('tab.close', overrides)).toBe( + formatShortcutKeyComboDetails('tab.close', overrides) + ) + expect(formatShortcutKeyComboDetails('tab.close', overrides)[0]?.keys).toEqual(['⌘', '⇧', 'K']) + expect(formatShortcutLabel('tab.close', overrides)).toBe('⌘⇧K') + }) + + it('returns null rather than a cached sentinel for a disabled action', () => { + const overrides: KeybindingOverrides = { 'tab.close': [] } + expect(formatOptionalShortcutLabel('tab.close', overrides)).toBe(null) + expect(formatOptionalShortcutLabel('tab.close', overrides)).toBe(null) + expect(formatShortcutLabel('tab.close', overrides)).toBe('Unassigned') + expect(formatPrimaryShortcutLabel('tab.close', overrides)).toBe('Unassigned') + }) + + it('recomputes as soon as a different overrides object arrives', () => { + expect(formatShortcutLabel('tab.close', overridesFor('Mod+Shift+K'))).toBe('⌘⇧K') + expect(formatShortcutLabel('tab.close', overridesFor('Mod+Shift+L'))).toBe('⌘⇧L') + expect(formatShortcutLabel('tab.close', overridesFor('Mod+Shift+K'))).toBe('⌘⇧K') + }) + + it('does not let one action id serve another', () => { + const overrides = overridesFor('Mod+Shift+K') + expect(formatShortcutLabel('tab.close', overrides)).toBe('⌘⇧K') + expect(formatShortcutLabel('tab.rename', overrides)).not.toBe('⌘⇧K') + }) + + it('keys the cache by platform so Mac and Windows glyphs never cross', () => { + const overrides = overridesFor('Mod+Shift+K') + expect(formatShortcutLabel('tab.close', overrides)).toBe('⌘⇧K') + platformRef.current = 'win32' + expect(formatShortcutLabel('tab.close', overrides)).toBe('Ctrl+Shift+K') + platformRef.current = 'darwin' + expect(formatShortcutLabel('tab.close', overrides)).toBe('⌘⇧K') + }) + + it('caches the undefined-overrides case without leaking into the override case', () => { + const defaultLabel = formatShortcutLabel('tab.close') + expect(formatShortcutLabel('tab.close')).toBe(defaultLabel) + expect(formatShortcutLabel('tab.close', overridesFor('Mod+Shift+K'))).toBe('⌘⇧K') + expect(formatShortcutLabel('tab.close')).toBe(defaultLabel) + }) +}) + +function CloseLabel(): React.JSX.Element { + return {useShortcutLabel('tab.close')} +} + +describe('useShortcutLabel', () => { + beforeEach(() => { + platformRef.current = 'darwin' + resetCounters() + }) + + afterEach(() => { + cleanup() + useAppStore.setState({ keybindings: {} }) + }) + + it('does not recompute the label on re-render', () => { + useAppStore.setState({ keybindings: overridesFor('Mod+Shift+K') }) + const { rerender } = render() + expect(screen.getByTestId('close-label').textContent).toBe('⌘⇧K') + resetCounters() + for (let index = 0; index < 25; index++) { + rerender() + } + expect(screen.getByTestId('close-label').textContent).toBe('⌘⇧K') + expect(counters.effective).toBe(0) + expect(counters.formatList).toBe(0) + }) + + it('shows an edited keybinding immediately, with no stale-cache window', () => { + useAppStore.setState({ keybindings: overridesFor('Mod+Shift+K') }) + render() + expect(screen.getByTestId('close-label').textContent).toBe('⌘⇧K') + + // Mirrors the store update a Settings edit performs: a brand new overrides object. + act(() => useAppStore.setState({ keybindings: overridesFor('Mod+Shift+L') })) + expect(screen.getByTestId('close-label').textContent).toBe('⌘⇧L') + + act(() => useAppStore.setState({ keybindings: { 'tab.close': ['Mod+Alt+Backspace'] } })) + expect(screen.getByTestId('close-label').textContent).toBe('⌘⌥⌫') + + act(() => useAppStore.setState({ keybindings: { 'tab.close': [] } })) + expect(screen.getByTestId('close-label').textContent).toBe('Unassigned') + + // Back to the first binding: a revert must not resurrect the entry cached for the old object. + act(() => useAppStore.setState({ keybindings: overridesFor('Mod+Shift+K') })) + expect(screen.getByTestId('close-label').textContent).toBe('⌘⇧K') + }) +}) diff --git a/src/renderer/src/hooks/use-audio-capture.ts b/src/renderer/src/hooks/use-audio-capture.ts index 702640ae1c9..31e851a58ee 100644 --- a/src/renderer/src/hooks/use-audio-capture.ts +++ b/src/renderer/src/hooks/use-audio-capture.ts @@ -55,7 +55,8 @@ export function useAudioCapture(publishMeter?: DictationMeterPublisher) { const capturedChunkCountRef = useRef(0) const sessionIdRef = useRef('desktop') const trackLostCleanupRef = useRef<(() => void) | null>(null) - const meterAnalyzerRef = useRef(createDictationMeterAnalyzerState()) + const meterAnalyzerRef = useRef>(undefined!) + meterAnalyzerRef.current ??= createDictationMeterAnalyzerState() const publishedMeterRef = useRef(DEFAULT_DICTATION_METER) const lastMeterPublishedAtRef = useRef(Number.NEGATIVE_INFINITY) diff --git a/src/renderer/src/hooks/useShortcutLabel.ts b/src/renderer/src/hooks/useShortcutLabel.ts index 1d96ad8745a..fe78739f687 100644 --- a/src/renderer/src/hooks/useShortcutLabel.ts +++ b/src/renderer/src/hooks/useShortcutLabel.ts @@ -16,14 +16,48 @@ export type ShortcutKeyComboDetails = { doubleTap: boolean } +// Why: these run in render bodies of components that re-render constantly, and every call is two full keybinding parses. +// The store hands out a new overrides object on every keybinding edit, so keying the cache on that object gives exact +// invalidation: an edit can never be served from a stale entry, and the old entry is dropped with the old object. +const cachesByOverrides = new WeakMap>() +const defaultOverridesCache = new Map() + +function labelCache(overrides: KeybindingOverrides | undefined): Map { + if (!overrides) { + return defaultOverridesCache + } + let cache = cachesByOverrides.get(overrides) + if (!cache) { + cache = new Map() + cachesByOverrides.set(overrides, cache) + } + return cache +} + +function memoizeShortcut( + kind: string, + actionId: KeybindingActionId, + platform: NodeJS.Platform, + overrides: KeybindingOverrides | undefined, + compute: () => T +): T { + const cache = labelCache(overrides) + const key = `${platform}\u0000${kind}\u0000${actionId}` + if (cache.has(key)) { + return cache.get(key) as T + } + const value = compute() + cache.set(key, value) + return value +} + export function formatShortcutLabel( actionId: KeybindingActionId, overrides?: KeybindingOverrides ): string { const platform = getShortcutPlatform() - return formatKeybindingList( - getEffectiveKeybindingsForAction(actionId, platform, overrides), - platform + return memoizeShortcut('label', actionId, platform, overrides, () => + formatKeybindingList(getEffectiveKeybindingsForAction(actionId, platform, overrides), platform) ) } @@ -32,8 +66,10 @@ export function formatPrimaryShortcutLabel( overrides?: KeybindingOverrides ): string { const platform = getShortcutPlatform() - const [binding] = getEffectiveKeybindingsForAction(actionId, platform, overrides) - return binding ? formatKeybindingList([binding], platform) : 'Unassigned' + return memoizeShortcut('primary', actionId, platform, overrides, () => { + const [binding] = getEffectiveKeybindingsForAction(actionId, platform, overrides) + return binding ? formatKeybindingList([binding], platform) : 'Unassigned' + }) } export function useShortcutLabel(actionId: KeybindingActionId): string { @@ -49,11 +85,13 @@ export function formatOptionalShortcutLabel( overrides?: KeybindingOverrides ): string | null { const platform = getShortcutPlatform() - const bindings = getEffectiveKeybindingsForAction(actionId, platform, overrides) - if (bindings.length === 0) { - return null - } - return formatKeybindingList(bindings, platform) + return memoizeShortcut('optional', actionId, platform, overrides, () => { + const bindings = getEffectiveKeybindingsForAction(actionId, platform, overrides) + if (bindings.length === 0) { + return null + } + return formatKeybindingList(bindings, platform) + }) } export function useOptionalShortcutLabel(actionId: KeybindingActionId): string | null { @@ -66,10 +104,13 @@ export function formatShortcutKeyComboDetails( overrides?: KeybindingOverrides ): ShortcutKeyComboDetails[] { const platform = getShortcutPlatform() - return getEffectiveKeybindingsForAction(actionId, platform, overrides).map((binding) => ({ - keys: formatKeybinding(binding, platform), - doubleTap: isDoubleTapBinding(binding) - })) + // The returned array is shared across callers now, so treat it as read-only (every current caller does). + return memoizeShortcut('combo', actionId, platform, overrides, () => + getEffectiveKeybindingsForAction(actionId, platform, overrides).map((binding) => ({ + keys: formatKeybinding(binding, platform), + doubleTap: isDoubleTapBinding(binding) + })) + ) } export function useShortcutKeyComboDetails( diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 5794e279820..c5335876275 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -16816,10 +16816,7 @@ "SortableTabContextMenu": { "switchToTerminalView": "Switch to terminal view", "switchToChatView": "Switch to chat view", - "closeTabsToLeft": "Close Tabs To The Left", - "copySessionId": "Copy Session ID", - "copySessionIdSuccess": "Session ID copied", - "copySessionIdError": "Failed to copy Session ID" + "closeTabsToLeft": "Close Tabs To The Left" }, "BrowserTab": { "closeOthers": "Close Others", @@ -17108,6 +17105,13 @@ "days": "{{value0}}d", "underOneMinute": "<1m" } + }, + "terminalPane": { + "TerminalContextMenu": { + "copySessionId": "Copy Session ID", + "copySessionIdSuccess": "Session ID copied", + "copySessionIdError": "Unable to copy session ID" + } } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4736cdbbbd4..79731f3b096 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -14652,10 +14652,7 @@ "SortableTabContextMenu": { "switchToTerminalView": "Cambiar a la vista de terminal", "switchToChatView": "Cambiar a vista de chat", - "closeTabsToLeft": "Cerrar pestañas a la izquierda", - "copySessionId": "Copiar ID de sesión", - "copySessionIdSuccess": "ID de sesión copiado", - "copySessionIdError": "No se pudo copiar el ID de sesión" + "closeTabsToLeft": "Cerrar pestañas a la izquierda" }, "BrowserTab": { "closeOthers": "Cerrar otras", @@ -14703,6 +14700,13 @@ "sent": "El contexto de la sesión se envió a {{agent}} en una sesión nueva.", "deliveryFailed": "La nueva sesión de {{agent}} se inició, pero no se pudo enviar su contexto.", "launchFailed": "No se pudo iniciar una sesión nueva de {{agent}}." + }, + "terminalPane": { + "TerminalContextMenu": { + "copySessionId": "Copiar ID de sesión", + "copySessionIdSuccess": "ID de sesión copiado", + "copySessionIdError": "No se pudo copiar el ID de sesión" + } } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 315e5775442..6afc506b12b 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -14652,10 +14652,7 @@ "SortableTabContextMenu": { "switchToTerminalView": "ターミナルビューに切り替える", "switchToChatView": "チャットビューに切り替える", - "closeTabsToLeft": "左側のタブを閉じる", - "copySessionId": "セッション ID をコピー", - "copySessionIdSuccess": "セッション ID をコピーしました", - "copySessionIdError": "セッション ID のコピーに失敗しました" + "closeTabsToLeft": "左側のタブを閉じる" }, "BrowserTab": { "closeOthers": "その他を閉じる", @@ -14703,6 +14700,13 @@ "sent": "セッションコンテキストを新規 {{agent}} セッションに送信しました。", "deliveryFailed": "新規 {{agent}} セッションは開始しましたが、コンテキストを送信できませんでした。", "launchFailed": "新規 {{agent}} セッションを開始できませんでした。" + }, + "terminalPane": { + "TerminalContextMenu": { + "copySessionId": "セッション ID をコピー", + "copySessionIdSuccess": "セッション ID をコピーしました", + "copySessionIdError": "セッション ID のコピーに失敗しました" + } } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4a196c62304..c2836df4c40 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -14708,10 +14708,7 @@ "SortableTabContextMenu": { "switchToTerminalView": "terminal 보기로 전환", "switchToChatView": "채팅 보기로 전환", - "closeTabsToLeft": "왼쪽으로 탭 닫기", - "copySessionId": "세션 ID 복사", - "copySessionIdSuccess": "세션 ID를 복사했습니다", - "copySessionIdError": "세션 ID를 복사하지 못했습니다" + "closeTabsToLeft": "왼쪽으로 탭 닫기" }, "BrowserTab": { "closeOthers": "다른 탭 닫기", @@ -14820,6 +14817,13 @@ "sent": "세션 컨텍스트를 새 {{agent}} 세션으로 보냈습니다.", "deliveryFailed": "새 {{agent}} 세션은 시작되었지만 컨텍스트를 보내지 못했습니다.", "launchFailed": "새 {{agent}} 세션을 시작할 수 없습니다." + }, + "terminalPane": { + "TerminalContextMenu": { + "copySessionId": "세션 ID 복사", + "copySessionIdSuccess": "세션 ID를 복사했습니다", + "copySessionIdError": "세션 ID를 복사하지 못했습니다" + } } }, "dashboardPopout": { diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index edbd96e2623..77f56e805ae 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -14708,10 +14708,7 @@ "SortableTabContextMenu": { "switchToTerminalView": "切换到终端视图", "switchToChatView": "切换到聊天视图", - "closeTabsToLeft": "关闭左侧的选项卡", - "copySessionId": "复制会话 ID", - "copySessionIdSuccess": "已复制会话 ID", - "copySessionIdError": "复制会话 ID 失败" + "closeTabsToLeft": "关闭左侧的选项卡" }, "BrowserTab": { "closeOthers": "关闭其他", @@ -14820,6 +14817,13 @@ "sent": "已将会话上下文发送到新的 {{agent}} 会话。", "deliveryFailed": "新的 {{agent}} 会话已启动,但无法发送上下文。", "launchFailed": "无法启动新的 {{agent}} 会话。" + }, + "terminalPane": { + "TerminalContextMenu": { + "copySessionId": "复制会话 ID", + "copySessionIdSuccess": "已复制会话 ID", + "copySessionIdError": "复制会话 ID 失败" + } } }, "dashboardPopout": { diff --git a/src/renderer/src/lazy-use-ref-ratchet.test.ts b/src/renderer/src/lazy-use-ref-ratchet.test.ts new file mode 100644 index 00000000000..c0b23821bcb --- /dev/null +++ b/src/renderer/src/lazy-use-ref-ratchet.test.ts @@ -0,0 +1,130 @@ +import { readFileSync, readdirSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' + +/** + * `useRef(create())` evaluates its argument on EVERY render and discards every result after the + * first, so any real work there is pure waste. The lazy form keeps the same value with none of the + * churn: + * + * const ref = useRef(undefined!) + * ref.current ??= create() + * + * Use a `useState` lazy initializer instead when the seed can legitimately be null/undefined, or + * an explicit `seededRef` guard when both are true. + */ +const RENDERER_ROOT = import.meta.dirname + +function collectSourceFiles(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name) + if (entry.isDirectory()) { + if (entry.name === 'node_modules' || entry.name === 'dist') { + continue + } + collectSourceFiles(full, out) + continue + } + if (!/\.tsx?$/.test(entry.name) || /\.(test|spec)\.tsx?$/.test(entry.name)) { + continue + } + out.push(full) + } + return out +} + +/** Reads the balanced argument text of the `useRef(...)` starting at `from`. */ +function readUseRefArgument(source: string, from: number): { arg: string; end: number } | null { + let i = from + while (source[i] === ' ') { + i++ + } + if (source[i] === '<') { + let depth = 0 + while (i < source.length) { + if (source[i] === '<') { + depth++ + } else if (source[i] === '>') { + depth-- + if (depth === 0) { + i++ + break + } + } + i++ + } + } + while (source[i] === ' ') { + i++ + } + if (source[i] !== '(') { + return null + } + const argStart = i + 1 + let depth = 0 + while (i < source.length) { + if (source[i] === '(') { + depth++ + } else if (source[i] === ')') { + depth-- + if (depth === 0) { + return { arg: source.slice(argStart, i).trim(), end: i + 1 } + } + } + i++ + } + return null +} + +// Allowed because none of these is work that a render repeats for nothing: +// - an empty collection literal, which the repo keeps in the direct form +// - `undefined!`, the lazy-seed marker +// - a function literal, which is the ref's payload rather than its initialization +// - a primitive coercion of an already-computed value +const ALLOWED_ARGUMENT = new RegExp( + [ + '^new (Map|Set|WeakMap|WeakSet)(<[\\s\\S]*>)?\\(\\)$', + '^undefined!$', + '^(async )?\\([\\s\\S]*?\\)\\s*(:[^=]*)?=>[\\s\\S]*$', + '^(Boolean|Number|String)\\([\\s\\S]*\\)$', + '^[A-Za-z_$][\\w$.?\\[\\]\'"]*$' + ].join('|') +) + +function findNonLazyUseRefs(file: string): string[] { + const source = readFileSync(file, 'utf8') + const findings: string[] = [] + let index = 0 + while ((index = source.indexOf('useRef', index)) !== -1) { + const start = index + index += 'useRef'.length + if (/[\w$.]/.test(source[start - 1] ?? '')) { + continue + } + const parsed = readUseRefArgument(source, index) + if (!parsed) { + continue + } + index = parsed.end + const arg = parsed.arg + if (arg === '' || ALLOWED_ARGUMENT.test(arg)) { + continue + } + // Only a call or constructor invocation actually burns work per render. + if (!/\(/.test(arg) && !/\bnew\b/.test(arg)) { + continue + } + const line = source.slice(0, start).split('\n').length + findings.push( + `${path.relative(RENDERER_ROOT, file)}:${line} useRef(${arg.replace(/\s+/g, ' ').slice(0, 90)})` + ) + } + return findings +} + +describe('renderer useRef initializers', () => { + it('never does work in the useRef argument', () => { + const findings = collectSourceFiles(RENDERER_ROOT).flatMap(findNonLazyUseRefs) + expect(findings).toEqual([]) + }) +}) diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts index 98ac6c1059e..7988e4656a7 100644 --- a/src/renderer/src/lib/local-preflight-context.ts +++ b/src/renderer/src/lib/local-preflight-context.ts @@ -9,6 +9,7 @@ import { import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import type { Repo } from '../../../shared/repo-types' import type { Worktree } from '../../../shared/worktree/types' +import { getIndexedRepoMap, getIndexedWorktreeById } from '@/store/worktree-repo-index' import { getProviderRuntimeContextKey } from './provider-runtime-context' import { getRendererAppPlatform } from './renderer-app-platform' import { @@ -36,6 +37,11 @@ type LocalProjectRuntimeState = Pick< 'activeRepoId' | 'activeWorktreeId' | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' > +// Why: the shared indexes are WeakMap-keyed on slice identity, so a fresh `{}` +// or `[]` fallback would miss the cache on every read. +const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {} +const EMPTY_REPOS: AppState['repos'] = [] + type LocalProjectRuntimeWslContext = { wslAvailable?: boolean availableWslDistros?: readonly string[] | null @@ -120,7 +126,7 @@ export function getLocalRepoProjectExecutionRuntimeContext( return undefined } - const repo = (state.repos ?? []).find((entry) => entry.id === repoId) + const repo = getIndexedRepoMap(state.repos ?? EMPTY_REPOS).get(repoId) if (!isLocalRuntimeRepo(repo)) { return undefined } @@ -270,7 +276,7 @@ function getLocalRuntimeRepoForWorktree( worktree?: Pick | null ): Pick | undefined { const repoId = worktree?.repoId ?? state.activeRepoId - return repoId ? (state.repos ?? []).find((repo) => repo.id === repoId) : undefined + return repoId ? getIndexedRepoMap(state.repos ?? EMPTY_REPOS).get(repoId) : undefined } function isLocalRuntimeRepo( @@ -302,11 +308,13 @@ function getLocalWorktree( worktreeId?: string | null ): Pick | null { const targetWorktreeId = worktreeId ?? state.activeWorktreeId - return targetWorktreeId - ? (Object.values(state.worktreesByRepo ?? {}) - .flat() - .find((worktree) => worktree.id === targetWorktreeId) ?? null) - : null + if (!targetWorktreeId) { + return null + } + return ( + getIndexedWorktreeById(state.worktreesByRepo ?? EMPTY_WORKTREES_BY_REPO, targetWorktreeId) ?? + null + ) } function getLocalPreflightProjectId( diff --git a/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.test.ts b/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.test.ts new file mode 100644 index 00000000000..66dc3fc872b --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.test.ts @@ -0,0 +1,300 @@ +// @vitest-environment happy-dom + +import { Terminal } from '@xterm/xterm' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPaneInternal } from './pane-manager-types' +import { resumePaneRendering, suspendPaneRendering } from './pane-rendering-control' +import { resetHiddenWebglRetentionForTest } from './terminal-webgl-hidden-retention' +import { isTerminalCursorBlinkSuspended } from './pane-cursor-blink-suspension' + +/** + * Invariant (`terminal-render.hidden-pane-idle-cost`): a hidden pane must not drive + * the cursor-blink redraw loop, and revealing it must put back exactly the blink + * state it had — never a frozen, missing, or unexpectedly blinking cursor. + * + * Oracle: the cursor xterm actually renders. The DOM renderer stamps + * `xterm-cursor` / `xterm-cursor-blink` on the cursor cell (DomRendererRowFactory), + * so these assertions read rendered output, not the option value — an assertion on + * `options.cursorBlink` alone would pass with the bug present. + * + * The hidden-side cost is proved separately by the redraw-count test at the bottom: + * the DOM renderer already gates its blink class on per-terminal focus, whereas the + * WebGL renderer's blink timer arms on `document.hasFocus()`, which is what leaks. + */ + +const COLS = 80 +const ROWS = 24 + +type TestPane = ManagedPaneInternal & { host: HTMLElement } + +function nextFrame(): Promise { + return new Promise((resolve) => requestAnimationFrame(() => resolve())) +} + +async function flushRender(): Promise { + await nextFrame() + await nextFrame() + await new Promise((resolve) => setTimeout(resolve, 0)) +} + +function write(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +function createTestPane(options: { cursorBlink?: boolean; webglAddon?: boolean } = {}): TestPane { + const host = document.createElement('div') + document.body.appendChild(host) + const terminal = new Terminal({ + cols: COLS, + rows: ROWS, + cursorBlink: options.cursorBlink ?? true, + allowProposedApi: true + }) + terminal.open(host) + terminal.focus() + const pane = { + id: 1, + terminal, + host, + container: host, + xtermContainer: host, + // Off so resume's reattachWebglIfNeeded leaves the DOM renderer in place; + // the rendered-cursor oracle needs a renderer that paints in happy-dom. + gpuRenderingEnabled: false, + terminalGpuAcceleration: 'off', + webglAddon: options.webglAddon ? ({ dispose: vi.fn() } as never) : null, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + webglAttachFailedSinceRecovery: false, + hasComplexScriptOutput: false, + pendingWebglRefreshRafId: null, + pendingObservedFitRafId: null + } as unknown as TestPane + return pane +} + +/** The cursor cell as the renderer painted it, or null when no cursor is rendered. */ +function renderedCursor(pane: TestPane): HTMLElement | null { + return pane.host.querySelector('.xterm-cursor') +} + +function rendersBlinkingCursor(pane: TestPane): boolean { + return renderedCursor(pane)?.classList.contains('xterm-cursor-blink') === true +} + +function renderedText(pane: TestPane): string { + return pane.host.querySelector('.xterm-rows')?.textContent ?? '' +} + +/** Reveal = the manager's resume pass, then the terminal regains real DOM focus. */ +async function reveal(panes: TestPane[], owner?: object): Promise { + resumePaneRendering(panes, owner) + for (const pane of panes) { + pane.terminal.focus() + } +} + +describe('hidden-pane cursor blink suspension', () => { + beforeEach(() => { + resetHiddenWebglRetentionForTest() + // happy-dom has no canvas text metrics; xterm measures glyphs on open(). + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + measureText: () => ({ width: 10 }) + } as unknown as CanvasRenderingContext2D) + }) + + afterEach(() => { + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + it('renders a blinking cursor again one frame after reveal', async () => { + const pane = createTestPane() + await write(pane.terminal, 'ready$ ') + await flushRender() + expect(rendersBlinkingCursor(pane)).toBe(true) + + suspendPaneRendering([pane]) + expect(isTerminalCursorBlinkSuspended(pane.terminal)).toBe(true) + + await reveal([pane]) + await nextFrame() + expect(renderedCursor(pane), 'reveal must not leave the pane without a cursor').not.toBeNull() + expect(rendersBlinkingCursor(pane)).toBe(true) + expect(isTerminalCursorBlinkSuspended(pane.terminal)).toBe(false) + }) + + it('keeps a user who disabled cursor blink un-blinking across hide and reveal', async () => { + const pane = createTestPane({ cursorBlink: false }) + await write(pane.terminal, 'ready$ ') + await flushRender() + expect(rendersBlinkingCursor(pane)).toBe(false) + + suspendPaneRendering([pane]) + await reveal([pane]) + await flushRender() + + expect(renderedCursor(pane), 'a steady cursor must still be rendered').not.toBeNull() + expect(rendersBlinkingCursor(pane), 'blink must stay off for this user').toBe(false) + }) + + it('does not drop output written while hidden, and echoes typing after reveal', async () => { + const pane = createTestPane() + await write(pane.terminal, 'before-hide\r\n') + await flushRender() + + suspendPaneRendering([pane]) + await write(pane.terminal, 'while-hidden\r\n') + + await reveal([pane]) + await flushRender() + expect(renderedText(pane)).toContain('before-hide') + expect(renderedText(pane)).toContain('while-hidden') + + // Real key input through xterm's textarea must still route to the PTY. + const typed: string[] = [] + pane.terminal.onData((data) => typed.push(data)) + const keydown = new KeyboardEvent('keydown', { + key: 'x', + code: 'KeyX', + bubbles: true, + cancelable: true + }) + Object.defineProperty(keydown, 'keyCode', { value: 88 }) + pane.terminal.textarea?.dispatchEvent(keydown) + expect(typed, 'keystrokes must still reach the PTY after a hide/reveal').toEqual(['x']) + + await write(pane.terminal, 'x') + await flushRender() + expect(renderedText(pane)).toContain('x') + expect(rendersBlinkingCursor(pane)).toBe(true) + }) + + it('restores blink on the retained-hidden-WebGL path, which skips dispose', async () => { + const owner = {} + const pane = createTestPane({ webglAddon: true }) + const addon = pane.webglAddon + await write(pane.terminal, 'ready$ ') + await flushRender() + + suspendPaneRendering([pane], { owner, livePanes: () => [pane] }) + // The retention branch returns before disposeWebgl — this is the path where + // nothing else could have stopped the blink timer. + expect(addon?.dispose).not.toHaveBeenCalled() + expect(isTerminalCursorBlinkSuspended(pane.terminal)).toBe(true) + + await reveal([pane], owner) + await nextFrame() + expect(rendersBlinkingCursor(pane)).toBe(true) + }) + + it('restores every pane of a split, including one revealed a second time', async () => { + const owner = {} + const left = createTestPane() + const right = createTestPane() + const panes = [left, right] + await write(left.terminal, 'left$ ') + await write(right.terminal, 'right$ ') + await flushRender() + + for (let cycle = 0; cycle < 2; cycle++) { + suspendPaneRendering(panes, { owner, livePanes: () => panes }) + resumePaneRendering(panes, owner) + // One at a time: the DOM renderer only paints a blinking cursor in the pane + // that currently holds real focus, so focus each split half in turn. + for (const [name, pane] of [ + ['left', left], + ['right', right] + ] as const) { + pane.terminal.focus() + await nextFrame() + expect(renderedCursor(pane), `${name} cursor, cycle ${cycle}`).not.toBeNull() + expect(rendersBlinkingCursor(pane), `${name} blink, cycle ${cycle}`).toBe(true) + } + } + expect(renderedText(left)).toContain('left$') + expect(renderedText(right)).toContain('right$') + }) + + it('does not re-arm a hidden pane when the blink setting changes mid-hide', async () => { + const pane = createTestPane({ cursorBlink: false }) + suspendPaneRendering([pane]) + + // What applyTerminalAppearance does when the user flips the setting. + const { setTerminalCursorBlinkOption } = await import('./pane-cursor-blink-suspension') + setTerminalCursorBlinkOption(pane.terminal, true) + expect( + pane.terminal.options.cursorBlink, + 'a hidden pane must not start blinking behind the surface' + ).toBe(false) + + await reveal([pane]) + await nextFrame() + expect(rendersBlinkingCursor(pane), 'the new setting applies on reveal').toBe(true) + }) + + it('resume is a no-op for a pane that was never suspended', async () => { + const pane = createTestPane() + await flushRender() + await reveal([pane]) + await nextFrame() + expect(rendersBlinkingCursor(pane)).toBe(true) + }) +}) + +/** + * Deterministic cost gate for the hidden side. + * + * Model, taken from `@xterm/addon-webgl@0.20` sources: `CursorBlinkStateManager` + * runs a 600 ms interval whenever `ICoreBrowserService.isFocused` is true — a + * DOCUMENT-level fact — and every toggle calls `WebglRenderer._requestRedrawCursor()`, + * which redraws one full row (`_updateModel` loops `x < cols` per row). + * `WebglRenderer._updateCursorBlink()` decides whether the manager exists from + * `decPrivateModes.cursorBlink ?? terminal.options.cursorBlink`, which is read from + * the real Terminal below. + */ +const BLINK_INTERVAL_MS = 600 + +function blinkCellsRedrawnPerWindow(terminal: Terminal, windowMs: number): number { + const decPrivateBlink = ( + terminal as unknown as { + _core: { coreService: { decPrivateModes: { cursorBlink?: boolean } } } + } + )._core.coreService.decPrivateModes.cursorBlink + const blinking = decPrivateBlink ?? terminal.options.cursorBlink === true + if (!blinking) { + return 0 + } + return Math.floor(windowMs / BLINK_INTERVAL_MS) * terminal.cols +} + +describe('hidden-pane cursor blink redraw cost', () => { + beforeEach(() => { + resetHiddenWebglRetentionForTest() + vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({ + measureText: () => ({ width: 10 }) + } as unknown as CanvasRenderingContext2D) + }) + + afterEach(() => { + vi.restoreAllMocks() + document.body.replaceChildren() + }) + + it('drops retained hidden panes to zero blink redraws and restores them on reveal', async () => { + const owner = {} + // The retention cap: MAX_RETAINED_HIDDEN_WEBGL_CONTEXTS. + const panes = Array.from({ length: 6 }, () => createTestPane({ webglAddon: true })) + const windowMs = 30_000 + const cells = () => + panes.reduce((sum, pane) => sum + blinkCellsRedrawnPerWindow(pane.terminal, windowMs), 0) + + expect(cells()).toBe(6 * 50 * COLS) + + suspendPaneRendering(panes, { owner, livePanes: () => panes }) + expect(cells(), 'hidden panes must cost nothing while the window is visible').toBe(0) + + await reveal(panes, owner) + expect(cells()).toBe(6 * 50 * COLS) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts b/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts new file mode 100644 index 00000000000..13f90af696c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-cursor-blink-suspension.ts @@ -0,0 +1,57 @@ +import type { Terminal } from '@xterm/xterm' + +/** + * Park `cursorBlink` while a pane is hidden. + * + * Why: a retained hidden pane (`terminal-webgl-hidden-retention.ts`) keeps a live + * WebglRenderer, and the only thing that stops its 600 ms blink timer is + * `WebglRenderer.handleBlur()` — reached only from a real DOM blur event. Today + * that arrives incidentally, because `display:none`/`visibility:hidden` move focus; + * under `opacity:0` without `inert` (TerminalOverlaySlot's startup probe) it never + * fires, `Terminal.blur()` is a no-op on a textarea that is not the active element, + * and the pane blinks — redrawing its whole cursor row through + * `WebglRenderer._updateModel` — until the 5-minute idle timeout. + * + * `cursorBlink` is the public option that tears the timer down deterministically + * (`RenderService.handleOptionsChanged` -> `WebglRenderer._updateCursorBlink`), so + * "a hidden pane does not blink" stops depending on which CSS hid it. + * + * Resume restores the parked value rather than the settings value, so a pane that + * was not blinking before the hide never comes back blinking. + */ +const parkedCursorBlink = new WeakMap() + +export function suspendTerminalCursorBlink(terminal: Terminal): void { + if (parkedCursorBlink.has(terminal)) { + return + } + parkedCursorBlink.set(terminal, terminal.options.cursorBlink === true) + terminal.options.cursorBlink = false +} + +/** Restores the pre-suspend blink state. No-op on a terminal that was never suspended. */ +export function resumeTerminalCursorBlink(terminal: Terminal): void { + if (!parkedCursorBlink.has(terminal)) { + return + } + const restored = parkedCursorBlink.get(terminal) === true + parkedCursorBlink.delete(terminal) + terminal.options.cursorBlink = restored +} + +/** + * Settings-driven blink writes land on the parked value while a pane is suspended; + * writing the option directly would re-arm the blink timer behind a hidden surface + * until the next reveal. + */ +export function setTerminalCursorBlinkOption(terminal: Terminal, enabled: boolean): void { + if (parkedCursorBlink.has(terminal)) { + parkedCursorBlink.set(terminal, enabled) + return + } + terminal.options.cursorBlink = enabled +} + +export function isTerminalCursorBlinkSuspended(terminal: Terminal): boolean { + return parkedCursorBlink.has(terminal) +} diff --git a/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts b/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts index 11efdff06c0..483f49a1f55 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-pane-creation.ts @@ -2,6 +2,7 @@ import type { ManagedPane, ManagedPaneInternal, PaneManagerOptions } from './pan import type { PaneManagerHost } from './pane-manager-host' import { applyPaneOpacity } from './pane-divider' import { createPaneDOM, openTerminal } from './pane-lifecycle' +import { suspendTerminalCursorBlink } from './pane-cursor-blink-suspension' import { shouldFollowMouseFocus } from './focus-follows-mouse' import { toPublicPane } from './pane-public-view' @@ -53,6 +54,10 @@ export function createManagedPaneInternal( } ) pane.webglAttachmentDeferred = host.isRenderingSuspended() + if (host.isRenderingSuspended()) { + // A pane that mounts behind a hidden surface never sees suspendPaneRendering(). + suspendTerminalCursorBlink(pane.terminal) + } host.panes.set(id, pane) host.identities.register(id, leafId) return pane diff --git a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts index 6709a7a1f98..219f17f4be6 100644 --- a/src/renderer/src/lib/pane-manager/pane-rendering-control.ts +++ b/src/renderer/src/lib/pane-manager/pane-rendering-control.ts @@ -1,4 +1,8 @@ import type { ManagedPaneInternal } from './pane-manager-types' +import { + resumeTerminalCursorBlink, + suspendTerminalCursorBlink +} from './pane-cursor-blink-suspension' import { safeFit } from './pane-tree-ops' import { attachWebgl, @@ -66,6 +70,12 @@ export function suspendPaneRendering( for (const pane of suspended) { pane.webglAttachmentDeferred = true pane.terminal.blur() + // Why here, above the retention return: the retention branch keeps a live + // WebglRenderer, whose blink timer only stops on a real DOM blur event. blur() + // above is a no-op unless that pane's textarea held focus, so under a hide mode + // that keeps focus the pane would blink — redrawing its cursor row — until the + // 5-minute idle timeout. Parking the option makes it unconditional. + suspendTerminalCursorBlink(pane.terminal) } // Keep recent hidden worktrees on live WebGL so switch-back never presents // DOM-fallback frames; evicted/over-cap owners fall back to dispose. @@ -86,6 +96,9 @@ export function resumePaneRendering( } for (const pane of panes) { clearTerminalWebglAttachBackoff(pane) + // Before the attach below so a freshly constructed WebglRenderer already samples + // the restored option and blinks on its first frame. + resumeTerminalCursorBlink(pane.terminal) const rebuildDeferred = pane.webglRebuildDeferred === true pane.webglAttachmentDeferred = false // Reveal can retry before the next resume, so both paths share the bounded loss policy. diff --git a/src/renderer/src/lib/pane-manager/pane-split-close.ts b/src/renderer/src/lib/pane-manager/pane-split-close.ts index 80e35a18171..df725156655 100644 --- a/src/renderer/src/lib/pane-manager/pane-split-close.ts +++ b/src/renderer/src/lib/pane-manager/pane-split-close.ts @@ -21,6 +21,7 @@ import { disposeWebgl } from './pane-webgl-renderer' import { clearPendingSplitScrollRestore, scheduleSplitScrollRestore } from './pane-split-scroll' import { reattachWebglIfNeeded } from './pane-webgl-reattach' import { toPublicPane } from './pane-public-view' +import { releaseTerminalScrollIntentKey } from './terminal-scroll-intent-key-store' type MovedPaneSplitState = { pane: ManagedPaneInternal @@ -179,6 +180,12 @@ function teardownManagedPane( const closedLeafId = pane.leafId args.releasePaneIdentity(args.paneId) removePaneContainer(args, pane) + if (reason === 'close') { + // Leaf ids are minted UUIDs and never reused, so a closed leaf's scroll + // intent is unreachable. Detach/retire hand the leaf to a new host, which + // must still be able to restore it. + releaseTerminalScrollIntentKey(closedLeafId) + } const nextActivePaneId = activateReplacementPane(args) applyPaneOpacity(args.panes.values(), nextActivePaneId, args.styleOptions) for (const p of args.panes.values()) { diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts index 508fb7e1240..89198580cf3 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts @@ -1,9 +1,16 @@ import { forceRepaintThroughRenderPause } from './terminal-render-pause-release' +import { + disposeParsedDirtyRows, + readParsedDirtyRowSpan, + resetParsedDirtyRows, + type ParsedDirtyRowSpan +} from './terminal-parsed-dirty-rows' import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard' export type ForegroundTerminalOutputTarget = { buffer?: { active?: { + type?: string cursorY?: number baseY?: number viewportY?: number @@ -32,6 +39,8 @@ const pendingViewportSettleRefreshByTerminal = new WeakMap< >() type ViewportSnapshot = { + type: string | null + cursorY: number | null baseY: number | null viewportY: number | null } @@ -39,7 +48,8 @@ type ViewportSnapshot = { function refreshVisibleRows( terminal: ForegroundTerminalOutputTarget, synchronously: boolean, - shouldReleaseRenderPause?: () => boolean + shouldReleaseRenderPause?: () => boolean, + span?: ParsedDirtyRowSpan | null ): void { if (typeof terminal.rows !== 'number' || terminal.rows < 1) { return @@ -51,10 +61,14 @@ function refreshVisibleRows( if (shouldReleaseRenderPause?.() === true && forceRepaintThroughRenderPause(terminal)) { return } - const start = 0 - const end = Math.max(0, terminal.rows - 1) + const lastRow = Math.max(0, terminal.rows - 1) + // Why not always the whole grid: xterm's render debouncer unions ranges, so a + // 0..rows-1 repair request turns every frame into a full-viewport cell walk. + // `span` is the parse's own dirty rows; `null` keeps the whole-grid repaint. + const start = span ? Math.min(Math.max(span.start, 0), lastRow) : 0 + const end = span ? Math.min(Math.max(span.end, start), lastRow) : lastRow // Why: DOM-rendered Windows ConPTY rewrites need an immediate repair, while - // WebGL can merge this full-grid request into xterm's already-queued frame. + // WebGL can merge this request into xterm's already-queued frame. if (synchronously && typeof terminal._core?.refresh === 'function') { terminal._core.refresh(start, end, true) return @@ -70,20 +84,19 @@ function refreshVisibleRows( } function captureViewportSnapshot(terminal: ForegroundTerminalOutputTarget): ViewportSnapshot { + const active = terminal.buffer?.active return { - baseY: typeof terminal.buffer?.active?.baseY === 'number' ? terminal.buffer.active.baseY : null, - viewportY: - typeof terminal.buffer?.active?.viewportY === 'number' - ? terminal.buffer.active.viewportY - : null + type: typeof active?.type === 'string' ? active.type : null, + cursorY: typeof active?.cursorY === 'number' ? active.cursorY : null, + baseY: typeof active?.baseY === 'number' ? active.baseY : null, + viewportY: typeof active?.viewportY === 'number' ? active.viewportY : null } } function viewportChangedDuringWrite( - terminal: ForegroundTerminalOutputTarget, - beforeWrite: ViewportSnapshot + beforeWrite: ViewportSnapshot, + afterWrite: ViewportSnapshot ): boolean { - const afterWrite = captureViewportSnapshot(terminal) return ( afterWrite.baseY !== null && afterWrite.viewportY !== null && @@ -91,6 +104,39 @@ function viewportChangedDuringWrite( ) } +/** + * The rows this write's repair must cover: the parse's own dirty span widened by + * the cursor rows on both sides of the write. + * + * Why the cursor rows: xterm's WebGL model drops its cursor whenever an update + * pass excludes the cursor row, so a repair that skips it would blank the caret. + * Returns `null` — repaint everything — whenever the span is unknown, the + * viewport scrolled (dirty rows were recorded against the pre-scroll origin), or + * the write flipped between the normal and alternate buffer. + */ +function repairRowSpan( + terminal: ForegroundTerminalOutputTarget, + beforeWrite: ViewportSnapshot, + afterWrite: ViewportSnapshot +): ParsedDirtyRowSpan | null { + if (beforeWrite.type !== afterWrite.type || viewportChangedDuringWrite(beforeWrite, afterWrite)) { + return null + } + const parsed = readParsedDirtyRowSpan(terminal) + if (!parsed) { + return null + } + let { start, end } = parsed + for (const cursorY of [beforeWrite.cursorY, afterWrite.cursorY]) { + if (cursorY === null) { + return null + } + start = Math.min(start, cursorY) + end = Math.max(end, cursorY) + } + return { start, end } +} + function cancelScheduledViewportSettleRefresh(terminal: ForegroundTerminalOutputTarget): void { const pending = pendingViewportSettleRefreshByTerminal.get(terminal) if (!pending) { @@ -133,17 +179,19 @@ function settleForegroundRender( beforeWriteViewport: ViewportSnapshot, options: ForegroundTerminalWriteOptions ): void { + const afterWriteViewport = captureViewportSnapshot(terminal) refreshVisibleRows( terminal, options.shouldRefreshViewportSynchronously?.() ?? true, - options.shouldReleaseRenderPause + options.shouldReleaseRenderPause, + repairRowSpan(terminal, beforeWriteViewport, afterWriteViewport) ) // Why: when output advances the viewport, Chromium can paint the freshly // scrolled top row one frame later than xterm finishes parsing. Repaint once // more after the scroll settles so the user doesn't need to jiggle the window. if ( options.followupViewportRefresh || - viewportChangedDuringWrite(terminal, beforeWriteViewport) + viewportChangedDuringWrite(beforeWriteViewport, afterWriteViewport) ) { scheduleViewportSettleRefresh( terminal, @@ -161,6 +209,11 @@ export function writeForegroundTerminalChunk( const beforeWriteViewport = options.forceViewportRefresh ? captureViewportSnapshot(terminal) : null + if (beforeWriteViewport) { + // Why here and not in the callback: the span must cover only this write's + // parse, and xterm fires its dirty-row request between the two. + resetParsedDirtyRows(terminal) + } // Why guarded steps: this callback runs inside xterm's WriteBuffer loop, // where an escaping throw permanently wedges the terminal (see // xterm-write-callback-guard.ts). Guard settle and onParsed separately so a @@ -190,4 +243,5 @@ export function writeForegroundTerminalChunk( export function discardForegroundRenderSettle(terminal: ForegroundTerminalOutputTarget): void { cancelScheduledViewportSettleRefresh(terminal) + disposeParsedDirtyRows(terminal) } diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts index 2897c1627de..fef2c9f5d1e 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-context-recovery.test.ts @@ -14,6 +14,7 @@ function createPane(options: { loadAddon?: () => void } = {}): ManagedPaneIntern leafId, stablePaneId: leafId, terminal: { + options: { cursorBlink: true }, cols: 80, rows: 24, refresh: vi.fn(), diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts index d6e30bb57f8..09f2ef98c84 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-refresh-lifecycle.test.ts @@ -17,6 +17,7 @@ function createPane( leafId, stablePaneId: leafId, terminal: { + options: { cursorBlink: true }, element: null, cols: 80, rows: 24, diff --git a/src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts b/src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts new file mode 100644 index 00000000000..de18152558f --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-foreground-repair-convergence.test.ts @@ -0,0 +1,364 @@ +import { Terminal } from '@xterm/headless' +import { describe, expect, it } from 'vitest' + +import { + discardForegroundRenderSettle, + writeForegroundTerminalChunk, + type ForegroundTerminalOutputTarget +} from './pane-terminal-foreground-render-settle' + +/** + * Convergence oracle for the narrowed foreground repaint. + * + * Invariant (`terminal-geometry.visible-convergence`): the rows Orca asks xterm + * to repaint after a forced foreground refresh must cover every viewport row + * whose rendered content changed during that write, plus the cursor row on both + * sides of it. A renderer whose model was converged before the write is then + * still converged after it, so narrowing the span can never strand a stale cell. + * + * A `null` span means "repaint the whole viewport" and trivially converges; the + * corpus below also asserts which cases must stay full-grid. + */ + +type SpanRequest = { start: number; end: number } + +type Harness = { + terminal: Terminal + target: ForegroundTerminalOutputTarget + requests: SpanRequest[] +} + +function createHarness(cols = 80, rows = 24): Harness { + const terminal = new Terminal({ cols, rows, allowProposedApi: true }) + const requests: SpanRequest[] = [] + const target = terminal as unknown as ForegroundTerminalOutputTarget & { + refresh: (start: number, end: number) => void + } + target.refresh = (start: number, end: number) => { + requests.push({ start, end }) + } + return { terminal, target, requests } +} + +function serializeRow(terminal: Terminal, viewportRow: number): string { + // Why the offset: `getLine` indexes the whole buffer, so viewport row 0 is the + // line at `viewportY`. Comparing raw buffer indices would compare scrollback + // that no write can touch and make the oracle vacuous. + const line = terminal.buffer.active.getLine(terminal.buffer.active.viewportY + viewportRow) + if (!line) { + return '' + } + const parts: string[] = [] + for (let x = 0; x < terminal.cols; x++) { + const cell = line.getCell(x) + if (!cell) { + parts.push('~') + continue + } + parts.push( + [ + cell.getChars(), + cell.getWidth(), + cell.getFgColorMode(), + cell.getFgColor(), + cell.getBgColorMode(), + cell.getBgColor(), + cell.isBold(), + cell.isItalic(), + cell.isDim(), + cell.isUnderline(), + cell.isBlink(), + cell.isInverse(), + cell.isInvisible(), + cell.isStrikethrough(), + cell.isOverline() + ].join(':') + ) + } + return parts.join('|') +} + +function snapshotViewport(terminal: Terminal): string[] { + const rows: string[] = [] + for (let y = 0; y < terminal.rows; y++) { + rows.push(serializeRow(terminal, y)) + } + return rows +} + +function changedRows(before: string[], after: string[]): number[] { + const changed: number[] = [] + for (let y = 0; y < Math.max(before.length, after.length); y++) { + if (before[y] !== after[y]) { + changed.push(y) + } + } + return changed +} + +async function writeAndSettle(harness: Harness, data: string): Promise { + await new Promise((resolve) => { + const accepted = writeForegroundTerminalChunk(harness.target, data, { + forceViewportRefresh: true, + // Why: headless has no `_core.refresh`, so drive the public `refresh` path + // the WebGL/async branch uses in the app. + shouldRefreshViewportSynchronously: () => false, + onParsed: () => resolve() + }) + expect(accepted).toBe(true) + }) +} + +/** Seed the pane without measuring: plain writes, drained before the oracle runs. */ +async function seed(harness: Harness, data: string): Promise { + await new Promise((resolve) => { + harness.terminal.write(data, () => resolve()) + }) +} + +type Case = { + name: string + setup?: string + write: string + /** Whole-viewport repaint is required (scroll, buffer flip, unknown span). */ + expectFullGrid?: boolean + cols?: number + rows?: number +} + +const SCROLLBACK_SEED = `${Array.from({ length: 40 }, (_, i) => `line ${i} ${'lorem ipsum '.repeat(3)}`).join('\r\n')}\r\n\r\n\r\n\r\n` + +const CLAUDE_STYLE_REDRAW = `\x1b[?25l\x1b[4A\x1b[2K* Thinking… (12s)\r\n\x1b[2K > tool call 3\r\n\x1b[2K ${'#'.repeat(30)}\r\n\x1b[2K\r\n\x1b[?25h` + +const CASES: Case[] = [ + { + name: 'claude-style in-place redraw of the bottom rows', + setup: SCROLLBACK_SEED, + write: CLAUDE_STYLE_REDRAW + }, + { + name: 'standalone carriage-return overwrite of the current line', + setup: `${SCROLLBACK_SEED}some existing prompt text`, + write: '\rrewritten prompt' + }, + { + name: 'backspace erase', + setup: `${SCROLLBACK_SEED}abcdef`, + write: '\b\b\b \b\b\b' + }, + { + name: 'erase in line to end', + setup: `${SCROLLBACK_SEED}${'x'.repeat(70)}`, + write: '\x1b[20G\x1b[K' + }, + { + name: 'erase in line, whole line', + setup: `${SCROLLBACK_SEED}${'x'.repeat(70)}`, + write: '\x1b[2K' + }, + { + name: 'erase in display from mid-screen to end', + setup: `${SCROLLBACK_SEED}${Array.from({ length: 10 }, (_, i) => `row ${i} ${'y'.repeat(40)}`).join('\r\n')}`, + write: '\x1b[12;5H\x1b[J' + }, + { + name: 'erase in display, above cursor', + setup: `${SCROLLBACK_SEED}${Array.from({ length: 10 }, (_, i) => `row ${i} ${'y'.repeat(40)}`).join('\r\n')}`, + write: '\x1b[12;5H\x1b[1J' + }, + { + name: 'full clear then home', + setup: SCROLLBACK_SEED, + write: '\x1b[2J\x1b[H' + }, + { + name: 'wide CJK glyphs rewritten in place', + setup: `${SCROLLBACK_SEED}${'漢字テスト'.repeat(6)}`, + write: '\r\x1b[2K中文字符测试中文字符测试' + }, + { + name: 'emoji rewritten in place', + setup: `${SCROLLBACK_SEED}status: 🚀🚀🚀 building`, + write: '\r\x1b[2Kstatus: ✅ done 🎉' + }, + { + name: 'combining characters rewritten in place', + setup: `${SCROLLBACK_SEED}café naïve`, + write: '\r\x1b[2Kcafé́ é̀̂ done' + }, + { + name: 'zero-width-joiner sequence', + setup: `${SCROLLBACK_SEED}team: `, + write: '\r\x1b[2Kteam: \u{1F469}‍\u{1F4BB} \u{1F468}‍\u{1F373}' + }, + { + name: 'insert lines inside a scroll region', + setup: `${SCROLLBACK_SEED}${Array.from({ length: 12 }, (_, i) => `region ${i}`).join('\r\n')}`, + write: '\x1b[5;18r\x1b[8;1H\x1b[3L\x1b[r' + }, + { + name: 'delete lines inside a scroll region', + setup: `${SCROLLBACK_SEED}${Array.from({ length: 12 }, (_, i) => `region ${i}`).join('\r\n')}`, + write: '\x1b[5;18r\x1b[8;1H\x1b[3M\x1b[r' + }, + { + name: 'reverse index at the top of the screen scrolls content down', + setup: `${SCROLLBACK_SEED}${Array.from({ length: 12 }, (_, i) => `ri ${i}`).join('\r\n')}`, + write: '\x1b[1;1H\x1bM\x1bM' + }, + { + name: 'cursor jump then paint on a far row', + setup: `${SCROLLBACK_SEED}${Array.from({ length: 12 }, (_, i) => `jump ${i}`).join('\r\n')}`, + write: '\x1b[2;5Hpainted far away\x1b[K' + }, + { + name: 'DEC synchronized-output frame touching scattered rows', + setup: `${SCROLLBACK_SEED}${Array.from({ length: 20 }, (_, i) => `tui ${i}`).join('\r\n')}`, + write: + '\x1b[?2026h\x1b[?25l\x1b[1;2H\x1b[38;2;255;138;0m/ agent\x1b[0m\x1b[9;4Hbody row\x1b[K\x1b[23;2Hstream 0001\x1b[K\x1b[?25h\x1b[?2026l' + }, + { + name: 'newline past the bottom scrolls the viewport', + setup: SCROLLBACK_SEED, + write: '\x1b[2Kfresh output line\r\n', + expectFullGrid: true + }, + { + name: 'entering the alternate screen', + setup: SCROLLBACK_SEED, + write: '\x1b[?1049h\x1b[2J\x1b[H\x1b[Khello alt screen', + expectFullGrid: true + }, + { + name: 'leaving the alternate screen', + setup: `${SCROLLBACK_SEED}\x1b[?1049h\x1b[2J\x1b[Halt content\x1b[K`, + write: '\x1b[?1049l\x1b[2K', + expectFullGrid: true + }, + { + name: 'narrow pane, full-width in-place rewrite', + cols: 40, + rows: 12, + setup: 'z'.repeat(38), + write: `\r\x1b[2K${'q'.repeat(38)}` + } +] + +describe('foreground repaint convergence', () => { + for (const testCase of CASES) { + it(`covers every changed row: ${testCase.name}`, async () => { + const harness = createHarness(testCase.cols ?? 80, testCase.rows ?? 24) + if (testCase.setup) { + await seed(harness, testCase.setup) + } + const before = snapshotViewport(harness.terminal) + const cursorBefore = harness.terminal.buffer.active.cursorY + harness.requests.length = 0 + + await writeAndSettle(harness, testCase.write) + + const after = snapshotViewport(harness.terminal) + const cursorAfter = harness.terminal.buffer.active.cursorY + expect(harness.requests.length).toBeGreaterThan(0) + + const request = harness.requests[0]! + const isFullGrid = request.start === 0 && request.end === harness.terminal.rows - 1 + if (testCase.expectFullGrid) { + expect(isFullGrid).toBe(true) + } + + const dirty = changedRows(before, after) + // Guard against a vacuous oracle: every corpus entry must move the screen. + expect(dirty.length).toBeGreaterThan(0) + for (const row of dirty) { + expect( + row >= request.start && row <= request.end, + `row ${row} changed but repaint span was ${request.start}..${request.end}` + ).toBe(true) + } + // The cursor row must be repainted: xterm's WebGL model drops the caret + // whenever an update pass excludes it. + expect(cursorBefore).toBeGreaterThanOrEqual(request.start) + expect(cursorBefore).toBeLessThanOrEqual(request.end) + expect(cursorAfter).toBeGreaterThanOrEqual(request.start) + expect(cursorAfter).toBeLessThanOrEqual(request.end) + + discardForegroundRenderSettle(harness.target) + harness.terminal.dispose() + }) + } + + it('narrows an in-place bottom-row redraw well below the full grid', async () => { + const harness = createHarness(80, 40) + await seed(harness, SCROLLBACK_SEED) + harness.requests.length = 0 + await writeAndSettle(harness, CLAUDE_STYLE_REDRAW) + const request = harness.requests[0]! + // Bottom-anchored redraw: the span ends on the cursor row but stays a few + // rows tall instead of the 40-row grid the old repair requested. + expect(request.end - request.start + 1).toBeLessThanOrEqual(8) + expect(request.start).toBeGreaterThan(0) + discardForegroundRenderSettle(harness.target) + harness.terminal.dispose() + }) + + it('widens the repair to the cursor rows on both sides of the write', () => { + // Why a double: xterm's own tracker always happens to include the cursor + // row, so only a controlled parse span can prove Orca adds it itself. The + // WebGL model drops the caret when an update pass excludes the cursor row. + const requests: SpanRequest[] = [] + let fire: (event: { start: number; end: number } | undefined) => void = () => {} + const active = { type: 'normal', cursorY: 2, baseY: 0, viewportY: 0 } + const target: ForegroundTerminalOutputTarget = { + rows: 24, + buffer: { active }, + refresh: (start, end) => requests.push({ start, end }), + write: (_data, callback) => { + fire({ start: 8, end: 9 }) + active.cursorY = 17 + callback?.() + }, + _core: { + _inputHandler: { + onRequestRefreshRows: (listener) => { + fire = listener + return { dispose: () => {} } + } + } + } + } as unknown as ForegroundTerminalOutputTarget + writeForegroundTerminalChunk(target, 'x', { + forceViewportRefresh: true, + shouldRefreshViewportSynchronously: () => false + }) + expect(requests).toEqual([{ start: 2, end: 17 }]) + }) + + it('repaints the whole viewport when the parse span cannot be observed', async () => { + const requests: SpanRequest[] = [] + const target: ForegroundTerminalOutputTarget = { + rows: 24, + buffer: { + active: { type: 'normal', cursorY: 3, baseY: 0, viewportY: 0 } + }, + refresh: (start, end) => requests.push({ start, end }), + write: (_data, callback) => callback?.() + } + writeForegroundTerminalChunk(target, 'x', { + forceViewportRefresh: true, + shouldRefreshViewportSynchronously: () => false + }) + expect(requests).toEqual([{ start: 0, end: 23 }]) + }) + + it('keeps the follow-up settle repaint on the whole viewport', async () => { + const harness = createHarness(80, 24) + await seed(harness, SCROLLBACK_SEED) + harness.requests.length = 0 + await writeAndSettle(harness, 'scrolling output\r\n') + // Primary repaint is full-grid because the viewport scrolled. + expect(harness.requests[0]).toEqual({ start: 0, end: 23 }) + discardForegroundRenderSettle(harness.target) + harness.terminal.dispose() + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-parsed-dirty-rows.ts b/src/renderer/src/lib/pane-manager/terminal-parsed-dirty-rows.ts new file mode 100644 index 00000000000..0b0185035c3 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-parsed-dirty-rows.ts @@ -0,0 +1,119 @@ +/** + * The viewport row span xterm itself marked dirty while parsing the writes made + * since the last reset. + * + * Why: xterm's InputHandler already tracks exactly which viewport rows a parse + * touched and asks the terminal to repaint them (`onRequestRefreshRows`). Orca's + * foreground settle re-issues that repaint so an in-place agent redraw is painted + * now instead of a frame later. Re-issuing it as `0..rows-1` widened every + * repaint to the whole grid — xterm's render debouncer unions ranges, so one + * full-grid request turns a five-row frame into a whole-viewport `_updateModel` + * pass over every cell. Observing the parse's own dirty span keeps the repair + * and drops the widening. + */ +export type ParsedDirtyRowSpan = { start: number; end: number } + +type RequestRefreshRowsEvent = { start: number; end: number } | undefined + +type ParsedDirtyRowSource = { + _core?: { + _inputHandler?: { + onRequestRefreshRows?: (listener: (event: RequestRefreshRowsEvent) => void) => { + dispose: () => void + } + } + } +} + +type ParsedDirtyRowTracker = { + start: number + end: number + observed: boolean + wholeViewport: boolean + dispose: () => void +} + +// `null` marks a terminal whose parse spans cannot be observed, so callers keep +// the full-grid behavior instead of narrowing on an absent signal. +const trackersByTerminal = new WeakMap() + +function attachTracker(terminal: object): ParsedDirtyRowTracker | null { + const existing = trackersByTerminal.get(terminal) + if (existing !== undefined) { + return existing + } + const subscribe = (terminal as ParsedDirtyRowSource)._core?._inputHandler?.onRequestRefreshRows + const inputHandler = (terminal as ParsedDirtyRowSource)._core?._inputHandler + if (typeof subscribe !== 'function' || !inputHandler) { + trackersByTerminal.set(terminal, null) + return null + } + const tracker: ParsedDirtyRowTracker = { + start: 0, + end: 0, + observed: false, + wholeViewport: false, + dispose: () => {} + } + try { + const subscription = subscribe.call(inputHandler, (event) => { + if (!event) { + // xterm asks for a whole-viewport repaint by firing `undefined`. + tracker.wholeViewport = true + tracker.observed = true + return + } + if (!tracker.observed) { + tracker.start = event.start + tracker.end = event.end + tracker.observed = true + return + } + tracker.start = Math.min(tracker.start, event.start) + tracker.end = Math.max(tracker.end, event.end) + }) + tracker.dispose = () => subscription.dispose() + } catch { + trackersByTerminal.set(terminal, null) + return null + } + trackersByTerminal.set(terminal, tracker) + return tracker +} + +/** Start (or reset) parse-span observation for the write that is about to run. */ +export function resetParsedDirtyRows(terminal: object): void { + const tracker = attachTracker(terminal) + if (!tracker) { + return + } + tracker.observed = false + tracker.wholeViewport = false + tracker.start = 0 + tracker.end = 0 +} + +/** + * The union of parse spans since the last reset, or `null` when the span is + * unknown (unobservable terminal, no parse seen, or an xterm full-refresh + * request) and the caller must repaint the whole viewport. + */ +export function readParsedDirtyRowSpan(terminal: object): ParsedDirtyRowSpan | null { + const tracker = trackersByTerminal.get(terminal) + if (!tracker || !tracker.observed || tracker.wholeViewport) { + return null + } + return { start: tracker.start, end: tracker.end } +} + +export function disposeParsedDirtyRows(terminal: object): void { + const tracker = trackersByTerminal.get(terminal) + if (tracker) { + try { + tracker.dispose() + } catch { + // A disposed terminal has already torn its emitters down. + } + } + trackersByTerminal.delete(terminal) +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts index 9545615705c..81436039d88 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-dom-tracking.ts @@ -8,7 +8,8 @@ import { syncTerminalScrollIntentFromViewport } from './terminal-scroll-intent' import { syncTerminalScrollIntentSoon } from './terminal-scroll-intent-settle' -import type { TerminalScrollIntentKey, TerminalScrollIntentTarget } from './terminal-scroll-intent' +import type { TerminalScrollIntentTarget } from './terminal-scroll-intent' +import type { TerminalScrollIntentKey } from './terminal-scroll-intent-key-store' import { isTerminalScrollIntentRebuildInFlight, onTerminalScrollIntentBufferRebuildComplete diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-retention.test.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-retention.test.ts new file mode 100644 index 00000000000..e7da03090a1 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-retention.test.ts @@ -0,0 +1,159 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ManagedPaneInternal } from './pane-manager-types' +import type { TerminalLeafId } from '../../../../shared/stable-pane-id' + +const disposePane = vi.hoisted(() => + vi.fn((pane: ManagedPaneInternal, panes: Map) => { + panes.delete(pane.id) + }) +) + +vi.mock('./pane-tree-ops', () => ({ + captureScrollState: vi.fn(), + findPaneChildren: vi.fn(() => []), + promoteSibling: vi.fn(), + removeDividers: vi.fn(), + safeFit: vi.fn(), + wrapInSplit: vi.fn() +})) +vi.mock('./pane-lifecycle', () => ({ disposePane, openTerminal: vi.fn() })) +vi.mock('./pane-webgl-renderer', () => ({ disposeWebgl: vi.fn() })) +vi.mock('./pane-split-scroll', () => ({ + clearPendingSplitScrollRestore: vi.fn(), + scheduleSplitScrollRestore: vi.fn() +})) +vi.mock('./pane-drag-reorder', () => ({ updateMultiPaneState: vi.fn() })) +vi.mock('./pane-divider', () => ({ applyDividerStyles: vi.fn(), applyPaneOpacity: vi.fn() })) + +import { + closeManagedPane, + detachManagedPaneForExternalMove, + retireManagedPanePreservingPty +} from './pane-split-close' +import { + bindTerminalScrollIntentKey, + markTerminalPinnedViewport, + type TerminalScrollIntentTarget +} from './terminal-scroll-intent' +import { + readTerminalScrollIntentKeyRetention, + releaseTerminalScrollIntentKey +} from './terminal-scroll-intent-key-store' + +function leafIdAt(index: number): TerminalLeafId { + return `11111111-1111-4111-8111-${String(index).padStart(12, '0')}` as TerminalLeafId +} + +/** A pinned (non-bottom) viewport so a keyed intent is actually retained. */ +function createPinnedTerminal(): TerminalScrollIntentTarget { + return { + buffer: { active: { type: 'normal', viewportY: 3, baseY: 40 } } as never, + scrollToBottom: vi.fn(), + scrollToLine: vi.fn() + } +} + +function createPane(id: number, leafId: TerminalLeafId): ManagedPaneInternal { + const container = { + classList: { contains: (className: string) => className === 'pane' }, + dataset: { paneId: String(id), leafId }, + parentElement: null, + remove: vi.fn() + } + return { + id, + leafId, + stablePaneId: leafId, + terminal: { focus: vi.fn() } as never, + container: container as unknown as HTMLElement, + xtermContainer: {} as never, + linkTooltip: {} as never, + terminalGpuAcceleration: 'auto', + gpuRenderingEnabled: false, + webglAttachmentDeferred: false, + webglDisabledAfterContextLoss: false, + hasComplexScriptOutput: false, + webglAddon: null, + ligaturesAddon: null, + fitResizeObserver: null, + pendingObservedFitRafId: null, + fitAddon: {} as never, + searchAddon: {} as never, + serializeAddon: {} as never, + unicode11Addon: {} as never, + webLinksAddon: {} as never, + compositionHandler: null, + pendingSplitScrollState: null, + debugLabel: null + } +} + +function openPane(id: number, leafId: TerminalLeafId): ManagedPaneInternal { + const pane = createPane(id, leafId) + const terminal = createPinnedTerminal() + bindTerminalScrollIntentKey(terminal, leafId) + markTerminalPinnedViewport(terminal) + return pane +} + +function closeArgs(pane: ManagedPaneInternal, panes: Map) { + return { + paneId: pane.id, + activePaneId: null, + panes, + root: {} as HTMLElement, + styleOptions: {}, + managerOptions: { linkOpenHint: () => '' }, + getDragCallbacks: () => ({}) as never, + releasePaneIdentity: vi.fn(), + setActivePaneId: vi.fn() + } +} + +describe('terminal scroll intent key retention', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('returns both keyed maps to baseline after 200 pane open/close cycles', () => { + const baseline = readTerminalScrollIntentKeyRetention() + + for (let index = 0; index < 200; index += 1) { + const leafId = leafIdAt(index) + const pane = openPane(index + 1, leafId) + const panes = new Map([[pane.id, pane]]) + // A pinned pane must actually retain its keyed intent while it is open. + expect(readTerminalScrollIntentKeyRetention()).toEqual({ + intents: baseline.intents + 1, + bindings: baseline.bindings + 1 + }) + // Keep a second pane so close is never the last-pane no-op path. + const survivor = createPane(10_000 + index, leafIdAt(10_000 + index)) + panes.set(survivor.id, survivor) + closeManagedPane(closeArgs(pane, panes)) + } + + expect(readTerminalScrollIntentKeyRetention()).toEqual(baseline) + }) + + it('keeps the keyed intent when the leaf is handed to a new host', () => { + const baseline = readTerminalScrollIntentKeyRetention() + + for (const teardown of [detachManagedPaneForExternalMove, retireManagedPanePreservingPty]) { + const leafId = leafIdAt(9000 + Number(teardown === retireManagedPanePreservingPty)) + const pane = openPane(9000, leafId) + const survivor = createPane(9001, leafIdAt(9001)) + const panes = new Map([ + [pane.id, pane], + [survivor.id, survivor] + ]) + + expect(teardown(closeArgs(pane, panes))).toBe(true) + expect(readTerminalScrollIntentKeyRetention().intents).toBe(baseline.intents + 1) + + releaseTerminalScrollIntentKey(leafId) + } + + expect(readTerminalScrollIntentKeyRetention()).toEqual(baseline) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-store.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-store.ts new file mode 100644 index 00000000000..23d2d5d8af1 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent-key-store.ts @@ -0,0 +1,63 @@ +import type { TerminalScrollBufferType } from './terminal-scroll-buffer-snapshot' + +export type TerminalScrollIntentKind = 'followOutput' | 'pinnedViewport' +export type TerminalScrollIntentKey = string + +export type TerminalScrollIntent = { + kind: TerminalScrollIntentKind + bufferType: TerminalScrollBufferType + viewportY: number + baseY: number + revision: number +} + +// Keyed by stable leaf id, so a pin outlives the xterm instance that recorded +// it (workspace switch, keyed remount). Unlike the WeakMap-keyed siblings in +// terminal-scroll-intent.ts these hold a strong string key, so a leaf that is +// gone for good must be released explicitly — see releaseTerminalScrollIntentKey. +const terminalScrollIntentByKey = new Map() +const terminalScrollIntentBindingByKey = new Map() + +export function readKeyedTerminalScrollIntent( + key: TerminalScrollIntentKey +): TerminalScrollIntent | undefined { + return terminalScrollIntentByKey.get(key) +} + +export function writeKeyedTerminalScrollIntent( + key: TerminalScrollIntentKey, + intent: TerminalScrollIntent +): void { + terminalScrollIntentByKey.set(key, intent) +} + +export function readKeyedTerminalScrollIntentBinding( + key: TerminalScrollIntentKey +): number | undefined { + return terminalScrollIntentBindingByKey.get(key) +} + +export function writeKeyedTerminalScrollIntentBinding( + key: TerminalScrollIntentKey, + binding: number +): void { + terminalScrollIntentBindingByKey.set(key, binding) +} + +/** + * Drops the keyed intent for a leaf that is gone for good. Only safe on a real + * close: plain disposal (workspace switch, keyed remount, manager destroy) + * relies on these entries to restore the pin when the leaf mounts again. + */ +export function releaseTerminalScrollIntentKey(key: TerminalScrollIntentKey): void { + terminalScrollIntentByKey.delete(key) + terminalScrollIntentBindingByKey.delete(key) +} + +/** Retention probe for the leak tests. */ +export function readTerminalScrollIntentKeyRetention(): { intents: number; bindings: number } { + return { + intents: terminalScrollIntentByKey.size, + bindings: terminalScrollIntentBindingByKey.size + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts index bd0b63611a6..9e1e32fcb81 100644 --- a/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts +++ b/src/renderer/src/lib/pane-manager/terminal-scroll-intent.ts @@ -3,6 +3,17 @@ import { notifyTerminalFollowOutputWaiters } from './terminal-follow-output-waiters' import { isTerminalScrollIntentRebuildInFlight } from './terminal-scroll-intent-rebuild' +import { + readKeyedTerminalScrollIntent, + readKeyedTerminalScrollIntentBinding, + writeKeyedTerminalScrollIntent, + writeKeyedTerminalScrollIntentBinding +} from './terminal-scroll-intent-key-store' +import type { + TerminalScrollIntent, + TerminalScrollIntentKey, + TerminalScrollIntentKind +} from './terminal-scroll-intent-key-store' import { clampTerminalViewportY, isTerminalViewportAtBottom, @@ -11,24 +22,12 @@ import { type TerminalScrollBufferType } from './terminal-scroll-buffer-snapshot' -type TerminalScrollIntentKind = 'followOutput' | 'pinnedViewport' - export type TerminalScrollIntentTarget = { buffer?: Parameters[0]['buffer'] scrollToBottom?: () => void scrollToLine?: (line: number) => void } -export type TerminalScrollIntentKey = string - -type TerminalScrollIntent = { - kind: TerminalScrollIntentKind - bufferType: TerminalScrollBufferType - viewportY: number - baseY: number - revision: number -} - export type TerminalStructuralScrollIntentSnapshot = { kind: TerminalScrollIntentKind bufferType: TerminalScrollBufferType @@ -53,8 +52,6 @@ const terminalScrollIntentKeyByTerminal = new WeakMap< TerminalScrollIntentKey >() const terminalScrollIntentKeyBindingByTerminal = new WeakMap() -const terminalScrollIntentByKey = new Map() -const terminalScrollIntentBindingByKey = new Map() let nextTerminalScrollIntentRevision = 1 let nextTerminalScrollIntentKeyBinding = 1 @@ -92,7 +89,7 @@ function writeIntentSnapshot( terminalScrollIntentByTerminal.set(terminal, intent) const key = terminalScrollIntentKeyByTerminal.get(terminal) if (key) { - terminalScrollIntentByKey.set(key, intent) + writeKeyedTerminalScrollIntent(key, intent) } if (kind === 'followOutput') { notifyTerminalFollowOutputWaiters(terminal) @@ -106,7 +103,7 @@ function readStoredIntent(terminal: TerminalScrollIntentTarget): TerminalScrollI return terminalIntent } const key = terminalScrollIntentKeyByTerminal.get(terminal) - return key ? terminalScrollIntentByKey.get(key) : undefined + return key ? readKeyedTerminalScrollIntent(key) : undefined } export function bindTerminalScrollIntentKey( @@ -120,8 +117,8 @@ export function bindTerminalScrollIntentKey( const binding = nextTerminalScrollIntentKeyBinding nextTerminalScrollIntentKeyBinding += 1 terminalScrollIntentKeyBindingByTerminal.set(terminal, binding) - terminalScrollIntentBindingByKey.set(key, binding) - const existing = terminalScrollIntentByKey.get(key) + writeKeyedTerminalScrollIntentBinding(key, binding) + const existing = readKeyedTerminalScrollIntent(key) if (existing) { terminalScrollIntentByTerminal.set(terminal, existing) } @@ -137,7 +134,7 @@ export function isTerminalScrollIntentKeyBindingCurrent( } return ( terminalScrollIntentKeyBindingByTerminal.get(terminal) === - terminalScrollIntentBindingByKey.get(key) + readKeyedTerminalScrollIntentBinding(key) ) } diff --git a/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts b/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts index e630e88d149..7d6bfdf26c2 100644 --- a/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-webgl-hidden-retention.test.ts @@ -10,7 +10,8 @@ import { function createPane(withAddon = true): ManagedPaneInternal { return { - terminal: { blur: vi.fn() }, + // options mirrors the real Terminal: suspend parks cursorBlink here. + terminal: { blur: vi.fn(), options: { cursorBlink: true } }, webglAddon: withAddon ? ({ dispose: vi.fn() } as unknown as ManagedPaneInternal['webglAddon']) : null, diff --git a/src/renderer/src/lib/renderer-app-platform.test.ts b/src/renderer/src/lib/renderer-app-platform.test.ts new file mode 100644 index 00000000000..22c0388084a --- /dev/null +++ b/src/renderer/src/lib/renderer-app-platform.test.ts @@ -0,0 +1,52 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + getRendererAppPlatform, + resetRendererAppPlatformCacheForTests +} from './renderer-app-platform' + +function stubPlatformApi(get: () => { platform: NodeJS.Platform }): void { + ;(window as unknown as { api: unknown }).api = { platform: { get } } +} + +describe('getRendererAppPlatform', () => { + beforeEach(() => { + resetRendererAppPlatformCacheForTests() + }) + + afterEach(() => { + delete (window as unknown as { api?: unknown }).api + resetRendererAppPlatformCacheForTests() + }) + + it('crosses the preload bridge once no matter how many renders ask', () => { + const get = vi.fn(() => ({ platform: 'darwin' as NodeJS.Platform })) + stubPlatformApi(get) + + for (let index = 0; index < 500; index += 1) { + expect(getRendererAppPlatform()).toBe('darwin') + } + + expect(get).toHaveBeenCalledTimes(1) + }) + + it.each(['darwin', 'win32', 'linux'] as const)( + 'reports the preload platform verbatim on %s', + (platform) => { + stubPlatformApi(() => ({ platform })) + + expect(getRendererAppPlatform()).toBe(platform) + } + ) + + // Why: the web client injects its platform API after boot, so an early caller must + // not pin the user-agent guess for the rest of the session. + it('does not cache the user-agent fallback', () => { + // 'freebsd' is never a user-agent fallback answer, so the swap is unambiguous. + expect(getRendererAppPlatform()).not.toBe('freebsd') + + stubPlatformApi(() => ({ platform: 'freebsd' })) + + expect(getRendererAppPlatform()).toBe('freebsd') + }) +}) diff --git a/src/renderer/src/lib/renderer-app-platform.ts b/src/renderer/src/lib/renderer-app-platform.ts index e036d0690e3..f94b77ed2e9 100644 --- a/src/renderer/src/lib/renderer-app-platform.ts +++ b/src/renderer/src/lib/renderer-app-platform.ts @@ -1,7 +1,16 @@ +// Why: hot render paths call this per render; the preload answer never changes, so +// cache it. The user-agent fallback stays uncached because window.api can still be +// installing (the web client injects its own platform API after boot). +let cachedAppPlatform: NodeJS.Platform | undefined + export function getRendererAppPlatform(): NodeJS.Platform { + if (cachedAppPlatform) { + return cachedAppPlatform + } const preloadPlatform = typeof window === 'undefined' ? undefined : window.api?.platform?.get?.()?.platform if (preloadPlatform) { + cachedAppPlatform = preloadPlatform return preloadPlatform } const userAgent = typeof navigator === 'undefined' ? '' : navigator.userAgent @@ -16,3 +25,8 @@ export function getRendererAppPlatform(): NodeJS.Platform { } return 'win32' } + +/** Tests swap the window.api platform stub between cases; the real value never changes. */ +export function resetRendererAppPlatformCacheForTests(): void { + cachedAppPlatform = undefined +} diff --git a/src/renderer/src/lib/session-write-subscriber-allocation.test.ts b/src/renderer/src/lib/session-write-subscriber-allocation.test.ts new file mode 100644 index 00000000000..9681af5a973 --- /dev/null +++ b/src/renderer/src/lib/session-write-subscriber-allocation.test.ts @@ -0,0 +1,196 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '@/store' +import { createSessionWriteSubscriber } from './session-write-subscriber' +import { SESSION_RELEVANT_FIELDS } from './workspace-session' +import { buildWorkspaceSessionPatch } from './workspace-session-patch' + +/** + * Why a hand-built store: the subscriber allocates a 35-field snapshot plus a changed-field array + * on every fire that reaches its body, and both are invisible from outside. Driving it through an + * injected store lets `Array.prototype.filter` stand in as the allocation counter — the changed + * list is built 1:1 with the snapshot, in the same block, so one count measures both. + */ +function makeSessionState(overrides: Partial = {}): AppState { + const base: Record = { + workspaceSessionReady: true, + hydrationSucceeded: true, + // Non-session state the subscriber must ignore. + agentStatusByPaneKey: {}, + runtimePaneTitlesByTabId: {} + } + const arrayFields = new Set([ + 'repos', + 'openFiles', + 'browserUrlHistory', + 'workspaceDocHistory' + ]) + const mapFields = new Set(['sshConnectionStates']) + for (const key of SESSION_RELEVANT_FIELDS) { + base[key] = arrayFields.has(key) ? [] : mapFields.has(key) ? new Map() : {} + } + base.activeRepoId = null + base.activeWorkspaceKey = null + base.activeWorktreeId = null + base.activeTabId = null + base.activeWorkspaceExecutionHostId = null + return { ...base, ...overrides } as AppState +} + +function createHarness() { + let state = makeSessionState() + const listeners: ((next: AppState) => void)[] = [] + const persisted: unknown[] = [] + const dispose = createSessionWriteSubscriber({ + store: { + subscribe: (listener) => { + listeners.push(listener) + return () => { + listeners.splice(listeners.indexOf(listener), 1) + } + }, + getState: () => state + }, + persist: (payload) => persisted.push(payload) + }) + return { + dispose, + persisted, + write(mutate?: (previous: AppState) => Partial) { + state = { ...state, ...mutate?.(state) } as AppState + for (const listener of listeners.slice()) { + listener(state) + } + } + } +} + +function countFilterCalls(run: () => T): number { + const original = Array.prototype.filter + let calls = 0 + const spy = vi.spyOn(Array.prototype, 'filter').mockImplementation(function filterCounting( + this: unknown[], + ...args: never[] + ) { + calls += 1 + return (original as (...a: never[]) => unknown[]).apply(this, args) + } as typeof Array.prototype.filter) + try { + run() + return calls + } finally { + spy.mockRestore() + } +} + +beforeEach(() => { + vi.useFakeTimers() +}) +afterEach(() => { + vi.useRealTimers() +}) + +describe('session write subscriber allocation', () => { + it('allocates nothing for store writes that touch no session field', () => { + const harness = createHarness() + try { + // Prime `prev` on the first fire. + harness.write() + vi.advanceTimersByTime(500) + + const writes = 200 + const calls = countFilterCalls(() => { + for (let write = 0; write < writes; write += 1) { + harness.write((previous) => ({ + agentStatusByPaneKey: { ...previous.agentStatusByPaneKey, [`p-${write}`]: {} } as never + })) + } + }) + // Before: one changed-field array (and one 35-field snapshot) per write. + expect(calls).toBe(0) + } finally { + harness.dispose() + } + }) + + it('still allocates and persists when a session field really changes', () => { + const harness = createHarness() + try { + harness.write() + vi.advanceTimersByTime(500) + harness.persisted.length = 0 + + const writes = 20 + const calls = countFilterCalls(() => { + for (let write = 0; write < writes; write += 1) { + harness.write(() => ({ activeTabId: `tab-${write}` })) + } + }) + expect(calls).toBe(writes) + vi.advanceTimersByTime(500) + expect(harness.persisted).toHaveLength(1) + } finally { + harness.dispose() + } + }) + + it('still wakes a deferred write when an unrelated field changes', () => { + let state = makeSessionState() + const listeners: ((next: AppState) => void)[] = [] + const persisted: unknown[] = [] + let gateOpen = false + const dispose = createSessionWriteSubscriber({ + store: { + subscribe: (listener) => { + listeners.push(listener) + return () => {} + }, + getState: () => state + }, + persist: (payload) => persisted.push(payload), + shouldSchedulePersist: () => gateOpen, + subscribeToPersistGateOpen: () => () => {} + }) + const write = (patch: Partial): void => { + state = { ...state, ...patch } as AppState + for (const listener of listeners.slice()) { + listener(state) + } + } + try { + // A real session change lands while the gate is closed, so the write is owed but deferred. + write({ activeTabId: 'tab-1' }) + vi.advanceTimersByTime(500) + expect(persisted).toHaveLength(0) + + // An unrelated write with the gate open must re-arm it even though nothing session-relevant + // moved — the identity-scan fast path must not swallow this wake-up. + gateOpen = true + write({ agentStatusByPaneKey: { a: {} } as never }) + vi.advanceTimersByTime(500) + expect(persisted).toHaveLength(1) + } finally { + dispose() + } + }) + + it('gates on a strict superset of the fields the patch builder reads', () => { + // A gate that misses a projection input persists stale state, so this is a correctness lock, + // not a perf one. Record what the builder actually touches rather than trusting the types. + const read = new Set() + const snapshot = new Proxy(makeSessionState() as unknown as Record, { + get(target, property, receiver) { + if (typeof property === 'string') { + read.add(property) + } + return Reflect.get(target, property, receiver) + } + }) + buildWorkspaceSessionPatch( + snapshot as never, + SESSION_RELEVANT_FIELDS as unknown as Iterable + ) + const gated = new Set(SESSION_RELEVANT_FIELDS) + expect([...read].filter((field) => !gated.has(field))).toEqual([]) + expect(read.size).toBeGreaterThan(0) + }) +}) diff --git a/src/renderer/src/lib/session-write-subscriber.ts b/src/renderer/src/lib/session-write-subscriber.ts index f01b7b25011..e0e366ef6ba 100644 --- a/src/renderer/src/lib/session-write-subscriber.ts +++ b/src/renderer/src/lib/session-write-subscriber.ts @@ -124,6 +124,10 @@ export function createSessionWriteSubscriber({ // reuse the prior identity while a real session change keeps fresh tabs for // the eventual getState() patch build. `null` makes the first fire proceed. let prev: Record | null = null + // Why held separately from `prev`: `prev` stores the *projected* tab maps, so the raw slice + // identity is the only thing the pre-allocation scan below can compare them against. + let prevTabsSource: TabsByWorktree | null = null + let prevUnifiedTabsSource: UnifiedTabsByWorktree | null = null // Why: this set is the only record that a mutation still owes a write — `prev` has already // advanced past it, and change detection is identity-based, so a field dropped from here can // never be re-detected. It is retired only by a flush that reached `persist` (or found nothing @@ -168,10 +172,53 @@ export function createSessionWriteSubscriber({ timer = setTimeout(flushPendingWrite, debounceMs) } + /** + * Identity-only scan over exactly SESSION_RELEVANT_FIELDS, allocating nothing. + * + * Why sound: for the two projected fields an unchanged raw slice is strictly stronger than an + * unchanged projection (the projection is a function of the slice), so a `false` here always + * implies the full comparison below would have found no changed field. A changed raw slice + * falls through to that comparison, where the projection can still collapse it. + */ + const hasSessionFieldIdentityChange = (state: AppState): boolean => { + if (prev === null) { + return true + } + for (const key of SESSION_RELEVANT_FIELDS) { + const unchanged = + key === 'tabsByWorktree' + ? state.tabsByWorktree === prevTabsSource + : key === 'unifiedTabsByWorktree' + ? state.unifiedTabsByWorktree === prevUnifiedTabsSource + : prev[key] === state[key] + if (!unchanged) { + return true + } + } + return false + } + const unsub = store.subscribe((state) => { if (!shouldPersistWorkspaceSession(state)) { return } + // Why: this fires on every store write and almost none of them touch a session field. Scan + // identities first so the common case never allocates the 35-field snapshot or the changed + // list; only a real identity change pays for them. + if (!hasSessionFieldIdentityChange(state)) { + if (pendingChangedFields.size === 0) { + return + } + if (shouldSchedulePersist && !shouldSchedulePersist()) { + return + } + // An unrelated update may wake a deferred write but must never reset an armed debounce. + if (timer !== null) { + return + } + armFlushTimer() + return + } const next: Record = {} for (const key of SESSION_RELEVANT_FIELDS) { const value = state[key] @@ -190,6 +237,8 @@ export function createSessionWriteSubscriber({ return } prev = next + prevTabsSource = state.tabsByWorktree + prevUnifiedTabsSource = state.unifiedTabsByWorktree for (const field of changedFields) { pendingChangedFields.add(field) } diff --git a/src/renderer/src/lib/workspace-session-host-contention.ts b/src/renderer/src/lib/workspace-session-host-contention.ts index 51d09027200..652678e24a1 100644 --- a/src/renderer/src/lib/workspace-session-host-contention.ts +++ b/src/renderer/src/lib/workspace-session-host-contention.ts @@ -10,7 +10,7 @@ import { getWorktreeIdFromHostIdentity, isWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity' -import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from './workspace-session-host-field-ownership' +import { WORKSPACE_SESSION_FIELD_OWNERSHIP } from '../../../shared/workspace-session-host-field-ownership' import { isWorkspaceSessionRecord, type WorkspaceSessionRecord diff --git a/src/renderer/src/lib/workspace-session-host-split.test.ts b/src/renderer/src/lib/workspace-session-host-split.test.ts index de359cfb12e..ad540d2af04 100644 --- a/src/renderer/src/lib/workspace-session-host-split.test.ts +++ b/src/renderer/src/lib/workspace-session-host-split.test.ts @@ -392,3 +392,46 @@ describe('split → merge round trip', () => { expect(roundTrip(state)).toEqual(state) }) }) + +/** + * The main-process load path drops a global field from a non-local partition when the local slice + * already has it, on the strength of exactly these two rules. If either moves, that prune starts + * discarding a value the renderer would otherwise have read. + */ +describe('mergeWorkspaceSessionsFromHosts global-field precedence', () => { + const localEntry = { + url: 'local', + normalizedUrl: 'local', + title: 'l', + lastVisitedAt: 2, + visitCount: 1 + } + const hostEntry = { + url: 'host', + normalizedUrl: 'host', + title: 'h', + lastVisitedAt: 1, + visitCount: 1 + } + + it("takes a global field from 'local' whenever local has one, ignoring every other slice", () => { + const merged = mergeWorkspaceSessionsFromHosts({ + [LOCAL_EXECUTION_HOST_ID]: { + ...getDefaultWorkspaceSession(), + browserUrlHistory: [localEntry] + }, + [RUNTIME_A]: { ...getDefaultWorkspaceSession(), browserUrlHistory: [hostEntry] } + }) + expect(merged.browserUrlHistory).toEqual([localEntry]) + }) + + it('falls back to another slice only when local does not have the field', () => { + const local = getDefaultWorkspaceSession() + delete local.browserUrlHistory + const merged = mergeWorkspaceSessionsFromHosts({ + [LOCAL_EXECUTION_HOST_ID]: local, + [RUNTIME_A]: { ...getDefaultWorkspaceSession(), browserUrlHistory: [hostEntry] } + }) + expect(merged.browserUrlHistory).toEqual([hostEntry]) + }) +}) diff --git a/src/renderer/src/lib/workspace-session-host-split.ts b/src/renderer/src/lib/workspace-session-host-split.ts index 817b87eebeb..ea03cc6f3f8 100644 --- a/src/renderer/src/lib/workspace-session-host-split.ts +++ b/src/renderer/src/lib/workspace-session-host-split.ts @@ -8,7 +8,7 @@ import { isWorktreeHostIdentity } from '../../../shared/worktree/host-qualified- import { GLOBAL_WORKSPACE_SESSION_FIELDS, WORKSPACE_SESSION_FIELD_OWNERSHIP -} from './workspace-session-host-field-ownership' +} from '../../../shared/workspace-session-host-field-ownership' import { buildWorktreeIdByFileId, buildWorktreeIdByTabId, diff --git a/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts b/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts index 724b8743dde..3ae6769bb15 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-agent-status-projection.test.ts @@ -6,14 +6,17 @@ import { resetRuntimeMobileAgentStatusProjectionCacheForTests } from './sync-runtime-graph' -// Reference: the pre-change whole-array serialization, kept verbatim. The bucket -// width is read from the module under test so a drifted constant cannot make this -// reference silently disagree for a reason unrelated to the change. +// Reference: the pre-change whole-array serialization, kept verbatim apart from the sort, which +// is now a code-unit compare — the projection is only ever `===`-compared, never displayed, so it +// needs to be deterministic rather than locale-correct. `localeCompareOrderedEntries` below pins +// that the two orders still serialize the same entries. The bucket width is read from the module +// under test so a drifted constant cannot make this reference silently disagree for a reason +// unrelated to the change. const BUCKET_MS = AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS function referenceProjection(map: AppState['agentStatusByPaneKey']): string { return JSON.stringify( Object.entries(map) - .sort(([a], [b]) => a.localeCompare(b)) + .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) .map(([paneKey, entry]) => ({ paneKey, entryPaneKey: entry.paneKey, @@ -120,4 +123,26 @@ describe('mobile agent-status projection equivalence', () => { }).toEqual({ round, projection: referenceProjection(current) }) } }) + + it('serializes the same entries the localeCompare order did', () => { + resetRuntimeMobileAgentStatusProjectionCacheForTests() + // Keys where locale and code-unit order disagree: 'tab-1:' sorts after 'tab-10:' by code unit + // (':' > '0') and before it by locale, and case/accent handling differs too. + const map: AppState['agentStatusByPaneKey'] = {} + for (const paneKey of ['tab-1:leaf-0', 'tab-10:leaf-0', 'B:leaf-0', 'a:leaf-0', 'á:leaf-0']) { + map[paneKey] = makeEntry(0, { paneKey }) + } + const localeCompareOrderedEntries = JSON.parse( + JSON.stringify( + Object.entries(map) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([paneKey]) => paneKey) + ) + ) as string[] + const projected = ( + JSON.parse(buildRuntimeMobileAgentStatusProjectionForTests(map)) as { paneKey: string }[] + ).map((entry) => entry.paneKey) + expect([...projected].sort()).toEqual([...localeCompareOrderedEntries].sort()) + expect(projected).toHaveLength(localeCompareOrderedEntries.length) + }) }) diff --git a/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts b/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts new file mode 100644 index 00000000000..ed1991e60d2 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph-projection-hot-path.test.ts @@ -0,0 +1,576 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { AppState } from '../store/types' +import { makeAgentStatusEntry, makeState } from './sync-runtime-graph-test-harness' +import type * as EditorDraftHashModule from './sync-runtime-graph/editor-draft-hash' + +// Why the mock: the draft hash is the only per-keystroke cost that scales with file size, so the +// regression this file guards is "how many characters were hashed", not "how long did it take". +// Instrumenting the real function through its own module keeps the counter out of shipped code. +const draftHashCounter = { calls: 0, chars: 0 } +vi.mock('./sync-runtime-graph/editor-draft-hash', async (importOriginal) => { + const actual = await importOriginal() + return { + stableHashString: (value: string): string => { + draftHashCounter.calls += 1 + draftHashCounter.chars += value.length + return actual.stableHashString(value) + } + } +}) + +const { stableHashString } = await import('./sync-runtime-graph/editor-draft-hash') +const { + buildRuntimeMobileAgentStatusProjectionForTests, + getRuntimeMobileSessionSyncKey, + resetRuntimeMobileAgentStatusProjectionCacheForTests, + resetRuntimeMobileSyncProjectionCachesForTests, + runtimeMobileSessionSyncKeysEqual +} = await import('./sync-runtime-graph') +const { + buildRuntimeMobileBrowserProjection, + buildRuntimeMobileEditorDraftsProjection, + buildRuntimeMobileOpenFilesProjection +} = await import('./sync-runtime-graph/sync-projections') +const { AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS } = await import('./sync-runtime-graph/graph-state') + +// ── Reference implementations: the pre-change bodies, kept verbatim ──────────────────── + +function referenceEditorDraftsProjection(editorDrafts: AppState['editorDrafts']): string { + return JSON.stringify( + Object.fromEntries( + Object.entries(editorDrafts).map(([fileId, content]) => [fileId, stableHashString(content)]) + ) + ) +} + +function referenceOpenFilesProjection(openFiles: AppState['openFiles']): string { + return JSON.stringify( + openFiles.map((file) => ({ + id: file.id, + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, + language: file.language, + mode: file.mode, + diffSource: file.diffSource, + isDirty: file.isDirty, + isUntitled: file.isUntitled, + deleteUntouchedOnClose: file.deleteUntouchedOnClose, + markdownPreviewSourceFileId: file.markdownPreviewSourceFileId + })) + ) +} + +function referenceBrowserProjection(state: AppState): string { + return JSON.stringify({ + workspacesByWorktree: Object.fromEntries( + Object.entries(state.browserTabsByWorktree ?? {}).map(([worktreeId, workspaces]) => [ + worktreeId, + workspaces.map((workspace) => ({ + id: workspace.id, + activePageId: workspace.activePageId, + title: workspace.title, + url: workspace.url, + loading: workspace.loading, + canGoBack: workspace.canGoBack, + canGoForward: workspace.canGoForward + })) + ]) + ), + pagesByWorkspace: Object.fromEntries( + Object.entries(state.browserPagesByWorkspace ?? {}).map(([workspaceId, pages]) => [ + workspaceId, + pages.map((page) => ({ + id: page.id, + title: page.title, + url: page.url, + loading: page.loading, + canGoBack: page.canGoBack, + canGoForward: page.canGoForward + })) + ]) + ) + }) +} + +/** The pre-change agent-status serialization, including its `localeCompare` sort. */ +function referenceAgentStatusProjection(map: AppState['agentStatusByPaneKey']): string { + return JSON.stringify( + Object.entries(map) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([paneKey, entry]) => ({ + paneKey, + entryPaneKey: entry.paneKey, + state: entry.state, + workingMode: entry.workingMode ?? null, + prompt: entry.prompt, + updatedAtBucket: Math.floor(entry.updatedAt / AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS), + stateStartedAt: entry.stateStartedAt, + agentType: entry.agentType ?? null, + terminalTitle: entry.terminalTitle ?? null, + stateHistory: entry.stateHistory.map((history) => ({ + state: history.state, + prompt: history.prompt, + startedAt: history.startedAt, + interrupted: history.interrupted ?? null + })), + toolName: entry.toolName ?? null, + toolInput: entry.toolInput ?? null, + interactivePrompt: entry.interactivePrompt ?? null, + lastAssistantMessage: entry.lastAssistantMessage ?? null, + lastAssistantMessageIsToolOutput: entry.lastAssistantMessageIsToolOutput ?? null, + interrupted: entry.interrupted ?? null + })) + ) +} + +/** Same entries, code-unit ordered — proves the new sort changes order only, never content. */ +function sortedProjectionEntries(projection: string): unknown[] { + return (JSON.parse(projection) as unknown[]).slice().sort((a, b) => { + const left = JSON.stringify(a) + const right = JSON.stringify(b) + return left < right ? -1 : left > right ? 1 : 0 + }) +} + +// ── Fixtures ────────────────────────────────────────────────────────────────────────── + +const DRAFT_FILE_COUNT = 5 +const DRAFT_CHARS_PER_FILE = 40_000 + +function makeDrafts(): Record { + const drafts: Record = {} + for (let index = 0; index < DRAFT_FILE_COUNT; index += 1) { + drafts[`file-${index}`] = 'x'.repeat(DRAFT_CHARS_PER_FILE) + } + return drafts +} + +function makeOpenFile(index: number, overrides: Record = {}): never { + return { + id: `file-${index}`, + filePath: `/repo/src/file-${index}.ts`, + relativePath: `src/file-${index}.ts`, + worktreeId: 'wt-1', + language: 'typescript', + mode: 'edit', + isDirty: false, + isUntitled: false, + ...overrides + } as never +} + +function makeBrowserWorkspace(index: number, overrides: Record = {}): never { + return { + id: `ws-${index}`, + activePageId: `page-${index}`, + title: `tab ${index}`, + url: `https://example.test/${index}`, + loading: false, + canGoBack: false, + canGoForward: false, + ...overrides + } as never +} + +function makeBrowserPage(index: number, overrides: Record = {}): never { + return { + id: `page-${index}`, + title: `page ${index}`, + url: `https://example.test/${index}`, + loading: false, + canGoBack: false, + canGoForward: false, + ...overrides + } as never +} + +/** + * Characters handed back by `JSON.stringify`, which is the allocation these projections dominate. + * Counting bytes rather than calls keeps the comparison fair: the old code made one big call per + * rebuild, the new code makes one small call per changed entry. + */ +function countSerializedChars(run: () => void): number { + const original = JSON.stringify + let chars = 0 + const spy = vi.spyOn(JSON, 'stringify').mockImplementation(((...args: never[]) => { + const serialized = (original as (...a: never[]) => string)(...args) + chars += serialized?.length ?? 0 + return serialized + }) as typeof JSON.stringify) + try { + run() + return chars + } finally { + spy.mockRestore() + } +} + +beforeEach(() => { + draftHashCounter.calls = 0 + draftHashCounter.chars = 0 + resetRuntimeMobileSyncProjectionCachesForTests() + resetRuntimeMobileAgentStatusProjectionCacheForTests() +}) + +describe('editor draft projection on the typing path', () => { + it('hashes only the edited file per keystroke, not every open dirty file', () => { + const typedCharacters = 100 + const totalDraftChars = DRAFT_FILE_COUNT * DRAFT_CHARS_PER_FILE + + // Baseline: the pre-change uncached projection, driven by the same counter. + let drafts = makeDrafts() + referenceEditorDraftsProjection(drafts) + draftHashCounter.calls = 0 + draftHashCounter.chars = 0 + for (let keystroke = 0; keystroke < typedCharacters; keystroke += 1) { + drafts = { ...drafts, 'file-0': `${drafts['file-0']}a` } + referenceEditorDraftsProjection(drafts) + } + const before = { calls: draftHashCounter.calls, chars: draftHashCounter.chars } + + resetRuntimeMobileSyncProjectionCachesForTests() + let memoDrafts = makeDrafts() + buildRuntimeMobileEditorDraftsProjection(memoDrafts) + draftHashCounter.calls = 0 + draftHashCounter.chars = 0 + for (let keystroke = 0; keystroke < typedCharacters; keystroke += 1) { + memoDrafts = { ...memoDrafts, 'file-0': `${memoDrafts['file-0']}a` } + buildRuntimeMobileEditorDraftsProjection(memoDrafts) + } + const after = { calls: draftHashCounter.calls, chars: draftHashCounter.chars } + + // Keystroke k has grown file-0 by k characters, so the exact totals are closed form. + const growth = (typedCharacters * (typedCharacters + 1)) / 2 + // Before: every keystroke rehashes all five drafts. + expect(before).toEqual({ + calls: typedCharacters * DRAFT_FILE_COUNT, + chars: typedCharacters * totalDraftChars + growth + }) + // After: one hash of one draft per keystroke. + expect(after).toEqual({ + calls: typedCharacters, + chars: typedCharacters * DRAFT_CHARS_PER_FILE + growth + }) + expect(before.chars / after.chars).toBeGreaterThan(DRAFT_FILE_COUNT - 0.1) + }) + + it('matches the uncached projection byte for byte across draft shapes', () => { + const shapes: Record[] = [ + {}, + { 'file-a': '' }, + { 'file-a': 'hello' }, + { 'file-a': 'hello', 'file-b': 'world' }, + { 'file-b': 'world', 'file-a': 'hello' }, + { '2': 'numeric-like key', 'file-a': 'hello', '1': 'other' }, + { 'quote"and\\slash': 'body with "quotes" and \\ and \u{1f389}' }, + { 'file-a': 'hello', 'file-b': 'world', 'file-c': 'third' }, + { 'file-a': 'HELLO', 'file-c': 'third' } + ] + for (const [index, shape] of shapes.entries()) { + expect({ index, projection: buildRuntimeMobileEditorDraftsProjection(shape) }).toEqual({ + index, + projection: referenceEditorDraftsProjection(shape) + }) + } + }) +}) + +describe('agent-status projection sort', () => { + it('constructs no ICU collator, and keeps the same entries as the localeCompare order', () => { + // MAX_LIVE_AGENT_STATUSES — the cap a busy session actually reaches. Real pane keys are + // `:`, so model them as unordered hex rather than a sorted + // `tab-` run that would let TimSort skip most comparisons. + let seed = 0x2f6e2b1 + const nextHex = (): string => { + seed = (seed * 1103515245 + 12345) & 0x7fffffff + return seed.toString(16).padStart(8, '0') + } + const paneKeys = Array.from({ length: 500 }, () => `${nextHex()}-${nextHex()}:${nextHex()}`) + const map: AppState['agentStatusByPaneKey'] = {} + for (const [index, paneKey] of paneKeys.entries()) { + map[paneKey] = makeAgentStatusEntry({ paneKey, prompt: `prompt ${index}` }) + } + // One ping replaces one entry and re-spreads the map, so the sort runs in full again. + const pinged = { ...map, [paneKeys[0]]: makeAgentStatusEntry({ paneKey: paneKeys[0] }) } + + const localeCompareSpy = vi.spyOn(String.prototype, 'localeCompare') + let projection = '' + let beforeCalls = 0 + let afterCalls = 0 + try { + referenceAgentStatusProjection(map) + beforeCalls = localeCompareSpy.mock.calls.length + localeCompareSpy.mockClear() + resetRuntimeMobileAgentStatusProjectionCacheForTests() + projection = buildRuntimeMobileAgentStatusProjectionForTests(map) + buildRuntimeMobileAgentStatusProjectionForTests(pinged) + afterCalls = localeCompareSpy.mock.calls.length + } finally { + // `mockRestore` clears the recorded calls, so read the counts first. + localeCompareSpy.mockRestore() + } + // Before: thousands of ICU collator comparisons for a single ping. + expect(beforeCalls).toBeGreaterThan(3000) + expect(afterCalls).toBe(0) + + // The projection is identical up to ordering, and ordering is only ever `===`-compared. + expect(sortedProjectionEntries(projection)).toEqual( + sortedProjectionEntries(referenceAgentStatusProjection(map)) + ) + }) + + it('is deterministic for keys where locale and code-unit order disagree', () => { + const map: AppState['agentStatusByPaneKey'] = {} + for (const paneKey of ['b:leaf', 'A:leaf', 'a:leaf', 'á:leaf', 'B:leaf']) { + map[paneKey] = makeAgentStatusEntry({ paneKey }) + } + resetRuntimeMobileAgentStatusProjectionCacheForTests() + const first = buildRuntimeMobileAgentStatusProjectionForTests(map) + resetRuntimeMobileAgentStatusProjectionCacheForTests() + const second = buildRuntimeMobileAgentStatusProjectionForTests({ ...map }) + expect(second).toBe(first) + expect(sortedProjectionEntries(first)).toEqual( + sortedProjectionEntries(referenceAgentStatusProjection(map)) + ) + }) +}) + +describe('open-files and browser projections', () => { + it('re-serializes only the changed entry per store write', () => { + const files = Array.from({ length: 20 }, (_value, index) => makeOpenFile(index)) + const writes = 50 + const driveWrites = (project: (openFiles: AppState['openFiles']) => string): void => { + let openFiles = files as unknown as AppState['openFiles'] + project(openFiles) + for (let write = 0; write < writes; write += 1) { + const next = [...openFiles] + next[0] = makeOpenFile(0, { isDirty: write % 2 === 0 }) + openFiles = next as unknown as AppState['openFiles'] + project(openFiles) + } + } + + const before = countSerializedChars(() => { + driveWrites(referenceOpenFilesProjection) + }) + resetRuntimeMobileSyncProjectionCachesForTests() + const after = countSerializedChars(() => { + driveWrites(buildRuntimeMobileOpenFilesProjection) + }) + // Only the flipped file re-serializes; the other 19 are reused by identity. + expect(before / after).toBeGreaterThan(14) + }) + + it('re-serializes only the changed browser bucket per store write', () => { + const workspaces = Array.from({ length: 8 }, (_value, index) => makeBrowserWorkspace(index)) + const pagesByWorkspace: Record = {} + for (let index = 0; index < 8; index += 1) { + pagesByWorkspace[`ws-${index}`] = [makeBrowserPage(index)] as never[] + } + const initial = makeState({ + browserTabsByWorktree: { 'wt-1': workspaces, 'wt-2': workspaces } as never, + browserPagesByWorkspace: pagesByWorkspace as never + }) + const writes = 50 + const driveWrites = (project: (state: AppState) => string): void => { + let state = initial + project(state) + for (let write = 0; write < writes; write += 1) { + const nextWorkspaces = [...(state.browserTabsByWorktree['wt-1'] ?? [])] + nextWorkspaces[0] = makeBrowserWorkspace(0, { title: `tab 0 (${write})` }) + state = makeState({ + ...state, + browserTabsByWorktree: { + ...state.browserTabsByWorktree, + 'wt-1': nextWorkspaces + } as never + }) + project(state) + } + } + + const before = countSerializedChars(() => { + driveWrites(referenceBrowserProjection) + }) + resetRuntimeMobileSyncProjectionCachesForTests() + const after = countSerializedChars(() => { + driveWrites(buildRuntimeMobileBrowserProjection) + }) + // Only the 'wt-1' bucket re-serializes; 'wt-2' and every page bucket are reused. + expect(before / after).toBeGreaterThan(2.5) + }) + + it('matches the uncached projections byte for byte across shapes', () => { + const openFileShapes: AppState['openFiles'][] = [ + [] as unknown as AppState['openFiles'], + [makeOpenFile(0)] as unknown as AppState['openFiles'], + [makeOpenFile(0, { isDirty: true })] as unknown as AppState['openFiles'], + [ + makeOpenFile(0, { isDirty: true, diffSource: 'working' }), + makeOpenFile(1, { mode: 'diff', markdownPreviewSourceFileId: 'file-0' }), + makeOpenFile(2, { isUntitled: true, deleteUntouchedOnClose: true, language: undefined }) + ] as unknown as AppState['openFiles'] + ] + for (const [index, shape] of openFileShapes.entries()) { + expect({ index, projection: buildRuntimeMobileOpenFilesProjection(shape) }).toEqual({ + index, + projection: referenceOpenFilesProjection(shape) + }) + } + + const browserShapes: AppState[] = [ + makeState({}), + makeState({ browserTabsByWorktree: { 'wt-1': [makeBrowserWorkspace(0)] } as never }), + makeState({ + browserTabsByWorktree: { + 'wt-1': [makeBrowserWorkspace(0, { title: undefined, loading: true })], + '3': [makeBrowserWorkspace(1)] + } as never, + browserPagesByWorkspace: { + 'ws-0': [makeBrowserPage(0), makeBrowserPage(1, { canGoBack: true })], + 'ws-1': [] + } as never + }), + makeState({ + browserTabsByWorktree: {} as never, + browserPagesByWorkspace: { 'ws-9': [makeBrowserPage(9, { url: 'a"b\\c' })] } as never + }) + ] + for (const [index, shape] of browserShapes.entries()) { + expect({ index, projection: buildRuntimeMobileBrowserProjection(shape) }).toEqual({ + index, + projection: referenceBrowserProjection(shape) + }) + } + }) +}) + +describe('sync key transitions', () => { + it('fires on exactly the transitions the uncached projections would have fired on', () => { + const drafts = { 'file-0': 'aaa', 'file-1': 'bbb' } + const files = [makeOpenFile(0), makeOpenFile(1)] as unknown as AppState['openFiles'] + const workspaces = [makeBrowserWorkspace(0)] as never + const status = { + 'tab-0:leaf-0': makeAgentStatusEntry({ paneKey: 'tab-0:leaf-0' }) + } as AppState['agentStatusByPaneKey'] + + const base = makeState({ + editorDrafts: drafts, + openFiles: files, + browserTabsByWorktree: { 'wt-1': workspaces } as never, + browserPagesByWorkspace: { 'ws-0': [makeBrowserPage(0)] } as never, + agentStatusByPaneKey: status + }) + + // Each step returns the next state; the flag is whether a mobile-visible input really moved. + const steps: { name: string; next: (from: AppState) => AppState }[] = [ + { name: 'no-op re-spread', next: (from) => makeState({ ...from }) }, + { + name: 'keystroke in one draft', + next: (from) => + makeState({ ...from, editorDrafts: { ...from.editorDrafts, 'file-0': 'aaab' } }) + }, + { + name: 'draft reverted to the same text', + next: (from) => + makeState({ ...from, editorDrafts: { ...from.editorDrafts, 'file-0': 'aaab' } }) + }, + { + name: 'draft removed', + next: (from) => makeState({ ...from, editorDrafts: { 'file-1': 'bbb' } }) + }, + { + name: 'isDirty flip', + next: (from) => + makeState({ + ...from, + openFiles: [makeOpenFile(0, { isDirty: true }), from.openFiles[1]] as never + }) + }, + { + name: 'open-files re-spread with identical content', + next: (from) => makeState({ ...from, openFiles: [...from.openFiles] as never }) + }, + { + name: 'browser title tick', + next: (from) => + makeState({ + ...from, + browserTabsByWorktree: { + 'wt-1': [makeBrowserWorkspace(0, { title: 'new title' })] + } as never + }) + }, + { + name: 'browser page loading flip', + next: (from) => + makeState({ + ...from, + browserPagesByWorkspace: { + 'ws-0': [makeBrowserPage(0, { loading: true })] + } as never + }) + }, + { + name: 'agent-status ping with an unchanged payload', + next: (from) => + makeState({ + ...from, + agentStatusByPaneKey: { + 'tab-0:leaf-0': makeAgentStatusEntry({ paneKey: 'tab-0:leaf-0' }) + } as never + }) + }, + { + name: 'agent-status prompt change', + next: (from) => + makeState({ + ...from, + agentStatusByPaneKey: { + 'tab-0:leaf-0': makeAgentStatusEntry({ paneKey: 'tab-0:leaf-0', prompt: 'new' }) + } as never + }) + }, + { + name: 'second agent added', + next: (from) => + makeState({ + ...from, + agentStatusByPaneKey: { + ...from.agentStatusByPaneKey, + 'tab-1:leaf-0': makeAgentStatusEntry({ paneKey: 'tab-1:leaf-0' }) + } as never + }) + } + ] + + const referenceTuple = (state: AppState): string[] => [ + referenceEditorDraftsProjection(state.editorDrafts), + referenceOpenFilesProjection(state.openFiles), + referenceBrowserProjection(state), + referenceAgentStatusProjection(state.agentStatusByPaneKey ?? {}) + ] + + resetRuntimeMobileSyncProjectionCachesForTests() + resetRuntimeMobileAgentStatusProjectionCacheForTests() + let previousState = base + let previousKey = getRuntimeMobileSessionSyncKey(base, undefined, undefined, false) + let previousReference = referenceTuple(base) + + for (const step of steps) { + const state = step.next(previousState) + const key = getRuntimeMobileSessionSyncKey(state, previousState, previousKey, false) + const reference = referenceTuple(state) + const referenceChanged = reference.some((part, index) => part !== previousReference[index]) + const keyChanged = !runtimeMobileSessionSyncKeysEqual(key, previousKey) + expect({ step: step.name, changed: keyChanged }).toEqual({ + step: step.name, + changed: referenceChanged + }) + previousState = state + previousKey = key + previousReference = reference + } + }) +}) diff --git a/src/renderer/src/runtime/sync-runtime-graph.ts b/src/renderer/src/runtime/sync-runtime-graph.ts index dd1bdb58d5f..b836a037134 100644 --- a/src/renderer/src/runtime/sync-runtime-graph.ts +++ b/src/renderer/src/runtime/sync-runtime-graph.ts @@ -21,6 +21,7 @@ import { runtimeMobileSessionSyncKeysEqual } from './sync-runtime-graph/sync-key' import { buildMobileSessionTabSnapshots } from './sync-runtime-graph/mobile-session-snapshots' +import { resetRuntimeMobileSyncProjectionCachesForTests } from './sync-runtime-graph/sync-projections' import type { RegisteredTerminalTab } from './sync-runtime-graph/types' export type { RegisteredTerminalTab, RuntimeMobileSessionSyncKey } from './sync-runtime-graph/types' @@ -28,6 +29,7 @@ export { AGENT_STATUS_SYNC_UPDATED_AT_BUCKET_MS_FOR_TESTS, buildRuntimeMobileAgentStatusProjectionForTests, resetRuntimeMobileAgentStatusProjectionCacheForTests, + resetRuntimeMobileSyncProjectionCachesForTests, canSkipRuntimeMobileSessionSyncKeyBuild, getRuntimeMobileSessionSyncKey, runtimeMobileSessionSyncKeysEqual, diff --git a/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts b/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts index f11e58a388f..5e14bd4a8ae 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/agent-status-projection.ts @@ -43,8 +43,11 @@ export function buildRuntimeMobileAgentStatusProjection( // A status ping replaces one entry and re-spreads the map; reuse every other entry. const entries = new Map() const parts: string[] = [] + // Code-unit order, not `localeCompare`: this projection is only ever compared with `===`, so it + // must be deterministic, not locale-correct — and an ICU collator per comparison is ~4.5k calls + // per ping at the 500-entry cap. for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey).sort(([a], [b]) => - a.localeCompare(b) + a < b ? -1 : a > b ? 1 : 0 )) { const previous = cached?.entries.get(paneKey) const entryCache = diff --git a/src/renderer/src/runtime/sync-runtime-graph/editor-draft-hash.ts b/src/renderer/src/runtime/sync-runtime-graph/editor-draft-hash.ts new file mode 100644 index 00000000000..a38ebcf9f35 --- /dev/null +++ b/src/renderer/src/runtime/sync-runtime-graph/editor-draft-hash.ts @@ -0,0 +1,15 @@ +/** + * FNV-1a stamp for one editor draft's text. + * + * Why its own module: it is the single hash shared by the mobile sync key and the mobile session + * snapshot, and its cost is proportional to the draft's length — so callers must memoize it per + * file rather than re-running it whenever the drafts record is re-spread. + */ +export function stableHashString(value: string): string { + let hash = 2166136261 + for (let i = 0; i < value.length; i += 1) { + hash ^= value.charCodeAt(i) + hash = Math.imul(hash, 16777619) + } + return `draft:${value.length}:${(hash >>> 0).toString(16)}` +} diff --git a/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts b/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts index 8aa0d87064a..4869479bfaa 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/graph-state.ts @@ -7,8 +7,12 @@ import type { } from '../../../../shared/runtime-types' import type { AgentStatusProjectionCache, + BrowserPagesProjectionCache, + BrowserWorkspacesProjectionCache, + EditorDraftHashCache, MobileSessionWorktreeInputs, OpenFileIndexes, + OpenFilesProjectionCache, RegisteredTerminalTab, TabsProjectionCache } from './types' @@ -54,10 +58,12 @@ export const graphState = { publishedMobileSessionSnapshotByWorktree: new Map(), cachedTabsProjection: null as TabsProjectionCache | null, cachedAgentStatusProjection: null as AgentStatusProjectionCache | null, + cachedOpenFilesProjection: null as OpenFilesProjectionCache | null, + cachedBrowserWorkspacesProjection: null as BrowserWorkspacesProjectionCache | null, + cachedBrowserPagesProjection: null as BrowserPagesProjectionCache | null, cachedOpenFileIndexesSource: null as AppState['openFiles'] | null, cachedOpenFileIndexes: null as OpenFileIndexes | null, - cachedEditorDraftsSource: null as AppState['editorDrafts'] | null, - cachedEditorDraftVersionByFileId: null as Map | null, + cachedEditorDraftHashes: null as EditorDraftHashCache | null, cachedMobileTerminalThemeSettings: null as AppState['settings'] | null, cachedMobileTerminalThemeSystemPrefersDark: null as boolean | null, cachedMobileTerminalTheme: undefined as RuntimeMobileTerminalTheme | undefined, diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts index 0d6157fe75d..d692a5b83d3 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-inputs.ts @@ -51,29 +51,6 @@ export function getOpenFileIndexes(openFiles: AppState['openFiles']): OpenFileIn return graphState.cachedOpenFileIndexes } -export function getEditorDraftVersionByFileId( - editorDrafts: AppState['editorDrafts'] -): Map { - if ( - graphState.cachedEditorDraftsSource === editorDrafts && - graphState.cachedEditorDraftVersionByFileId - ) { - return graphState.cachedEditorDraftVersionByFileId - } - const versions = new Map() - for (const [fileId, content] of Object.entries(editorDrafts)) { - let hash = 2166136261 - for (let index = 0; index < content.length; index += 1) { - hash ^= content.charCodeAt(index) - hash = Math.imul(hash, 16777619) - } - versions.set(fileId, `draft:${content.length}:${(hash >>> 0).toString(16)}`) - } - graphState.cachedEditorDraftsSource = editorDrafts - graphState.cachedEditorDraftVersionByFileId = versions - return versions -} - export function buildMobileSessionAgentStatusByWorktree( agentStatusByPaneKey: AppState['agentStatusByPaneKey'], tabsByWorktree: AppState['tabsByWorktree'] diff --git a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts index f6ba7f49cfb..7dc106a6cd4 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/mobile-session-snapshots.ts @@ -14,9 +14,9 @@ import { import { buildMobileSessionAgentStatusByWorktree, buildMobileSessionWorktreeInputs, - getEditorDraftVersionByFileId, getOpenFileIndexes } from './mobile-session-inputs' +import { getEditorDraftVersionByFileId } from './sync-projections' import { getMobileTerminalTheme } from './mobile-terminal-theme' import { isMobilePublishableBrowserWorkspace, diff --git a/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts b/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts index 0627bccee06..44a1606b056 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/sync-projections.ts @@ -1,11 +1,19 @@ import type { AppState } from '@/store/types' import { resolveTerminalTabTitle } from '../../../../shared/tab-title-resolution' +import { stableHashString } from './editor-draft-hash' import type { TerminalTab } from '../../../../shared/terminal-tab-types' import { EMPTY_BROWSER_PAGES_BY_WORKSPACE, EMPTY_BROWSER_TABS_BY_WORKTREE, graphState } from './graph-state' +import type { + BrowserPagesProjectionCacheEntry, + BrowserWorkspacesProjectionCacheEntry, + EditorDraftHashCache, + EditorDraftHashCacheEntry, + OpenFilesProjectionCacheEntry +} from './types' export function getBrowserTabsByWorktree(state: AppState): AppState['browserTabsByWorktree'] { // Some callers/tests build partial pre-browser states; treat missing slices as empty. @@ -76,72 +84,192 @@ export function buildRuntimeMobileTabsProjection( } export function buildRuntimeMobileOpenFilesProjection(openFiles: AppState['openFiles']): string { - return JSON.stringify( - openFiles.map((file) => ({ - id: file.id, - filePath: file.filePath, - relativePath: file.relativePath, - worktreeId: file.worktreeId, - language: file.language, - mode: file.mode, - diffSource: file.diffSource, - isDirty: file.isDirty, - isUntitled: file.isUntitled, - deleteUntouchedOnClose: file.deleteUntouchedOnClose, - markdownPreviewSourceFileId: file.markdownPreviewSourceFileId - })) - ) + const cached = graphState.cachedOpenFilesProjection + if (cached?.source === openFiles) { + return cached.projection + } + + // An isDirty flip replaces one file and re-spreads the array; reuse every other file. + const previousEntries = cached?.entries + const entries = new Map() + const parts: string[] = [] + for (const file of openFiles) { + const previous = previousEntries?.get(file.id) + const entry = + previous?.file === file + ? previous + : { + file, + projection: JSON.stringify({ + id: file.id, + filePath: file.filePath, + relativePath: file.relativePath, + worktreeId: file.worktreeId, + language: file.language, + mode: file.mode, + diffSource: file.diffSource, + isDirty: file.isDirty, + isUntitled: file.isUntitled, + deleteUntouchedOnClose: file.deleteUntouchedOnClose, + markdownPreviewSourceFileId: file.markdownPreviewSourceFileId + }) + } + entries.set(file.id, entry) + parts.push(entry.projection) + } + const projection = `[${parts.join(',')}]` + graphState.cachedOpenFilesProjection = { source: openFiles, entries, projection } + return projection +} + +function buildBrowserWorkspacesProjection( + browserTabsByWorktree: AppState['browserTabsByWorktree'] +): string { + const cached = graphState.cachedBrowserWorkspacesProjection + if (cached?.source === browserTabsByWorktree) { + return cached.projection + } + + const previousEntries = cached?.entries + const entries = new Map() + const parts: string[] = [] + for (const [worktreeId, workspaces] of Object.entries(browserTabsByWorktree)) { + const previous = previousEntries?.get(worktreeId) + const entry = + previous?.workspaces === workspaces + ? previous + : { + workspaces, + keyJson: previous?.keyJson ?? JSON.stringify(worktreeId), + projection: JSON.stringify( + workspaces.map((workspace) => ({ + id: workspace.id, + activePageId: workspace.activePageId, + title: workspace.title, + url: workspace.url, + loading: workspace.loading, + canGoBack: workspace.canGoBack, + canGoForward: workspace.canGoForward + })) + ) + } + entries.set(worktreeId, entry) + parts.push(`${entry.keyJson}:${entry.projection}`) + } + const projection = `{${parts.join(',')}}` + graphState.cachedBrowserWorkspacesProjection = { + source: browserTabsByWorktree, + entries, + projection + } + return projection +} + +function buildBrowserPagesProjection( + browserPagesByWorkspace: AppState['browserPagesByWorkspace'] +): string { + const cached = graphState.cachedBrowserPagesProjection + if (cached?.source === browserPagesByWorkspace) { + return cached.projection + } + + const previousEntries = cached?.entries + const entries = new Map() + const parts: string[] = [] + for (const [workspaceId, pages] of Object.entries(browserPagesByWorkspace)) { + const previous = previousEntries?.get(workspaceId) + const entry = + previous?.pages === pages + ? previous + : { + pages, + keyJson: previous?.keyJson ?? JSON.stringify(workspaceId), + projection: JSON.stringify( + pages.map((page) => ({ + id: page.id, + title: page.title, + url: page.url, + loading: page.loading, + canGoBack: page.canGoBack, + canGoForward: page.canGoForward + })) + ) + } + entries.set(workspaceId, entry) + parts.push(`${entry.keyJson}:${entry.projection}`) + } + const projection = `{${parts.join(',')}}` + graphState.cachedBrowserPagesProjection = { + source: browserPagesByWorkspace, + entries, + projection + } + return projection } export function buildRuntimeMobileBrowserProjection(state: AppState): string { - const browserTabsByWorktree = getBrowserTabsByWorktree(state) - const browserPagesByWorkspace = getBrowserPagesByWorkspace(state) - return JSON.stringify({ - workspacesByWorktree: Object.fromEntries( - Object.entries(browserTabsByWorktree).map(([worktreeId, workspaces]) => [ - worktreeId, - workspaces.map((workspace) => ({ - id: workspace.id, - activePageId: workspace.activePageId, - title: workspace.title, - url: workspace.url, - loading: workspace.loading, - canGoBack: workspace.canGoBack, - canGoForward: workspace.canGoForward - })) - ]) - ), - pagesByWorkspace: Object.fromEntries( - Object.entries(browserPagesByWorkspace).map(([workspaceId, pages]) => [ - workspaceId, - pages.map((page) => ({ - id: page.id, - title: page.title, - url: page.url, - loading: page.loading, - canGoBack: page.canGoBack, - canGoForward: page.canGoForward - })) - ]) - ) - }) + // A title/url/loading tick replaces one worktree or workspace bucket; reuse the rest. + return `{"workspacesByWorktree":${buildBrowserWorkspacesProjection( + getBrowserTabsByWorktree(state) + )},"pagesByWorkspace":${buildBrowserPagesProjection(getBrowserPagesByWorkspace(state))}}` +} + +/** + * Why memoized per file id: `setEditorDraft` fires on every Monaco keystroke and re-spreads + * `editorDrafts`, so an unmemoized rebuild re-hashed every open dirty file's full text on the + * input path. Only the typed file's draft string changes identity, so only it needs rehashing. + */ +function getEditorDraftHashCache(editorDrafts: AppState['editorDrafts']): EditorDraftHashCache { + const cached = graphState.cachedEditorDraftHashes + if (cached?.source === editorDrafts) { + return cached + } + + const previousEntries = cached?.entries + const entries = new Map() + const hashByFileId = new Map() + const parts: string[] = [] + for (const [fileId, content] of Object.entries(editorDrafts)) { + const previous = previousEntries?.get(fileId) + let entry: EditorDraftHashCacheEntry + if (previous?.content === content) { + entry = previous + } else { + const fileIdJson = previous?.fileIdJson ?? JSON.stringify(fileId) + const hash = stableHashString(content) + entry = { content, hash, fileIdJson, projection: `${fileIdJson}:${JSON.stringify(hash)}` } + } + entries.set(fileId, entry) + hashByFileId.set(fileId, entry.hash) + parts.push(entry.projection) + } + const next: EditorDraftHashCache = { + source: editorDrafts, + entries, + hashByFileId, + projection: `{${parts.join(',')}}` + } + graphState.cachedEditorDraftHashes = next + return next } export function buildRuntimeMobileEditorDraftsProjection( editorDrafts: AppState['editorDrafts'] ): string { - return JSON.stringify( - Object.fromEntries( - Object.entries(editorDrafts).map(([fileId, content]) => [fileId, stableHashString(content)]) - ) - ) + return getEditorDraftHashCache(editorDrafts).projection } -export function stableHashString(value: string): string { - let hash = 2166136261 - for (let i = 0; i < value.length; i += 1) { - hash ^= value.charCodeAt(i) - hash = Math.imul(hash, 16777619) - } - return `draft:${value.length}:${(hash >>> 0).toString(16)}` +/** Per-file draft version stamps for the mobile session snapshot; shares the keystroke memo. */ +export function getEditorDraftVersionByFileId( + editorDrafts: AppState['editorDrafts'] +): ReadonlyMap { + return getEditorDraftHashCache(editorDrafts).hashByFileId +} + +export function resetRuntimeMobileSyncProjectionCachesForTests(): void { + graphState.cachedTabsProjection = null + graphState.cachedOpenFilesProjection = null + graphState.cachedBrowserWorkspacesProjection = null + graphState.cachedBrowserPagesProjection = null + graphState.cachedEditorDraftHashes = null } diff --git a/src/renderer/src/runtime/sync-runtime-graph/types.ts b/src/renderer/src/runtime/sync-runtime-graph/types.ts index b51b8cd8afa..448a3652a5d 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/types.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/types.ts @@ -60,6 +60,48 @@ export type TabsProjectionCache = { entries: Map projection: string } +export type OpenFilesProjectionCacheEntry = { + file: AppState['openFiles'][number] + projection: string +} +export type OpenFilesProjectionCache = { + source: AppState['openFiles'] + entries: Map + projection: string +} +export type BrowserWorkspacesProjectionCacheEntry = { + workspaces: NonNullable + keyJson: string + projection: string +} +export type BrowserWorkspacesProjectionCache = { + source: AppState['browserTabsByWorktree'] + entries: Map + projection: string +} +export type BrowserPagesProjectionCacheEntry = { + pages: NonNullable + keyJson: string + projection: string +} +export type BrowserPagesProjectionCache = { + source: AppState['browserPagesByWorkspace'] + entries: Map + projection: string +} +/** One dirty file's FNV draft stamp plus its pre-serialized projection fragment. */ +export type EditorDraftHashCacheEntry = { + content: string + hash: string + fileIdJson: string + projection: string +} +export type EditorDraftHashCache = { + source: AppState['editorDrafts'] + entries: Map + hashByFileId: Map + projection: string +} export type AgentStatusProjectionCacheEntry = { entry: AppState['agentStatusByPaneKey'][string] projection: string diff --git a/src/renderer/src/startup/active-workspace-ssh-targets.test.ts b/src/renderer/src/startup/active-workspace-ssh-targets.test.ts new file mode 100644 index 00000000000..f36a186b0ac --- /dev/null +++ b/src/renderer/src/startup/active-workspace-ssh-targets.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { toAppSshPtyId } from '../../../shared/ssh-pty-id' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import { collectActiveWorkspaceSshTargetIds } from './active-workspace-ssh-targets' + +function tab(id: string, ptyId: string | null = null): TerminalTab { + return { + id, + ptyId, + worktreeId: 'repo-a::/w/a', + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } as TerminalTab +} + +const emptyInput = { + activeWorktreeId: null as string | null, + tabsByWorktree: {} as Record, + pendingReconnectPtyIdByTabId: {} as Record, + terminalLayoutsByTabId: {} as Record }>, + repos: [] as { id: string; connectionId?: string | null }[] +} + +describe('collectActiveWorkspaceSshTargetIds', () => { + it('returns nothing when no workspace is active', () => { + expect(collectActiveWorkspaceSshTargetIds(emptyInput)).toEqual([]) + }) + + it('returns nothing for a purely local active workspace', () => { + expect( + collectActiveWorkspaceSshTargetIds({ + ...emptyInput, + activeWorktreeId: 'repo-a::/w/a', + tabsByWorktree: { 'repo-a::/w/a': [tab('t1', 'local-pty-1')] }, + repos: [{ id: 'repo-a', connectionId: null }] + }) + ).toEqual([]) + }) + + it('names the target from the active workspace repo connection', () => { + expect( + collectActiveWorkspaceSshTargetIds({ + ...emptyInput, + activeWorktreeId: 'repo-remote::/srv/w', + repos: [{ id: 'repo-remote', connectionId: 'ssh-1' }] + }) + ).toEqual(['ssh-1']) + }) + + it('names the target from a restored PTY id when the repo catalog has no connection', () => { + // SSH worktrees are absent from worktreesByRepo at cold start; the PTY id is the durable name. + expect( + collectActiveWorkspaceSshTargetIds({ + ...emptyInput, + activeWorktreeId: 'repo-a::/w/a', + tabsByWorktree: { 'repo-a::/w/a': [tab('t1')] }, + pendingReconnectPtyIdByTabId: { t1: toAppSshPtyId('ssh-2', 'pty-9') }, + repos: [{ id: 'repo-a' }] + }) + ).toEqual(['ssh-2']) + }) + + it('names split-leaf targets on the active workspace', () => { + expect( + collectActiveWorkspaceSshTargetIds({ + ...emptyInput, + activeWorktreeId: 'repo-a::/w/a', + tabsByWorktree: { 'repo-a::/w/a': [tab('t1')] }, + terminalLayoutsByTabId: { + t1: { + ptyIdsByLeafId: { + leaf1: toAppSshPtyId('ssh-3', 'pty-1'), + leaf2: null, + leaf3: 'local-pty-2' + } + } + }, + repos: [{ id: 'repo-a' }] + }) + ).toEqual(['ssh-3']) + }) + + it('ignores targets that only own an inactive workspace', () => { + expect( + collectActiveWorkspaceSshTargetIds({ + ...emptyInput, + activeWorktreeId: 'repo-a::/w/a', + tabsByWorktree: { + 'repo-a::/w/a': [tab('t1', 'local-pty-1')], + 'repo-b::/w/b': [tab('t2', toAppSshPtyId('ssh-other', 'pty-1'))] + }, + repos: [{ id: 'repo-a' }, { id: 'repo-b', connectionId: 'ssh-other' }] + }) + ).toEqual([]) + }) + + it('deduplicates a target named by both the repo and its PTY ids', () => { + expect( + collectActiveWorkspaceSshTargetIds({ + ...emptyInput, + activeWorktreeId: 'repo-remote::/srv/w', + tabsByWorktree: { + 'repo-remote::/srv/w': [tab('t1', toAppSshPtyId('ssh-1', 'pty-1'))] + }, + repos: [{ id: 'repo-remote', connectionId: 'ssh-1' }] + }) + ).toEqual(['ssh-1']) + }) +}) diff --git a/src/renderer/src/startup/active-workspace-ssh-targets.ts b/src/renderer/src/startup/active-workspace-ssh-targets.ts new file mode 100644 index 00000000000..c7514abf4a1 --- /dev/null +++ b/src/renderer/src/startup/active-workspace-ssh-targets.ts @@ -0,0 +1,49 @@ +import { parseAppSshPtyId } from '../../../shared/ssh-pty-id' +import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' + +type ActiveWorkspaceSshTargetInput = { + activeWorktreeId: string | null + tabsByWorktree: Readonly> + /** Restored tab-level PTY ids, keyed by tab id. */ + pendingReconnectPtyIdByTabId: Readonly> + terminalLayoutsByTabId: Readonly< + Record> } | undefined> + > + repos: readonly { id: string; connectionId?: string | null }[] +} + +/** + * SSH targets that own terminals the user sees the moment the startup gate opens: the ones + * whose reconnect must still be awaited. Everything else can connect in the background and + * reattach on tab focus. + * + * Derived from the restored PTY ids rather than the repo catalog alone, because SSH worktrees + * are absent from `worktreesByRepo` at cold start — the PTY id is the durable name of the + * target the pane will reattach. + */ +export function collectActiveWorkspaceSshTargetIds(input: ActiveWorkspaceSshTargetInput): string[] { + const { activeWorktreeId } = input + if (!activeWorktreeId) { + return [] + } + const targetIds = new Set() + const repoId = getRepoIdFromWorktreeId(activeWorktreeId) + const connectionId = input.repos.find((repo) => repo.id === repoId)?.connectionId + if (connectionId) { + targetIds.add(connectionId) + } + for (const tab of input.tabsByWorktree[activeWorktreeId] ?? []) { + const ptyIds = [ + tab.ptyId, + input.pendingReconnectPtyIdByTabId[tab.id], + ...Object.values(input.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}) + ] + for (const ptyId of ptyIds) { + const parsed = ptyId ? parseAppSshPtyId(ptyId) : null + if (parsed) { + targetIds.add(parsed.connectionId) + } + } + } + return [...targetIds] +} diff --git a/src/renderer/src/startup/ssh-startup-reconnect.ts b/src/renderer/src/startup/ssh-startup-reconnect.ts index d312239a878..e31bd963d6f 100644 --- a/src/renderer/src/startup/ssh-startup-reconnect.ts +++ b/src/renderer/src/startup/ssh-startup-reconnect.ts @@ -6,7 +6,9 @@ export type SshStartupReconnectResult = { export async function reconnectSshTargetForRendererStartup(args: { targetId: string - timeoutMs: number + /** Omitted for a connect nobody is waiting on — no timer, so it cannot report + * a timeout the caller has no use for. */ + timeoutMs?: number connect: (targetId: string) => Promise publishState: (targetId: string, state: SshConnectionState) => void onFailure: (targetId: string, error: unknown) => void @@ -14,10 +16,16 @@ export async function reconnectSshTargetForRendererStartup(args: { const { targetId, timeoutMs, connect, publishState, onFailure } = args let timeoutId: ReturnType | null = null try { - const timeout = new Promise((_resolve, reject) => { - timeoutId = setTimeout(() => reject(new Error('SSH reconnect timeout')), timeoutMs) - }) - const state = await Promise.race([connect(targetId), timeout]) + const connected = connect(targetId) + const state = + timeoutMs === undefined + ? await connected + : await Promise.race([ + connected, + new Promise((_resolve, reject) => { + timeoutId = setTimeout(() => reject(new Error('SSH reconnect timeout')), timeoutMs) + }) + ]) // Why: the state-change IPC can trail connect's resolution. Publish the // authoritative result before restored terminals inspect renderer state. if (state) { diff --git a/src/renderer/src/startup/startup-ssh-connection-restore.test.ts b/src/renderer/src/startup/startup-ssh-connection-restore.test.ts new file mode 100644 index 00000000000..3464816d0d8 --- /dev/null +++ b/src/renderer/src/startup/startup-ssh-connection-restore.test.ts @@ -0,0 +1,208 @@ +import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from 'vitest' +import type { SshConnectionState, SshProviderEpoch, SshTarget } from '../../../shared/ssh-types' +import { restoreSshConnectionsForStartup } from './startup-ssh-connection-restore' + +function connectedState(targetId: string): SshConnectionState { + return { + targetId, + status: 'connected', + error: null, + reconnectAttempt: 0, + providerEpoch: 'epoch' as SshProviderEpoch, + connectionGeneration: 1, + remotePlatform: 'linux' + } +} + +function target(id: string, lastRequiredPassphrase = false): SshTarget { + return { + id, + label: id, + host: `${id}.example`, + port: 22, + username: 'orca', + lastRequiredPassphrase + } +} + +type Harness = { + connect: Mock<(targetId: string) => Promise> + getState: Mock<(targetId: string) => Promise> + setDeferredSshReconnectTargets: Mock<(targetIds: string[]) => void> + removeDeferredSshReconnectTarget: Mock<(targetId: string) => void> + publishSshConnectionState: Mock<(targetId: string, state: SshConnectionState) => void> +} + +let harness: Harness + +function installWindowApi(targets: SshTarget[]): void { + harness = { + connect: vi.fn(), + getState: vi.fn().mockResolvedValue(null), + setDeferredSshReconnectTargets: vi.fn(), + removeDeferredSshReconnectTarget: vi.fn(), + publishSshConnectionState: vi.fn() + } + vi.stubGlobal('window', { + api: { + app: { startupDiagnostic: undefined }, + ssh: { + listTargets: vi.fn().mockResolvedValue(targets), + connect: (args: { targetId: string }) => harness.connect(args.targetId), + getState: (args: { targetId: string }) => harness.getState(args.targetId) + } + } + }) +} + +beforeEach(() => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + vi.restoreAllMocks() +}) + +describe('restoreSshConnectionsForStartup', () => { + it('does not wait on a target that owns no immediately-mounted pane', async () => { + vi.useFakeTimers() + installWindowApi([target('ssh-active'), target('ssh-asleep')]) + // The asleep host never answers — the old code awaited it for the full timeout. + harness.connect.mockImplementation((targetId: string) => + targetId === 'ssh-active' + ? Promise.resolve(connectedState(targetId)) + : new Promise(() => {}) + ) + + let settled = false + const restore = restoreSshConnectionsForStartup({ + connectionIds: ['ssh-active', 'ssh-asleep'], + blockingConnectionIds: ['ssh-active'], + setDeferredSshReconnectTargets: harness.setDeferredSshReconnectTargets, + removeDeferredSshReconnectTarget: harness.removeDeferredSshReconnectTarget, + publishSshConnectionState: harness.publishSshConnectionState + }).then(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(0) + await restore + expect(settled).toBe(true) + // Both were dialled; only the active one gated restoration. + expect(harness.connect).toHaveBeenCalledWith('ssh-active') + expect(harness.connect).toHaveBeenCalledWith('ssh-asleep') + expect(harness.publishSshConnectionState).toHaveBeenCalledWith( + 'ssh-active', + connectedState('ssh-active') + ) + // The unreachable host is deferred, so its panes reattach on tab focus. + expect(harness.setDeferredSshReconnectTargets).toHaveBeenCalledWith(['ssh-asleep']) + }) + + it('awaits the target that owns the active workspace', async () => { + vi.useFakeTimers() + installWindowApi([target('ssh-active')]) + harness.connect.mockReturnValue(new Promise(() => {})) + + let settled = false + const restore = restoreSshConnectionsForStartup({ + connectionIds: ['ssh-active'], + blockingConnectionIds: ['ssh-active'], + setDeferredSshReconnectTargets: harness.setDeferredSshReconnectTargets, + removeDeferredSshReconnectTarget: harness.removeDeferredSshReconnectTarget, + publishSshConnectionState: harness.publishSshConnectionState + }).then(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(14_000) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1_000) + await restore + expect(settled).toBe(true) + expect(harness.setDeferredSshReconnectTargets).toHaveBeenLastCalledWith(['ssh-active']) + }) + + it('clears the deferred flag once a background target connects', async () => { + installWindowApi([target('ssh-bg')]) + harness.connect.mockResolvedValue(connectedState('ssh-bg')) + + await restoreSshConnectionsForStartup({ + connectionIds: ['ssh-bg'], + blockingConnectionIds: [], + setDeferredSshReconnectTargets: harness.setDeferredSshReconnectTargets, + removeDeferredSshReconnectTarget: harness.removeDeferredSshReconnectTarget, + publishSshConnectionState: harness.publishSshConnectionState + }) + await vi.waitFor(() => + expect(harness.removeDeferredSshReconnectTarget).toHaveBeenCalledWith('ssh-bg') + ) + expect(harness.publishSshConnectionState).toHaveBeenCalledWith( + 'ssh-bg', + connectedState('ssh-bg') + ) + }) + + it('does not push a connected background target back into the deferred list', async () => { + installWindowApi([target('ssh-active'), target('ssh-bg')]) + // The active host never answers and times out; the background host connects first. + harness.connect.mockImplementation((targetId: string) => + targetId === 'ssh-bg' + ? Promise.resolve(connectedState(targetId)) + : new Promise(() => {}) + ) + + await restoreSshConnectionsForStartup({ + connectionIds: ['ssh-active', 'ssh-bg'], + blockingConnectionIds: ['ssh-active'], + setDeferredSshReconnectTargets: harness.setDeferredSshReconnectTargets, + removeDeferredSshReconnectTarget: harness.removeDeferredSshReconnectTarget, + publishSshConnectionState: harness.publishSshConnectionState + }) + + expect(harness.removeDeferredSshReconnectTarget).toHaveBeenCalledWith('ssh-bg') + // The timed-out rewrite must not resurrect the reachable background target: a deferred + // connected target sends fresh panes down the cold-restore path instead of the normal one. + expect(harness.setDeferredSshReconnectTargets).toHaveBeenLastCalledWith(['ssh-active']) + }, 30_000) + + it('keeps passphrase targets deferred and never dials them', async () => { + installWindowApi([target('ssh-key', true), target('ssh-bg')]) + harness.connect.mockResolvedValue(connectedState('ssh-bg')) + + await restoreSshConnectionsForStartup({ + connectionIds: ['ssh-key', 'ssh-bg'], + blockingConnectionIds: [], + setDeferredSshReconnectTargets: harness.setDeferredSshReconnectTargets, + removeDeferredSshReconnectTarget: harness.removeDeferredSshReconnectTarget, + publishSshConnectionState: harness.publishSshConnectionState + }) + + expect(harness.connect).not.toHaveBeenCalledWith('ssh-key') + expect(harness.setDeferredSshReconnectTargets).toHaveBeenCalledWith(['ssh-key', 'ssh-bg']) + }) + + it('awaits every target when no blocking set is supplied', async () => { + vi.useFakeTimers() + installWindowApi([target('ssh-a'), target('ssh-b')]) + harness.connect.mockReturnValue(new Promise(() => {})) + + let settled = false + const restore = restoreSshConnectionsForStartup({ + connectionIds: ['ssh-a', 'ssh-b'], + setDeferredSshReconnectTargets: harness.setDeferredSshReconnectTargets, + removeDeferredSshReconnectTarget: harness.removeDeferredSshReconnectTarget, + publishSshConnectionState: harness.publishSshConnectionState + }).then(() => { + settled = true + }) + + await vi.advanceTimersByTimeAsync(14_000) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1_000) + await restore + expect(harness.setDeferredSshReconnectTargets).toHaveBeenLastCalledWith(['ssh-a', 'ssh-b']) + }) +}) diff --git a/src/renderer/src/startup/startup-ssh-connection-restore.ts b/src/renderer/src/startup/startup-ssh-connection-restore.ts index b3ad1a42d61..35efaed2bfa 100644 --- a/src/renderer/src/startup/startup-ssh-connection-restore.ts +++ b/src/renderer/src/startup/startup-ssh-connection-restore.ts @@ -8,13 +8,27 @@ const SSH_RECONNECT_TIMEOUT_MS = 15_000 * Re-establishes the SSH targets that were live at shutdown before terminal reconnect, so * SSH-backed tabs route through pty.attach. Passphrase-protected and timed-out targets are * handed back as deferred so their PTYs reattach on tab focus instead of stacking dialogs. + * + * Only `blockingConnectionIds` are awaited. Every other target connects in the background and + * is registered as deferred up front, so an unreachable host cannot hold local terminal + * restoration for the reconnect timeout. Background connects keep running in main; the pane's + * deferred flow joins the same in-flight `ssh.connect` on tab focus. */ export async function restoreSshConnectionsForStartup(args: { connectionIds: string[] + /** Targets whose panes mount as soon as the startup gate opens. Omitted = await all. */ + blockingConnectionIds?: readonly string[] setDeferredSshReconnectTargets: (targetIds: string[]) => void + removeDeferredSshReconnectTarget: (targetId: string) => void publishSshConnectionState: (targetId: string, state: SshConnectionState) => void }): Promise { - const { connectionIds, setDeferredSshReconnectTargets, publishSshConnectionState } = args + const { + connectionIds, + blockingConnectionIds, + setDeferredSshReconnectTargets, + removeDeferredSshReconnectTarget, + publishSshConnectionState + } = args const allTargets = await timeRendererStartupStep('ssh-list-targets', () => window.api.ssh.listTargets() ) @@ -24,11 +38,42 @@ export async function restoreSshConnectionsForStartup(args: { needsPassphrase: targetMap.get(targetId)?.lastRequiredPassphrase ?? false })) - const eagerTargets = targets.filter((t) => !t.needsPassphrase) - const deferredTargets = targets.filter((t) => t.needsPassphrase) + const passphraseTargetIds = targets.filter((t) => t.needsPassphrase).map((t) => t.targetId) + const blocking = blockingConnectionIds ? new Set(blockingConnectionIds) : null + const eagerTargets = targets.filter( + (t) => !t.needsPassphrase && (blocking === null || blocking.has(t.targetId)) + ) + const backgroundTargets = targets.filter( + (t) => !t.needsPassphrase && blocking !== null && !blocking.has(t.targetId) + ) - if (deferredTargets.length > 0) { - setDeferredSshReconnectTargets(deferredTargets.map((t) => t.targetId)) + const deferredTargetIds = [...passphraseTargetIds, ...backgroundTargets.map((t) => t.targetId)] + if (deferredTargetIds.length > 0) { + setDeferredSshReconnectTargets(deferredTargetIds) + } + + // Why tracked: the timed-out branch below rewrites the whole deferred list, and a + // background target that already connected must not be pushed back into it. + const connectedBackgroundTargetIds = new Set() + // Why fired before the awaited group: a background target that lands before terminal + // reconnect reads as an ordinary connected target, exactly as it does today. + for (const { targetId } of backgroundTargets) { + void reconnectSshTargetForRendererStartup({ + targetId, + connect: (id) => window.api.ssh.connect({ targetId: id }), + publishState: (id, state) => { + publishSshConnectionState(id, state) + if (state.status === 'connected') { + // Why: a still-deferred connected target sends fresh panes down the deferred + // spawn path instead of the normal one. Clear it as soon as it is reachable. + connectedBackgroundTargetIds.add(id) + removeDeferredSshReconnectTarget(id) + } + }, + onFailure: (id, error) => { + console.warn(`SSH background auto-reconnect failed for ${id}:`, error) + } + }) } // Why: treat timed-out eager targets as deferred so their PTYs reattach on tab focus (ssh.connect keeps running in main and likely finishes by then). @@ -52,10 +97,17 @@ export async function restoreSshConnectionsForStartup(args: { } }) ), - { eagerTargets: eagerTargets.length, deferredTargets: deferredTargets.length } + { + eagerTargets: eagerTargets.length, + deferredTargets: passphraseTargetIds.length, + backgroundTargets: backgroundTargets.length + } ) if (timedOutTargets.length > 0) { - setDeferredSshReconnectTargets([...deferredTargets.map((t) => t.targetId), ...timedOutTargets]) + setDeferredSshReconnectTargets([ + ...deferredTargetIds.filter((id) => !connectedBackgroundTargetIds.has(id)), + ...timedOutTargets + ]) } // Why: older/wrapped providers may return no state from connect; poll main once as a compatibility fallback before terminal restoration. diff --git a/src/renderer/src/store/always-mounted-selector-scan-cost.test.ts b/src/renderer/src/store/always-mounted-selector-scan-cost.test.ts new file mode 100644 index 00000000000..2baeb5958ef --- /dev/null +++ b/src/renderer/src/store/always-mounted-selector-scan-cost.test.ts @@ -0,0 +1,277 @@ +/** + * Zustand reruns every subscriber's selector on every store write, so an O(N) + * scan inside an always-mounted selector is paid thousands of times per second + * while the app is idle. These tests count property reads on the store rows to + * prove each selector builds its index once per snapshot instead of per read. + */ +import { describe, expect, it } from 'vitest' +import type { Repo } from '../../../shared/repo-types' +import type { TerminalTab } from '../../../shared/terminal-tab-types' +import type { Worktree } from '../../../shared/worktree/types' +import { WORKTREE_ID_SEPARATOR } from '../../../shared/worktree/id' +import { getLocalPreflightContext } from '../lib/local-preflight-context' +import { getProjectRuntimeSessionSummary } from '../components/settings/repository-runtime-session-summary' +import type { AppState } from './types' +import { selectRepoByIdForActiveWorkspace } from './selectors' + +// The user scale that motivated this: 10 repos, 423 worktrees, 382 open tabs. +const REPO_COUNT = 10 +const WORKTREES_PER_REPO = 42 +const TABS_PER_WORKTREE = 1 +const STORE_WRITES = 200 + +type ReadCounter = { count: number } + +function makeRepoRows(counter: ReadCounter): Repo[] { + return Array.from({ length: REPO_COUNT }, (_unused, index) => { + const id = `repo-${index}` + return { + get id() { + counter.count += 1 + return id + }, + path: `/tmp/repo-${index}`, + displayName: `repo-${index}`, + badgeColor: '#737373', + addedAt: 100, + kind: 'git' + } as Repo + }) +} + +function makeWorktreesByRepo(counter: ReadCounter): AppState['worktreesByRepo'] { + const worktreesByRepo: Record = {} + for (let repoIndex = 0; repoIndex < REPO_COUNT; repoIndex += 1) { + const repoId = `repo-${repoIndex}` + worktreesByRepo[repoId] = Array.from({ length: WORKTREES_PER_REPO }, (_unused, index) => { + const path = String.raw`\\wsl.localhost\Ubuntu\home\alice\wt-${repoIndex}-${index}` + const id = `${repoId}${WORKTREE_ID_SEPARATOR}${path}` + return { + get id() { + counter.count += 1 + return id + }, + repoId, + path + } as Worktree + }) + } + return worktreesByRepo +} + +/** The worst case for a first-wins linear scan: the last row of the last repo. */ +function lastWorktreeId(worktreesByRepo: AppState['worktreesByRepo']): string { + const lastBucket = Object.values(worktreesByRepo).at(-1) ?? [] + return (lastBucket.at(-1) as Worktree).id +} + +describe('local preflight context worktree lookup', () => { + it('builds the worktree index once instead of rescanning per store write', () => { + const worktreeReads: ReadCounter = { count: 0 } + const repoReads: ReadCounter = { count: 0 } + const worktreesByRepo = makeWorktreesByRepo(worktreeReads) + const activeWorktreeId = lastWorktreeId(worktreesByRepo) + const state = { + activeRepoId: `repo-${REPO_COUNT - 1}`, + activeWorktreeId, + repos: makeRepoRows(repoReads), + worktreesByRepo, + projects: [] + } as unknown as AppState + const rowCount = REPO_COUNT * WORKTREES_PER_REPO + worktreeReads.count = 0 + repoReads.count = 0 + + for (let write = 0; write < STORE_WRITES; write += 1) { + expect(getLocalPreflightContext(state, 'darwin')).toEqual({ + wslDistro: 'Ubuntu' + }) + } + + // One index build per snapshot, not one scan per store write. + expect(worktreeReads.count).toBeLessThanOrEqual(rowCount) + expect(repoReads.count).toBeLessThanOrEqual(REPO_COUNT) + }) + + it('rebuilds against a replacement snapshot', () => { + const counter: ReadCounter = { count: 0 } + const worktreesByRepo = makeWorktreesByRepo(counter) + const repos = makeRepoRows({ count: 0 }) + const activeWorktreeId = lastWorktreeId(worktreesByRepo) + const before = getLocalPreflightContext( + { + activeRepoId: 'repo-0', + activeWorktreeId, + repos, + worktreesByRepo + } as unknown as AppState, + 'darwin' + ) + expect(before).toEqual({ wslDistro: 'Ubuntu' }) + + const movedWorktree = { + id: activeWorktreeId, + repoId: `repo-${REPO_COUNT - 1}`, + path: String.raw`\\wsl.localhost\Debian\home\alice\moved` + } as Worktree + const after = getLocalPreflightContext( + { + activeRepoId: 'repo-0', + activeWorktreeId, + repos, + worktreesByRepo: { [`repo-${REPO_COUNT - 1}`]: [movedWorktree] } + } as unknown as AppState, + 'darwin' + ) + + expect(after).toEqual({ wslDistro: 'Debian' }) + }) +}) + +describe('selectRepoByIdForActiveWorkspace', () => { + function makeActiveWorkspaceState(counter: ReadCounter): AppState { + return { + repos: makeRepoRows(counter), + activeRepoId: 'repo-0', + // No repo row carries this host, so the fallback branch runs every time. + activeWorkspaceExecutionHostId: 'ssh:host-a' + } as unknown as AppState + } + + it('resolves the active-workspace host once per repos snapshot', () => { + const counter: ReadCounter = { count: 0 } + const state = makeActiveWorkspaceState(counter) + counter.count = 0 + + for (let write = 0; write < STORE_WRITES; write += 1) { + expect(selectRepoByIdForActiveWorkspace(state, 'repo-0')).toBeNull() + } + + // Worst case: the id-keyed map build plus one host-filter pass. + expect(counter.count).toBeLessThanOrEqual(REPO_COUNT * 3) + }) + + it('still prefers the row that carries the active workspace host', () => { + const localRepo = { + id: 'repo-0', + path: '/tmp/a', + displayName: 'a' + } as Repo + const sshRepo = { + id: 'repo-0', + path: '/tmp/a', + displayName: 'a', + connectionId: 'host-a' + } as Repo + const state = { + repos: [localRepo, sshRepo], + activeRepoId: 'repo-0', + activeWorkspaceExecutionHostId: 'ssh:host-a' + } as unknown as AppState + + expect(selectRepoByIdForActiveWorkspace(state, 'repo-0')).toBe(sshRepo) + expect(selectRepoByIdForActiveWorkspace(state, 'repo-0')).toBe(sshRepo) + }) + + it('returns an identical result for repeated reads of one snapshot', () => { + const state = makeActiveWorkspaceState({ count: 0 }) + expect(selectRepoByIdForActiveWorkspace(state, 'repo-0')).toBe( + selectRepoByIdForActiveWorkspace(state, 'repo-0') + ) + expect(selectRepoByIdForActiveWorkspace(state, 'repo-1')).toBe( + selectRepoByIdForActiveWorkspace(state, 'repo-1') + ) + }) +}) + +describe('project runtime session summary', () => { + function makeRuntimeSessionState(counter: ReadCounter): AppState { + const tabsByWorktree: Record = {} + for (let repoIndex = 0; repoIndex < REPO_COUNT; repoIndex += 1) { + for (let index = 0; index < WORKTREES_PER_REPO; index += 1) { + const worktreeId = `repo-${repoIndex}${WORKTREE_ID_SEPARATOR}/tmp/wt-${repoIndex}-${index}` + tabsByWorktree[worktreeId] = Array.from( + { length: TABS_PER_WORKTREE }, + (_unused, tabIndex) => { + const id = `tab-${repoIndex}-${index}-${tabIndex}` + return { + get id() { + counter.count += 1 + return id + }, + ptyId: `pty-${id}`, + worktreeId + } as TerminalTab + } + ) + } + } + return { + tabsByWorktree, + ptyIdsByTabId: {}, + agentStatusByPaneKey: {} + } as unknown as AppState + } + + it('reuses the tab index across repos and store writes', () => { + const counter: ReadCounter = { count: 0 } + const state = makeRuntimeSessionState(counter) + const tabCount = REPO_COUNT * WORKTREES_PER_REPO * TABS_PER_WORKTREE + counter.count = 0 + + // One RepositoryPane per project, all re-running on every store write. + for (let write = 0; write < STORE_WRITES; write += 1) { + for (let repoIndex = 0; repoIndex < REPO_COUNT; repoIndex += 1) { + expect(getProjectRuntimeSessionSummary(state, `repo-${repoIndex}`)).toEqual({ + liveTerminalCount: WORKTREES_PER_REPO * TABS_PER_WORKTREE, + activeTaskCount: 0 + }) + } + } + + // The shared tab index plus one pass over each repo's own tabs. + expect(counter.count).toBeLessThanOrEqual(tabCount * 3) + }) + + it('returns an identical summary for repeated reads of one snapshot', () => { + const state = makeRuntimeSessionState({ count: 0 }) + expect(getProjectRuntimeSessionSummary(state, 'repo-0')).toBe( + getProjectRuntimeSessionSummary(state, 'repo-0') + ) + }) + + it('recomputes when a tab slice is replaced', () => { + const state = makeRuntimeSessionState({ count: 0 }) + const first = getProjectRuntimeSessionSummary(state, 'repo-0') + const worktreeId = `repo-0${WORKTREE_ID_SEPARATOR}/tmp/wt-0-0` + const next = getProjectRuntimeSessionSummary( + { + ...state, + tabsByWorktree: { + [worktreeId]: [{ id: 'tab-new', ptyId: 'pty-new', worktreeId } as TerminalTab] + } + } as unknown as AppState, + 'repo-0' + ) + + expect(first.liveTerminalCount).toBe(WORKTREES_PER_REPO * TABS_PER_WORKTREE) + expect(next.liveTerminalCount).toBe(1) + }) + + it('counts running agents against the owning project only', () => { + const state = makeRuntimeSessionState({ count: 0 }) + const summary = getProjectRuntimeSessionSummary( + { + ...state, + agentStatusByPaneKey: { + 'tab-0-0-0:leaf': { state: 'working', tabId: 'tab-0-0-0' }, + 'tab-1-0-0:leaf': { state: 'working', tabId: 'tab-1-0-0' }, + 'tab-0-1-0:leaf': { state: 'done', tabId: 'tab-0-1-0' } + } + } as unknown as AppState, + 'repo-0' + ) + + expect(summary.activeTaskCount).toBe(1) + }) +}) diff --git a/src/renderer/src/store/selectors.ts b/src/renderer/src/store/selectors.ts index 77c124f562c..b30673a0b39 100644 --- a/src/renderer/src/store/selectors.ts +++ b/src/renderer/src/store/selectors.ts @@ -228,33 +228,65 @@ export const useActiveRepo = () => useAppStore(useShallow((s) => selectRepoByIdForActiveWorkspace(s, s.activeRepoId))) export const useRepoMap = () => useAppStore((s) => getCachedRepoMap(s.repos)) +type ActiveWorkspaceRepoState = Pick< + AppState, + 'repos' | 'activeRepoId' | 'activeWorkspaceExecutionHostId' +> + +// Why: mirrors getIndexedRepoMap above — the host-scoped branch re-filtered every +// repo on each store write even though its answer only moves when `repos` or the +// active workspace host does. +const activeWorkspaceRepoCache = new WeakMap>() + +function resolveRepoOnActiveWorkspaceHost( + state: ActiveWorkspaceRepoState, + repoId: string, + activeWorkspaceExecutionHostId: ExecutionHostId +): Repo | null { + const repoCandidates = state.repos.filter((candidate) => candidate.id === repoId) + const hostMatch = repoCandidates.find( + (candidate) => getRepoExecutionHostId(candidate) === activeWorkspaceExecutionHostId + ) + if (hostMatch) { + return hostMatch + } + // Why: withRepoHostOwnership keeps a paired-hub worktree on its own SSH host while the repo + // stays hub-owned, so that one mismatch still names the right repo; every other stays closed. + if (parseExecutionHostId(activeWorkspaceExecutionHostId)?.kind !== 'ssh') { + return null + } + const pairedHubRepos = repoCandidates.filter( + (candidate) => parseExecutionHostId(getRepoExecutionHostId(candidate))?.kind === 'runtime' + ) + return pairedHubRepos.length === 1 ? pairedHubRepos[0] : null +} + export function selectRepoByIdForActiveWorkspace( - state: Pick, + state: ActiveWorkspaceRepoState, repoId: string | null ): Repo | null { if (!repoId) { return null } const repo = getCachedRepoMap(state.repos).get(repoId) ?? null - if (repoId === state.activeRepoId && state.activeWorkspaceExecutionHostId) { - const repoCandidates = state.repos.filter((candidate) => candidate.id === repoId) - const hostMatch = repoCandidates.find( - (candidate) => getRepoExecutionHostId(candidate) === state.activeWorkspaceExecutionHostId - ) - if (hostMatch) { - return hostMatch - } - // Why: withRepoHostOwnership keeps a paired-hub worktree on its own SSH host while the repo - // stays hub-owned, so that one mismatch still names the right repo; every other stays closed. - if (parseExecutionHostId(state.activeWorkspaceExecutionHostId)?.kind !== 'ssh') { - return null - } - const pairedHubRepos = repoCandidates.filter( - (candidate) => parseExecutionHostId(getRepoExecutionHostId(candidate))?.kind === 'runtime' - ) - return pairedHubRepos.length === 1 ? pairedHubRepos[0] : null + const activeWorkspaceExecutionHostId = state.activeWorkspaceExecutionHostId + if (repoId !== state.activeRepoId || !activeWorkspaceExecutionHostId) { + return repo } - return repo + // The branch below only fires for the active repo, so the host id fully keys it. + let byHost = activeWorkspaceRepoCache.get(state.repos) + if (!byHost) { + byHost = new Map() + activeWorkspaceRepoCache.set(state.repos, byHost) + } + const cacheKey = `${activeWorkspaceExecutionHostId}\u0000${repoId}` + const cached = byHost.get(cacheKey) + if (cached !== undefined) { + return cached + } + const resolved = resolveRepoOnActiveWorkspaceHost(state, repoId, activeWorkspaceExecutionHostId) + byHost.set(cacheKey, resolved) + return resolved } export const useRepoById = (repoId: string | null) => diff --git a/src/renderer/src/store/slices/preflight.test.ts b/src/renderer/src/store/slices/preflight.test.ts index 386bdfdc59a..168f0099fcc 100644 --- a/src/renderer/src/store/slices/preflight.test.ts +++ b/src/renderer/src/store/slices/preflight.test.ts @@ -6,6 +6,7 @@ import type { Worktree } from '../../../../shared/worktree/types' import type { AppState } from '../types' import { createPreflightSlice } from './preflight' import { createRuntimeStatusSlice } from './runtime-status' +import { resetRendererAppPlatformCacheForTests } from '@/lib/renderer-app-platform' const preflightCheck = vi.fn() const callRuntimeRpc = vi.fn() @@ -62,6 +63,7 @@ function resetPreflightMocks(): void { preflightCheck.mockReset() callRuntimeRpc.mockReset() platformGet.mockReset().mockReturnValue({ platform: 'linux' }) + resetRendererAppPlatformCacheForTests() } function makeStatus(glabInstalled: boolean): PreflightStatus { diff --git a/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts b/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts index 81c6350680d..c0f05124eda 100644 --- a/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts +++ b/src/renderer/src/store/slices/store-session-terminal-reconnect.test.ts @@ -599,9 +599,12 @@ describe('reconnectPersistedTerminals', () => { } const sshConnectionStates = new Map([[targetId, currentAuthorityState]]) const originalGet = sshConnectionStates.get.bind(sshConnectionStates) + // Rotate from the first read after the entry authority check, so the + // pre-publication check sees the new generation whatever number of + // intermediate reads the reconnect pass happens to make. sshConnectionStates.get = ((key: string) => { authorityReads += 1 - return authorityReads >= 4 ? rotatedAuthorityState : originalGet(key) + return authorityReads >= 2 ? rotatedAuthorityState : originalGet(key) }) as typeof sshConnectionStates.get store.setState({ repos: [ @@ -636,7 +639,7 @@ describe('reconnectPersistedTerminals', () => { }) const after = store.getState() - expect(authorityReads).toBeGreaterThanOrEqual(4) + expect(authorityReads).toBeGreaterThanOrEqual(2) expect(after.tabsByWorktree).toBe(before.tabsByWorktree) expect(after.ptyIdsByTabId).toBe(before.ptyIdsByTabId) expect(after.pendingReconnectWorktreeIds).toBe(before.pendingReconnectWorktreeIds) diff --git a/src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts b/src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts index ef78f34f954..ea2a9bbb385 100644 --- a/src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts +++ b/src/renderer/src/store/slices/tabs-label-and-pin-state.test.ts @@ -26,14 +26,6 @@ describe('TabsSlice', () => { store = createTestStore() }) - it('setRenamingTabId sets and clears the tab rename signal', () => { - expect(store.getState().renamingTabId).toBeNull() - store.getState().setRenamingTabId('terminal-tab-1') - expect(store.getState().renamingTabId).toBe('terminal-tab-1') - store.getState().setRenamingTabId(null) - expect(store.getState().renamingTabId).toBeNull() - }) - // ─── setTabLabel / setTabCustomLabel / setUnifiedTabColor ───────── describe('tab property setters', () => { diff --git a/src/renderer/src/store/slices/tabs/create-tabs-slice.ts b/src/renderer/src/store/slices/tabs/create-tabs-slice.ts index 799cd257339..adbea2f1726 100644 --- a/src/renderer/src/store/slices/tabs/create-tabs-slice.ts +++ b/src/renderer/src/store/slices/tabs/create-tabs-slice.ts @@ -14,7 +14,6 @@ import { createTabsSessionActions } from './tabs-session-actions' export const createTabsSlice: StateCreator = (set, get) => ({ unifiedTabsByWorktree: {}, - renamingTabId: null, groupsByWorktree: {}, activeGroupIdByWorktree: {}, layoutByWorktree: {}, diff --git a/src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-batch.test.ts b/src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-batch.test.ts new file mode 100644 index 00000000000..e6a7bfe7002 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-batch.test.ts @@ -0,0 +1,148 @@ +import { describe, it, expect, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import { createTabsSliceMockApi } from '../tabs-slice-test-harness' +import { createTestStore } from '../store-test-helpers' +import { + buildHydratedWorkspaceFixture, + HYDRATED_TAB_COUNT, + HYDRATED_WORKSPACE_COUNT, + RECONCILIATION_WRITABLE_KEYS +} from './hydrated-workspace-reconciliation-fixture' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) + +createTabsSliceMockApi() + +// Approximates the renderer's live non-React store-subscriber population. +const SUBSCRIBER_COUNT = 1200 + +type Store = ReturnType + +function hydrateFixtureStore(): { store: Store; workspaceIds: string[] } { + const fixture = buildHydratedWorkspaceFixture() + expect(fixture.workspaceIds).toHaveLength(HYDRATED_WORKSPACE_COUNT) + expect(fixture.tabCount).toBe(HYDRATED_TAB_COUNT) + const store = createTestStore() + store.setState(fixture.state) + return { store, workspaceIds: fixture.workspaceIds } +} + +function countNotifications(store: Store, run: () => void): number { + let notifications = 0 + const unsubscribes = Array.from({ length: SUBSCRIBER_COUNT }, () => + store.subscribe(() => { + notifications += 1 + }) + ) + try { + run() + } finally { + for (const unsubscribe of unsubscribes) { + unsubscribe() + } + } + return notifications +} + +/** Group ids for restored legacy terminals are minted, so pin them to compare states. */ +function withDeterministicUuids(run: () => T): T { + let counter = 0 + const spy = vi.spyOn(globalThis.crypto, 'randomUUID').mockImplementation(() => { + counter += 1 + return `00000000-0000-4000-8000-${String(counter).padStart(12, '0')}` + }) + try { + return run() + } finally { + spy.mockRestore() + } +} + +function reconciliationSnapshot(store: Store): Record { + const state = store.getState() as unknown as Record + return Object.fromEntries(RECONCILIATION_WRITABLE_KEYS.map((key) => [key, state[key]])) +} + +describe('whole-session workspace tab-model reconciliation', () => { + it('collapses a 193-workspace hydration to one store write', () => { + const perWorkspace = hydrateFixtureStore() + const perWorkspaceNotifications = countNotifications(perWorkspace.store, () => { + for (const worktreeId of perWorkspace.workspaceIds) { + perWorkspace.store.getState().reconcileWorktreeTabModel(worktreeId) + } + }) + + const batched = hydrateFixtureStore() + const batchedNotifications = countNotifications(batched.store, () => { + batched.store.getState().reconcileWorktreeTabModels(batched.workspaceIds) + }) + + // The fixture must actually be write-heavy, or the collapse proves nothing. + expect(perWorkspaceNotifications / SUBSCRIBER_COUNT).toBeGreaterThan(100) + expect(batchedNotifications).toBe(SUBSCRIBER_COUNT) + }) + + it('leaves the store byte-identical to the per-workspace path', () => { + const perWorkspace = hydrateFixtureStore() + withDeterministicUuids(() => { + for (const worktreeId of perWorkspace.workspaceIds) { + perWorkspace.store.getState().reconcileWorktreeTabModel(worktreeId) + } + }) + const expected = reconciliationSnapshot(perWorkspace.store) + + const batched = hydrateFixtureStore() + withDeterministicUuids(() => { + batched.store.getState().reconcileWorktreeTabModels(batched.workspaceIds) + }) + const actual = reconciliationSnapshot(batched.store) + + expect(actual).toEqual(expected) + // Object key order is observable through Object.keys/entries iteration in + // selectors and session serialization, so equal values are not enough. + for (const key of RECONCILIATION_WRITABLE_KEYS) { + const actualValue = actual[key] + const expectedValue = expected[key] + if (actualValue && typeof actualValue === 'object' && !Array.isArray(actualValue)) { + expect([key, Object.keys(actualValue)]).toEqual([key, Object.keys(expectedValue as object)]) + } + } + }) + + it('re-reconciling the batched result is a no-op, as it is for the per-workspace path', () => { + const { store, workspaceIds } = hydrateFixtureStore() + store.getState().reconcileWorktreeTabModels(workspaceIds) + const settled = reconciliationSnapshot(store) + + const notifications = countNotifications(store, () => { + store.getState().reconcileWorktreeTabModels(workspaceIds) + }) + + expect(notifications).toBe(0) + expect(reconciliationSnapshot(store)).toEqual(settled) + }) + + it('indexes openFiles once instead of rescanning it per workspace', () => { + const { store, workspaceIds } = hydrateFixtureStore() + const openFiles = store.getState().openFiles + let scans = 0 + store.setState({ + openFiles: new Proxy(openFiles, { + get(target, property, receiver) { + if (property === 'filter') { + scans += 1 + } + return Reflect.get(target, property, receiver) + } + }) + }) + + store.getState().reconcileWorktreeTabModels(workspaceIds) + + expect(scans).toBe(0) + }) +}) diff --git a/src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-fixture.ts b/src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-fixture.ts new file mode 100644 index 00000000000..30ba7be1633 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/hydrated-workspace-reconciliation-fixture.ts @@ -0,0 +1,242 @@ +import type { AppState } from '../../types' +import type { Tab, TabGroup } from '../../../../../shared/tab-types' +import type { TerminalTab } from '../../../../../shared/terminal-tab-types' +import type { OpenFile } from '../editor' + +/** + * Session shape measured on a real heavy profile: 193 workspaces / 382 tabs, + * spread over every reconciliation outcome (no-op, dropped tab, restored legacy + * runtime terminal, orphan sweep, editor tab) so a whole-session fold exercises + * both the workspace-scoped maps and the store-global ones workspaces share. + */ +export const HYDRATED_WORKSPACE_COUNT = 193 +export const HYDRATED_TAB_COUNT = 382 + +const BUCKETS = ['stable', 'stale', 'legacy', 'orphan', 'editor'] as const +type Bucket = (typeof BUCKETS)[number] + +export type HydratedWorkspaceFixture = { + workspaceIds: string[] + tabCount: number + state: Partial +} + +type FixtureDraft = { + unifiedTabsByWorktree: Record + groupsByWorktree: Record + activeGroupIdByWorktree: Record + tabsByWorktree: Record + activeTabIdByWorktree: Record + tabBarOrderByWorktree: Record + ptyIdsByTabId: Record + pendingReconnectPtyIdByTabId: Record + unreadTerminalTabs: Record + cacheTimerByKey: Record + openFiles: OpenFile[] +} + +function unifiedTab(id: string, worktreeId: string, groupId: string, over: Partial): Tab { + return { + id, + entityId: id, + groupId, + worktreeId, + contentType: 'terminal', + label: id, + customLabel: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...over + } +} + +function runtimeTab(id: string, worktreeId: string, over: Partial): TerminalTab { + return { + id, + ptyId: `pty-${id}`, + worktreeId, + title: id, + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1, + ...over + } +} + +function setGroup(draft: FixtureDraft, worktreeId: string, groupId: string, tabs: Tab[]): void { + draft.unifiedTabsByWorktree[worktreeId] = tabs + draft.groupsByWorktree[worktreeId] = [ + { + id: groupId, + worktreeId, + activeTabId: tabs[0]?.id ?? null, + tabOrder: tabs.map((tab) => tab.id) + } + ] +} + +/** Live terminal rows that reconcile to a no-op patch. */ +function buildStableWorkspace(draft: FixtureDraft, worktreeId: string, groupId: string): number { + const live = `${worktreeId}#live` + setGroup(draft, worktreeId, groupId, [unifiedTab(live, worktreeId, groupId, {})]) + draft.tabsByWorktree[worktreeId] = [runtimeTab(live, worktreeId, {})] + return 1 +} + +/** A unified row with no runtime backing: dropped, and its store-global unread flag with it. */ +function buildStaleWorkspace(draft: FixtureDraft, worktreeId: string, groupId: string): number { + const live = `${worktreeId}#live` + const stale = `${worktreeId}#stale` + setGroup(draft, worktreeId, groupId, [ + unifiedTab(live, worktreeId, groupId, {}), + unifiedTab(stale, worktreeId, groupId, { sortOrder: 1 }) + ]) + draft.tabsByWorktree[worktreeId] = [runtimeTab(live, worktreeId, {})] + draft.unreadTerminalTabs[stale] = true + return 2 +} + +/** Live only in a reconnect map: restored into the unified model, minting a group and layout. */ +function buildLegacyWorkspace(draft: FixtureDraft, worktreeId: string): number { + const legacy = `${worktreeId}#legacy` + draft.tabsByWorktree[worktreeId] = [runtimeTab(legacy, worktreeId, { ptyId: null })] + draft.pendingReconnectPtyIdByTabId[legacy] = `session-${legacy}` + draft.ptyIdsByTabId[legacy] = [] + draft.unifiedTabsByWorktree[worktreeId] = [] + draft.groupsByWorktree[worktreeId] = [] + draft.activeTabIdByWorktree[worktreeId] = legacy + return 1 +} + +/** No PTY and no unified row: swept, which writes the store-global per-tab maps. */ +function buildOrphanWorkspace(draft: FixtureDraft, worktreeId: string): number { + const orphan = `${worktreeId}#orphan` + draft.tabsByWorktree[worktreeId] = [runtimeTab(orphan, worktreeId, { ptyId: null })] + draft.tabBarOrderByWorktree[worktreeId] = [orphan] + draft.activeTabIdByWorktree[worktreeId] = orphan + draft.cacheTimerByKey[`${orphan}:git`] = 1 + draft.unifiedTabsByWorktree[worktreeId] = [] + draft.groupsByWorktree[worktreeId] = [] + return 1 +} + +/** One editor tab backed by an open file, one that is not — the openFiles rescan path. */ +function buildEditorWorkspace(draft: FixtureDraft, worktreeId: string, groupId: string): number { + const fileId = `${worktreeId}#file` + const ghost = `${worktreeId}#ghost-file` + setGroup(draft, worktreeId, groupId, [ + unifiedTab(fileId, worktreeId, groupId, { contentType: 'editor' }), + unifiedTab(ghost, worktreeId, groupId, { contentType: 'editor', sortOrder: 1 }) + ]) + draft.tabsByWorktree[worktreeId] = [] + draft.openFiles.push({ + id: fileId, + filePath: `/tmp/${worktreeId}/${fileId}`, + relativePath: fileId, + worktreeId, + language: 'typescript', + isDirty: false, + mode: 'edit' + }) + return 2 +} + +function buildWorkspace( + draft: FixtureDraft, + bucket: Bucket, + worktreeId: string, + groupId: string +): number { + switch (bucket) { + case 'stable': + return buildStableWorkspace(draft, worktreeId, groupId) + case 'stale': + return buildStaleWorkspace(draft, worktreeId, groupId) + case 'legacy': + return buildLegacyWorkspace(draft, worktreeId) + case 'orphan': + return buildOrphanWorkspace(draft, worktreeId) + case 'editor': + return buildEditorWorkspace(draft, worktreeId, groupId) + } +} + +/** Tops the fixture up to the measured tab count with extra live rows. */ +function padToTabCount(draft: FixtureDraft, workspaceIds: string[], missing: number): number { + let added = 0 + for (let slot = 0; added < missing; slot += 1) { + const worktreeId = workspaceIds[slot % workspaceIds.length] + const group = draft.groupsByWorktree[worktreeId]?.[0] + if (!group || draft.tabsByWorktree[worktreeId] == null) { + continue + } + const id = `${worktreeId}#extra-${slot}` + draft.unifiedTabsByWorktree[worktreeId].push( + unifiedTab(id, worktreeId, group.id, { sortOrder: 10 + slot }) + ) + draft.tabsByWorktree[worktreeId].push(runtimeTab(id, worktreeId, { sortOrder: 10 + slot })) + group.tabOrder.push(id) + added += 1 + } + return added +} + +export function buildHydratedWorkspaceFixture( + workspaceCount = HYDRATED_WORKSPACE_COUNT, + tabCountTarget = HYDRATED_TAB_COUNT +): HydratedWorkspaceFixture { + const draft: FixtureDraft = { + unifiedTabsByWorktree: {}, + groupsByWorktree: {}, + activeGroupIdByWorktree: {}, + tabsByWorktree: {}, + activeTabIdByWorktree: {}, + tabBarOrderByWorktree: {}, + ptyIdsByTabId: {}, + pendingReconnectPtyIdByTabId: {}, + unreadTerminalTabs: {}, + cacheTimerByKey: {}, + openFiles: [] + } + const workspaceIds: string[] = [] + let tabCount = 0 + + for (let index = 0; index < workspaceCount; index += 1) { + const worktreeId = `repo1::/tmp/w${index}` + const groupId = `g-${index}` + workspaceIds.push(worktreeId) + draft.activeGroupIdByWorktree[worktreeId] = groupId + tabCount += buildWorkspace(draft, BUCKETS[index % BUCKETS.length], worktreeId, groupId) + } + tabCount += padToTabCount(draft, workspaceIds, Math.max(0, tabCountTarget - tabCount)) + + return { workspaceIds, tabCount, state: { ...draft } } +} + +/** Every top-level key the two reconciliation patch producers can write. */ +export const RECONCILIATION_WRITABLE_KEYS = [ + 'unifiedTabsByWorktree', + 'groupsByWorktree', + 'activeGroupIdByWorktree', + 'layoutByWorktree', + 'unreadTerminalTabs', + 'tabsByWorktree', + 'ptyIdsByTabId', + 'runtimePaneTitlesByTabId', + 'expandedPaneByTabId', + 'canExpandPaneByTabId', + 'terminalLayoutsByTabId', + 'pendingStartupByTabId', + 'pendingInitialCwdByTabId', + 'pendingSetupSplitByTabId', + 'pendingIssueCommandSplitByTabId', + 'automaticAgentResumeClaimsByTabId', + 'nativeChatLaunchPromptByTabId', + 'nativeChatLaunchDraftByTabId', + 'tabBarOrderByWorktree', + 'cacheTimerByKey', + 'activeTabIdByWorktree', + 'activeTabId' +] as const satisfies readonly (keyof AppState)[] diff --git a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts index 012e1e8c0c0..8acb925bfae 100644 --- a/src/renderer/src/store/slices/tabs/tabs-label-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-label-actions.ts @@ -18,7 +18,6 @@ export function createTabsLabelActions( | 'setTabLabel' | 'setTabViewMode' | 'toggleTabViewMode' - | 'setRenamingTabId' | 'setTabCustomLabel' | 'setUnifiedTabColor' | 'pinTab' @@ -101,10 +100,6 @@ export function createTabsLabelActions( } }, - setRenamingTabId: (tabId) => { - set({ renamingTabId: tabId }) - }, - setTabCustomLabel: (tabId, label, opts) => { const exists = get().getTab(tabId) !== null set((state) => patchTab(state.unifiedTabsByWorktree, tabId, { customLabel: label }) ?? {}) diff --git a/src/renderer/src/store/slices/tabs/tabs-reconciliation-batch.ts b/src/renderer/src/store/slices/tabs/tabs-reconciliation-batch.ts new file mode 100644 index 00000000000..1eb11064203 --- /dev/null +++ b/src/renderer/src/store/slices/tabs/tabs-reconciliation-batch.ts @@ -0,0 +1,55 @@ +import type { AppState } from '../../types' + +/** + * Scratch shared by one multi-workspace reconciliation fold. + * + * Reconciling N workspaces one `set()` at a time re-spreads the same + * workspace-keyed maps N times and rescans `openFiles` N times. A batch lets + * the fold clone each map once and then write into its own private draft, and + * index `openFiles` once — `openFiles` is never written by reconciliation, so + * the index stays valid for the whole fold. + */ +export type WorktreeTabModelReconciliationBatch = { + /** Top-level `AppState` keys this fold already cloned and therefore owns. */ + readonly ownedStateKeys: Set + readonly liveEditorIdsByWorktree: ReadonlyMap> +} + +export const EMPTY_LIVE_EDITOR_IDS: ReadonlySet = new Set() + +export function createWorktreeTabModelReconciliationBatch( + state: Pick +): WorktreeTabModelReconciliationBatch { + const liveEditorIdsByWorktree = new Map>() + for (const file of state.openFiles) { + let ids = liveEditorIdsByWorktree.get(file.worktreeId) + if (!ids) { + ids = new Set() + liveEditorIdsByWorktree.set(file.worktreeId, ids) + } + ids.add(file.id) + } + return { ownedStateKeys: new Set(), liveEditorIdsByWorktree } +} + +/** + * Sets one workspace entry, mutating the batch's own draft once it owns the + * map. Insertion order matches the spread it replaces: an existing key keeps + * its slot, a new key is appended. + */ +export function writeBatchedWorkspaceRecordEntry( + current: Record, + stateKey: string, + worktreeId: string, + // `undefined` is accepted because the spread this replaces also stored it. + value: T | undefined, + batch: WorktreeTabModelReconciliationBatch | undefined +): Record { + if (batch?.ownedStateKeys.has(stateKey)) { + ;(current as Record)[worktreeId] = value + return current + } + const next = { ...current, [worktreeId]: value } as Record + batch?.ownedStateKeys.add(stateKey) + return next +} diff --git a/src/renderer/src/store/slices/tabs/tabs-reconciliation.ts b/src/renderer/src/store/slices/tabs/tabs-reconciliation.ts index 1be6199b55c..72d7be7195e 100644 --- a/src/renderer/src/store/slices/tabs/tabs-reconciliation.ts +++ b/src/renderer/src/store/slices/tabs/tabs-reconciliation.ts @@ -7,6 +7,11 @@ import { getOrphanTerminalIds, terminalTabHasReconnectablePty } from '../terminal-orphan-helpers' +import { + EMPTY_LIVE_EDITOR_IDS, + writeBatchedWorkspaceRecordEntry, + type WorktreeTabModelReconciliationBatch +} from './tabs-reconciliation-batch' export type WorktreeTabModelReconciliation = { patch: Partial @@ -14,9 +19,15 @@ export type WorktreeTabModelReconciliation = { activeRenderableTabId: string | null } +/** + * Pure projection of one workspace's reconciliation patch. Passing `batch` + * lets a multi-workspace fold reuse its own map drafts and `openFiles` index; + * the projected values are identical either way. + */ export function projectWorktreeTabModelReconciliation( state: AppState, - worktreeId: string + worktreeId: string, + batch?: WorktreeTabModelReconciliationBatch ): WorktreeTabModelReconciliation { const unifiedTabs = state.unifiedTabsByWorktree[worktreeId] ?? [] const groups = state.groupsByWorktree[worktreeId] ?? [] @@ -93,9 +104,13 @@ export function projectWorktreeTabModelReconciliation( const liveTerminalIds = new Set( runtimeTerminalTabs.filter((tab) => !orphanTerminalIds.has(tab.id)).map((tab) => tab.id) ) - const liveEditorIds = new Set( - state.openFiles.filter((file) => file.worktreeId === worktreeId).map((file) => file.id) - ) + // Why batched: the unbatched scan is O(openFiles) per workspace, so a + // whole-session reconcile is O(workspaces x openFiles). + const liveEditorIds: ReadonlySet = batch + ? (batch.liveEditorIdsByWorktree.get(worktreeId) ?? EMPTY_LIVE_EDITOR_IDS) + : new Set( + state.openFiles.filter((file) => file.worktreeId === worktreeId).map((file) => file.id) + ) const liveBrowserIds = new Set( (state.browserTabsByWorktree[worktreeId] ?? []).map((browserTab) => browserTab.id) ) @@ -177,7 +192,10 @@ export function projectWorktreeTabModelReconciliation( ) let nextUnreadTerminalTabs = state.unreadTerminalTabs if (droppedTerminalEntityIds.length > 0) { - const copy = { ...state.unreadTerminalTabs } + // A batch that already owns this map published it in an earlier patch, so + // draining further entries in place needs no second patch entry. + const owned = batch?.ownedStateKeys.has('unreadTerminalTabs') === true + const copy = owned ? state.unreadTerminalTabs : { ...state.unreadTerminalTabs } let changed = false for (const entityId of droppedTerminalEntityIds) { if (copy[entityId]) { @@ -187,25 +205,44 @@ export function projectWorktreeTabModelReconciliation( } if (changed) { nextUnreadTerminalTabs = copy + batch?.ownedStateKeys.add('unreadTerminalTabs') } } patch = { - unifiedTabsByWorktree: { ...state.unifiedTabsByWorktree, [worktreeId]: validTabs }, - groupsByWorktree: { ...state.groupsByWorktree, [worktreeId]: nextGroups }, - activeGroupIdByWorktree: { - ...state.activeGroupIdByWorktree, - [worktreeId]: nextActiveGroupId - }, + unifiedTabsByWorktree: writeBatchedWorkspaceRecordEntry( + state.unifiedTabsByWorktree, + 'unifiedTabsByWorktree', + worktreeId, + validTabs, + batch + ), + groupsByWorktree: writeBatchedWorkspaceRecordEntry( + state.groupsByWorktree, + 'groupsByWorktree', + worktreeId, + nextGroups, + batch + ), + activeGroupIdByWorktree: writeBatchedWorkspaceRecordEntry( + state.activeGroupIdByWorktree, + 'activeGroupIdByWorktree', + worktreeId, + nextActiveGroupId, + batch + ), ...(nextUnreadTerminalTabs !== state.unreadTerminalTabs ? { unreadTerminalTabs: nextUnreadTerminalTabs } : {}), ...(nextLayout && layoutChanged ? { - layoutByWorktree: { - ...state.layoutByWorktree, - // Why: restored runtime terminals need a concrete leaf before activation. - [worktreeId]: nextLayout - } + // Why: restored runtime terminals need a concrete leaf before activation. + layoutByWorktree: writeBatchedWorkspaceRecordEntry( + state.layoutByWorktree, + 'layoutByWorktree', + worktreeId, + nextLayout, + batch + ) } : {}), ...(orphanTerminalIds.size > 0 diff --git a/src/renderer/src/store/slices/tabs/tabs-session-actions.ts b/src/renderer/src/store/slices/tabs/tabs-session-actions.ts index 1c2d22c0ce7..e33f1f533cd 100644 --- a/src/renderer/src/store/slices/tabs/tabs-session-actions.ts +++ b/src/renderer/src/store/slices/tabs/tabs-session-actions.ts @@ -8,6 +8,8 @@ import { } from '../degraded-repo-worktree-validity' import { buildHydratedTabState } from '../tabs-hydration' import { projectWorktreeTabModelReconciliation } from './tabs-reconciliation' +import { createWorktreeTabModelReconciliationBatch } from './tabs-reconciliation-batch' +import type { AppState } from '../../types' function replaceWorkspaceRecordKeys( current: Record, @@ -20,11 +22,49 @@ function replaceWorkspaceRecordKeys( } } +/** + * Folds every workspace's reconciliation into one patch. Equivalent to + * applying each patch with its own `set()`: each projection reads the state + * left by its predecessors (they share `unreadTerminalTabs` and the orphan + * cleanup maps), only the store write and subscriber fanout are deferred. + */ +function projectWorktreeTabModelReconciliations( + state: AppState, + worktreeIds: readonly string[] +): Partial { + const batch = createWorktreeTabModelReconciliationBatch(state) + // Private working copy so batch-owned maps can be written in place. + const working = { ...state } + const merged: Partial = {} + for (const worktreeId of worktreeIds) { + const { patch } = projectWorktreeTabModelReconciliation(working, worktreeId, batch) + if (Object.keys(patch).length === 0) { + continue + } + Object.assign(merged, patch) + Object.assign(working, patch) + } + return merged +} + export function createTabsSessionActions( set: TabsSliceSet, get: TabsSliceGet -): Pick { +): Pick< + TabsSlice, + 'reconcileWorktreeTabModel' | 'reconcileWorktreeTabModels' | 'hydrateTabsSession' +> { return { + reconcileWorktreeTabModels: (worktreeIds) => { + if (worktreeIds.length === 0) { + return + } + const patch = projectWorktreeTabModelReconciliations(get(), worktreeIds) + if (Object.keys(patch).length > 0) { + set(patch) + } + }, + reconcileWorktreeTabModel: (worktreeId) => { const reconciliation = projectWorktreeTabModelReconciliation(get(), worktreeId) if (Object.keys(reconciliation.patch).length > 0) { diff --git a/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts b/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts index 456d5892255..2835d5e46d6 100644 --- a/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts +++ b/src/renderer/src/store/slices/tabs/tabs-slice-contract.ts @@ -13,8 +13,6 @@ export type TabSplitDirection = 'left' | 'right' | 'up' | 'down' export type TabsSlice = { unifiedTabsByWorktree: Record - // Why: id of the tab whose inline title editor should open; shortcut (tab.rename) sets it, the tab clears it on consume. - renamingTabId: string | null groupsByWorktree: Record activeGroupIdByWorktree: Record layoutByWorktree: Record @@ -101,7 +99,6 @@ export type TabsSlice = { opts?: { recordInteraction?: boolean } ) => void setUnifiedTabColor: (tabId: string, color: string | null) => void - setRenamingTabId: (tabId: string | null) => void pinTab: (tabId: string) => void unpinTab: (tabId: string) => void closeOtherTabs: (tabId: string) => string[] @@ -152,6 +149,8 @@ export type TabsSlice = { renderableTabCount: number activeRenderableTabId: string | null } + /** Reconciles many workspaces through one store write instead of one per workspace. */ + reconcileWorktreeTabModels: (worktreeIds: readonly string[]) => void hydrateTabsSession: ( session: WorkspaceSessionState, options?: WorkspaceSessionHydrationOptions diff --git a/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts b/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts index 5b1e0d39f93..ea0c0545998 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-reconnect.ts @@ -50,18 +50,6 @@ export function createWorkspaceTerminalReconnectActions( const repoById = buildByIdIndex(get().repos) for (const worktreeId of ids) { const tabs = tabsByWorktree[worktreeId] ?? [] - const worktree = worktreeById.get(worktreeId) - const repo = worktree ? (repoById.get(worktree.repoId) ?? null) : null - // Why: only allow deferred reattach when the SSH connection is active; reattaching to a not-yet-connected relay (deferred/passphrase targets) would fail. - const sshTargetId = options?.directSshAuthority.targetId ?? repo?.connectionId ?? null - const sshState = sshTargetId ? get().sshConnectionStates.get(sshTargetId) : null - const sshConnected = sshTargetId != null && sshState?.status === 'connected' - const supportsDeferredReattach = options - ? sshConnected - : !repo?.connectionId || sshConnected - console.debug( - `[reconnect-terminals] worktree=${worktreeId} connectionId=${repo?.connectionId} sshStatus=${sshState?.status} supportsDeferredReattach=${supportsDeferredReattach}` - ) const targetTabIds = pendingReconnectTabByWorktree[worktreeId] ?? [] const tabsToReconnect: TerminalTab[] = targetTabIds.length > 0 @@ -84,11 +72,8 @@ export function createWorkspaceTerminalReconnectActions( ? undefined : pendingPtyId const hasLeafMappings = Object.keys(leafPtyMap).length > 0 - // Why: publish live PTY hints before mount; pty-connection reattaches later. - console.debug( - `[reconnect-terminals] tab=${tabId} tabLevelPtyId=${tabLevelPtyId} supportsDeferredReattach=${supportsDeferredReattach} hasLeafMappings=${hasLeafMappings}` - ) - // Why: populate ptyIdsByTabId so the sessions status segment maps daemon IDs to tabs; otherwise all sessions look like orphans until the pane mounts. + // Why: publish live PTY hints before mount (pty-connection reattaches later) so the + // sessions status segment maps daemon IDs to tabs; otherwise all sessions look like orphans until the pane mounts. // A row whose tab.ptyId went to the canonical row has no tab-level id left, but its own leaf PTYs still need advertising. const allPtyIds = hasLeafMappings ? (Object.values(leafPtyMap).filter(Boolean) as string[]) diff --git a/src/renderer/src/web/preload-api/web-app-api.ts b/src/renderer/src/web/preload-api/web-app-api.ts index f182b8a7790..28fd9f99176 100644 --- a/src/renderer/src/web/preload-api/web-app-api.ts +++ b/src/renderer/src/web/preload-api/web-app-api.ts @@ -33,6 +33,7 @@ export function createWebAppApi(): Partial { // Staging already wrote through to browser storage, so there is nothing left to join. awaitBeforeUnloadCheckpoint: () => Promise.resolve(), awaitFirstWindowStartupServices: () => Promise.resolve(), + awaitGitEnvironmentStartupBarrier: () => Promise.resolve(), prepareTerminalStartupRestoration: () => Promise.resolve(), recoverLegacyWorkerTerminalsForRendererStartup: () => Promise.resolve(), startupDiagnostic: () => Promise.resolve(), diff --git a/src/shared/agent-title-core.ts b/src/shared/agent-title-core.ts index 5d2c688b7d2..3d0bdbc0feb 100644 --- a/src/shared/agent-title-core.ts +++ b/src/shared/agent-title-core.ts @@ -7,6 +7,7 @@ import { } from './agent-name-token-match' import { stripLeadingAgentTitleDecorationOrEmpty } from './agent-title-decoration' import { isLegacyPiCompatibleTitle } from './pi-compatible-synthetic-title' +import { memoizeTitleClassification } from './terminal-title-classification-memo' import { getWrapperTitleSegments } from './terminal-title-wrapper-segments' export { AGY_AGENT_NAME_RE, DROID_AGENT_NAME_RE, HERMES_AGENT_NAME_RE, titleHasAgentName } @@ -54,7 +55,7 @@ export const BRAILLE_SPINNER_RE = /[\u2800-\u28ff]/g // Reserve the whole quarter-circle block so a later frame addition cannot regress this. export const QUARTER_CIRCLE_SPINNER_RE = /[\u25d0-\u25d3]/g -export function isGeminiTerminalTitle(title: string): boolean { +function computeIsGeminiTerminalTitle(title: string): boolean { // Why: Gemini OSC glyphs are stronger evidence than any cwd/session text. if ( title.includes(GEMINI_PERMISSION) || @@ -80,6 +81,11 @@ export function isGeminiTerminalTitle(title: string): boolean { return titleHasAgentName(title, 'gemini') } +/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ +export const isGeminiTerminalTitle: (title: string) => boolean = memoizeTitleClassification( + computeIsGeminiTerminalTitle +) + export function isPiTerminalTitle(title: string): boolean { return isLegacyPiCompatibleTitle(title) && !containsBrailleSpinner(title) } diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts index cdfc6946777..2b5194bfda8 100644 --- a/src/shared/agent-title-identity.ts +++ b/src/shared/agent-title-identity.ts @@ -12,12 +12,13 @@ import { } from './agent-title-core' import { isOpenCodeNativeTitle } from './opencode-terminal-title' import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-title' +import { memoizeTitleClassification } from './terminal-title-classification-memo' /** * Returns true when the terminal title matches Claude Code's title conventions. * Used to scope prompt-cache-timer behavior to Claude sessions only. */ -export function isClaudeAgent(title: string): boolean { +function computeIsClaudeAgent(title: string): boolean { if (!title || isClaudeManagementTitle(title) || isOpenCodeNativeTitle(title)) { return false } @@ -43,7 +44,11 @@ export function isClaudeAgent(title: string): boolean { ) } -export function getAgentLabel(title: string): string | null { +/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ +export const isClaudeAgent: (title: string) => boolean = + memoizeTitleClassification(computeIsClaudeAgent) + +function computeAgentLabel(title: string): string | null { if (isClaudeManagementTitle(title)) { return null } @@ -119,3 +124,7 @@ export function getAgentLabel(title: string): string | null { return null } + +/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ +export const getAgentLabel: (title: string) => string | null = + memoizeTitleClassification(computeAgentLabel) diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts index 423af759281..fa1e35652e2 100644 --- a/src/shared/agent-title-status.ts +++ b/src/shared/agent-title-status.ts @@ -32,6 +32,7 @@ import { import { clearPiStateWorkingMarker, getPiStateTitleStatus } from './pi-state-title-marker' import { getWrapperTitleSegments } from './terminal-title-wrapper-segments' import { isGrokRotatingWorkingTitle } from './terminal-title-agent-type' +import { memoizeTitleClassification } from './terminal-title-classification-memo' /** * Strip working-status indicators so stale exit titles stop reporting working. @@ -178,7 +179,7 @@ function canonicalizeBrailleSpinnerFrame(title: string): string { return canonical } -export function detectAgentStatusFromTitle(title: string): AgentStatus | null { +function computeAgentStatusFromTitle(title: string): AgentStatus | null { if (!title || isClaudeManagementTitle(title)) { return null } @@ -262,6 +263,13 @@ export function detectAgentStatusFromTitle(title: string): AgentStatus | null { return 'idle' } +/** + * Pure in `title`, so it is memoized on the title string: sidebar/tab selectors + * re-ask for the same unchanged titles on every store write. + */ +export const detectAgentStatusFromTitle: (title: string) => AgentStatus | null = + memoizeTitleClassification(computeAgentStatusFromTitle) + /** * True when a quarter-circle spinner frame is the only agent evidence a title carries. * Any TUI animates those glyphs, so they prove activity, not identity — callers that diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index a61e64464da..5ad37398d14 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -15,6 +15,7 @@ import { isUnsupportedWorktreeListZError } from './git-worktree-command-capabilities' import { gitCredentialPromptGuardEnv } from './git-credential-prompt-env' +import { parseGitRemoteFetchUrls } from './git-remote-url-index' import { GIT_HISTORY_COMMIT_FORMAT, parseGitHistoryLog } from './git-history-log-parser' import { githubPullRequestHeadLocalRef, @@ -214,6 +215,41 @@ describeBinaryCompatibility('real Git binary compatibility', () => { await expect(runGit(['merge-base', '--end-of-options', head, unrelated])).rejects.toBeDefined() }) + // Why pin this: Orca answers "which remote has this URL" from one `git remote -v` + // instead of one `git remote get-url` per remote. That is only equivalent if both + // commands report the same URL — the insteadOf-expanded first `remote..url`, + // which a raw config read does not produce — on every supported Git. + it('reports the same fetch URL from remote -v as from remote get-url', async () => { + await runGit(['config', 'url.git@example.invalid:.insteadOf', 'https://example.invalid/']) + await runGit(['remote', 'add', 'compat-single', 'https://example.invalid/a/repo.git']) + await runGit(['remote', 'add', 'compat-multi', 'https://example.invalid/b/repo.git']) + await runGit([ + 'config', + '--add', + 'remote.compat-multi.url', + 'https://example.invalid/b2/repo.git' + ]) + await runGit([ + 'config', + 'remote.compat-multi.pushurl', + 'https://push.example.invalid/b/repo.git' + ]) + try { + const fetchUrls = parseGitRemoteFetchUrls((await runGit(['remote', '-v'])).stdout) + for (const name of ['compat-single', 'compat-multi']) { + const getUrl = (await runGit(['remote', 'get-url', name])).stdout.trim() + expect(fetchUrls.get(name)).toBe(getUrl) + } + expect(fetchUrls.get('compat-single')).toBe('git@example.invalid:a/repo.git') + // A `pushurl` must not displace the fetch URL the scan compares against. + expect(fetchUrls.get('compat-multi')).toBe('git@example.invalid:b/repo.git') + } finally { + await runGit(['remote', 'remove', 'compat-single']) + await runGit(['remote', 'remove', 'compat-multi']) + await runGit(['config', '--unset-all', 'url.git@example.invalid:.insteadOf']) + } + }) + it('recognizes ref and merge-tree compatibility boundaries', async () => { const fetchHeadPath = join(repoPath, '.git', 'FETCH_HEAD') await writeFile(fetchHeadPath, 'sentinel\n') diff --git a/src/shared/git-configured-branch-target.test.ts b/src/shared/git-configured-branch-target.test.ts new file mode 100644 index 00000000000..a599a53bbb8 --- /dev/null +++ b/src/shared/git-configured-branch-target.test.ts @@ -0,0 +1,175 @@ +// Why: resolving a URL-valued `branch..remote` (or `remote.pushDefault`) to a +// remote name used to cost `git remote` plus one serial `git remote get-url` per remote. +// `hasConfiguredBranchPushTarget` resolves up to two of them, so a 58-remote repo paid +// up to 118 subprocesses for one question. These tests pin the count and result parity. + +import { describe, expect, it } from 'vitest' +import { + getConfiguredBranchRemoteUpstream, + hasConfiguredBranchPushTarget +} from './git-configured-branch-target' + +const BRANCH = 'imp/translation' +const FORK_URL = 'https://github.com/contributor/orca.git' +const UPSTREAM_URL = 'https://github.com/stablyai/orca.git' + +type RemoteRow = { name: string; fetchUrl: string; pushUrl?: string } + +type Fixture = { + remotes: readonly RemoteRow[] + config: Readonly> +} + +function makeRunner(fixture: Fixture): { + runGit: (args: string[]) => Promise<{ stdout: string }> + spawns: string[][] +} { + const spawns: string[][] = [] + const runGit = async (args: string[]): Promise<{ stdout: string }> => { + spawns.push(args) + if (args[0] === 'config' && args[1] === '--get') { + const value = fixture.config[args[2]] + if (value === undefined) { + throw Object.assign(new Error('config key is not set'), { code: 1 }) + } + return { stdout: `${value}\n` } + } + if (args[0] === 'remote' && args[1] === '-v') { + return { + stdout: fixture.remotes + .flatMap((remote) => [ + `${remote.name}\t${remote.fetchUrl} (fetch)`, + `${remote.name}\t${remote.pushUrl ?? remote.fetchUrl} (push)` + ]) + .join('\n') + } + } + if (args[0] === 'remote' && args.length === 1) { + return { stdout: `${fixture.remotes.map((remote) => remote.name).join('\n')}\n` } + } + if (args[0] === 'remote' && args[1] === 'get-url') { + const match = fixture.remotes.find((remote) => remote.name === args[2]) + if (!match) { + throw new Error(`No such remote ${args[2]}`) + } + return { stdout: `${match.fetchUrl}\n` } + } + throw new Error(`unexpected git command: ${args.join(' ')}`) + } + return { runGit, spawns } +} + +const fiftyEightRemotes: RemoteRow[] = [ + { name: 'origin', fetchUrl: UPSTREAM_URL }, + ...Array.from({ length: 56 }, (_, index) => ({ + name: `pr-user${index}-orca`, + fetchUrl: `https://github.com/user${index}/orca.git` + })), + { name: 'pr-contributor-orca', fetchUrl: FORK_URL } +] + +describe('hasConfiguredBranchPushTarget', () => { + it('resolves both URL-valued remotes from one remote table read at 58 remotes', async () => { + const { runGit, spawns } = makeRunner({ + remotes: fiftyEightRemotes, + config: { + [`branch.${BRANCH}.pushRemote`]: FORK_URL, + [`branch.${BRANCH}.remote`]: FORK_URL, + [`branch.${BRANCH}.merge`]: `refs/heads/${BRANCH}` + } + }) + + await expect(hasConfiguredBranchPushTarget(runGit, BRANCH)).resolves.toBe(true) + + // Both the push remote and the branch remote name the same URL, so one table read answers. + expect(spawns.filter((args) => args[0] === 'remote')).toEqual([['remote', '-v']]) + expect(spawns.filter((args) => args[1] === 'get-url')).toEqual([]) + }) + + it('keeps the not-set case false when no remote is configured', async () => { + const { runGit } = makeRunner({ remotes: fiftyEightRemotes, config: {} }) + await expect(hasConfiguredBranchPushTarget(runGit, BRANCH)).resolves.toBe(false) + }) + + it('keeps the URL itself as the remote name when nothing matches', async () => { + const { runGit } = makeRunner({ + remotes: [{ name: 'origin', fetchUrl: UPSTREAM_URL }], + config: { + [`branch.${BRANCH}.pushRemote`]: FORK_URL, + [`branch.${BRANCH}.remote`]: FORK_URL, + [`branch.${BRANCH}.merge`]: 'refs/heads/other' + } + }) + // Unchanged no-match fallback: both remotes stay the raw URL, so they still agree + // and the differently named merge branch is still pushable. + await expect(hasConfiguredBranchPushTarget(runGit, BRANCH)).resolves.toBe(true) + }) +}) + +describe('getConfiguredBranchRemoteUpstream', () => { + const remoteTrackingRefExists = async (): Promise => true + + it('picks the first remote holding a duplicated URL', async () => { + const { runGit, spawns } = makeRunner({ + remotes: [ + { name: 'origin', fetchUrl: UPSTREAM_URL }, + { name: 'fork-a', fetchUrl: FORK_URL }, + { name: 'fork-b', fetchUrl: FORK_URL } + ], + config: { + [`branch.${BRANCH}.remote`]: FORK_URL, + [`branch.${BRANCH}.merge`]: `refs/heads/${BRANCH}` + } + }) + + await expect( + getConfiguredBranchRemoteUpstream(runGit, BRANCH, remoteTrackingRefExists) + ).resolves.toEqual({ + upstreamName: `fork-a/${BRANCH}`, + remoteName: 'fork-a', + branchName: BRANCH, + isConfiguredUpstream: false + }) + expect(spawns.filter((args) => args[0] === 'remote')).toEqual([['remote', '-v']]) + }) + + it('ignores a push URL when fetch and push differ', async () => { + const { runGit } = makeRunner({ + remotes: [{ name: 'split', fetchUrl: UPSTREAM_URL, pushUrl: FORK_URL }], + config: { + [`branch.${BRANCH}.remote`]: FORK_URL, + [`branch.${BRANCH}.merge`]: `refs/heads/${BRANCH}` + } + }) + await expect( + getConfiguredBranchRemoteUpstream(runGit, BRANCH, remoteTrackingRefExists) + ).resolves.toBeNull() + }) + + it('returns null with no remotes at all', async () => { + const { runGit } = makeRunner({ + remotes: [], + config: { + [`branch.${BRANCH}.remote`]: FORK_URL, + [`branch.${BRANCH}.merge`]: `refs/heads/${BRANCH}` + } + }) + await expect( + getConfiguredBranchRemoteUpstream(runGit, BRANCH, remoteTrackingRefExists) + ).resolves.toBeNull() + }) + + it('keeps a plain named remote untouched', async () => { + const { runGit, spawns } = makeRunner({ + remotes: fiftyEightRemotes, + config: { + [`branch.${BRANCH}.remote`]: 'origin', + [`branch.${BRANCH}.merge`]: `refs/heads/${BRANCH}` + } + }) + await expect( + getConfiguredBranchRemoteUpstream(runGit, BRANCH, remoteTrackingRefExists) + ).resolves.toMatchObject({ remoteName: 'origin' }) + expect(spawns.filter((args) => args[0] === 'remote')).toEqual([]) + }) +}) diff --git a/src/shared/git-configured-branch-target.ts b/src/shared/git-configured-branch-target.ts index 5c28f023814..9faca91dce7 100644 --- a/src/shared/git-configured-branch-target.ts +++ b/src/shared/git-configured-branch-target.ts @@ -1,4 +1,5 @@ import { gitRefTargetsBranchOnRemote } from './git-remote-branch-name' +import { findGitRemoteNameByFetchUrl } from './git-remote-url-index' type GitCommandRunner = (args: string[]) => Promise<{ stdout: string }> @@ -25,30 +26,18 @@ function isUrlValuedRemote(remote: string): boolean { return /^[A-Za-z][A-Za-z0-9+.-]*:\/\//.test(remote) || /^[^@/:]+@[^:]+:.+/.test(remote) } +// `hasConfiguredBranchPushTarget` resolves up to two URL-valued remotes, so the old +// per-remote `get-url` scan cost up to 2 x (1 + remotes) subprocesses per call. async function findRemoteNameForUrl( runGit: GitCommandRunner, remoteUrl: string ): Promise { try { - const { stdout } = await runGit(['remote']) - const remotes = stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .filter(Boolean) - for (const remoteName of remotes) { - try { - const { stdout: urlStdout } = await runGit(['remote', 'get-url', remoteName]) - if (urlStdout.trim() === remoteUrl) { - return remoteName - } - } catch { - // Ignore a remote that disappeared or has no fetch URL. - } - } + const { stdout } = await runGit(['remote', '-v']) + return findGitRemoteNameByFetchUrl(stdout, (candidateUrl) => candidateUrl === remoteUrl) } catch { return null } - return null } export async function getConfiguredBranchRemoteUpstream( @@ -101,11 +90,14 @@ export async function hasConfiguredBranchPushTarget( const pushRemoteName = isUrlValuedRemote(remote) ? ((await findRemoteNameForUrl(runGit, remote)) ?? remote) : remote - const branchRemoteName = branchRemote - ? isUrlValuedRemote(branchRemote) - ? ((await findRemoteNameForUrl(runGit, branchRemote)) ?? branchRemote) - : branchRemote - : null + // The two usually name the same URL; resolving it twice reads the remote table twice. + const branchRemoteName = !branchRemote + ? null + : branchRemote === remote + ? pushRemoteName + : isUrlValuedRemote(branchRemote) + ? ((await findRemoteNameForUrl(runGit, branchRemote)) ?? branchRemote) + : branchRemote if (gitRefTargetsBranchOnRemote(baseRef, pushRemoteName, branchName)) { return false } diff --git a/src/shared/git-remote-url-index.test.ts b/src/shared/git-remote-url-index.test.ts new file mode 100644 index 00000000000..e798e136ae4 --- /dev/null +++ b/src/shared/git-remote-url-index.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from 'vitest' +import { + findGitRemoteNameByFetchUrl, + parseGitRemoteFetchUrls, + parseGitRemoteVerboseLine +} from './git-remote-url-index' + +const SSH_URL = 'git@github.com:contributor/orca.git' +const HTTPS_URL = 'https://github.com/contributor/orca.git' + +function verbose(rows: readonly (readonly [string, string])[]): string { + return rows.map(([name, url]) => `${name}\t${url}`).join('\n') +} + +describe('parseGitRemoteVerboseLine', () => { + it('reads the name, URL and direction', () => { + expect(parseGitRemoteVerboseLine(`origin\t${HTTPS_URL} (fetch)`)).toEqual({ + name: 'origin', + url: HTTPS_URL, + direction: 'fetch' + }) + }) + + it('keeps a URL that itself contains spaces and parentheses', () => { + const url = '/tmp/my repo (mirror)' + expect(parseGitRemoteVerboseLine(`local\t${url} (push)`)).toEqual({ + name: 'local', + url, + direction: 'push' + }) + }) + + it('rejects the URL-less row git prints for a pushurl-only remote', () => { + expect(parseGitRemoteVerboseLine('pushonly\t')).toBeNull() + expect(parseGitRemoteVerboseLine('not a remote row')).toBeNull() + }) +}) + +describe('parseGitRemoteFetchUrls', () => { + it('returns nothing for a repo with no remotes', () => { + expect([...parseGitRemoteFetchUrls('')]).toEqual([]) + }) + + it('keeps only fetch rows, in git remote order', () => { + const stdout = verbose([ + ['a', `${SSH_URL} (fetch)`], + ['a', `${SSH_URL} (push)`], + ['b', `${HTTPS_URL} (fetch)`], + ['b', 'https://github.com/contributor/other.git (push)'] + ]) + expect([...parseGitRemoteFetchUrls(stdout)]).toEqual([ + ['a', SSH_URL], + ['b', HTTPS_URL] + ]) + }) + + it('parses CRLF output', () => { + const stdout = `a\t${SSH_URL} (fetch)\r\na\t${SSH_URL} (push)\r\n` + expect([...parseGitRemoteFetchUrls(stdout)]).toEqual([['a', SSH_URL]]) + }) + + it('takes the first URL of a multi-URL remote, matching remote get-url', () => { + const stdout = verbose([ + ['multi', `${SSH_URL} (fetch)`], + ['multi', `${SSH_URL} (push)`], + ['multi', `${HTTPS_URL} (push)`] + ]) + expect(parseGitRemoteFetchUrls(stdout).get('multi')).toBe(SSH_URL) + }) + + it('scales to 58 remotes without losing order', () => { + const rows = Array.from({ length: 58 }, (_, index) => [ + `r${index}`, + `https://example.com/o${index}/repo.git` + ]) + const stdout = rows + .flatMap(([name, url]) => [`${name}\t${url} (fetch)`, `${name}\t${url} (push)`]) + .join('\n') + const parsed = [...parseGitRemoteFetchUrls(stdout)] + expect(parsed).toHaveLength(58) + expect(parsed[0]).toEqual(['r0', 'https://example.com/o0/repo.git']) + expect(parsed[57]).toEqual(['r57', 'https://example.com/o57/repo.git']) + }) +}) + +describe('findGitRemoteNameByFetchUrl', () => { + const stdout = verbose([ + ['origin', 'https://github.com/stablyai/orca.git (fetch)'], + ['origin', 'https://github.com/stablyai/orca.git (push)'], + ['first-fork', `${SSH_URL} (fetch)`], + ['first-fork', `${SSH_URL} (push)`], + ['second-fork', `${SSH_URL} (fetch)`], + ['second-fork', `${SSH_URL} (push)`] + ]) + + it('returns the first remote holding a duplicated URL', () => { + expect(findGitRemoteNameByFetchUrl(stdout, (url) => url === SSH_URL)).toBe('first-fork') + }) + + it('returns null when nothing matches', () => { + expect(findGitRemoteNameByFetchUrl(stdout, (url) => url === HTTPS_URL)).toBeNull() + }) + + it('ignores push URLs when fetch and push differ', () => { + const split = verbose([ + ['split', `${SSH_URL} (fetch)`], + ['split', `${HTTPS_URL} (push)`] + ]) + expect(findGitRemoteNameByFetchUrl(split, (url) => url === SSH_URL)).toBe('split') + expect(findGitRemoteNameByFetchUrl(split, (url) => url === HTTPS_URL)).toBeNull() + }) +}) diff --git a/src/shared/git-remote-url-index.ts b/src/shared/git-remote-url-index.ts new file mode 100644 index 00000000000..ec4f2b4a509 --- /dev/null +++ b/src/shared/git-remote-url-index.ts @@ -0,0 +1,64 @@ +// Why: "which remote has this URL?" was answered with one `git remote get-url` +// subprocess per remote, awaited serially -- 58 spawns on a repo with 58 remotes, +// on every push-target resolution. `git remote -v` answers for every remote from +// one child. +// +// `remote -v` is the faithful one-command form, not `config --get-regexp '^remote\.'`: +// both `remote -v` and `remote get-url` print the URL *after* `url..insteadOf` +// expansion and pick the first of several `remote..url` values, while raw config +// reads return the unexpanded value and the last of the multiple values. +// +// `remote -v` also predates `remote get-url` (2.7), so this lowers rather than raises +// the Git floor and needs no capability gate. + +import { iterateProcessOutputLines } from './process-output-field-scanner' + +export type GitRemoteVerboseEntry = { + name: string + url: string + direction: 'fetch' | 'push' +} + +// Greedy prefix so a URL containing spaces or parentheses keeps them. +const REMOTE_VERBOSE_URL_PATTERN = /^(.*) \((fetch|push)\)$/ + +/** Parse one `\t (fetch|push)` row. */ +export function parseGitRemoteVerboseLine(line: string): GitRemoteVerboseEntry | null { + const tabIndex = line.indexOf('\t') + if (tabIndex === -1) { + return null + } + const name = line.slice(0, tabIndex) + const match = REMOTE_VERBOSE_URL_PATTERN.exec(line.slice(tabIndex + 1).trim()) + return match ? { name, url: match[1], direction: match[2] as 'fetch' | 'push' } : null +} + +/** + * Fetch URL per remote in `git remote` order -- the value `git remote get-url ` + * prints. A remote configured with only a `pushurl` has no fetch row and is absent + * here; `get-url` echoed the remote's own name for it, which no caller can match. + */ +export function parseGitRemoteFetchUrls(stdout: string): Map { + const fetchUrls = new Map() + for (const line of iterateProcessOutputLines(stdout)) { + const parsed = parseGitRemoteVerboseLine(line) + // First wins: `get-url` without `--all` prints the first `remote..url`. + if (parsed?.direction === 'fetch' && !fetchUrls.has(parsed.name)) { + fetchUrls.set(parsed.name, parsed.url) + } + } + return fetchUrls +} + +/** First remote whose fetch URL matches, in the order the per-remote scan visited them. */ +export function findGitRemoteNameByFetchUrl( + stdout: string, + matchesUrl: (url: string) => boolean +): string | null { + for (const [name, url] of parseGitRemoteFetchUrls(stdout)) { + if (matchesUrl(url)) { + return name + } + } + return null +} diff --git a/src/shared/keybindings-parse-cache.test.ts b/src/shared/keybindings-parse-cache.test.ts new file mode 100644 index 00000000000..d2eb899b309 --- /dev/null +++ b/src/shared/keybindings-parse-cache.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from 'vitest' +import { KEYBINDING_DEFINITIONS } from './keybindings/definitions' +import { getDefaultBindings } from './keybindings/effective' +import { formatKeybindingList } from './keybindings/formatting' +import { normalizeKeyToken, parseKeybinding } from './keybindings/parser' + +describe('parseKeybinding memoization', () => { + it('reuses the parsed result for a repeated binding string', () => { + const first = parseKeybinding('Mod+Shift+K') + const second = parseKeybinding('Mod+Shift+K') + expect(first).not.toBeNull() + expect(second).toBe(first) + }) + + it('caches rejections without re-parsing', () => { + expect(parseKeybinding('K+J')).toBeNull() + expect(parseKeybinding('K+J')).toBeNull() + }) + + it('never hands out an entry a caller can corrupt', () => { + const parsed = parseKeybinding('Mod+P') + expect(Object.isFrozen(parsed)).toBe(true) + expect(parseKeybinding('Mod+P')?.key).toBe('P') + }) + + it('stays bounded and correct when fed far more strings than the cache holds', () => { + for (let index = 0; index < 2000; index++) { + expect(parseKeybinding(`Mod+Alt+F${(index % 24) + 1}`)?.key).toBe(`F${(index % 24) + 1}`) + } + // A cleared cache must still return the right answer, not a stale neighbour. + expect(parseKeybinding('Mod+Shift+K')?.key).toBe('K') + expect(parseKeybinding('DoubleTap+Shift')?.doubleTapModifier).toBe('Shift') + }) + + it('parses every distinct token form identically across repeat calls', () => { + const tokens = [ + ' ', + 'a', + '7', + 'f7', + '[', + '}', + '-', + '_', + '=', + '+', + ',', + '.', + '/', + '\\', + ';', + "'", + '`', + 'return', + 'esc', + 'spacebar', + 'pgup', + 'pgdn', + 'arrowleft', + 'left', + 'down', + 'backspace', + 'del', + 'ins', + 'numpadadd', + 'subtract', + 'nonsense', + '' + ] + for (const token of tokens) { + expect(normalizeKeyToken(token)).toBe(normalizeKeyToken(token)) + } + expect(normalizeKeyToken(' ')).toBe('Space') + expect(normalizeKeyToken('pgdn')).toBe('PageDown') + expect(normalizeKeyToken('subtract')).toBe('NumpadSubtract') + expect(normalizeKeyToken('nonsense')).toBe(null) + expect(normalizeKeyToken('')).toBe(null) + // Object.prototype keys must not leak through the token table. + expect(normalizeKeyToken('constructor')).toBe(null) + expect(normalizeKeyToken('__proto__')).toBe(null) + }) +}) + +describe('shortcut label output', () => { + it('formats every default binding identically on repeat calls, on both glyph platforms', () => { + for (const platform of ['darwin', 'win32'] as const) { + for (const definition of KEYBINDING_DEFINITIONS) { + const bindings = getDefaultBindings(definition, platform) + const label = formatKeybindingList(bindings, platform) + expect(formatKeybindingList(bindings, platform)).toBe(label) + expect(label.length).toBeGreaterThan(0) + } + } + }) +}) diff --git a/src/shared/keybindings/formatting.ts b/src/shared/keybindings/formatting.ts index 1c3c75ba8fd..c9e2b35ac43 100644 --- a/src/shared/keybindings/formatting.ts +++ b/src/shared/keybindings/formatting.ts @@ -90,38 +90,41 @@ export function findKeybindingActionsForBinding( ).map((definition) => definition.id) } +const KEY_TOKEN_LABELS: Record = { + BracketLeft: '[', + BracketRight: ']', + Minus: '-', + Underscore: '_', + Equal: '=', + Plus: '+', + ArrowLeft: '←', + ArrowRight: '→', + ArrowUp: '↑', + ArrowDown: '↓', + PageUp: 'PageUp', + PageDown: 'PageDown', + NumpadAdd: 'Numpad +', + NumpadSubtract: 'Numpad -', + Comma: ',', + Period: '.', + Slash: '/', + Backslash: '\\', + Semicolon: ';', + Quote: "'", + Backquote: '`', + Enter: 'Enter', + Backspace: 'Backspace', + Delete: 'Delete', + Insert: 'Insert', + Tab: 'Tab', + Escape: 'Esc', + Space: 'Space' +} + +const MAC_KEY_TOKEN_LABELS: Record = { ...KEY_TOKEN_LABELS, Backspace: '⌫' } + function formatKeyToken(token: string, isMac: boolean): string { - const labels: Record = { - BracketLeft: '[', - BracketRight: ']', - Minus: '-', - Underscore: '_', - Equal: '=', - Plus: '+', - ArrowLeft: '←', - ArrowRight: '→', - ArrowUp: '↑', - ArrowDown: '↓', - PageUp: 'PageUp', - PageDown: 'PageDown', - NumpadAdd: 'Numpad +', - NumpadSubtract: 'Numpad -', - Comma: ',', - Period: '.', - Slash: '/', - Backslash: '\\', - Semicolon: ';', - Quote: "'", - Backquote: '`', - Enter: 'Enter', - Backspace: isMac ? '⌫' : 'Backspace', - Delete: 'Delete', - Insert: 'Insert', - Tab: 'Tab', - Escape: 'Esc', - Space: 'Space' - } - return labels[token] ?? token + return (isMac ? MAC_KEY_TOKEN_LABELS : KEY_TOKEN_LABELS)[token] ?? token } export function findKeybindingConflicts( diff --git a/src/shared/keybindings/parser.ts b/src/shared/keybindings/parser.ts index 022aa4bec29..0c3180d9d00 100644 --- a/src/shared/keybindings/parser.ts +++ b/src/shared/keybindings/parser.ts @@ -16,31 +16,8 @@ export function hasModifier( return Boolean(input.shift ?? input.shiftKey) } -function isFunctionKeyToken(key: string): boolean { - return /^F([1-9]|1[0-9]|2[0-4])$/.test(key) -} - -export function normalizeKeyToken(token: string): string | null { - if (token === ' ') { - return 'Space' - } - const trimmed = token.trim() - if (!trimmed) { - return null - } - const upper = trimmed.toUpperCase() - if (upper.length === 1 && upper >= 'A' && upper <= 'Z') { - return upper - } - if (upper.length === 1 && upper >= '0' && upper <= '9') { - return upper - } - // Function keys F1–F24 (event.key/event.code report them verbatim, e.g. F7). - if (isFunctionKeyToken(upper)) { - return upper - } - - const simple: Record = { +const SIMPLE_KEY_TOKENS = new Map( + Object.entries({ '[': 'BracketLeft', ']': 'BracketRight', '{': 'BracketLeft', @@ -97,9 +74,34 @@ export function normalizeKeyToken(token: string): string | null { SEMICOLON: 'Semicolon', QUOTE: 'Quote', BACKQUOTE: 'Backquote' + }) +) + +function isFunctionKeyToken(key: string): boolean { + return /^F([1-9]|1[0-9]|2[0-4])$/.test(key) +} + +export function normalizeKeyToken(token: string): string | null { + if (token === ' ') { + return 'Space' + } + const trimmed = token.trim() + if (!trimmed) { + return null + } + const upper = trimmed.toUpperCase() + if (upper.length === 1 && upper >= 'A' && upper <= 'Z') { + return upper + } + if (upper.length === 1 && upper >= '0' && upper <= '9') { + return upper + } + // Function keys F1–F24 (event.key/event.code report them verbatim, e.g. F7). + if (isFunctionKeyToken(upper)) { + return upper } - return simple[upper] ?? null + return SIMPLE_KEY_TOKENS.get(upper) ?? null } export function parseModifierToken(rawPart: string): ModifierToken | null { @@ -177,7 +179,24 @@ export function parseDoubleTapKeybinding(rawParts: string[]): ParsedKeybinding | return parsed } +// Binding strings come from a fixed definition set plus user overrides, so the live set is tiny; the cap only guards a caller feeding arbitrary strings. +const PARSE_CACHE_LIMIT = 512 +const parseCache = new Map() + export function parseKeybinding(binding: string): ParsedKeybinding | null { + if (parseCache.has(binding)) { + return parseCache.get(binding) ?? null + } + const parsed = parseKeybindingUncached(binding) + if (parseCache.size >= PARSE_CACHE_LIMIT) { + parseCache.clear() + } + // Frozen so a caller can never corrupt the shared entry; every current caller spread-copies before changing a field. + parseCache.set(binding, parsed ? Object.freeze(parsed) : null) + return parsed +} + +function parseKeybindingUncached(binding: string): ParsedKeybinding | null { const rawParts = binding .split('+') .map((part) => part.trim()) diff --git a/src/shared/process-table-snapshot.test.ts b/src/shared/process-table-snapshot.test.ts index f773c46df09..5cf693dfdbf 100644 --- a/src/shared/process-table-snapshot.test.ts +++ b/src/shared/process-table-snapshot.test.ts @@ -15,6 +15,7 @@ import { parseProcessTableRows, parseStrictProcessTableRows, ProcessTableCaptureError, + PS_MAX_BUFFER_BYTES, resetProcessTableSnapshotForTests, type ProcessTableIndexStats } from './process-table-snapshot' @@ -261,7 +262,14 @@ describe('shared process-table capture', () => { expect(forks()).toBe(1) expect(strict).toEqual([ - { pid: 100, ppid: 1, pgid: 100, tpgid: 100, stat: 'Ss+', command: '/bin/zsh' } + { + pid: 100, + ppid: 1, + pgid: 100, + tpgid: 100, + stat: 'Ss+', + command: '/bin/zsh' + } ]) }) @@ -293,7 +301,12 @@ describe('parseProcessTableRows', () => { ) expect(rows).toEqual([ { pid: 501, ppid: 1, stat: 'S', command: '/bin/zsh' }, - { pid: 600, ppid: 501, stat: 'S+', command: 'node /path/bin/codex --flag' } + { + pid: 600, + ppid: 501, + stat: 'S+', + command: 'node /path/bin/codex --flag' + } ]) }) @@ -320,11 +333,46 @@ describe('parseStrictProcessTableRows', () => { stat: 'I', command: '[pool_workqueue_release]' }, - { pid: 4, ppid: 2, pgid: 0, tpgid: -1, stat: 'I', command: '[kworker/R-rcu_g]' }, - { pid: 5, ppid: 2, pgid: 0, tpgid: -1, stat: 'I', command: '[kworker/R-sync_wq]' }, - { pid: 6, ppid: 2, pgid: 0, tpgid: -1, stat: 'I', command: '[kworker/R-slub_]' }, - { pid: 100, ppid: 1, pgid: 100, tpgid: 100, stat: 'Ss+', command: '/bin/bash -l' }, - { pid: 101, ppid: 100, pgid: 101, tpgid: 101, stat: 'S+', command: 'node /opt/codex' } + { + pid: 4, + ppid: 2, + pgid: 0, + tpgid: -1, + stat: 'I', + command: '[kworker/R-rcu_g]' + }, + { + pid: 5, + ppid: 2, + pgid: 0, + tpgid: -1, + stat: 'I', + command: '[kworker/R-sync_wq]' + }, + { + pid: 6, + ppid: 2, + pgid: 0, + tpgid: -1, + stat: 'I', + command: '[kworker/R-slub_]' + }, + { + pid: 100, + ppid: 1, + pgid: 100, + tpgid: 100, + stat: 'Ss+', + command: '/bin/bash -l' + }, + { + pid: 101, + ppid: 100, + pgid: 101, + tpgid: 101, + stat: 'S+', + command: 'node /opt/codex' + } ]) }) @@ -334,7 +382,14 @@ describe('parseStrictProcessTableRows', () => { ' PID PPID PGID TPGID STAT COMMAND\r\n 100 1 100 101 Ss /bin/zsh -l\r\n 101 100 101 101 S+ node /opt/codex --flag value\r\n' ) ).toEqual([ - { pid: 100, ppid: 1, pgid: 100, tpgid: 101, stat: 'Ss', command: '/bin/zsh -l' }, + { + pid: 100, + ppid: 1, + pgid: 100, + tpgid: 101, + stat: 'Ss', + command: '/bin/zsh -l' + }, { pid: 101, ppid: 100, @@ -348,10 +403,24 @@ describe('parseStrictProcessTableRows', () => { it('accepts no-controlling-tty sentinels for later unverifiable classification', () => { expect(parseStrictProcessTableRows('100 1 100 0 Ss /bin/zsh')).toEqual([ - { pid: 100, ppid: 1, pgid: 100, tpgid: 0, stat: 'Ss', command: '/bin/zsh' } + { + pid: 100, + ppid: 1, + pgid: 100, + tpgid: 0, + stat: 'Ss', + command: '/bin/zsh' + } ]) expect(parseStrictProcessTableRows('100 1 100 -1 Ss /bin/zsh')).toEqual([ - { pid: 100, ppid: 1, pgid: 100, tpgid: -1, stat: 'Ss', command: '/bin/zsh' } + { + pid: 100, + ppid: 1, + pgid: 100, + tpgid: -1, + stat: 'Ss', + command: '/bin/zsh' + } ]) }) @@ -416,7 +485,11 @@ describe('getProcessTableIndex', () => { it('keeps the memo out of measured builds so a cache hit cannot satisfy a perf gate', () => { const rows = parseProcessTableRows('100 1 Ss bash') - const stats: ProcessTableIndexStats = { indexBuilds: 0, rowVisits: 0, indexLookups: 0 } + const stats: ProcessTableIndexStats = { + indexBuilds: 0, + rowVisits: 0, + indexLookups: 0 + } const memoized = getProcessTableIndex(rows) const measured = buildProcessTableIndex(rows, stats) @@ -428,3 +501,98 @@ describe('getProcessTableIndex', () => { expect(getProcessTableIndex(rows)).toBe(memoized) }) }) + +describe('process-table capture completeness', () => { + const NODE_DEFAULT_MAX_BUFFER_BYTES = 1024 * 1024 + + beforeEach(() => { + execFileMock.mockReset() + resetProcessTableSnapshotForTests() + }) + + /** + * Emulate Node's own execFile buffering: over `maxBuffer` it kills the child and + * calls back with ERR_CHILD_PROCESS_STDIO_MAXBUFFER plus the truncated bytes. A + * mock that always resolves would hide the very ceiling under test. + */ + function mockPsWithNodeBufferSemantics(stdout: string): void { + execFileMock.mockImplementation( + (_command: string, _args: string[], options: unknown, callback: unknown) => { + const done = callback as (err: unknown, result: { stdout: string; stderr: string }) => void + const maxBuffer = + (options as { maxBuffer?: number })?.maxBuffer ?? NODE_DEFAULT_MAX_BUFFER_BYTES + if (Buffer.byteLength(stdout, 'utf-8') > maxBuffer) { + const error = Object.assign(new Error('stdout maxBuffer length exceeded'), { + code: 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER' + }) + done(error, { stdout: stdout.slice(0, maxBuffer), stderr: '' }) + return + } + done(null, { stdout, stderr: '' }) + } + ) + } + + function busyHostTable(processCount: number): string { + // ~285 bytes/row, so 4k processes clears 1MB the way a real busy host does. + const argv = `/usr/bin/node ${'--inspect-brk-and-a-long-flag '.repeat(8)}server.js` + return `${Array.from( + { length: processCount }, + (_, index) => `${1000 + index} 1 ${1000 + index} ${1000 + index} S+ ${argv}` + ).join('\n')}\n` + } + + it('reads a busy host whose table overflows execFile 1MB default', async () => { + const table = busyHostTable(4_000) + expect(Buffer.byteLength(table, 'utf-8')).toBeGreaterThan(NODE_DEFAULT_MAX_BUFFER_BYTES) + mockPsWithNodeBufferSemantics(table) + + // Without an explicit maxBuffer this rejects, so the whole subsystem degrades + // to "unverifiable" on every capture for as long as the host stays busy. + await expect(getProcessTableSnapshot()).resolves.toHaveLength(4_000) + }) + + it('reports a truncated capture as unreadable rather than a silently short table', async () => { + const table = busyHostTable(200) + const truncated = table + 'x'.repeat(PS_MAX_BUFFER_BYTES - table.length) + // A capture that stopped at the ceiling but still resolved: the lenient parser + // drops the cut tail without complaint, so 200 of N processes would read as + // the complete list — a false "no agent" the execution boundary forbids. + expect(parseProcessTableRows(truncated)).toHaveLength(200) + execFileMock.mockImplementation( + (_command: string, _args: string[], _options: unknown, callback: unknown) => { + ;(callback as (err: unknown, result: { stdout: string; stderr: string }) => void)(null, { + stdout: truncated, + stderr: '' + }) + } + ) + + await expect(getProcessTableSnapshot()).rejects.toThrow(ProcessTableCaptureError) + await expect(getProcessTableSnapshot()).rejects.toThrow('capture_truncated') + }) + + it('names a buffer-ceiling rejection as truncation on both views', async () => { + mockPsWithNodeBufferSemantics('x'.repeat(PS_MAX_BUFFER_BYTES + 1)) + + await expect(getProcessTableSnapshot()).rejects.toThrow('capture_truncated') + resetProcessTableSnapshotForTests() + await expect(getStrictProcessTableSnapshot()).rejects.toThrow('capture_truncated') + }) + + it('reports an empty capture as unreadable rather than an empty process table', async () => { + // The lenient view used to answer [] here: zero processes on a machine that is + // by definition running at least `ps` itself. + execFileMock.mockImplementation( + (_command: string, _args: string[], _options: unknown, callback: unknown) => { + ;(callback as (err: unknown, result: { stdout: string; stderr: string }) => void)(null, { + stdout: ' \n', + stderr: '' + }) + } + ) + + await expect(getProcessTableSnapshot()).rejects.toThrow(ProcessTableCaptureError) + await expect(getProcessTableSnapshot()).rejects.toThrow('empty_capture') + }) +}) diff --git a/src/shared/process-table-snapshot.ts b/src/shared/process-table-snapshot.ts index 2d65fc3f324..8340ae37a1b 100644 --- a/src/shared/process-table-snapshot.ts +++ b/src/shared/process-table-snapshot.ts @@ -10,6 +10,11 @@ const execFile = promisify(execFileCb) /** Columns used by the evidence reader. Keep command last so its spaces survive parsing. */ export const PS_ARGS = ['-axo', 'pid=,ppid=,pgid=,tpgid=,stat=,command='] as const const PS_TIMEOUT_MS = 3000 +// Why: execFile's 1MB default leaves ~3x headroom (326KB / 1,460 processes, and +// a single 5KB argv row is ordinary), so a busy host overflows it and then EVERY +// capture fails — a readable process table degrading into permanent +// "unverifiable". Matches the sibling reader in pty-descendant-termination.ts. +export const PS_MAX_BUFFER_BYTES = 32 * 1024 * 1024 // Why: 500ms is below the active cadence poll's minimum inter-poll gap (~675ms // = 750ms less jitter), so a cadence-driven pane never reuses a snapshot older @@ -124,28 +129,36 @@ export type ProcessTableIndexStats = { indexLookups: number } -export type ProcessTableIndex = { - rows: readonly ProcessTableRow[] - byPid: ReadonlyMap - childrenByPpid: ReadonlyMap +/** The parent/child fields every process-table row shape shares. */ +export type ProcessIdentityRow = { pid: number; ppid: number } + +export type ProcessTableIndexOf = { + rows: readonly Row[] + byPid: ReadonlyMap + childrenByPpid: ReadonlyMap stats?: ProcessTableIndexStats } +export type ProcessTableIndex = ProcessTableIndexOf + /** * Build the correlation indexes in one linear pass over a capture. Only the * indexes a resolver actually reads are materialized: group indexes would cost * two more maps plus a per-row array allocation on every capture, and foreground * membership is derived from each row's own `pgid` against the root's `tpgid`. + * + * Generic over the row shape so the Windows snapshot (`pid`/`ppid`/`name`/ + * `command`) shares this pass rather than carrying a parallel one. */ -export function buildProcessTableIndex( - rows: readonly ProcessTableRow[], +export function buildProcessTableIndex( + rows: readonly Row[], stats?: ProcessTableIndexStats -): ProcessTableIndex { +): ProcessTableIndexOf { if (stats) { stats.indexBuilds += 1 } - const byPid = new Map() - const childrenByPpid = new Map() + const byPid = new Map() + const childrenByPpid = new Map() for (const row of rows) { if (stats) { stats.rowVisits += 1 @@ -161,6 +174,29 @@ export function buildProcessTableIndex( return { rows, byPid, childrenByPpid, stats } } +/** + * Depth-first descendants of `rootPid`, deepest-last, off a prebuilt index. + * + * Each row is copied with its depth, so callers may not mutate the index's rows + * through the result. Ordering matches a per-call `childrenByPpid` walk exactly: + * children keep capture order and the stack pops last-pushed first. + */ +export function collectDescendantsFromIndex( + index: ProcessTableIndexOf, + rootPid: number +): (Row & { depth: number })[] { + const descendants: (Row & { depth: number })[] = [] + const stack = (index.childrenByPpid.get(rootPid) ?? []).map((row) => ({ row, depth: 1 })) + while (stack.length > 0) { + const { row, depth } = stack.pop()! + descendants.push({ ...row, depth }) + for (const child of index.childrenByPpid.get(row.pid) ?? []) { + stack.push({ row: child, depth: depth + 1 }) + } + } + return descendants +} + /** * Rank a descendant row as a foreground candidate: a `+` (foreground process * group) row always outranks a background one, then the deepest wins. @@ -169,9 +205,9 @@ export function scoreForegroundCandidateRow(row: ProcessTableRow & { depth: numb return (row.stat.includes('+') ? 10_000 : 0) + row.depth } -export function lookupProcessTableIndex( - index: ProcessTableIndex, - lookup: (index: ProcessTableIndex) => T, +export function lookupProcessTableIndex( + index: ProcessTableIndexOf, + lookup: (index: ProcessTableIndexOf) => T, stats = index.stats ): T { if (stats) { @@ -180,7 +216,9 @@ export function lookupProcessTableIndex( return lookup(index) } -const processTableIndexes = new WeakMap() +// Keyed by array identity, which also pins the row shape the entry was built +// for, so the one cast below cannot hand a caller another row type's index. +const processTableIndexes = new WeakMap() /** * Memoize one index per snapshot identity, so the panes that share a TTL-cached @@ -195,8 +233,10 @@ const processTableIndexes = new WeakMap( + rows: readonly Row[] +): ProcessTableIndexOf { + const cached = processTableIndexes.get(rows) as ProcessTableIndexOf | undefined if (cached) { return cached } @@ -353,13 +393,44 @@ function createProcessTableCapture(stdout: string): ProcessTableCapture { } } +/** + * Reject a capture that cannot be a whole process table. + * + * Why this is not redundant with the buffer ceiling: `parseProcessTableRows` + * drops unparseable lines, so a short capture reads as a *complete* table whose + * missing processes simply are not running — a false "no agent"/"no children", + * i.e. the `unverifiable` -> `exited` collapse the execution boundary forbids. + * Only the strict view would notice today; the lenient view would not. A capture + * that stopped at the ceiling, or that carries no rows at all, is unreadable + * evidence and must fail loudly on both views. + */ +function assertWholeCapture(stdout: string): string { + if (Buffer.byteLength(stdout, 'utf-8') >= PS_MAX_BUFFER_BYTES) { + throw new ProcessTableCaptureError('capture_truncated') + } + if (!/\S/.test(stdout)) { + throw new ProcessTableCaptureError('empty_capture') + } + return stdout +} + const processTableReader = createProcessTableSnapshotReader({ runPs: async () => { - const { stdout } = await execFile('ps', [...PS_ARGS], { - encoding: 'utf-8', - timeout: PS_TIMEOUT_MS - }) - return createProcessTableCapture(stdout) + let stdout: string + try { + ;({ stdout } = await execFile('ps', [...PS_ARGS], { + encoding: 'utf-8', + timeout: PS_TIMEOUT_MS, + maxBuffer: PS_MAX_BUFFER_BYTES + })) + } catch (error) { + // A ceiling hit is truncation, not absence: name it in the domain vocabulary. + if ((error as { code?: unknown } | null)?.code === 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER') { + throw new ProcessTableCaptureError('capture_truncated') + } + throw error + } + return createProcessTableCapture(assertWholeCapture(stdout)) }, now: () => Date.now() }) diff --git a/src/shared/terminal-title-agent-type.ts b/src/shared/terminal-title-agent-type.ts index 4a078fde877..cdbce788806 100644 --- a/src/shared/terminal-title-agent-type.ts +++ b/src/shared/terminal-title-agent-type.ts @@ -10,6 +10,7 @@ import { getPiCompatibleSyntheticAgentLabel, isLegacyPiCompatibleTitle } from './pi-compatible-synthetic-title' +import { memoizeTitleClassification } from './terminal-title-classification-memo' import type { TuiAgent } from './tui-agent' export const CLAUDE_IDLE = '\u2733' // ✳ (eight-spoked asterisk — Claude Code idle prefix) @@ -84,7 +85,7 @@ export function isPiAgentTitle(title: string): boolean { * Used to scope prompt-cache-timer behavior to Claude sessions only — other * agents have different (or no) caching semantics. */ -export function isClaudeAgent(title: string): boolean { +function computeIsClaudeAgent(title: string): boolean { if (!title || isClaudeManagementTitle(title) || isOpenCodeNativeTitle(title)) { return false } @@ -121,11 +122,15 @@ export function isClaudeAgent(title: string): boolean { return false } +/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ +export const isClaudeAgent: (title: string) => boolean = + memoizeTitleClassification(computeIsClaudeAgent) + export function isClaudeManagementTitle(title: string): boolean { return CLAUDE_MANAGEMENT_TITLE_RE.test(title) } -export function getAgentLabel(title: string): string | null { +function computeAgentLabel(title: string): string | null { if (isClaudeManagementTitle(title)) { return null } @@ -235,6 +240,10 @@ const TITLE_LABEL_TO_AGENT: Partial> = { OMP: 'omp' } +/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ +export const getAgentLabel: (title: string) => string | null = + memoizeTitleClassification(computeAgentLabel) + function hasGenericClaudeStatusPrefix(title: string): boolean { return ( containsAgentSpinnerGlyph(title) || @@ -266,10 +275,14 @@ export function resolveTerminalTitleAgentType(title: string): TuiAgent | null { * that something is running, not proof the agent is Claude — so a task or * worktree title cannot become Claude without an explicit "Claude Code" name. */ -export function resolveExplicitTerminalTitleAgentType(title: string): TuiAgent | null { +function computeExplicitTerminalTitleAgentType(title: string): TuiAgent | null { const titleAgent = resolveTerminalTitleAgentType(title) if (isGenericClaudeStatusClaim(title, titleAgent)) { return null } return titleAgent } + +/** Pure in `title` — memoized so repeated selector reads skip the regex ladder. */ +export const resolveExplicitTerminalTitleAgentType: (title: string) => TuiAgent | null = + memoizeTitleClassification(computeExplicitTerminalTitleAgentType) diff --git a/src/shared/terminal-title-classification-corpus.test.ts b/src/shared/terminal-title-classification-corpus.test.ts new file mode 100644 index 00000000000..ae41d451b20 --- /dev/null +++ b/src/shared/terminal-title-classification-corpus.test.ts @@ -0,0 +1,293 @@ +import { describe, expect, it } from 'vitest' +import { isGeminiTerminalTitle } from './agent-title-core' +import { getAgentLabel, isClaudeAgent } from './agent-title-identity' +import { detectAgentStatusFromTitle } from './agent-title-status' +import { TERMINAL_TITLE_CLASSIFICATION_CORPUS } from './terminal-title-classification-corpus' +import { + getAgentLabel as getExplicitAgentLabel, + isClaudeAgent as isExplicitClaudeAgent, + resolveExplicitTerminalTitleAgentType, + resolveTerminalTitleAgentType +} from './terminal-title-agent-type' + +/** + * Pins the exact verdict every title classifier returns for a realistic corpus. + * + * Why: these classifiers are now memoized on the title string, and a caching bug + * here would repaint a pane under the wrong agent. This table is the proof that + * memoization is transparent — it was generated from the pre-memo implementation + * and must keep matching byte-for-byte. + */ +type PinnedRow = [ + title: string, + status: string | null, + label: string | null, + claude: boolean, + gemini: boolean, + explicitLabel: string | null, + explicitClaude: boolean, + titleAgent: string | null, + explicitTitleAgent: string | null +] + +const PINNED_CLASSIFICATIONS: readonly PinnedRow[] = [ + ['', null, null, false, false, null, false, null, null], + ['zsh', null, null, false, false, null, false, null, null], + ['bash', null, null, false, false, null, false, null, null], + ['nwparker@mac: ~/orca', null, null, false, false, null, false, null, null], + ['npm run dev', null, null, false, false, null, false, null, null], + ['opencode-blinker', null, null, false, false, null, false, null, null], + [ + 'openclaude', + 'idle', + 'OpenClaude', + false, + false, + 'OpenClaude', + false, + 'openclaude', + 'openclaude' + ], + ['openclaude-scratch', null, null, false, false, null, false, null, null], + ['claude-scratch', null, null, false, false, null, false, null, null], + ['~/codex/ready', null, null, false, false, null, false, null, null], + ['review-14600-codex', null, null, false, false, null, false, null, null], + ['timestamp ready', null, null, false, false, null, false, null, null], + ['android build running', null, null, false, false, null, false, null, null], + ['~/hermes/working', null, null, false, false, null, false, null, null], + ['C:\\tools\\codex\\run', null, null, false, false, null, false, null, null], + ['/usr/local/bin/claude/notes', null, null, false, false, null, false, null, null], + ['agy-nightly', null, null, false, false, null, false, null, null], + ['codex.exe', 'idle', 'Codex', false, false, 'Codex', false, 'codex', 'codex'], + [ + 'openclaude.cmd', + 'idle', + 'OpenClaude', + false, + false, + 'OpenClaude', + false, + 'openclaude', + 'openclaude' + ], + [ + 'claude.bat working', + 'working', + 'Claude Code', + true, + false, + 'Claude Code', + true, + 'claude', + 'claude' + ], + ['aider.ps1 ready', 'idle', 'Aider', false, false, 'Aider', false, 'aider', 'aider'], + [ + 'copilot.exe - action required', + 'permission', + 'GitHub Copilot', + false, + false, + 'GitHub Copilot', + false, + 'copilot', + 'copilot' + ], + ['droid.exe', null, null, false, false, null, false, null, null], + ['\u2733', 'idle', 'Claude Code', true, false, 'Claude Code', true, 'claude', null], + [ + '\u2733 Claude Code', + 'idle', + 'Claude Code', + true, + false, + 'Claude Code', + true, + 'claude', + 'claude' + ], + ['\u2733 ready', 'idle', 'Claude Code', true, false, 'Claude Code', true, 'claude', null], + ['. building the parser', null, 'Claude Code', true, false, 'Claude Code', true, 'claude', null], + ['* done', null, 'Claude Code', true, false, 'Claude Code', true, 'claude', null], + ['Claude Code', 'idle', 'Claude Code', true, false, 'Claude Code', true, 'claude', 'claude'], + [ + 'claude - action required', + 'permission', + 'Claude Code', + true, + false, + 'Claude Code', + true, + 'claude', + 'claude' + ], + ['Claude ready', 'idle', 'Claude Code', true, false, 'Claude Code', true, 'claude', 'claude'], + ['claude agents', null, null, false, false, null, false, null, null], + ['"/usr/local/bin/claude" agents', null, null, false, false, null, false, null, null], + [ + '\u280b Claude Code', + 'working', + 'Claude Code', + true, + false, + 'Claude Code', + true, + 'claude', + 'claude' + ], + [ + '\u2809 Codex \u2014 refactoring', + 'working', + 'Codex', + true, + false, + 'Codex', + true, + 'codex', + 'codex' + ], + ['\u25d0 working', 'working', 'Claude Code', true, false, 'Claude Code', true, 'claude', null], + ['\u25d3 Grok', 'working', 'Grok', true, false, 'Grok', true, 'grok', 'grok'], + ['\u280b Cursor Agent', 'working', 'Cursor', false, false, 'Cursor', false, 'cursor', 'cursor'], + ['\u280b Droid', 'working', 'Droid', true, false, 'Droid', true, 'droid', 'droid'], + ['\u280b Hermes', 'working', 'Hermes', true, false, 'Hermes', true, 'hermes', 'hermes'], + ['\u2726 gemini', 'working', 'Gemini CLI', false, true, 'Gemini CLI', false, 'gemini', 'gemini'], + [ + '\u23f2 Gemini CLI', + 'working', + 'Gemini CLI', + false, + true, + 'Gemini CLI', + false, + 'gemini', + 'gemini' + ], + ['\u25c7 Gemini CLI', 'idle', 'Gemini CLI', false, true, 'Gemini CLI', false, 'gemini', 'gemini'], + [ + '\u270b Gemini CLI', + 'permission', + 'Gemini CLI', + false, + true, + 'Gemini CLI', + false, + 'gemini', + 'gemini' + ], + ['gemini', 'idle', 'Gemini CLI', false, true, 'Gemini CLI', false, 'gemini', 'gemini'], + [ + 'antigravity gemini 3 pro', + 'idle', + 'Antigravity', + false, + false, + 'Antigravity', + false, + 'antigravity', + 'antigravity' + ], + [ + 'agy - gemini 2 flash', + 'idle', + 'Antigravity', + false, + false, + 'Antigravity', + false, + 'antigravity', + 'antigravity' + ], + ['codex working', 'working', 'Codex', false, false, 'Codex', false, 'codex', 'codex'], + ['codex ready', 'idle', 'Codex', false, false, 'Codex', false, 'codex', 'codex'], + [ + 'copilot waiting', + 'permission', + 'GitHub Copilot', + false, + false, + 'GitHub Copilot', + false, + 'copilot', + 'copilot' + ], + ['devin thinking', 'working', 'Devin', false, false, 'Devin', false, 'devin', 'devin'], + ['mimo idle', 'idle', 'MiMo Code', false, false, 'MiMo Code', false, 'mimo-code', 'mimo-code'], + ['aider running', 'working', 'Aider', false, false, 'Aider', false, 'aider', 'aider'], + ['grok done', 'idle', 'Grok', false, false, 'Grok', false, 'grok', 'grok'], + ['opencode ready', 'idle', 'OpenCode', false, false, 'OpenCode', false, 'opencode', 'opencode'], + ['hermes ready', 'idle', 'Hermes', false, false, 'Hermes', false, 'hermes', 'hermes'], + ['droid ready', 'idle', 'Droid', false, false, 'Droid', false, 'droid', 'droid'], + ['cursor agent', null, 'Cursor', false, false, 'Cursor', false, 'cursor', 'cursor'], + ['cursor ready', 'idle', 'Cursor', false, false, 'Cursor', false, 'cursor', 'cursor'], + [ + 'cursor - action required', + 'permission', + 'Cursor', + false, + false, + 'Cursor', + false, + 'cursor', + 'cursor' + ], + ['cursor position reset', 'idle', null, false, false, null, false, null, null], + ['\u03c0 > session - ~/orca', 'idle', 'Pi', false, false, 'Pi', false, 'pi', 'pi'], + ['\u03c0 ! blocked-session', 'permission', 'Pi', false, false, 'Pi', false, 'pi', 'pi'], + ['\u280b \u03c0 - session - ~/orca', 'working', 'Pi', true, false, 'Pi', true, 'pi', 'pi'], + ['zsh | \u280b Codex', 'working', 'Codex', true, false, 'Codex', true, 'codex', 'codex'], + ['tmux | claude - action required', 'permission', null, false, false, null, false, null, null], + [ + 'ssh host | opencode ready', + 'idle', + 'OpenCode', + false, + false, + 'OpenCode', + false, + 'opencode', + 'opencode' + ] +] + +describe('terminal title classification', () => { + it('covers every corpus title exactly once', () => { + expect(PINNED_CLASSIFICATIONS.map(([title]) => title)).toEqual([ + ...TERMINAL_TITLE_CLASSIFICATION_CORPUS + ]) + }) + + it.each(PINNED_CLASSIFICATIONS)( + 'classifies %j identically', + ( + title, + status, + label, + claude, + gemini, + explicitLabel, + explicitClaude, + titleAgent, + explicitTitleAgent + ) => { + expect(detectAgentStatusFromTitle(title)).toBe(status) + expect(getAgentLabel(title)).toBe(label) + expect(isClaudeAgent(title)).toBe(claude) + expect(isGeminiTerminalTitle(title)).toBe(gemini) + expect(getExplicitAgentLabel(title)).toBe(explicitLabel) + expect(isExplicitClaudeAgent(title)).toBe(explicitClaude) + expect(resolveTerminalTitleAgentType(title)).toBe(titleAgent) + expect(resolveExplicitTerminalTitleAgentType(title)).toBe(explicitTitleAgent) + } + ) + + it('returns the same verdict on the second read of every title', () => { + for (const title of TERMINAL_TITLE_CLASSIFICATION_CORPUS) { + expect(detectAgentStatusFromTitle(title)).toBe(detectAgentStatusFromTitle(title)) + expect(getAgentLabel(title)).toBe(getAgentLabel(title)) + expect(resolveExplicitTerminalTitleAgentType(title)).toBe( + resolveExplicitTerminalTitleAgentType(title) + ) + } + }) +}) diff --git a/src/shared/terminal-title-classification-corpus.ts b/src/shared/terminal-title-classification-corpus.ts new file mode 100644 index 00000000000..e1eb6e833a7 --- /dev/null +++ b/src/shared/terminal-title-classification-corpus.ts @@ -0,0 +1,86 @@ +/** + * Realistic terminal-title corpus for pinning agent classification. + * + * Why a shared const: the corpus is the contract the memoized classifiers must + * reproduce byte-for-byte, so the pinning test and the memo regression test + * read the same titles. + */ +export const TERMINAL_TITLE_CLASSIFICATION_CORPUS: readonly string[] = [ + // Plain shell / directory titles — must classify as nothing. + '', + 'zsh', + 'bash', + 'nwparker@mac: ~/orca', + 'npm run dev', + // Boundary-guard cases from agent-name-token-match.ts's header comment. + 'opencode-blinker', + 'openclaude', + 'openclaude-scratch', + 'claude-scratch', + '~/codex/ready', + 'review-14600-codex', + 'timestamp ready', + 'android build running', + '~/hermes/working', + 'C:\\tools\\codex\\run', + '/usr/local/bin/claude/notes', + 'agy-nightly', + // Windows launcher suffixes. + 'codex.exe', + 'openclaude.cmd', + 'claude.bat working', + 'aider.ps1 ready', + 'copilot.exe - action required', + 'droid.exe', + // Claude Code prefixes and identity frames. + '\u2733', + '\u2733 Claude Code', + '\u2733 ready', + '. building the parser', + '* done', + 'Claude Code', + 'claude - action required', + 'Claude ready', + 'claude agents', + '"/usr/local/bin/claude" agents', + // Leading spinner glyphs (braille + quarter circle). + '\u280b Claude Code', + '\u2809 Codex \u2014 refactoring', + '\u25d0 working', + '\u25d3 Grok', + '\u280b Cursor Agent', + '\u280b Droid', + '\u280b Hermes', + // Gemini glyph vocabulary. + '\u2726 gemini', + '\u23f2 Gemini CLI', + '\u25c7 Gemini CLI', + '\u270b Gemini CLI', + 'gemini', + 'antigravity gemini 3 pro', + 'agy - gemini 2 flash', + // Named agents with status words. + 'codex working', + 'codex ready', + 'copilot waiting', + 'devin thinking', + 'mimo idle', + 'aider running', + 'grok done', + 'opencode ready', + 'hermes ready', + 'droid ready', + // Cursor's closed identity set. + 'cursor agent', + 'cursor ready', + 'cursor - action required', + 'cursor position reset', + // Pi / OMP compatible titles. + '\u03c0 > session - ~/orca', + '\u03c0 ! blocked-session', + '\u280b \u03c0 - session - ~/orca', + // Wrapper/multiplexer prefixes. + 'zsh | \u280b Codex', + 'tmux | claude - action required', + 'ssh host | opencode ready' +] diff --git a/src/shared/terminal-title-classification-memo.test.ts b/src/shared/terminal-title-classification-memo.test.ts new file mode 100644 index 00000000000..e2606a58291 --- /dev/null +++ b/src/shared/terminal-title-classification-memo.test.ts @@ -0,0 +1,98 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import * as AgentNameTokenMatchModule from './agent-name-token-match' +import { getAgentLabel } from './agent-title-identity' +import { detectAgentStatusFromTitle } from './agent-title-status' +import { memoizeTitleClassification } from './terminal-title-classification-memo' +import { resolveExplicitTerminalTitleAgentType } from './terminal-title-agent-type' + +// Why a module mock: `titleHasAgentName` is the leaf regex test every title +// classifier funnels into, so counting its invocations is the direct measure of +// what one store write costs when no title has changed. +vi.mock('./agent-name-token-match', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, titleHasAgentName: vi.fn(actual.titleHasAgentName) } +}) + +const classifierCalls = vi.mocked(AgentNameTokenMatchModule.titleHasAgentName) + +// Titles a real sidebar holds steady while unrelated agent-status writes churn. +const UNCHANGED_TITLES = [ + 'codex working', + 'opencode-blinker', + 'zsh', + '✳ Claude Code', + 'copilot.exe - action required', + 'gemini', + 'cursor agent' +] +const STORE_WRITES = 50 + +function classifyEveryTitle(): void { + for (const title of UNCHANGED_TITLES) { + getAgentLabel(title) + detectAgentStatusFromTitle(title) + resolveExplicitTerminalTitleAgentType(title) + } +} + +describe('terminal title classification memo', () => { + beforeEach(() => { + classifierCalls.mockClear() + }) + + it('classifies each distinct title once across repeated store writes', () => { + // Warm the caches the way the first render would, then measure steady state. + classifyEveryTitle() + classifierCalls.mockClear() + + for (let write = 0; write < STORE_WRITES; write += 1) { + classifyEveryTitle() + } + + // Unmemoized this is STORE_WRITES x titles x the whole regex ladder — 4,350 + // leaf matches for this fixture. Memoized, an unchanged title costs nothing. + expect(classifierCalls).not.toHaveBeenCalled() + }) + + it('classifies a title once no matter how many readers ask', () => { + const title = 'aider running' + getAgentLabel(title) + const firstReadCalls = classifierCalls.mock.calls.length + expect(firstReadCalls).toBeGreaterThan(0) + + for (let read = 0; read < 20; read += 1) { + getAgentLabel(title) + } + expect(classifierCalls.mock.calls.length).toBe(firstReadCalls) + }) + + it('reclassifies as soon as the title changes', () => { + expect(getAgentLabel('codex ready')).toBe('Codex') + expect(getAgentLabel('grok ready')).toBe('Grok') + expect(detectAgentStatusFromTitle('codex ready')).toBe('idle') + expect(detectAgentStatusFromTitle('codex working')).toBe('working') + }) + + it('caches null and false verdicts, not just truthy ones', () => { + const classify = vi.fn((): string | null => null) + const memoized = memoizeTitleClassification(classify) + expect(memoized('zsh')).toBeNull() + expect(memoized('zsh')).toBeNull() + expect(classify).toHaveBeenCalledTimes(1) + }) + + it('evicts oldest entries instead of growing without bound', () => { + const classify = vi.fn((title: string) => title.length) + const memoized = memoizeTitleClassification(classify) + // Cap is 1024; overflow it and confirm the newest key still hits while the + // oldest was evicted. + for (let index = 0; index < 1030; index += 1) { + memoized(`title-${index}`) + } + const afterFill = classify.mock.calls.length + memoized('title-1029') + expect(classify.mock.calls.length).toBe(afterFill) + memoized('title-0') + expect(classify.mock.calls.length).toBe(afterFill + 1) + }) +}) diff --git a/src/shared/terminal-title-classification-memo.ts b/src/shared/terminal-title-classification-memo.ts new file mode 100644 index 00000000000..c712ca65c96 --- /dev/null +++ b/src/shared/terminal-title-classification-memo.ts @@ -0,0 +1,43 @@ +/** + * Bounded memo for pure `(title: string) => T` terminal-title classifiers. + * + * Why: the sidebar cards and tab strip re-derive agent identity/status from + * every pane title inside zustand selectors and render bodies, so an UNCHANGED + * title was re-tested against every agent-name regex on every store write — + * thousands of classifications per second while the app sat idle. Every + * classifier below depends on nothing but the title string, so the verdict is + * reusable until the title itself changes; a new title is simply a new key, so + * there is no staleness window and no invalidation signal to miss. + */ + +/** + * Cap: comfortably above the live working set (one title per open pane plus + * retained rows) so steady-state hit rate stays ~100%, small enough that the + * map cannot grow with session length. Entries hold a reference to a string the + * store already retains, so the marginal cost is the map entry itself. + */ +const MAX_MEMOIZED_TITLES = 1024 + +export function memoizeTitleClassification( + classify: (title: string) => T +): (title: string) => T { + // Boxed values so `undefined`/`null` verdicts are still cache hits. + const cache = new Map() + return (title: string): T => { + const cached = cache.get(title) + if (cached) { + return cached.value + } + const value = classify(title) + // Insertion-ordered FIFO eviction: a pane's superseded title frames are the + // oldest keys and the least likely to be asked for again. + if (cache.size >= MAX_MEMOIZED_TITLES) { + const oldest = cache.keys().next() + if (!oldest.done) { + cache.delete(oldest.value) + } + } + cache.set(title, { value }) + return value + } +} diff --git a/src/renderer/src/lib/workspace-session-host-field-ownership.ts b/src/shared/workspace-session-host-field-ownership.ts similarity index 97% rename from src/renderer/src/lib/workspace-session-host-field-ownership.ts rename to src/shared/workspace-session-host-field-ownership.ts index a44c7669bda..6397c2ad239 100644 --- a/src/renderer/src/lib/workspace-session-host-field-ownership.ts +++ b/src/shared/workspace-session-host-field-ownership.ts @@ -1,4 +1,4 @@ -import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import type { WorkspaceSessionState } from './workspace-session-state-types' export type WorkspaceSessionFieldOwnership = | 'global' diff --git a/tests/e2e/tab-context-menu-session-id.spec.ts b/tests/e2e/terminal-context-menu-session-id.spec.ts similarity index 59% rename from tests/e2e/tab-context-menu-session-id.spec.ts rename to tests/e2e/terminal-context-menu-session-id.spec.ts index cab18bace32..dc58567cefa 100644 --- a/tests/e2e/tab-context-menu-session-id.spec.ts +++ b/tests/e2e/terminal-context-menu-session-id.spec.ts @@ -1,7 +1,4 @@ -/** - * E2E coverage for copying an agent provider session ID from a terminal tab's - * context menu. - */ +/** E2E coverage for copying provider identity from the exact terminal pane. */ import { test, expect } from './helpers/orca-app' import { @@ -11,10 +8,11 @@ import { waitForSessionReady } from './helpers/store' import { waitForPaneIdentitySnapshot } from './helpers/terminal' +import { openTerminalContextMenu } from './helpers/terminal-pane-title-actions' -const SESSION_ID = 'e2e-terminal-tab-session' +const SESSION_ID = 'e2e-terminal-pane-session' -test('terminal tab context menu copies the active agent session ID', async ({ orcaPage }) => { +test('terminal pane context menu copies its agent session ID', async ({ orcaPage }) => { await waitForSessionReady(orcaPage) const worktreeId = await waitForActiveWorktree(orcaPage) await ensureTerminalVisible(orcaPage) @@ -30,21 +28,20 @@ test('terminal tab context menu copies the active agent session ID', async ({ or } const paneKey = `${tabId}:${leafId}` - // Seed the same renderer state a live agent hook produces while keeping the - // test independent of an installed provider CLI. + // Keep this independent of an installed provider CLI while exercising the + // durable pane identity used when transient live status has been cleared. await orcaPage.evaluate( ({ paneKey, tabId, worktreeId, sessionId }) => { const state = window.__store?.getState() if (!state) { throw new Error('Store unavailable') } - state.setAgentStatus( + state.recordAgentProviderSession( paneKey, - { state: 'working', prompt: 'copy session id', agentType: 'claude' }, - 'Claude', + 'claude', + { key: 'session_id', id: sessionId }, undefined, - { tabId, worktreeId }, - { providerSession: { key: 'session_id', id: sessionId } } + { tabId, worktreeId } ) }, { paneKey, tabId, worktreeId, sessionId: SESSION_ID } @@ -55,16 +52,22 @@ test('terminal tab context menu copies the active agent session ID', async ({ or () => orcaPage.evaluate( ({ paneKey }) => - window.__store?.getState().agentStatusByPaneKey[paneKey]?.providerSession?.id, + window.__store?.getState().sleepingAgentSessionsByPaneKey[paneKey]?.providerSession.id, { paneKey } ), { timeout: 3_000 } ) .toBe(SESSION_ID) - const tab = orcaPage.locator(`[data-testid="sortable-tab"][data-tab-id="${tabId}"]`) - await expect(tab).toBeVisible() - await tab.click({ button: 'right' }) + await openTerminalContextMenu(orcaPage) + + const identityItems = await orcaPage.getByRole('menuitem').allInnerTexts() + const sessionIdIndex = identityItems.indexOf('Copy Session ID') + expect(identityItems.slice(sessionIdIndex, sessionIdIndex + 3)).toEqual([ + 'Copy Session ID', + 'Copy Terminal ID', + 'Copy Pane ID' + ]) const copyItem = orcaPage.getByRole('menuitem', { name: 'Copy Session ID', exact: true }) await expect(copyItem).toBeVisible() diff --git a/tests/tools/benchmarks/startup-bench-state-fixture.mjs b/tests/tools/benchmarks/startup-bench-state-fixture.mjs new file mode 100644 index 00000000000..87f353bcfce --- /dev/null +++ b/tests/tools/benchmarks/startup-bench-state-fixture.mjs @@ -0,0 +1,193 @@ +/** + * Persisted-state fixtures for the startup benchmark: the git repos, GitHub + * remotes, restored terminal tabs, and unreachable SSH targets that `orca-data.json` + * must contain for a run to exercise the corresponding startup path. + */ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +function initFixtureGitRepo(repoDir) { + mkdirSync(repoDir, { recursive: true }) + if (!existsSync(join(repoDir, '.git'))) { + const init = spawnSync('git', ['init', repoDir], { stdio: 'ignore' }) + if (init.status !== 0) { + throw new Error(`Failed to create git repo fixture at ${repoDir}`) + } + } + return realpathSync(repoDir) +} + +/** + * Seed repos whose hydration reaches the `gh` login probe: a GitHub `origin` + * remote and no github.user/user.username config (the bench also points + * GIT_CONFIG_GLOBAL away from the developer's real config at launch). + */ +function buildGithubRepoFixtures(fixtureDir, githubRepos) { + const repos = [] + for (let i = 0; i < githubRepos; i++) { + const repoPath = initFixtureGitRepo(join(fixtureDir, `bench-gh-repo-${i}`)) + const remote = spawnSync( + 'git', + [ + '-C', + repoPath, + 'remote', + 'add', + 'origin', + `https://github.com/orca-bench/bench-gh-repo-${i}.git` + ], + { stdio: 'ignore' } + ) + // Exit 3 (remote exists) is fine on fixture reuse; anything else is not. + if (remote.status !== 0 && remote.status !== 3) { + throw new Error(`Failed to add GitHub remote to ${repoPath}`) + } + repos.push({ + id: `bench-gh-repo-${i}`, + path: repoPath, + displayName: `Bench GH Repo ${i}`, + badgeColor: '#000000', + addedAt: 1, + externalWorktreeVisibility: 'show' + }) + } + return repos +} + +/** + * SSH targets on TEST-NET-3 (RFC 5737). The address is guaranteed unroutable, + * so the TCP handshake never completes and never gets a reset — the wire + * behaviour of a host that is asleep or behind a dropped VPN. + */ +function buildUnreachableSshTargets(count) { + const targets = [] + for (let i = 0; i < count; i++) { + targets.push({ + id: `bench-ssh-unreachable-${i}`, + label: `Unreachable Host ${i}`, + host: `203.0.113.${i + 1}`, + port: 22, + username: 'orca', + source: 'manual', + lastRequiredPassphrase: false + }) + } + return targets +} + +export function writePersistedStateFixture( + fixtureDir, + { stateProfile, sessionTabs, githubRepos, sshUnreachableTargets = 0 } +) { + const dataPath = join(fixtureDir, 'orca-data.json') + if (stateProfile === 'none' && githubRepos === 0 && sshUnreachableTargets === 0) { + try { + unlinkSync(dataPath) + } catch { + // no persisted state fixture + } + return 0 + } + if (!['none', 'restored-local-tabs'].includes(stateProfile)) { + throw new Error(`Unknown state profile: ${stateProfile}`) + } + + const githubRepoEntries = buildGithubRepoFixtures(fixtureDir, githubRepos) + const sshTargets = buildUnreachableSshTargets(sshUnreachableTargets) + if (stateProfile === 'none') { + const state = { + schemaVersion: 1, + ...(sshTargets.length > 0 ? { sshTargets } : {}), + repos: githubRepoEntries, + settings: { + telemetry: { + installId: 'startup-bench', + optedIn: false, + existedBeforeTelemetryRelease: true + } + } + } + const json = JSON.stringify(state, null, 2) + writeFileSync(dataPath, json, 'utf-8') + return Buffer.byteLength(json) + } + + const repoPath = initFixtureGitRepo(join(fixtureDir, 'bench-repo')) + const repoId = 'bench-repo' + const worktreeId = `${repoId}::${repoPath}` + const tabCount = Math.max(1, sessionTabs) + const tabs = [] + const terminalLayoutsByTabId = {} + const activeTabIdByWorktree = {} + for (let i = 0; i < tabCount; i++) { + const tabId = `bench-tab-${String(i).padStart(5, '0')}` + const ptyId = `bench-pty-${String(i).padStart(5, '0')}` + tabs.push({ + id: tabId, + ptyId, + worktreeId, + title: `Terminal ${i + 1}`, + customTitle: null, + color: null, + sortOrder: i, + createdAt: 1 + }) + terminalLayoutsByTabId[tabId] = { + root: null, + activeLeafId: null, + expandedLeafId: null + } + } + activeTabIdByWorktree[worktreeId] = tabs[0]?.id ?? null + const state = { + schemaVersion: 1, + repos: [ + { + id: repoId, + path: repoPath, + displayName: 'Bench Repo', + badgeColor: '#000000', + addedAt: 1, + externalWorktreeVisibility: 'show' + }, + ...githubRepoEntries + ], + settings: { + telemetry: { + installId: 'startup-bench', + optedIn: false, + existedBeforeTelemetryRelease: true + } + }, + ui: { + lastActiveRepoId: repoId, + lastActiveWorktreeId: worktreeId + }, + workspaceSession: { + activeRepoId: repoId, + activeWorktreeId: worktreeId, + activeTabId: tabs[0]?.id ?? null, + tabsByWorktree: { + [worktreeId]: tabs + }, + terminalLayoutsByTabId, + activeTabIdByWorktree, + activeWorktreeIdsOnShutdown: [worktreeId], + defaultTerminalTabsAppliedByWorktreeId: { + [worktreeId]: true + }, + // Why on the session and not just the target list: startup reconnect only + // dials targets that were connected at shutdown. + ...(sshTargets.length > 0 + ? { activeConnectionIdsAtShutdown: sshTargets.map((target) => target.id) } + : {}) + } + } + if (sshTargets.length > 0) { + state.sshTargets = sshTargets + } + const json = JSON.stringify(state, null, 2) + writeFileSync(dataPath, json, 'utf-8') + return Buffer.byteLength(json) +} diff --git a/tests/tools/benchmarks/startup-time-bench.mjs b/tests/tools/benchmarks/startup-time-bench.mjs index 29363432c7a..e39f2d1d133 100644 --- a/tests/tools/benchmarks/startup-time-bench.mjs +++ b/tests/tools/benchmarks/startup-time-bench.mjs @@ -12,6 +12,7 @@ * node tests/tools/benchmarks/startup-time-bench.mjs --label baseline * [--iterations 5] [--files 28000] [--fixture-dir ] * [--state-profile none|restored-local-tabs] [--session-tabs 200] + * [--ssh-unreachable-targets 1] * [--github-repos 3] [--gh-hang-ms 30000] * [--wait-for-event renderer-startup-hydration-done] * [--exe ] [--timeout-ms 240000] @@ -27,20 +28,14 @@ * Results: tests/tools/benchmarks/results/startup-