From 2ff0ca10d4d3c10a5b53f3c31bc19e8f3e8b6bbc Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:36:07 -0700 Subject: [PATCH 1/8] fix(ssh): match the exact worktree created --- src/main/ipc/worktree-remote.ts | 8 ++++---- src/main/ipc/worktrees-ssh-setup-launch.test.ts | 7 +++++++ 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index edda8ebbbce..5e1f004972b 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -90,7 +90,7 @@ import { shouldSetDisplayName, mergeWorktree } from './worktree-logic' -import { resolveCreatedWorktree } from './created-worktree-reconciliation' +import { findCreatedWorktree, resolveCreatedWorktree } from './created-worktree-reconciliation' import type { BranchPrefixSettings } from '../../shared/branch-prefix' import { getRepoIdFromWorktreeId } from '../../shared/worktree/id' import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope' @@ -1840,9 +1840,9 @@ export async function createRemoteWorktree( const gitWorktrees = await timing.time('list_created_worktree', async () => provider.listWorktrees(repo.path) ) - const created = gitWorktrees.find( - (gw) => gw.branch?.endsWith(branchName) || gw.path.endsWith(effectiveSanitizedName) - ) + // Match the exact requested path first, then the exact branch ref. Suffix matching can + // select an older `prefix/` worktree when the newly created row is present. + const created = findCreatedWorktree(gitWorktrees, remotePath, branchName) if (!created) { throw new Error('Worktree created but not found in listing') } diff --git a/src/main/ipc/worktrees-ssh-setup-launch.test.ts b/src/main/ipc/worktrees-ssh-setup-launch.test.ts index f3a76bdacde..a0735bfd50d 100644 --- a/src/main/ipc/worktrees-ssh-setup-launch.test.ts +++ b/src/main/ipc/worktrees-ssh-setup-launch.test.ts @@ -126,6 +126,13 @@ describe('registerWorktreeHandlers', () => { fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined), addWorktree: vi.fn().mockResolvedValue(undefined), listWorktrees: vi.fn().mockResolvedValue([ + { + path: '/remote/old-improve-dashboard', + head: 'old123', + branch: 'refs/heads/archive/improve-dashboard', + isBare: false, + isMainWorktree: false + }, { path: '/remote/repo-improve-dashboard', head: 'abc123', From a085c28e1b5baf37cfb31fc5640c4fd4ff02dd4f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:36:14 -0700 Subject: [PATCH 2/8] fix(relay): propagate worktree listing failures --- .../git-handler-worktree-inspection.test.ts | 14 ++--- src/relay/git-handler-worktree-operations.ts | 52 +++++++++---------- 2 files changed, 32 insertions(+), 34 deletions(-) diff --git a/src/relay/git-handler-worktree-inspection.test.ts b/src/relay/git-handler-worktree-inspection.test.ts index 552a5becc81..84cec6a9660 100644 --- a/src/relay/git-handler-worktree-inspection.test.ts +++ b/src/relay/git-handler-worktree-inspection.test.ts @@ -49,13 +49,13 @@ describe('GitHandler', () => { .spyOn(handler as unknown as GitSpyTarget, 'git') .mockRejectedValue(new Error('aborted')) - const result = await dispatcher.callRequest( - 'git.listWorktrees', - { repoPath: tmpDir }, - { isStale: () => false, signal: controller.signal } - ) - - expect(result).toEqual([]) + await expect( + dispatcher.callRequest( + 'git.listWorktrees', + { repoPath: tmpDir }, + { isStale: () => false, signal: controller.signal } + ) + ).rejects.toThrow('aborted') expect(gitSpy).toHaveBeenCalledWith(['worktree', 'list', '--porcelain', '-z'], tmpDir, { signal: controller.signal }) diff --git a/src/relay/git-handler-worktree-operations.ts b/src/relay/git-handler-worktree-operations.ts index 32993dd845b..6689c178344 100644 --- a/src/relay/git-handler-worktree-operations.ts +++ b/src/relay/git-handler-worktree-operations.ts @@ -119,37 +119,35 @@ export class GitHandlerWorktreeOperations extends GitHandlerOperationContext { async listWorktrees(params: Record, context?: RequestContext) { const repoPath = params.repoPath as string - return this.gitCapabilities - .runWithFallback( - 'worktree-list-z', - async () => { - const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath, { + return this.gitCapabilities.runWithFallback( + 'worktree-list-z', + async () => { + const { stdout } = await this.git(['worktree', 'list', '--porcelain', '-z'], repoPath, { + signal: context?.signal + }) + return this.normalizeMainWorktreePath( + repoPath, + parseWorktreeList(stdout, { nulDelimited: true }) + ) + }, + async () => { + // Why: Git <2.36 lacks worktree-list `-z`, so fall back to the newline-block parser (loses newline-in-path safety). + try { + const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, { signal: context?.signal }) - return this.normalizeMainWorktreePath( + const normalized = await this.normalizeMainWorktreePath( repoPath, - parseWorktreeList(stdout, { nulDelimited: true }) + parseWorktreeList(stdout) ) - }, - async () => { - // Why: Git <2.36 lacks worktree-list `-z`, so fall back to the newline-block parser (loses newline-in-path safety). - try { - const { stdout } = await this.git(['worktree', 'list', '--porcelain'], repoPath, { - signal: context?.signal - }) - const normalized = await this.normalizeMainWorktreePath( - repoPath, - parseWorktreeList(stdout) - ) - // Why: Git <2.31 emits no `prunable` annotation, so probe each linked worktree's existence instead of trusting stale registrations (issue #8389). - return annotatePrunableWorktreesByExistence(normalized) - } catch { - return [] - } - }, - isUnsupportedWorktreeListZError - ) - .catch(() => []) + // Why: Git <2.31 emits no `prunable` annotation, so probe each linked worktree's existence instead of trusting stale registrations (issue #8389). + return annotatePrunableWorktreesByExistence(normalized) + } catch { + return [] + } + }, + isUnsupportedWorktreeListZError + ) } async addWorktree(params: Record) { From 879fdfdac63991d70c3056e8ae1c6ea01dbea06f Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:36:20 -0700 Subject: [PATCH 3/8] fix(cli): resolve WSL mounted-drive worktree paths --- src/cli/selectors.ts | 13 ++++++--- .../worktree-selector-wsl-posix-path.test.ts | 10 +++++++ src/shared/cross-platform-path.test.ts | 27 +++++++++++++++++++ src/shared/cross-platform-path.ts | 20 ++++++++++++++ 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/src/cli/selectors.ts b/src/cli/selectors.ts index 86ed2157ea2..0f90dc94508 100644 --- a/src/cli/selectors.ts +++ b/src/cli/selectors.ts @@ -4,7 +4,11 @@ import type { RuntimeWorktreeListResult, RuntimeWorktreeRecord } from '../shared/runtime-types' -import { isPathInsideOrEqual, isWslUncPathForCallerLinuxPath } from '../shared/cross-platform-path' +import { + isPathInsideOrEqual, + isWslUncPathForCallerLinuxPath, + isWslUncPathForLinuxMountedPath +} from '../shared/cross-platform-path' import { parseWslUncPath } from '../shared/wsl-paths' import type { RuntimeClient } from './runtime-client' import { RuntimeClientError } from './runtime/types' @@ -49,8 +53,9 @@ export async function resolveCallerDistroPathSelector( // the WSL launcher always sets and which arrives here as the invocation cwd, proves it. const callerDistro = parseWslUncPath(cwd)?.distro const linuxPath = selector.startsWith('path:') ? selector.slice(5) : '' + const isLinuxMountedPath = /^\/mnt\/[A-Za-z](?:\/|$)/.test(linuxPath) if ( - !callerDistro || + (!callerDistro && !isLinuxMountedPath) || client.isRemote || !linuxPath.startsWith('/') || linuxPath.startsWith('//') || @@ -63,7 +68,9 @@ export async function resolveCallerDistroPathSelector( limit: 10_000 }) const match = worktrees.result.worktrees.find((worktree) => - isWslUncPathForCallerLinuxPath(worktree.path, linuxPath, callerDistro) + isLinuxMountedPath + ? isWslUncPathForLinuxMountedPath(worktree.path, linuxPath) + : isWslUncPathForCallerLinuxPath(worktree.path, linuxPath, callerDistro!) ) // Why the stored spelling rather than a synthesized UNC path: an unmatched selector must // reach the runtime verbatim and fail as the caller typed it, never as a guessed distro. diff --git a/src/cli/worktree-selector-wsl-posix-path.test.ts b/src/cli/worktree-selector-wsl-posix-path.test.ts index 10d71c8e9ed..78ef1779c82 100644 --- a/src/cli/worktree-selector-wsl-posix-path.test.ts +++ b/src/cli/worktree-selector-wsl-posix-path.test.ts @@ -11,6 +11,7 @@ import { normalizeWorktreeSelectorForCaller } from './selectors' const UBUNTU = 'Ubuntu-24.04' const DEBIAN = 'Debian' const LINUX_PATH = '/home/neil/qa-repo' +const MOUNTED_PATH = '/mnt/c/Users/neil/qa-repo' function uncPath(distro: string, linuxPath: string): string { return `\\\\wsl.localhost\\${distro}${linuxPath.replace(/\//g, '\\')}` @@ -37,6 +38,15 @@ afterEach(() => { }) describe('normalizeWorktreeSelectorForCaller in a WSL shell (#16628)', () => { + it('resolves a shared /mnt drive without requiring the caller distro', async () => { + const storedPath = uncPath(UBUNTU, '/mnt/c/Users/neil/qa-repo') + const { client } = makeClient([storedPath]) + + await expect( + normalizeWorktreeSelectorForCaller(`path:${MOUNTED_PATH}`, '/mnt/c/Users/neil', client) + ).resolves.toBe(`path:${storedPath}`) + }) + it.each([ ['a backslash UNC registration', uncPath(UBUNTU, LINUX_PATH)], ['a forward-slash UNC registration', `//wsl.localhost/${UBUNTU}${LINUX_PATH}`], diff --git a/src/shared/cross-platform-path.test.ts b/src/shared/cross-platform-path.test.ts index f23892d98e3..6bdda5d064d 100644 --- a/src/shared/cross-platform-path.test.ts +++ b/src/shared/cross-platform-path.test.ts @@ -5,6 +5,7 @@ import { isPathInsideOrEqual, isRuntimePathAbsolute, isWslUncPathForCallerLinuxPath, + isWslUncPathForLinuxMountedPath, normalizeRuntimePathForComparison, relativePathInsideRoot, resolveRuntimePath @@ -99,6 +100,32 @@ describe('isWslUncPathForCallerLinuxPath', () => { }) }) +describe('isWslUncPathForLinuxMountedPath', () => { + it('matches a shared /mnt drive regardless of distro', () => { + expect( + isWslUncPathForLinuxMountedPath( + '\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Users\\Neil\\repo', + '/mnt/c/users/neil/repo' + ) + ).toBe(true) + expect( + isWslUncPathForLinuxMountedPath( + '\\\\wsl.localhost\\Debian\\mnt\\c\\Users\\Neil\\repo', + '/mnt/c/users/neil/repo' + ) + ).toBe(true) + }) + + it('keeps non-mounted Linux paths out of the distro-independent match', () => { + expect( + isWslUncPathForLinuxMountedPath( + '\\\\wsl.localhost\\Ubuntu\\home\\Neil\\repo', + '/home/neil/repo' + ) + ).toBe(false) + }) +}) + describe('cross-platform path containment', () => { it('keeps POSIX sibling prefixes outside the root', () => { expect(isPathInsideOrEqual('/repo/app', '/repo/app')).toBe(true) diff --git a/src/shared/cross-platform-path.ts b/src/shared/cross-platform-path.ts index 679ee9e57ba..61308a70489 100644 --- a/src/shared/cross-platform-path.ts +++ b/src/shared/cross-platform-path.ts @@ -81,6 +81,26 @@ export function isWslUncPathForCallerLinuxPath( ) } +/** + * Whether a WSL UNC path fronts the same Windows-mounted `/mnt/` path. + * + * `/mnt/` is backed by the host drive and is shared across distros, so + * matching it does not require the caller's distro proof used for Linux paths. + */ +export function isWslUncPathForLinuxMountedPath(uncPath: string, linuxPath: string): boolean { + const parsed = parseWslUncPath(uncPath) + if (!parsed || !/^\/mnt\/[A-Za-z](?:\/|$)/.test(parsed.linuxPath)) { + return false + } + if (!/^\/mnt\/[A-Za-z](?:\/|$)/.test(linuxPath)) { + return false + } + return ( + normalizeRuntimePathForComparison(toWindowsWslPath(parsed.linuxPath, parsed.distro)) === + normalizeRuntimePathForComparison(toWindowsWslPath(linuxPath, parsed.distro)) + ) +} + export function areLocalWindowsWslPathAliases(left: string, right: string): boolean { const leftIdentity = getLocalWindowsWslPathIdentity(left) const rightIdentity = getLocalWindowsWslPathIdentity(right) From cb8e08834ce49be7ec25a08a8e35b3fb0cc61462 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:36:26 -0700 Subject: [PATCH 4/8] fix(paste): exclude xterm helper textareas from text ownership --- src/renderer/src/lib/text-control-paste-ownership.test.ts | 1 + src/renderer/src/lib/text-control-paste-ownership.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/src/renderer/src/lib/text-control-paste-ownership.test.ts b/src/renderer/src/lib/text-control-paste-ownership.test.ts index f4f8205f5c7..cc5d4ceafa0 100644 --- a/src/renderer/src/lib/text-control-paste-ownership.test.ts +++ b/src/renderer/src/lib/text-control-paste-ownership.test.ts @@ -54,6 +54,7 @@ describe('text control paste ownership', () => { expect(findOwnedPasteEventTextControlTarget(textarea, textarea)).toBe(textarea) expect(findOwnedPasteEventTextControlTarget(other, textarea)).toBeNull() expect(findOwnedPasteEventTextControlTarget(terminalTextarea, terminalTextarea)).toBeNull() + expect(findOwnedTextControlPasteTarget(terminalTextarea)).toBeNull() }) it('allows native paste for empty and small payloads', () => { diff --git a/src/renderer/src/lib/text-control-paste-ownership.ts b/src/renderer/src/lib/text-control-paste-ownership.ts index b068ac05c04..df06b806b2e 100644 --- a/src/renderer/src/lib/text-control-paste-ownership.ts +++ b/src/renderer/src/lib/text-control-paste-ownership.ts @@ -30,6 +30,11 @@ export function findOwnedTextControlPasteTarget( if (!(activeElement instanceof Element)) { return null } + // xterm focuses a hidden helper textarea; it is terminal input, not a native + // text-control target for app-menu or large-paste ownership. + if (activeElement.closest('.xterm-helper-textarea')) { + return null + } const textControl = activeElement.closest('input, textarea') if (!textControl || !isPrimarySelectionTextControl(textControl)) { return null From d7604e43075b67c9fc3002b96a3bae3286932207 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:42:46 -0700 Subject: [PATCH 5/8] fix(pi): settle OMP status from the agent_end contract (#17209) * fix(pi): settle OMP status from the agent_end contract OMP exposes no agent_settled hook and ctx.isIdle() can stay false after a finished turn, so Orca's idle-recheck loop backed off and spun forever and the pane stayed "working" indefinitely. OMP instead marks non-terminal agent_end events with willContinue; honor that for configured and runtime-routed OMP and treat an absent flag as terminal. Extracted from #15658, which bundles this with a launch-authority change that conflicts with in-flight #17077. Only the settle half lands here. STA-4130 Co-authored-by: Bing.Z * fix(pi): preserve non-terminal continuation guards Keep the base Pi and Prime willContinue guard while settling terminal OMP events directly. Add regression coverage so sibling runtimes cannot publish a false completion after conflict resolution. --------- Co-authored-by: Bing.Z Co-authored-by: Merge Sim --- ...ent-status-extension-omp-lifecycle.test.ts | 107 ++++++++++ .../pi/agent-status-extension-source.test.ts | 196 +----------------- .../pi/agent-status-extension-test-harness.ts | 190 +++++++++++++++++ src/main/pi/agent-status-handler-source.ts | 8 +- 4 files changed, 310 insertions(+), 191 deletions(-) create mode 100644 src/main/pi/agent-status-extension-omp-lifecycle.test.ts create mode 100644 src/main/pi/agent-status-extension-test-harness.ts diff --git a/src/main/pi/agent-status-extension-omp-lifecycle.test.ts b/src/main/pi/agent-status-extension-omp-lifecycle.test.ts new file mode 100644 index 00000000000..89f8c94d9a5 --- /dev/null +++ b/src/main/pi/agent-status-extension-omp-lifecycle.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createAgentStatusExtensionHarness } from './agent-status-extension-test-harness' + +function postedHookNames(fetchMock: ReturnType): string[] { + return fetchMock.mock.calls.map( + (call) => JSON.parse(String(call[1]?.body)).payload.hook_event_name as string + ) +} + +const OMP_RUNTIME_CASES = [ + ['configured OMP', { kind: 'omp' as const }], + ['title-routed OMP', { kind: 'pi' as const, title: 'omp' }], + ['argv-routed OMP', { kind: 'pi' as const, argv: ['node', '/usr/local/bin/omp'] }] +] as const + +describe('OMP agent_end contract', () => { + it.each(OMP_RUNTIME_CASES)( + 'keeps %s working when agent_end will continue', + async (_name, args) => { + vi.useFakeTimers() + try { + const harness = createAgentStatusExtensionHarness(args) + const context = { isIdle: vi.fn(() => true) } + + await harness.callHook('agent_start') + await harness.callHook('agent_end', { willContinue: true }, context) + await vi.advanceTimersByTimeAsync(1_000) + + expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start']) + expect(context.isIdle).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + } finally { + vi.useRealTimers() + } + } + ) + + it.each(OMP_RUNTIME_CASES)( + 'settles a completed %s turn without waiting for ctx.isIdle', + async (_name, args) => { + // Why: absent payload and absent flag are both terminal for a version that cannot send one. + for (const event of [{ willContinue: false }, {}, undefined]) { + const harness = createAgentStatusExtensionHarness(args) + const context = { isIdle: vi.fn(() => false) } + + await harness.callHook('agent_start') + await harness.callHook('agent_end', event, context) + + await vi.waitFor(() => + expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start', 'agent_end']) + ) + expect(context.isIdle).not.toHaveBeenCalled() + } + } + ) + + it('settles a later terminal OMP agent_end after a continuation', async () => { + const harness = createAgentStatusExtensionHarness({ kind: 'omp' }) + const context = { isIdle: vi.fn(() => false) } + + await harness.callHook('agent_start') + await harness.callHook('agent_end', { willContinue: true }, context) + expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start']) + + await harness.callHook('agent_end', { willContinue: false }, context) + await vi.waitFor(() => + expect(postedHookNames(harness.fetchMock)).toEqual(['agent_start', 'agent_end']) + ) + }) + + it('does not apply the OMP contract to Pi or Prime', async () => { + vi.useFakeTimers() + try { + for (const kind of ['pi', 'prime-agent'] as const) { + const harness = createAgentStatusExtensionHarness({ kind }) + const context = { isIdle: vi.fn(() => false) } + + await harness.callHook('agent_end', { willContinue: false }, context) + await vi.advanceTimersByTimeAsync(1_000) + + expect(postedHookNames(harness.fetchMock)).toEqual([]) + expect(context.isIdle).toHaveBeenCalled() + } + } finally { + vi.useRealTimers() + } + }) + + it('preserves non-terminal agent_end handling for Pi and Prime', async () => { + vi.useFakeTimers() + try { + for (const kind of ['pi', 'prime-agent'] as const) { + const harness = createAgentStatusExtensionHarness({ kind }) + const context = { isIdle: vi.fn(() => true) } + + await harness.callHook('agent_end', { willContinue: true }, context) + await vi.advanceTimersByTimeAsync(1_000) + + expect(postedHookNames(harness.fetchMock)).toEqual([]) + expect(context.isIdle).not.toHaveBeenCalled() + } + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/pi/agent-status-extension-source.test.ts b/src/main/pi/agent-status-extension-source.test.ts index 45534662d55..fed179837db 100644 --- a/src/main/pi/agent-status-extension-source.test.ts +++ b/src/main/pi/agent-status-extension-source.test.ts @@ -1,193 +1,9 @@ -import { runInNewContext } from 'node:vm' -// TypeScript 7 is a native CLI; transpile tests still need the legacy JavaScript API. -import ts from 'typescript-api' import { describe, expect, it, vi } from 'vitest' -import { getPiAgentStatusExtensionSource } from './agent-status-extension-source' - -type HookContext = { - isIdle?: () => boolean - sessionManager?: { - getSessionId?: () => unknown - getSessionFile?: () => unknown - } -} - -type HookHandler = (event?: unknown, context?: HookContext) => Promise | void - -type FakeCurlChild = { - on: ReturnType - stdin: { - on: ReturnType - end: ReturnType - } -} - -type Harness = { - fetchMock: ReturnType - spawnMock: ReturnType - spawnedChildren: FakeCurlChild[] - fsMock: { - existsSync: ReturnType - readFileSync: ReturnType - statSync: ReturnType - } - handlers: Record - processEnv: Record - callHook: (name: string, event?: unknown, context?: HookContext) => Promise - // Re-invoke the extension factory in the same process (as Pi does on an - // in-process extension reload), swapping in the freshly registered handlers. - reload: () => void -} - -const BASE_ENV = { - ORCA_PANE_KEY: 'pane-1', - ORCA_AGENT_LAUNCH_TOKEN: 'launch-1', - ORCA_TAB_ID: 'tab-1', - ORCA_WORKTREE_ID: 'tree-1', - ORCA_AGENT_HOOK_PORT: '4321', - ORCA_AGENT_HOOK_TOKEN: 'token-1', - ORCA_AGENT_HOOK_ENV: 'env-1', - ORCA_AGENT_HOOK_VERSION: '1.2.3' -} satisfies Record - -// Why: ownership keys on process.pid, so reload and child-process tests need -// stable, distinct identities. -const SELF_PID = 4242 - -function createHarness(args: { - kind: 'pi' | 'omp' | 'prime-agent' - env?: Record - pid?: number - title?: string - argv?: string[] - existsSync?: (path: string) => boolean - readFileSync?: (path: string, encoding: string) => string - statSync?: (path: string) => { mtimeMs: number; size: number; ino: number } - fetchImpl?: (...params: Parameters) => Promise -}): Harness { - const fetchMock = vi.fn( - args.fetchImpl ?? - (async () => ({ - ok: true - })) - ) - - const spawnedChildren: FakeCurlChild[] = [] - const spawnMock = vi.fn(() => { - const child: FakeCurlChild = { - on: vi.fn(), - stdin: { - on: vi.fn(), - end: vi.fn() - } - } - spawnedChildren.push(child) - return child - }) - - const fsMock = { - existsSync: vi.fn(args.existsSync ?? (() => false)), - statSync: vi.fn( - args.statSync ?? - ((path: string) => { - throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) - }) - ), - readFileSync: vi.fn( - args.readFileSync ?? - ((path: string) => { - throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) - }) - ) - } - - const module = { - exports: {} as { default?: (pi: { on: (name: string, handler: HookHandler) => void }) => void } - } - const requireMock = vi.fn((specifier: string) => { - if (specifier === 'fs') { - return fsMock - } - if (specifier === 'child_process') { - return { spawn: spawnMock } - } - throw new Error(`unexpected require(${specifier})`) - }) - - const processMock = { - env: { - ...BASE_ENV, - ...(args.kind === 'prime-agent' ? { PRIME_AGENT_INTERNAL_DAEMON_WORKER: '1' } : {}), - ...args.env - }, - pid: args.pid ?? SELF_PID, - title: args.title ?? 'node', - argv: args.argv ?? ['node', '/usr/bin/orca'] - } - - const context = { - module, - exports: module.exports, - require: requireMock, - process: processMock, - fetch: fetchMock, - console: { - warn: vi.fn(), - error: vi.fn(), - log: vi.fn() - }, - Promise, - Buffer, - URL, - AbortController, - setTimeout, - clearTimeout - } as Record - context.globalThis = context - - const source = getPiAgentStatusExtensionSource(args.kind) - const output = ts.transpileModule(source, { - compilerOptions: { - module: ts.ModuleKind.CommonJS, - target: ts.ScriptTarget.ES2020 - } - }).outputText - runInNewContext(output, context) - - const register = module.exports.default - if (!register) { - throw new Error('expected default export from generated source') - } - - const handlers: Record = {} - const registerInto = (target: Record): void => { - register({ - on(name: string, handler: HookHandler) { - target[name] = handler - } - }) - } - registerInto(handlers) - - return { - fetchMock, - spawnMock, - spawnedChildren, - fsMock, - handlers, - processEnv: processMock.env, - callHook: async (name, event, hookContext) => { - await handlers[name]?.(event, hookContext) - }, - reload: () => { - for (const key of Object.keys(handlers)) { - delete handlers[key] - } - registerInto(handlers) - } - } -} +import { + AGENT_STATUS_EXTENSION_SELF_PID as SELF_PID, + createAgentStatusExtensionHarness as createHarness +} from './agent-status-extension-test-harness' describe('getPiAgentStatusExtensionSource', () => { it('registers Prime hooks only in the event-emitting daemon worker', () => { @@ -826,10 +642,10 @@ describe('getPiAgentStatusExtensionSource', () => { } }) - it('keeps reporting Pi-compatible agents once their agent_end handlers settle', async () => { + it('keeps polling Pi and Prime until their agent_end handlers settle', async () => { vi.useFakeTimers() try { - for (const kind of ['pi', 'omp', 'prime-agent'] as const) { + for (const kind of ['pi', 'prime-agent'] as const) { const harness = createHarness({ kind }) let idle = false const context = { isIdle: vi.fn(() => idle) } diff --git a/src/main/pi/agent-status-extension-test-harness.ts b/src/main/pi/agent-status-extension-test-harness.ts new file mode 100644 index 00000000000..eec2b615017 --- /dev/null +++ b/src/main/pi/agent-status-extension-test-harness.ts @@ -0,0 +1,190 @@ +import { runInNewContext } from 'node:vm' +// TypeScript 7 is a native CLI; transpile tests still need the legacy JavaScript API. +import ts from 'typescript-api' +import { vi } from 'vitest' + +import { getPiAgentStatusExtensionSource } from './agent-status-extension-source' + +export type HookContext = { + isIdle?: () => boolean + sessionManager?: { + getSessionId?: () => unknown + getSessionFile?: () => unknown + } +} + +export type HookHandler = (event?: unknown, context?: HookContext) => Promise | void + +type FakeCurlChild = { + on: ReturnType + stdin: { + on: ReturnType + end: ReturnType + } +} + +export type AgentStatusExtensionHarness = { + fetchMock: ReturnType + spawnMock: ReturnType + spawnedChildren: FakeCurlChild[] + fsMock: { + existsSync: ReturnType + readFileSync: ReturnType + statSync: ReturnType + } + handlers: Record + processEnv: Record + callHook: (name: string, event?: unknown, context?: HookContext) => Promise + // Re-invoke the extension factory in the same process (as Pi does on an + // in-process extension reload), swapping in the freshly registered handlers. + reload: () => void +} + +const BASE_ENV = { + ORCA_PANE_KEY: 'pane-1', + ORCA_AGENT_LAUNCH_TOKEN: 'launch-1', + ORCA_TAB_ID: 'tab-1', + ORCA_WORKTREE_ID: 'tree-1', + ORCA_AGENT_HOOK_PORT: '4321', + ORCA_AGENT_HOOK_TOKEN: 'token-1', + ORCA_AGENT_HOOK_ENV: 'env-1', + ORCA_AGENT_HOOK_VERSION: '1.2.3' +} satisfies Record + +// Why: ownership keys on process.pid, so reload and child-process tests need +// stable, distinct identities. +export const AGENT_STATUS_EXTENSION_SELF_PID = 4242 + +export function createAgentStatusExtensionHarness(args: { + kind: 'pi' | 'omp' | 'prime-agent' + env?: Record + pid?: number + title?: string + argv?: readonly string[] + existsSync?: (path: string) => boolean + readFileSync?: (path: string, encoding: string) => string + statSync?: (path: string) => { mtimeMs: number; size: number; ino: number } + fetchImpl?: (...params: Parameters) => Promise +}): AgentStatusExtensionHarness { + const fetchMock = vi.fn( + args.fetchImpl ?? + (async () => ({ + ok: true + })) + ) + + const spawnedChildren: FakeCurlChild[] = [] + const spawnMock = vi.fn(() => { + const child: FakeCurlChild = { + on: vi.fn(), + stdin: { + on: vi.fn(), + end: vi.fn() + } + } + spawnedChildren.push(child) + return child + }) + + const fsMock = { + existsSync: vi.fn(args.existsSync ?? (() => false)), + statSync: vi.fn( + args.statSync ?? + ((path: string) => { + throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + }) + ), + readFileSync: vi.fn( + args.readFileSync ?? + ((path: string) => { + throw Object.assign(new Error(`ENOENT: ${path}`), { code: 'ENOENT' }) + }) + ) + } + + const module = { + exports: {} as { default?: (pi: { on: (name: string, handler: HookHandler) => void }) => void } + } + const requireMock = vi.fn((specifier: string) => { + if (specifier === 'fs') { + return fsMock + } + if (specifier === 'child_process') { + return { spawn: spawnMock } + } + throw new Error(`unexpected require(${specifier})`) + }) + + const processMock = { + env: { + ...BASE_ENV, + ...(args.kind === 'prime-agent' ? { PRIME_AGENT_INTERNAL_DAEMON_WORKER: '1' } : {}), + ...args.env + }, + pid: args.pid ?? AGENT_STATUS_EXTENSION_SELF_PID, + title: args.title ?? 'node', + argv: args.argv ?? ['node', '/usr/bin/orca'] + } + + const context = { + module, + exports: module.exports, + require: requireMock, + process: processMock, + fetch: fetchMock, + console: { + warn: vi.fn(), + error: vi.fn(), + log: vi.fn() + }, + Promise, + Buffer, + URL, + AbortController, + setTimeout, + clearTimeout + } as Record + context.globalThis = context + + const source = getPiAgentStatusExtensionSource(args.kind) + const output = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2020 + } + }).outputText + runInNewContext(output, context) + + const register = module.exports.default + if (!register) { + throw new Error('expected default export from generated source') + } + + const handlers: Record = {} + const registerInto = (target: Record): void => { + register({ + on(name: string, handler: HookHandler) { + target[name] = handler + } + }) + } + registerInto(handlers) + + return { + fetchMock, + spawnMock, + spawnedChildren, + fsMock, + handlers, + processEnv: processMock.env, + callHook: async (name, event, hookContext) => { + await handlers[name]?.(event, hookContext) + }, + reload: () => { + for (const key of Object.keys(handlers)) { + delete handlers[key] + } + registerInto(handlers) + } + } +} diff --git a/src/main/pi/agent-status-handler-source.ts b/src/main/pi/agent-status-handler-source.ts index 20f3f31929a..71bcbf96534 100644 --- a/src/main/pi/agent-status-handler-source.ts +++ b/src/main/pi/agent-status-handler-source.ts @@ -115,7 +115,9 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' })', '', ' // Why: modern Pi stays non-idle across retry/compaction/follow-up work,', - ' // while legacy Pi/OMP becomes idle after its final agent_end handlers.', + ' // while legacy Pi becomes idle after its final agent_end handlers.', + ' // OMP instead marks non-terminal agent_end events with willContinue, so it', + ' // returns before the recheck timer is ever armed.', ' const AGENT_END_IDLE_RECHECK_MS = 25', ' const AGENT_END_IDLE_RECHECK_MAX_MS = 250', ' let agentSettledSupported = false', @@ -173,6 +175,10 @@ export function getPiAgentStatusHandlerSourceLines(kind: PiAgentKind): string[] ' clearPendingAgentEndCheck()', ' return', ' }', + ' if (isOmpRuntime()) {', + ' postAgentEndOnce()', + ' return', + ' }', ' if (agentSettledSupported) return', " if (!ctx || typeof ctx.isIdle !== 'function') {", ' postAgentEndOnce()', From b3912ebed2e01ad6c97e39a5ea57e0b793e26bbf Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:43:37 -0700 Subject: [PATCH 6/8] Split up combined-diff viewer into feature-organized modules (#17341) * Reorganize combined-diff components into feature-organized structure Splits flat combined-diff files into feature-focused subdirectories (browse-files, load-sections, resolve-changes, review-controls, scroll-viewport) to improve code organization and reduce clutter in the editor directory. Groups related logic by concern for easier navigation and maintenance. * Split up combined-diff viewer into feature-organized modules Decompose the 221-line monolithic CombinedDiffViewer into smaller, focused modules organized by feature: entry resolution, section loading, view state memory, file tree navigation, review controls, and scroll viewport handling. Main component now composes these hooks to orchestrate the combined-diff view. * fix(combined-diff): prevent replayed preference writes Move preference write outside state updater callback since React may replay state updaters, causing multiple writes. Add sideBySide to dependency array. * fix(combined-diff): re-resolve sections by key to handle list rebuilds The section list can rebuild while a write is pending (due to rebase, file changes, etc.); re-resolve by key instead of stale index to apply updates to the correct section. - Convert skipped conflicts message to structured i18n plural forms - Add oldPath field to git status signature for rename tracking * Suppress react-doctor diagnostics in combined-diff feature Add suppressions for react-doctor diagnostics that are necessary patterns for the combined-diff implementation, configured in both the quality check script and package.json. --- config/max-lines-baseline.txt | 1 - config/scripts/check-changed-code-quality.mjs | 20 +- package.json | 22 + .../components/editor/CombinedDiffViewer.tsx | 2256 ----------------- .../combined-diff/CombinedDiffViewer.tsx | 383 +++ .../combined-diff-file-tree-filter.ts} | 78 +- .../combined-diff-file-tree-navigation.ts | 47 + .../combined-diff-file-tree-row.tsx | 6 +- .../combined-diff-file-tree.test.ts} | 12 +- .../browse-files/combined-diff-file-tree.tsx} | 17 +- ...se-combined-diff-file-tree-resize.test.tsx | 0 .../use-combined-diff-file-tree-resize.ts | 2 +- .../use-combined-diff-tree-navigation.ts | 98 + ...combined-diff-initial-section-load.test.ts | 0 .../combined-diff-initial-section-load.ts | 0 .../combined-diff-load-scheduler.test.ts | 0 .../combined-diff-load-scheduler.ts | 0 .../combined-diff-section-connection.test.ts | 8 +- .../combined-diff-section-connection.ts | 0 .../combined-diff-section-load-registry.ts | 79 + .../combined-diff-section-load-state.test.ts | 4 +- .../combined-diff-section-load-state.ts | 4 +- .../combined-diff-section-load-timeout.ts | 34 + .../fetch-combined-diff-section.ts | 95 + .../use-combined-diff-section-loader.ts | 220 ++ .../use-combined-diff-section-retry.ts | 152 ++ .../use-combined-diff-section-revalidation.ts | 108 + .../combined-diff-view-memory.ts | 45 + .../use-combined-diff-view-persist.ts | 56 + .../use-combined-diff-view-restore.ts | 169 ++ .../combined-diff-entries.test.ts | 4 +- .../resolve-changes}/combined-diff-entries.ts | 6 +- .../combined-diff-git-status-signature.ts | 39 + .../combined-diff-section-cache-match.test.ts | 6 +- .../combined-diff-section-cache-match.ts | 10 +- .../combined-diff-section-identity.ts | 26 + .../use-combined-diff-entry-set.ts | 166 ++ .../combined-diff-commit-header.tsx | 44 + .../combined-diff-commit-message.test.ts | 0 .../combined-diff-commit-message.ts | 0 .../combined-diff-notes-popover.tsx | 165 ++ .../combined-diff-skipped-conflicts.tsx | 104 + .../review-controls/combined-diff-toolbar.tsx | 193 ++ .../use-combined-diff-notes-actions.ts | 138 + .../use-combined-diff-section-actions.ts | 246 ++ .../use-combined-diff-view-preferences.ts | 101 + .../combined-diff-scrollbar-drag.test.ts | 0 .../combined-diff-scrollbar-drag.ts | 0 .../combined-diff-section-list.tsx | 165 ++ .../use-combined-diff-direct-scroll-input.ts | 21 + .../use-combined-diff-scroll-anchors.ts | 188 ++ .../use-combined-diff-scroll-persistence.ts | 213 ++ .../use-combined-diff-scrollbar.ts | 140 + .../use-combined-diff-virtualizer.ts | 88 + .../components/editor/editor-lazy-views.ts | 2 +- .../pr-files-combined-diff-body.tsx | 4 +- .../pr-files-combined-diff-viewer.tsx | 12 +- ...ver-reveal-touch-action-visibility.test.ts | 2 +- .../files/combined-diff-viewer.tsx | 14 +- src/renderer/src/i18n/locales/en.json | 5 +- src/renderer/src/i18n/locales/es.json | 5 +- src/renderer/src/i18n/locales/ja.json | 5 +- src/renderer/src/i18n/locales/ko.json | 5 +- src/renderer/src/i18n/locales/zh.json | 5 +- 64 files changed, 3638 insertions(+), 2400 deletions(-) delete mode 100644 src/renderer/src/components/editor/CombinedDiffViewer.tsx create mode 100644 src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx rename src/renderer/src/components/editor/{combined-diff-file-tree-model.ts => combined-diff/browse-files/combined-diff-file-tree-filter.ts} (53%) create mode 100644 src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-navigation.ts rename src/renderer/src/components/editor/{ => combined-diff/browse-files}/combined-diff-file-tree-row.tsx (95%) rename src/renderer/src/components/editor/{CombinedDiffFileTree.test.ts => combined-diff/browse-files/combined-diff-file-tree.test.ts} (92%) rename src/renderer/src/components/editor/{CombinedDiffFileTree.tsx => combined-diff/browse-files/combined-diff-file-tree.tsx} (97%) rename src/renderer/src/components/editor/{ => combined-diff/browse-files}/use-combined-diff-file-tree-resize.test.tsx (100%) rename src/renderer/src/components/editor/{ => combined-diff/browse-files}/use-combined-diff-file-tree-resize.ts (97%) create mode 100644 src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.ts rename src/renderer/src/components/editor/{ => combined-diff/load-sections}/combined-diff-initial-section-load.test.ts (100%) rename src/renderer/src/components/editor/{ => combined-diff/load-sections}/combined-diff-initial-section-load.ts (100%) rename src/renderer/src/components/editor/{ => combined-diff/load-sections}/combined-diff-load-scheduler.test.ts (100%) rename src/renderer/src/components/editor/{ => combined-diff/load-sections}/combined-diff-load-scheduler.ts (100%) rename src/renderer/src/components/editor/{ => combined-diff/load-sections}/combined-diff-section-connection.test.ts (92%) rename src/renderer/src/components/editor/{ => combined-diff/load-sections}/combined-diff-section-connection.ts (100%) create mode 100644 src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-registry.ts rename src/renderer/src/components/editor/{ => combined-diff/load-sections}/combined-diff-section-load-state.test.ts (97%) rename src/renderer/src/components/editor/{ => combined-diff/load-sections}/combined-diff-section-load-state.ts (95%) create mode 100644 src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-timeout.ts create mode 100644 src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts create mode 100644 src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-loader.ts create mode 100644 src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-retry.ts create mode 100644 src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-revalidation.ts create mode 100644 src/renderer/src/components/editor/combined-diff/remember-view/combined-diff-view-memory.ts create mode 100644 src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-persist.ts create mode 100644 src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.ts rename src/renderer/src/components/editor/{ => combined-diff/resolve-changes}/combined-diff-entries.test.ts (98%) rename src/renderer/src/components/editor/{ => combined-diff/resolve-changes}/combined-diff-entries.ts (94%) create mode 100644 src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-git-status-signature.ts rename src/renderer/src/components/editor/{ => combined-diff/resolve-changes}/combined-diff-section-cache-match.test.ts (90%) rename src/renderer/src/components/editor/{ => combined-diff/resolve-changes}/combined-diff-section-cache-match.ts (77%) create mode 100644 src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-identity.ts create mode 100644 src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-entry-set.ts create mode 100644 src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-commit-header.tsx rename src/renderer/src/components/editor/{ => combined-diff/review-controls}/combined-diff-commit-message.test.ts (100%) rename src/renderer/src/components/editor/{ => combined-diff/review-controls}/combined-diff-commit-message.ts (100%) create mode 100644 src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-notes-popover.tsx create mode 100644 src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-skipped-conflicts.tsx create mode 100644 src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-toolbar.tsx create mode 100644 src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-notes-actions.ts create mode 100644 src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts create mode 100644 src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-view-preferences.ts rename src/renderer/src/components/editor/{ => combined-diff/scroll-viewport}/combined-diff-scrollbar-drag.test.ts (100%) rename src/renderer/src/components/editor/{ => combined-diff/scroll-viewport}/combined-diff-scrollbar-drag.ts (100%) create mode 100644 src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-section-list.tsx create mode 100644 src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-direct-scroll-input.ts create mode 100644 src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scroll-anchors.ts create mode 100644 src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scroll-persistence.ts create mode 100644 src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scrollbar.ts create mode 100644 src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-virtualizer.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 536140abfe6..15bddb7ae55 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -27,7 +27,6 @@ inline src/renderer/src/components/TaskPage.tsx inline src/renderer/src/components/Terminal.tsx inline src/renderer/src/components/WorktreeJumpPalette.tsx inline src/renderer/src/components/automations/AutomationsPage.tsx -inline src/renderer/src/components/editor/CombinedDiffViewer.tsx inline src/renderer/src/components/editor/MarkdownPreview.tsx inline src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx inline src/renderer/src/components/new-workspace/SmartWorkspaceNameField.tsx diff --git a/config/scripts/check-changed-code-quality.mjs b/config/scripts/check-changed-code-quality.mjs index 615a2402bcf..c7d07a927e6 100644 --- a/config/scripts/check-changed-code-quality.mjs +++ b/config/scripts/check-changed-code-quality.mjs @@ -23,6 +23,15 @@ export const OXLINT_SCANS = [ } ] +const SUPPRESSED_REACT_DOCTOR_DIAGNOSTICS = new Map([ + [ + 'react-doctor(no-derived-state-effect)', + new Set([ + 'src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-view-preferences.ts' + ]) + ] +]) + export function parseAddedLineRanges(diff) { const ranges = [] const hunkPattern = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/ @@ -263,6 +272,11 @@ function printDiagnostic(diagnostic, root) { console.error(`${file}:${line} ${code}: ${diagnostic.message}`) } +function isSuppressedDiagnostic(diagnostic, root) { + const files = SUPPRESSED_REACT_DOCTOR_DIAGNOSTICS.get(diagnostic.code) + return files?.has(normalizedDiagnosticPath(root, diagnostic.filename)) ?? false +} + function runOxlintScan(root, scan, files) { const pnpm = process.platform === 'win32' ? 'pnpm.cmd' : 'pnpm' const result = spawnSync(pnpm, ['exec', 'oxlint', ...scan.args, '--format', 'json', ...files], { @@ -295,8 +309,10 @@ export function main( let failures = 0 for (const scan of OXLINT_SCANS) { - const diagnostics = runOxlintScan(root, scan, files).filter((diagnostic) => - diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks) + const diagnostics = runOxlintScan(root, scan, files).filter( + (diagnostic) => + !isSuppressedDiagnostic(diagnostic, root) && + diagnosticTouchesAddedLines(diagnostic, rangesByFile, root, baseBlocks) ) for (const diagnostic of diagnostics) { printDiagnostic(diagnostic, root) diff --git a/package.json b/package.json index 873bb34b559..115095a14b9 100644 --- a/package.json +++ b/package.json @@ -300,6 +300,28 @@ }, "packageManager": "pnpm@12.0.0+sha512.9e2e3dc3911995868dc94b8175c217c27e95408fa03b4a22749778f2b34f773b77cdd3b39ede8171b22fcd53be6a35342e9fac9948a68ef58df6488ce89a7e67", "reactDoctor": { + "ignore": { + "overrides": [ + { + "files": [ + "src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-view-preferences.ts" + ], + "rules": [ + "react-doctor/no-derived-state-effect" + ] + }, + { + "files": [ + "src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.ts", + "src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-registry.ts", + "src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts" + ], + "rules": [ + "react-doctor/no-ref-current-in-render" + ] + } + ] + }, "rules": { "react-doctor/js-combine-iterations": "off" } diff --git a/src/renderer/src/components/editor/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/CombinedDiffViewer.tsx deleted file mode 100644 index b004ef3a799..00000000000 --- a/src/renderer/src/components/editor/CombinedDiffViewer.tsx +++ /dev/null @@ -1,2256 +0,0 @@ -/* eslint-disable max-lines -- Why: the whole viewer is one state machine (lazy load, inline edit, restore-on-remount cache, scroll preservation); splitting it hides lifecycle edges. */ -/* oxlint-disable react-doctor/no-adjust-state-on-prop-change -- Why: diff entry changes must reset virtualizer measurement and generation state in lockstep with external scroll restoration. */ -import React, { useState, useEffect, useCallback, useRef, useLayoutEffect, useMemo } from 'react' -import { elementScroll, useVirtualizer } from '@tanstack/react-virtual' -import type { editor as monacoEditor } from 'monaco-editor' -import { useAppStore } from '@/store' -import { - useVirtualizedScrollAnchor, - VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT, - type VirtualizedScrollAnchor -} from '@/hooks/useVirtualizedScrollAnchor' -import { getVirtualizedScrollAnchorForOffset } from '@/hooks/virtualized-scroll-anchor-recording' -import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks' -import { joinPath } from '@/lib/path' -import { detectLanguage } from '@/lib/language-detect' -import { openFilePreviewToSide, useWorkspaceFileBrowserActionPredicate } from '@/lib/file-preview' -import { canOpenDiffSectionPreviewToSide } from './diff-section-preview' -import { setWithLRU } from '@/lib/scroll-cache' -import { getCombinedDiffSectionConnectionId } from './combined-diff-section-connection' -import { findWorktreeById } from '@/store/slices/worktree-helpers' -import { selectWorktreeDiffCommentsOrEmpty } from '@/store/worktree-diff-comments-selector' -import { writeRuntimeFile } from '@/runtime/runtime-file-client' -import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' -import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner' -import { formatDiffComments } from '@/lib/diff-comments-format' -import { getDiffCommentLineLabel } from '@/lib/diff-comment-compat' -import { - getRuntimeGitBranchDiff, - getRuntimeGitCommitDiff, - getRuntimeGitDiff -} from '@/runtime/runtime-git-client' -import '@/lib/monaco-setup' -import { Button } from '@/components/ui/button' -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle -} from '@/components/ui/dialog' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import type { OpenFile } from '@/store/slices/editor' -import type { DiffComment } from '../../../../shared/diff-comment-types' -import type { GitBranchChangeEntry, GitDiffResult } from '../../../../shared/git-diff-compare-types' -import type { GitStatusEntry } from '../../../../shared/git-status-types' -import { Check, Copy, MessageSquare, PanelLeftOpen, Sparkles, Trash2, WrapText } from 'lucide-react' -import { toast } from 'sonner' -import { DiffSectionItem } from './DiffSectionItem' -import { DiffNotesSendMenu } from './DiffNotesSendMenu' -import { - CombinedDiffFileTree, - createCombinedDiffSectionIndexMap, - handleCombinedDiffFileTreeNavigation -} from './CombinedDiffFileTree' -import { getCombinedDiffFileTreeSectionKey } from './combined-diff-file-tree-model' -import { - ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, - type EditorPathMutationTarget -} from './editor-autosave' -import { - getCombinedBranchEntries, - getCombinedUncommittedEntries, - resolveCombinedUncommittedSnapshotEntries, - shouldAutoReloadCombinedDiffFromGitStatus -} from './combined-diff-entries' -import { getCombinedDiffCommitMessageBody } from './combined-diff-commit-message' -import { getDiffSectionEstimatedHeight, isIntrinsicHeightImageDiff } from './diff-section-layout' -import { getLargeDiffRenderLimit } from './large-diff-render-limit' -import { getStoredTextDiffContent, getStoredTextDiffResult } from './large-diff-section-content' -import type { DiffSection } from './diff-section-types' -import { getInitialCombinedDiffSectionLoadIndices } from './combined-diff-initial-section-load' -import { removeDiffSectionMeasuredHeight } from './diff-section-height-cache' -import { createCombinedDiffLoadScheduler } from './combined-diff-load-scheduler' -import { combinedDiffSectionsMatchEntryMetadata } from './combined-diff-section-cache-match' -import { - beginCombinedDiffScrollbarDrag, - type CombinedDiffScrollbarDragCleanup -} from './combined-diff-scrollbar-drag' -import { - isUnchangedDiffSectionReload, - shouldRequestCombinedDiffSectionLoad -} from './combined-diff-section-load-state' -import { translate } from '@/i18n/i18n' - -type CachedCombinedDiffViewState = { - entrySignature: string - gitStatusSignature: string - sections: DiffSection[] - sectionHeights: Record - loadedIndices: number[] - scrollTop: number - sideBySide: boolean -} - -type CombinedDiffScrollThumb = { - visible: boolean - top: number - height: number -} - -const combinedDiffViewStateCache = new Map() -const combinedDiffScrollTopCache = new Map() -const combinedDiffScrollAnchorCache = new Map() - -function buildCombinedGitStatusSignature( - sections: readonly { path: string }[], - gitStatusEntries: readonly GitStatusEntry[] -): string { - const sectionPaths = new Set(sections.map((section) => section.path)) - const matching = gitStatusEntries.filter((entry) => sectionPaths.has(entry.path)) - return JSON.stringify( - matching.map((entry) => ({ - path: entry.path, - area: entry.area, - status: entry.status, - added: entry.added ?? null, - removed: entry.removed ?? null - })) - ) -} - -function invalidateCombinedDiffCachesForRelativePath(relativePath: string): void { - for (const [key, cached] of combinedDiffViewStateCache.entries()) { - if (cached.sections.some((section) => section.path === relativePath)) { - combinedDiffViewStateCache.delete(key) - } - } -} - -function getRetainedResolvedSnapshotEntries(sections: readonly DiffSection[]): GitStatusEntry[] { - return sections.flatMap((section) => - section.area === undefined - ? [] - : [ - { - path: section.path, - status: section.status as GitStatusEntry['status'], - area: section.area, - oldPath: section.oldPath, - added: section.added, - removed: section.removed - } - ] - ) -} - -if (typeof window !== 'undefined') { - window.addEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, (event) => { - const detail = (event as CustomEvent).detail - if (detail?.relativePath) { - // Why: inactive combined-diff tabs are unmounted, so only a module-level cache bust stops a remount replaying stale bodies. - invalidateCombinedDiffCachesForRelativePath(detail.relativePath) - } - }) -} -const COMBINED_DIFF_OVERSCAN = 5 -const COMBINED_DIFF_SCROLLBAR_THUMB_MIN_HEIGHT = 64 -const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntry[] = [] -const EMPTY_GIT_BRANCH_ENTRIES: GitBranchChangeEntry[] = [] -let combinedDiffCollapsedPreference: boolean | null = null -let combinedDiffSideBySidePreference: boolean | null = null -let combinedDiffFileTreeCollapsedPreference: boolean | null = null -// Why: local Electron IPC has no RPC timeout; a hung git diff must become a retryable row error, not permanent "Loading...". -const COMBINED_DIFF_SECTION_LOAD_TIMEOUT_MS = 30_000 -// Why: git rewrites a path several times during a rebase; refetch once the writes stop. -const COMBINED_DIFF_SECTION_RELOAD_COALESCE_MS = 300 - -function clearPendingSectionReloadTimers(timers: Map): void { - for (const timer of timers.values()) { - window.clearTimeout(timer) - } - timers.clear() -} - -class CombinedDiffSectionLoadTimeoutError extends Error { - constructor() { - super('Diff did not finish loading.') - this.name = 'CombinedDiffSectionLoadTimeoutError' - } -} - -function withDiffSectionLoadTimeout(promise: Promise): Promise { - let timeoutId: number | null = null - - const timeoutPromise = new Promise((_, reject) => { - timeoutId = window.setTimeout(() => { - reject(new CombinedDiffSectionLoadTimeoutError()) - }, COMBINED_DIFF_SECTION_LOAD_TIMEOUT_MS) - }) - - return Promise.race([promise, timeoutPromise]).finally(() => { - if (timeoutId !== null) { - window.clearTimeout(timeoutId) - } - }) -} - -function getDiffSectionLoadErrorMessage(error: unknown): string { - if (error instanceof CombinedDiffSectionLoadTimeoutError) { - return 'Diff did not finish loading.' - } - return error instanceof Error && error.message.trim().length > 0 - ? error.message - : 'Unable to load diff.' -} - -function getInitialCombinedDiffSideBySide(diffDefaultView: string | undefined): boolean { - return combinedDiffSideBySidePreference ?? diffDefaultView === 'side-by-side' -} - -function getInitialCombinedDiffFileTreeCollapsed( - combinedDiffFileTreeVisibleByDefault: boolean | undefined -): boolean { - // Why: the tree is opt-in; only an explicit saved setting should open it while settings are still loading. - return combinedDiffFileTreeCollapsedPreference ?? combinedDiffFileTreeVisibleByDefault !== true -} - -export default function CombinedDiffViewer({ - file, - viewStateKey -}: { - file: OpenFile - viewStateKey: string -}): React.JSX.Element { - const settings = useAppStore((s) => s.settings) - const gitStatusEntries = useAppStore( - (s) => s.gitStatusByWorktree[file.worktreeId] ?? EMPTY_GIT_STATUS_ENTRIES - ) - const liveBranchEntries = useAppStore( - (s) => s.gitBranchChangesByWorktree[file.worktreeId] ?? EMPTY_GIT_BRANCH_ENTRIES - ) - const branchSummary = useAppStore((s) => s.gitBranchCompareSummaryByWorktree[file.worktreeId]) - const openAllDiffs = useAppStore((s) => s.openAllDiffs) - const openFile = useAppStore((s) => s.openFile) - const openBranchDiff = useAppStore((s) => s.openBranchDiff) - const openCommitDiff = useAppStore((s) => s.openCommitDiff) - const openConflictReview = useAppStore((s) => s.openConflictReview) - const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs) - const updateSettings = useAppStore((s) => s.updateSettings) - const clearDiffComments = useAppStore((s) => s.clearDiffComments) - const diffCommentsForWorktree = useAppStore((s) => - selectWorktreeDiffCommentsOrEmpty(s, file.worktreeId) - ) - const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[file.worktreeId]) - const canOpenWorkspaceFileBrowserForPath = useWorkspaceFileBrowserActionPredicate(file.worktreeId) - const isDark = - settings?.theme === 'dark' || - (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) - - const diffCommentCount = diffCommentsForWorktree.length - const diffCommentsPrompt = React.useMemo( - () => formatDiffComments(diffCommentsForWorktree), - [diffCommentsForWorktree] - ) - const previewDiffComments = React.useMemo( - () => - [...diffCommentsForWorktree] - .sort((a, b) => a.filePath.localeCompare(b.filePath) || a.lineNumber - b.lineNumber) - .slice(0, 4), - [diffCommentsForWorktree] - ) - - const [sections, setSections] = useState([]) - const [sideBySide, setSideBySide] = useState(() => - getInitialCombinedDiffSideBySide(settings?.diffDefaultView) - ) - const [sectionHeights, setSectionHeights] = useState>({}) - const [clearNotesDialogOpen, setClearNotesDialogOpen] = useState(false) - const [isClearingNotes, setIsClearingNotes] = useState(false) - const clearNotesDialogVisible = clearNotesDialogOpen && (diffCommentCount > 0 || isClearingNotes) - if (clearNotesDialogOpen && !clearNotesDialogVisible) { - // Why: notes may be cleared outside this dialog; close it this render instead of flashing an empty confirmation. - setClearNotesDialogOpen(false) - } - const [notesCopied, setNotesCopied] = useState(false) - const mountedRef = useRef(true) - // Why: the copy action owns its reset timer instead of repairing copied state after render. - const notesCopiedResetTimerRef = useRef(null) - // Why: clipboard IPC can resolve after unmount; skip copied feedback rather than start a reset timer on a stale viewer. - const notesCopyMountedRef = useRef(false) - const [fileTreeCollapsed, setFileTreeCollapsedState] = useState(() => - getInitialCombinedDiffFileTreeCollapsed(settings?.combinedDiffFileTreeVisibleByDefault) - ) - // Why: generation (state) keys DiffSectionItem remounts; generationRef mirrors it so loadSection's stale-async check avoids a stale closure. - const [generation, setGeneration] = useState(0) - const scrollContainerRef = useRef(null) - const [scrollThumb, setScrollThumb] = useState({ - visible: false, - top: 0, - height: COMBINED_DIFF_SCROLLBAR_THUMB_MIN_HEIGHT - }) - const scrollOffsetRef = useRef(combinedDiffScrollTopCache.get(viewStateKey) ?? 0) - const scrollAnchorRef = useRef( - combinedDiffScrollAnchorCache.get(viewStateKey) ?? null - ) - const latestDomScrollAnchorRef = useRef( - combinedDiffScrollAnchorCache.get(viewStateKey) ?? null - ) - const directScrollInputUntilRef = useRef(0) - const [programmaticScrollMarks] = useState(createProgrammaticScrollMarks) - // Why: a scroll pinned at a shrunken max is a browser clamp, not user input; bump to ask the anchor restore to re-pin. - const [clampRestoreCount, setClampRestoreCount] = useState(0) - const lastScrollHeightRef = useRef(0) - const activeScrollbarDragCleanupRef = useRef(null) - const loadedIndicesRef = useRef>(new Set()) - const loadingIndicesRef = useRef>(new Set()) - const sectionsRef = useRef([]) - const generationRef = useRef(0) - // Why: per-section reload token, so a sibling's reload can't discard this section's in-flight load. - const sectionLoadTokensRef = useRef>(new Map()) - const renderedIndicesRef = useRef>(new Set()) - const reloadTimersRef = useRef>(new Map()) - const loadSectionRef = useRef<(index: number) => Promise>(async () => {}) - const retrySectionRef = useRef<(index: number) => void>(() => {}) - const requestSectionReloadRef = useRef<(index: number) => void>(() => {}) - const updateCombinedDiffScrollbar = useCallback(() => { - const container = scrollContainerRef.current - if (!container || container.scrollHeight <= container.clientHeight + 1) { - setScrollThumb((prev) => - prev.visible - ? { - visible: false, - top: 0, - height: COMBINED_DIFF_SCROLLBAR_THUMB_MIN_HEIGHT - } - : prev - ) - return - } - - const trackHeight = Math.max(1, container.clientHeight - 8) - const maxScrollTop = Math.max(1, container.scrollHeight - container.clientHeight) - const height = Math.min( - trackHeight, - Math.max( - COMBINED_DIFF_SCROLLBAR_THUMB_MIN_HEIGHT, - (container.clientHeight / container.scrollHeight) * trackHeight - ) - ) - const top = ((trackHeight - height) * container.scrollTop) / maxScrollTop - setScrollThumb({ visible: true, top, height }) - }, []) - - const markDirectScrollInput = useCallback((): void => { - directScrollInputUntilRef.current = window.performance.now() + 250 - }, []) - - const hasDirectScrollInput = useCallback( - () => window.performance.now() < directScrollInputUntilRef.current, - [] - ) - - const clearNotesCopiedResetTimer = useCallback((): void => { - if (notesCopiedResetTimerRef.current !== null) { - window.clearTimeout(notesCopiedResetTimerRef.current) - notesCopiedResetTimerRef.current = null - } - }, []) - - const cleanupActiveScrollbarDrag = useCallback((): void => { - activeScrollbarDragCleanupRef.current?.() - }, []) - - const setScrollContainerRef = useCallback( - (node: HTMLDivElement | null) => { - scrollContainerRef.current = node - notesCopyMountedRef.current = node !== null - if (node === null) { - // Why: copied feedback is tied to the surface lifetime; the root-ref unmount is where stale feedback gets disabled. - clearNotesCopiedResetTimer() - cleanupActiveScrollbarDrag() - return - } - window.requestAnimationFrame(updateCombinedDiffScrollbar) - }, - [cleanupActiveScrollbarDrag, clearNotesCopiedResetTimer, updateCombinedDiffScrollbar] - ) - - useEffect(() => { - mountedRef.current = true - return () => { - mountedRef.current = false - cleanupActiveScrollbarDrag() - } - }, [cleanupActiveScrollbarDrag]) - const loadSchedulerRef = useRef( - createCombinedDiffLoadScheduler({ - loadSection: (index) => loadSectionRef.current(index) - }) - ) - sectionsRef.current = sections - - // Why: seed from Settings until the user picks a toolbar mode this session, then follow that choice over the global default. - useEffect(() => { - if (settings?.diffDefaultView !== undefined && combinedDiffSideBySidePreference === null) { - setSideBySide(settings.diffDefaultView === 'side-by-side') - } - }, [settings?.diffDefaultView]) - - useEffect(() => { - if ( - settings?.combinedDiffFileTreeVisibleByDefault !== undefined && - combinedDiffFileTreeCollapsedPreference === null - ) { - setFileTreeCollapsedState(settings.combinedDiffFileTreeVisibleByDefault === false) - } - }, [settings?.combinedDiffFileTreeVisibleByDefault]) - - const setFileTreeCollapsed = useCallback((collapsed: boolean) => { - combinedDiffFileTreeCollapsedPreference = collapsed - setFileTreeCollapsedState(collapsed) - }, []) - - const isBranchMode = file.diffSource === 'combined-branch' - const isCommitMode = file.diffSource === 'combined-commit' - const isAllMode = file.diffSource === 'combined-all' - const branchCompare = - file.branchCompare?.baseOid && file.branchCompare.headOid && file.branchCompare.mergeBase - ? file.branchCompare - : null - const commitCompare = file.commitCompare?.commitOid ? file.commitCompare : null - - // Why: prefer the tab-open snapshot so a commit changing gitStatusByWorktree doesn't rebuild sections and lose loaded content. - const snapshotEntries = React.useMemo( - () => file.uncommittedEntriesSnapshot?.filter((e) => e.conflictStatus !== 'unresolved'), - [file.uncommittedEntriesSnapshot] - ) - const uncommittedEntries = React.useMemo(() => { - if (!snapshotEntries) { - return getCombinedUncommittedEntries(gitStatusEntries, file.combinedAreaFilter) - } - // Why: row load-state changes must not rebuild the snapshot list; the ref is consulted only when live Git status changes. - return resolveCombinedUncommittedSnapshotEntries( - snapshotEntries, - gitStatusEntries, - getRetainedResolvedSnapshotEntries(sectionsRef.current) - ) - }, [snapshotEntries, gitStatusEntries, file.combinedAreaFilter]) - const branchEntries = React.useMemo(() => { - return getCombinedBranchEntries(file.branchEntriesSnapshot, liveBranchEntries) - }, [file.branchEntriesSnapshot, liveBranchEntries]) - const renderableBranchEntries = React.useMemo( - () => (branchCompare ? branchEntries : []), - [branchCompare, branchEntries] - ) - const commitEntries = React.useMemo( - () => file.commitEntriesSnapshot ?? [], - [file.commitEntriesSnapshot] - ) - const allEntries = React.useMemo( - () => [...uncommittedEntries, ...renderableBranchEntries], - [renderableBranchEntries, uncommittedEntries] - ) - const entries = isAllMode - ? allEntries - : isBranchMode - ? renderableBranchEntries - : isCommitMode - ? commitEntries - : uncommittedEntries - const treeMode = isAllMode - ? 'all' - : isBranchMode - ? 'branch' - : isCommitMode - ? 'commit' - : 'uncommitted' - const hasUncommittedEntriesSnapshot = file.uncommittedEntriesSnapshot !== undefined - const shouldAutoReloadFromGitStatus = shouldAutoReloadCombinedDiffFromGitStatus({ - mode: treeMode, - hasUncommittedEntriesSnapshot - }) - const entrySignature = React.useMemo( - () => - JSON.stringify({ - mode: file.diffSource, - areaFilter: file.combinedAreaFilter ?? null, - compareVersion: file.branchCompare?.compareVersion ?? null, - commitVersion: file.commitCompare?.compareVersion ?? null, - compare: - isBranchMode && branchCompare - ? { - baseOid: branchCompare.baseOid, - headOid: branchCompare.headOid, - mergeBase: branchCompare.mergeBase - } - : null, - commit: - isCommitMode && commitCompare - ? { - commitOid: commitCompare.commitOid, - parentOid: commitCompare.parentOid ?? null - } - : null, - entries: entries.map((entry) => ({ - path: entry.path, - status: entry.status, - oldPath: entry.oldPath ?? null, - area: 'area' in entry ? entry.area : null, - added: 'added' in entry ? (entry.added ?? null) : null, - removed: 'removed' in entry ? (entry.removed ?? null) : null - })) - }), - [ - branchCompare, - commitCompare, - entries, - file.branchCompare?.compareVersion, - file.combinedAreaFilter, - file.commitCompare?.compareVersion, - file.diffSource, - isBranchMode, - isCommitMode - ] - ) - - // Why: tab/worktree switches unmount this viewer; cache by pane key so remount restores sections+scroll before repaint. - useLayoutEffect(() => { - const cached = combinedDiffViewStateCache.get(viewStateKey) - const canRestoreSnapshotSectionsByKey = - hasUncommittedEntriesSnapshot && - cached !== undefined && - combinedDiffSectionsMatchEntryMetadata({ - entries, - sections: cached.sections, - treeMode - }) - const canRestoreCachedSections = - cached && - (cached.entrySignature === entrySignature || canRestoreSnapshotSectionsByKey) && - (!shouldAutoReloadFromGitStatus || - (cached.gitStatusSignature ?? '') === - buildCombinedGitStatusSignature(cached.sections, gitStatusEntries)) && - (cached.sections.length > 0 || entries.length === 0) - if (canRestoreCachedSections && cached) { - const collapsedPreference = combinedDiffCollapsedPreference - const restoredSections = - collapsedPreference === null - ? cached.sections - : cached.sections.map((section) => ({ - ...section, - collapsed: collapsedPreference - })) - setSections(restoredSections) - setSectionHeights(cached.sectionHeights) - setSideBySide(combinedDiffSideBySidePreference ?? cached.sideBySide) - loadedIndicesRef.current = new Set( - cached.loadedIndices.filter((index) => !restoredSections[index]?.loading) - ) - loadingIndicesRef.current.clear() - scrollOffsetRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop - scrollAnchorRef.current = combinedDiffScrollAnchorCache.get(viewStateKey) ?? null - latestDomScrollAnchorRef.current = scrollAnchorRef.current - return - } - - scrollOffsetRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? 0 - scrollAnchorRef.current = combinedDiffScrollAnchorCache.get(viewStateKey) ?? null - latestDomScrollAnchorRef.current = scrollAnchorRef.current - setSections( - entries.map((entry) => ({ - key: getCombinedDiffFileTreeSectionKey(treeMode, entry), - path: entry.path, - status: entry.status, - area: 'area' in entry ? entry.area : undefined, - oldPath: entry.oldPath, - added: 'added' in entry ? entry.added : undefined, - removed: 'removed' in entry ? entry.removed : undefined, - originalContent: '', - modifiedContent: '', - collapsed: combinedDiffCollapsedPreference ?? false, - loading: true, - error: undefined, - dirty: false, - diffResult: null, - largeDiffRenderLimit: null - })) - ) - setSectionHeights({}) - loadedIndicesRef.current.clear() - loadingIndicesRef.current.clear() - sectionLoadTokensRef.current.clear() - clearPendingSectionReloadTimers(reloadTimersRef.current) - loadSchedulerRef.current.reset() - generationRef.current += 1 - setGeneration((prev) => prev + 1) - }, [ - entries, - entrySignature, - gitStatusEntries, - hasUncommittedEntriesSnapshot, - shouldAutoReloadFromGitStatus, - treeMode, - viewStateKey - ]) - - const loadSectionNow = useCallback( - async (index: number) => { - if (loadedIndicesRef.current.has(index) || loadingIndicesRef.current.has(index)) { - return - } - loadingIndicesRef.current.add(index) - - const gen = generationRef.current - const loadToken = sectionLoadTokensRef.current.get(index) ?? 0 - const entries = isAllMode - ? allEntries - : isBranchMode - ? renderableBranchEntries - : isCommitMode - ? commitEntries - : uncommittedEntries - const entry = entries[index] - if (!entry) { - loadingIndicesRef.current.delete(index) - return - } - - let result: GitDiffResult - let error: string | undefined - try { - const connectionId = getCombinedDiffSectionConnectionId( - file.worktreeId, - file.filePath, - entry.path - ) - const state = useAppStore.getState() - const fileSettings = settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId) - if ((isBranchMode || (isAllMode && !('area' in entry))) && branchCompare) { - result = await withDiffSectionLoadTimeout( - getRuntimeGitBranchDiff( - { - settings: fileSettings, - worktreeId: file.worktreeId, - worktreePath: file.filePath, - connectionId - }, - { - compare: { - baseRef: branchCompare.baseRef, - baseOid: branchCompare.baseOid!, - headOid: branchCompare.headOid!, - mergeBase: branchCompare.mergeBase! - }, - filePath: entry.path, - oldPath: entry.oldPath - } - ) - ) - } else if (isCommitMode && commitCompare) { - result = await withDiffSectionLoadTimeout( - getRuntimeGitCommitDiff( - { - settings: fileSettings, - worktreeId: file.worktreeId, - worktreePath: file.filePath, - connectionId - }, - { - commitOid: commitCompare.commitOid, - parentOid: commitCompare.parentOid, - filePath: entry.path, - oldPath: entry.oldPath - } - ) - ) - } else { - result = await withDiffSectionLoadTimeout( - getRuntimeGitDiff( - { - settings: fileSettings, - worktreeId: file.worktreeId, - worktreePath: file.filePath, - connectionId - }, - { - filePath: entry.path, - staged: 'area' in entry && entry.area === 'staged' - } - ) - ) - } - } catch (err) { - error = getDiffSectionLoadErrorMessage(err) - result = { - kind: 'text', - originalContent: '', - modifiedContent: '', - originalIsBinary: false, - modifiedIsBinary: false - } as GitDiffResult - } - - const largeDiffRenderLimit = - !error && result.kind === 'text' - ? (result.largeDiffRenderLimit ?? - getLargeDiffRenderLimit({ - originalContent: result.originalContent, - modifiedContent: result.modifiedContent - })) - : null - - if (generationRef.current !== gen) { - // Why: the generation reset already cleared the in-flight set, and a newer load for this - // index may own the entry now — deleting it here would hide that load from the guard above. - return - } - loadingIndicesRef.current.delete(index) - if ((sectionLoadTokensRef.current.get(index) ?? 0) !== loadToken) { - // Why: an invalidation landed mid-flight and deferred its reload to this settle point, so - // the refetch happens once here instead of racing a second fetch against this one. - requestSectionReloadRef.current(index) - return - } - const storedContent = getStoredTextDiffContent(result, largeDiffRenderLimit) - const storedResult = getStoredTextDiffResult(result, largeDiffRenderLimit) - loadedIndicesRef.current.add(index) - const current = sectionsRef.current[index] - // A revalidation lands on a section that is already showing content. If the refetch matches - // what's on screen, committing it would swap Monaco models and re-measure for nothing. - const wasShowingContent = current !== undefined && !current.loading - if ( - wasShowingContent && - isUnchangedDiffSectionReload(current, { - diffResult: storedResult, - error, - largeDiffRenderLimit, - originalContent: storedContent.originalContent, - modifiedContent: storedContent.modifiedContent - }) - ) { - return - } - if (wasShowingContent) { - // Why: content really changed, so the old Monaco height no longer describes this row. - setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index)) - } - setSections((prev) => { - return prev.map((s, i) => - i === index - ? { - ...s, - diffResult: storedResult, - originalContent: storedContent.originalContent, - modifiedContent: storedContent.modifiedContent, - loading: false, - error, - largeDiffRenderLimit, - // Why: models are keyed by path, so a changed refetch must not reuse the old model. - contentGeneration: wasShowingContent - ? (s.contentGeneration ?? 0) + 1 - : s.contentGeneration - } - : s - ) - }) - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - branchCompare?.baseOid, - branchCompare?.headOid, - branchCompare?.mergeBase, - allEntries, - commitCompare?.commitOid, - commitCompare?.parentOid, - commitEntries, - file.filePath, - file.runtimeEnvironmentId, - isAllMode, - isBranchMode, - isCommitMode, - renderableBranchEntries, - uncommittedEntries - ] - ) - loadSectionRef.current = loadSectionNow - - useEffect(() => { - // Why: React StrictMode replays effect cleanup in dev; reset revives the scheduler for the replayed mount. - const scheduler = loadSchedulerRef.current - const reloadTimers = reloadTimersRef.current - scheduler.reset() - return () => { - clearPendingSectionReloadTimers(reloadTimers) - scheduler.dispose() - } - }, []) - - // Progressive loading: queue diff content when a section becomes visible. - const loadSection = useCallback((index: number) => { - if (sectionsRef.current[index]?.collapsed) { - return - } - loadSchedulerRef.current.request(index) - }, []) - - useEffect(() => { - // Why: queue the first rows deterministically so the visible viewport doesn't depend on IntersectionObserver delivery. - const currentSections = sectionsRef.current - for (let index = 0; index < currentSections.length; index += 1) { - if (currentSections[index]?.loading && loadedIndicesRef.current.has(index)) { - loadedIndicesRef.current.delete(index) - } - } - - const initialIndices = getInitialCombinedDiffSectionLoadIndices({ - sectionCount: currentSections.length, - loadedIndices: loadedIndicesRef.current - }) - - for (const index of initialIndices) { - if (!currentSections[index]?.collapsed) { - loadSection(index) - } - } - }, [entrySignature, loadSection, sections.length]) - - const invalidateCombinedDiffViewStateCache = useCallback((): void => { - combinedDiffViewStateCache.delete(viewStateKey) - }, [viewStateKey]) - - const retrySection = useCallback( - (index: number) => { - const collapsed = sectionsRef.current[index]?.collapsed ?? false - loadedIndicesRef.current.delete(index) - loadingIndicesRef.current.delete(index) - invalidateCombinedDiffViewStateCache() - // Why: reloading one section must not bump the global generation — that is part of - // the virtualizer item key, so it would remount every rendered Monaco editor (STA-3420). - sectionLoadTokensRef.current.set(index, (sectionLoadTokensRef.current.get(index) ?? 0) + 1) - const coalesced = reloadTimersRef.current.get(index) - if (coalesced !== undefined) { - window.clearTimeout(coalesced) - reloadTimersRef.current.delete(index) - } - setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index)) - setSections((prev) => - prev.map((section, sectionIndex) => - sectionIndex === index - ? { - ...section, - loading: !collapsed, - error: undefined, - diffResult: null, - originalContent: '', - modifiedContent: '', - largeDiffRenderLimit: null, - contentGeneration: (section.contentGeneration ?? 0) + 1 - } - : section - ) - ) - if (collapsed) { - return - } - loadSchedulerRef.current.rerequest(index) - }, - [invalidateCombinedDiffViewStateCache] - ) - retrySectionRef.current = retrySection - - const modifiedEditorsRef = useRef>(new Map()) - - const virtualizer = useVirtualizer({ - count: sections.length, - getScrollElement: () => scrollContainerRef.current, - estimateSize: (index) => { - const section = sections[index] - if (!section) { - return 88 - } - - return getDiffSectionEstimatedHeight({ - collapsed: section.collapsed, - measuredContentHeight: sectionHeights[index], - originalContent: section.originalContent, - modifiedContent: section.modifiedContent, - changedLineCount: - section.added === undefined && section.removed === undefined - ? undefined - : (section.added ?? 0) + (section.removed ?? 0), - useIntrinsicImageHeight: isIntrinsicHeightImageDiff(section.diffResult), - isLargeDiffLimited: section.largeDiffRenderLimit?.limited === true, - lineCounts: section.largeDiffRenderLimit?.lineCounts ?? undefined - }) - }, - overscan: COMBINED_DIFF_OVERSCAN, - initialOffset: () => scrollOffsetRef.current, - // Why: mark every virtualizer-issued scroll so events are attributed to the user only when this code didn't cause them. - scrollToFn: (offset, options, instance) => { - const target = offset + (options.adjustments ?? 0) - // Why: writing the current position emits no scroll event; a mark here would go stale and claim a later user scroll. - if (instance.scrollElement?.scrollTop !== target) { - programmaticScrollMarks.mark(target) - } - elementScroll(offset, options, instance) - }, - getItemKey: (index) => { - const section = sections[index] - if (!section) { - return `${index}:${generation}` - } - // Why: contentGeneration is per-section, so a single row's reload remounts only that row. - return `${section.key}:${section.collapsed ? 'collapsed' : 'expanded'}:${generation}:${section.contentGeneration ?? 0}` - } - }) - const combinedDiffTotalSize = virtualizer.getTotalSize() - const combinedDiffVirtualItems = virtualizer.getVirtualItems() - // Why: keep render pure (React Doctor); retrySection still needs the on-screen set without the virtualizer as a dep. - useLayoutEffect(() => { - renderedIndicesRef.current = new Set(combinedDiffVirtualItems.map((item) => item.index)) - }, [combinedDiffVirtualItems]) - const getCombinedDiffSectionKey = useCallback((section: DiffSection): string => section.key, []) - const getCombinedDiffSectionElementKey = useCallback( - (element: Element): string | null => - element instanceof HTMLElement ? (element.dataset.combinedDiffSectionKey ?? null) : null, - [] - ) - const recordCombinedDiffVirtualScrollAnchor = useCallback( - (scrollTop: number): void => { - scrollAnchorRef.current = getVirtualizedScrollAnchorForOffset({ - getRowKey: getCombinedDiffSectionKey, - rows: sectionsRef.current, - scrollTop, - virtualItems: virtualizer.getVirtualItems() - }) - latestDomScrollAnchorRef.current = null - }, - [getCombinedDiffSectionKey, virtualizer] - ) - const recordCombinedDiffDomScrollAnchor = useCallback((): boolean => { - const container = scrollContainerRef.current - if (!container) { - return false - } - - const containerRect = container.getBoundingClientRect() - const visibleRows = Array.from( - container.querySelectorAll('[data-combined-diff-section-row]') - ) - .map((row) => { - const key = row.dataset.combinedDiffSectionKey - if (!key || !row.isConnected) { - return null - } - const rect = row.getBoundingClientRect() - if ( - rect.height <= 0 || - rect.bottom <= containerRect.top || - rect.top >= containerRect.bottom - ) { - return null - } - return { key, rect } - }) - .filter((row): row is { key: string; rect: DOMRect } => row !== null) - .sort((a, b) => a.rect.top - b.rect.top) - - const firstVisible = visibleRows[0] - if (!firstVisible) { - return false - } - - const anchor: NonNullable = { - fallbackKeys: visibleRows.slice(1).map((row) => row.key), - key: firstVisible.key, - offset: Math.min( - firstVisible.rect.height, - Math.max(0, containerRect.top - firstVisible.rect.top) - ), - scrollTop: container.scrollTop - } - scrollAnchorRef.current = anchor - latestDomScrollAnchorRef.current = anchor - return true - }, []) - const writeCombinedDiffScrollAnchor = useCallback((): void => { - const anchor = scrollAnchorRef.current - if (anchor) { - setWithLRU(combinedDiffScrollAnchorCache, viewStateKey, anchor) - } else { - combinedDiffScrollAnchorCache.delete(viewStateKey) - } - }, [viewStateKey]) - const persistCombinedDiffScrollAnchor = useCallback( - (refreshDomAnchor = true): void => { - if (refreshDomAnchor) { - recordCombinedDiffDomScrollAnchor() - } - writeCombinedDiffScrollAnchor() - }, - [recordCombinedDiffDomScrollAnchor, writeCombinedDiffScrollAnchor] - ) - - // Why: restore only on structural changes — restoring on measurement churn overwrote scrollTop during active wheel input. - const combinedDiffRestoreSignal = useMemo( - () => - // Why: a single-section reload drops that row's measured height, so it shifts rows - // below it — still a structural change even though `generation` no longer moves. - `${generation}|${sideBySide ? 'sbs' : 'inline'}|${clampRestoreCount}|${sections - .map( - (section) => - `${section.key}:${section.collapsed ? 'c' : 'e'}:${section.contentGeneration ?? 0}` - ) - .join(',')}`, - [clampRestoreCount, generation, sections, sideBySide] - ) - - useVirtualizedScrollAnchor({ - anchorRef: scrollAnchorRef, - getItemElementKey: getCombinedDiffSectionElementKey, - getRowKey: getCombinedDiffSectionKey, - hasDirectScrollInput, - itemElementSelector: '[data-combined-diff-section-row]', - programmaticScrollMarks, - recordAnchorOnCleanup: false, - recordAnchorOnScroll: false, - restoreSignal: combinedDiffRestoreSignal, - rows: sections, - scrollElementRef: scrollContainerRef, - shouldSkipRestore: hasDirectScrollInput, - scrollOffsetRef, - totalSize: combinedDiffTotalSize, - virtualizer - }) - - useLayoutEffect(() => { - // Why: inline vs side-by-side changes Monaco row heights; re-measure on the mode flip, not on every section load. - virtualizer.measure() - }, [sideBySide, virtualizer]) - - const toggleSection = useCallback((index: number) => { - const shouldLoadAfterExpand = sectionsRef.current[index]?.collapsed ?? false - setSections((prev) => prev.map((s, i) => (i === index ? { ...s, collapsed: !s.collapsed } : s))) - if (shouldLoadAfterExpand) { - loadSchedulerRef.current.request(index) - } - }, []) - const sectionIndexByKey = React.useMemo( - () => createCombinedDiffSectionIndexMap(sections), - [sections] - ) - const sectionIndexByKeyRef = useRef(sectionIndexByKey) - sectionIndexByKeyRef.current = sectionIndexByKey - // Why: invalidation (rebase/commit/external write) revalidates in place — it must not tear the - // section down first. Clearing content up front forces a Monaco remodel even when the refetched - // diff is identical, which is what wedged the renderer during a rebase (STA-3420). - const requestCombinedDiffSectionReload = useCallback( - (index: number): void => { - const section = sectionsRef.current[index] - if (!section || section.dirty) { - return - } - loadedIndicesRef.current.delete(index) - invalidateCombinedDiffViewStateCache() - sectionLoadTokensRef.current.set(index, (sectionLoadTokensRef.current.get(index) ?? 0) + 1) - if (loadingIndicesRef.current.has(index)) { - // Why: the in-flight load now carries a stale token, so it re-drives this reload when it - // settles. Scheduling one here would fetch the same large diff a second time. - return - } - if (section.collapsed || !renderedIndicesRef.current.has(index)) { - // Why: a rebase invalidates every touched path at once. Refetching off-screen sections is - // unbounded work nobody can see; the row reloads on mount once it scrolls into view. - return - } - // Why: a rebase touches the same path many times over a few seconds. Without coalescing - // each touch refetches a whole diff, and the payload churn alone stalls the renderer. - const pending = reloadTimersRef.current.get(index) - if (pending !== undefined) { - window.clearTimeout(pending) - } - reloadTimersRef.current.set( - index, - window.setTimeout(() => { - reloadTimersRef.current.delete(index) - loadSchedulerRef.current.rerequest(index) - }, COMBINED_DIFF_SECTION_RELOAD_COALESCE_MS) - ) - }, - [invalidateCombinedDiffViewStateCache] - ) - requestSectionReloadRef.current = requestCombinedDiffSectionReload - const ensureCombinedDiffSectionLoaded = useCallback((index: number): void => { - const section = sectionsRef.current[index] - if (!shouldRequestCombinedDiffSectionLoad(section, loadingIndicesRef.current.has(index))) { - return - } - loadedIndicesRef.current.delete(index) - loadSchedulerRef.current.request(index) - }, []) - const [activeTreeSectionState, setActiveTreeSectionState] = useState<{ - entrySignature: string - key: string | null - }>(() => ({ entrySignature, key: null })) - const activeTreeSectionKey = - activeTreeSectionState.entrySignature === entrySignature ? activeTreeSectionState.key : null - if (activeTreeSectionState.entrySignature !== entrySignature) { - // Why: the tree highlight belongs to one entry set; reset now so it can't flash on another before an Effect would. - setActiveTreeSectionState({ entrySignature, key: null }) - } - const viewedSectionKeys = React.useMemo( - () => new Set(sections.filter((section) => !section.loading).map((section) => section.key)), - [sections] - ) - const handleTreeNavigate = useCallback( - (entry: GitStatusEntry | GitBranchChangeEntry) => { - markDirectScrollInput() - const navigatedIndex = handleCombinedDiffFileTreeNavigation({ - mode: treeMode, - entry, - sections: sectionsRef.current, - sectionIndexByKey, - toggleSection, - loadSection: ensureCombinedDiffSectionLoaded, - scrollToIndex: (index) => { - scrollAnchorRef.current = null - latestDomScrollAnchorRef.current = null - virtualizer.scrollToIndex(index, { align: 'start' }) - // Why: this jump is programmatic (no scroll event records an anchor); snapshot the destination once layout settles. - window.requestAnimationFrame(() => { - scrollContainerRef.current?.dispatchEvent( - new Event(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT) - ) - }) - } - }) - if (navigatedIndex !== null) { - setActiveTreeSectionState({ - entrySignature, - key: sectionsRef.current[navigatedIndex]?.key ?? null - }) - } - }, - [ - ensureCombinedDiffSectionLoaded, - entrySignature, - markDirectScrollInput, - sectionIndexByKey, - toggleSection, - treeMode, - virtualizer - ] - ) - - const combinedGitStatusSignature = React.useMemo(() => { - if (!shouldAutoReloadFromGitStatus) { - return '' - } - return buildCombinedGitStatusSignature(sections, gitStatusEntries) - }, [gitStatusEntries, sections, shouldAutoReloadFromGitStatus]) - const prevCombinedGitStatusSignatureRef = useRef(null) - - useEffect(() => { - if (!shouldAutoReloadFromGitStatus) { - prevCombinedGitStatusSignatureRef.current = null - return - } - if (prevCombinedGitStatusSignatureRef.current === null) { - prevCombinedGitStatusSignatureRef.current = combinedGitStatusSignature - return - } - if (prevCombinedGitStatusSignatureRef.current === combinedGitStatusSignature) { - return - } - prevCombinedGitStatusSignatureRef.current = combinedGitStatusSignature - for (const index of loadedIndicesRef.current) { - requestCombinedDiffSectionReload(index) - } - }, [combinedGitStatusSignature, requestCombinedDiffSectionReload, shouldAutoReloadFromGitStatus]) - - useEffect(() => { - if (treeMode !== 'all' && treeMode !== 'uncommitted') { - return - } - const handler = (event: Event): void => { - const detail = (event as CustomEvent).detail - if (!detail || detail.worktreeId !== file.worktreeId) { - return - } - const hasRuntimeOwnerFilter = Object.hasOwn(detail, 'runtimeEnvironmentId') - const targetRuntimeOwner = detail.runtimeEnvironmentId?.trim() || null - const fileRuntimeOwner = file.runtimeEnvironmentId?.trim() || null - if (hasRuntimeOwnerFilter && targetRuntimeOwner !== fileRuntimeOwner) { - return - } - for (const area of ['unstaged', 'staged', 'untracked'] as const) { - const key = getCombinedDiffFileTreeSectionKey('uncommitted', { - path: detail.relativePath, - status: 'modified', - area - }) - const index = sectionIndexByKeyRef.current.get(key) - if (index !== undefined) { - requestCombinedDiffSectionReload(index) - } - } - } - window.addEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, handler as EventListener) - return () => - window.removeEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, handler as EventListener) - }, [file.runtimeEnvironmentId, file.worktreeId, requestCombinedDiffSectionReload, treeMode]) - - const setAllSectionsCollapsed = useCallback((collapsed: boolean) => { - combinedDiffCollapsedPreference = collapsed - setSections((prev) => prev.map((section) => ({ ...section, collapsed }))) - if (!collapsed) { - const initialIndices = getInitialCombinedDiffSectionLoadIndices({ - sectionCount: sectionsRef.current.length, - loadedIndices: loadedIndicesRef.current - }) - for (const index of initialIndices) { - loadSchedulerRef.current.request(index) - } - } - }, []) - - const toggleSideBySide = useCallback(() => { - setSideBySide((prev) => { - const next = !prev - combinedDiffSideBySidePreference = next - return next - }) - }, []) - - const toggleDiffWordWrap = useCallback(() => { - void updateSettings({ diffWordWrap: settings?.diffWordWrap !== true }) - }, [settings?.diffWordWrap, updateSettings]) - - const openSection = useCallback( - (index: number) => { - const section = sectionsRef.current[index] - if (!section) { - return - } - - const language = detectLanguage(section.path) - const entry: GitBranchChangeEntry = { - path: section.path, - status: section.status as GitBranchChangeEntry['status'], - oldPath: section.oldPath, - added: section.added, - removed: section.removed - } - - const isBranchEntry = section.area === undefined - - if ((isBranchMode || (isAllMode && isBranchEntry)) && branchCompare) { - openBranchDiff(file.worktreeId, file.filePath, entry, branchCompare, language) - return - } - - if (isCommitMode && commitCompare) { - openCommitDiff(file.worktreeId, file.filePath, entry, commitCompare, language) - return - } - - openFile({ - filePath: joinPath(file.filePath, section.path), - relativePath: section.path, - worktreeId: file.worktreeId, - runtimeEnvironmentId: file.runtimeEnvironmentId, - language, - mode: 'edit' - }) - }, - [ - branchCompare, - commitCompare, - file.filePath, - file.runtimeEnvironmentId, - file.worktreeId, - isAllMode, - isBranchMode, - isCommitMode, - openBranchDiff, - openCommitDiff, - openFile - ] - ) - - // Why: match single-file HTML diffs — preview the on-disk working tree file - // beside the combined view when the section is still present on disk. - const openSectionPreview = useCallback( - (section: DiffSection) => { - if ( - !canOpenDiffSectionPreviewToSide({ - path: section.path, - status: section.status, - isCommitSurface: isCommitMode, - canOpenWorkspaceFileBrowser: canOpenWorkspaceFileBrowserForPath( - joinPath(file.filePath, section.path) - ) - }) - ) { - return - } - // Why: use this combined-diff tab's group, not worktree activeGroupId — - // in a multi-pane layout the active group may be a different split. - const state = useAppStore.getState() - const sourceGroupId = - (state.unifiedTabsByWorktree[file.worktreeId] ?? []).find( - (tab) => - tab.entityId === file.id && (tab.contentType === 'diff' || tab.contentType === 'editor') - )?.groupId ?? - activeGroupId ?? - null - openFilePreviewToSide({ - language: detectLanguage(section.path), - filePath: joinPath(file.filePath, section.path), - worktreeId: file.worktreeId, - sourceGroupId - }) - }, - [ - activeGroupId, - canOpenWorkspaceFileBrowserForPath, - file.filePath, - file.id, - file.worktreeId, - isCommitMode - ] - ) - - const handleSectionSave = useCallback( - async (index: number) => { - const section = sections[index] - if (!section) { - return - } - const modifiedEditor = modifiedEditorsRef.current.get(index) - if (!modifiedEditor && !section.dirty) { - return - } - - const content = modifiedEditor?.getValue() ?? section.modifiedContent - const absolutePath = joinPath(file.filePath, section.path) - try { - const state = useAppStore.getState() - const worktree = file.worktreeId - ? findWorktreeById(state.worktreesByRepo, file.worktreeId) - : null - await writeRuntimeFile( - getEditorFileOperationContext( - state, - { - worktreeId: file.worktreeId, - runtimeEnvironmentId: file.runtimeEnvironmentId, - operationProvenance: file.operationProvenance - }, - worktree?.path ?? null - ), - absolutePath, - content - ) - setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index)) - setSections((prev) => - prev.map((s, i) => { - if (i !== index) { - return s - } - - if (s.diffResult?.kind !== 'text') { - return { - ...s, - modifiedContent: content, - dirty: false, - largeDiffRenderLimit: s.largeDiffRenderLimit - } - } - - const nextDiffResult = { ...s.diffResult, modifiedContent: content } - const nextLargeDiffRenderLimit = getLargeDiffRenderLimit({ - originalContent: s.originalContent, - modifiedContent: content - }) - const storedContent = getStoredTextDiffContent(nextDiffResult, nextLargeDiffRenderLimit) - - return { - ...s, - modifiedContent: storedContent.modifiedContent, - originalContent: storedContent.originalContent, - dirty: false, - diffResult: getStoredTextDiffResult(nextDiffResult, nextLargeDiffRenderLimit), - largeDiffRenderLimit: nextLargeDiffRenderLimit - } - }) - ) - } catch (err) { - console.error('Save failed:', err) - } - }, - [file.filePath, file.operationProvenance, file.runtimeEnvironmentId, file.worktreeId, sections] - ) - - const handleSectionSaveRef = useRef(handleSectionSave) - handleSectionSaveRef.current = handleSectionSave - - useEffect(() => { - if (sections.length === 0 && entries.length > 0) { - return - } - const preservedScrollTop = - combinedDiffScrollTopCache.get(viewStateKey) ?? scrollContainerRef.current?.scrollTop ?? 0 - setWithLRU(combinedDiffViewStateCache, viewStateKey, { - entrySignature, - gitStatusSignature: combinedGitStatusSignature, - sections, - sectionHeights, - loadedIndices: Array.from(loadedIndicesRef.current).filter( - (index) => !sections[index]?.loading - ), - scrollTop: preservedScrollTop, - sideBySide - }) - }, [ - combinedGitStatusSignature, - entries.length, - entrySignature, - sectionHeights, - sections, - sideBySide, - viewStateKey - ]) - - useLayoutEffect(() => { - const container = scrollContainerRef.current - if (!container) { - return - } - - const cached = combinedDiffViewStateCache.get(viewStateKey) - if (cached && cached.entrySignature === entrySignature) { - scrollOffsetRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop - } - - let anchorIdleTimerId: number | null = null - let anchorFrameId: number | null = null - const cancelScheduledAnchorPersist = (): void => { - if (anchorIdleTimerId !== null) { - window.clearTimeout(anchorIdleTimerId) - anchorIdleTimerId = null - } - if (anchorFrameId !== null) { - window.cancelAnimationFrame(anchorFrameId) - anchorFrameId = null - } - } - const scheduleSettledAnchorPersist = (): void => { - cancelScheduledAnchorPersist() - anchorIdleTimerId = window.setTimeout(() => { - anchorIdleTimerId = null - if (hasDirectScrollInput()) { - // Why: the idle timer can fire mid-wheel while TanStack still shows a transitional virtual window. - scheduleSettledAnchorPersist() - return - } - anchorFrameId = window.requestAnimationFrame(() => { - anchorFrameId = null - persistCombinedDiffScrollAnchor() - }) - }, 150) - } - - const updateCachedScrollPosition = ({ - recordDomAnchor, - scheduleSettled, - scrollTop, - writeAnchor - }: { - recordDomAnchor: boolean - scheduleSettled: boolean - scrollTop: number - writeAnchor: boolean - }): void => { - const existing = combinedDiffViewStateCache.get(viewStateKey) - scrollOffsetRef.current = scrollTop - setWithLRU(combinedDiffScrollTopCache, viewStateKey, scrollTop) - if (writeAnchor) { - if (recordDomAnchor) { - persistCombinedDiffScrollAnchor() - } else { - writeCombinedDiffScrollAnchor() - } - } - if (scheduleSettled) { - scheduleSettledAnchorPersist() - } - updateCombinedDiffScrollbar() - if (!existing || existing.entrySignature !== entrySignature) { - return - } - setWithLRU(combinedDiffViewStateCache, viewStateKey, { - ...existing, - scrollTop - }) - } - lastScrollHeightRef.current = container.scrollHeight - const handleScroll = (event: Event): void => { - const scrollTop = container.scrollTop - const scrollHeight = container.scrollHeight - const maxScrollTop = Math.max(0, scrollHeight - container.clientHeight) - const shrank = scrollHeight < lastScrollHeightRef.current - 1 - lastScrollHeightRef.current = scrollHeight - if (programmaticScrollMarks.consume(event, scrollTop, maxScrollTop)) { - updateCombinedDiffScrollbar() - return - } - if (shrank && scrollTop >= maxScrollTop - 1 && scrollOffsetRef.current > maxScrollTop + 1) { - // Why: pinned at a just-shrunk max from an unreachable offset is a browser clamp, not user input — re-pin, don't record it. - setClampRestoreCount((count) => count + 1) - updateCombinedDiffScrollbar() - return - } - // Why: any unmarked scroll is the user's — even events delayed past their window by main-thread jank. - recordCombinedDiffVirtualScrollAnchor(scrollTop) - updateCachedScrollPosition({ - recordDomAnchor: false, - scheduleSettled: true, - scrollTop, - writeAnchor: true - }) - } - - // Why: detach in the layout phase so the outgoing tab snapshots its real scroll before teardown fires a reset-to-top scroll. - updateCombinedDiffScrollbar() - const resizeObserver = new ResizeObserver(updateCombinedDiffScrollbar) - resizeObserver.observe(container) - container.addEventListener('scroll', handleScroll) - return () => { - cancelScheduledAnchorPersist() - if (latestDomScrollAnchorRef.current) { - scrollAnchorRef.current = latestDomScrollAnchorRef.current - } - updateCachedScrollPosition({ - recordDomAnchor: false, - scheduleSettled: false, - scrollTop: scrollOffsetRef.current, - writeAnchor: true - }) - resizeObserver.disconnect() - container.removeEventListener('scroll', handleScroll) - } - }, [ - entrySignature, - hasDirectScrollInput, - persistCombinedDiffScrollAnchor, - programmaticScrollMarks, - recordCombinedDiffVirtualScrollAnchor, - sections.length, - updateCombinedDiffScrollbar, - writeCombinedDiffScrollAnchor, - viewStateKey - ]) - - useLayoutEffect(() => { - updateCombinedDiffScrollbar() - const container = scrollContainerRef.current - if (!container || container.scrollTop <= 0) { - return - } - - let frameId: number | null = null - const timerId = window.setTimeout(() => { - if (!container.isConnected || hasDirectScrollInput()) { - return - } - frameId = window.requestAnimationFrame(() => { - frameId = null - persistCombinedDiffScrollAnchor() - }) - }, 300) - - return () => { - window.clearTimeout(timerId) - if (frameId !== null) { - window.cancelAnimationFrame(frameId) - } - } - }, [ - hasDirectScrollInput, - persistCombinedDiffScrollAnchor, - sectionHeights, - sections, - updateCombinedDiffScrollbar - ]) - - const openAlternateDiff = useCallback(() => { - if (!file.combinedAlternate) { - return - } - - if (file.combinedAlternate.source === 'combined-all') { - openAllDiffs(file.worktreeId, file.filePath) - return - } - - if (branchSummary && branchSummary.status === 'ready') { - openBranchAllDiffs(file.worktreeId, file.filePath, branchSummary, { - source: 'combined-all' - }) - } - }, [branchSummary, file, openAllDiffs, openBranchAllDiffs]) - - const handleCombinedDiffScrollbarPointerDown = useCallback( - (event: React.PointerEvent) => { - const container = scrollContainerRef.current - if (!container) { - return - } - - event.preventDefault() - markDirectScrollInput() - const track = event.currentTarget - const thumb = - event.target instanceof HTMLElement - ? event.target.closest('[data-combined-diff-scrollbar-thumb]') - : null - - const getLiveThumbHeight = (): number => { - const trackHeight = Math.max(1, track.getBoundingClientRect().height) - return Math.min( - trackHeight, - Math.max( - COMBINED_DIFF_SCROLLBAR_THUMB_MIN_HEIGHT, - (container.clientHeight / container.scrollHeight) * trackHeight - ) - ) - } - - const getScrollTopForPointer = (clientY: number, grabOffset: number): number => { - const trackRect = track.getBoundingClientRect() - const trackHeight = Math.max(1, trackRect.height) - const thumbHeight = getLiveThumbHeight() - const maxThumbTop = Math.max(1, trackHeight - thumbHeight) - const maxScrollTop = Math.max(1, container.scrollHeight - container.clientHeight) - const thumbTop = Math.max(0, Math.min(maxThumbTop, clientY - trackRect.top - grabOffset)) - return (thumbTop / maxThumbTop) * maxScrollTop - } - - const grabOffset = thumb - ? event.clientY - thumb.getBoundingClientRect().top - : getLiveThumbHeight() / 2 - - if (!thumb) { - container.scrollTop = getScrollTopForPointer(event.clientY, grabOffset) - updateCombinedDiffScrollbar() - } - - const handlePointerMove = (moveEvent: PointerEvent): void => { - moveEvent.preventDefault() - markDirectScrollInput() - container.scrollTop = getScrollTopForPointer(moveEvent.clientY, grabOffset) - updateCombinedDiffScrollbar() - } - cleanupActiveScrollbarDrag() - let cleanupPointerDrag: CombinedDiffScrollbarDragCleanup - cleanupPointerDrag = beginCombinedDiffScrollbarDrag({ - track, - pointerId: event.pointerId, - onPointerMove: handlePointerMove, - onEnd: () => { - if (activeScrollbarDragCleanupRef.current === cleanupPointerDrag) { - activeScrollbarDragCleanupRef.current = null - } - } - }) - activeScrollbarDragCleanupRef.current = cleanupPointerDrag - }, - [cleanupActiveScrollbarDrag, markDirectScrollInput, updateCombinedDiffScrollbar] - ) - - const handleCopyNotes = useCallback(async (): Promise => { - if (diffCommentCount === 0) { - return - } - try { - await window.api.ui.writeClipboardText(diffCommentsPrompt) - if (!notesCopyMountedRef.current) { - return - } - clearNotesCopiedResetTimer() - setNotesCopied(true) - notesCopiedResetTimerRef.current = window.setTimeout(() => { - setNotesCopied(false) - notesCopiedResetTimerRef.current = null - }, 1500) - } catch { - // Why: clipboard writes can fail while the app is unfocused; keep the popover non-blocking. - } - }, [clearNotesCopiedResetTimer, diffCommentCount, diffCommentsPrompt]) - - const handleConfirmClearNotes = useCallback(async (): Promise => { - if (diffCommentCount === 0 || isClearingNotes) { - return - } - setIsClearingNotes(true) - try { - const ok = await clearDiffComments(file.worktreeId) - if (!mountedRef.current) { - return - } - if (ok) { - setClearNotesDialogOpen(false) - } else { - toast.error( - translate( - 'auto.components.editor.CombinedDiffViewer.45cf23b418', - 'Failed to clear notes.' - ) - ) - } - } finally { - if (mountedRef.current) { - setIsClearingNotes(false) - } - } - }, [clearDiffComments, diffCommentCount, file.worktreeId, isClearingNotes]) - - const commitBody = getCombinedDiffCommitMessageBody( - commitCompare?.message, - commitCompare?.subject - ) - const commitHeader = - isCommitMode && commitCompare ? ( -
-
-
- {commitCompare.subject && ( - - -
- {commitCompare.subject} -
-
- - {commitCompare.subject} - -
- )} - {commitBody && ( -
- {commitBody} -
- )} -
- - {commitCompare.compareRef} - -
-
- ) : null - - if (sections.length === 0 && (file.skippedConflicts?.length ?? 0) > 0) { - return ( -
- {commitHeader} -
-
-
- {translate( - 'auto.components.editor.CombinedDiffViewer.820ec01f24', - 'Conflicted files are reviewed separately' - )} -
-
- {translate( - 'auto.components.editor.CombinedDiffViewer.eb5f40e49c', - 'This diff view excludes unresolved conflicts because the normal two-way diff pipeline is not conflict-safe.' - )} -
-
- {file.skippedConflicts!.map((entry) => entry.path).join(', ')} -
-
- -
-
-
-
- ) - } - - if (sections.length === 0) { - return ( -
- {commitHeader} -
- {translate( - 'auto.components.editor.CombinedDiffViewer.fd8892b120', - 'No changes to display' - )} -
-
- ) - } - - const skippedConflictNotice = - (file.skippedConflicts?.length ?? 0) > 0 ? ( -
-
- {translate( - 'auto.components.editor.CombinedDiffViewer.820ec01f24', - 'Conflicted files are reviewed separately' - )} -
-
- {file.skippedConflicts!.length}{' '} - {translate('auto.components.editor.CombinedDiffViewer.689b99f8ad', 'unresolved conflict')} - {file.skippedConflicts!.length === 1 ? '' : 's'}{' '} - {translate( - 'auto.components.editor.CombinedDiffViewer.39e73e7181', - 'were excluded from this diff view.' - )} -
-
- -
-
- ) : null - const allSectionsCollapsed = sections.every((section) => section.collapsed) - - return ( - <> -
-
-
- {fileTreeCollapsed && ( - - - - - - {translate( - 'auto.components.editor.CombinedDiffViewer.b6c3b84476', - 'Show file tree' - )} - - - )} - - {sections.length}{' '} - {translate('auto.components.editor.CombinedDiffViewer.7e7ca60816', 'changed files')} - {(isAllMode || isBranchMode) && branchCompare - ? translate( - 'auto.components.editor.CombinedDiffViewer.6094135eec', - ' vs {{value0}}', - { value0: branchCompare.baseRef } - ) - : ''} - {isCommitMode && commitCompare - ? translate( - 'auto.components.editor.CombinedDiffViewer.724a13568d', - ' in {{value0}}', - { value0: commitCompare.compareRef } - ) - : ''} - - {diffCommentCount > 0 && ( -
- - - - - - void handleCopyNotes()} - onClear={() => setClearNotesDialogOpen(true)} - /> - - - -
- )} -
-
- {file.combinedAlternate && ( - - )} - - - -
-
- - {commitHeader} -
- -
-
- {skippedConflictNotice} -
- {combinedDiffVirtualItems.map((virtualItem) => { - const section = sections[virtualItem.index] - if (!section) { - return null - } - - return ( -
- { - const fileNotes = diffCommentsForWorktree.filter( - (comment) => comment.filePath === section.path - ) - return fileNotes.length > 0 ? ( - - ) : null - }} - /> -
- ) - })} -
-
- {scrollThumb.visible && ( - -
-
- { - if (!open && !isClearingNotes) { - setClearNotesDialogOpen(false) - } else if (open) { - setClearNotesDialogOpen(true) - } - }} - > - - - - {translate('auto.components.editor.CombinedDiffViewer.948a5fd6c8', 'Clear Notes')} - - - {translate('auto.components.editor.CombinedDiffViewer.84898c548d', 'Clear')}{' '} - {diffCommentCount}{' '} - {diffCommentCount === 1 - ? translate('auto.components.editor.CombinedDiffViewer.8ab3248fd8', 'note') - : translate('auto.components.editor.CombinedDiffViewer.0fb870a0fe', 'notes')}{' '} - {translate( - 'auto.components.editor.CombinedDiffViewer.80a286d8f5', - 'from this worktree?' - )} - - - - - - - - - - ) -} - -function DiffNotesPreviewPopover({ - comments, - totalCount, - copied, - onCopy, - onClear -}: { - comments: DiffComment[] - totalCount: number - copied: boolean - onCopy: () => void - onClear: () => void -}): React.JSX.Element { - const remainingCount = Math.max(0, totalCount - comments.length) - - return ( -
-
-
- - - {translate('auto.components.editor.CombinedDiffViewer.bb84b4c374', 'AI notes')} - - - {totalCount} - -
-
- - -
-
-
- {comments.map((comment) => ( -
-
- {comment.filePath} - {comment.sentAt ? ( - - {translate('auto.components.editor.CombinedDiffViewer.1da745c551', 'Sent')} - - ) : null} - - {getDiffCommentLineLabel(comment, true)} - -
-
- {comment.body} -
-
- ))} - {remainingCount > 0 && ( -
- {remainingCount}{' '} - {translate('auto.components.editor.CombinedDiffViewer.e3b9a6ce02', 'more')} - {remainingCount === 1 - ? translate('auto.components.editor.CombinedDiffViewer.8ab3248fd8', 'note') - : translate('auto.components.editor.CombinedDiffViewer.0fb870a0fe', 'notes')}{' '} - {translate('auto.components.editor.CombinedDiffViewer.35cc27aeb2', 'in Source Control')} -
- )} -
-
- ) -} diff --git a/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx b/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx new file mode 100644 index 00000000000..318a2f3b268 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/CombinedDiffViewer.tsx @@ -0,0 +1,383 @@ +import React, { useCallback, useRef, useState } from 'react' +import { useAppStore } from '@/store' +import { createProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks' +import { useWorkspaceFileBrowserActionPredicate } from '@/lib/file-preview' +import { selectWorktreeDiffCommentsOrEmpty } from '@/store/worktree-diff-comments-selector' +import type { OpenFile } from '@/store/slices/editor' +import '@/lib/monaco-setup' +import type { DiffSection } from '../diff-section-types' +import { + EMPTY_GIT_BRANCH_ENTRIES, + EMPTY_GIT_STATUS_ENTRIES, + useCombinedDiffEntrySet +} from './resolve-changes/use-combined-diff-entry-set' +import { useCombinedDiffSectionLoadRegistry } from './load-sections/combined-diff-section-load-registry' +import { useCombinedDiffSectionLoader } from './load-sections/use-combined-diff-section-loader' +import { useCombinedDiffSectionRetry } from './load-sections/use-combined-diff-section-retry' +import { useCombinedDiffSectionRevalidation } from './load-sections/use-combined-diff-section-revalidation' +import { useCombinedDiffViewPersist } from './remember-view/use-combined-diff-view-persist' +import { useCombinedDiffViewRestore } from './remember-view/use-combined-diff-view-restore' +import { useCombinedDiffDirectScrollInput } from './scroll-viewport/use-combined-diff-direct-scroll-input' +import { useCombinedDiffScrollAnchors } from './scroll-viewport/use-combined-diff-scroll-anchors' +import { useCombinedDiffScrollPersistence } from './scroll-viewport/use-combined-diff-scroll-persistence' +import { useCombinedDiffScrollbar } from './scroll-viewport/use-combined-diff-scrollbar' +import { useCombinedDiffVirtualizer } from './scroll-viewport/use-combined-diff-virtualizer' +import { CombinedDiffSectionList } from './scroll-viewport/combined-diff-section-list' +import { CombinedDiffFileTree } from './browse-files/combined-diff-file-tree' +import { useCombinedDiffTreeNavigation } from './browse-files/use-combined-diff-tree-navigation' +import { CombinedDiffCommitHeader } from './review-controls/combined-diff-commit-header' +import { CombinedDiffToolbar } from './review-controls/combined-diff-toolbar' +import { ClearDiffNotesDialog } from './review-controls/combined-diff-notes-popover' +import { + CombinedDiffNoChangesEmptyState, + CombinedDiffSkippedConflictNotice, + CombinedDiffSkippedConflictsEmptyState +} from './review-controls/combined-diff-skipped-conflicts' +import { useCombinedDiffNotesActions } from './review-controls/use-combined-diff-notes-actions' +import { useCombinedDiffSectionActions } from './review-controls/use-combined-diff-section-actions' +import { useCombinedDiffViewPreferences } from './review-controls/use-combined-diff-view-preferences' + +export default function CombinedDiffViewer({ + file, + viewStateKey +}: { + file: OpenFile + viewStateKey: string +}): React.JSX.Element { + const settings = useAppStore((s) => s.settings) + const gitStatusEntries = useAppStore( + (s) => s.gitStatusByWorktree[file.worktreeId] ?? EMPTY_GIT_STATUS_ENTRIES + ) + const liveBranchEntries = useAppStore( + (s) => s.gitBranchChangesByWorktree[file.worktreeId] ?? EMPTY_GIT_BRANCH_ENTRIES + ) + const branchSummary = useAppStore((s) => s.gitBranchCompareSummaryByWorktree[file.worktreeId]) + const openAllDiffs = useAppStore((s) => s.openAllDiffs) + const openConflictReview = useAppStore((s) => s.openConflictReview) + const openBranchAllDiffs = useAppStore((s) => s.openBranchAllDiffs) + const updateSettings = useAppStore((s) => s.updateSettings) + const clearDiffComments = useAppStore((s) => s.clearDiffComments) + const diffCommentsForWorktree = useAppStore((s) => + selectWorktreeDiffCommentsOrEmpty(s, file.worktreeId) + ) + const activeGroupId = useAppStore((s) => s.activeGroupIdByWorktree[file.worktreeId]) + const canOpenWorkspaceFileBrowserForPath = useWorkspaceFileBrowserActionPredicate(file.worktreeId) + const isDark = + settings?.theme === 'dark' || + (settings?.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) + + const [sections, setSections] = useState([]) + const [sectionHeights, setSectionHeights] = useState>({}) + const [generation, setGeneration] = useState(0) + // Why: a browser scroll clamp must re-pin the restore without being recorded as user intent. + const [clampRestoreCount, setClampRestoreCount] = useState(0) + const [programmaticScrollMarks] = useState(createProgrammaticScrollMarks) + const scrollContainerRef = useRef(null) + + const registry = useCombinedDiffSectionLoadRegistry(sections) + const entrySet = useCombinedDiffEntrySet({ + file, + gitStatusEntries, + liveBranchEntries, + sectionsRef: registry.sectionsRef + }) + const notes = useCombinedDiffNotesActions({ + clearDiffComments, + diffCommentsForWorktree, + worktreeId: file.worktreeId + }) + const preferences = useCombinedDiffViewPreferences({ + combinedDiffFileTreeVisibleByDefault: settings?.combinedDiffFileTreeVisibleByDefault, + diffDefaultView: settings?.diffDefaultView, + diffWordWrap: settings?.diffWordWrap, + registry, + setSections, + updateSettings + }) + const restore = useCombinedDiffViewRestore({ + entrySet, + gitStatusEntries, + registry, + setGeneration, + setSectionHeights, + setSections, + setSideBySide: preferences.setSideBySide, + viewStateKey + }) + const loadSection = useCombinedDiffSectionLoader({ + entrySet, + file, + registry, + sectionCount: sections.length, + setSectionHeights, + setSections + }) + const { ensureSectionLoaded, requestSectionReload, retrySection } = useCombinedDiffSectionRetry({ + invalidateViewStateCache: restore.invalidateViewStateCache, + registry, + setSectionHeights, + setSections + }) + + const { hasDirectScrollInput, markDirectScrollInput } = useCombinedDiffDirectScrollInput() + const { cleanupActiveScrollbarDrag, handleScrollbarPointerDown, scrollThumb, updateScrollbar } = + useCombinedDiffScrollbar({ markDirectScrollInput, scrollContainerRef }) + const virtualizer = useCombinedDiffVirtualizer({ + generation, + programmaticScrollMarks, + renderedIndicesRef: registry.renderedIndicesRef, + scrollContainerRef, + scrollOffsetRef: restore.scrollOffsetRef, + sectionHeights, + sections, + sideBySide: preferences.sideBySide + }) + const anchors = useCombinedDiffScrollAnchors({ + clampRestoreCount, + generation, + hasDirectScrollInput, + latestDomScrollAnchorRef: restore.latestDomScrollAnchorRef, + programmaticScrollMarks, + scrollAnchorRef: restore.scrollAnchorRef, + scrollContainerRef, + scrollOffsetRef: restore.scrollOffsetRef, + sections, + sectionsRef: registry.sectionsRef, + sideBySide: preferences.sideBySide, + totalSize: virtualizer.getTotalSize(), + viewStateKey, + virtualizer + }) + + const toggleSection = useCallback( + (index: number) => { + const shouldLoadAfterExpand = registry.sectionsRef.current[index]?.collapsed ?? false + setSections((prev) => + prev.map((s, i) => (i === index ? { ...s, collapsed: !s.collapsed } : s)) + ) + if (shouldLoadAfterExpand) { + registry.loadSchedulerRef.current.request(index) + } + }, + [registry.loadSchedulerRef, registry.sectionsRef] + ) + + const treeNavigation = useCombinedDiffTreeNavigation({ + ensureSectionLoaded, + entrySignature: entrySet.entrySignature, + markDirectScrollInput, + scrollToIndex: anchors.scrollToSectionIndex, + sections, + sectionsRef: registry.sectionsRef, + toggleSection, + treeMode: entrySet.treeMode + }) + const combinedGitStatusSignature = useCombinedDiffSectionRevalidation({ + file, + gitStatusEntries, + registry, + requestSectionReload, + sectionIndexByKeyRef: treeNavigation.sectionIndexByKeyRef, + sections, + shouldAutoReloadFromGitStatus: entrySet.shouldAutoReloadFromGitStatus, + treeMode: entrySet.treeMode + }) + const { handleSectionSaveRef, modifiedEditorsRef, openSection, openSectionPreview } = + useCombinedDiffSectionActions({ + activeGroupId, + branchCompare: entrySet.branchCompare, + canOpenWorkspaceFileBrowserForPath, + commitCompare: entrySet.commitCompare, + file, + isAllMode: entrySet.isAllMode, + isBranchMode: entrySet.isBranchMode, + isCommitMode: entrySet.isCommitMode, + sections, + sectionsRef: registry.sectionsRef, + setSectionHeights, + setSections + }) + + useCombinedDiffViewPersist({ + combinedGitStatusSignature, + entryCount: entrySet.entries.length, + entrySignature: entrySet.entrySignature, + loadedIndicesRef: registry.loadedIndicesRef, + scrollContainerRef, + sectionHeights, + sections, + sideBySide: preferences.sideBySide, + viewStateKey + }) + useCombinedDiffScrollPersistence({ + anchors, + entrySignature: entrySet.entrySignature, + hasDirectScrollInput, + latestDomScrollAnchorRef: restore.latestDomScrollAnchorRef, + programmaticScrollMarks, + scrollAnchorRef: restore.scrollAnchorRef, + scrollContainerRef, + scrollOffsetRef: restore.scrollOffsetRef, + sectionCount: sections.length, + sectionHeights, + sections, + setClampRestoreCount, + updateScrollbar, + viewStateKey + }) + + const openAlternateDiff = useCallback(() => { + if (!file.combinedAlternate) { + return + } + + if (file.combinedAlternate.source === 'combined-all') { + openAllDiffs(file.worktreeId, file.filePath) + return + } + + if (branchSummary && branchSummary.status === 'ready') { + openBranchAllDiffs(file.worktreeId, file.filePath, branchSummary, { + source: 'combined-all' + }) + } + }, [branchSummary, file, openAllDiffs, openBranchAllDiffs]) + + const { setScrollSurfaceMounted } = notes + const setScrollContainerRef = useCallback( + (node: HTMLDivElement | null) => { + scrollContainerRef.current = node + setScrollSurfaceMounted(node !== null) + if (node === null) { + cleanupActiveScrollbarDrag() + return + } + window.requestAnimationFrame(updateScrollbar) + }, + [cleanupActiveScrollbarDrag, setScrollSurfaceMounted, updateScrollbar] + ) + + const skippedConflicts = file.skippedConflicts + const reviewSkippedConflicts = useCallback(() => { + openConflictReview( + file.worktreeId, + file.filePath, + (skippedConflicts ?? []).map((entry) => ({ + path: entry.path, + conflictKind: entry.conflictKind + })), + 'combined-diff-exclusion' + ) + }, [file.filePath, file.worktreeId, openConflictReview, skippedConflicts]) + + const commitHeader = + entrySet.isCommitMode && entrySet.commitCompare ? ( + + ) : null + + if (sections.length === 0 && (skippedConflicts?.length ?? 0) > 0) { + return ( + + ) + } + + if (sections.length === 0) { + return + } + + const skippedConflictNotice = + (skippedConflicts?.length ?? 0) > 0 ? ( + + ) : null + const allSectionsCollapsed = sections.every((section) => section.collapsed) + + return ( + <> +
+ void notes.handleCopyNotes()} + onOpenAlternateDiff={openAlternateDiff} + onOpenClearNotes={() => notes.setClearNotesDialogOpen(true)} + onShowFileTree={() => preferences.setFileTreeCollapsed(false)} + previewDiffComments={notes.previewDiffComments} + sectionCount={sections.length} + setAllSectionsCollapsed={preferences.setAllSectionsCollapsed} + sideBySide={preferences.sideBySide} + toggleDiffWordWrap={preferences.toggleDiffWordWrap} + toggleSideBySide={preferences.toggleSideBySide} + /> + + {commitHeader} +
+ + +
+
+ void notes.handleConfirmClearNotes()} + open={notes.clearNotesDialogVisible} + setOpen={notes.setClearNotesDialogOpen} + /> + + ) +} diff --git a/src/renderer/src/components/editor/combined-diff-file-tree-model.ts b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-filter.ts similarity index 53% rename from src/renderer/src/components/editor/combined-diff-file-tree-model.ts rename to src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-filter.ts index fc3c440fc95..b3ae62d9f56 100644 --- a/src/renderer/src/components/editor/combined-diff-file-tree-model.ts +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-filter.ts @@ -1,16 +1,18 @@ import { basename } from '@/lib/path' -import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types' -import type { GitStatusEntry } from '../../../../shared/git-status-types' -import { isClipboardTextByteLengthOverLimit } from '../../../../shared/clipboard-text' +import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types' +import { isClipboardTextByteLengthOverLimit } from '../../../../../../shared/clipboard-text' import { buildSourceControlTree, compactSourceControlTree, flattenSourceControlTree } from '@/components/right-sidebar/source-control-tree' - -export type CombinedDiffFileTreeMode = 'all' | 'uncommitted' | 'branch' | 'commit' -export type CombinedDiffFileTreeEntry = GitStatusEntry | GitBranchChangeEntry -export type CombinedDiffBranchTreeArea = 'combined-branch' | 'combined-commit' +import { + getCombinedDiffFileTreeSectionKey, + isGitStatusEntry, + type CombinedDiffBranchTreeArea, + type CombinedDiffFileTreeEntry, + type CombinedDiffFileTreeMode +} from '../resolve-changes/combined-diff-section-identity' export const NO_EXTENSION_KEY = '(no extension)' export const COMBINED_DIFF_FILE_TREE_QUERY_MAX_BYTES = 2 * 1024 @@ -22,68 +24,6 @@ export function isCombinedDiffFileTreeQueryTooLarge( return isClipboardTextByteLengthOverLimit(query, maxBytes) } -export function getCombinedDiffFileTreeSectionKey( - mode: CombinedDiffFileTreeMode, - entry: CombinedDiffFileTreeEntry -): string { - if ((mode === 'all' || mode === 'uncommitted') && 'area' in entry) { - return `${entry.area}:${entry.path}` - } - return `${mode === 'commit' ? 'combined-commit' : 'combined-branch'}:${entry.path}` -} - -export function createCombinedDiffSectionIndexMap( - sections: readonly { key: string }[] -): Map { - return new Map(sections.map((section, index) => [section.key, index])) -} - -export function getCombinedDiffFileTreeNavigationIndex({ - mode, - entry, - sectionIndexByKey -}: { - mode: CombinedDiffFileTreeMode - entry: CombinedDiffFileTreeEntry - sectionIndexByKey: ReadonlyMap -}): number | null { - return sectionIndexByKey.get(getCombinedDiffFileTreeSectionKey(mode, entry)) ?? null -} - -export function handleCombinedDiffFileTreeNavigation({ - mode, - entry, - sections, - sectionIndexByKey, - toggleSection, - loadSection, - scrollToIndex -}: { - mode: CombinedDiffFileTreeMode - entry: CombinedDiffFileTreeEntry - sections: readonly { collapsed: boolean }[] - sectionIndexByKey: ReadonlyMap - toggleSection: (index: number) => void - loadSection?: (index: number) => void - scrollToIndex: (index: number) => void -}): number | null { - const index = getCombinedDiffFileTreeNavigationIndex({ mode, entry, sectionIndexByKey }) - if (index === null || !sections[index]) { - return null - } - - if (sections[index].collapsed) { - toggleSection(index) - } - loadSection?.(index) - scrollToIndex(index) - return index -} - -export function isGitStatusEntry(entry: CombinedDiffFileTreeEntry): entry is GitStatusEntry { - return 'area' in entry -} - export function getEntryExtension(entry: CombinedDiffFileTreeEntry): string { const name = basename(entry.path) const index = name.lastIndexOf('.') diff --git a/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-navigation.ts b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-navigation.ts new file mode 100644 index 00000000000..7f8a16ef590 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-navigation.ts @@ -0,0 +1,47 @@ +import { + getCombinedDiffFileTreeSectionKey, + type CombinedDiffFileTreeEntry, + type CombinedDiffFileTreeMode +} from '../resolve-changes/combined-diff-section-identity' + +export function getCombinedDiffFileTreeNavigationIndex({ + mode, + entry, + sectionIndexByKey +}: { + mode: CombinedDiffFileTreeMode + entry: CombinedDiffFileTreeEntry + sectionIndexByKey: ReadonlyMap +}): number | null { + return sectionIndexByKey.get(getCombinedDiffFileTreeSectionKey(mode, entry)) ?? null +} + +export function handleCombinedDiffFileTreeNavigation({ + mode, + entry, + sections, + sectionIndexByKey, + toggleSection, + loadSection, + scrollToIndex +}: { + mode: CombinedDiffFileTreeMode + entry: CombinedDiffFileTreeEntry + sections: readonly { collapsed: boolean }[] + sectionIndexByKey: ReadonlyMap + toggleSection: (index: number) => void + loadSection?: (index: number) => void + scrollToIndex: (index: number) => void +}): number | null { + const index = getCombinedDiffFileTreeNavigationIndex({ mode, entry, sectionIndexByKey }) + if (index === null || !sections[index]) { + return null + } + + if (sections[index].collapsed) { + toggleSection(index) + } + loadSection?.(index) + scrollToIndex(index) + return index +} diff --git a/src/renderer/src/components/editor/combined-diff-file-tree-row.tsx b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx similarity index 95% rename from src/renderer/src/components/editor/combined-diff-file-tree-row.tsx rename to src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx index b78e24da43c..e7ccab9b470 100644 --- a/src/renderer/src/components/editor/combined-diff-file-tree-row.tsx +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree-row.tsx @@ -6,18 +6,18 @@ import { getFileTypeIcon } from '@/lib/file-type-icons' import { basename, dirname, joinPath } from '@/lib/path' import { cn } from '@/lib/utils' import { WORKSPACE_FILE_PATH_MIME } from '@/lib/workspace-file-drag' -import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types' +import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types' import type { GitFileStatus, GitStagingArea, GitStatusEntry -} from '../../../../shared/git-status-types' +} from '../../../../../../shared/git-status-types' import { getCombinedDiffFileTreeSectionKey, type CombinedDiffBranchTreeArea, type CombinedDiffFileTreeEntry, type CombinedDiffFileTreeMode -} from './combined-diff-file-tree-model' +} from '../resolve-changes/combined-diff-section-identity' export type CombinedDiffTreeNode = SourceControlTreeNode< GitStatusEntry | GitBranchChangeEntry, diff --git a/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.test.ts similarity index 92% rename from src/renderer/src/components/editor/CombinedDiffFileTree.test.ts rename to src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.test.ts index 7a21281213d..965fe2d70a3 100644 --- a/src/renderer/src/components/editor/CombinedDiffFileTree.test.ts +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.test.ts @@ -1,18 +1,20 @@ import { describe, expect, it, vi } from 'vitest' import { createCombinedDiffSectionIndexMap, + getCombinedDiffFileTreeSectionKey +} from '../resolve-changes/combined-diff-section-identity' +import { getCombinedDiffFileTreeNavigationIndex, - getCombinedDiffFileTreeSectionKey, handleCombinedDiffFileTreeNavigation -} from './CombinedDiffFileTree' +} from './combined-diff-file-tree-navigation' import { COMBINED_DIFF_FILE_TREE_QUERY_MAX_BYTES, getCombinedDiffBranchEntriesInTreeOrder, getFilteredCombinedDiffFileTreeEntries, isCombinedDiffFileTreeQueryTooLarge -} from './combined-diff-file-tree-model' -import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types' -import type { GitStatusEntry } from '../../../../shared/git-status-types' +} from './combined-diff-file-tree-filter' +import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../../../shared/git-status-types' describe('CombinedDiffFileTree navigation mapping', () => { it('disambiguates uncommitted entries with the same path by area', () => { diff --git a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx similarity index 97% rename from src/renderer/src/components/editor/CombinedDiffFileTree.tsx rename to src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx index 99a53af8795..5afb79ccac3 100644 --- a/src/renderer/src/components/editor/CombinedDiffFileTree.tsx +++ b/src/renderer/src/components/editor/combined-diff/browse-files/combined-diff-file-tree.tsx @@ -10,27 +10,22 @@ import { compactSourceControlTree, flattenSourceControlTree } from '@/components/right-sidebar/source-control-tree' -import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types' -import type { GitStagingArea, GitStatusEntry } from '../../../../shared/git-status-types' +import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types' +import type { GitStagingArea, GitStatusEntry } from '../../../../../../shared/git-status-types' import { getEntryExtension, - getFilteredCombinedDiffFileTreeEntries, + getFilteredCombinedDiffFileTreeEntries +} from './combined-diff-file-tree-filter' +import { isGitStatusEntry, type CombinedDiffBranchTreeArea, type CombinedDiffFileTreeEntry, type CombinedDiffFileTreeMode -} from './combined-diff-file-tree-model' +} from '../resolve-changes/combined-diff-section-identity' import { CombinedDiffFileTreeRow, type CombinedDiffTreeNode } from './combined-diff-file-tree-row' import { useCombinedDiffFileTreeResize } from './use-combined-diff-file-tree-resize' import { translate } from '@/i18n/i18n' -export { - createCombinedDiffSectionIndexMap, - getCombinedDiffFileTreeNavigationIndex, - getCombinedDiffFileTreeSectionKey, - handleCombinedDiffFileTreeNavigation -} from './combined-diff-file-tree-model' - const UNCOMMITTED_AREA_ORDER: readonly GitStagingArea[] = ['unstaged', 'staged', 'untracked'] const UNCOMMITTED_AREA_LABELS: Record = { unstaged: 'Changes', diff --git a/src/renderer/src/components/editor/use-combined-diff-file-tree-resize.test.tsx b/src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-file-tree-resize.test.tsx similarity index 100% rename from src/renderer/src/components/editor/use-combined-diff-file-tree-resize.test.tsx rename to src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-file-tree-resize.test.tsx diff --git a/src/renderer/src/components/editor/use-combined-diff-file-tree-resize.ts b/src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-file-tree-resize.ts similarity index 97% rename from src/renderer/src/components/editor/use-combined-diff-file-tree-resize.ts rename to src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-file-tree-resize.ts index ad87cdf78a2..8b9e9c9b5c1 100644 --- a/src/renderer/src/components/editor/use-combined-diff-file-tree-resize.ts +++ b/src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-file-tree-resize.ts @@ -3,7 +3,7 @@ import { clampCombinedDiffFileTreeWidth, COMBINED_DIFF_FILE_TREE_RESIZE_STEP, computeCombinedDiffFileTreeWidthBounds -} from '../../../../shared/combined-diff-file-tree-width' +} from '../../../../../../shared/combined-diff-file-tree-width' import { useSidebarResize } from '@/hooks/useSidebarResize' import { useAppStore } from '@/store' 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 new file mode 100644 index 00000000000..1d09080ba6b --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/browse-files/use-combined-diff-tree-navigation.ts @@ -0,0 +1,98 @@ +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 { handleCombinedDiffFileTreeNavigation } from './combined-diff-file-tree-navigation' + +export type CombinedDiffTreeNavigation = { + activeTreeSectionKey: string | null + handleTreeNavigate: (entry: GitStatusEntry | GitBranchChangeEntry) => void + sectionIndexByKey: Map + sectionIndexByKeyRef: React.RefObject> + viewedSectionKeys: Set +} + +// Why: navigation targets are passed in rather than imported so this stays a leaf of the tree folder. +export function useCombinedDiffTreeNavigation({ + ensureSectionLoaded, + entrySignature, + markDirectScrollInput, + scrollToIndex, + sections, + sectionsRef, + toggleSection, + treeMode +}: { + ensureSectionLoaded: (index: number) => void + entrySignature: string + markDirectScrollInput: () => void + scrollToIndex: (index: number) => void + sections: DiffSection[] + sectionsRef: React.RefObject + toggleSection: (index: number) => void + treeMode: CombinedDiffFileTreeMode +}): CombinedDiffTreeNavigation { + const sectionIndexByKey = React.useMemo( + () => createCombinedDiffSectionIndexMap(sections), + [sections] + ) + const sectionIndexByKeyRef = useRef>(sectionIndexByKey) + sectionIndexByKeyRef.current = sectionIndexByKey + + const [activeTreeSectionState, setActiveTreeSectionState] = useState<{ + entrySignature: string + key: string | null + }>(() => ({ entrySignature, key: null })) + const activeTreeSectionKey = + activeTreeSectionState.entrySignature === entrySignature ? activeTreeSectionState.key : null + if (activeTreeSectionState.entrySignature !== entrySignature) { + // Why: the tree highlight belongs to one entry set; reset now so it can't flash on another before an Effect would. + setActiveTreeSectionState({ entrySignature, key: null }) + } + const viewedSectionKeys = React.useMemo( + () => new Set(sections.filter((section) => !section.loading).map((section) => section.key)), + [sections] + ) + const handleTreeNavigate = useCallback( + (entry: GitStatusEntry | GitBranchChangeEntry) => { + markDirectScrollInput() + const navigatedIndex = handleCombinedDiffFileTreeNavigation({ + mode: treeMode, + entry, + sections: sectionsRef.current, + sectionIndexByKey, + toggleSection, + loadSection: ensureSectionLoaded, + scrollToIndex + }) + if (navigatedIndex !== null) { + setActiveTreeSectionState({ + entrySignature, + key: sectionsRef.current[navigatedIndex]?.key ?? null + }) + } + }, + [ + ensureSectionLoaded, + entrySignature, + markDirectScrollInput, + scrollToIndex, + sectionIndexByKey, + sectionsRef, + toggleSection, + treeMode + ] + ) + + return { + activeTreeSectionKey, + handleTreeNavigate, + sectionIndexByKey, + sectionIndexByKeyRef, + viewedSectionKeys + } +} diff --git a/src/renderer/src/components/editor/combined-diff-initial-section-load.test.ts b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-initial-section-load.test.ts similarity index 100% rename from src/renderer/src/components/editor/combined-diff-initial-section-load.test.ts rename to src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-initial-section-load.test.ts diff --git a/src/renderer/src/components/editor/combined-diff-initial-section-load.ts b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-initial-section-load.ts similarity index 100% rename from src/renderer/src/components/editor/combined-diff-initial-section-load.ts rename to src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-initial-section-load.ts diff --git a/src/renderer/src/components/editor/combined-diff-load-scheduler.test.ts b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-load-scheduler.test.ts similarity index 100% rename from src/renderer/src/components/editor/combined-diff-load-scheduler.test.ts rename to src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-load-scheduler.test.ts diff --git a/src/renderer/src/components/editor/combined-diff-load-scheduler.ts b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-load-scheduler.ts similarity index 100% rename from src/renderer/src/components/editor/combined-diff-load-scheduler.ts rename to src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-load-scheduler.ts diff --git a/src/renderer/src/components/editor/combined-diff-section-connection.test.ts b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-connection.test.ts similarity index 92% rename from src/renderer/src/components/editor/combined-diff-section-connection.test.ts rename to src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-connection.test.ts index 89254a6f6b1..863ffbc8215 100644 --- a/src/renderer/src/components/editor/combined-diff-section-connection.test.ts +++ b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-connection.test.ts @@ -1,9 +1,9 @@ import { afterEach, describe, expect, it } from 'vitest' -import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../shared/project-group-types' -import type { Repo } from '../../../../shared/repo-types' +import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../../../shared/project-group-types' +import type { Repo } from '../../../../../../shared/repo-types' import { useAppStore } from '@/store' -import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope' import { getCombinedDiffSectionConnectionId } from './combined-diff-section-connection' const initialState = useAppStore.getInitialState() diff --git a/src/renderer/src/components/editor/combined-diff-section-connection.ts b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-connection.ts similarity index 100% rename from src/renderer/src/components/editor/combined-diff-section-connection.ts rename to src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-connection.ts 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 new file mode 100644 index 00000000000..2b234c89350 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-registry.ts @@ -0,0 +1,79 @@ +import { useEffect, useRef } from 'react' +import type { DiffSection } from '../../diff-section-types' +import { + createCombinedDiffLoadScheduler, + type CombinedDiffLoadScheduler +} from './combined-diff-load-scheduler' + +// Why: git rewrites a path several times during a rebase; refetch once the writes stop. +export const COMBINED_DIFF_SECTION_RELOAD_COALESCE_MS = 300 + +export function clearPendingSectionReloadTimers(timers: Map): void { + for (const timer of timers.values()) { + window.clearTimeout(timer) + } + timers.clear() +} + +// Why: one owner for the load bookkeeping the loader, retry and revalidation hooks all mutate, +// and for the callback refs that break the loader <-> retry cycle. +export type CombinedDiffSectionLoadRegistry = { + generationRef: React.RefObject + loadSchedulerRef: React.RefObject + loadSectionRef: React.RefObject<(index: number) => Promise> + loadedIndicesRef: React.RefObject> + loadingIndicesRef: React.RefObject> + reloadTimersRef: React.RefObject> + renderedIndicesRef: React.RefObject> + requestSectionReloadRef: React.RefObject<(index: number) => void> + retrySectionRef: React.RefObject<(index: number) => void> + sectionLoadTokensRef: React.RefObject> + sectionsRef: React.RefObject +} + +export function useCombinedDiffSectionLoadRegistry( + sections: DiffSection[] +): CombinedDiffSectionLoadRegistry { + const loadedIndicesRef = useRef>(new Set()) + const loadingIndicesRef = useRef>(new Set()) + const sectionsRef = useRef([]) + const generationRef = useRef(0) + // Why: per-section reload token, so a sibling's reload can't discard this section's in-flight load. + const sectionLoadTokensRef = useRef>(new Map()) + const renderedIndicesRef = useRef>(new Set()) + const reloadTimersRef = useRef>(new Map()) + 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) + }) + ) + sectionsRef.current = sections + + useEffect(() => { + // Why: React StrictMode replays effect cleanup in dev; reset revives the scheduler for the replayed mount. + const scheduler = loadSchedulerRef.current + const reloadTimers = reloadTimersRef.current + scheduler.reset() + return () => { + clearPendingSectionReloadTimers(reloadTimers) + scheduler.dispose() + } + }, []) + + return { + generationRef, + loadSchedulerRef, + loadSectionRef, + loadedIndicesRef, + loadingIndicesRef, + reloadTimersRef, + renderedIndicesRef, + requestSectionReloadRef, + retrySectionRef, + sectionLoadTokensRef, + sectionsRef + } +} diff --git a/src/renderer/src/components/editor/combined-diff-section-load-state.test.ts b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.test.ts similarity index 97% rename from src/renderer/src/components/editor/combined-diff-section-load-state.test.ts rename to src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.test.ts index 0b647561738..c308fbc6da9 100644 --- a/src/renderer/src/components/editor/combined-diff-section-load-state.test.ts +++ b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import type { GitDiffResult } from '../../../../shared/git-diff-compare-types' -import type { LargeDiffRenderLimit } from './large-diff-render-limit' +import type { GitDiffResult } from '../../../../../../shared/git-diff-compare-types' +import type { LargeDiffRenderLimit } from '../../large-diff-render-limit' import { isUnchangedDiffSectionReload, shouldRequestCombinedDiffSectionLoad diff --git a/src/renderer/src/components/editor/combined-diff-section-load-state.ts b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.ts similarity index 95% rename from src/renderer/src/components/editor/combined-diff-section-load-state.ts rename to src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.ts index 7f789ecd9ed..33bd99ac20f 100644 --- a/src/renderer/src/components/editor/combined-diff-section-load-state.ts +++ b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-state.ts @@ -1,5 +1,5 @@ -import type { DiffLineCounts, LargeDiffRenderLimit } from './large-diff-render-limit' -import type { DiffSection } from './diff-section-types' +import type { DiffLineCounts, LargeDiffRenderLimit } from '../../large-diff-render-limit' +import type { DiffSection } from '../../diff-section-types' // Why: `diffResult === null` subsumes a dirty check — `dirty` is only ever set from a mounted // editor's content compare, which implies content was already loaded. diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-timeout.ts b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-timeout.ts new file mode 100644 index 00000000000..51b69923e18 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/load-sections/combined-diff-section-load-timeout.ts @@ -0,0 +1,34 @@ +// Why: local Electron IPC has no RPC timeout; a hung git diff must become a retryable row error, not permanent "Loading...". +const COMBINED_DIFF_SECTION_LOAD_TIMEOUT_MS = 30_000 + +class CombinedDiffSectionLoadTimeoutError extends Error { + constructor() { + super('Diff did not finish loading.') + this.name = 'CombinedDiffSectionLoadTimeoutError' + } +} + +export function withDiffSectionLoadTimeout(promise: Promise): Promise { + let timeoutId: number | null = null + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = window.setTimeout(() => { + reject(new CombinedDiffSectionLoadTimeoutError()) + }, COMBINED_DIFF_SECTION_LOAD_TIMEOUT_MS) + }) + + return Promise.race([promise, timeoutPromise]).finally(() => { + if (timeoutId !== null) { + window.clearTimeout(timeoutId) + } + }) +} + +export function getDiffSectionLoadErrorMessage(error: unknown): string { + if (error instanceof CombinedDiffSectionLoadTimeoutError) { + return 'Diff did not finish loading.' + } + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : 'Unable to load diff.' +} diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts b/src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts new file mode 100644 index 00000000000..7ea5a406a71 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/load-sections/fetch-combined-diff-section.ts @@ -0,0 +1,95 @@ +import { useAppStore } from '@/store' +import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client' +import { + getRuntimeGitBranchDiff, + getRuntimeGitCommitDiff, + getRuntimeGitDiff +} from '@/runtime/runtime-git-client' +import type { OpenFile } from '@/store/slices/editor' +import type { + GitBranchChangeEntry, + GitDiffResult +} from '../../../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../../../shared/git-status-types' +import { getCombinedDiffSectionConnectionId } from './combined-diff-section-connection' +import { withDiffSectionLoadTimeout } from './combined-diff-section-load-timeout' + +export function fetchCombinedDiffSection({ + branchCompare, + commitCompare, + entry, + file, + isAllMode, + isBranchMode, + isCommitMode +}: { + branchCompare: NonNullable | null + commitCompare: NonNullable | null + entry: GitStatusEntry | GitBranchChangeEntry + file: OpenFile + isAllMode: boolean + isBranchMode: boolean + isCommitMode: boolean +}): Promise { + const connectionId = getCombinedDiffSectionConnectionId( + file.worktreeId, + file.filePath, + entry.path + ) + const state = useAppStore.getState() + const fileSettings = settingsForRuntimeOwner(state.settings, file.runtimeEnvironmentId) + if ((isBranchMode || (isAllMode && !('area' in entry))) && branchCompare) { + return withDiffSectionLoadTimeout( + getRuntimeGitBranchDiff( + { + settings: fileSettings, + worktreeId: file.worktreeId, + worktreePath: file.filePath, + connectionId + }, + { + compare: { + baseRef: branchCompare.baseRef, + baseOid: branchCompare.baseOid!, + headOid: branchCompare.headOid!, + mergeBase: branchCompare.mergeBase! + }, + filePath: entry.path, + oldPath: entry.oldPath + } + ) + ) + } + if (isCommitMode && commitCompare) { + return withDiffSectionLoadTimeout( + getRuntimeGitCommitDiff( + { + settings: fileSettings, + worktreeId: file.worktreeId, + worktreePath: file.filePath, + connectionId + }, + { + commitOid: commitCompare.commitOid, + parentOid: commitCompare.parentOid, + filePath: entry.path, + oldPath: entry.oldPath + } + ) + ) + } + return withDiffSectionLoadTimeout( + getRuntimeGitDiff( + { + settings: fileSettings, + worktreeId: file.worktreeId, + worktreePath: file.filePath, + connectionId + }, + { + filePath: entry.path, + staged: 'area' in entry && entry.area === 'staged' + } + ) + ) +} diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-loader.ts b/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-loader.ts new file mode 100644 index 00000000000..ab5dbe79b0f --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-loader.ts @@ -0,0 +1,220 @@ +import { useCallback, useEffect } from 'react' +import type React from 'react' +import type { OpenFile } from '@/store/slices/editor' +import type { + GitBranchChangeEntry, + GitDiffResult +} from '../../../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../../../shared/git-status-types' +import type { DiffSection } from '../../diff-section-types' +import { getLargeDiffRenderLimit } from '../../large-diff-render-limit' +import { getStoredTextDiffContent, getStoredTextDiffResult } from '../../large-diff-section-content' +import { removeDiffSectionMeasuredHeight } from '../../diff-section-height-cache' +import type { CombinedDiffEntrySet } from '../resolve-changes/use-combined-diff-entry-set' +import { fetchCombinedDiffSection } from './fetch-combined-diff-section' +import { getDiffSectionLoadErrorMessage } from './combined-diff-section-load-timeout' +import { getInitialCombinedDiffSectionLoadIndices } from './combined-diff-initial-section-load' +import { isUnchangedDiffSectionReload } from './combined-diff-section-load-state' +import type { CombinedDiffSectionLoadRegistry } from './combined-diff-section-load-registry' + +export function useCombinedDiffSectionLoader({ + entrySet, + file, + registry, + sectionCount, + setSectionHeights, + setSections +}: { + entrySet: CombinedDiffEntrySet + file: OpenFile + registry: CombinedDiffSectionLoadRegistry + sectionCount: number + setSectionHeights: React.Dispatch>> + setSections: React.Dispatch> +}): (index: number) => void { + const { + allEntries, + branchCompare, + commitCompare, + commitEntries, + entrySignature, + isAllMode, + isBranchMode, + isCommitMode, + renderableBranchEntries, + uncommittedEntries + } = entrySet + const { + generationRef, + loadSchedulerRef, + loadSectionRef, + loadedIndicesRef, + loadingIndicesRef, + requestSectionReloadRef, + sectionLoadTokensRef, + sectionsRef + } = registry + + const loadSectionNow = useCallback( + async (index: number) => { + if (loadedIndicesRef.current.has(index) || loadingIndicesRef.current.has(index)) { + return + } + loadingIndicesRef.current.add(index) + + const gen = generationRef.current + const loadToken = sectionLoadTokensRef.current.get(index) ?? 0 + const entries: (GitStatusEntry | GitBranchChangeEntry)[] = isAllMode + ? allEntries + : isBranchMode + ? renderableBranchEntries + : isCommitMode + ? commitEntries + : uncommittedEntries + const entry = entries[index] + if (!entry) { + loadingIndicesRef.current.delete(index) + return + } + + let result: GitDiffResult + let error: string | undefined + try { + result = await fetchCombinedDiffSection({ + branchCompare, + commitCompare, + entry, + file, + isAllMode, + isBranchMode, + isCommitMode + }) + } catch (err) { + error = getDiffSectionLoadErrorMessage(err) + result = { + kind: 'text', + originalContent: '', + modifiedContent: '', + originalIsBinary: false, + modifiedIsBinary: false + } as GitDiffResult + } + + const largeDiffRenderLimit = + !error && result.kind === 'text' + ? (result.largeDiffRenderLimit ?? + getLargeDiffRenderLimit({ + originalContent: result.originalContent, + modifiedContent: result.modifiedContent + })) + : null + + if (generationRef.current !== gen) { + // Why: the generation reset already cleared the in-flight set, and a newer load for this + // index may own the entry now — deleting it here would hide that load from the guard above. + return + } + loadingIndicesRef.current.delete(index) + if ((sectionLoadTokensRef.current.get(index) ?? 0) !== loadToken) { + // Why: an invalidation landed mid-flight and deferred its reload to this settle point, so + // the refetch happens once here instead of racing a second fetch against this one. + requestSectionReloadRef.current(index) + return + } + const storedContent = getStoredTextDiffContent(result, largeDiffRenderLimit) + const storedResult = getStoredTextDiffResult(result, largeDiffRenderLimit) + loadedIndicesRef.current.add(index) + const current = sectionsRef.current[index] + // A revalidation lands on a section that is already showing content. If the refetch matches + // what's on screen, committing it would swap Monaco models and re-measure for nothing. + const wasShowingContent = current !== undefined && !current.loading + if ( + wasShowingContent && + isUnchangedDiffSectionReload(current, { + diffResult: storedResult, + error, + largeDiffRenderLimit, + originalContent: storedContent.originalContent, + modifiedContent: storedContent.modifiedContent + }) + ) { + return + } + if (wasShowingContent) { + // Why: content really changed, so the old Monaco height no longer describes this row. + setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index)) + } + setSections((prev) => { + return prev.map((s, i) => + i === index + ? { + ...s, + diffResult: storedResult, + originalContent: storedContent.originalContent, + modifiedContent: storedContent.modifiedContent, + loading: false, + error, + largeDiffRenderLimit, + // Why: models are keyed by path, so a changed refetch must not reuse the old model. + contentGeneration: wasShowingContent + ? (s.contentGeneration ?? 0) + 1 + : s.contentGeneration + } + : s + ) + }) + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + branchCompare?.baseOid, + branchCompare?.headOid, + branchCompare?.mergeBase, + allEntries, + commitCompare?.commitOid, + commitCompare?.parentOid, + commitEntries, + file.filePath, + file.runtimeEnvironmentId, + isAllMode, + isBranchMode, + isCommitMode, + renderableBranchEntries, + uncommittedEntries + ] + ) + loadSectionRef.current = loadSectionNow + + // Progressive loading: queue diff content when a section becomes visible. + const loadSection = useCallback( + (index: number) => { + if (sectionsRef.current[index]?.collapsed) { + return + } + loadSchedulerRef.current.request(index) + }, + [loadSchedulerRef, sectionsRef] + ) + + useEffect(() => { + // Why: queue the first rows deterministically so the visible viewport doesn't depend on IntersectionObserver delivery. + const currentSections = sectionsRef.current + for (let index = 0; index < currentSections.length; index += 1) { + if (currentSections[index]?.loading && loadedIndicesRef.current.has(index)) { + loadedIndicesRef.current.delete(index) + } + } + + const initialIndices = getInitialCombinedDiffSectionLoadIndices({ + sectionCount: currentSections.length, + loadedIndices: loadedIndicesRef.current + }) + + for (const index of initialIndices) { + if (!currentSections[index]?.collapsed) { + loadSection(index) + } + } + }, [entrySignature, loadSection, loadedIndicesRef, sectionCount, sectionsRef]) + + return loadSection +} diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-retry.ts b/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-retry.ts new file mode 100644 index 00000000000..8c798482ecb --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-retry.ts @@ -0,0 +1,152 @@ +import { useCallback } from 'react' +import type React from 'react' +import type { DiffSection } from '../../diff-section-types' +import { removeDiffSectionMeasuredHeight } from '../../diff-section-height-cache' +import { shouldRequestCombinedDiffSectionLoad } from './combined-diff-section-load-state' +import { + COMBINED_DIFF_SECTION_RELOAD_COALESCE_MS, + type CombinedDiffSectionLoadRegistry +} from './combined-diff-section-load-registry' + +export type CombinedDiffSectionRetryActions = { + ensureSectionLoaded: (index: number) => void + requestSectionReload: (index: number) => void + retrySection: (index: number) => void +} + +export function useCombinedDiffSectionRetry({ + invalidateViewStateCache, + registry, + setSectionHeights, + setSections +}: { + invalidateViewStateCache: () => void + registry: CombinedDiffSectionLoadRegistry + setSectionHeights: React.Dispatch>> + setSections: React.Dispatch> +}): CombinedDiffSectionRetryActions { + const { + loadSchedulerRef, + loadedIndicesRef, + loadingIndicesRef, + reloadTimersRef, + renderedIndicesRef, + requestSectionReloadRef, + retrySectionRef, + sectionLoadTokensRef, + sectionsRef + } = registry + + const retrySection = useCallback( + (index: number) => { + const collapsed = sectionsRef.current[index]?.collapsed ?? false + loadedIndicesRef.current.delete(index) + loadingIndicesRef.current.delete(index) + invalidateViewStateCache() + // Why: reloading one section must not bump the global generation — that is part of + // the virtualizer item key, so it would remount every rendered Monaco editor (STA-3420). + sectionLoadTokensRef.current.set(index, (sectionLoadTokensRef.current.get(index) ?? 0) + 1) + const coalesced = reloadTimersRef.current.get(index) + if (coalesced !== undefined) { + window.clearTimeout(coalesced) + reloadTimersRef.current.delete(index) + } + setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, index)) + setSections((prev) => + prev.map((section, sectionIndex) => + sectionIndex === index + ? { + ...section, + loading: !collapsed, + error: undefined, + diffResult: null, + originalContent: '', + modifiedContent: '', + largeDiffRenderLimit: null, + contentGeneration: (section.contentGeneration ?? 0) + 1 + } + : section + ) + ) + if (collapsed) { + return + } + loadSchedulerRef.current.rerequest(index) + }, + [ + invalidateViewStateCache, + loadSchedulerRef, + loadedIndicesRef, + loadingIndicesRef, + reloadTimersRef, + sectionLoadTokensRef, + sectionsRef, + setSectionHeights, + setSections + ] + ) + retrySectionRef.current = retrySection + + // Why: invalidation (rebase/commit/external write) revalidates in place — it must not tear the + // section down first. Clearing content up front forces a Monaco remodel even when the refetched + // diff is identical, which is what wedged the renderer during a rebase (STA-3420). + const requestSectionReload = useCallback( + (index: number): void => { + const section = sectionsRef.current[index] + if (!section || section.dirty) { + return + } + loadedIndicesRef.current.delete(index) + invalidateViewStateCache() + sectionLoadTokensRef.current.set(index, (sectionLoadTokensRef.current.get(index) ?? 0) + 1) + if (loadingIndicesRef.current.has(index)) { + // Why: the in-flight load now carries a stale token, so it re-drives this reload when it + // settles. Scheduling one here would fetch the same large diff a second time. + return + } + if (section.collapsed || !renderedIndicesRef.current.has(index)) { + // Why: a rebase invalidates every touched path at once. Refetching off-screen sections is + // unbounded work nobody can see; the row reloads on mount once it scrolls into view. + return + } + // Why: a rebase touches the same path many times over a few seconds. Without coalescing + // each touch refetches a whole diff, and the payload churn alone stalls the renderer. + const pending = reloadTimersRef.current.get(index) + if (pending !== undefined) { + window.clearTimeout(pending) + } + reloadTimersRef.current.set( + index, + window.setTimeout(() => { + reloadTimersRef.current.delete(index) + loadSchedulerRef.current.rerequest(index) + }, COMBINED_DIFF_SECTION_RELOAD_COALESCE_MS) + ) + }, + [ + invalidateViewStateCache, + loadSchedulerRef, + loadedIndicesRef, + loadingIndicesRef, + reloadTimersRef, + renderedIndicesRef, + sectionLoadTokensRef, + sectionsRef + ] + ) + requestSectionReloadRef.current = requestSectionReload + + const ensureSectionLoaded = useCallback( + (index: number): void => { + const section = sectionsRef.current[index] + if (!shouldRequestCombinedDiffSectionLoad(section, loadingIndicesRef.current.has(index))) { + return + } + loadedIndicesRef.current.delete(index) + loadSchedulerRef.current.request(index) + }, + [loadSchedulerRef, loadedIndicesRef, loadingIndicesRef, sectionsRef] + ) + + return { ensureSectionLoaded, requestSectionReload, retrySection } +} diff --git a/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-revalidation.ts b/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-revalidation.ts new file mode 100644 index 00000000000..d8f080a0bc4 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/load-sections/use-combined-diff-section-revalidation.ts @@ -0,0 +1,108 @@ +import React, { useEffect, useRef } from 'react' +import type { OpenFile } from '@/store/slices/editor' +import type { GitStatusEntry } from '../../../../../../shared/git-status-types' +import type { DiffSection } from '../../diff-section-types' +import { + ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, + type EditorPathMutationTarget +} from '../../editor-autosave' +import { buildCombinedGitStatusSignature } from '../resolve-changes/combined-diff-git-status-signature' +import { + getCombinedDiffFileTreeSectionKey, + type CombinedDiffFileTreeMode +} from '../resolve-changes/combined-diff-section-identity' +import type { CombinedDiffSectionLoadRegistry } from './combined-diff-section-load-registry' + +// Why: git status and on-disk writes both revalidate loaded rows; one hook owns both watches so +// they cannot disagree about which sections are eligible. +export function useCombinedDiffSectionRevalidation({ + file, + gitStatusEntries, + registry, + requestSectionReload, + sectionIndexByKeyRef, + sections, + shouldAutoReloadFromGitStatus, + treeMode +}: { + file: OpenFile + gitStatusEntries: GitStatusEntry[] + registry: CombinedDiffSectionLoadRegistry + requestSectionReload: (index: number) => void + sectionIndexByKeyRef: React.RefObject> + sections: DiffSection[] + shouldAutoReloadFromGitStatus: boolean + treeMode: CombinedDiffFileTreeMode +}): string { + const { loadedIndicesRef } = registry + const combinedGitStatusSignature = React.useMemo(() => { + if (!shouldAutoReloadFromGitStatus) { + return '' + } + return buildCombinedGitStatusSignature(sections, gitStatusEntries) + }, [gitStatusEntries, sections, shouldAutoReloadFromGitStatus]) + const prevCombinedGitStatusSignatureRef = useRef(null) + + useEffect(() => { + if (!shouldAutoReloadFromGitStatus) { + prevCombinedGitStatusSignatureRef.current = null + return + } + if (prevCombinedGitStatusSignatureRef.current === null) { + prevCombinedGitStatusSignatureRef.current = combinedGitStatusSignature + return + } + if (prevCombinedGitStatusSignatureRef.current === combinedGitStatusSignature) { + return + } + prevCombinedGitStatusSignatureRef.current = combinedGitStatusSignature + for (const index of loadedIndicesRef.current) { + requestSectionReload(index) + } + }, [ + combinedGitStatusSignature, + loadedIndicesRef, + requestSectionReload, + shouldAutoReloadFromGitStatus + ]) + + useEffect(() => { + if (treeMode !== 'all' && treeMode !== 'uncommitted') { + return + } + const handler = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (!detail || detail.worktreeId !== file.worktreeId) { + return + } + const hasRuntimeOwnerFilter = Object.hasOwn(detail, 'runtimeEnvironmentId') + const targetRuntimeOwner = detail.runtimeEnvironmentId?.trim() || null + const fileRuntimeOwner = file.runtimeEnvironmentId?.trim() || null + if (hasRuntimeOwnerFilter && targetRuntimeOwner !== fileRuntimeOwner) { + return + } + for (const area of ['unstaged', 'staged', 'untracked'] as const) { + const key = getCombinedDiffFileTreeSectionKey('uncommitted', { + path: detail.relativePath, + status: 'modified', + area + }) + const index = sectionIndexByKeyRef.current.get(key) + if (index !== undefined) { + requestSectionReload(index) + } + } + } + window.addEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, handler as EventListener) + return () => + window.removeEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, handler as EventListener) + }, [ + file.runtimeEnvironmentId, + file.worktreeId, + requestSectionReload, + sectionIndexByKeyRef, + treeMode + ]) + + return combinedGitStatusSignature +} diff --git a/src/renderer/src/components/editor/combined-diff/remember-view/combined-diff-view-memory.ts b/src/renderer/src/components/editor/combined-diff/remember-view/combined-diff-view-memory.ts new file mode 100644 index 00000000000..8360fa40b33 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/remember-view/combined-diff-view-memory.ts @@ -0,0 +1,45 @@ +import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' +import type { DiffSection } from '../../diff-section-types' +import { + ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, + type EditorPathMutationTarget +} from '../../editor-autosave' + +export type CachedCombinedDiffViewState = { + entrySignature: string + gitStatusSignature: string + sections: DiffSection[] + sectionHeights: Record + loadedIndices: number[] + scrollTop: number + sideBySide: boolean +} + +export const combinedDiffViewStateCache = new Map() +export const combinedDiffScrollTopCache = new Map() +export const combinedDiffScrollAnchorCache = new Map() + +// Why: session-scoped toolbar choices outlive the unmount, so they are module state rather than component state. +export const combinedDiffViewPreferences: { + collapsed: boolean | null + sideBySide: boolean | null + fileTreeCollapsed: boolean | null +} = { collapsed: null, sideBySide: null, fileTreeCollapsed: null } + +function invalidateCombinedDiffCachesForRelativePath(relativePath: string): void { + for (const [key, cached] of combinedDiffViewStateCache.entries()) { + if (cached.sections.some((section) => section.path === relativePath)) { + combinedDiffViewStateCache.delete(key) + } + } +} + +if (typeof window !== 'undefined') { + window.addEventListener(ORCA_EDITOR_EXTERNAL_FILE_CHANGE_EVENT, (event) => { + const detail = (event as CustomEvent).detail + if (detail?.relativePath) { + // Why: inactive combined-diff tabs are unmounted, so only a module-level cache bust stops a remount replaying stale bodies. + invalidateCombinedDiffCachesForRelativePath(detail.relativePath) + } + }) +} diff --git a/src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-persist.ts b/src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-persist.ts new file mode 100644 index 00000000000..63875af0ecd --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-persist.ts @@ -0,0 +1,56 @@ +import { useEffect } from 'react' +import type React from 'react' +import { setWithLRU } from '@/lib/scroll-cache' +import type { DiffSection } from '../../diff-section-types' +import { combinedDiffScrollTopCache, combinedDiffViewStateCache } from './combined-diff-view-memory' + +export function useCombinedDiffViewPersist({ + combinedGitStatusSignature, + entryCount, + entrySignature, + scrollContainerRef, + sectionHeights, + sections, + loadedIndicesRef, + sideBySide, + viewStateKey +}: { + combinedGitStatusSignature: string + entryCount: number + entrySignature: string + scrollContainerRef: React.RefObject + sectionHeights: Record + sections: DiffSection[] + loadedIndicesRef: React.RefObject> + sideBySide: boolean + viewStateKey: string +}): void { + useEffect(() => { + if (sections.length === 0 && entryCount > 0) { + return + } + const preservedScrollTop = + combinedDiffScrollTopCache.get(viewStateKey) ?? scrollContainerRef.current?.scrollTop ?? 0 + setWithLRU(combinedDiffViewStateCache, viewStateKey, { + entrySignature, + gitStatusSignature: combinedGitStatusSignature, + sections, + sectionHeights, + loadedIndices: Array.from(loadedIndicesRef.current).filter( + (index) => !sections[index]?.loading + ), + scrollTop: preservedScrollTop, + sideBySide + }) + }, [ + combinedGitStatusSignature, + entryCount, + entrySignature, + loadedIndicesRef, + scrollContainerRef, + sectionHeights, + sections, + sideBySide, + viewStateKey + ]) +} 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 new file mode 100644 index 00000000000..0677030fff3 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/remember-view/use-combined-diff-view-restore.ts @@ -0,0 +1,169 @@ +import { useCallback, useLayoutEffect, useRef } from 'react' +import type React from 'react' +import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' +import type { GitStatusEntry } from '../../../../../../shared/git-status-types' +import type { DiffSection } from '../../diff-section-types' +import { buildCombinedGitStatusSignature } from '../resolve-changes/combined-diff-git-status-signature' +import { combinedDiffSectionsMatchEntryMetadata } from '../resolve-changes/combined-diff-section-cache-match' +import { getCombinedDiffFileTreeSectionKey } from '../resolve-changes/combined-diff-section-identity' +import type { CombinedDiffEntrySet } from '../resolve-changes/use-combined-diff-entry-set' +import type { CombinedDiffSectionLoadRegistry } from '../load-sections/combined-diff-section-load-registry' +import { clearPendingSectionReloadTimers } from '../load-sections/combined-diff-section-load-registry' +import { + combinedDiffScrollAnchorCache, + combinedDiffScrollTopCache, + combinedDiffViewPreferences, + combinedDiffViewStateCache +} from './combined-diff-view-memory' + +export type CombinedDiffViewRestore = { + invalidateViewStateCache: () => void + latestDomScrollAnchorRef: React.RefObject + scrollAnchorRef: React.RefObject + scrollOffsetRef: React.RefObject +} + +export function useCombinedDiffViewRestore({ + entrySet, + gitStatusEntries, + registry, + setGeneration, + setSectionHeights, + setSections, + setSideBySide, + viewStateKey +}: { + entrySet: CombinedDiffEntrySet + gitStatusEntries: GitStatusEntry[] + registry: CombinedDiffSectionLoadRegistry + setGeneration: React.Dispatch> + setSectionHeights: React.Dispatch>> + setSections: React.Dispatch> + setSideBySide: React.Dispatch> + viewStateKey: string +}): CombinedDiffViewRestore { + const { + entries, + entrySignature, + hasUncommittedEntriesSnapshot, + shouldAutoReloadFromGitStatus, + treeMode + } = entrySet + const { + generationRef, + loadSchedulerRef, + loadedIndicesRef, + loadingIndicesRef, + reloadTimersRef, + sectionLoadTokensRef + } = registry + + const scrollOffsetRef = useRef(combinedDiffScrollTopCache.get(viewStateKey) ?? 0) + const scrollAnchorRef = useRef( + combinedDiffScrollAnchorCache.get(viewStateKey) ?? null + ) + const latestDomScrollAnchorRef = useRef( + combinedDiffScrollAnchorCache.get(viewStateKey) ?? null + ) + + // Why: tab/worktree switches unmount this viewer; cache by pane key so remount restores sections+scroll before repaint. + useLayoutEffect(() => { + const cached = combinedDiffViewStateCache.get(viewStateKey) + const canRestoreSnapshotSectionsByKey = + hasUncommittedEntriesSnapshot && + cached !== undefined && + combinedDiffSectionsMatchEntryMetadata({ + entries, + sections: cached.sections, + treeMode + }) + const canRestoreCachedSections = + cached && + (cached.entrySignature === entrySignature || canRestoreSnapshotSectionsByKey) && + (!shouldAutoReloadFromGitStatus || + (cached.gitStatusSignature ?? '') === + buildCombinedGitStatusSignature(cached.sections, gitStatusEntries)) && + (cached.sections.length > 0 || entries.length === 0) + if (canRestoreCachedSections && cached) { + const collapsedPreference = combinedDiffViewPreferences.collapsed + const restoredSections = + collapsedPreference === null + ? cached.sections + : cached.sections.map((section) => ({ + ...section, + collapsed: collapsedPreference + })) + setSections(restoredSections) + setSectionHeights(cached.sectionHeights) + setSideBySide(combinedDiffViewPreferences.sideBySide ?? cached.sideBySide) + loadedIndicesRef.current = new Set( + cached.loadedIndices.filter((index) => !restoredSections[index]?.loading) + ) + loadingIndicesRef.current.clear() + scrollOffsetRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop + scrollAnchorRef.current = combinedDiffScrollAnchorCache.get(viewStateKey) ?? null + latestDomScrollAnchorRef.current = scrollAnchorRef.current + return + } + + scrollOffsetRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? 0 + scrollAnchorRef.current = combinedDiffScrollAnchorCache.get(viewStateKey) ?? null + latestDomScrollAnchorRef.current = scrollAnchorRef.current + setSections( + entries.map((entry) => ({ + key: getCombinedDiffFileTreeSectionKey(treeMode, entry), + path: entry.path, + status: entry.status, + area: 'area' in entry ? entry.area : undefined, + oldPath: entry.oldPath, + added: 'added' in entry ? entry.added : undefined, + removed: 'removed' in entry ? entry.removed : undefined, + originalContent: '', + modifiedContent: '', + collapsed: combinedDiffViewPreferences.collapsed ?? false, + loading: true, + error: undefined, + dirty: false, + diffResult: null, + largeDiffRenderLimit: null + })) + ) + setSectionHeights({}) + loadedIndicesRef.current.clear() + loadingIndicesRef.current.clear() + sectionLoadTokensRef.current.clear() + clearPendingSectionReloadTimers(reloadTimersRef.current) + loadSchedulerRef.current.reset() + generationRef.current += 1 + setGeneration((prev) => prev + 1) + }, [ + entries, + entrySignature, + generationRef, + gitStatusEntries, + hasUncommittedEntriesSnapshot, + loadSchedulerRef, + loadedIndicesRef, + loadingIndicesRef, + reloadTimersRef, + sectionLoadTokensRef, + setGeneration, + setSectionHeights, + setSections, + setSideBySide, + shouldAutoReloadFromGitStatus, + treeMode, + viewStateKey + ]) + + const invalidateViewStateCache = useCallback((): void => { + combinedDiffViewStateCache.delete(viewStateKey) + }, [viewStateKey]) + + return { + invalidateViewStateCache, + latestDomScrollAnchorRef, + scrollAnchorRef, + scrollOffsetRef + } +} diff --git a/src/renderer/src/components/editor/combined-diff-entries.test.ts b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.test.ts similarity index 98% rename from src/renderer/src/components/editor/combined-diff-entries.test.ts rename to src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.test.ts index 312d6baa8c2..4ad806d032d 100644 --- a/src/renderer/src/components/editor/combined-diff-entries.test.ts +++ b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.test.ts @@ -5,8 +5,8 @@ import { resolveCombinedUncommittedSnapshotEntries, shouldAutoReloadCombinedDiffFromGitStatus } from './combined-diff-entries' -import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types' -import type { GitStatusEntry } from '../../../../shared/git-status-types' +import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../../../shared/git-status-types' describe('getCombinedUncommittedEntries', () => { it('filters unresolved conflicts from live entries', () => { diff --git a/src/renderer/src/components/editor/combined-diff-entries.ts b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.ts similarity index 94% rename from src/renderer/src/components/editor/combined-diff-entries.ts rename to src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.ts index ab64df67d47..0f5f75d615b 100644 --- a/src/renderer/src/components/editor/combined-diff-entries.ts +++ b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-entries.ts @@ -1,7 +1,7 @@ import type { OpenFile } from '@/store/slices/editor' -import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types' -import type { GitStatusEntry } from '../../../../shared/git-status-types' -import type { CombinedDiffFileTreeMode } from './combined-diff-file-tree-model' +import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../../../shared/git-status-types' +import type { CombinedDiffFileTreeMode } from './combined-diff-section-identity' /** * Fallback filtering for combined-diff tabs that were opened before the diff --git a/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-git-status-signature.ts b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-git-status-signature.ts new file mode 100644 index 00000000000..20c266d04c3 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-git-status-signature.ts @@ -0,0 +1,39 @@ +import type { GitStatusEntry } from '../../../../../../shared/git-status-types' +import type { DiffSection } from '../../diff-section-types' + +export function buildCombinedGitStatusSignature( + sections: readonly { path: string }[], + gitStatusEntries: readonly GitStatusEntry[] +): string { + const sectionPaths = new Set(sections.map((section) => section.path)) + const matching = gitStatusEntries.filter((entry) => sectionPaths.has(entry.path)) + return JSON.stringify( + matching.map((entry) => ({ + path: entry.path, + oldPath: entry.oldPath ?? null, + area: entry.area, + status: entry.status, + added: entry.added ?? null, + removed: entry.removed ?? null + })) + ) +} + +export function getRetainedResolvedSnapshotEntries( + sections: readonly DiffSection[] +): GitStatusEntry[] { + return sections.flatMap((section) => + section.area === undefined + ? [] + : [ + { + path: section.path, + status: section.status as GitStatusEntry['status'], + area: section.area, + oldPath: section.oldPath, + added: section.added, + removed: section.removed + } + ] + ) +} diff --git a/src/renderer/src/components/editor/combined-diff-section-cache-match.test.ts b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.test.ts similarity index 90% rename from src/renderer/src/components/editor/combined-diff-section-cache-match.test.ts rename to src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.test.ts index 1eea28d9d89..be8305eec23 100644 --- a/src/renderer/src/components/editor/combined-diff-section-cache-match.test.ts +++ b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' -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 type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../../../shared/git-status-types' +import type { DiffSection } from '../../diff-section-types' import { combinedDiffSectionsMatchEntryMetadata } from './combined-diff-section-cache-match' function section(overrides: Partial): DiffSection { diff --git a/src/renderer/src/components/editor/combined-diff-section-cache-match.ts b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.ts similarity index 77% rename from src/renderer/src/components/editor/combined-diff-section-cache-match.ts rename to src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.ts index 7f25e96f7c3..9b6a2ff1f04 100644 --- a/src/renderer/src/components/editor/combined-diff-section-cache-match.ts +++ b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-cache-match.ts @@ -1,8 +1,8 @@ -import type { GitBranchChangeEntry } from '../../../../shared/git-diff-compare-types' -import type { GitStatusEntry } from '../../../../shared/git-status-types' -import type { CombinedDiffFileTreeMode } from './combined-diff-file-tree-model' -import { getCombinedDiffFileTreeSectionKey } from './combined-diff-file-tree-model' -import type { DiffSection } from './diff-section-types' +import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../../../shared/git-status-types' +import type { CombinedDiffFileTreeMode } from './combined-diff-section-identity' +import { getCombinedDiffFileTreeSectionKey } from './combined-diff-section-identity' +import type { DiffSection } from '../../diff-section-types' export function combinedDiffSectionsMatchEntryMetadata({ entries, diff --git a/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-identity.ts b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-identity.ts new file mode 100644 index 00000000000..f8e29b6e238 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/resolve-changes/combined-diff-section-identity.ts @@ -0,0 +1,26 @@ +import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types' +import type { GitStatusEntry } from '../../../../../../shared/git-status-types' + +export type CombinedDiffFileTreeMode = 'all' | 'uncommitted' | 'branch' | 'commit' +export type CombinedDiffFileTreeEntry = GitStatusEntry | GitBranchChangeEntry +export type CombinedDiffBranchTreeArea = 'combined-branch' | 'combined-commit' + +export function getCombinedDiffFileTreeSectionKey( + mode: CombinedDiffFileTreeMode, + entry: CombinedDiffFileTreeEntry +): string { + if ((mode === 'all' || mode === 'uncommitted') && 'area' in entry) { + return `${entry.area}:${entry.path}` + } + return `${mode === 'commit' ? 'combined-commit' : 'combined-branch'}:${entry.path}` +} + +export function createCombinedDiffSectionIndexMap( + sections: readonly { key: string }[] +): Map { + return new Map(sections.map((section, index) => [section.key, index])) +} + +export function isGitStatusEntry(entry: CombinedDiffFileTreeEntry): entry is GitStatusEntry { + return 'area' in entry +} diff --git a/src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-entry-set.ts b/src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-entry-set.ts new file mode 100644 index 00000000000..ece086c9527 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/resolve-changes/use-combined-diff-entry-set.ts @@ -0,0 +1,166 @@ +import React from 'react' +import type { OpenFile } from '@/store/slices/editor' +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 type { CombinedDiffFileTreeMode } from './combined-diff-section-identity' +import { + getCombinedBranchEntries, + getCombinedUncommittedEntries, + resolveCombinedUncommittedSnapshotEntries, + shouldAutoReloadCombinedDiffFromGitStatus +} from './combined-diff-entries' +import { getRetainedResolvedSnapshotEntries } from './combined-diff-git-status-signature' + +// Why: stable identities so the store selectors don't resubscribe on every empty read. +export const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntry[] = [] +export const EMPTY_GIT_BRANCH_ENTRIES: GitBranchChangeEntry[] = [] + +export type CombinedDiffEntrySet = { + allEntries: (GitStatusEntry | GitBranchChangeEntry)[] + branchCompare: NonNullable | null + commitCompare: NonNullable | null + commitEntries: GitBranchChangeEntry[] + entries: (GitStatusEntry | GitBranchChangeEntry)[] + entrySignature: string + hasUncommittedEntriesSnapshot: boolean + isAllMode: boolean + isBranchMode: boolean + isCommitMode: boolean + renderableBranchEntries: GitBranchChangeEntry[] + shouldAutoReloadFromGitStatus: boolean + treeMode: CombinedDiffFileTreeMode + uncommittedEntries: GitStatusEntry[] +} + +export function useCombinedDiffEntrySet({ + file, + gitStatusEntries, + liveBranchEntries, + sectionsRef +}: { + file: OpenFile + gitStatusEntries: GitStatusEntry[] + liveBranchEntries: GitBranchChangeEntry[] + sectionsRef: React.RefObject +}): CombinedDiffEntrySet { + const isBranchMode = file.diffSource === 'combined-branch' + const isCommitMode = file.diffSource === 'combined-commit' + const isAllMode = file.diffSource === 'combined-all' + const branchCompare = + file.branchCompare?.baseOid && file.branchCompare.headOid && file.branchCompare.mergeBase + ? file.branchCompare + : null + const commitCompare = file.commitCompare?.commitOid ? file.commitCompare : null + + // Why: prefer the tab-open snapshot so a commit changing gitStatusByWorktree doesn't rebuild sections and lose loaded content. + const snapshotEntries = React.useMemo( + () => file.uncommittedEntriesSnapshot?.filter((e) => e.conflictStatus !== 'unresolved'), + [file.uncommittedEntriesSnapshot] + ) + const uncommittedEntries = React.useMemo(() => { + if (!snapshotEntries) { + return getCombinedUncommittedEntries(gitStatusEntries, file.combinedAreaFilter) + } + // Why: row load-state changes must not rebuild the snapshot list; the ref is consulted only when live Git status changes. + return resolveCombinedUncommittedSnapshotEntries( + snapshotEntries, + gitStatusEntries, + getRetainedResolvedSnapshotEntries(sectionsRef.current) + ) + }, [snapshotEntries, gitStatusEntries, file.combinedAreaFilter, sectionsRef]) + const branchEntries = React.useMemo(() => { + return getCombinedBranchEntries(file.branchEntriesSnapshot, liveBranchEntries) + }, [file.branchEntriesSnapshot, liveBranchEntries]) + const renderableBranchEntries = React.useMemo( + () => (branchCompare ? branchEntries : []), + [branchCompare, branchEntries] + ) + const commitEntries = React.useMemo( + () => file.commitEntriesSnapshot ?? [], + [file.commitEntriesSnapshot] + ) + const allEntries = React.useMemo( + () => [...uncommittedEntries, ...renderableBranchEntries], + [renderableBranchEntries, uncommittedEntries] + ) + const entries = isAllMode + ? allEntries + : isBranchMode + ? renderableBranchEntries + : isCommitMode + ? commitEntries + : uncommittedEntries + const treeMode = isAllMode + ? 'all' + : isBranchMode + ? 'branch' + : isCommitMode + ? 'commit' + : 'uncommitted' + const hasUncommittedEntriesSnapshot = file.uncommittedEntriesSnapshot !== undefined + const shouldAutoReloadFromGitStatus = shouldAutoReloadCombinedDiffFromGitStatus({ + mode: treeMode, + hasUncommittedEntriesSnapshot + }) + const entrySignature = React.useMemo( + () => + JSON.stringify({ + mode: file.diffSource, + areaFilter: file.combinedAreaFilter ?? null, + compareVersion: file.branchCompare?.compareVersion ?? null, + commitVersion: file.commitCompare?.compareVersion ?? null, + compare: + isBranchMode && branchCompare + ? { + baseOid: branchCompare.baseOid, + headOid: branchCompare.headOid, + mergeBase: branchCompare.mergeBase + } + : null, + commit: + isCommitMode && commitCompare + ? { + commitOid: commitCompare.commitOid, + parentOid: commitCompare.parentOid ?? null + } + : null, + entries: entries.map((entry) => ({ + path: entry.path, + status: entry.status, + oldPath: entry.oldPath ?? null, + area: 'area' in entry ? entry.area : null, + added: 'added' in entry ? (entry.added ?? null) : null, + removed: 'removed' in entry ? (entry.removed ?? null) : null + })) + }), + [ + branchCompare, + commitCompare, + entries, + file.branchCompare?.compareVersion, + file.combinedAreaFilter, + file.commitCompare?.compareVersion, + file.diffSource, + isBranchMode, + isCommitMode + ] + ) + + return { + allEntries, + branchCompare, + commitCompare, + commitEntries, + entries, + entrySignature, + hasUncommittedEntriesSnapshot, + isAllMode, + isBranchMode, + isCommitMode, + renderableBranchEntries, + shouldAutoReloadFromGitStatus, + treeMode, + uncommittedEntries + } +} diff --git a/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-commit-header.tsx b/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-commit-header.tsx new file mode 100644 index 00000000000..e3ad1689048 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-commit-header.tsx @@ -0,0 +1,44 @@ +import type React from 'react' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import type { OpenFile } from '@/store/slices/editor' +import { getCombinedDiffCommitMessageBody } from './combined-diff-commit-message' + +export function CombinedDiffCommitHeader({ + commitCompare +}: { + commitCompare: NonNullable +}): React.JSX.Element { + const commitBody = getCombinedDiffCommitMessageBody(commitCompare.message, commitCompare.subject) + + return ( +
+
+
+ {commitCompare.subject && ( + + +
+ {commitCompare.subject} +
+
+ + {commitCompare.subject} + +
+ )} + {commitBody && ( +
+ {commitBody} +
+ )} +
+ + {commitCompare.compareRef} + +
+
+ ) +} diff --git a/src/renderer/src/components/editor/combined-diff-commit-message.test.ts b/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-commit-message.test.ts similarity index 100% rename from src/renderer/src/components/editor/combined-diff-commit-message.test.ts rename to src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-commit-message.test.ts diff --git a/src/renderer/src/components/editor/combined-diff-commit-message.ts b/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-commit-message.ts similarity index 100% rename from src/renderer/src/components/editor/combined-diff-commit-message.ts rename to src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-commit-message.ts diff --git a/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-notes-popover.tsx b/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-notes-popover.tsx new file mode 100644 index 00000000000..c19c6042989 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-notes-popover.tsx @@ -0,0 +1,165 @@ +import type React from 'react' +import { Check, Copy, MessageSquare, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle +} from '@/components/ui/dialog' +import { getDiffCommentLineLabel } from '@/lib/diff-comment-compat' +import { translate } from '@/i18n/i18n' +import type { DiffComment } from '../../../../../../shared/diff-comment-types' + +export function DiffNotesPreviewPopover({ + comments, + totalCount, + copied, + onCopy, + onClear +}: { + comments: DiffComment[] + totalCount: number + copied: boolean + onCopy: () => void + onClear: () => void +}): React.JSX.Element { + const remainingCount = Math.max(0, totalCount - comments.length) + + return ( +
+
+
+ + + {translate('auto.components.editor.CombinedDiffViewer.bb84b4c374', 'AI notes')} + + + {totalCount} + +
+
+ + +
+
+
+ {comments.map((comment) => ( +
+
+ {comment.filePath} + {comment.sentAt ? ( + + {translate('auto.components.editor.CombinedDiffViewer.1da745c551', 'Sent')} + + ) : null} + + {getDiffCommentLineLabel(comment, true)} + +
+
+ {comment.body} +
+
+ ))} + {remainingCount > 0 && ( +
+ {remainingCount}{' '} + {translate('auto.components.editor.CombinedDiffViewer.e3b9a6ce02', 'more')} + {remainingCount === 1 + ? translate('auto.components.editor.CombinedDiffViewer.8ab3248fd8', 'note') + : translate('auto.components.editor.CombinedDiffViewer.0fb870a0fe', 'notes')}{' '} + {translate('auto.components.editor.CombinedDiffViewer.35cc27aeb2', 'in Source Control')} +
+ )} +
+
+ ) +} + +export function ClearDiffNotesDialog({ + diffCommentCount, + isClearingNotes, + onConfirm, + open, + setOpen +}: { + diffCommentCount: number + isClearingNotes: boolean + onConfirm: () => void + open: boolean + setOpen: React.Dispatch> +}): React.JSX.Element { + return ( + { + if (!nextOpen && !isClearingNotes) { + setOpen(false) + } else if (nextOpen) { + setOpen(true) + } + }} + > + + + + {translate('auto.components.editor.CombinedDiffViewer.948a5fd6c8', 'Clear Notes')} + + + {translate('auto.components.editor.CombinedDiffViewer.84898c548d', 'Clear')}{' '} + {diffCommentCount}{' '} + {diffCommentCount === 1 + ? translate('auto.components.editor.CombinedDiffViewer.8ab3248fd8', 'note') + : translate('auto.components.editor.CombinedDiffViewer.0fb870a0fe', 'notes')}{' '} + {translate( + 'auto.components.editor.CombinedDiffViewer.80a286d8f5', + 'from this worktree?' + )} + + + + + + + + + ) +} diff --git a/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-skipped-conflicts.tsx b/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-skipped-conflicts.tsx new file mode 100644 index 00000000000..745cbc0a576 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-skipped-conflicts.tsx @@ -0,0 +1,104 @@ +import type React from 'react' +import { Button } from '@/components/ui/button' +import { translate } from '@/i18n/i18n' +import type { OpenFile } from '@/store/slices/editor' + +type SkippedConflicts = NonNullable + +export function CombinedDiffSkippedConflictsEmptyState({ + commitHeader, + onReviewConflicts, + skippedConflicts +}: { + commitHeader: React.ReactNode + onReviewConflicts: () => void + skippedConflicts: SkippedConflicts +}): React.JSX.Element { + return ( +
+ {commitHeader} +
+
+
+ {translate( + 'auto.components.editor.CombinedDiffViewer.820ec01f24', + 'Conflicted files are reviewed separately' + )} +
+
+ {translate( + 'auto.components.editor.CombinedDiffViewer.eb5f40e49c', + 'This diff view excludes unresolved conflicts because the normal two-way diff pipeline is not conflict-safe.' + )} +
+
+ {skippedConflicts.map((entry) => entry.path).join(', ')} +
+
+ +
+
+
+
+ ) +} + +export function CombinedDiffNoChangesEmptyState({ + commitHeader +}: { + commitHeader: React.ReactNode +}): React.JSX.Element { + return ( +
+ {commitHeader} +
+ {translate('auto.components.editor.CombinedDiffViewer.fd8892b120', 'No changes to display')} +
+
+ ) +} + +export function CombinedDiffSkippedConflictNotice({ + onReviewConflicts, + skippedConflicts +}: { + onReviewConflicts: () => void + skippedConflicts: SkippedConflicts +}): React.JSX.Element { + return ( +
+
+ {translate( + 'auto.components.editor.CombinedDiffViewer.820ec01f24', + 'Conflicted files are reviewed separately' + )} +
+
+ {translate( + 'auto.components.editor.CombinedDiffViewer.skippedConflictsExcluded', + '{{count}} unresolved conflicts were excluded from this diff view.', + { + count: skippedConflicts.length, + defaultValue_one: '{{count}} unresolved conflict was excluded from this diff view.' + } + )} +
+
+ +
+
+ ) +} diff --git a/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-toolbar.tsx b/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-toolbar.tsx new file mode 100644 index 00000000000..fb5bdd54b8c --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/review-controls/combined-diff-toolbar.tsx @@ -0,0 +1,193 @@ +import type React from 'react' +import { PanelLeftOpen, Sparkles, WrapText } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' +import { translate } from '@/i18n/i18n' +import type { OpenFile } from '@/store/slices/editor' +import type { DiffComment } from '../../../../../../shared/diff-comment-types' +import { DiffNotesSendMenu } from '../../DiffNotesSendMenu' +import { DiffNotesPreviewPopover } from './combined-diff-notes-popover' + +export function CombinedDiffToolbar({ + activeGroupId, + allSectionsCollapsed, + branchCompare, + commitCompare, + diffCommentCount, + diffCommentsForWorktree, + diffWordWrap, + file, + fileTreeCollapsed, + isAllMode, + isBranchMode, + isCommitMode, + notesCopied, + onCopyNotes, + onOpenAlternateDiff, + onOpenClearNotes, + onShowFileTree, + previewDiffComments, + sectionCount, + setAllSectionsCollapsed, + sideBySide, + toggleDiffWordWrap, + toggleSideBySide +}: { + activeGroupId: string | undefined + allSectionsCollapsed: boolean + branchCompare: NonNullable | null + commitCompare: NonNullable | null + diffCommentCount: number + diffCommentsForWorktree: DiffComment[] + diffWordWrap: boolean | undefined + file: OpenFile + fileTreeCollapsed: boolean + isAllMode: boolean + isBranchMode: boolean + isCommitMode: boolean + notesCopied: boolean + onCopyNotes: () => void + onOpenAlternateDiff: () => void + onOpenClearNotes: () => void + onShowFileTree: () => void + previewDiffComments: DiffComment[] + sectionCount: number + setAllSectionsCollapsed: (collapsed: boolean) => void + sideBySide: boolean + toggleDiffWordWrap: () => void + toggleSideBySide: () => void +}): React.JSX.Element { + return ( +
+
+ {fileTreeCollapsed && ( + + + + + + {translate('auto.components.editor.CombinedDiffViewer.b6c3b84476', 'Show file tree')} + + + )} + + {sectionCount}{' '} + {translate('auto.components.editor.CombinedDiffViewer.7e7ca60816', 'changed files')} + {(isAllMode || isBranchMode) && branchCompare + ? translate('auto.components.editor.CombinedDiffViewer.6094135eec', ' vs {{value0}}', { + value0: branchCompare.baseRef + }) + : ''} + {isCommitMode && commitCompare + ? translate('auto.components.editor.CombinedDiffViewer.724a13568d', ' in {{value0}}', { + value0: commitCompare.compareRef + }) + : ''} + + {diffCommentCount > 0 && ( +
+ + + + + + + + + +
+ )} +
+
+ {file.combinedAlternate && ( + + )} + + + +
+
+ ) +} diff --git a/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-notes-actions.ts b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-notes-actions.ts new file mode 100644 index 00000000000..9fec9416b8f --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-notes-actions.ts @@ -0,0 +1,138 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' +import { formatDiffComments } from '@/lib/diff-comments-format' +import { translate } from '@/i18n/i18n' +import type { DiffComment } from '../../../../../../shared/diff-comment-types' + +export type CombinedDiffNotesActions = { + clearNotesDialogVisible: boolean + diffCommentCount: number + handleConfirmClearNotes: () => Promise + handleCopyNotes: () => Promise + isClearingNotes: boolean + notesCopied: boolean + previewDiffComments: DiffComment[] + setClearNotesDialogOpen: React.Dispatch> + setScrollSurfaceMounted: (mounted: boolean) => void +} + +export function useCombinedDiffNotesActions({ + clearDiffComments, + diffCommentsForWorktree, + worktreeId +}: { + clearDiffComments: (worktreeId: string) => Promise + diffCommentsForWorktree: DiffComment[] + worktreeId: string +}): CombinedDiffNotesActions { + const diffCommentCount = diffCommentsForWorktree.length + const diffCommentsPrompt = React.useMemo( + () => formatDiffComments(diffCommentsForWorktree), + [diffCommentsForWorktree] + ) + const previewDiffComments = React.useMemo( + () => + [...diffCommentsForWorktree] + .sort((a, b) => a.filePath.localeCompare(b.filePath) || a.lineNumber - b.lineNumber) + .slice(0, 4), + [diffCommentsForWorktree] + ) + + const [clearNotesDialogOpen, setClearNotesDialogOpen] = useState(false) + const [isClearingNotes, setIsClearingNotes] = useState(false) + const clearNotesDialogVisible = clearNotesDialogOpen && (diffCommentCount > 0 || isClearingNotes) + if (clearNotesDialogOpen && !clearNotesDialogVisible) { + // Why: notes may be cleared outside this dialog; close it this render instead of flashing an empty confirmation. + setClearNotesDialogOpen(false) + } + const [notesCopied, setNotesCopied] = useState(false) + const mountedRef = useRef(true) + // Why: the copy action owns its reset timer instead of repairing copied state after render. + const notesCopiedResetTimerRef = useRef(null) + // Why: clipboard IPC can resolve after unmount; skip copied feedback rather than start a reset timer on a stale viewer. + const notesCopyMountedRef = useRef(false) + + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + } + }, []) + + const clearNotesCopiedResetTimer = useCallback((): void => { + if (notesCopiedResetTimerRef.current !== null) { + window.clearTimeout(notesCopiedResetTimerRef.current) + notesCopiedResetTimerRef.current = null + } + }, []) + + const setScrollSurfaceMounted = useCallback( + (mounted: boolean): void => { + notesCopyMountedRef.current = mounted + if (!mounted) { + // Why: copied feedback is tied to the surface lifetime; the root-ref unmount is where stale feedback gets disabled. + clearNotesCopiedResetTimer() + } + }, + [clearNotesCopiedResetTimer] + ) + + const handleCopyNotes = useCallback(async (): Promise => { + if (diffCommentCount === 0) { + return + } + try { + await window.api.ui.writeClipboardText(diffCommentsPrompt) + if (!notesCopyMountedRef.current) { + return + } + clearNotesCopiedResetTimer() + setNotesCopied(true) + notesCopiedResetTimerRef.current = window.setTimeout(() => { + setNotesCopied(false) + notesCopiedResetTimerRef.current = null + }, 1500) + } catch { + // Why: clipboard writes can fail while the app is unfocused; keep the popover non-blocking. + } + }, [clearNotesCopiedResetTimer, diffCommentCount, diffCommentsPrompt]) + + const handleConfirmClearNotes = useCallback(async (): Promise => { + if (diffCommentCount === 0 || isClearingNotes) { + return + } + setIsClearingNotes(true) + try { + const ok = await clearDiffComments(worktreeId) + if (!mountedRef.current) { + return + } + if (ok) { + setClearNotesDialogOpen(false) + } else { + toast.error( + translate( + 'auto.components.editor.CombinedDiffViewer.45cf23b418', + 'Failed to clear notes.' + ) + ) + } + } finally { + if (mountedRef.current) { + setIsClearingNotes(false) + } + } + }, [clearDiffComments, diffCommentCount, isClearingNotes, worktreeId]) + + return { + clearNotesDialogVisible, + diffCommentCount, + handleConfirmClearNotes, + handleCopyNotes, + isClearingNotes, + notesCopied, + previewDiffComments, + setClearNotesDialogOpen, + setScrollSurfaceMounted + } +} diff --git a/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts new file mode 100644 index 00000000000..0fa71dbf136 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-section-actions.ts @@ -0,0 +1,246 @@ +import { useCallback, useRef } from 'react' +import type React from 'react' +import type { editor as monacoEditor } from 'monaco-editor' +import { useAppStore } from '@/store' +import { detectLanguage } from '@/lib/language-detect' +import { joinPath } from '@/lib/path' +import { openFilePreviewToSide } from '@/lib/file-preview' +import { getEditorFileOperationContext } from '@/lib/editor-file-operation-owner' +import { writeRuntimeFile } from '@/runtime/runtime-file-client' +import { findWorktreeById } from '@/store/slices/worktree-helpers' +import type { OpenFile } from '@/store/slices/editor' +import type { GitBranchChangeEntry } from '../../../../../../shared/git-diff-compare-types' +import { canOpenDiffSectionPreviewToSide } from '../../diff-section-preview' +import { getLargeDiffRenderLimit } from '../../large-diff-render-limit' +import { getStoredTextDiffContent, getStoredTextDiffResult } from '../../large-diff-section-content' +import { removeDiffSectionMeasuredHeight } from '../../diff-section-height-cache' +import type { DiffSection } from '../../diff-section-types' +import type { DiffSectionItemProps } from '../../diff-section-item-props' + +export type CombinedDiffSectionActions = { + handleSectionSaveRef: DiffSectionItemProps['handleSectionSaveRef'] + modifiedEditorsRef: DiffSectionItemProps['modifiedEditorsRef'] + openSection: (index: number) => void + openSectionPreview: (section: DiffSection) => void +} + +export function useCombinedDiffSectionActions({ + activeGroupId, + branchCompare, + canOpenWorkspaceFileBrowserForPath, + commitCompare, + file, + isAllMode, + isBranchMode, + isCommitMode, + sections, + sectionsRef, + setSectionHeights, + setSections +}: { + activeGroupId: string | undefined + branchCompare: NonNullable | null + canOpenWorkspaceFileBrowserForPath: (path: string) => boolean + commitCompare: NonNullable | null + file: OpenFile + isAllMode: boolean + isBranchMode: boolean + isCommitMode: boolean + sections: DiffSection[] + sectionsRef: React.RefObject + setSectionHeights: React.Dispatch>> + setSections: React.Dispatch> +}): CombinedDiffSectionActions { + const openFile = useAppStore((s) => s.openFile) + const openBranchDiff = useAppStore((s) => s.openBranchDiff) + const openCommitDiff = useAppStore((s) => s.openCommitDiff) + const modifiedEditorsRef = useRef>(new Map()) + + const openSection = useCallback( + (index: number) => { + const section = sectionsRef.current[index] + if (!section) { + return + } + + const language = detectLanguage(section.path) + const entry: GitBranchChangeEntry = { + path: section.path, + status: section.status as GitBranchChangeEntry['status'], + oldPath: section.oldPath, + added: section.added, + removed: section.removed + } + + const isBranchEntry = section.area === undefined + + if ((isBranchMode || (isAllMode && isBranchEntry)) && branchCompare) { + openBranchDiff(file.worktreeId, file.filePath, entry, branchCompare, language) + return + } + + if (isCommitMode && commitCompare) { + openCommitDiff(file.worktreeId, file.filePath, entry, commitCompare, language) + return + } + + openFile({ + filePath: joinPath(file.filePath, section.path), + relativePath: section.path, + worktreeId: file.worktreeId, + runtimeEnvironmentId: file.runtimeEnvironmentId, + language, + mode: 'edit' + }) + }, + [ + branchCompare, + commitCompare, + file.filePath, + file.runtimeEnvironmentId, + file.worktreeId, + isAllMode, + isBranchMode, + isCommitMode, + openBranchDiff, + openCommitDiff, + openFile, + sectionsRef + ] + ) + + // Why: match single-file HTML diffs — preview the on-disk working tree file + // beside the combined view when the section is still present on disk. + const openSectionPreview = useCallback( + (section: DiffSection) => { + if ( + !canOpenDiffSectionPreviewToSide({ + path: section.path, + status: section.status, + isCommitSurface: isCommitMode, + canOpenWorkspaceFileBrowser: canOpenWorkspaceFileBrowserForPath( + joinPath(file.filePath, section.path) + ) + }) + ) { + return + } + // Why: use this combined-diff tab's group, not worktree activeGroupId — + // in a multi-pane layout the active group may be a different split. + const state = useAppStore.getState() + const sourceGroupId = + (state.unifiedTabsByWorktree[file.worktreeId] ?? []).find( + (tab) => + tab.entityId === file.id && (tab.contentType === 'diff' || tab.contentType === 'editor') + )?.groupId ?? + activeGroupId ?? + null + openFilePreviewToSide({ + language: detectLanguage(section.path), + filePath: joinPath(file.filePath, section.path), + worktreeId: file.worktreeId, + sourceGroupId + }) + }, + [ + activeGroupId, + canOpenWorkspaceFileBrowserForPath, + file.filePath, + file.id, + file.worktreeId, + isCommitMode + ] + ) + + const handleSectionSave = useCallback( + async (index: number) => { + const section = sections[index] + if (!section) { + return + } + const modifiedEditor = modifiedEditorsRef.current.get(index) + if (!modifiedEditor && !section.dirty) { + return + } + + const sectionKey = section.key + const content = modifiedEditor?.getValue() ?? section.modifiedContent + const absolutePath = joinPath(file.filePath, section.path) + try { + const state = useAppStore.getState() + const worktree = file.worktreeId + ? findWorktreeById(state.worktreesByRepo, file.worktreeId) + : null + await writeRuntimeFile( + getEditorFileOperationContext( + state, + { + worktreeId: file.worktreeId, + runtimeEnvironmentId: file.runtimeEnvironmentId, + operationProvenance: file.operationProvenance + }, + worktree?.path ?? null + ), + absolutePath, + content + ) + // Why: the section list can be rebuilt while the write is pending, so re-resolve + // by key — the captured index may now point at a different file. + const savedIndex = sectionsRef.current.findIndex((s) => s.key === sectionKey) + if (savedIndex === -1) { + return + } + setSectionHeights((prev) => removeDiffSectionMeasuredHeight(prev, savedIndex)) + setSections((prev) => + prev.map((s) => { + if (s.key !== sectionKey) { + return s + } + + if (s.diffResult?.kind !== 'text') { + return { + ...s, + modifiedContent: content, + dirty: false, + largeDiffRenderLimit: s.largeDiffRenderLimit + } + } + + const nextDiffResult = { ...s.diffResult, modifiedContent: content } + const nextLargeDiffRenderLimit = getLargeDiffRenderLimit({ + originalContent: s.originalContent, + modifiedContent: content + }) + const storedContent = getStoredTextDiffContent(nextDiffResult, nextLargeDiffRenderLimit) + + return { + ...s, + modifiedContent: storedContent.modifiedContent, + originalContent: storedContent.originalContent, + dirty: false, + diffResult: getStoredTextDiffResult(nextDiffResult, nextLargeDiffRenderLimit), + largeDiffRenderLimit: nextLargeDiffRenderLimit + } + }) + ) + } catch (err) { + console.error('Save failed:', err) + } + }, + [ + file.filePath, + file.operationProvenance, + file.runtimeEnvironmentId, + file.worktreeId, + sections, + sectionsRef, + setSectionHeights, + setSections + ] + ) + + const handleSectionSaveRef = useRef(handleSectionSave) + handleSectionSaveRef.current = handleSectionSave + + return { handleSectionSaveRef, modifiedEditorsRef, openSection, openSectionPreview } +} diff --git a/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-view-preferences.ts b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-view-preferences.ts new file mode 100644 index 00000000000..1ff70d76026 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/review-controls/use-combined-diff-view-preferences.ts @@ -0,0 +1,101 @@ +import { useCallback, useEffect, useState } from 'react' +import type React from 'react' +import type { DiffSection } from '../../diff-section-types' +import { combinedDiffViewPreferences } from '../remember-view/combined-diff-view-memory' +import { getInitialCombinedDiffSectionLoadIndices } from '../load-sections/combined-diff-initial-section-load' +import type { CombinedDiffSectionLoadRegistry } from '../load-sections/combined-diff-section-load-registry' + +export type CombinedDiffViewPreferences = { + fileTreeCollapsed: boolean + setAllSectionsCollapsed: (collapsed: boolean) => void + setFileTreeCollapsed: (collapsed: boolean) => void + setSideBySide: React.Dispatch> + sideBySide: boolean + toggleDiffWordWrap: () => void + toggleSideBySide: () => void +} + +export function useCombinedDiffViewPreferences({ + combinedDiffFileTreeVisibleByDefault, + diffDefaultView, + diffWordWrap, + registry, + setSections, + updateSettings +}: { + combinedDiffFileTreeVisibleByDefault: boolean | undefined + diffDefaultView: string | undefined + diffWordWrap: boolean | undefined + registry: CombinedDiffSectionLoadRegistry + setSections: React.Dispatch> + updateSettings: (patch: { diffWordWrap: boolean }) => unknown +}): CombinedDiffViewPreferences { + const { loadSchedulerRef, loadedIndicesRef, sectionsRef } = registry + const [sideBySide, setSideBySide] = useState( + () => combinedDiffViewPreferences.sideBySide ?? diffDefaultView === 'side-by-side' + ) + const [fileTreeCollapsed, setFileTreeCollapsedState] = useState( + () => + // Why: the tree is opt-in; only an explicit saved setting should open it while settings are still loading. + combinedDiffViewPreferences.fileTreeCollapsed ?? combinedDiffFileTreeVisibleByDefault !== true + ) + + // Why: seed from Settings until the user picks a toolbar mode this session, then follow that choice over the global default. + useEffect(() => { + if (diffDefaultView !== undefined && combinedDiffViewPreferences.sideBySide === null) { + setSideBySide(diffDefaultView === 'side-by-side') + } + }, [diffDefaultView]) + + useEffect(() => { + if ( + combinedDiffFileTreeVisibleByDefault !== undefined && + combinedDiffViewPreferences.fileTreeCollapsed === null + ) { + setFileTreeCollapsedState(combinedDiffFileTreeVisibleByDefault === false) + } + }, [combinedDiffFileTreeVisibleByDefault]) + + const setFileTreeCollapsed = useCallback((collapsed: boolean) => { + combinedDiffViewPreferences.fileTreeCollapsed = collapsed + setFileTreeCollapsedState(collapsed) + }, []) + + const setAllSectionsCollapsed = useCallback( + (collapsed: boolean) => { + combinedDiffViewPreferences.collapsed = collapsed + setSections((prev) => prev.map((section) => ({ ...section, collapsed }))) + if (!collapsed) { + const initialIndices = getInitialCombinedDiffSectionLoadIndices({ + sectionCount: sectionsRef.current.length, + loadedIndices: loadedIndicesRef.current + }) + for (const index of initialIndices) { + loadSchedulerRef.current.request(index) + } + } + }, + [loadSchedulerRef, loadedIndicesRef, sectionsRef, setSections] + ) + + const toggleSideBySide = useCallback(() => { + // Why: React may replay a state updater, so the module preference is written here rather than inside it. + const next = !sideBySide + combinedDiffViewPreferences.sideBySide = next + setSideBySide(next) + }, [sideBySide]) + + const toggleDiffWordWrap = useCallback(() => { + void updateSettings({ diffWordWrap: diffWordWrap !== true }) + }, [diffWordWrap, updateSettings]) + + return { + fileTreeCollapsed, + setAllSectionsCollapsed, + setFileTreeCollapsed, + setSideBySide, + sideBySide, + toggleDiffWordWrap, + toggleSideBySide + } +} diff --git a/src/renderer/src/components/editor/combined-diff-scrollbar-drag.test.ts b/src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-scrollbar-drag.test.ts similarity index 100% rename from src/renderer/src/components/editor/combined-diff-scrollbar-drag.test.ts rename to src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-scrollbar-drag.test.ts diff --git a/src/renderer/src/components/editor/combined-diff-scrollbar-drag.ts b/src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-scrollbar-drag.ts similarity index 100% rename from src/renderer/src/components/editor/combined-diff-scrollbar-drag.ts rename to src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-scrollbar-drag.ts diff --git a/src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-section-list.tsx b/src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-section-list.tsx new file mode 100644 index 00000000000..dc9cf5c2930 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/scroll-viewport/combined-diff-section-list.tsx @@ -0,0 +1,165 @@ +import type React from 'react' +import type { Virtualizer } from '@tanstack/react-virtual' +import { joinPath } from '@/lib/path' +import type { OpenFile } from '@/store/slices/editor' +import type { DiffComment } from '../../../../../../shared/diff-comment-types' +import { DiffSectionItem } from '../../DiffSectionItem' +import type { DiffSectionItemProps } from '../../diff-section-item-props' +import { DiffNotesSendMenu } from '../../DiffNotesSendMenu' +import { canOpenDiffSectionPreviewToSide } from '../../diff-section-preview' +import type { DiffSection } from '../../diff-section-types' +import type { CombinedDiffScrollThumb } from './use-combined-diff-scrollbar' + +export function CombinedDiffSectionList({ + activeGroupId, + canOpenWorkspaceFileBrowserForPath, + diffCommentsForWorktree, + file, + handleSectionSaveRef, + isAllMode, + isBranchMode, + isCommitMode, + isDark, + loadSection, + markDirectScrollInput, + modifiedEditorsRef, + onScrollbarPointerDown, + openSection, + openSectionPreview, + retrySection, + scrollThumb, + sectionHeights, + sections, + setScrollContainerRef, + setSectionHeights, + setSections, + settings, + sideBySide, + skippedConflictNotice, + toggleSection, + virtualizer +}: { + activeGroupId: string | undefined + canOpenWorkspaceFileBrowserForPath: (path: string) => boolean + diffCommentsForWorktree: DiffComment[] + file: OpenFile + handleSectionSaveRef: DiffSectionItemProps['handleSectionSaveRef'] + isAllMode: boolean + isBranchMode: boolean + isCommitMode: boolean + isDark: boolean + loadSection: (index: number) => void + markDirectScrollInput: () => void + modifiedEditorsRef: DiffSectionItemProps['modifiedEditorsRef'] + onScrollbarPointerDown: (event: React.PointerEvent) => void + openSection: (index: number) => void + openSectionPreview: (section: DiffSection) => void + retrySection: (index: number) => void + scrollThumb: CombinedDiffScrollThumb + sectionHeights: Record + sections: DiffSection[] + setScrollContainerRef: (node: HTMLDivElement | null) => void + setSectionHeights: React.Dispatch>> + setSections: React.Dispatch> + settings: DiffSectionItemProps['settings'] + sideBySide: boolean + skippedConflictNotice: React.ReactNode + toggleSection: (index: number) => void + virtualizer: Virtualizer +}): React.JSX.Element { + return ( +
+
+ {skippedConflictNotice} +
+ {virtualizer.getVirtualItems().map((virtualItem) => { + const section = sections[virtualItem.index] + if (!section) { + return null + } + + return ( +
+ { + const fileNotes = diffCommentsForWorktree.filter( + (comment) => comment.filePath === section.path + ) + return fileNotes.length > 0 ? ( + + ) : null + }} + /> +
+ ) + })} +
+
+ {scrollThumb.visible && ( + + ) +} diff --git a/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-direct-scroll-input.ts b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-direct-scroll-input.ts new file mode 100644 index 00000000000..057825fa442 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-direct-scroll-input.ts @@ -0,0 +1,21 @@ +import { useCallback, useRef } from 'react' + +export type CombinedDiffDirectScrollInput = { + hasDirectScrollInput: () => boolean + markDirectScrollInput: () => void +} + +export function useCombinedDiffDirectScrollInput(): CombinedDiffDirectScrollInput { + const directScrollInputUntilRef = useRef(0) + + const markDirectScrollInput = useCallback((): void => { + directScrollInputUntilRef.current = window.performance.now() + 250 + }, []) + + const hasDirectScrollInput = useCallback( + () => window.performance.now() < directScrollInputUntilRef.current, + [] + ) + + return { hasDirectScrollInput, markDirectScrollInput } +} diff --git a/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scroll-anchors.ts b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scroll-anchors.ts new file mode 100644 index 00000000000..9dbf7edec03 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scroll-anchors.ts @@ -0,0 +1,188 @@ +import { useCallback, useMemo } from 'react' +import type React from 'react' +import type { Virtualizer } from '@tanstack/react-virtual' +import { + useVirtualizedScrollAnchor, + VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT, + type VirtualizedScrollAnchor +} from '@/hooks/useVirtualizedScrollAnchor' +import { getVirtualizedScrollAnchorForOffset } from '@/hooks/virtualized-scroll-anchor-recording' +import type { ProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks' +import { setWithLRU } from '@/lib/scroll-cache' +import type { DiffSection } from '../../diff-section-types' +import { combinedDiffScrollAnchorCache } from '../remember-view/combined-diff-view-memory' + +export type CombinedDiffScrollAnchors = { + persistScrollAnchor: (refreshDomAnchor?: boolean) => void + scrollToSectionIndex: (index: number) => void + recordDomScrollAnchor: () => boolean + recordVirtualScrollAnchor: (scrollTop: number) => void + writeScrollAnchor: () => void +} + +export function useCombinedDiffScrollAnchors({ + clampRestoreCount, + generation, + hasDirectScrollInput, + latestDomScrollAnchorRef, + programmaticScrollMarks, + scrollAnchorRef, + scrollContainerRef, + scrollOffsetRef, + sections, + sectionsRef, + sideBySide, + totalSize, + viewStateKey, + virtualizer +}: { + clampRestoreCount: number + generation: number + hasDirectScrollInput: () => boolean + latestDomScrollAnchorRef: React.RefObject + programmaticScrollMarks: ProgrammaticScrollMarks + scrollAnchorRef: React.RefObject + scrollContainerRef: React.RefObject + scrollOffsetRef: React.RefObject + sections: DiffSection[] + sectionsRef: React.RefObject + sideBySide: boolean + totalSize: number + viewStateKey: string + virtualizer: Virtualizer +}): CombinedDiffScrollAnchors { + const getCombinedDiffSectionKey = useCallback((section: DiffSection): string => section.key, []) + const getCombinedDiffSectionElementKey = useCallback( + (element: Element): string | null => + element instanceof HTMLElement ? (element.dataset.combinedDiffSectionKey ?? null) : null, + [] + ) + const recordVirtualScrollAnchor = useCallback( + (scrollTop: number): void => { + scrollAnchorRef.current = getVirtualizedScrollAnchorForOffset({ + getRowKey: getCombinedDiffSectionKey, + rows: sectionsRef.current, + scrollTop, + virtualItems: virtualizer.getVirtualItems() + }) + latestDomScrollAnchorRef.current = null + }, + [getCombinedDiffSectionKey, latestDomScrollAnchorRef, scrollAnchorRef, sectionsRef, virtualizer] + ) + const recordDomScrollAnchor = useCallback((): boolean => { + const container = scrollContainerRef.current + if (!container) { + return false + } + + const containerRect = container.getBoundingClientRect() + const visibleRows = Array.from( + container.querySelectorAll('[data-combined-diff-section-row]') + ) + .map((row) => { + const key = row.dataset.combinedDiffSectionKey + if (!key || !row.isConnected) { + return null + } + const rect = row.getBoundingClientRect() + if ( + rect.height <= 0 || + rect.bottom <= containerRect.top || + rect.top >= containerRect.bottom + ) { + return null + } + return { key, rect } + }) + .filter((row): row is { key: string; rect: DOMRect } => row !== null) + .sort((a, b) => a.rect.top - b.rect.top) + + const firstVisible = visibleRows[0] + if (!firstVisible) { + return false + } + + const anchor: NonNullable = { + fallbackKeys: visibleRows.slice(1).map((row) => row.key), + key: firstVisible.key, + offset: Math.min( + firstVisible.rect.height, + Math.max(0, containerRect.top - firstVisible.rect.top) + ), + scrollTop: container.scrollTop + } + scrollAnchorRef.current = anchor + latestDomScrollAnchorRef.current = anchor + return true + }, [latestDomScrollAnchorRef, scrollAnchorRef, scrollContainerRef]) + const writeScrollAnchor = useCallback((): void => { + const anchor = scrollAnchorRef.current + if (anchor) { + setWithLRU(combinedDiffScrollAnchorCache, viewStateKey, anchor) + } else { + combinedDiffScrollAnchorCache.delete(viewStateKey) + } + }, [scrollAnchorRef, viewStateKey]) + const persistScrollAnchor = useCallback( + (refreshDomAnchor = true): void => { + if (refreshDomAnchor) { + recordDomScrollAnchor() + } + writeScrollAnchor() + }, + [recordDomScrollAnchor, writeScrollAnchor] + ) + + // Why: restore only on structural changes — restoring on measurement churn overwrote scrollTop during active wheel input. + const combinedDiffRestoreSignal = useMemo( + () => + // Why: a single-section reload drops that row's measured height, so it shifts rows + // below it — still a structural change even though `generation` no longer moves. + `${generation}|${sideBySide ? 'sbs' : 'inline'}|${clampRestoreCount}|${sections + .map( + (section) => + `${section.key}:${section.collapsed ? 'c' : 'e'}:${section.contentGeneration ?? 0}` + ) + .join(',')}`, + [clampRestoreCount, generation, sections, sideBySide] + ) + + useVirtualizedScrollAnchor({ + anchorRef: scrollAnchorRef, + getItemElementKey: getCombinedDiffSectionElementKey, + getRowKey: getCombinedDiffSectionKey, + hasDirectScrollInput, + itemElementSelector: '[data-combined-diff-section-row]', + programmaticScrollMarks, + recordAnchorOnCleanup: false, + recordAnchorOnScroll: false, + restoreSignal: combinedDiffRestoreSignal, + rows: sections, + scrollElementRef: scrollContainerRef, + shouldSkipRestore: hasDirectScrollInput, + scrollOffsetRef, + totalSize, + virtualizer + }) + + const scrollToSectionIndex = useCallback( + (index: number): void => { + scrollAnchorRef.current = null + latestDomScrollAnchorRef.current = null + virtualizer.scrollToIndex(index, { align: 'start' }) + // Why: this jump is programmatic (no scroll event records an anchor); snapshot the destination once layout settles. + window.requestAnimationFrame(() => { + scrollContainerRef.current?.dispatchEvent(new Event(VIRTUALIZED_SCROLL_ANCHOR_RECORD_EVENT)) + }) + }, + [latestDomScrollAnchorRef, scrollAnchorRef, scrollContainerRef, virtualizer] + ) + + return { + persistScrollAnchor, + recordDomScrollAnchor, + recordVirtualScrollAnchor, + scrollToSectionIndex, + writeScrollAnchor + } +} diff --git a/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scroll-persistence.ts b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scroll-persistence.ts new file mode 100644 index 00000000000..a0198051238 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scroll-persistence.ts @@ -0,0 +1,213 @@ +import { useLayoutEffect } from 'react' +import type React from 'react' +import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' +import type { ProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks' +import { setWithLRU } from '@/lib/scroll-cache' +import type { DiffSection } from '../../diff-section-types' +import { + combinedDiffScrollTopCache, + combinedDiffViewStateCache +} from '../remember-view/combined-diff-view-memory' +import type { CombinedDiffScrollAnchors } from './use-combined-diff-scroll-anchors' + +export function useCombinedDiffScrollPersistence({ + anchors, + entrySignature, + hasDirectScrollInput, + latestDomScrollAnchorRef, + programmaticScrollMarks, + scrollAnchorRef, + scrollContainerRef, + scrollOffsetRef, + sectionCount, + sectionHeights, + sections, + setClampRestoreCount, + updateScrollbar, + viewStateKey +}: { + anchors: CombinedDiffScrollAnchors + entrySignature: string + hasDirectScrollInput: () => boolean + latestDomScrollAnchorRef: React.RefObject + programmaticScrollMarks: ProgrammaticScrollMarks + scrollAnchorRef: React.RefObject + scrollContainerRef: React.RefObject + scrollOffsetRef: React.RefObject + sectionCount: number + sectionHeights: Record + sections: DiffSection[] + setClampRestoreCount: React.Dispatch> + updateScrollbar: () => void + viewStateKey: string +}): void { + const { persistScrollAnchor, recordVirtualScrollAnchor, writeScrollAnchor } = anchors + + useLayoutEffect(() => { + const container = scrollContainerRef.current + if (!container) { + return + } + + const cached = combinedDiffViewStateCache.get(viewStateKey) + if (cached && cached.entrySignature === entrySignature) { + scrollOffsetRef.current = combinedDiffScrollTopCache.get(viewStateKey) ?? cached.scrollTop + } + + let anchorIdleTimerId: number | null = null + let anchorFrameId: number | null = null + const cancelScheduledAnchorPersist = (): void => { + if (anchorIdleTimerId !== null) { + window.clearTimeout(anchorIdleTimerId) + anchorIdleTimerId = null + } + if (anchorFrameId !== null) { + window.cancelAnimationFrame(anchorFrameId) + anchorFrameId = null + } + } + const scheduleSettledAnchorPersist = (): void => { + cancelScheduledAnchorPersist() + anchorIdleTimerId = window.setTimeout(() => { + anchorIdleTimerId = null + if (hasDirectScrollInput()) { + // Why: the idle timer can fire mid-wheel while TanStack still shows a transitional virtual window. + scheduleSettledAnchorPersist() + return + } + anchorFrameId = window.requestAnimationFrame(() => { + anchorFrameId = null + persistScrollAnchor() + }) + }, 150) + } + + const updateCachedScrollPosition = ({ + recordDomAnchor, + scheduleSettled, + scrollTop, + writeAnchor + }: { + recordDomAnchor: boolean + scheduleSettled: boolean + scrollTop: number + writeAnchor: boolean + }): void => { + const existing = combinedDiffViewStateCache.get(viewStateKey) + scrollOffsetRef.current = scrollTop + setWithLRU(combinedDiffScrollTopCache, viewStateKey, scrollTop) + if (writeAnchor) { + if (recordDomAnchor) { + persistScrollAnchor() + } else { + writeScrollAnchor() + } + } + if (scheduleSettled) { + scheduleSettledAnchorPersist() + } + updateScrollbar() + if (!existing || existing.entrySignature !== entrySignature) { + return + } + setWithLRU(combinedDiffViewStateCache, viewStateKey, { + ...existing, + scrollTop + }) + } + let lastScrollHeight = container.scrollHeight + const handleScroll = (event: Event): void => { + const scrollTop = container.scrollTop + const scrollHeight = container.scrollHeight + const maxScrollTop = Math.max(0, scrollHeight - container.clientHeight) + const shrank = scrollHeight < lastScrollHeight - 1 + lastScrollHeight = scrollHeight + if (programmaticScrollMarks.consume(event, scrollTop, maxScrollTop)) { + updateScrollbar() + return + } + if (shrank && scrollTop >= maxScrollTop - 1 && scrollOffsetRef.current > maxScrollTop + 1) { + // Why: pinned at a just-shrunk max from an unreachable offset is a browser clamp, not user input — re-pin, don't record it. + setClampRestoreCount((count) => count + 1) + updateScrollbar() + return + } + // Why: any unmarked scroll is the user's — even events delayed past their window by main-thread jank. + recordVirtualScrollAnchor(scrollTop) + updateCachedScrollPosition({ + recordDomAnchor: false, + scheduleSettled: true, + scrollTop, + writeAnchor: true + }) + } + + // Why: detach in the layout phase so the outgoing tab snapshots its real scroll before teardown fires a reset-to-top scroll. + updateScrollbar() + const resizeObserver = new ResizeObserver(updateScrollbar) + resizeObserver.observe(container) + container.addEventListener('scroll', handleScroll) + return () => { + cancelScheduledAnchorPersist() + if (latestDomScrollAnchorRef.current) { + scrollAnchorRef.current = latestDomScrollAnchorRef.current + } + updateCachedScrollPosition({ + recordDomAnchor: false, + scheduleSettled: false, + scrollTop: scrollOffsetRef.current, + writeAnchor: true + }) + resizeObserver.disconnect() + container.removeEventListener('scroll', handleScroll) + } + }, [ + entrySignature, + hasDirectScrollInput, + latestDomScrollAnchorRef, + persistScrollAnchor, + programmaticScrollMarks, + recordVirtualScrollAnchor, + scrollAnchorRef, + scrollContainerRef, + scrollOffsetRef, + sectionCount, + setClampRestoreCount, + updateScrollbar, + writeScrollAnchor, + viewStateKey + ]) + + useLayoutEffect(() => { + updateScrollbar() + const container = scrollContainerRef.current + if (!container || container.scrollTop <= 0) { + return + } + + let frameId: number | null = null + const timerId = window.setTimeout(() => { + if (!container.isConnected || hasDirectScrollInput()) { + return + } + frameId = window.requestAnimationFrame(() => { + frameId = null + persistScrollAnchor() + }) + }, 300) + + return () => { + window.clearTimeout(timerId) + if (frameId !== null) { + window.cancelAnimationFrame(frameId) + } + } + }, [ + hasDirectScrollInput, + persistScrollAnchor, + scrollContainerRef, + sectionHeights, + sections, + updateScrollbar + ]) +} diff --git a/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scrollbar.ts b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scrollbar.ts new file mode 100644 index 00000000000..a7fe20938c4 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-scrollbar.ts @@ -0,0 +1,140 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type React from 'react' +import { + beginCombinedDiffScrollbarDrag, + type CombinedDiffScrollbarDragCleanup +} from './combined-diff-scrollbar-drag' + +const COMBINED_DIFF_SCROLLBAR_THUMB_MIN_HEIGHT = 64 + +export type CombinedDiffScrollThumb = { + visible: boolean + top: number + height: number +} + +export type CombinedDiffScrollbar = { + cleanupActiveScrollbarDrag: () => void + handleScrollbarPointerDown: (event: React.PointerEvent) => void + scrollThumb: CombinedDiffScrollThumb + updateScrollbar: () => void +} + +export function useCombinedDiffScrollbar({ + markDirectScrollInput, + scrollContainerRef +}: { + markDirectScrollInput: () => void + scrollContainerRef: React.RefObject +}): CombinedDiffScrollbar { + const [scrollThumb, setScrollThumb] = useState({ + visible: false, + top: 0, + height: COMBINED_DIFF_SCROLLBAR_THUMB_MIN_HEIGHT + }) + const activeScrollbarDragCleanupRef = useRef(null) + + const updateScrollbar = useCallback(() => { + const container = scrollContainerRef.current + if (!container || container.scrollHeight <= container.clientHeight + 1) { + setScrollThumb((prev) => + prev.visible + ? { + visible: false, + top: 0, + height: COMBINED_DIFF_SCROLLBAR_THUMB_MIN_HEIGHT + } + : prev + ) + return + } + + const trackHeight = Math.max(1, container.clientHeight - 8) + const maxScrollTop = Math.max(1, container.scrollHeight - container.clientHeight) + const height = Math.min( + trackHeight, + Math.max( + COMBINED_DIFF_SCROLLBAR_THUMB_MIN_HEIGHT, + (container.clientHeight / container.scrollHeight) * trackHeight + ) + ) + const top = ((trackHeight - height) * container.scrollTop) / maxScrollTop + setScrollThumb({ visible: true, top, height }) + }, [scrollContainerRef]) + + const cleanupActiveScrollbarDrag = useCallback((): void => { + activeScrollbarDragCleanupRef.current?.() + }, []) + + useEffect(() => cleanupActiveScrollbarDrag, [cleanupActiveScrollbarDrag]) + + const handleScrollbarPointerDown = useCallback( + (event: React.PointerEvent) => { + const container = scrollContainerRef.current + if (!container) { + return + } + + event.preventDefault() + markDirectScrollInput() + const track = event.currentTarget + const thumb = + event.target instanceof HTMLElement + ? event.target.closest('[data-combined-diff-scrollbar-thumb]') + : null + + const getLiveThumbHeight = (): number => { + const trackHeight = Math.max(1, track.getBoundingClientRect().height) + return Math.min( + trackHeight, + Math.max( + COMBINED_DIFF_SCROLLBAR_THUMB_MIN_HEIGHT, + (container.clientHeight / container.scrollHeight) * trackHeight + ) + ) + } + + const getScrollTopForPointer = (clientY: number, grabOffset: number): number => { + const trackRect = track.getBoundingClientRect() + const trackHeight = Math.max(1, trackRect.height) + const thumbHeight = getLiveThumbHeight() + const maxThumbTop = Math.max(1, trackHeight - thumbHeight) + const maxScrollTop = Math.max(1, container.scrollHeight - container.clientHeight) + const thumbTop = Math.max(0, Math.min(maxThumbTop, clientY - trackRect.top - grabOffset)) + return (thumbTop / maxThumbTop) * maxScrollTop + } + + const grabOffset = thumb + ? event.clientY - thumb.getBoundingClientRect().top + : getLiveThumbHeight() / 2 + + if (!thumb) { + container.scrollTop = getScrollTopForPointer(event.clientY, grabOffset) + updateScrollbar() + } + + const handlePointerMove = (moveEvent: PointerEvent): void => { + moveEvent.preventDefault() + markDirectScrollInput() + container.scrollTop = getScrollTopForPointer(moveEvent.clientY, grabOffset) + updateScrollbar() + } + cleanupActiveScrollbarDrag() + let cleanupPointerDrag: CombinedDiffScrollbarDragCleanup + cleanupPointerDrag = beginCombinedDiffScrollbarDrag({ + track, + pointerId: event.pointerId, + onPointerMove: handlePointerMove, + onEnd: () => { + if (activeScrollbarDragCleanupRef.current === cleanupPointerDrag) { + activeScrollbarDragCleanupRef.current = null + } + } + }) + activeScrollbarDragCleanupRef.current = cleanupPointerDrag + }, + [cleanupActiveScrollbarDrag, markDirectScrollInput, scrollContainerRef, updateScrollbar] + ) + + return { cleanupActiveScrollbarDrag, handleScrollbarPointerDown, scrollThumb, updateScrollbar } +} diff --git a/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-virtualizer.ts b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-virtualizer.ts new file mode 100644 index 00000000000..3043b4a57d8 --- /dev/null +++ b/src/renderer/src/components/editor/combined-diff/scroll-viewport/use-combined-diff-virtualizer.ts @@ -0,0 +1,88 @@ +import { useLayoutEffect } from 'react' +import type React from 'react' +import { elementScroll, useVirtualizer, type Virtualizer } from '@tanstack/react-virtual' +import type { ProgrammaticScrollMarks } from '@/hooks/programmatic-scroll-marks' +import type { DiffSection } from '../../diff-section-types' +import { + getDiffSectionEstimatedHeight, + isIntrinsicHeightImageDiff +} from '../../diff-section-layout' + +const COMBINED_DIFF_OVERSCAN = 5 + +export function useCombinedDiffVirtualizer({ + generation, + programmaticScrollMarks, + renderedIndicesRef, + scrollContainerRef, + scrollOffsetRef, + sectionHeights, + sections, + sideBySide +}: { + generation: number + programmaticScrollMarks: ProgrammaticScrollMarks + renderedIndicesRef: React.RefObject> + scrollContainerRef: React.RefObject + scrollOffsetRef: React.RefObject + sectionHeights: Record + sections: DiffSection[] + sideBySide: boolean +}): Virtualizer { + const virtualizer = useVirtualizer({ + count: sections.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: (index) => { + const section = sections[index] + if (!section) { + return 88 + } + + return getDiffSectionEstimatedHeight({ + collapsed: section.collapsed, + measuredContentHeight: sectionHeights[index], + originalContent: section.originalContent, + modifiedContent: section.modifiedContent, + changedLineCount: + section.added === undefined && section.removed === undefined + ? undefined + : (section.added ?? 0) + (section.removed ?? 0), + useIntrinsicImageHeight: isIntrinsicHeightImageDiff(section.diffResult), + isLargeDiffLimited: section.largeDiffRenderLimit?.limited === true, + lineCounts: section.largeDiffRenderLimit?.lineCounts ?? undefined + }) + }, + overscan: COMBINED_DIFF_OVERSCAN, + initialOffset: () => scrollOffsetRef.current, + // Why: mark every virtualizer-issued scroll so events are attributed to the user only when this code didn't cause them. + scrollToFn: (offset, options, instance) => { + const target = offset + (options.adjustments ?? 0) + // Why: writing the current position emits no scroll event; a mark here would go stale and claim a later user scroll. + if (instance.scrollElement?.scrollTop !== target) { + programmaticScrollMarks.mark(target) + } + elementScroll(offset, options, instance) + }, + getItemKey: (index) => { + const section = sections[index] + if (!section) { + return `${index}:${generation}` + } + // Why: contentGeneration is per-section, so a single row's reload remounts only that row. + return `${section.key}:${section.collapsed ? 'collapsed' : 'expanded'}:${generation}:${section.contentGeneration ?? 0}` + } + }) + + // Why: keep render pure (React Doctor); retrySection still needs the on-screen set without the virtualizer as a dep. + const virtualItems = virtualizer.getVirtualItems() + useLayoutEffect(() => { + renderedIndicesRef.current = new Set(virtualItems.map((item) => item.index)) + }, [renderedIndicesRef, virtualItems]) + + useLayoutEffect(() => { + // Why: inline vs side-by-side changes Monaco row heights; re-measure on the mode flip, not on every section load. + virtualizer.measure() + }, [sideBySide, virtualizer]) + + return virtualizer +} diff --git a/src/renderer/src/components/editor/editor-lazy-views.ts b/src/renderer/src/components/editor/editor-lazy-views.ts index bfdacaba622..093b06317b8 100644 --- a/src/renderer/src/components/editor/editor-lazy-views.ts +++ b/src/renderer/src/components/editor/editor-lazy-views.ts @@ -4,7 +4,7 @@ import { lazyWithRetry as lazy } from '@/lib/lazy-with-retry' // same module is a distinct component type, so it gets its own Suspense boundary and remount. export const MonacoEditor = lazy(() => import('./MonacoEditor')) export const DiffViewer = lazy(() => import('./DiffViewer')) -export const CombinedDiffViewer = lazy(() => import('./CombinedDiffViewer')) +export const CombinedDiffViewer = lazy(() => import('./combined-diff/CombinedDiffViewer')) export const RichMarkdownEditor = lazy(() => import('./RichMarkdownEditor'), { reloadKey: 'rich-markdown-editor' }) diff --git a/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-body.tsx b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-body.tsx index b706ba073bf..2364f966746 100644 --- a/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-body.tsx +++ b/src/renderer/src/components/github-item-dialog/inspect-pull-request/pr-files-combined-diff-body.tsx @@ -4,9 +4,9 @@ import type { editor as monacoEditor } from 'monaco-editor' import { DiffSectionItem } from '@/components/editor/DiffSectionItem' import { translate } from '@/i18n/i18n' import type { DecoratedDiffComment } from '@/components/diff-comments/decorated-diff-comment' -import { CombinedDiffFileTree } from '@/components/editor/CombinedDiffFileTree' +import { CombinedDiffFileTree } from '../../editor/combined-diff/browse-files/combined-diff-file-tree' import type { DiffSection } from '@/components/editor/diff-section-types' -import type { CombinedDiffFileTreeEntry } from '@/components/editor/combined-diff-file-tree-model' +import type { CombinedDiffFileTreeEntry } from '../../editor/combined-diff/resolve-changes/combined-diff-section-identity' import type { GitHubPRFile } from '../../../../../shared/github/pull-request-types' import type { GitBranchChangeEntry } from '../../../../../shared/git-diff-compare-types' import type { DiffSectionItemProps } from '@/components/editor/diff-section-item-props' 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 f4add6132b6..71772a01754 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,19 +2,15 @@ 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, - handleCombinedDiffFileTreeNavigation -} from '@/components/editor/CombinedDiffFileTree' +import { createCombinedDiffSectionIndexMap } from '../../editor/combined-diff/resolve-changes/combined-diff-section-identity' +import { handleCombinedDiffFileTreeNavigation } from '../../editor/combined-diff/browse-files/combined-diff-file-tree-navigation' import { getDiffSectionEstimatedHeight, isIntrinsicHeightImageDiff } from '@/components/editor/diff-section-layout' import type { DiffSection } from '@/components/editor/diff-section-types' -import { - getCombinedDiffBranchEntriesInTreeOrder, - type CombinedDiffFileTreeEntry -} from '@/components/editor/combined-diff-file-tree-model' +import { getCombinedDiffBranchEntriesInTreeOrder } from '../../editor/combined-diff/browse-files/combined-diff-file-tree-filter' +import type { CombinedDiffFileTreeEntry } from '../../editor/combined-diff/resolve-changes/combined-diff-section-identity' import { useAppStore } from '@/store' import type { GitBranchChangeEntry } from '../../../../../shared/git-diff-compare-types' import { isPRFileViewed } from '@/components/github/pr-file-content-size' diff --git a/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts b/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts index 77aca89c326..30c7d53b17b 100644 --- a/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts +++ b/src/renderer/src/components/hover-reveal-touch-action-visibility.test.ts @@ -13,7 +13,7 @@ const HOVER_REVEAL_FILES = [ resolve(__dirname, 'browser-pane/annotate/browser-page-annotation-tray.tsx'), resolve(__dirname, 'dashboard/DashboardAgentRow.tsx'), resolve(__dirname, 'dashboard/DashboardAgentRowTrailingControls.tsx'), - resolve(__dirname, 'editor/CombinedDiffViewer.tsx'), + resolve(__dirname, 'editor/combined-diff/CombinedDiffViewer.tsx'), resolve(__dirname, 'editor/DiffSectionHeader.tsx'), resolve(__dirname, 'github-project/ProjectPicker.tsx'), resolve(__dirname, 'github-project/ProjectRow.tsx'), 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 3cee0fee2aa..45419accec3 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 @@ -3,20 +3,16 @@ import { useVirtualizer } from '@tanstack/react-virtual' import type { editor as monacoEditor } from 'monaco-editor' import { useAppStore } from '@/store' import { DiffSectionItem } from '@/components/editor/DiffSectionItem' -import { - CombinedDiffFileTree, - createCombinedDiffSectionIndexMap, - handleCombinedDiffFileTreeNavigation -} from '@/components/editor/CombinedDiffFileTree' +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 { handleCombinedDiffFileTreeNavigation } from '../../editor/combined-diff/browse-files/combined-diff-file-tree-navigation' import { getDiffSectionEstimatedHeight, isIntrinsicHeightImageDiff } from '@/components/editor/diff-section-layout' import type { DiffSection } from '@/components/editor/diff-section-types' -import { - getCombinedDiffBranchEntriesInTreeOrder, - type CombinedDiffFileTreeEntry -} from '@/components/editor/combined-diff-file-tree-model' +import { getCombinedDiffBranchEntriesInTreeOrder } from '../../editor/combined-diff/browse-files/combined-diff-file-tree-filter' +import type { CombinedDiffFileTreeEntry } from '../../editor/combined-diff/resolve-changes/combined-diff-section-identity' import { PRViewedCheckbox } from '@/components/github/PRViewedCheckbox' import { isPRFileViewed } from '@/components/github/pr-file-content-size' import { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 15f84eb2a26..11baf14c8d3 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -14239,8 +14239,9 @@ "7e7ca60816": "changed files", "b6c3b84476": "Show file tree", "39f8007549": "Review conflicts", - "39e73e7181": "were excluded from this diff view.", - "689b99f8ad": "unresolved conflict", + "skippedConflictsExcluded": "{{count}} unresolved conflicts were excluded from this diff view.", + "skippedConflictsExcluded_one": "{{count}} unresolved conflict was excluded from this diff view.", + "skippedConflictsExcluded_other": "{{count}} unresolved conflicts were excluded from this diff view.", "820ec01f24": "Conflicted files are reviewed separately", "fd8892b120": "No changes to display", "eb5f40e49c": "This diff view excludes unresolved conflicts because the normal two-way diff pipeline is not conflict-safe.", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 8e73672554c..91e9dc49dcc 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -12767,8 +12767,9 @@ "7e7ca60816": "archivos cambiados", "b6c3b84476": "Mostrar árbol de archivos", "39f8007549": "Revisar conflictos", - "39e73e7181": "se excluyeron de esta vista de diff.", - "689b99f8ad": "conflicto no resuelto", + "skippedConflictsExcluded": "Se excluyeron {{count}} conflictos no resueltos de esta vista de diff.", + "skippedConflictsExcluded_one": "Se excluyó {{count}} conflicto no resuelto de esta vista de diff.", + "skippedConflictsExcluded_other": "Se excluyeron {{count}} conflictos no resueltos de esta vista de diff.", "820ec01f24": "Los archivos en conflicto se revisan por separado", "fd8892b120": "No hay cambios para mostrar", "eb5f40e49c": "Esta vista de diff excluye los conflictos sin resolver porque el pipeline normal de diff de dos vías no es seguro para conflictos.", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c9ec69f8e46..493dfce6bda 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -12767,8 +12767,9 @@ "7e7ca60816": "変更されたファイル", "b6c3b84476": "ファイルツリーを表示", "39f8007549": "競合をレビューする", - "39e73e7181": "はこの差分ビューから除外されました。", - "689b99f8ad": "未解決の競合", + "skippedConflictsExcluded": "未解決の競合 {{count}} 件がこの差分ビューから除外されました。", + "skippedConflictsExcluded_one": "未解決の競合 {{count}} 件がこの差分ビューから除外されました。", + "skippedConflictsExcluded_other": "未解決の競合 {{count}} 件がこの差分ビューから除外されました。", "820ec01f24": "競合したファイルは個別にレビューされます", "fd8892b120": "表示に変更はありません", "eb5f40e49c": "通常の双方向 diff パイプラインは競合に対して安全ではないため、この diff ビューでは未解決の競合が除外されます。", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index bf60596e599..583b6ba2da6 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -12799,8 +12799,9 @@ "7e7ca60816": "변경된 파일", "b6c3b84476": "파일 트리 표시", "39f8007549": "충돌 리뷰", - "39e73e7181": "이 차이점 보기에서 제외되었습니다.", - "689b99f8ad": "미해결 충돌", + "skippedConflictsExcluded": "미해결 충돌 {{count}}개가 이 차이점 보기에서 제외되었습니다.", + "skippedConflictsExcluded_one": "미해결 충돌 {{count}}개가 이 차이점 보기에서 제외되었습니다.", + "skippedConflictsExcluded_other": "미해결 충돌 {{count}}개가 이 차이점 보기에서 제외되었습니다.", "820ec01f24": "충돌하는 파일은 별도로 리뷰됩니다.", "fd8892b120": "표시할 변경사항이 없습니다.", "eb5f40e49c": "일반적인 양방향 diff 파이프라인은 충돌로부터 안전하지 않기 때문에 이 diff 보기는 해결되지 않은 충돌을 제외합니다.", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 3f2a1e2d1ed..dc0cf670765 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -12810,8 +12810,9 @@ "7e7ca60816": "更改的文件", "b6c3b84476": "显示文件树", "39f8007549": "评审冲突", - "39e73e7181": "被排除在此差异视图之外。", - "689b99f8ad": "未解决的冲突", + "skippedConflictsExcluded": "{{count}} 个未解决的冲突已被排除在此差异视图之外。", + "skippedConflictsExcluded_one": "{{count}} 个未解决的冲突已被排除在此差异视图之外。", + "skippedConflictsExcluded_other": "{{count}} 个未解决的冲突已被排除在此差异视图之外。", "820ec01f24": "冲突文件单独评审", "fd8892b120": "没有显示任何变化", "eb5f40e49c": "此差异视图排除了未解决的冲突,因为普通的双向差异管道不是冲突安全的。", From b8d5b0486effc6d5f4c3cfae0dda37975de6e841 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:48:43 -0700 Subject: [PATCH 7/8] Fix opening HTTPS URLs from headless runtimes (#17467) * fix(runtime): open headless browser URLs on paired client * fix(pty): tolerate runtimes without browser relay probe * fix(browser): require automation-capable client host * fix(browser): map client URL opener in sidecar --------- Co-authored-by: Merge Sim --- src/cli/browser-handler-groups.ts | 11 ++- src/cli/browser.test.ts | 21 ++++++ src/cli/handlers/browser-tab.ts | 10 +++ src/cli/specs/browser-basic.ts | 6 ++ .../ipc/pty-spawn-env-terminal-basics.test.ts | 69 +++++++++++++++++++ src/main/ipc/pty/host-env/assembly.ts | 9 +++ src/main/ipc/pty/host-env/types.ts | 2 + src/main/ipc/pty/ipc/spawn-env-codex.ts | 1 + src/main/ipc/pty/provider/local-configure.ts | 3 +- src/main/ipc/pty/runtime/spawn-preflight.ts | 1 + .../electron-sidecar-method-routing.test.ts | 4 ++ .../orcad/electron-sidecar-method-routing.ts | 3 + .../browser-host-capability-selection.test.ts | 10 +++ src/main/runtime/orca-runtime-browser.ts | 28 ++++++++ src/main/runtime/orca-runtime.test.ts | 15 ++++ src/main/runtime/orca-runtime.ts | 7 ++ src/main/runtime/rpc/methods/browser-core.ts | 7 +- .../rpc/methods/browser-tab-create-schema.ts | 5 ++ src/main/runtime/rpc/methods/browser.test.ts | 20 ++++++ 19 files changed, 229 insertions(+), 3 deletions(-) diff --git a/src/cli/browser-handler-groups.ts b/src/cli/browser-handler-groups.ts index 0a7de2fa573..3567e8b0596 100644 --- a/src/cli/browser-handler-groups.ts +++ b/src/cli/browser-handler-groups.ts @@ -53,7 +53,16 @@ export const BROWSER_HANDLER_GROUPS: readonly HandlerGroup[] = [ }, { name: 'browser-tab', - keys: ['tab list', 'tab show', 'tab current', 'tab switch', 'tab create', 'tab close', 'exec'], + keys: [ + 'open-url', + 'tab list', + 'tab show', + 'tab current', + 'tab switch', + 'tab create', + 'tab close', + 'exec' + ], load: async () => (await import('./handlers/browser-tab.js')).BROWSER_TAB_HANDLERS }, { diff --git a/src/cli/browser.test.ts b/src/cli/browser.test.ts index 66c2aabb3fb..f1dff8bc302 100644 --- a/src/cli/browser.test.ts +++ b/src/cli/browser.test.ts @@ -183,6 +183,27 @@ describe('orca cli browser page targeting', () => { ) }) + it('opens browser-launch URLs on the client hosting the current worktree', async () => { + queueFixtures( + callMock, + worktreeListFixture([buildWorktree('/tmp/repo/feature', 'feature/foo')]), + okFixture('req_open_url', { browserPageId: 'page-local' }) + ) + vi.spyOn(console, 'log').mockImplementation(() => {}) + + await main(['open-url', '--url', 'https://example.com/login', '--json'], '/tmp/repo/feature') + + expect(callMock).toHaveBeenNthCalledWith( + 2, + 'browser.openUrl', + { + url: 'https://example.com/login', + worktree: 'id:repo::/tmp/repo/feature' + }, + { timeoutMs: 60_000 } + ) + }) + it('passes tab profile updates through by page id', async () => { queueFixtures( callMock, diff --git a/src/cli/handlers/browser-tab.ts b/src/cli/handlers/browser-tab.ts index 3e975bb360b..5d19301ad4c 100644 --- a/src/cli/handlers/browser-tab.ts +++ b/src/cli/handlers/browser-tab.ts @@ -15,6 +15,16 @@ import { RuntimeClientError } from '../runtime-client' import { getBrowserCommandTarget, getBrowserWorktreeSelector } from '../selectors' export const BROWSER_TAB_HANDLERS: Record = { + 'open-url': async ({ flags, client, cwd, json }) => { + const url = getRequiredStringFlag(flags, 'url') + const worktree = await getBrowserWorktreeSelector(flags, cwd, client) + const result = await client.call<{ browserPageId: string }>( + 'browser.openUrl', + { url, worktree }, + { timeoutMs: 60_000 } + ) + printResult(result, json, (v) => `Opened URL in tab ${v.browserPageId}`) + }, 'tab list': async ({ flags, client, cwd, json }) => { const worktree = await getBrowserWorktreeSelector(flags, cwd, client) const result = await client.call('browser.tabList', { worktree }) diff --git a/src/cli/specs/browser-basic.ts b/src/cli/specs/browser-basic.ts index 391a22acf31..7e3bb4a5770 100644 --- a/src/cli/specs/browser-basic.ts +++ b/src/cli/specs/browser-basic.ts @@ -2,6 +2,12 @@ import type { CommandSpec } from '../args' import { GLOBAL_FLAGS } from '../args' export const BROWSER_BASIC_COMMAND_SPECS: CommandSpec[] = [ + { + path: ['open-url'], + summary: 'Open a URL on the paired client that hosts this terminal', + usage: 'orca open-url --url [--worktree ] [--json]', + allowedFlags: [...GLOBAL_FLAGS, 'url', 'worktree'] + }, { path: ['snapshot'], summary: 'Capture an accessibility snapshot of the active browser tab', diff --git a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts index 54ec3e7663c..b15255b870d 100644 --- a/src/main/ipc/pty-spawn-env-terminal-basics.test.ts +++ b/src/main/ipc/pty-spawn-env-terminal-basics.test.ts @@ -58,6 +58,75 @@ describe('registerPtyHandlers', () => { const { handlers, mainWindow, spawnAndGetEnv, withBundledCli } = setupPtyIpcSuite() describe('spawn environment', () => { + it('routes headless browser launches through the owning Orca workspace', () => { + const inheritedBrowser = process.env.BROWSER + delete process.env.BROWSER + try { + const env = buildPtyHostEnv( + 'pty-headless', + {}, + { + isPackaged: true, + userDataPath: '/tmp/orca-user-data', + selectedCodexHomePath: null, + agentStatusHooksEnabled: false, + routeBrowserOpensToClient: true + } + ) + + expect(env.BROWSER).toBe('orca open-url --url %s') + } finally { + if (inheritedBrowser === undefined) { + delete process.env.BROWSER + } else { + process.env.BROWSER = inheritedBrowser + } + } + }) + + it('preserves an explicit browser command on headless runtimes', () => { + const env = buildPtyHostEnv( + 'pty-custom-browser', + { BROWSER: 'custom-browser %s' }, + { + isPackaged: true, + userDataPath: '/tmp/orca-user-data', + selectedCodexHomePath: null, + agentStatusHooksEnabled: false, + routeBrowserOpensToClient: true + } + ) + + expect(env.BROWSER).toBe('custom-browser %s') + }) + + it('uses the registered WSL CLI name for headless browser launches', () => { + const inheritedBrowser = process.env.BROWSER + delete process.env.BROWSER + try { + const env = buildPtyHostEnv( + 'pty-headless-wsl', + {}, + { + isPackaged: true, + userDataPath: '/tmp/orca-user-data', + selectedCodexHomePath: null, + isWsl: true, + agentStatusHooksEnabled: false, + routeBrowserOpensToClient: true + } + ) + + expect(env.BROWSER).toBe('orca-ide open-url --url %s') + } finally { + if (inheritedBrowser === undefined) { + delete process.env.BROWSER + } else { + process.env.BROWSER = inheritedBrowser + } + } + }) + it('passes the PTY-resolved Codex home to the WSL relay lane', () => { const runtimeHome = '\\\\wsl.localhost\\Ubuntu\\home\\jin\\.local\\share\\orca\\codex-runtime-home\\home' diff --git a/src/main/ipc/pty/host-env/assembly.ts b/src/main/ipc/pty/host-env/assembly.ts index cee3bf5c50b..908d0730221 100644 --- a/src/main/ipc/pty/host-env/assembly.ts +++ b/src/main/ipc/pty/host-env/assembly.ts @@ -264,6 +264,15 @@ export function buildPtyHostEnv( : bundledCliBin } + if ( + opts.routeBrowserOpensToClient === true && + baseEnv.BROWSER === undefined && + process.env.BROWSER === undefined + ) { + const cliCommand = opts.isWsl ? (opts.isPackaged ? 'orca-ide' : 'orca-dev') : 'orca' + baseEnv.BROWSER = `${cliCommand} open-url --url %s` + } + // Why: must run after the prepends above — they re-read PATH from the unscrubbed // process.env when baseEnv carries none, which is the daemon path's normal shape. stripLegacyTerminalShimEnv(baseEnv, process.platform) diff --git a/src/main/ipc/pty/host-env/types.ts b/src/main/ipc/pty/host-env/types.ts index 298949d615a..f97ab265359 100644 --- a/src/main/ipc/pty/host-env/types.ts +++ b/src/main/ipc/pty/host-env/types.ts @@ -29,6 +29,8 @@ export type BuildPtyHostEnvOptions = { agentStatusHooksEnabled: boolean codexStatusHooksEnabled?: boolean networkProxySettings?: NetworkProxySettings + /** Headless paired runtimes hand browser launches to the client-hosted Orca browser. */ + routeBrowserOpensToClient?: boolean /** Keep indexed Git config off the sparse daemon wire; the daemon appends guard entries after merging its inherited env. */ deferGitConfigGuardToDaemon?: boolean } diff --git a/src/main/ipc/pty/ipc/spawn-env-codex.ts b/src/main/ipc/pty/ipc/spawn-env-codex.ts index 42550f6732d..3b9bc444607 100644 --- a/src/main/ipc/pty/ipc/spawn-env-codex.ts +++ b/src/main/ipc/pty/ipc/spawn-env-codex.ts @@ -142,6 +142,7 @@ export async function assemblePtyIpcSpawnCodexEnv(ctx: PtyIpcSpawnState): Promis agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), networkProxySettings: ptySettings, + routeBrowserOpensToClient: ctx.deps.runtime?.shouldRelayTerminalBrowserOpens?.(), deferGitConfigGuardToDaemon: ctx.provider.supportsGitCredentialGuardHost?.(ctx.effectiveSessionId) === true }) diff --git a/src/main/ipc/pty/provider/local-configure.ts b/src/main/ipc/pty/provider/local-configure.ts index 50f3c3e4016..1ebf0248e12 100644 --- a/src/main/ipc/pty/provider/local-configure.ts +++ b/src/main/ipc/pty/provider/local-configure.ts @@ -72,7 +72,8 @@ export function configureLocalPtyProvider(args: { wslDistro: ctx?.wslDistro ?? null, agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), - networkProxySettings: ptySettings + networkProxySettings: ptySettings, + routeBrowserOpensToClient: runtime?.shouldRelayTerminalBrowserOpens?.() }) // Why: agents need their terminal handle at process start to self-identify in orchestration messages without an extra RPC. const requestedHandle = baseEnv.ORCA_TERMINAL_HANDLE diff --git a/src/main/ipc/pty/runtime/spawn-preflight.ts b/src/main/ipc/pty/runtime/spawn-preflight.ts index f1ce7df7633..aed89b44df8 100644 --- a/src/main/ipc/pty/runtime/spawn-preflight.ts +++ b/src/main/ipc/pty/runtime/spawn-preflight.ts @@ -258,6 +258,7 @@ export async function prepareRuntimePtySpawn( agentStatusHooksEnabled: isAgentStatusHooksEnabled(ptySettings), codexStatusHooksEnabled: isCodexStatusHooksEnabled(ptySettings), networkProxySettings: ptySettings, + routeBrowserOpensToClient: ctx.deps.runtime?.shouldRelayTerminalBrowserOpens?.(), deferGitConfigGuardToDaemon: ctx.provider.supportsGitCredentialGuardHost?.(ctx.sessionId) === true }) diff --git a/src/main/orcad/electron-sidecar-method-routing.test.ts b/src/main/orcad/electron-sidecar-method-routing.test.ts index 07f96a10d7b..a1b7ba17d50 100644 --- a/src/main/orcad/electron-sidecar-method-routing.test.ts +++ b/src/main/orcad/electron-sidecar-method-routing.test.ts @@ -52,6 +52,10 @@ async function runtimeCommandFor( } describe('electronSidecarRuntimeMethodName', () => { + it('maps client URL opens to the browser.openUrl RPC', () => { + expect(electronSidecarRuntimeMethodName('browserOpenUrlOnClient')).toBe('browser.openUrl') + }) + it('round-trips every registered browser RPC method', async () => { const mismatches: string[] = [] const unmapped: string[] = [] diff --git a/src/main/orcad/electron-sidecar-method-routing.ts b/src/main/orcad/electron-sidecar-method-routing.ts index a2dcfec79ef..5b0140c5793 100644 --- a/src/main/orcad/electron-sidecar-method-routing.ts +++ b/src/main/orcad/electron-sidecar-method-routing.ts @@ -10,6 +10,9 @@ export const TARGETLESS_BROWSER_METHODS: Record = { } export function electronSidecarRuntimeMethodName(browserMethod: string): string { + if (browserMethod === 'browserOpenUrlOnClient') { + return 'browser.openUrl' + } const suffix = browserMethod.slice('browser'.length) if (suffix === 'ProceedCertificate') { return 'browser.certificate.proceed' diff --git a/src/main/runtime/browser-host-capability-selection.test.ts b/src/main/runtime/browser-host-capability-selection.test.ts index 03b28fc09c6..a190acf27bd 100644 --- a/src/main/runtime/browser-host-capability-selection.test.ts +++ b/src/main/runtime/browser-host-capability-selection.test.ts @@ -19,6 +19,16 @@ describe('browser host capability selection', () => { expect(() => leases.select(undefined, ['mirror.v1'])).toThrow('browser_host_unavailable') }) + it('skips a webview-only host when client automation is required', () => { + const leases = registry() + attach(leases, 'webview-only', ['webview']) + attach(leases, 'automation', ['webview', 'automation-v1']) + + expect(leases.select(undefined, ['webview', 'automation-v1'])).toMatchObject({ + browserHostClientId: 'host-automation' + }) + }) + it('keeps multiple qualified hosts ambiguous without arbitrary routing', () => { const leases = registry() attach(leases, 'a', ['webview', 'commands.v1']) diff --git a/src/main/runtime/orca-runtime-browser.ts b/src/main/runtime/orca-runtime-browser.ts index caf5beedd35..272ff07c80a 100644 --- a/src/main/runtime/orca-runtime-browser.ts +++ b/src/main/runtime/orca-runtime-browser.ts @@ -54,6 +54,7 @@ import type { BrowserSessionUserAgentMode } from '../../shared/browser-workspace-types' import type { BrowserNetworkExecutionHost } from '../../shared/browser-client-host-protocol' +import { BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY } from '../../shared/browser-client-automation-protocol' import type { BrowserPageCreationPlacement } from '../../shared/browser-client-host-placement' import type { ExecutionHostId } from '../../shared/execution-host' import { browserNetworkExecutionHostKey } from '../browser/browser-network-execution-route' @@ -96,6 +97,7 @@ import { } from './browser-tab-create-publication' import type { RuntimeNavigationTarget } from '../../shared/runtime-navigation' import type { BrowserHostLeaseRegistry } from './browser-host-lease-registry' +import { BROWSER_HOST_WEBVIEW_CAPABILITY } from './browser-host-capability-selection' import { closeRuntimeBrowserClientPage, createRuntimeBrowserClientPage, @@ -1727,6 +1729,32 @@ export class RuntimeBrowserCommands { return { browserPageId } } + async browserOpenUrlOnClient(params: { + url: string + worktree: string + }): Promise<{ browserPageId: string }> { + const protocol = new URL(params.url).protocol + if (protocol !== 'http:' && protocol !== 'https:') { + throw new BrowserError('invalid_argument', 'Only http(s) URLs can be opened on the client.') + } + const lease = this.host + .getBrowserHostLeaseRegistry() + .select(undefined, [ + BROWSER_HOST_WEBVIEW_CAPABILITY, + BROWSER_CLIENT_AUTOMATION_HOST_CAPABILITY + ]) + return this.browserTabCreate( + { + url: params.url, + worktree: params.worktree, + activate: true, + navigation: 'caller', + placement: { kind: 'client', browserHostClientId: lease.browserHostClientId } + }, + { pairedDeviceId: lease.pairedDeviceId, clientKind: 'runtime' } + ) + } + async browserTabSetProfile( params: { profileId: string diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index bdcab959b01..6aae780db35 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -3135,6 +3135,21 @@ describe('OrcaRuntimeService', () => { }) }) + it('relays terminal browser launches only while headless owns the graph', () => { + const runtime = createRuntime() + electronMocks.BrowserWindow.fromId.mockImplementation((windowId: number) => + windowId === TEST_WINDOW_ID ? ({ isDestroyed: () => false } as never) : null + ) + + expect(runtime.shouldRelayTerminalBrowserOpens()).toBe(false) + + runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] }) + expect(runtime.shouldRelayTerminalBrowserOpens()).toBe(true) + + runtime.attachWindow(TEST_WINDOW_ID) + expect(runtime.shouldRelayTerminalBrowserOpens()).toBe(false) + }) + it('marks live headless PTYs for renderer reattach before desktop promotion', () => { const { runtimeStore, getSession } = makeRuntimeStoreWithWorkspaceSession( makeWorkspaceSessionWithHeadlessTerminal({ diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index c6fa5b474ec..c41a1d5be69 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -6625,6 +6625,10 @@ export class OrcaRuntimeService { } } + shouldRelayTerminalBrowserOpens(): boolean { + return this.authoritativeWindowId === HEADLESS_RUNTIME_WINDOW_ID + } + // Why: scans the transcript-owning host's disk (correct by construction over // RPC — a remote/SSH host scans its own disk). Delegates to the one shared // cache so the desktop panel and the mobile screen never double-scan. @@ -41267,6 +41271,9 @@ export class OrcaRuntimeService { browserTabCreate: RuntimeBrowserCommands['browserTabCreate'] = this.browserCommands.browserTabCreate.bind(this.browserCommands) + browserOpenUrlOnClient: RuntimeBrowserCommands['browserOpenUrlOnClient'] = + this.browserCommands.browserOpenUrlOnClient.bind(this.browserCommands) + browserTabSetProfile: RuntimeBrowserCommands['browserTabSetProfile'] = this.browserCommands.browserTabSetProfile.bind(this.browserCommands) diff --git a/src/main/runtime/rpc/methods/browser-core.ts b/src/main/runtime/rpc/methods/browser-core.ts index 8a7dffa40ba..5e5fba227b6 100644 --- a/src/main/runtime/rpc/methods/browser-core.ts +++ b/src/main/runtime/rpc/methods/browser-core.ts @@ -31,7 +31,7 @@ import { Upload, Wait } from './browser-schemas' -import { BrowserTabCreateParams } from './browser-tab-create-schema' +import { BrowserOpenUrlParams, BrowserTabCreateParams } from './browser-tab-create-schema' import { BROWSER_TEXT_METHODS } from './browser-text-rpc-methods' const CertificateProceed = BrowserTarget.extend({ @@ -118,6 +118,11 @@ export const BROWSER_CORE_METHODS: RpcMethod[] = [ ? runtime.browserTabCreate(params, { pairedDeviceId, clientKind }) : runtime.browserTabCreate(params, { clientKind }) }), + defineMethod({ + name: 'browser.openUrl', + params: BrowserOpenUrlParams, + handler: async (params, { runtime }) => runtime.browserOpenUrlOnClient(params) + }), defineMethod({ name: 'browser.tabSetProfile', params: TabSetProfile, diff --git a/src/main/runtime/rpc/methods/browser-tab-create-schema.ts b/src/main/runtime/rpc/methods/browser-tab-create-schema.ts index 1adea66eed7..799111b2a6b 100644 --- a/src/main/runtime/rpc/methods/browser-tab-create-schema.ts +++ b/src/main/runtime/rpc/methods/browser-tab-create-schema.ts @@ -16,3 +16,8 @@ export const BrowserTabCreateParams = z.object({ targetGroupId: OptionalString, placement: BrowserPageCreationPlacement.optional() }) + +export const BrowserOpenUrlParams = z.object({ + url: z.url(), + worktree: z.string().min(1) +}) diff --git a/src/main/runtime/rpc/methods/browser.test.ts b/src/main/runtime/rpc/methods/browser.test.ts index 22f9e8ca036..cb104565c3d 100644 --- a/src/main/runtime/rpc/methods/browser.test.ts +++ b/src/main/runtime/rpc/methods/browser.test.ts @@ -48,6 +48,26 @@ describe('browser RPC methods', () => { }) }) + it('routes host browser-open requests through the dedicated client opener', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + browserOpenUrlOnClient: vi.fn().mockResolvedValue({ browserPageId: 'page-local' }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: BROWSER_CORE_METHODS }) + + await dispatcher.dispatch( + makeRequest('browser.openUrl', { + url: 'https://example.com/login', + worktree: 'id:wt-1' + }) + ) + + expect(runtime.browserOpenUrlOnClient).toHaveBeenCalledWith({ + url: 'https://example.com/login', + worktree: 'id:wt-1' + }) + }) + it('validates profile user-agent modes', () => { expect( ProfileCreate.safeParse({ label: 'Google', scope: 'isolated', userAgentMode: 'native' }) From 3d341c132ffd79352b7eb62390b0ee6d1828058b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:59:55 -0700 Subject: [PATCH 8/8] fix(mobile): dismiss the keyboard after sending to an agent (#17059) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mobile): dismiss the keyboard after sending to an agent Sending a message left the software keyboard up, covering the reply the user was waiting on. Drop it once the send is accepted, on all three send paths: the terminal live input, the buffered command input, and the chat composer. Gated on the tab being an agent session. A plain shell keeps the keyboard so back-to-back commands stay typeable, a rejected send keeps it so the handed-back draft stays editable, and the accessory shortcut row is untouched because dismissing would pull away the row being tapped. * fix(mobile): gate keyboard dismissal on accepted sends * fix(mobile): fence keyboard dismissal completions * fix(mobile): fence stale send completions * test(mobile): update terminal guard expectations * fix(mobile): restore rejected buffered drafts by origin * fix(mobile): preserve intentional buffered draft clears * fix(mobile): harden send dismissal authority * test(mobile): preserve Strict Mode send dismissal * fix(mobile): preserve drafts across terminal remints * fix(mobile): preserve draft ownership through terminal races * fix(mobile): harden draft recovery and send freshness * fix(mobile): fence route reuse and native draft clears * fix(mobile): preserve native draft edits before clear * test(mobile): pin the terminal-list sweep that bounds buffered drafts `bufferedTerminalDraftState.pruneDrafts(retainedHandles)` is the only bound on two structures that live as long as the session screen — the buffered-draft record and the pending-restoration map — and nothing failed when it was deleted or when it was pointed at the raw `terminal.list` handles instead of the retained set. Both mutations reddened 0 of 3,949 mobile tests. Adds the wiring pin (both mutations now redden it) plus two behavioural tests showing why the argument matters: `terminal.list` omits a chat-covered handle while the desktop graph reloads, so the raw list drops a draft the user is still holding while the retained set keeps it. --------- Co-authored-by: Merge Sim Co-authored-by: Merge Sim --- .../app/h/[hostId]/session/[worktreeId].tsx | 537 +++--------------- .../src/session/MobileDiffCommentLineRow.tsx | 147 +++++ .../session/MobileNativeChatComposer.test.ts | 275 ++++++++- .../src/session/MobileNativeChatComposer.tsx | 43 +- .../session/MobileNativeChatOverlay.test.ts | 2 + .../src/session/MobileNativeChatOverlay.tsx | 9 + .../src/session/MobileNativeChatView.test.ts | 2 + mobile/src/session/MobileNativeChatView.tsx | 11 + .../src/session/MobileSessionFileReader.tsx | 302 ++++++++++ ...ent-send-keyboard-dismissal-wiring.test.ts | 175 ++++++ .../agent-send-keyboard-dismissal.test.ts | 131 +++++ .../session/agent-send-keyboard-dismissal.ts | 39 ++ .../mobile-native-chat-controller-contract.ts | 1 + ...bile-native-chat-draft-edit-generations.ts | 19 + .../mobile-native-chat-pending-echo.test.ts | 1 + .../mobile-native-chat-pending-echo.ts | 1 + .../mobile-session-last-tab-close.test.ts | 4 + .../session/mobile-terminal-tab-agent.test.ts | 12 +- .../src/session/mobile-terminal-tab-agent.ts | 23 +- .../use-agent-send-keyboard-dismissal.ts | 27 + .../use-mobile-native-chat-controller.test.ts | 1 + .../use-mobile-native-chat-controller.ts | 2 + .../use-mobile-native-chat-drafts.test.ts | 51 +- .../session/use-mobile-native-chat-drafts.ts | 19 +- ...-mobile-send-completion-generation.test.ts | 78 +++ .../use-mobile-send-completion-generation.ts | 28 + ...uffered-terminal-draft-restoration.test.ts | 107 ++++ .../buffered-terminal-draft-restoration.ts | 115 ++++ .../terminal-ios-dictation-write-back.test.ts | 4 +- .../terminal-live-input-affordance.test.ts | 9 +- .../use-buffered-terminal-drafts.test.tsx | 302 ++++++++++ .../terminal/use-buffered-terminal-drafts.ts | 196 +++++++ ...rminal-live-accessory-input-commit.test.ts | 1 + ...se-terminal-live-accessory-input-commit.ts | 4 + .../use-terminal-live-input-commit.test.ts | 46 +- .../use-terminal-live-input-commit.ts | 43 +- 36 files changed, 2278 insertions(+), 489 deletions(-) create mode 100644 mobile/src/session/MobileDiffCommentLineRow.tsx create mode 100644 mobile/src/session/MobileSessionFileReader.tsx create mode 100644 mobile/src/session/agent-send-keyboard-dismissal-wiring.test.ts create mode 100644 mobile/src/session/agent-send-keyboard-dismissal.test.ts create mode 100644 mobile/src/session/agent-send-keyboard-dismissal.ts create mode 100644 mobile/src/session/mobile-native-chat-draft-edit-generations.ts create mode 100644 mobile/src/session/use-agent-send-keyboard-dismissal.ts create mode 100644 mobile/src/session/use-mobile-send-completion-generation.test.ts create mode 100644 mobile/src/session/use-mobile-send-completion-generation.ts create mode 100644 mobile/src/terminal/buffered-terminal-draft-restoration.test.ts create mode 100644 mobile/src/terminal/buffered-terminal-draft-restoration.ts create mode 100644 mobile/src/terminal/use-buffered-terminal-drafts.test.tsx create mode 100644 mobile/src/terminal/use-buffered-terminal-drafts.ts diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index a34c8643ab0..10b413d0528 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -5,8 +5,6 @@ import { Linking, type AppStateStatus, BackHandler, - FlatList, - Image, View, Text, ScrollView, @@ -16,8 +14,7 @@ import { Platform, ActivityIndicator, type KeyboardEvent, - type LayoutChangeEvent, - type ListRenderItem + type LayoutChangeEvent } from 'react-native' import * as Clipboard from 'expo-clipboard' import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context' @@ -37,12 +34,10 @@ import { GitBranch, Globe, Keyboard as KeyboardIcon, - MessageSquare, Monitor, MoreHorizontal, Plus, RefreshCw, - Send, Smartphone, SquareTerminal, X @@ -82,6 +77,8 @@ import { } from '../../../../src/session/mobile-bulk-close-sheet-actions' import { useMobilePrBranchContext } from '../../../../src/session/use-mobile-pr-branch-context' import { isFloatingWorkspaceWorktreeId } from '../../../../src/session/floating-workspace' +import { useAgentSendKeyboardDismissal } from '../../../../src/session/use-agent-send-keyboard-dismissal' +import { useMobileSendCompletionGeneration } from '../../../../src/session/use-mobile-send-completion-generation' import { SessionDockColumn } from '../../../../src/session/SessionDockColumn' import { MobileSessionHeaderIconButton } from '../../../../src/session/MobileSessionHeaderIconButton' import { MobileSessionHeaderMoreActionsSheet } from '../../../../src/session/MobileSessionHeaderMoreActionsSheet' @@ -130,6 +127,7 @@ import { useTerminalLiveInputFocus } from '../../../../src/terminal/use-terminal import { dismissTerminalKeyboard } from '../../../../src/terminal/terminal-keyboard-dismiss' import type { TerminalLiveInputSender } from '../../../../src/terminal/terminal-live-input-sender' import { isTerminalSendRpcAccepted } from '../../../../src/terminal/terminal-send-rpc-response' +import { useBufferedTerminalDrafts } from '../../../../src/terminal/use-buffered-terminal-drafts' import { sendMobileTerminalQueryReply } from '../../../../src/terminal/mobile-terminal-query-reply' import { TERMINAL_QUERY_REPLY_INPUT_RUNTIME_CAPABILITY } from '../../../../../src/shared/protocol-version' import { useTerminalLiveInputCommit } from '../../../../src/terminal/use-terminal-live-input-commit' @@ -160,7 +158,6 @@ import { MobileAgentIcon } from '../../../../src/components/MobileAgentIcon' import { TextInputModal } from '../../../../src/components/TextInputModal' import { ConfirmModal } from '../../../../src/components/ConfirmModal' import { MobileMarkdownReader } from '../../../../src/session/MobileMarkdownReader' -import { MobileSyntaxSegments } from '../../../../src/components/MobileSyntaxSegments' import { CustomKeyModal, loadCustomKeys, @@ -174,12 +171,6 @@ import { removeDeliveredMobileDiffComments, removeMobileDiffComments } from '../../../../src/session/mobile-diff-comments' -import { - buildPlainMobileDiffSyntaxLines, - highlightMobileCode, - highlightMobileDiffLines, - resolveMobileSyntaxLanguage -} from '../../../../src/session/mobile-file-syntax' import { getTerminalRecordsFromSessionTabs, hasConnectedTerminalAbsentFromSessionTabs, @@ -223,7 +214,6 @@ import { buildMarkdownDiskFallbackDoc, shouldReadMarkdownFromDiskAfterReadTabFailure } from '../../../../src/session/mobile-markdown-disk-fallback' -import { MobileHtmlPreview } from '../../../../src/components/MobileHtmlPreview' import { MobileDictationSetupSheet } from '../../../../src/components/MobileDictationSetupSheet' import { fetchDictationSetup, @@ -290,21 +280,18 @@ import { import { colors } from '../../../../src/theme/mobile-theme' import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton' import { styles } from '../../../../src/session/mobile-session-styles' +import { MobileSessionFileReader } from '../../../../src/session/MobileSessionFileReader' import type { DiffComment } from '../../../../../src/shared/diff-comment-types' import type { TerminalQuickCommand } from '../../../../../src/shared/terminal-quick-command-types' import type { - DiffCommentActions, DiffNotesDelivery, - DiffSyntaxState, DirtyMarkdownDraft, FileDocState, - FileSyntaxState, MarkdownDocState, MobileDisplayMode, MobileNewTabAgentLoadState, MobileSessionTab, MobileSessionTabType, - RenderableDiffLine, RuntimeRepoSummary, SessionTabsResult, Terminal, @@ -324,416 +311,6 @@ function getClosedTabTombstoneExpiry(): number { return Date.now() + CLOSED_TAB_TOMBSTONE_TTL_MS } -function DiffLineRow({ - line, - title, - index, - comments, - activeCommentLine, - commentDraft, - commentsBusy, - onStartComment, - onCancelComment, - onDraftChange, - onSubmitComment, - onDeleteComment -}: { - line: RenderableDiffLine - title: string - index: number - comments: DiffComment[] - activeCommentLine: number | null - commentDraft: string - commentsBusy: boolean - onStartComment: (lineNumber: number) => void - onCancelComment: () => void - onDraftChange: (value: string) => void - onSubmitComment: (lineNumber: number) => void - onDeleteComment: (commentId: string) => void -}) { - const commentLine = line.newLineNumber - const isCommenting = commentLine !== undefined && activeCommentLine === commentLine - const canComment = commentLine !== undefined - // Why: review notes anchor to the modified side, so show that line number in the single mobile gutter. - const gutterLineNumber = line.newLineNumber ?? line.oldLineNumber ?? '' - return ( - - - {gutterLineNumber} - - - {line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '} - - - - {canComment ? ( - [ - styles.diffCommentAddButton, - pressed && styles.diffCommentAddButtonPressed, - commentsBusy && styles.diffCommentButtonDisabled - ]} - disabled={commentsBusy} - onPress={() => { - if (commentLine !== undefined) { - onStartComment(commentLine) - } - }} - accessibilityLabel={`Add note on line ${commentLine}`} - > - - - ) : null} - - {comments.length > 0 ? ( - - {comments.map((comment) => ( - - - - Line {comment.lineNumber} - onDeleteComment(comment.id)} - accessibilityLabel={`Delete note on line ${comment.lineNumber}`} - > - - - - {comment.body} - - ))} - - ) : null} - {isCommenting ? ( - - - - - Cancel - - { - if (commentLine !== undefined) { - onSubmitComment(commentLine) - } - }} - > - Save note - - - - ) : null} - - ) -} - -function FileReader({ - doc, - title, - relativePath, - language, - diffCommentActions -}: { - doc: FileDocState | undefined - title: string - relativePath: string - language?: string - diffCommentActions?: DiffCommentActions -}) { - const syntaxLanguage = useMemo( - () => resolveMobileSyntaxLanguage(relativePath || title, language), - [language, relativePath, title] - ) - const [fileSyntax, setFileSyntax] = useState(null) - const [diffSyntax, setDiffSyntax] = useState(null) - const [activeCommentLine, setActiveCommentLine] = useState(null) - const [commentDraft, setCommentDraft] = useState('') - const plainDiffLines = useMemo( - () => - doc?.status === 'ready' && doc.kind === 'diff' - ? buildPlainMobileDiffSyntaxLines(doc.lines) - : [], - [doc] - ) - const diffCommentsForFile = useMemo( - () => - diffCommentActions?.comments.filter( - (comment) => comment.filePath === relativePath && comment.source !== 'markdown' - ) ?? [], - [diffCommentActions?.comments, relativePath] - ) - const diffCommentsByLine = useMemo(() => { - const map = new Map() - for (const comment of diffCommentsForFile) { - const list = map.get(comment.lineNumber) ?? [] - list.push(comment) - map.set(comment.lineNumber, list) - } - for (const list of map.values()) { - list.sort((a, b) => a.createdAt - b.createdAt) - } - return map - }, [diffCommentsForFile]) - - const startComment = useCallback((lineNumber: number) => { - setActiveCommentLine(lineNumber) - setCommentDraft('') - }, []) - - const cancelComment = useCallback(() => { - setActiveCommentLine(null) - setCommentDraft('') - }, []) - - const submitComment = useCallback( - (lineNumber: number) => { - if (!diffCommentActions) { - return - } - void diffCommentActions.onAdd(relativePath, lineNumber, commentDraft).then((added) => { - if (added) { - setActiveCommentLine(null) - setCommentDraft('') - } - }) - }, - [commentDraft, diffCommentActions, relativePath] - ) - - const renderDiffLine: ListRenderItem = useCallback( - ({ item, index }) => ( - { - if (diffCommentActions) { - void diffCommentActions.onDelete(commentId) - } - }} - /> - ), - [ - activeCommentLine, - cancelComment, - commentDraft, - diffCommentActions, - diffCommentsByLine, - startComment, - submitComment, - title - ] - ) - - useEffect(() => { - if (doc?.status !== 'ready') { - return undefined - } - - // Why: defer highlighting one tick so large files show as plain text immediately before colors are applied. - const timer = setTimeout(() => { - // file + html share the syntax-segment source view (html's "Source" toggle). - if (doc.kind === 'file' || doc.kind === 'html') { - setFileSyntax({ - doc, - language: syntaxLanguage, - segments: highlightMobileCode(doc.content, syntaxLanguage).segments - }) - return - } - if (doc.kind === 'diff') { - setDiffSyntax({ - doc, - language: syntaxLanguage, - lines: highlightMobileDiffLines(doc.lines, syntaxLanguage) - }) - } - // image: no syntax highlighting. - }, 0) - - return () => clearTimeout(timer) - }, [doc, syntaxLanguage]) - - if (!doc || doc.status === 'loading') { - return ( - - - - ) - } - if (doc.status === 'error') { - return ( - - {doc.message} - - ) - } - - if (doc.kind === 'diff') { - const activeDiffSyntax = - diffSyntax?.doc === doc && diffSyntax.language === syntaxLanguage ? diffSyntax.lines : null - const commentCount = diffCommentActions?.comments.length ?? 0 - const unsentCommentCount = - diffCommentActions?.comments.filter((comment) => !comment.sentAt).length ?? 0 - const commentsBusy = diffCommentActions?.busy === true - const canCopyNotes = commentCount > 0 && !commentsBusy - const canSendNotes = unsentCommentCount > 0 && !commentsBusy - return ( - - {diffCommentActions ? ( - - - - - {commentCount === 0 - ? 'No review notes' - : `${commentCount} review ${commentCount === 1 ? 'note' : 'notes'}`} - - - - void diffCommentActions.onCopyAll()} - accessibilityLabel="Copy review notes" - > - - Copy - - - - Send - - - - ) : null} - - `${index}:${line.kind}:${line.oldLineNumber ?? ''}:${line.newLineNumber ?? ''}` - } - renderItem={renderDiffLine} - initialNumToRender={32} - maxToRenderPerBatch={48} - windowSize={7} - removeClippedSubviews={Platform.OS !== 'web'} - keyboardShouldPersistTaps="handled" - /> - - ) - } - - if (doc.kind === 'image') { - return ( - - - - - - ) - } - - const renderSourceText = (content: string) => ( - - - - - - - - ) - - if (doc.kind === 'html') { - return ( - - renderSourceText(doc.content)} /> - - ) - } - - return renderSourceText(doc.content) -} - export default function SessionScreen() { const { hostId, @@ -815,7 +392,6 @@ export default function SessionScreen() { // Why: after an optimistic close, suppress the tab (with expiry) until the publisher confirms, so an in-flight snapshot can't flash it back. const closedTabTombstonesRef = useRef>(new Map()) const [terminalsLoaded, setTerminalsLoaded] = useWorktreeSessionTabsLoaded(worktreeId) - const [input, setInput] = useState('') // Why: baseline terminal zoom reloaded on focus so a Settings → Terminal change applies in place (panes stay mounted). const [terminalTextScale, setTerminalTextScale] = useState(1) // Why: terminal command-bar autocomplete opt-in, reloaded on focus so a Settings → Terminal toggle takes effect on return. @@ -951,6 +527,8 @@ export default function SessionScreen() { // Why: don't subscribe until the WebView fires web-ready — iOS may defer JS in hidden WebViews and init() messages would queue unrendered. const webReadyHandlesRef = useRef>(new Set()) const activeHandleRef = useRef(null) + const bufferedTerminalDraftState = useBufferedTerminalDrafts({ activeHandle, activeHandleRef }) + const reconcileBufferedDraftsRef = useRef(bufferedTerminalDraftState.reconcileTerminalTabs) const activeSessionTabTypeRef = useRef(null) const pendingActiveSessionTabIdRef = useRef(null) // Why: survive transient snapshot gaps so the device's own tab pick can re-bind. @@ -983,6 +561,7 @@ export default function SessionScreen() { const { clearPendingLiveInputCommit, flushPendingLiveInputBeforeExternalSend, + getLiveInputInteractionGeneration: getLiveInteractionGeneration, handleLiveInputAccessoryBytes, handleLiveInputChange, handleLiveInputKeyPress, @@ -1015,12 +594,6 @@ export default function SessionScreen() { liveInputEnabled, timerRef: liveInputFocusTimerRef }) - useFocusEffect( - useCallback(() => { - // Expo retains this route while pushed screens are visible. - return resetLiveInputFocus - }, [resetLiveInputFocus]) - ) const [browserScreencastSupported, setBrowserScreencastSupported] = useState(null) // Why: hosts without aiVault.v1 reject listSessions, so hide the header entry instead of a dead-end "update this host" panel. const [agentSessionHistorySupported, setAgentSessionHistorySupported] = useState( @@ -1133,6 +706,11 @@ export default function SessionScreen() { }) const { toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController nativeChatSendError.bannerMountedRef.current = showNativeChat + const routeKey = nativeChatScopeKey ?? `${hostId}\0${worktreeId}` + const getSendCompletionGeneration = useMobileSendCompletionGeneration({ + onBlur: resetLiveInputFocus, + surfaceKey: JSON.stringify([routeKey, activeHandle, showNativeChat, liveInputEnabled]) + }) const dictation = useMobileDictation({ client, @@ -1170,7 +748,7 @@ export default function SessionScreen() { })() return } - setInput((current) => appendBufferedDictation(current, route.text)) + bufferedTerminalDraftState.setInput((current) => appendBufferedDictation(current, route.text)) showToast('Dictation inserted') }, onError: (err) => { @@ -1664,7 +1242,9 @@ export default function SessionScreen() { // Sweep against the retained set, not the raw list: a chat-covered handle // keeps its subscription across a graph reload, so erasing its live-input // preference on the same refresh is the erasure this guard exists to stop. - pruneTerminalHandlesFromLiveInput(resolveRetainedTerminalHandles(pruneContext)) + const retainedHandles = resolveRetainedTerminalHandles(pruneContext) + pruneTerminalHandlesFromLiveInput(retainedHandles) + bufferedTerminalDraftState.pruneDrafts(retainedHandles) defaultTerminalHandlesToLiveInput([...liveHandles]) const shouldPrune = createTerminalPrunePredicate(pruneContext) for (const handle of Array.from(terminalUnsubsRef.current.keys())) { @@ -1721,6 +1301,7 @@ export default function SessionScreen() { clearTerminalLiveInputDefault, defaultTerminalHandlesToLiveInput, nativeChatStream, + bufferedTerminalDraftState.pruneDrafts, pruneTerminalHandlesFromLiveInput, subscribeToTerminal, terminalInventoryRequest, @@ -1761,6 +1342,9 @@ export default function SessionScreen() { if (orphanedDraftTabs.length > 0) { nextTabs = [...orphanedDraftTabs, ...nextTabs] } + reconcileBufferedDraftsRef.current(currentSessionTabs, nextTabs, { + retainMissingSurfaces: result.tabs.length === 0 + }) sessionTabsRef.current = nextTabs initialSessionAutoCreateRef.current.sawSessionTabs ||= nextTabs.length > 0 // Why: subscribe snapshots often repeat identical payloads; skip re-set to avoid a subscription teardown/replay loop. @@ -2663,6 +2247,7 @@ export default function SessionScreen() { terminalDiagnosticsRef.current.resetRoute() appliedSnapshotMarkerRef.current = { epoch: null, version: -1 } closedTabTombstonesRef.current.clear() + bufferedTerminalDraftState.resetDrafts() for (const queued of terminalGestureInputQueuesRef.current.values()) { if (queued.timer) { clearTimeout(queued.timer) @@ -2682,14 +2267,17 @@ export default function SessionScreen() { return () => { sessionTabActionSheetRequestSeqRef.current += 1 sessionTabActionSheetKeyboardHideSubRef.current?.remove() + bufferedTerminalDraftState.clearPendingRestorations() clearPendingLiveInputCommit() clearDelayedActionTimers() } }, [ clearDelayedActionTimers, + bufferedTerminalDraftState.clearPendingRestorations, clearPendingLiveInputCommit, clearTerminalCache, hostId, + bufferedTerminalDraftState.resetDrafts, worktreeId ]) @@ -3020,6 +2608,20 @@ export default function SessionScreen() { } }, [activeSessionTab, fileDocs, readFileTab]) + const dismissSoftwareKeyboard = useCallback(() => { + dismissTerminalKeyboard({ + clearPendingLiveInputFocus: () => clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef), + commandInput: commandInputRef.current, + dismissKeyboard: () => Keyboard.dismiss(), + liveInput: liveInputRef.current + }) + }, []) + + const dismissKeyboardAfterAgentSend = useAgentSendKeyboardDismissal( + dismissSoftwareKeyboard, + getSendCompletionGeneration + ) + async function handleSend() { // Why: the return key still submits while offline; hold the composed text instead of firing a doomed RPC (#6713). if (!client || !activeHandle || sendingRef.current || !canSend) { @@ -3027,12 +2629,23 @@ export default function SessionScreen() { } sendingRef.current = true - const text = normalizeTerminalTextInput(input) - setInput('') + const draft = bufferedTerminalDraftState.input + const text = normalizeTerminalTextInput(draft) + const bufferedDraftSend = bufferedTerminalDraftState.beginBufferedTerminalDraftSend( + activeHandle, + draft + ) + const sendOrigin = { + handle: activeHandle, + tab: activeSessionTab, + generation: getSendCompletionGeneration() + } + const restoreRejectedDraft = () => + bufferedTerminalDraftState.restoreRejectedDraft(bufferedDraftSend) try { // Why: fail now and restore the text — a send parked across a reconnect would execute long after the tap. - await client.sendRequest( + const response = await client.sendRequest( 'terminal.send', buildTerminalSendParams({ terminal: activeHandle, @@ -3042,9 +2655,17 @@ export default function SessionScreen() { }), TERMINAL_INPUT_SEND_OPTIONS ) + const accepted = isTerminalSendRpcAccepted(response) + if (!accepted) { + restoreRejectedDraft() + } + const draftUnchanged = + accepted && bufferedTerminalDraftState.settleBufferedTerminalDraftSend(bufferedDraftSend) + dismissKeyboardAfterAgentSend(sendOrigin, accepted && draftUnchanged) } catch { - setInput(text) + restoreRejectedDraft() } finally { + bufferedTerminalDraftState.settleBufferedTerminalDraftSend(bufferedDraftSend) sendingRef.current = false } } @@ -3180,15 +2801,6 @@ export default function SessionScreen() { ] ) - const dismissSoftwareKeyboard = useCallback(() => { - dismissTerminalKeyboard({ - clearPendingLiveInputFocus: () => clearTerminalLiveInputFocusTimer(liveInputFocusTimerRef), - commandInput: commandInputRef.current, - dismissKeyboard: () => Keyboard.dismiss(), - liveInput: liveInputRef.current - }) - }, []) - // Tap a terminal or chat file path → resolve on host, open as file tab/preview. const { handleFileTap, handleNativeChatFileTap } = useMobileFileTapHandlers({ client, @@ -4123,6 +3735,7 @@ export default function SessionScreen() { }) if (response.ok) { const remainingTabs = sessionTabsRef.current.filter((candidate) => candidate.id !== tab.id) + reconcileBufferedDraftsRef.current(sessionTabsRef.current, remainingTabs) if (tab.type === 'browser' && tab.browserPageId === pendingBrowserFocusPageIdRef.current) { pendingBrowserFocusPageIdRef.current = null } @@ -4649,7 +4262,7 @@ export default function SessionScreen() { ) : activeFileTab ? ( - {toastMessage && ( @@ -5023,7 +4638,20 @@ export default function SessionScreen() { // marked-text report that says whether this text is still preedit. onChange={handleLiveInputChange} onKeyPress={handleLiveInputKeyPress} - onSubmitEditing={handleLiveInputSubmit} + onSubmitEditing={() => { + const submit = handleLiveInputSubmit() + const sendOrigin = { + tab: activeSessionTab, + generation: getSendCompletionGeneration(), + interaction: getLiveInteractionGeneration() + } + void submit.then((accepted) => + dismissKeyboardAfterAgentSend( + sendOrigin, + accepted && sendOrigin.interaction === getLiveInteractionGeneration() + ) + ) + }} placeholder="" showSoftInputOnFocus autoCapitalize="none" @@ -5052,9 +4680,9 @@ export default function SessionScreen() { : 'cmd-input' } style={styles.textInput} - value={input} + value={bufferedTerminalDraftState.input} // Why: iOS kills active dictation/IME if JS writes a value differing from native text; store raw, normalize at send. - onChangeText={setInput} + onChangeText={bufferedTerminalDraftState.setInput} placeholder="Type a command…" placeholderTextColor={colors.textMuted} autoCapitalize="none" @@ -5068,6 +4696,7 @@ export default function SessionScreen() { autocompleteEnabled )} returnKeyType="send" + blurOnSubmit={false} // Why: composing is local — an outage must not lock the field or discard typed text (#6713). editable={canCompose} onSubmitEditing={() => void handleSend()} diff --git a/mobile/src/session/MobileDiffCommentLineRow.tsx b/mobile/src/session/MobileDiffCommentLineRow.tsx new file mode 100644 index 00000000000..5a0df303e90 --- /dev/null +++ b/mobile/src/session/MobileDiffCommentLineRow.tsx @@ -0,0 +1,147 @@ +import { Pressable, Text, TextInput, View } from 'react-native' +import { MessageSquare, Plus, X } from 'lucide-react-native' +import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments' +import { colors } from '../theme/mobile-theme' +import { styles } from './mobile-session-styles' +import type { DiffComment } from '../../../src/shared/diff-comment-types' +import type { RenderableDiffLine } from './mobile-session-route-types' + +export function MobileDiffCommentLineRow({ + line, + title, + index, + comments, + activeCommentLine, + commentDraft, + commentsBusy, + onStartComment, + onCancelComment, + onDraftChange, + onSubmitComment, + onDeleteComment +}: { + line: RenderableDiffLine + title: string + index: number + comments: DiffComment[] + activeCommentLine: number | null + commentDraft: string + commentsBusy: boolean + onStartComment: (lineNumber: number) => void + onCancelComment: () => void + onDraftChange: (value: string) => void + onSubmitComment: (lineNumber: number) => void + onDeleteComment: (commentId: string) => void +}) { + const commentLine = line.newLineNumber + const isCommenting = commentLine !== undefined && activeCommentLine === commentLine + const canComment = commentLine !== undefined + // Why: review notes anchor to the modified side, so show that line number in the single mobile gutter. + const gutterLineNumber = line.newLineNumber ?? line.oldLineNumber ?? '' + return ( + + + {gutterLineNumber} + + + {line.kind === 'add' ? '+ ' : line.kind === 'delete' ? '- ' : ' '} + + + + {canComment ? ( + [ + styles.diffCommentAddButton, + pressed && styles.diffCommentAddButtonPressed, + commentsBusy && styles.diffCommentButtonDisabled + ]} + disabled={commentsBusy} + onPress={() => { + if (commentLine !== undefined) { + onStartComment(commentLine) + } + }} + accessibilityLabel={`Add note on line ${commentLine}`} + > + + + ) : null} + + {comments.length > 0 ? ( + + {comments.map((comment) => ( + + + + Line {comment.lineNumber} + onDeleteComment(comment.id)} + accessibilityLabel={`Delete note on line ${comment.lineNumber}`} + > + + + + {comment.body} + + ))} + + ) : null} + {isCommenting ? ( + + + + + Cancel + + { + if (commentLine !== undefined) { + onSubmitComment(commentLine) + } + }} + > + Save note + + + + ) : null} + + ) +} diff --git a/mobile/src/session/MobileNativeChatComposer.test.ts b/mobile/src/session/MobileNativeChatComposer.test.ts index 3c8afe2cefd..145ea48600c 100644 --- a/mobile/src/session/MobileNativeChatComposer.test.ts +++ b/mobile/src/session/MobileNativeChatComposer.test.ts @@ -1,8 +1,20 @@ -import { createElement } from 'react' +import { createElement, StrictMode, type ComponentProps } from 'react' import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { Keyboard } from 'react-native' import { afterEach, describe, expect, it, vi } from 'vitest' import { radii, spacing } from '../theme/mobile-theme' -import { MobileNativeChatComposer } from './MobileNativeChatComposer' +import { MobileNativeChatComposer as NativeChatComposer } from './MobileNativeChatComposer' + +const getNoComposerEditGeneration = () => 0 + +function MobileNativeChatComposer({ + getComposerEditGeneration = getNoComposerEditGeneration, + ...props +}: Omit, 'getComposerEditGeneration'> & { + getComposerEditGeneration?: () => number +}): React.JSX.Element { + return createElement(NativeChatComposer, { ...props, getComposerEditGeneration }) +} vi.mock('react-native', async () => { const React = await import('react') @@ -45,6 +57,7 @@ vi.mock('../components/BottomDrawer', async () => { describe('MobileNativeChatComposer', () => { let renderer: ReactTestRenderer | null = null + const getCurrentSendCompletionGeneration = () => 0 afterEach(() => { act(() => renderer?.unmount()) @@ -53,15 +66,24 @@ describe('MobileNativeChatComposer', () => { async function render( onSend: (text: string) => Promise, - onChangeText: () => void, - isAttaching = false + onChangeText: (text: string) => void, + isAttaching = false, + sendSurfaceId = 'tab-a', + getSendCompletionGeneration = () => 0 ) { + let composerEditGeneration = 0 await act(async () => { renderer = create( createElement(MobileNativeChatComposer, { value: ' hello ', - onChangeText, + onChangeText: (text) => { + composerEditGeneration += 1 + onChangeText(text) + }, onSend, + sendSurfaceId, + getSendCompletionGeneration, + getComposerEditGeneration: () => composerEditGeneration, isAttaching }) ) @@ -117,7 +139,9 @@ describe('MobileNativeChatComposer', () => { createElement(MobileNativeChatComposer, { value: ' /clear is prose ', onChangeText: vi.fn(), - onSend + onSend, + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: getCurrentSendCompletionGeneration }) ) }) @@ -164,6 +188,8 @@ describe('MobileNativeChatComposer', () => { value: 'run the tests', onChangeText: vi.fn(), onSend, + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: getCurrentSendCompletionGeneration, sessionOptions: { isWorking: false, controller } }) ) @@ -196,6 +222,8 @@ describe('MobileNativeChatComposer', () => { value: 'hello', onChangeText: vi.fn(), onSend, + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: getCurrentSendCompletionGeneration, sessionOptions: { isWorking: false, controller: { @@ -241,6 +269,8 @@ describe('MobileNativeChatComposer', () => { value: 'half-typed', onChangeText: vi.fn(), onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: getCurrentSendCompletionGeneration, disabled: true }) ) @@ -262,6 +292,8 @@ describe('MobileNativeChatComposer', () => { value: '', onChangeText: vi.fn(), onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: getCurrentSendCompletionGeneration, attachments: [ { id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }, { id: 'img-2', path: '/tmp/b.png', previewUri: 'file:///b.png' } @@ -290,6 +322,8 @@ describe('MobileNativeChatComposer', () => { value: '', onChangeText: vi.fn(), onSend, + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: getCurrentSendCompletionGeneration, attachments: [{ id: 'img-1', path: '/tmp/a.png', previewUri: 'file:///a.png' }] }) ) @@ -307,6 +341,8 @@ describe('MobileNativeChatComposer', () => { value: '/c', onChangeText, onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: getCurrentSendCompletionGeneration, agent: 'claude' }) ) @@ -345,6 +381,8 @@ describe('MobileNativeChatComposer', () => { value: '/', onChangeText: vi.fn(), onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: getCurrentSendCompletionGeneration, agent: 'codex' }) ) @@ -378,6 +416,8 @@ describe('MobileNativeChatComposer', () => { value: '', onChangeText: vi.fn(), onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: getCurrentSendCompletionGeneration, onMicPress, dictationMode: 'hold', onMicPressIn, @@ -396,6 +436,8 @@ describe('MobileNativeChatComposer', () => { value: '', onChangeText: vi.fn(), onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: getCurrentSendCompletionGeneration, onMicPress, dictationMode: 'toggle', onMicPressIn, @@ -408,4 +450,225 @@ describe('MobileNativeChatComposer', () => { expect(mic().props.onPressIn).toBeUndefined() expect(mic().props.onPressOut).toBeUndefined() }) + + it('dismisses the keyboard once a send is accepted', async () => { + // Why: the reply the user is now waiting on sits behind the keyboard. + vi.mocked(Keyboard.dismiss).mockClear() + await render(vi.fn().mockResolvedValue(true), vi.fn()) + + await act(async () => sendButton().props.onPress()) + + expect(Keyboard.dismiss).toHaveBeenCalledTimes(1) + }) + + it('dismisses an accepted send after Strict Mode replays mount effects', async () => { + vi.mocked(Keyboard.dismiss).mockClear() + await act(async () => { + renderer = create( + createElement( + StrictMode, + null, + createElement(MobileNativeChatComposer, { + value: 'hello', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: getCurrentSendCompletionGeneration + }) + ) + ) + }) + + await act(async () => sendButton().props.onPress()) + + expect(Keyboard.dismiss).toHaveBeenCalledTimes(1) + }) + + it('keeps the keyboard up when the send is rejected', async () => { + // A rejected send hands the draft back for editing, so yanking the keyboard + // would make the user re-open it to fix and retry. + vi.mocked(Keyboard.dismiss).mockClear() + await render(vi.fn().mockResolvedValue(false), vi.fn()) + + await act(async () => sendButton().props.onPress()) + + expect(Keyboard.dismiss).not.toHaveBeenCalled() + }) + + it('does not dismiss a newly focused composer when an old accepted send settles', async () => { + vi.mocked(Keyboard.dismiss).mockClear() + let resolveSend: ((accepted: boolean) => void) | null = null + const onSend = vi.fn( + () => + new Promise((resolve) => { + resolveSend = resolve + }) + ) + await render(onSend, vi.fn()) + + let pendingSend!: Promise + await act(async () => { + pendingSend = sendButton().props.onPress() + await Promise.resolve() + }) + await act(async () => { + renderer!.update( + createElement(MobileNativeChatComposer, { + value: 'new surface draft', + onChangeText: vi.fn(), + onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-b', + getSendCompletionGeneration: getCurrentSendCompletionGeneration + }) + ) + }) + await act(async () => { + resolveSend?.(true) + await pendingSend + }) + + expect(Keyboard.dismiss).not.toHaveBeenCalled() + }) + + it('does not dismiss after a newer edit on the same surface', async () => { + vi.mocked(Keyboard.dismiss).mockClear() + let resolveSend: ((accepted: boolean) => void) | null = null + const onSend = vi.fn( + () => + new Promise((resolve) => { + resolveSend = resolve + }) + ) + await render(onSend, vi.fn()) + + let pendingSend!: Promise + await act(async () => { + pendingSend = sendButton().props.onPress() + await Promise.resolve() + }) + const input = renderer!.root.find((node) => node.type === 'TextInput') as { + props: { onChangeText: (text: string) => void } + } + await act(async () => input.props.onChangeText('newer draft')) + await act(async () => { + resolveSend?.(true) + await pendingSend + }) + + expect(Keyboard.dismiss).not.toHaveBeenCalled() + }) + + it('does not dismiss after autocomplete mutates the same surface', async () => { + vi.mocked(Keyboard.dismiss).mockClear() + let editGeneration = 0 + let resolveSend: ((accepted: boolean) => void) | null = null + const onSend = vi.fn( + () => + new Promise((resolve) => { + resolveSend = resolve + }) + ) + await act(async () => { + renderer = create( + createElement(MobileNativeChatComposer, { + value: '/c', + onChangeText: () => { + editGeneration += 1 + }, + onSend, + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: getCurrentSendCompletionGeneration, + getComposerEditGeneration: () => editGeneration, + agent: 'claude' + }) + ) + }) + const input = renderer!.root.find((node) => node.type === 'TextInput') as { + props: { onSelectionChange: (event: { nativeEvent: { selection: { end: number } } }) => void } + } + await act(async () => input.props.onSelectionChange({ nativeEvent: { selection: { end: 2 } } })) + + let pendingSend!: Promise + await act(async () => { + pendingSend = sendButton().props.onPress() + await Promise.resolve() + }) + const suggestion = renderer!.root.findAll( + (node) => node.type === 'Pressable' && !node.props.accessibilityLabel + )[0] as { props: { onPress: () => void } } + await act(async () => suggestion.props.onPress()) + await act(async () => { + resolveSend?.(true) + await pendingSend + }) + + expect(Keyboard.dismiss).not.toHaveBeenCalled() + }) + + it('does not dismiss after dictation mutates the controlled draft', async () => { + vi.mocked(Keyboard.dismiss).mockClear() + let editGeneration = 0 + let resolveSend: ((accepted: boolean) => void) | null = null + const onSend = vi.fn( + () => + new Promise((resolve) => { + resolveSend = resolve + }) + ) + const props = { + value: 'hello', + onChangeText: vi.fn(), + onSend, + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: getCurrentSendCompletionGeneration, + getComposerEditGeneration: () => editGeneration + } + await act(async () => { + renderer = create(createElement(MobileNativeChatComposer, props)) + }) + + let pendingSend!: Promise + await act(async () => { + pendingSend = sendButton().props.onPress() + await Promise.resolve() + }) + editGeneration += 1 + await act(async () => { + renderer!.update( + createElement(MobileNativeChatComposer, { ...props, value: 'hello dictated text' }) + ) + }) + await act(async () => { + resolveSend?.(true) + await pendingSend + }) + + expect(Keyboard.dismiss).not.toHaveBeenCalled() + }) + + it('does not dismiss after its retained route loses focus', async () => { + vi.mocked(Keyboard.dismiss).mockClear() + let generation = 0 + let resolveSend: ((accepted: boolean) => void) | null = null + const onSend = vi.fn( + () => + new Promise((resolve) => { + resolveSend = resolve + }) + ) + await render(onSend, vi.fn(), false, 'tab-a', () => generation) + + let pendingSend!: Promise + await act(async () => { + pendingSend = sendButton().props.onPress() + await Promise.resolve() + }) + generation += 1 + await act(async () => { + resolveSend?.(true) + await pendingSend + }) + + expect(Keyboard.dismiss).not.toHaveBeenCalled() + }) }) diff --git a/mobile/src/session/MobileNativeChatComposer.tsx b/mobile/src/session/MobileNativeChatComposer.tsx index ee937369ea7..c75d28d9663 100644 --- a/mobile/src/session/MobileNativeChatComposer.tsx +++ b/mobile/src/session/MobileNativeChatComposer.tsx @@ -1,7 +1,8 @@ -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { ActivityIndicator, Image, + Keyboard, Pressable, ScrollView, StyleSheet, @@ -36,6 +37,12 @@ type Props = { value: string onChangeText: (text: string) => void onSend: (text: string) => Promise + /** Changes whenever the route focuses a different chat composer surface. */ + sendSurfaceId: string + /** Reads the retained route's focus generation without forcing a screen render. */ + getSendCompletionGeneration: () => number + /** Reads user draft mutations owned above this renderable composer. */ + getComposerEditGeneration: () => number /** Active tab's agent — the slash autocomplete serves its command catalog. */ agent?: string | null /** Model/session-option pickers shown in the composer action row; null when @@ -63,6 +70,9 @@ export function MobileNativeChatComposer({ value, onChangeText, onSend, + sendSurfaceId, + getSendCompletionGeneration, + getComposerEditGeneration, agent, sessionOptions, onAttachImage, @@ -87,6 +97,15 @@ export function MobileNativeChatComposer({ null ) const sendingRef = useRef(false) + const mountedRef = useRef(true) + const sendSurfaceIdRef = useRef(sendSurfaceId) + const sendSurfaceGenerationRef = useRef(0) + useLayoutEffect(() => { + if (sendSurfaceIdRef.current !== sendSurfaceId) { + sendSurfaceIdRef.current = sendSurfaceId + sendSurfaceGenerationRef.current += 1 + } + }, [sendSurfaceId]) const [sending, setSending] = useState(false) const trimmed = value.trim() const sessionOptionDispatching = sessionOptions?.controller.pendingId != null @@ -126,6 +145,14 @@ export function MobileNativeChatComposer({ } }, [onNeedFiles, trigger?.kind, trigger?.query]) + useEffect(() => { + mountedRef.current = true + return () => { + mountedRef.current = false + sendSurfaceGenerationRef.current += 1 + } + }, []) + const handleChange = (next: string): void => { onChangeText(next) } @@ -150,12 +177,24 @@ export function MobileNativeChatComposer({ } sendingRef.current = true setSending(true) + const sendSurfaceGeneration = sendSurfaceGenerationRef.current + const sendCompletionGeneration = getSendCompletionGeneration() + const composerEditGeneration = getComposerEditGeneration() try { // Raw, not trimmed: the send seam owns the wire trim, and a rejection has // to hand the user back exactly what they typed (#14819). const accepted = await onSend(value) - if (accepted) { + if ( + accepted && + mountedRef.current && + sendSurfaceGeneration === sendSurfaceGenerationRef.current && + sendCompletionGeneration === getSendCompletionGeneration() && + composerEditGeneration === getComposerEditGeneration() + ) { setCursor(0) + // Why: the turn is now the agent's — the keyboard would cover the reply. + // A rejected send keeps it up so the handed-back draft stays editable. + Keyboard.dismiss() } } finally { sendingRef.current = false diff --git a/mobile/src/session/MobileNativeChatOverlay.test.ts b/mobile/src/session/MobileNativeChatOverlay.test.ts index b43a7c79f3b..471e07e76c1 100644 --- a/mobile/src/session/MobileNativeChatOverlay.test.ts +++ b/mobile/src/session/MobileNativeChatOverlay.test.ts @@ -51,6 +51,8 @@ function overlayElement(tick: Tick): ReturnType { inputLockReason: null, sendErrorMessage: null, onClearSendError: vi.fn(), + sendSurfaceId: tick.identity ?? 'tab-a', + getSendCompletionGeneration: () => 0, keyboardInset: 0 }) } diff --git a/mobile/src/session/MobileNativeChatOverlay.tsx b/mobile/src/session/MobileNativeChatOverlay.tsx index 699f8a1614b..23724300ddf 100644 --- a/mobile/src/session/MobileNativeChatOverlay.tsx +++ b/mobile/src/session/MobileNativeChatOverlay.tsx @@ -24,6 +24,10 @@ type Props = { sendErrorMessage: string | null /** Drops that failure once a later send succeeds. */ onClearSendError: () => void + /** Stable host/worktree/tab identity for accepted-send completion fencing. */ + sendSurfaceId: string + /** Reads the retained route's focus generation for accepted-send fencing. */ + getSendCompletionGeneration: () => number keyboardInset: number } @@ -43,6 +47,8 @@ export function MobileNativeChatOverlay({ inputLockReason, sendErrorMessage, onClearSendError, + sendSurfaceId, + getSendCompletionGeneration, keyboardInset }: Props): React.JSX.Element | null { const session = controller.nativeChatSession @@ -81,6 +87,9 @@ export function MobileNativeChatOverlay({ loadingEarlier={session.loadingEarlier} onLoadEarlier={session.loadEarlier} onSend={images.sendNativeChat} + sendSurfaceId={sendSurfaceId} + getSendCompletionGeneration={getSendCompletionGeneration} + getComposerEditGeneration={controller.getChatComposerEditGeneration} pending={controller.chatPending} imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId} composerText={controller.chatComposerText} diff --git a/mobile/src/session/MobileNativeChatView.test.ts b/mobile/src/session/MobileNativeChatView.test.ts index 1021671b837..9d171e3ac8a 100644 --- a/mobile/src/session/MobileNativeChatView.test.ts +++ b/mobile/src/session/MobileNativeChatView.test.ts @@ -85,6 +85,8 @@ function chatViewElement(overrides: Overrides): ReturnType status: 'ready', streaming: null, onSend: vi.fn().mockResolvedValue(true), + sendSurfaceId: 'tab-a', + getSendCompletionGeneration: () => 0, pending: [], composerText: '', onComposerTextChange: vi.fn(), diff --git a/mobile/src/session/MobileNativeChatView.tsx b/mobile/src/session/MobileNativeChatView.tsx index 2bd0e92b22c..4db4e437b43 100644 --- a/mobile/src/session/MobileNativeChatView.tsx +++ b/mobile/src/session/MobileNativeChatView.tsx @@ -58,6 +58,12 @@ type Props = { loadingEarlier?: boolean onLoadEarlier?: () => void onSend: (text: string) => Promise + /** Route identity used to fence accepted sends that settle after a tab/view switch. */ + sendSurfaceId: string + /** Reads the retained route's focus generation for accepted-send fencing. */ + getSendCompletionGeneration: () => number + /** Reads user draft mutations from the route-owned controller. */ + getComposerEditGeneration: () => number /** Accepted user echoes awaiting transcript replacement, including image previews. */ pending: MobileNativeChatPendingItem[] /** Local photo URIs retained when the authoritative transcript replaces an @@ -126,6 +132,9 @@ export function MobileNativeChatView({ loadingEarlier, onLoadEarlier, onSend, + sendSurfaceId, + getSendCompletionGeneration, + getComposerEditGeneration, pending, imagePreviewsByMessageId, composerText, @@ -431,6 +440,8 @@ export function MobileNativeChatView({ value={composerText} onChangeText={onComposerTextChange} onSend={handleSend} + sendSurfaceId={sendSurfaceId} + {...{ getSendCompletionGeneration, getComposerEditGeneration }} agent={agent} sessionOptions={sessionOptions} onAttachImage={onAttachImage} diff --git a/mobile/src/session/MobileSessionFileReader.tsx b/mobile/src/session/MobileSessionFileReader.tsx new file mode 100644 index 00000000000..8b8907f72b5 --- /dev/null +++ b/mobile/src/session/MobileSessionFileReader.tsx @@ -0,0 +1,302 @@ +import { useState, useCallback, useEffect, useMemo } from 'react' +import { + ActivityIndicator, + FlatList, + Image, + Platform, + Pressable, + ScrollView, + Text, + View, + type ListRenderItem +} from 'react-native' +import { Copy, MessageSquare, Send } from 'lucide-react-native' +import { MobileHtmlPreview } from '../components/MobileHtmlPreview' +import { MobileSyntaxSegments } from '../components/MobileSyntaxSegments' +import { colors } from '../theme/mobile-theme' +import { + buildPlainMobileDiffSyntaxLines, + highlightMobileCode, + highlightMobileDiffLines, + resolveMobileSyntaxLanguage +} from './mobile-file-syntax' +import { styles } from './mobile-session-styles' +import { MobileDiffCommentLineRow } from './MobileDiffCommentLineRow' +import type { DiffComment } from '../../../src/shared/diff-comment-types' +import type { + DiffCommentActions, + DiffSyntaxState, + FileDocState, + FileSyntaxState, + RenderableDiffLine +} from './mobile-session-route-types' + +export function MobileSessionFileReader({ + doc, + title, + relativePath, + language, + diffCommentActions +}: { + doc: FileDocState | undefined + title: string + relativePath: string + language?: string + diffCommentActions?: DiffCommentActions +}) { + const syntaxLanguage = useMemo( + () => resolveMobileSyntaxLanguage(relativePath || title, language), + [language, relativePath, title] + ) + const [fileSyntax, setFileSyntax] = useState(null) + const [diffSyntax, setDiffSyntax] = useState(null) + const [activeCommentLine, setActiveCommentLine] = useState(null) + const [commentDraft, setCommentDraft] = useState('') + const plainDiffLines = useMemo( + () => + doc?.status === 'ready' && doc.kind === 'diff' + ? buildPlainMobileDiffSyntaxLines(doc.lines) + : [], + [doc] + ) + const diffCommentsForFile = useMemo( + () => + diffCommentActions?.comments.filter( + (comment) => comment.filePath === relativePath && comment.source !== 'markdown' + ) ?? [], + [diffCommentActions?.comments, relativePath] + ) + const diffCommentsByLine = useMemo(() => { + const map = new Map() + for (const comment of diffCommentsForFile) { + const list = map.get(comment.lineNumber) ?? [] + list.push(comment) + map.set(comment.lineNumber, list) + } + for (const list of map.values()) { + list.sort((a, b) => a.createdAt - b.createdAt) + } + return map + }, [diffCommentsForFile]) + + const startComment = useCallback((lineNumber: number) => { + setActiveCommentLine(lineNumber) + setCommentDraft('') + }, []) + + const cancelComment = useCallback(() => { + setActiveCommentLine(null) + setCommentDraft('') + }, []) + + const submitComment = useCallback( + (lineNumber: number) => { + if (!diffCommentActions) { + return + } + void diffCommentActions.onAdd(relativePath, lineNumber, commentDraft).then((added) => { + if (added) { + setActiveCommentLine(null) + setCommentDraft('') + } + }) + }, + [commentDraft, diffCommentActions, relativePath] + ) + + const renderDiffLine: ListRenderItem = useCallback( + ({ item, index }) => ( + { + if (diffCommentActions) { + void diffCommentActions.onDelete(commentId) + } + }} + /> + ), + [ + activeCommentLine, + cancelComment, + commentDraft, + diffCommentActions, + diffCommentsByLine, + startComment, + submitComment, + title + ] + ) + + useEffect(() => { + if (doc?.status !== 'ready') { + return undefined + } + + // Why: defer highlighting one tick so large files show as plain text immediately before colors are applied. + const timer = setTimeout(() => { + // file + html share the syntax-segment source view (html's "Source" toggle). + if (doc.kind === 'file' || doc.kind === 'html') { + setFileSyntax({ + doc, + language: syntaxLanguage, + segments: highlightMobileCode(doc.content, syntaxLanguage).segments + }) + return + } + if (doc.kind === 'diff') { + setDiffSyntax({ + doc, + language: syntaxLanguage, + lines: highlightMobileDiffLines(doc.lines, syntaxLanguage) + }) + } + // image: no syntax highlighting. + }, 0) + + return () => clearTimeout(timer) + }, [doc, syntaxLanguage]) + + if (!doc || doc.status === 'loading') { + return ( + + + + ) + } + if (doc.status === 'error') { + return ( + + {doc.message} + + ) + } + + if (doc.kind === 'diff') { + const activeDiffSyntax = + diffSyntax?.doc === doc && diffSyntax.language === syntaxLanguage ? diffSyntax.lines : null + const commentCount = diffCommentActions?.comments.length ?? 0 + const unsentCommentCount = + diffCommentActions?.comments.filter((comment) => !comment.sentAt).length ?? 0 + const commentsBusy = diffCommentActions?.busy === true + const canCopyNotes = commentCount > 0 && !commentsBusy + const canSendNotes = unsentCommentCount > 0 && !commentsBusy + return ( + + {diffCommentActions ? ( + + + + + {commentCount === 0 + ? 'No review notes' + : `${commentCount} review ${commentCount === 1 ? 'note' : 'notes'}`} + + + + void diffCommentActions.onCopyAll()} + accessibilityLabel="Copy review notes" + > + + Copy + + + + Send + + + + ) : null} + + `${index}:${line.kind}:${line.oldLineNumber ?? ''}:${line.newLineNumber ?? ''}` + } + renderItem={renderDiffLine} + initialNumToRender={32} + maxToRenderPerBatch={48} + windowSize={7} + removeClippedSubviews={Platform.OS !== 'web'} + keyboardShouldPersistTaps="handled" + /> + + ) + } + + if (doc.kind === 'image') { + return ( + + + + + + ) + } + + const renderSourceText = (content: string) => ( + + + + + + + + ) + + if (doc.kind === 'html') { + return ( + + renderSourceText(doc.content)} /> + + ) + } + + return renderSourceText(doc.content) +} diff --git a/mobile/src/session/agent-send-keyboard-dismissal-wiring.test.ts b/mobile/src/session/agent-send-keyboard-dismissal-wiring.test.ts new file mode 100644 index 00000000000..3ceb71857b1 --- /dev/null +++ b/mobile/src/session/agent-send-keyboard-dismissal-wiring.test.ts @@ -0,0 +1,175 @@ +import { readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' + +const sessionRouteSource = readFileSync( + new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url), + 'utf8' +) +const bufferedDraftHookSource = readFileSync( + new URL('../terminal/use-buffered-terminal-drafts.ts', import.meta.url), + 'utf8' +) +const keyboardDismissalHookSource = readFileSync( + new URL('./use-agent-send-keyboard-dismissal.ts', import.meta.url), + 'utf8' +) + +function routeSlice(anchorStart: string, anchorEnd: string): string { + const start = sessionRouteSource.indexOf(anchorStart) + expect(start).toBeGreaterThanOrEqual(0) + // Why: a duplicated start anchor would silently slice the wrong region. + expect(sessionRouteSource.indexOf(anchorStart, start + 1)).toBe(-1) + const end = sessionRouteSource.indexOf(anchorEnd, start) + expect(end).toBeGreaterThan(start) + return sessionRouteSource.slice(start, end + anchorEnd.length) +} + +describe('terminal send keyboard dismissal wiring', () => { + it('gates the dismissal on the agent-session predicate', () => { + const slice = routeSlice( + 'const dismissKeyboardAfterAgentSend = useAgentSendKeyboardDismissal(', + 'getSendCompletionGeneration\n )' + ) + expect(slice).toContain('dismissSoftwareKeyboard') + expect(keyboardDismissalHookSource).toContain( + 'shouldDismissKeyboardAfterTerminalSend(origin.tab, accepted)' + ) + expect(keyboardDismissalHookSource).toContain( + 'origin.generation === getSendCompletionGeneration()' + ) + expect(keyboardDismissalHookSource).toContain('dismissSoftwareKeyboard()') + expect(keyboardDismissalHookSource).toContain('return useCallback(') + expect(sessionRouteSource).toContain( + "import { useAgentSendKeyboardDismissal } from '../../../../src/session/use-agent-send-keyboard-dismissal'" + ) + }) + + it('invalidates pending terminal sends when the focused input surface changes', () => { + const slice = routeSlice( + 'const getSendCompletionGeneration = useMobileSendCompletionGeneration({', + '})' + ) + expect(sessionRouteSource).toContain( + 'const routeKey = nativeChatScopeKey ?? `${hostId}\\0${worktreeId}`' + ) + expect(slice).toContain( + 'surfaceKey: JSON.stringify([routeKey, activeHandle, showNativeChat, liveInputEnabled])' + ) + }) + + it('dismisses after the live input submits, which is the only Enter path', () => { + // terminal-live-input.ts deliberately keeps Enter off the key map, so + // onSubmitEditing is the single send seam for the live field. + const slice = routeSlice('ref={liveInputRef}', 'importantForAutofill="no"') + expect(slice).toContain('generation: getSendCompletionGeneration()') + expect(slice).toContain('const submit = handleLiveInputSubmit()') + expect(slice).toContain('interaction: getLiveInteractionGeneration()') + expect(slice).toContain('sendOrigin.interaction === getLiveInteractionGeneration()') + expect(slice).toContain('dismissKeyboardAfterAgentSend(') + // Explicit dismissal replaces RN's blur, which stays off so a shell send + // does not drop focus. + expect(slice).toContain('blurOnSubmit={false}') + }) + + it('dismisses the buffered command send only once the write is accepted', () => { + const slice = routeSlice('async function handleSend() {', 'async function handleAccessoryKey(') + const acceptedAt = slice.indexOf('const accepted = isTerminalSendRpcAccepted(response)') + const restoreAt = slice.indexOf('restoreRejectedDraft()', acceptedAt) + const dismissAt = slice.indexOf('dismissKeyboardAfterAgentSend(') + const responseAt = slice.indexOf('const response = await client.sendRequest(') + const catchAt = slice.indexOf('} catch {') + expect(dismissAt).toBeGreaterThan(0) + expect(responseAt).toBeGreaterThan(0) + expect(acceptedAt).toBeGreaterThan(responseAt) + expect(restoreAt).toBeGreaterThan(acceptedAt) + expect(dismissAt).toBeGreaterThan(responseAt) + expect(slice).toContain( + 'const draftUnchanged =\n accepted && bufferedTerminalDraftState.settleBufferedTerminalDraftSend(bufferedDraftSend)' + ) + expect(slice).toContain('dismissKeyboardAfterAgentSend(sendOrigin, accepted && draftUnchanged)') + expect(catchAt).toBeGreaterThan(0) + // Both resolved rejections and transport failures restore the raw draft. + expect(dismissAt).toBeLessThan(catchAt) + expect(slice.slice(catchAt)).not.toContain('dismissKeyboardAfterAgentSend(') + expect(slice.slice(catchAt)).toContain('restoreRejectedDraft()') + }) + + it('keeps buffered Return focused until accepted-agent dismissal runs', () => { + const slice = routeSlice('ref={commandInputRef}', 'onSubmitEditing={() => void handleSend()}') + expect(slice).toContain('blurOnSubmit={false}') + }) + + it('restores a rejected buffered draft by origin without generation fencing', () => { + const sendSlice = routeSlice( + 'async function handleSend() {', + 'async function handleAccessoryKey(' + ) + const originAt = sendSlice.indexOf('handle: activeHandle') + const requestAt = sendSlice.indexOf('await client.sendRequest(') + const restoreSlice = routeSlice( + 'const bufferedDraftSend = bufferedTerminalDraftState.beginBufferedTerminalDraftSend(', + 'bufferedTerminalDraftState.restoreRejectedDraft(bufferedDraftSend)' + ) + expect(originAt).toBeGreaterThan(0) + expect(originAt).toBeLessThan(requestAt) + expect(restoreSlice).toContain('activeHandle,\n draft') + expect(bufferedDraftHookSource).not.toContain('getSendCompletionGeneration()') + expect(bufferedDraftHookSource).toContain( + 'restoreRejectedBufferedTerminalDraft(current, send.token.handle, send.draft)' + ) + expect(sendSlice.match(/restoreRejectedDraft\(\)/g)).toHaveLength(2) + expect(keyboardDismissalHookSource).toContain( + 'origin.generation === getSendCompletionGeneration()' + ) + }) + + it('keeps buffered draft callbacks scoped to terminal surfaces and prunes ended tabs', () => { + expect(bufferedDraftHookSource).toContain('const handle = activeHandleRef.current') + expect(bufferedDraftHookSource).toContain('invalidateBufferedTerminalDraftRestoration(') + expect(bufferedDraftHookSource).toContain( + 'setDrafts((current) => updateBufferedTerminalDraft(current, handle, value))' + ) + expect(bufferedDraftHookSource).toContain('pruneBufferedTerminalDraftRestorations(') + expect(sessionRouteSource).toContain('useRef(bufferedTerminalDraftState.reconcileTerminalTabs)') + expect(sessionRouteSource).toContain( + 'reconcileBufferedDraftsRef.current(currentSessionTabs, nextTabs, {' + ) + const routeResetSlice = routeSlice( + '// Why: Expo reuses this screen across worktrees;', + 'clearDelayedActionTimers()\n }' + ) + expect(routeResetSlice).toContain('bufferedTerminalDraftState.resetDrafts()') + expect(routeResetSlice).toContain('bufferedTerminalDraftState.clearPendingRestorations()') + }) + + it('bounds buffered drafts on the terminal.list sweep, against the retained set', () => { + // The drafts record and the pending-restoration map both live as long as the + // session screen does; this one call is the only thing that bounds either. + const slice = routeSlice( + 'const liveHandles = new Set(result.terminals.map((terminal) => terminal.handle))', + 'setTerminalKeyboardMetrics((prev) => pruneTerminalKeyboardMetrics(prev, shouldPrune))' + ) + expect(slice).toContain('const retainedHandles = resolveRetainedTerminalHandles(pruneContext)') + expect(slice).toContain('bufferedTerminalDraftState.pruneDrafts(retainedHandles)') + // Not the raw list: terminal.list omits a chat-covered handle while the desktop + // graph reloads, so sweeping drafts against it erases text the user still holds. + expect(slice).not.toContain('pruneDrafts(liveHandles)') + expect(slice.match(/pruneDrafts\(/g)).toHaveLength(1) + expect(bufferedDraftHookSource).toContain( + 'setDrafts((current) => pruneBufferedTerminalDrafts(current, retainedMappedHandles))' + ) + expect(bufferedDraftHookSource).toContain( + 'pruneBufferedTerminalDraftRestorations(pendingRestorationsRef.current, retainedMappedHandles)' + ) + }) + + it('leaves the accessory shortcut keys alone, Enter included', () => { + // Why: the accessory bar sits on top of the keyboard — dismissing would + // pull away the very row the user is tapping. + const slice = routeSlice( + 'async function handleAccessoryKey(', + 'const sendLiveTerminalInput = useCallback(' + ) + expect(slice).not.toContain('dismissKeyboardAfterAgentSend') + }) +}) diff --git a/mobile/src/session/agent-send-keyboard-dismissal.test.ts b/mobile/src/session/agent-send-keyboard-dismissal.test.ts new file mode 100644 index 00000000000..2078a3a7210 --- /dev/null +++ b/mobile/src/session/agent-send-keyboard-dismissal.test.ts @@ -0,0 +1,131 @@ +import { describe, expect, it } from 'vitest' +import { + shouldDismissKeyboardAfterTerminalSend, + type AgentSendKeyboardDismissalTab +} from './agent-send-keyboard-dismissal' + +function terminalTab(overrides: Partial = {}) { + return { type: 'terminal', title: 'Terminal', ...overrides } +} + +describe('shouldDismissKeyboardAfterTerminalSend', () => { + it('dismisses for a live agent session', () => { + expect( + shouldDismissKeyboardAfterTerminalSend( + terminalTab({ agentStatus: { agentType: 'claude' } }), + true + ) + ).toBe(true) + }) + + it('dismisses off launchAgent before the first agent-status update lands', () => { + expect( + shouldDismissKeyboardAfterTerminalSend(terminalTab({ launchAgent: 'codex' }), true) + ).toBe(true) + }) + + it('keeps the keyboard when an agent send is rejected', () => { + expect( + shouldDismissKeyboardAfterTerminalSend( + terminalTab({ agentStatus: { agentType: 'claude' } }), + false + ) + ).toBe(false) + }) + + it('keeps the keyboard for a plain shell so back-to-back commands stay typeable', () => { + expect(shouldDismissKeyboardAfterTerminalSend(terminalTab(), true)).toBe(false) + expect(shouldDismissKeyboardAfterTerminalSend(terminalTab({ agentStatus: null }), true)).toBe( + false + ) + }) + + it('treats a blank agent label as no agent', () => { + // A truthy-empty agentType would otherwise dismiss on every shell Enter. + expect( + shouldDismissKeyboardAfterTerminalSend(terminalTab({ agentStatus: { agentType: '' } }), true) + ).toBe(false) + expect( + shouldDismissKeyboardAfterTerminalSend( + terminalTab({ agentStatus: { agentType: ' ' } }), + true + ) + ).toBe(false) + expect( + shouldDismissKeyboardAfterTerminalSend( + terminalTab({ + agentStatus: { agentType: null }, + launchAgent: null + }), + true + ) + ).toBe(false) + }) + + it('falls through to launchAgent only when live status carries no agent', () => { + expect( + shouldDismissKeyboardAfterTerminalSend( + terminalTab({ + agentStatus: { agentType: null }, + launchAgent: 'claude' + }), + true + ) + ).toBe(true) + }) + + it('never dismisses for non-terminal tabs or a missing tab', () => { + expect( + shouldDismissKeyboardAfterTerminalSend( + { + type: 'markdown', + title: 'README.md', + agentStatus: { agentType: 'claude' } + }, + true + ) + ).toBe(false) + expect(shouldDismissKeyboardAfterTerminalSend(null, true)).toBe(false) + expect(shouldDismissKeyboardAfterTerminalSend(undefined, true)).toBe(false) + }) + + it('does not authorize dismissal from unknown status or a display-only title', () => { + expect( + shouldDismissKeyboardAfterTerminalSend( + terminalTab({ agentStatus: { agentType: 'unknown' } }), + true + ) + ).toBe(false) + expect( + shouldDismissKeyboardAfterTerminalSend(terminalTab({ title: '✦ Gemini CLI' }), true) + ).toBe(false) + }) + + it.each(['zsh', 'bash', 'pwsh'])( + 'keeps the keyboard when identity-only done status is stale under %s', + (title) => { + expect( + shouldDismissKeyboardAfterTerminalSend( + terminalTab({ + title, + agentStatus: { agentType: 'claude', state: 'done' }, + launchAgent: 'claude' + }), + true + ) + ).toBe(false) + } + ) + + it('still dismisses for a completed agent under a non-shell title', () => { + expect( + shouldDismissKeyboardAfterTerminalSend( + terminalTab({ + title: 'Terminal', + agentStatus: { agentType: 'claude', state: 'done' } + }), + true + ) + ).toBe(true) + }) +}) diff --git a/mobile/src/session/agent-send-keyboard-dismissal.ts b/mobile/src/session/agent-send-keyboard-dismissal.ts new file mode 100644 index 00000000000..b07cf17fcf0 --- /dev/null +++ b/mobile/src/session/agent-send-keyboard-dismissal.ts @@ -0,0 +1,39 @@ +import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' +import type { TuiAgent } from '../../../src/shared/tui-agent' +import { isClaudeManagementTitle } from '../../../src/shared/agent-title-core' +import { isShellProcess } from '../../../src/shared/shell-process-detection' +import { resolveMobileTerminalTabOwnedAgentId } from './mobile-terminal-tab-agent' + +/** Minimal session-tab shape needed to tell an agent session from a plain shell. */ +export type AgentSendKeyboardDismissalTab = { + readonly type: string + readonly title: string + readonly agentStatus?: { + readonly agentType?: AgentStatusEntry['agentType'] | null + readonly state?: AgentStatusEntry['state'] + } | null + readonly launchAgent?: TuiAgent | null +} + +/** Whether a send from this tab should drop the software keyboard. + * + * Why: sending to an agent hands the turn over, and the keyboard hides the + * reply the user is now waiting on. A plain shell keeps it — commands come in + * bursts, and re-opening the keyboard between each one costs more than the + * covered rows. `launchAgent` counts before the first agent-status update + * lands, so the very first accepted prompt of a session already dismisses. */ +export function shouldDismissKeyboardAfterTerminalSend( + tab: AgentSendKeyboardDismissalTab | null | undefined, + accepted: boolean +): boolean { + if (!accepted || !tab || tab.type !== 'terminal') { + return false + } + if ( + tab.agentStatus?.state === 'done' && + (isShellProcess(tab.title) || isClaudeManagementTitle(tab.title)) + ) { + return false + } + return resolveMobileTerminalTabOwnedAgentId(tab) !== null +} diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts index 1a086783dbb..2283256da05 100644 --- a/mobile/src/session/mobile-native-chat-controller-contract.ts +++ b/mobile/src/session/mobile-native-chat-controller-contract.ts @@ -21,6 +21,7 @@ export type MobileNativeChatController = { nativeChatAgent: string | null chatComposerText: string setChatComposerText: Dispatch> + getChatComposerEditGeneration: () => number chatPending: MobileNativeChatPendingMessage[] chatImagePreviewsByMessageId: Record nativeChatSession: ReturnType diff --git a/mobile/src/session/mobile-native-chat-draft-edit-generations.ts b/mobile/src/session/mobile-native-chat-draft-edit-generations.ts new file mode 100644 index 00000000000..d5364aeae2c --- /dev/null +++ b/mobile/src/session/mobile-native-chat-draft-edit-generations.ts @@ -0,0 +1,19 @@ +export class MobileNativeChatDraftEditGenerations { + private composerGeneration = 0 + private readonly byDraft = new Map() + + advance(draftKey: string): void { + this.composerGeneration += 1 + this.byDraft.set(draftKey, this.readDraft(draftKey) + 1) + } + + readonly readComposer = (): number => this.composerGeneration + + readDraft(draftKey: string): number { + return this.byDraft.get(draftKey) ?? 0 + } + + isCurrent(draftKey: string, generation: number): boolean { + return this.readDraft(draftKey) === generation + } +} diff --git a/mobile/src/session/mobile-native-chat-pending-echo.test.ts b/mobile/src/session/mobile-native-chat-pending-echo.test.ts index 9ff7281be95..f1f70df1a92 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.test.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.test.ts @@ -20,6 +20,7 @@ function sendOrigin( const normalizedText = normalizeReconcileText(text) return { draftKey: 'host\0worktree\0tab', + draftEditGeneration: 0, pendingKey: 'host\0worktree\0tab\0session', normalizedText, // Use production's counter so the test cannot mirror its normalization drift. diff --git a/mobile/src/session/mobile-native-chat-pending-echo.ts b/mobile/src/session/mobile-native-chat-pending-echo.ts index 5ded9c73015..91bf778ba7c 100644 --- a/mobile/src/session/mobile-native-chat-pending-echo.ts +++ b/mobile/src/session/mobile-native-chat-pending-echo.ts @@ -16,6 +16,7 @@ export type MobileNativeChatPendingMessage = { export type MobileNativeChatSendOrigin = { draftKey: string + draftEditGeneration: number pendingKey: string | null normalizedText: string baselineOccurrences: number diff --git a/mobile/src/session/mobile-session-last-tab-close.test.ts b/mobile/src/session/mobile-session-last-tab-close.test.ts index 7bd6ad179fc..a125bbe3af0 100644 --- a/mobile/src/session/mobile-session-last-tab-close.test.ts +++ b/mobile/src/session/mobile-session-last-tab-close.test.ts @@ -13,6 +13,7 @@ describe('mobile session last-tab close', () => { const block = sessionRouteSource.slice(start, end) expect(block).toContain('} else if (active) {') + expect(block).toContain('retainMissingSurfaces: result.tabs.length === 0') }) it('clears stale active identity when closing leaves no tabs', () => { @@ -25,5 +26,8 @@ describe('mobile session last-tab close', () => { ) expect(block).toContain('activeSessionTabIdRef.current = null') expect(block).toContain('activeHandleRef.current = null') + expect(block).toContain( + 'reconcileBufferedDraftsRef.current(sessionTabsRef.current, remainingTabs)' + ) }) }) diff --git a/mobile/src/session/mobile-terminal-tab-agent.test.ts b/mobile/src/session/mobile-terminal-tab-agent.test.ts index db6d95c228e..5177981f0e6 100644 --- a/mobile/src/session/mobile-terminal-tab-agent.test.ts +++ b/mobile/src/session/mobile-terminal-tab-agent.test.ts @@ -4,7 +4,8 @@ import type { TuiAgent } from '../../../src/shared/tui-agent' import type { MobileSessionTab } from './mobile-session-route-types' import { getMobileSessionTabTitle, - resolveMobileTerminalTabAgentId + resolveMobileTerminalTabAgentId, + resolveMobileTerminalTabOwnedAgentId } from './mobile-terminal-tab-agent' function agentStatus(agentType: string | undefined): AgentStatusEntry { @@ -75,6 +76,15 @@ describe('resolveMobileTerminalTabAgentId', () => { }) }) +describe('resolveMobileTerminalTabOwnedAgentId', () => { + it('excludes display-only terminal titles from behavioral authority', () => { + expect(resolveMobileTerminalTabOwnedAgentId(terminalTab('✦ Gemini CLI'))).toBeNull() + expect( + resolveMobileTerminalTabOwnedAgentId(terminalTab('Terminal', { launchAgent: 'gemini' })) + ).toBe('gemini') + }) +}) + describe('getMobileSessionTabTitle', () => { it('strips leading agent decorations when an icon is shown', () => { expect(getMobileSessionTabTitle(terminalTab('✦ Gemini CLI'))).toBe('Gemini CLI') diff --git a/mobile/src/session/mobile-terminal-tab-agent.ts b/mobile/src/session/mobile-terminal-tab-agent.ts index f28d9b3fd98..20326d41c98 100644 --- a/mobile/src/session/mobile-terminal-tab-agent.ts +++ b/mobile/src/session/mobile-terminal-tab-agent.ts @@ -15,11 +15,16 @@ import type { MobileSessionTab } from './mobile-session-route-types' * Returns null when no agent is identified (plain shell / unknown), so the tab * keeps its text-only label. */ -export function resolveMobileTerminalTabAgentId(tab: { +type MobileTerminalTabAgentIdentity = { title: string - agentStatus?: AgentStatusEntry | null - launchAgent?: TuiAgent -}): string | null { + agentStatus?: { agentType?: AgentStatusEntry['agentType'] | null } | null + launchAgent?: TuiAgent | null +} + +/** Agent identity Orca owns, excluding the display-only title fallback. */ +export function resolveMobileTerminalTabOwnedAgentId( + tab: MobileTerminalTabAgentIdentity +): string | null { const hookAgentType = tab.agentStatus?.agentType?.trim() if (hookAgentType && hookAgentType !== 'unknown') { return hookAgentType @@ -27,6 +32,16 @@ export function resolveMobileTerminalTabAgentId(tab: { if (tab.launchAgent) { return tab.launchAgent } + return null +} + +export function resolveMobileTerminalTabAgentId( + tab: MobileTerminalTabAgentIdentity +): string | null { + const ownedAgent = resolveMobileTerminalTabOwnedAgentId(tab) + if (ownedAgent) { + return ownedAgent + } return resolveExplicitTerminalTitleAgentType(tab.title) } diff --git a/mobile/src/session/use-agent-send-keyboard-dismissal.ts b/mobile/src/session/use-agent-send-keyboard-dismissal.ts new file mode 100644 index 00000000000..38996a06a60 --- /dev/null +++ b/mobile/src/session/use-agent-send-keyboard-dismissal.ts @@ -0,0 +1,27 @@ +import { useCallback } from 'react' +import { + shouldDismissKeyboardAfterTerminalSend, + type AgentSendKeyboardDismissalTab +} from './agent-send-keyboard-dismissal' + +type AgentSendOrigin = { + readonly tab: AgentSendKeyboardDismissalTab | null + readonly generation: number +} + +export function useAgentSendKeyboardDismissal( + dismissSoftwareKeyboard: () => void, + getSendCompletionGeneration: () => number +) { + return useCallback( + (origin: AgentSendOrigin, accepted: boolean): void => { + if ( + origin.generation === getSendCompletionGeneration() && + shouldDismissKeyboardAfterTerminalSend(origin.tab, accepted) + ) { + dismissSoftwareKeyboard() + } + }, + [dismissSoftwareKeyboard, getSendCompletionGeneration] + ) +} diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts index d22b4fe26ce..40937adc0e0 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.test.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts @@ -93,6 +93,7 @@ const sendWithOutcome = vi.mocked(sendMobileNativeChatMessageWithOutcome) const ORIGIN = { draftKey: 'h\0w\0tab-1', + draftEditGeneration: 0, pendingKey: 'h\0w\0tab-1\0session-1', normalizedText: 'look', baselineOccurrences: 0, diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index ab1319c7847..4d405ac5d65 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -90,6 +90,7 @@ export function useMobileNativeChatController(args: { const { composerText: chatComposerText, setComposerText: setChatComposerText, + getComposerEditGeneration: getChatComposerEditGeneration, pending: chatPending, imagePreviewsByMessageId: chatImagePreviewsByMessageId, captureSendOrigin, @@ -262,6 +263,7 @@ export function useMobileNativeChatController(args: { nativeChatAgent: activeChatResolution?.agent ?? null, chatComposerText, setChatComposerText, + getChatComposerEditGeneration, chatPending, chatImagePreviewsByMessageId, nativeChatSession, diff --git a/mobile/src/session/use-mobile-native-chat-drafts.test.ts b/mobile/src/session/use-mobile-native-chat-drafts.test.ts index 2a5ff2d54c6..586b384e770 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.test.ts @@ -115,6 +115,20 @@ describe('useMobileNativeChatDrafts', () => { expect(state?.composerText).toBe('') }) + it('tracks every composer mutation with a stable route-owned generation', async () => { + await mount('a') + const getter = state!.getComposerEditGeneration + const initialGeneration = getter() + + act(() => state?.setComposerText('typed')) + expect(getter()).toBe(initialGeneration + 1) + + await switchTo('b') + expect(state?.getComposerEditGeneration).toBe(getter) + act(() => state?.setComposerText((current) => `${current} dictated`)) + expect(getter()).toBe(initialGeneration + 2) + }) + it('restores the text on a definite rejection', async () => { await mount('a') act(() => state?.setComposerText('ping')) @@ -146,6 +160,26 @@ describe('useMobileNativeChatDrafts', () => { expect(state?.composerText).toBe('newer edit') }) + it('preserves an intentional clear after a newer edit while a rejection is pending', async () => { + await mount('a') + act(() => state?.setComposerText('ping')) + const origin = state?.captureSendOrigin('ping') + act(() => { + if (origin) { + state?.clearDraftForSend(origin, 'ping') + } + }) + act(() => state?.setComposerText('newer edit')) + act(() => state?.setComposerText('')) + act(() => { + if (origin) { + state?.restoreRejectedDraft(origin, 'ping') + } + }) + + expect(state?.composerText).toBe('') + }) + it('restores a rejected send onto its originating tab only', async () => { await mount('a') act(() => state?.setComposerText('from a')) @@ -157,12 +191,13 @@ describe('useMobileNativeChatDrafts', () => { }) await switchTo('b') + act(() => state?.setComposerText('from b')) act(() => { if (originA) { state?.restoreRejectedDraft(originA, 'from a') } }) - expect(state?.composerText).toBe('') + expect(state?.composerText).toBe('from b') await switchTo('a') expect(state?.composerText).toBe('from a') @@ -443,6 +478,20 @@ describe('useMobileNativeChatDrafts', () => { expect(state?.composerText).toBe('new edit') }) + it('does not erase a whitespace-only newer edit when an older send clears', async () => { + await mount('a') + act(() => state?.setComposerText('/clear')) + const origin = state?.captureSendOrigin('/clear') + act(() => state?.setComposerText(' /clear')) + act(() => { + if (origin) { + state?.clearDraftForSend(origin, '/clear') + } + }) + + expect(state?.composerText).toBe(' /clear') + }) + it('stays quiet when an unconfirmed send lands in the transcript', async () => { vi.useFakeTimers() try { diff --git a/mobile/src/session/use-mobile-native-chat-drafts.ts b/mobile/src/session/use-mobile-native-chat-drafts.ts index 784f025847b..a50ccf4c923 100644 --- a/mobile/src/session/use-mobile-native-chat-drafts.ts +++ b/mobile/src/session/use-mobile-native-chat-drafts.ts @@ -22,6 +22,7 @@ import { import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key' import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed' +import { MobileNativeChatDraftEditGenerations } from './mobile-native-chat-draft-edit-generations' export type { MobileNativeChatPendingMessage, MobileNativeChatSendOrigin } @@ -54,6 +55,7 @@ export function useMobileNativeChatDrafts(args: { }): { composerText: string setComposerText: Dispatch> + getComposerEditGeneration: () => number pending: MobileNativeChatPendingMessage[] /** Phone-local previews rebound to the transcript message that replaced the * optimistic echo, keyed by authoritative message id. */ @@ -100,6 +102,7 @@ export function useMobileNativeChatDrafts(args: { Record> >({}) const pendingCounterRef = useRef(0) + const draftEditGenerationsRef = useRef(new MobileNativeChatDraftEditGenerations()) const messagesRef = useRef(messages) messagesRef.current = messages const activeDraftKeyRef = useRef(draftKey) @@ -123,6 +126,7 @@ export function useMobileNativeChatDrafts(args: { if (!draftKey) { return } + draftEditGenerationsRef.current.advance(draftKey) setDrafts((previous) => { const current = previous[draftKey] ?? '' const next = typeof value === 'function' ? value(current) : value @@ -131,7 +135,6 @@ export function useMobileNativeChatDrafts(args: { }, [draftKey] ) - const captureSendOrigin = useCallback( (text: string) => { if (!draftKey) { @@ -140,6 +143,7 @@ export function useMobileNativeChatDrafts(args: { const normalizedText = normalizeReconcileText(text) return { draftKey, + draftEditGeneration: draftEditGenerationsRef.current.readDraft(draftKey), pendingKey, normalizedText, baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText), @@ -158,7 +162,8 @@ export function useMobileNativeChatDrafts(args: { // send". Clear at send time; a definite rejection restores the text below. const clearDraftForSend = useCallback((origin: MobileNativeChatSendOrigin, text: string) => { setDrafts((previous) => - (previous[origin.draftKey] ?? '').trim() === text.trim() + draftEditGenerationsRef.current.isCurrent(origin.draftKey, origin.draftEditGeneration) && + (previous[origin.draftKey] ?? '') === text ? { ...previous, [origin.draftKey]: '' } : previous ) @@ -167,7 +172,10 @@ export function useMobileNativeChatDrafts(args: { const restoreRejectedDraft = useCallback((origin: MobileNativeChatSendOrigin, text: string) => { // Why: never clobber text the user typed while the rejection was in flight. setDrafts((previous) => - (previous[origin.draftKey] ?? '') === '' ? { ...previous, [origin.draftKey]: text } : previous + draftEditGenerationsRef.current.isCurrent(origin.draftKey, origin.draftEditGeneration) && + (previous[origin.draftKey] ?? '') === '' + ? { ...previous, [origin.draftKey]: text } + : previous ) }, []) @@ -269,9 +277,7 @@ export function useMobileNativeChatDrafts(args: { return } const movedIds = new Set(waitingForSession.map((item) => item.id)) - setPendingBySession((previous) => - mergeWaitingSessionPending(previous, pendingKey, waitingForSession) - ) + setPendingBySession((state) => mergeWaitingSessionPending(state, pendingKey, waitingForSession)) setPendingWaitingForSession((previous) => removeWaitingSessionPending(previous, draftKey, movedIds) ) @@ -330,6 +336,7 @@ export function useMobileNativeChatDrafts(args: { return { composerText: draftKey ? (drafts[draftKey] ?? '') : '', setComposerText, + getComposerEditGeneration: draftEditGenerationsRef.current.readComposer, pending, imagePreviewsByMessageId: pendingKey ? (imagePreviewsBySession[pendingKey] ?? NO_IMAGE_PREVIEWS) diff --git a/mobile/src/session/use-mobile-send-completion-generation.test.ts b/mobile/src/session/use-mobile-send-completion-generation.test.ts new file mode 100644 index 00000000000..0776ca717c6 --- /dev/null +++ b/mobile/src/session/use-mobile-send-completion-generation.test.ts @@ -0,0 +1,78 @@ +import { createElement, Suspense } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { describe, expect, it, vi } from 'vitest' +import { useMobileSendCompletionGeneration } from './use-mobile-send-completion-generation' + +let focusCleanup: (() => void) | undefined + +vi.mock('expo-router', () => ({ + useFocusEffect: (effect: () => void | (() => void)) => { + focusCleanup = effect() ?? undefined + } +})) + +describe('mobile send completion generation', () => { + it('invalidates completions on a surface change and retained-route blur', () => { + const onBlur = vi.fn() + let getGeneration: (() => number) | null = null + let renderer: ReactTestRenderer + + function Harness({ surfaceKey }: { surfaceKey: string }): null { + getGeneration = useMobileSendCompletionGeneration({ onBlur, surfaceKey }) + return null + } + + act(() => { + renderer = create(createElement(Harness, { surfaceKey: 'tab-a' })) + }) + const initialGeneration = getGeneration!() + act(() => { + renderer.update(createElement(Harness, { surfaceKey: 'tab-b' })) + }) + expect(getGeneration!()).toBeGreaterThan(initialGeneration) + + const surfaceGeneration = getGeneration!() + act(() => focusCleanup?.()) + expect(getGeneration!()).toBeGreaterThan(surfaceGeneration) + expect(onBlur).toHaveBeenCalledTimes(1) + + act(() => renderer.unmount()) + }) + + it('does not invalidate the committed surface during a suspended render', () => { + const never = new Promise(() => undefined) + let getGeneration: (() => number) | null = null + let renderer: ReactTestRenderer + + function Harness({ surfaceKey, suspend }: { surfaceKey: string; suspend: boolean }): null { + getGeneration = useMobileSendCompletionGeneration({ onBlur: vi.fn(), surfaceKey }) + if (suspend) { + throw never + } + return null + } + + act(() => { + renderer = create( + createElement( + Suspense, + { fallback: null }, + createElement(Harness, { surfaceKey: 'tab-a', suspend: false }) + ) + ) + }) + const committedGeneration = getGeneration!() + act(() => { + renderer.update( + createElement( + Suspense, + { fallback: null }, + createElement(Harness, { surfaceKey: 'tab-b', suspend: true }) + ) + ) + }) + + expect(getGeneration!()).toBe(committedGeneration) + act(() => renderer.unmount()) + }) +}) diff --git a/mobile/src/session/use-mobile-send-completion-generation.ts b/mobile/src/session/use-mobile-send-completion-generation.ts new file mode 100644 index 00000000000..16f74aee396 --- /dev/null +++ b/mobile/src/session/use-mobile-send-completion-generation.ts @@ -0,0 +1,28 @@ +import { useCallback, useLayoutEffect, useRef } from 'react' +import { useFocusEffect } from 'expo-router' + +type Options = { + readonly onBlur: () => void + readonly surfaceKey: string +} + +/** Fences async send completions after a surface change or retained-route blur. */ +export function useMobileSendCompletionGeneration({ onBlur, surfaceKey }: Options): () => number { + const generationRef = useRef(0) + const surfaceRef = useRef(surfaceKey) + useLayoutEffect(() => { + if (surfaceRef.current !== surfaceKey) { + surfaceRef.current = surfaceKey + generationRef.current += 1 + } + }, [surfaceKey]) + useFocusEffect( + useCallback(() => { + return () => { + generationRef.current += 1 + onBlur() + } + }, [onBlur]) + ) + return useCallback(() => generationRef.current, []) +} diff --git a/mobile/src/terminal/buffered-terminal-draft-restoration.test.ts b/mobile/src/terminal/buffered-terminal-draft-restoration.test.ts new file mode 100644 index 00000000000..218feba117c --- /dev/null +++ b/mobile/src/terminal/buffered-terminal-draft-restoration.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, it } from 'vitest' +import { + beginBufferedTerminalDraftRestoration, + invalidateBufferedTerminalDraftRestoration, + pruneBufferedTerminalDrafts, + pruneBufferedTerminalDraftRestorations, + restoreRejectedBufferedTerminalDraft, + settleBufferedTerminalDraftRestoration, + updateBufferedTerminalDraft +} from './buffered-terminal-draft-restoration' + +describe('buffered terminal draft restoration', () => { + it('restores the exact rejected draft when the composer is still empty', () => { + const pendingRestorations = new Map() + const token = beginBufferedTerminalDraftRestoration(pendingRestorations, 'terminal') + expect(settleBufferedTerminalDraftRestoration(pendingRestorations, 'terminal', token)).toBe( + true + ) + expect( + restoreRejectedBufferedTerminalDraft({ terminal: '' }, 'terminal', ' echo a–b ') + ).toEqual({ terminal: ' echo a–b ' }) + }) + + it('preserves newer text composed while the rejected send was in flight', () => { + const drafts = { terminal: 'next command' } + const pendingRestorations = new Map() + const token = beginBufferedTerminalDraftRestoration(pendingRestorations, 'terminal') + invalidateBufferedTerminalDraftRestoration(pendingRestorations, 'terminal') + expect(settleBufferedTerminalDraftRestoration(pendingRestorations, 'terminal', token)).toBe( + false + ) + expect(restoreRejectedBufferedTerminalDraft(drafts, 'terminal', 'rejected command')).toBe( + drafts + ) + }) + + it('clears restoration metadata when an accepted send settles', () => { + const pendingRestorations = new Map() + const token = beginBufferedTerminalDraftRestoration(pendingRestorations, 'terminal') + + expect(settleBufferedTerminalDraftRestoration(pendingRestorations, 'terminal', token)).toBe( + true + ) + expect(settleBufferedTerminalDraftRestoration(pendingRestorations, 'terminal', token)).toBe( + false + ) + }) + + it('preserves a later intentional clear while the rejected send was in flight', () => { + const terminal = 'terminal' + const rejectedDraft = 'rejected command' + const pendingRestorations = new Map() + const token = beginBufferedTerminalDraftRestoration(pendingRestorations, terminal) + let drafts = { [terminal]: rejectedDraft } + drafts = updateBufferedTerminalDraft(drafts, terminal, '') + invalidateBufferedTerminalDraftRestoration(pendingRestorations, terminal) + drafts = updateBufferedTerminalDraft(drafts, terminal, 'new command') + drafts = updateBufferedTerminalDraft(drafts, terminal, '') + + if (settleBufferedTerminalDraftRestoration(pendingRestorations, terminal, token)) { + drafts = restoreRejectedBufferedTerminalDraft(drafts, terminal, rejectedDraft) + } + expect(drafts).toEqual({ [terminal]: '' }) + }) + + it('restores a rejection to terminal A after switching to terminal B', () => { + const terminalA = 'terminal-a' + const terminalB = 'terminal-b' + const rejectedDraft = ' echo exact–text ' + let activeHandle = terminalA + const sendOrigin = activeHandle + const pendingRestorations = new Map() + const token = beginBufferedTerminalDraftRestoration(pendingRestorations, sendOrigin) + let drafts = { [terminalA]: rejectedDraft, [terminalB]: 'new command for B' } + drafts = updateBufferedTerminalDraft(drafts, sendOrigin, '') + activeHandle = terminalB + + if (settleBufferedTerminalDraftRestoration(pendingRestorations, sendOrigin, token)) { + drafts = restoreRejectedBufferedTerminalDraft(drafts, sendOrigin, rejectedDraft) + } + + expect(activeHandle).toBe(terminalB) + expect(drafts).toEqual({ + [terminalA]: rejectedDraft, + [terminalB]: 'new command for B' + }) + }) + + it('prunes drafts when their terminal lifetime ends', () => { + const liveDrafts = { live: 'keep' } + expect(pruneBufferedTerminalDrafts(liveDrafts, new Set(['live']))).toBe(liveDrafts) + expect( + pruneBufferedTerminalDrafts({ live: 'keep', closed: 'drop' }, new Set(['live'])) + ).toEqual({ live: 'keep' }) + + const pendingRestorations = new Map() + const liveToken = beginBufferedTerminalDraftRestoration(pendingRestorations, 'live') + const closedToken = beginBufferedTerminalDraftRestoration(pendingRestorations, 'closed') + pruneBufferedTerminalDraftRestorations(pendingRestorations, new Set(['live'])) + expect(settleBufferedTerminalDraftRestoration(pendingRestorations, 'live', liveToken)).toBe( + true + ) + expect(settleBufferedTerminalDraftRestoration(pendingRestorations, 'closed', closedToken)).toBe( + false + ) + }) +}) diff --git a/mobile/src/terminal/buffered-terminal-draft-restoration.ts b/mobile/src/terminal/buffered-terminal-draft-restoration.ts new file mode 100644 index 00000000000..6ca11c21652 --- /dev/null +++ b/mobile/src/terminal/buffered-terminal-draft-restoration.ts @@ -0,0 +1,115 @@ +export type BufferedTerminalDraftValue = string | ((current: string) => string) +export type BufferedTerminalDraftRestorationToken = { handle: string } + +export function updateBufferedTerminalDraft( + currentDrafts: Record, + handle: string | null, + value: BufferedTerminalDraftValue +): Record { + if (!handle) { + return currentDrafts + } + const current = currentDrafts[handle] ?? '' + const next = typeof value === 'function' ? value(current) : value + return next === current ? currentDrafts : { ...currentDrafts, [handle]: next } +} + +export function beginBufferedTerminalDraftRestoration( + pendingRestorations: Map, + handle: string +): BufferedTerminalDraftRestorationToken { + const token = { handle } + pendingRestorations.set(handle, token) + return token +} + +export function invalidateBufferedTerminalDraftRestoration( + pendingRestorations: Map, + handle: string +): void { + pendingRestorations.delete(handle) +} + +export function settleBufferedTerminalDraftRestoration( + pendingRestorations: Map, + handle: string, + token: BufferedTerminalDraftRestorationToken +): boolean { + const currentHandle = pendingRestorations.get(handle) === token ? handle : token.handle + if (pendingRestorations.get(currentHandle) !== token) { + return false + } + pendingRestorations.delete(currentHandle) + return true +} + +export function remapBufferedTerminalDraftRestoration( + pendingRestorations: Map, + previousHandle: string, + nextHandle: string +): void { + const token = pendingRestorations.get(previousHandle) + if (!token || pendingRestorations.has(nextHandle)) { + pendingRestorations.delete(previousHandle) + return + } + pendingRestorations.delete(previousHandle) + token.handle = nextHandle + pendingRestorations.set(nextHandle, token) +} + +export function remapBufferedTerminalDraft( + currentDrafts: Record, + previousHandle: string, + nextHandle: string +): Record { + if (previousHandle === nextHandle || !Object.hasOwn(currentDrafts, previousHandle)) { + return currentDrafts + } + const next = { ...currentDrafts } + if (!Object.hasOwn(currentDrafts, nextHandle)) { + next[nextHandle] = currentDrafts[previousHandle] ?? '' + } + delete next[previousHandle] + return next +} + +/** Restore a rejected send without overwriting text composed while its RPC was in flight. */ +export function restoreRejectedBufferedTerminalDraft( + currentDrafts: Record, + originHandle: string, + rejectedDraft: string +): Record { + if ((currentDrafts[originHandle] ?? '').length > 0) { + return currentDrafts + } + return updateBufferedTerminalDraft(currentDrafts, originHandle, rejectedDraft) +} + +export function pruneBufferedTerminalDrafts( + currentDrafts: Record, + retainedHandles: ReadonlySet +): Record { + let next = currentDrafts + for (const handle of Object.keys(currentDrafts)) { + if (retainedHandles.has(handle)) { + continue + } + if (next === currentDrafts) { + next = { ...currentDrafts } + } + delete next[handle] + } + return next +} + +export function pruneBufferedTerminalDraftRestorations( + pendingRestorations: Map, + retainedHandles: ReadonlySet +): void { + for (const handle of pendingRestorations.keys()) { + if (!retainedHandles.has(handle)) { + pendingRestorations.delete(handle) + } + } +} diff --git a/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts b/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts index 8dff075d85e..093ef8e17dc 100644 --- a/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts +++ b/mobile/src/terminal/terminal-ios-dictation-write-back.test.ts @@ -13,13 +13,13 @@ const sessionRouteSource = readFileSync( // apply dash normalization only on the send/mirror path. See stablyai/orca#7925. describe('terminal iOS dictation write-back', () => { it('does not write normalized text back into the buffered command input value', () => { - expect(sessionRouteSource).toContain('onChangeText={setInput}') + expect(sessionRouteSource).toContain('onChangeText={bufferedTerminalDraftState.setInput}') expect(sessionRouteSource).not.toContain( 'setInput((previousText) => normalizeTerminalTextInput' ) }) it('still normalizes the buffered command text at send time', () => { - expect(sessionRouteSource).toContain('normalizeTerminalTextInput(input)') + expect(sessionRouteSource).toContain('normalizeTerminalTextInput(draft)') }) }) diff --git a/mobile/src/terminal/terminal-live-input-affordance.test.ts b/mobile/src/terminal/terminal-live-input-affordance.test.ts index ce360091ce2..4a7b3bde817 100644 --- a/mobile/src/terminal/terminal-live-input-affordance.test.ts +++ b/mobile/src/terminal/terminal-live-input-affordance.test.ts @@ -17,6 +17,10 @@ const liveInputFocusSource = readFileSync( new URL('./use-terminal-live-input-focus.ts', import.meta.url), 'utf8' ) +const sendCompletionGenerationSource = readFileSync( + new URL('../session/use-mobile-send-completion-generation.ts', import.meta.url), + 'utf8' +) function liveInputBarBlock(): string { const start = sessionRouteSource.indexOf('{liveInputEnabled ? (') @@ -41,7 +45,10 @@ describe('terminal live input affordance', () => { expect(block).toContain('showSoftInputOnFocus') expect(block).toContain('liveInputText={liveInputCapture}') expect(sessionRouteSource).toContain('useTerminalLiveInputFocus({') - expect(sessionRouteSource).toContain('return resetLiveInputFocus') + expect(sessionRouteSource).toContain('useMobileSendCompletionGeneration({') + expect(sessionRouteSource).toContain('onBlur: resetLiveInputFocus') + expect(sendCompletionGenerationSource).toContain('return () => {') + expect(sendCompletionGenerationSource).toContain('onBlur()') expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current') expect(liveInputFocusSource).toContain('lifecycleIdentity,') expect(liveInputFocusSource).toContain('resetLiveInputFocus') diff --git a/mobile/src/terminal/use-buffered-terminal-drafts.test.tsx b/mobile/src/terminal/use-buffered-terminal-drafts.test.tsx new file mode 100644 index 00000000000..02ae3ddd206 --- /dev/null +++ b/mobile/src/terminal/use-buffered-terminal-drafts.test.tsx @@ -0,0 +1,302 @@ +import { createElement, useRef } from 'react' +import { act, create, type ReactTestRenderer } from 'react-test-renderer' +import { afterEach, describe, expect, it } from 'vitest' +import { resolveRetainedTerminalHandles } from '../session/mobile-terminal-prune-decision' +import { useBufferedTerminalDrafts } from './use-buffered-terminal-drafts' + +type BufferedDraftHook = ReturnType + +let currentHook: BufferedDraftHook | null = null +let renderer: ReactTestRenderer | null = null +let probeRenderCount = 0 + +function Probe({ activeHandle }: { readonly activeHandle: string | null }) { + probeRenderCount += 1 + const activeHandleRef = useRef(activeHandle) + activeHandleRef.current = activeHandle + currentHook = useBufferedTerminalDrafts({ activeHandle, activeHandleRef }) + return null +} + +function hook(): BufferedDraftHook { + if (!currentHook) { + throw new Error('Hook probe is not mounted') + } + return currentHook +} + +afterEach(() => { + act(() => renderer?.unmount()) + currentHook = null + probeRenderCount = 0 + renderer = null +}) + +describe('useBufferedTerminalDrafts', () => { + it('does not re-render for an unchanged terminal reconciliation', () => { + act(() => { + renderer = create(createElement(Probe, { activeHandle: 'terminal' })) + }) + const initialRenderCount = probeRenderCount + act(() => { + hook().reconcileTerminalTabs( + [{ id: 'tab-1', leafId: 'leaf-1', terminal: 'terminal' }], + [{ id: 'tab-1', leafId: 'leaf-1', terminal: 'terminal' }] + ) + }) + + expect(probeRenderCount).toBe(initialRenderCount) + }) + + it('does not re-render when terminal-list pruning retains every mapped draft', () => { + act(() => { + renderer = create(createElement(Probe, { activeHandle: 'terminal' })) + }) + act(() => hook().setInput('draft')) + act(() => { + hook().reconcileTerminalTabs( + [{ id: 'tab-1', leafId: 'leaf-1', terminal: 'terminal' }], + [{ id: 'tab-1', leafId: 'leaf-1', terminal: 'terminal' }] + ) + }) + const renderCountBeforePrune = probeRenderCount + + act(() => hook().pruneDrafts(new Set())) + + expect(probeRenderCount).toBe(renderCountBeforePrune) + }) + + it('carries an unsent draft through a pending-handle remint', () => { + act(() => { + renderer = create(createElement(Probe, { activeHandle: 'terminal-old' })) + }) + act(() => hook().setInput('keep across reload')) + act(() => { + hook().reconcileTerminalTabs( + [{ id: 'tab-old', leafId: 'leaf-1', terminal: 'terminal-old' }], + [{ id: 'tab-old', leafId: 'leaf-1', terminal: null }] + ) + renderer?.update(createElement(Probe, { activeHandle: null })) + }) + act(() => { + hook().reconcileTerminalTabs( + [{ id: 'tab-old', leafId: 'leaf-1', terminal: null }], + [{ id: 'tab-reminted', leafId: 'leaf-1', terminal: 'terminal-new' }] + ) + renderer?.update(createElement(Probe, { activeHandle: 'terminal-new' })) + }) + + expect(hook().input).toBe('keep across reload') + }) + + it('carries an unsent draft through a transient empty snapshot and handle remint', () => { + act(() => { + renderer = create(createElement(Probe, { activeHandle: 'terminal-old' })) + }) + act(() => hook().setInput('keep through empty snapshot')) + act(() => { + hook().reconcileTerminalTabs( + [{ id: 'tab-old', leafId: 'leaf-1', terminal: 'terminal-old' }], + [], + { retainMissingSurfaces: true } + ) + renderer?.update(createElement(Probe, { activeHandle: null })) + }) + act(() => { + hook().reconcileTerminalTabs( + [], + [{ id: 'tab-reminted', leafId: 'leaf-1', terminal: 'terminal-new' }] + ) + renderer?.update(createElement(Probe, { activeHandle: 'terminal-new' })) + }) + + expect(hook().input).toBe('keep through empty snapshot') + }) + + it('restores a rejected send to its reminted terminal surface', () => { + act(() => { + renderer = create(createElement(Probe, { activeHandle: 'terminal-old' })) + }) + act(() => hook().setInput('rejected command')) + let send: ReturnType + act(() => { + send = hook().beginBufferedTerminalDraftSend('terminal-old', hook().input) + hook().reconcileTerminalTabs( + [{ id: 'tab-old', leafId: 'leaf-1', terminal: 'terminal-old' }], + [{ id: 'tab-reminted', leafId: 'leaf-1', terminal: 'terminal-new' }] + ) + renderer?.update(createElement(Probe, { activeHandle: 'terminal-new' })) + }) + act(() => hook().restoreRejectedDraft(send)) + + expect(hook().input).toBe('rejected command') + }) + + it('restores a rejected send after a transient empty snapshot remints its surface', () => { + act(() => { + renderer = create(createElement(Probe, { activeHandle: 'terminal-old' })) + }) + act(() => hook().setInput('rejected command')) + let send: ReturnType + act(() => { + send = hook().beginBufferedTerminalDraftSend('terminal-old', hook().input) + hook().reconcileTerminalTabs( + [{ id: 'tab-old', leafId: 'leaf-1', terminal: 'terminal-old' }], + [], + { retainMissingSurfaces: true } + ) + hook().reconcileTerminalTabs( + [], + [{ id: 'tab-reminted', leafId: 'leaf-1', terminal: 'terminal-new' }] + ) + renderer?.update(createElement(Probe, { activeHandle: 'terminal-new' })) + }) + act(() => hook().restoreRejectedDraft(send)) + + expect(hook().input).toBe('rejected command') + }) + + it('keeps mapped drafts during terminal-list gaps but prunes them after confirmed close', () => { + act(() => { + renderer = create(createElement(Probe, { activeHandle: 'terminal' })) + }) + act(() => hook().setInput('bounded draft')) + act(() => { + hook().reconcileTerminalTabs( + [{ id: 'tab-1', leafId: 'leaf-1', terminal: 'terminal' }], + [{ id: 'tab-1', leafId: 'leaf-1', terminal: null }] + ) + hook().pruneDrafts(new Set()) + }) + expect(hook().input).toBe('bounded draft') + + act(() => { + hook().reconcileTerminalTabs([{ id: 'tab-1', leafId: 'leaf-1', terminal: null }], []) + hook().pruneDrafts(new Set()) + }) + expect(hook().input).toBe('') + }) + + it('preserves an intentional clear after the optimistic send clear', () => { + act(() => { + renderer = create(createElement(Probe, { activeHandle: 'terminal' })) + }) + act(() => hook().setInput('rejected command')) + let send: ReturnType + act(() => { + send = hook().beginBufferedTerminalDraftSend('terminal', hook().input) + }) + act(() => hook().setInput('new command')) + act(() => hook().setInput('')) + expect(hook().settleBufferedTerminalDraftSend(send)).toBe(false) + act(() => hook().restoreRejectedDraft(send)) + + expect(hook().input).toBe('') + }) + + it('restores by origin after a tab switch and preserves stable callback identities', () => { + act(() => { + renderer = create(createElement(Probe, { activeHandle: 'terminal-a' })) + }) + const callbacks = { + begin: hook().beginBufferedTerminalDraftSend, + prune: hook().pruneDrafts, + reconcile: hook().reconcileTerminalTabs, + reset: hook().resetDrafts, + restore: hook().restoreRejectedDraft, + setInput: hook().setInput, + settle: hook().settleBufferedTerminalDraftSend + } + act(() => hook().setInput(' echo exact–text ')) + let send: ReturnType + act(() => { + send = hook().beginBufferedTerminalDraftSend('terminal-a', hook().input) + renderer?.update(createElement(Probe, { activeHandle: 'terminal-b' })) + }) + act(() => hook().setInput('new command for B')) + act(() => hook().restoreRejectedDraft(send)) + act(() => renderer?.update(createElement(Probe, { activeHandle: 'terminal-a' }))) + + expect(hook().input).toBe(' echo exact–text ') + expect(hook().beginBufferedTerminalDraftSend).toBe(callbacks.begin) + expect(hook().pruneDrafts).toBe(callbacks.prune) + expect(hook().reconcileTerminalTabs).toBe(callbacks.reconcile) + expect(hook().resetDrafts).toBe(callbacks.reset) + expect(hook().restoreRejectedDraft).toBe(callbacks.restore) + expect(hook().setInput).toBe(callbacks.setInput) + expect(hook().settleBufferedTerminalDraftSend).toBe(callbacks.settle) + }) + + // Why the handle set the session sweep passes matters: terminal.list omits a + // chat-covered handle while the desktop graph reloads, so the raw list and the + // retained set disagree exactly there, and only the retained set keeps the draft. + it('keeps a chat-covered draft against the retained set and drops it against the raw list', () => { + const listedHandles = new Set(['other-terminal']) + const retainedHandles = resolveRetainedTerminalHandles({ + liveHandles: listedHandles, + showNativeChat: true, + activeHandle: 'covered-terminal' + }) + act(() => { + renderer = create(createElement(Probe, { activeHandle: 'covered-terminal' })) + }) + act(() => hook().setInput('half-typed command')) + + act(() => hook().pruneDrafts(retainedHandles)) + expect(hook().input).toBe('half-typed command') + + act(() => hook().pruneDrafts(listedHandles)) + expect(hook().input).toBe('') + }) + + it('keeps a chat-covered pending restoration against the retained set only', () => { + const listedHandles = new Set(['other-terminal']) + const retainedHandles = resolveRetainedTerminalHandles({ + liveHandles: listedHandles, + showNativeChat: true, + activeHandle: 'covered-terminal' + }) + act(() => { + renderer = create(createElement(Probe, { activeHandle: 'covered-terminal' })) + }) + act(() => hook().setInput('rejected command')) + let retainedSend: ReturnType + act(() => { + retainedSend = hook().beginBufferedTerminalDraftSend('covered-terminal', hook().input) + }) + act(() => hook().pruneDrafts(retainedHandles)) + act(() => hook().restoreRejectedDraft(retainedSend)) + expect(hook().input).toBe('rejected command') + + let droppedSend: ReturnType + act(() => { + droppedSend = hook().beginBufferedTerminalDraftSend('covered-terminal', hook().input) + }) + act(() => hook().pruneDrafts(listedHandles)) + act(() => hook().restoreRejectedDraft(droppedSend)) + expect(hook().input).toBe('') + }) + + it('drops ended-handle and route-reset restoration metadata', () => { + act(() => { + renderer = create(createElement(Probe, { activeHandle: 'terminal' })) + }) + act(() => hook().setInput('rejected command')) + let prunedSend: ReturnType + act(() => { + prunedSend = hook().beginBufferedTerminalDraftSend('terminal', hook().input) + hook().reconcileTerminalTabs([{ id: 'tab-1', leafId: 'leaf-1', terminal: 'terminal' }], []) + hook().restoreRejectedDraft(prunedSend) + }) + expect(hook().input).toBe('') + + act(() => hook().setInput('route draft')) + let resetSend: ReturnType + act(() => { + resetSend = hook().beginBufferedTerminalDraftSend('terminal', hook().input) + hook().resetDrafts() + hook().restoreRejectedDraft(resetSend) + }) + expect(hook().input).toBe('') + }) +}) diff --git a/mobile/src/terminal/use-buffered-terminal-drafts.ts b/mobile/src/terminal/use-buffered-terminal-drafts.ts new file mode 100644 index 00000000000..58c154fb4c3 --- /dev/null +++ b/mobile/src/terminal/use-buffered-terminal-drafts.ts @@ -0,0 +1,196 @@ +import { useCallback, useRef, useState } from 'react' +import type { RefObject } from 'react' +import { + type BufferedTerminalDraftRestorationToken, + type BufferedTerminalDraftValue, + beginBufferedTerminalDraftRestoration, + invalidateBufferedTerminalDraftRestoration, + pruneBufferedTerminalDrafts, + pruneBufferedTerminalDraftRestorations, + remapBufferedTerminalDraft, + remapBufferedTerminalDraftRestoration, + restoreRejectedBufferedTerminalDraft, + settleBufferedTerminalDraftRestoration, + updateBufferedTerminalDraft +} from './buffered-terminal-draft-restoration' + +interface BufferedTerminalDraftSend { + readonly draft: string + readonly handle: string + readonly token: BufferedTerminalDraftRestorationToken +} + +interface UseBufferedTerminalDraftsOptions { + readonly activeHandle: string | null + readonly activeHandleRef: RefObject +} + +type BufferedTerminalDraftTab = { + readonly id: string + readonly type?: string + readonly leafId?: string + readonly terminal?: string | null +} + +type ReconcileBufferedTerminalDraftTabsOptions = { + readonly retainMissingSurfaces?: boolean +} + +function getBufferedTerminalDraftSurfaceKey(tab: BufferedTerminalDraftTab): string { + return tab.leafId ? `leaf:${tab.leafId}` : `tab:${tab.id}` +} + +export function useBufferedTerminalDrafts({ + activeHandle, + activeHandleRef +}: UseBufferedTerminalDraftsOptions) { + const [drafts, setDrafts] = useState>({}) + const pendingRestorationsRef = useRef>( + new Map() + ) + const handlesBySurfaceRef = useRef>(new Map()) + const input = activeHandle ? (drafts[activeHandle] ?? '') : '' + + const setInput = useCallback( + (value: BufferedTerminalDraftValue) => { + const handle = activeHandleRef.current + if (!handle) { + return + } + invalidateBufferedTerminalDraftRestoration(pendingRestorationsRef.current, handle) + setDrafts((current) => updateBufferedTerminalDraft(current, handle, value)) + }, + [activeHandleRef] + ) + + const beginBufferedTerminalDraftSend = useCallback( + (handle: string, draft: string): BufferedTerminalDraftSend => { + const token = beginBufferedTerminalDraftRestoration(pendingRestorationsRef.current, handle) + setDrafts((current) => updateBufferedTerminalDraft(current, handle, '')) + return { draft, handle, token } + }, + [] + ) + + const restoreRejectedDraft = useCallback((send: BufferedTerminalDraftSend): void => { + if ( + !settleBufferedTerminalDraftRestoration( + pendingRestorationsRef.current, + send.handle, + send.token + ) + ) { + return + } + setDrafts((current) => + restoreRejectedBufferedTerminalDraft(current, send.token.handle, send.draft) + ) + }, []) + + const settleBufferedTerminalDraftSend = useCallback( + (send: BufferedTerminalDraftSend): boolean => + settleBufferedTerminalDraftRestoration( + pendingRestorationsRef.current, + send.handle, + send.token + ), + [] + ) + + const pruneDrafts = useCallback((retainedHandles: ReadonlySet): void => { + const retainedMappedHandles = new Set(retainedHandles) + for (const handle of handlesBySurfaceRef.current.values()) { + retainedMappedHandles.add(handle) + } + setDrafts((current) => pruneBufferedTerminalDrafts(current, retainedMappedHandles)) + pruneBufferedTerminalDraftRestorations(pendingRestorationsRef.current, retainedMappedHandles) + }, []) + + const reconcileTerminalTabs = useCallback( + ( + previousTabs: readonly BufferedTerminalDraftTab[], + nextTabs: readonly BufferedTerminalDraftTab[], + { retainMissingSurfaces = false }: ReconcileBufferedTerminalDraftTabsOptions = {} + ): void => { + const handlesBySurface = handlesBySurfaceRef.current + for (const tab of previousTabs) { + if (tab.type && tab.type !== 'terminal') { + continue + } + if (typeof tab.terminal === 'string') { + const surfaceKey = getBufferedTerminalDraftSurfaceKey(tab) + if (!handlesBySurface.has(surfaceKey)) { + handlesBySurface.set(surfaceKey, tab.terminal) + } + } + } + + const retainedHandles = new Set( + retainMissingSurfaces ? handlesBySurface.values() : [] + ) + const retainedSurfaces = new Set(retainMissingSurfaces ? handlesBySurface.keys() : []) + const remaps: Array<{ previousHandle: string; nextHandle: string }> = [] + for (const tab of nextTabs) { + if (tab.type && tab.type !== 'terminal') { + continue + } + const surfaceKey = getBufferedTerminalDraftSurfaceKey(tab) + retainedSurfaces.add(surfaceKey) + const previousHandle = handlesBySurface.get(surfaceKey) + if (typeof tab.terminal === 'string') { + retainedHandles.add(tab.terminal) + if (previousHandle && previousHandle !== tab.terminal) { + remaps.push({ previousHandle, nextHandle: tab.terminal }) + } + handlesBySurface.set(surfaceKey, tab.terminal) + } else if (previousHandle) { + retainedHandles.add(previousHandle) + } + } + for (const surfaceKey of handlesBySurface.keys()) { + if (!retainedSurfaces.has(surfaceKey)) { + handlesBySurface.delete(surfaceKey) + } + } + + for (const { previousHandle, nextHandle } of remaps) { + remapBufferedTerminalDraftRestoration( + pendingRestorationsRef.current, + previousHandle, + nextHandle + ) + } + pruneBufferedTerminalDraftRestorations(pendingRestorationsRef.current, retainedHandles) + setDrafts((current) => { + let next = current + for (const { previousHandle, nextHandle } of remaps) { + next = remapBufferedTerminalDraft(next, previousHandle, nextHandle) + } + return pruneBufferedTerminalDrafts(next, retainedHandles) + }) + }, + [] + ) + + const resetDrafts = useCallback((): void => { + pendingRestorationsRef.current.clear() + handlesBySurfaceRef.current.clear() + setDrafts((current) => (Object.keys(current).length === 0 ? current : {})) + }, []) + + const clearPendingRestorations = useCallback((): void => { + pendingRestorationsRef.current.clear() + }, []) + + return { + beginBufferedTerminalDraftSend, + clearPendingRestorations, + input, + pruneDrafts, + reconcileTerminalTabs, + resetDrafts, + restoreRejectedDraft, + setInput, + settleBufferedTerminalDraftSend + } +} diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts index ad1d1726798..acf2b0533ce 100644 --- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.test.ts @@ -90,6 +90,7 @@ function createAccessoryInputCommitHarness({ liveInputComposingRef, liveInputRef, liveInputTerminalHandles, + onInteraction: vi.fn(), pendingLiveInputHandleRef, sentLiveInputTextRef, sendLiveTerminalInputRef, diff --git a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts index 80a8422d085..36f33996eab 100644 --- a/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-accessory-input-commit.ts @@ -32,6 +32,7 @@ type TerminalLiveAccessoryInputCommitOptions = { readonly liveInputComposingRef: RefObject readonly liveInputRef: RefObject readonly liveInputTerminalHandles: ReadonlySet + readonly onInteraction: () => void readonly pendingLiveInputHandleRef: RefObject readonly sentLiveInputTextRef: RefObject readonly sendLiveTerminalInputRef: RefObject @@ -48,6 +49,7 @@ export function useTerminalLiveAccessoryInputCommit({ liveInputComposingRef, liveInputRef, liveInputTerminalHandles, + onInteraction, pendingLiveInputHandleRef, sentLiveInputTextRef, sendLiveTerminalInputRef, @@ -64,6 +66,7 @@ export function useTerminalLiveAccessoryInputCommit({ if (!liveInputTerminalHandles.has(activeHandle)) { return getTerminalLiveAccessoryInactiveInputCommitResult(waitForPendingLiveInputFlush) } + onInteraction() const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle if (pendingLiveInputHandleRef.current && !ownsPendingState) { clearPendingLiveInputCommit() @@ -116,6 +119,7 @@ export function useTerminalLiveAccessoryInputCommit({ liveInputComposingRef, liveInputRef, liveInputTerminalHandles, + onInteraction, pendingLiveInputHandleRef, sentLiveInputTextRef, sendLiveTerminalInputRef, diff --git a/mobile/src/terminal/use-terminal-live-input-commit.test.ts b/mobile/src/terminal/use-terminal-live-input-commit.test.ts index 6077ccb4033..87ad2b64292 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.test.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.test.ts @@ -19,6 +19,7 @@ function changeLiveInput( type TerminalLiveInputCommitHarness = { readonly captures: readonly string[] + readonly getHandlers: () => TerminalLiveInputCommitHandlers readonly handlers: TerminalLiveInputCommitHandlers readonly sent: readonly string[] readonly setActiveSessionTabType: (next: string | undefined) => void @@ -85,6 +86,12 @@ function createTerminalLiveInputCommitHarness({ return { captures, + getHandlers: () => { + if (!handlers) { + throw new Error('terminal live input hook is not mounted') + } + return handlers + }, handlers, sent, setActiveSessionTabType: (next: string | undefined): void => { @@ -239,9 +246,10 @@ describe('terminal live input commit hook', () => { changeLiveInput(handlers, '한') // When - handlers.handleLiveInputSubmit() + const accepted = await handlers.handleLiveInputSubmit() // Then + expect(accepted).toBe(true) await vi.waitFor(() => expect(sent).toEqual(['한', '\r'])) }) @@ -250,26 +258,54 @@ describe('terminal live input commit hook', () => { const { handlers, sent } = createTerminalLiveInputCommitHarness() // When - handlers.handleLiveInputSubmit() + const accepted = await handlers.handleLiveInputSubmit() // Then + expect(accepted).toBe(true) await vi.waitFor(() => expect(sent).toEqual(['\r'])) }) + it('increments a stable interaction generation for typing, submit, and accessory Enter', async () => { + const { getHandlers, handlers, setActiveSessionTabType } = + createTerminalLiveInputCommitHarness() + const getter = handlers.getLiveInputInteractionGeneration + const initialGeneration = getter() + + changeLiveInput(handlers, 'newer text') + const typedGeneration = getter() + await handlers.handleLiveInputSubmit() + const submitGeneration = getter() + await handlers.handleLiveInputAccessoryBytes({ bytes: '\r' }) + setActiveSessionTabType(undefined) + + expect(getHandlers().getLiveInputInteractionGeneration).toBe(getter) + expect(typedGeneration).toBe(initialGeneration + 1) + expect(submitGeneration).toBeGreaterThan(typedGeneration) + expect(getter()).toBeGreaterThan(submitGeneration) + }) + it('Given a rejected held-text send When submit is requested Then suppresses the carriage return', async () => { // Given const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false }) changeLiveInput(handlers, '한') // When - handlers.handleLiveInputSubmit() - await Promise.resolve() - await Promise.resolve() + const accepted = await handlers.handleLiveInputSubmit() // Then: the held commit went out but was not accepted, so no \r follows + expect(accepted).toBe(false) await vi.waitFor(() => expect(sent).toEqual(['한'])) }) + it('Given a rejected carriage return When submit is requested Then reports rejection', async () => { + const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false }) + + const accepted = await handlers.handleLiveInputSubmit() + + expect(accepted).toBe(false) + expect(sent).toEqual(['\r']) + }) + it('Given ASCII typing When changes arrive Then mirrors immediately', async () => { // Given const { handlers, sent } = createTerminalLiveInputCommitHarness() diff --git a/mobile/src/terminal/use-terminal-live-input-commit.ts b/mobile/src/terminal/use-terminal-live-input-commit.ts index 952612f0a87..55bc23c3dcc 100644 --- a/mobile/src/terminal/use-terminal-live-input-commit.ts +++ b/mobile/src/terminal/use-terminal-live-input-commit.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, type RefObject } from 'react' +import { useCallback, useEffect, useRef, type RefObject } from 'react' import type { TextInput } from 'react-native' import { getTerminalLiveSpecialKeyDecision } from './terminal-live-text-commit' import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order' @@ -43,12 +43,13 @@ type TerminalLiveInputCommitOptions = { type TerminalLiveInputCommitHandlers = { readonly clearPendingLiveInputCommit: () => void readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise + readonly getLiveInputInteractionGeneration: () => number readonly handleLiveInputAccessoryBytes: ( input: TerminalLiveAccessoryInput ) => Promise readonly handleLiveInputChange: (event: TerminalLiveInputChangeEvent) => void readonly handleLiveInputKeyPress: (event: TerminalLiveInputKeyPressEvent) => void - readonly handleLiveInputSubmit: () => void + readonly handleLiveInputSubmit: () => Promise } export function useTerminalLiveInputCommit({ @@ -63,6 +64,10 @@ export function useTerminalLiveInputCommit({ sendLiveTerminalInputRef, setLiveInputCapture }: TerminalLiveInputCommitOptions): TerminalLiveInputCommitHandlers { + const liveInputInteractionGenerationRef = useRef(0) + const advanceLiveInputInteractionGeneration = useCallback(() => { + liveInputInteractionGenerationRef.current += 1 + }, []) const { applyLiveInputMirror, clearPendingLiveInputCommit, @@ -108,6 +113,7 @@ export function useTerminalLiveInputCommit({ const flushPendingLiveInputBeforeExternalSend = useCallback( async (handle: string): Promise => { + advanceLiveInputInteractionGeneration() const pendingHandle = pendingLiveInputHandleRef.current if (pendingHandle && pendingHandle !== handle) { clearPendingLiveInputCommit() @@ -120,7 +126,12 @@ export function useTerminalLiveInputCommit({ } return waitForPendingLiveInputFlush() }, - [clearPendingLiveInputCommit, flushPendingLiveInputText, waitForPendingLiveInputFlush] + [ + advanceLiveInputInteractionGeneration, + clearPendingLiveInputCommit, + flushPendingLiveInputText, + waitForPendingLiveInputFlush + ] ) const handleLiveInputChange = useCallback( @@ -132,6 +143,7 @@ export function useTerminalLiveInputCommit({ // Why: iOS kills an active dictation/IME session when JS writes a value // that differs from the native field text, so the controlled capture must // echo the field verbatim; only the PTY mirror sees normalized text. + advanceLiveInputInteractionGeneration() setLiveInputCapture(nativeEvent.text) void applyLiveInputMirror( activeHandle, @@ -141,6 +153,7 @@ export function useTerminalLiveInputCommit({ }, [ activeHandle, + advanceLiveInputInteractionGeneration, applyLiveInputMirror, clearPendingLiveInputCommit, liveInputTerminalHandles, @@ -148,11 +161,17 @@ export function useTerminalLiveInputCommit({ ] ) + const getLiveInputInteractionGeneration = useCallback( + () => liveInputInteractionGenerationRef.current, + [] + ) + const handleLiveInputKeyPress = useCallback( (event: TerminalLiveInputKeyPressEvent) => { if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) { return } + advanceLiveInputInteractionGeneration() const ownsPendingState = pendingLiveInputHandleRef.current === activeHandle if (pendingLiveInputHandleRef.current && !ownsPendingState) { clearPendingLiveInputCommit() @@ -183,6 +202,7 @@ export function useTerminalLiveInputCommit({ }, [ activeHandle, + advanceLiveInputInteractionGeneration, clearPendingLiveInputCommit, flushPendingLiveInputText, liveInputTerminalHandles, @@ -200,6 +220,7 @@ export function useTerminalLiveInputCommit({ liveInputComposingRef, liveInputRef, liveInputTerminalHandles, + onInteraction: advanceLiveInputInteractionGeneration, pendingLiveInputHandleRef, sentLiveInputTextRef, sendLiveTerminalInputRef, @@ -207,19 +228,27 @@ export function useTerminalLiveInputCommit({ waitForPendingLiveInputFlush }) - const handleLiveInputSubmit = useCallback(() => { + const handleLiveInputSubmit = useCallback((): Promise => { if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) { - return + return Promise.resolve(false) } - void sendTerminalLiveControlAfterPendingFlush( + advanceLiveInputInteractionGeneration() + return sendTerminalLiveControlAfterPendingFlush( () => flushPendingLiveInputText(activeHandle), () => sendLiveTerminalInputRef.current(activeHandle, '\r') ) - }, [activeHandle, flushPendingLiveInputText, liveInputTerminalHandles, sendLiveTerminalInputRef]) + }, [ + activeHandle, + advanceLiveInputInteractionGeneration, + flushPendingLiveInputText, + liveInputTerminalHandles, + sendLiveTerminalInputRef + ]) return { clearPendingLiveInputCommit, flushPendingLiveInputBeforeExternalSend, + getLiveInputInteractionGeneration, handleLiveInputAccessoryBytes, handleLiveInputChange, handleLiveInputKeyPress,