diff --git a/src/main/ipc/worktrees-create-metadata-persistence.test.ts b/src/main/ipc/worktrees-create-metadata-persistence.test.ts index dbf433a7934..2570699ac73 100644 --- a/src/main/ipc/worktrees-create-metadata-persistence.test.ts +++ b/src/main/ipc/worktrees-create-metadata-persistence.test.ts @@ -27,6 +27,7 @@ const WORKTREE_HANDLER_CHANNELS = [ 'worktrees:adoptProvisionedRoot', 'worktrees:prefetchCreateBase', 'worktrees:prepareCreateCheckout', + 'worktrees:setCreateStandby', 'worktrees:resolvePrBase', 'worktrees:resolveMrBase', 'worktrees:remove', diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index ed34efbd66c..140657e1669 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -26,6 +26,7 @@ registerDetectedWorktreeScanInvalidation() // Why not module scope like the invalidation above: this needs `mainWindow`/`store`, which only // exist once a window is attached, and must track the current ones across re-registration. +let disposeWorktreeStandbys: (() => void) | undefined let disposeSparseCheckoutCacheInvalidation: (() => void) | undefined const WORKTREE_HANDLER_CHANNELS = [ @@ -40,6 +41,7 @@ const WORKTREE_HANDLER_CHANNELS = [ 'worktrees:adoptProvisionedRoot', 'worktrees:prefetchCreateBase', 'worktrees:prepareCreateCheckout', + 'worktrees:setCreateStandby', 'worktrees:resolvePrBase', 'worktrees:resolveMrBase', 'worktrees:remove', @@ -87,7 +89,8 @@ export function registerWorktreeHandlers( registerWorktreeCatalogHandlers(context) registerHostCatalogHandlers(context) registerDetectedWorktreeHandlers(context) - registerWorktreePrefetchHandler(context) + disposeWorktreeStandbys?.() + disposeWorktreeStandbys = registerWorktreePrefetchHandler(context) registerWorktreeCreateHandlers(context) registerReviewBaseHandlers(context) registerWorktreeRemovalHandlers(context) diff --git a/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts b/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts index 069c5e5a159..e20bd027c13 100644 --- a/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts +++ b/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts @@ -1,12 +1,66 @@ -import { ipcMain } from 'electron' -import { prepareWorktreeCreateStandby } from '../../../worktree-create-standby' +import { ipcMain, type WebContents } from 'electron' +import { createWorktreeStandbyOwner } from '../../../worktree-create-standby-owner' +import { + prepareWorktreeCreateStandby, + retainWorktreeCreateStandby +} from '../../../worktree-create-standby' import { prefetchWorktreeCreateBase } from '../../../worktree-create-base-prefetch' import { prepareWorktreeCreateForRepo } from '../../../worktree-create-preparation' import { getWorktreeCreatePrefetchGitOptions } from '../../../project-runtime-git-options' import type { WorktreeIpcContext } from '../worktree-ipc-context' -export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): void { +export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): () => void { const { store, runtime } = context + const owners = new Map< + WebContents, + { controller: ReturnType; dispose: () => void } + >() + ipcMain.handle( + 'worktrees:setCreateStandby', + async (event, args: { repoId: string | null; baseBranch?: string }) => { + const sender = event.sender + if (sender.isDestroyed()) { + return + } + let owner = owners.get(sender) + if (!owner) { + if (!args.repoId) { + return + } + const controller = createWorktreeStandbyOwner() + const dispose = () => { + controller.close() + owners.delete(sender) + sender.removeListener('destroyed', dispose) + sender.removeListener('render-process-gone', dispose) + sender.removeListener('did-start-navigation', onNavigation) + } + const onNavigation = ( + _event: Electron.Event, + _url: string, + isInPlace: boolean, + isMainFrame: boolean + ): void => { + if (isMainFrame && !isInPlace) { + dispose() + } + } + owner = { controller, dispose } + owners.set(sender, owner) + sender.once('destroyed', dispose) + sender.once('render-process-gone', dispose) + sender.on('did-start-navigation', onNavigation) + } + await owner.controller.set( + args.repoId + ? async () => { + const repo = store.getRepo(args.repoId!) + return repo ? retainWorktreeCreateStandby(store, repo, args.baseBranch) : () => {} + } + : undefined + ) + } + ) ipcMain.handle( 'worktrees:prepareCreateCheckout', @@ -44,4 +98,9 @@ export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): vo } } ) + return () => { + for (const owner of owners.values()) { + owner.dispose() + } + } } diff --git a/src/main/ipc/worktrees/create/register-worktree-standby-handler.test.ts b/src/main/ipc/worktrees/create/register-worktree-standby-handler.test.ts index 99033776f76..110ed284a70 100644 --- a/src/main/ipc/worktrees/create/register-worktree-standby-handler.test.ts +++ b/src/main/ipc/worktrees/create/register-worktree-standby-handler.test.ts @@ -1,14 +1,19 @@ -import { beforeEach, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' import type { WorktreeIpcContext } from '../worktree-ipc-context' const mocks = vi.hoisted(() => ({ handle: vi.fn(), standby: vi.fn(), + retain: vi.fn(), prefetch: vi.fn(), getRepo: vi.fn() })) vi.mock('electron', () => ({ ipcMain: { handle: mocks.handle } })) -vi.mock('../../../worktree-create-standby', () => ({ prepareWorktreeCreateStandby: mocks.standby })) +vi.mock('../../../worktree-create-standby', () => ({ + prepareWorktreeCreateStandby: mocks.standby, + retainWorktreeCreateStandby: mocks.retain +})) vi.mock('../../../worktree-create-base-prefetch', () => ({ prefetchWorktreeCreateBase: mocks.prefetch })) @@ -22,10 +27,11 @@ import { registerWorktreePrefetchHandler } from './register-worktree-prefetch-ha const context = { store: { getRepo: mocks.getRepo }, runtime: {} } as unknown as WorktreeIpcContext const repo = { id: 'repo', path: '/repo' } let standby: (event: null, args: { repoId: string; baseBranch?: string }) => Promise +let dispose: () => void beforeEach(() => { vi.resetAllMocks() mocks.getRepo.mockReturnValue(repo) - registerWorktreePrefetchHandler(context) + dispose = registerWorktreePrefetchHandler(context) standby = mocks.handle.mock.calls.find( ([channel]) => channel === 'worktrees:prepareCreateCheckout' )![1] @@ -48,3 +54,41 @@ it('contains speculative failure without falling back to a fetch', async () => { await expect(standby(null, { repoId: 'repo' })).resolves.toBeUndefined() expect(mocks.prefetch).not.toHaveBeenCalled() }) + +afterEach(() => dispose()) + +it.each(['destroy', 'crash', 'reload', 'register cleanup'] as const)( + 'releases held checkout on %s', + async (action) => { + const release = vi.fn() + mocks.retain.mockResolvedValue(release) + const sender = Object.assign(new EventEmitter(), { isDestroyed: () => false }) + const handler = mocks.handle.mock.calls.find( + ([name]) => name === 'worktrees:setCreateStandby' + )![1] + await handler({ sender }, { repoId: 'repo', baseBranch: 'chosen' }) + expect(mocks.retain).toHaveBeenCalledExactlyOnceWith(context.store, repo, 'chosen') + if (action === 'destroy') { + sender.emit('destroyed') + } else { + dispose() + } + expect(release).toHaveBeenCalledOnce() + expect(sender.listenerCount('destroyed')).toBe(0) + expect(sender.listenerCount('render-process-gone')).toBe(0) + expect(sender.listenerCount('did-start-navigation')).toBe(0) + } +) + +it('releases on hide without starting another preparation', async () => { + const release = vi.fn() + mocks.retain.mockResolvedValue(release) + const sender = Object.assign(new EventEmitter(), { isDestroyed: () => false }) + const handler = mocks.handle.mock.calls.find( + ([name]) => name === 'worktrees:setCreateStandby' + )![1] + await handler({ sender }, { repoId: 'repo' }) + await handler({ sender }, { repoId: null }) + expect(release).toHaveBeenCalledOnce() + expect(mocks.retain).toHaveBeenCalledOnce() +}) diff --git a/src/main/worktree-create-preparation-pool.ts b/src/main/worktree-create-preparation-pool.ts index e3d2ce8c35d..e6a97219ce5 100644 --- a/src/main/worktree-create-preparation-pool.ts +++ b/src/main/worktree-create-preparation-pool.ts @@ -144,6 +144,29 @@ export function takePreparation(entry: PreparationEntry): void { clearTimeout(entry.expiration) } +const preparationHolds = new WeakMap() + +export function holdPreparation(entry: PreparationEntry): () => void { + if (preparations.get(entry.key) !== entry) { + return () => {} + } + clearTimeout(entry.expiration) + preparationHolds.set(entry, (preparationHolds.get(entry) ?? 0) + 1) + let released = false + return () => { + if (released) { + return + } + released = true + const remaining = (preparationHolds.get(entry) ?? 1) - 1 + preparationHolds.set(entry, remaining) + if (remaining === 0 && preparations.get(entry.key) === entry) { + entry.expiration = setTimeout(() => expireEntry(entry), WORKTREE_CREATE_PREPARATION_TTL_MS) + entry.expiration.unref() + } + } +} + export function startPreparation({ repoPath, workspaceRoot, diff --git a/src/main/worktree-create-preparation.ts b/src/main/worktree-create-preparation.ts index bd5ca06024a..3f96b77b07f 100644 --- a/src/main/worktree-create-preparation.ts +++ b/src/main/worktree-create-preparation.ts @@ -12,6 +12,7 @@ import { preparationPathKey, selectPreparationForCreate } from './worktree-creat import { _resetPreparationPoolForTests, findPreparation, + holdPreparation, hasPendingPreparations, listPreparations, startPreparation, @@ -71,11 +72,11 @@ function canonicalBaseRef( ) } -export async function prepareWorktreeCreateForRepo( +async function getOrStartPreparationForRepo( store: Store, repo: Repo, baseBranch: string -): Promise { +): Promise { if (repo.connectionId || isFolderRepo(repo)) { return } @@ -96,16 +97,50 @@ export async function prepareWorktreeCreateForRepo( options.wslDistro ?? '' ) if (existing) { - return existing.ready + return existing } - return startPreparation({ + void startPreparation({ repoPath: repo.path, workspaceRoot, baseBranch, canonicalBase, options }) + return findPreparation( + preparationPathKey(repo.path), + preparationPathKey(workspaceRoot), + canonicalBase, + options.wslDistro ?? '' + ) +} + +export async function prepareWorktreeCreateForRepo( + store: Store, + repo: Repo, + baseBranch: string +): Promise { + const entry = await getOrStartPreparationForRepo(store, repo, baseBranch) + await entry?.ready +} + +export async function retainWorktreeCreateForRepo( + store: Store, + repo: Repo, + baseBranch: string +): Promise<() => void> { + const entry = await getOrStartPreparationForRepo(store, repo, baseBranch) + if (!entry) { + return () => {} + } + const release = holdPreparation(entry) + try { + await entry.ready + return release + } catch (error) { + release() + throw error + } } type ClaimedPreparation = diff --git a/src/main/worktree-create-standby-owner.test.ts b/src/main/worktree-create-standby-owner.test.ts new file mode 100644 index 00000000000..3a39bb1fb39 --- /dev/null +++ b/src/main/worktree-create-standby-owner.test.ts @@ -0,0 +1,63 @@ +import { expect, it, vi } from 'vitest' +import { createWorktreeStandbyOwner } from './worktree-create-standby-owner' + +function deferred() { + let resolve!: (release: () => void) => void + const promise = new Promise<() => void>((done) => { + resolve = done + }) + return { promise, resolve } +} + +it('serializes disk work and skips targets replaced while queued', async () => { + const first = createWorktreeStandbyOwner() + const second = createWorktreeStandbyOwner() + const pending = deferred() + const releaseFirst = vi.fn() + const releaseLast = vi.fn() + const start = vi.fn(() => pending.promise) + const skipped = vi.fn(async () => () => {}) + const last = vi.fn(async () => releaseLast) + const active = first.set(start) + await Promise.resolve() + expect(start).toHaveBeenCalledOnce() + const obsolete = second.set(skipped) + const latest = second.set(last) + await Promise.resolve() + expect(last).not.toHaveBeenCalled() + pending.resolve(releaseFirst) + await Promise.all([active, obsolete, latest]) + expect(skipped).not.toHaveBeenCalled() + expect(last).toHaveBeenCalledOnce() + first.close() + second.close() + expect(releaseFirst).toHaveBeenCalledOnce() + expect(releaseLast).toHaveBeenCalledOnce() +}) + +it('releases a preparation that finishes after its owner is closed', async () => { + const owner = createWorktreeStandbyOwner() + const pending = deferred() + const release = vi.fn() + const work = owner.set(() => pending.promise) + await Promise.resolve() + owner.close() + pending.resolve(release) + await work + expect(release).toHaveBeenCalledOnce() + const prepare = vi.fn() + await owner.set(prepare) + expect(prepare).not.toHaveBeenCalled() +}) + +it('releases on hide and does not poison later work after a failed preparation', async () => { + const owner = createWorktreeStandbyOwner() + await owner.set(async () => { + throw new Error('disk unavailable') + }) + const release = vi.fn() + await owner.set(async () => release) + await owner.set() + owner.close() + expect(release).toHaveBeenCalledOnce() +}) diff --git a/src/main/worktree-create-standby-owner.ts b/src/main/worktree-create-standby-owner.ts new file mode 100644 index 00000000000..2a6452afdc8 --- /dev/null +++ b/src/main/worktree-create-standby-owner.ts @@ -0,0 +1,42 @@ +type ReleaseStandby = () => void +let standbyQueue: Promise = Promise.resolve() + +export function createWorktreeStandbyOwner() { + let revision = 0 + let closed = false + let release: ReleaseStandby | undefined + + return { + set(prepare?: () => Promise): Promise { + const current = ++revision + release?.() + release = undefined + if (closed || !prepare) { + return Promise.resolve() + } + const job = standbyQueue.then(async () => { + if (closed || revision !== current) { + return + } + try { + const prepared = await prepare() + if (closed || revision !== current) { + prepared() + } else { + release = prepared + } + } catch { + // Speculative failure leaves ordinary Create responsible for recovery. + } + }) + standbyQueue = job + return job + }, + close(): void { + closed = true + revision++ + release?.() + release = undefined + } + } +} diff --git a/src/main/worktree-create-standby.test.ts b/src/main/worktree-create-standby.test.ts index 809cf08585d..9941b379b62 100644 --- a/src/main/worktree-create-standby.test.ts +++ b/src/main/worktree-create-standby.test.ts @@ -30,7 +30,8 @@ beforeEach(() => { describe('checkout-only standby', () => { it.each([ { ...repo, kind: 'folder' as const }, - { ...repo, connectionId: 'ssh' } + { ...repo, connectionId: 'ssh' }, + { ...repo, executionHostId: 'runtime:other' as const } ])('does no local work for an ineligible repo', async (target) => { await prepareWorktreeCreateStandby(store, target) expect(mocks.options).not.toHaveBeenCalled() diff --git a/src/main/worktree-create-standby.ts b/src/main/worktree-create-standby.ts index dfa760092c4..e78f3f4c4d6 100644 --- a/src/main/worktree-create-standby.ts +++ b/src/main/worktree-create-standby.ts @@ -1,18 +1,22 @@ import type { Store } from './persistence' import type { Repo } from '../shared/repo-types' import { isFolderRepo } from '../shared/repo-kind' +import { getRepoExecutionHostId } from '../shared/execution-host' import { getBaseRefDefault } from './git/repo' import { hasLocalWorktreeBaseRef } from './git/worktree-base-ref-probe' import { getLocalProjectWorktreeGitOptions } from './project-runtime-git-options' import { resolveWorktreeCreateBase } from './worktree-create-base' -import { prepareWorktreeCreateForRepo } from './worktree-create-preparation' +import { + prepareWorktreeCreateForRepo, + retainWorktreeCreateForRepo +} from './worktree-create-preparation' -export async function prepareWorktreeCreateStandby( +async function resolveStandbyBase( store: Store, repo: Repo, requestedBaseBranch?: string -): Promise { - if (repo.connectionId || isFolderRepo(repo)) { +): Promise { + if (getRepoExecutionHostId(repo) !== 'local' || isFolderRepo(repo)) { return } const options = getLocalProjectWorktreeGitOptions(store, repo) @@ -26,5 +30,25 @@ export async function prepareWorktreeCreateStandby( if (!baseBranch || !(await hasLocalWorktreeBaseRef(repo.path, baseBranch, options))) { return } - await prepareWorktreeCreateForRepo(store, repo, baseBranch) + return baseBranch +} + +export async function prepareWorktreeCreateStandby( + store: Store, + repo: Repo, + requestedBaseBranch?: string +): Promise { + const base = await resolveStandbyBase(store, repo, requestedBaseBranch) + if (base) { + await prepareWorktreeCreateForRepo(store, repo, base) + } +} + +export async function retainWorktreeCreateStandby( + store: Store, + repo: Repo, + requestedBaseBranch?: string +): Promise<() => void> { + const base = await resolveStandbyBase(store, repo, requestedBaseBranch) + return base ? retainWorktreeCreateForRepo(store, repo, base) : () => {} } diff --git a/src/main/worktree-preparation-hold.test.ts b/src/main/worktree-preparation-hold.test.ts new file mode 100644 index 00000000000..af2eea7b5fd --- /dev/null +++ b/src/main/worktree-preparation-hold.test.ts @@ -0,0 +1,86 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +const mocks = vi.hoisted(() => ({ prepare: vi.fn(), discard: vi.fn() })) +vi.mock('node:fs/promises', () => ({ mkdir: vi.fn(async () => {}) })) +vi.mock('./git/worktree-create-preparation', () => ({ + prepareWorktreeCreateCheckout: mocks.prepare +})) +vi.mock('./worktree-create-preparation-stale-cleanup', () => ({ + cleanupStalePreparations: vi.fn(async () => {}), + hasPendingStalePreparationCleanup: () => false, + resetStalePreparationCleanupForTests: vi.fn(async () => {}) +})) +vi.mock('./worktree-preparation-discard-retry', () => ({ + discardPreparationWithRetry: mocks.discard, + trackPreparationDiscard: vi.fn(), + resetPendingPreparationDiscardsForTests: vi.fn(async () => {}) +})) +import { + startPreparation, + listPreparations, + holdPreparation, + takePreparation, + _resetPreparationPoolForTests, + WORKTREE_CREATE_PREPARATION_TTL_MS as ttl, + WORKTREE_CREATE_PREPARATION_LIMIT as limit +} from './worktree-create-preparation-pool' + +const args = { + repoPath: '/repo', + workspaceRoot: '/workspace', + baseBranch: 'main', + canonicalBase: 'refs/heads/main', + options: {} +} +beforeEach(() => { + vi.useFakeTimers() + vi.resetAllMocks() + mocks.prepare.mockResolvedValue(undefined) + mocks.discard.mockResolvedValue(undefined) +}) +afterEach(async () => { + await _resetPreparationPoolForTests() + vi.useRealTimers() +}) + +it('keeps one checkout through repeated TTLs without rebuilding and expires after final release', async () => { + await startPreparation(args) + const entry = listPreparations()[0] + const releaseFirst = holdPreparation(entry) + const releaseSecond = holdPreparation(entry) + await vi.advanceTimersByTimeAsync(ttl * 3) + expect(listPreparations()).toEqual([entry]) + expect(mocks.prepare).toHaveBeenCalledOnce() + expect(mocks.discard).not.toHaveBeenCalled() + releaseFirst() + releaseFirst() + await vi.advanceTimersByTimeAsync(ttl * 2) + expect(listPreparations()).toEqual([entry]) + releaseSecond() + await vi.advanceTimersByTimeAsync(ttl - 1) + expect(mocks.discard).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(1) + expect(listPreparations()).toEqual([]) + expect(mocks.discard).toHaveBeenCalledOnce() +}) + +it('never deletes a held checkout after Create has claimed it', async () => { + await startPreparation(args) + const entry = listPreparations()[0] + const release = holdPreparation(entry) + takePreparation(entry) + release() + await vi.advanceTimersByTimeAsync(ttl * 2) + expect(mocks.discard).not.toHaveBeenCalled() +}) + +it('preserves the pool limit even if every entry is held', async () => { + const releases: (() => void)[] = [] + for (let i = 0; i <= limit; i++) { + await startPreparation({ ...args, canonicalBase: `refs/heads/branch-${i}` }) + releases.push(holdPreparation(listPreparations().at(-1)!)) + } + expect(listPreparations()).toHaveLength(limit) + await vi.advanceTimersByTimeAsync(0) + expect(mocks.discard).toHaveBeenCalledOnce() + releases.forEach((release) => release()) +}) diff --git a/src/preload/api/worktree-api.ts b/src/preload/api/worktree-api.ts index 1a7db809f60..29b29f33db9 100644 --- a/src/preload/api/worktree-api.ts +++ b/src/preload/api/worktree-api.ts @@ -69,6 +69,7 @@ export type WorktreeApi = { onCreateProgress: ( callback: (data: { creationId?: string; phase: 'fetching' | 'creating' }) => void ) => () => void + setCreateStandby?: (args: { repoId: string | null; baseBranch?: string }) => Promise prepareCreateCheckout?: (args: { repoId: string; baseBranch?: string }) => Promise prefetchCreateBase: (args: { repoId: string; baseBranch?: string }) => Promise resolvePrBase: (args: { diff --git a/src/preload/api/worktrees-bridge.ts b/src/preload/api/worktrees-bridge.ts index 6b15e9f0e71..879633b73db 100644 --- a/src/preload/api/worktrees-bridge.ts +++ b/src/preload/api/worktrees-bridge.ts @@ -41,6 +41,8 @@ export const worktreesApi = { return () => ipcRenderer.removeListener('createWorktree:progress', listener) }, + setCreateStandby: (args) => ipcRenderer.invoke('worktrees:setCreateStandby', args), + prepareCreateCheckout: (args) => ipcRenderer.invoke('worktrees:prepareCreateCheckout', args), prefetchCreateBase: (args) => ipcRenderer.invoke('worktrees:prefetchCreateBase', args), diff --git a/src/renderer/src/app-shell/AppBackgroundServices.tsx b/src/renderer/src/app-shell/AppBackgroundServices.tsx index 014b3f69bca..85d551cba55 100644 --- a/src/renderer/src/app-shell/AppBackgroundServices.tsx +++ b/src/renderer/src/app-shell/AppBackgroundServices.tsx @@ -6,6 +6,7 @@ import RetainedAgentsSyncGate from '../components/dashboard/RetainedAgentsSyncGa import { WorkspacePortScanner } from '../components/ports/WorkspacePortScanner' import { MacosTccPromptNoticeHost } from '../hooks/MacosTccPromptNoticeHost' import { useAppStore } from '../store' +import { WorktreeCreateStandbyGate } from './WorktreeCreateStandbyGate' import { StructuredAgentSessionStatusBridge } from '../components/native-chat/StructuredAgentSessionStatusBridge' const DashboardPopoutBridge = lazy(() => import('../components/dashboard/DashboardPopoutBridge')) @@ -23,6 +24,7 @@ export function AppBackgroundServices(): React.JSX.Element { return ( <> + {/* Why: plugin language-pack discovery must not re-render the App shell. */} {/* Why: leaf-mounted retention sync keeps agent-status subscriptions out of the App render tree. */} diff --git a/src/renderer/src/app-shell/WorktreeCreateStandbyGate.test.tsx b/src/renderer/src/app-shell/WorktreeCreateStandbyGate.test.tsx new file mode 100644 index 00000000000..f191853c948 --- /dev/null +++ b/src/renderer/src/app-shell/WorktreeCreateStandbyGate.test.tsx @@ -0,0 +1,239 @@ +// @vitest-environment happy-dom +import { act, cleanup, render } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { create } from 'zustand' +import { getDefaultSettings } from '../../../shared/constants' +import type { AppState } from '../store/types' +import type { Repo } from '../../../shared/repo-types' + +const mocks = vi.hoisted(() => ({ web: false, setStandby: vi.fn() })) +vi.mock('../store', () => ({ useAppStore: create(() => ({}) as AppState) })) +vi.mock('../lib/web-client-location', () => ({ isWebClientLocation: () => mocks.web })) +import { useAppStore } from '../store' +import { WorktreeCreateStandbyGate } from './WorktreeCreateStandbyGate' +import { resetInputQuietSchedulerForTest } from '../lib/input-quiet-scheduler' + +const repo = { id: 'one', path: '/one' } as Repo +const second = { id: 'two', path: '/two' } as Repo +function visible(value: boolean): void { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + value: value ? 'visible' : 'hidden' + }) + act(() => document.dispatchEvent(new Event('visibilitychange'))) +} +async function settle(): Promise { + await act(async () => { + await vi.advanceTimersByTimeAsync(3_100) + }) +} +beforeEach(() => { + vi.useFakeTimers() + resetInputQuietSchedulerForTest() + mocks.web = false + mocks.setStandby.mockReset().mockResolvedValue(undefined) + Object.defineProperty(window, 'api', { + configurable: true, + value: { worktrees: { setCreateStandby: mocks.setStandby } } + }) + visible(true) + useAppStore.setState({ + repos: [repo, second], + projects: [], + activeRepoId: repo.id, + settings: null, + newWorkspaceDraft: null, + workspaceHostScope: 'all' + }) +}) +afterEach(() => { + cleanup() + vi.useRealTimers() +}) + +it('prepares only the latest target after rapid switches', async () => { + render() + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000) + }) + act(() => useAppStore.setState({ activeRepoId: second.id })) + await settle() + expect(mocks.setStandby.mock.calls).toEqual([[{ repoId: second.id }]]) +}) + +it('cancels pending work while hidden and releases a prepared target on unmount', async () => { + const view = render() + visible(false) + await settle() + expect(mocks.setStandby).not.toHaveBeenCalled() + visible(true) + await settle() + expect(mocks.setStandby).toHaveBeenLastCalledWith({ repoId: repo.id }) + view.unmount() + expect(mocks.setStandby).toHaveBeenLastCalledWith({ repoId: null }) +}) + +it('releases when hidden and does not refill from unrelated state updates', async () => { + render() + await settle() + act(() => useAppStore.setState({ repos: [{ ...repo }, { ...second }] })) + await settle() + expect(mocks.setStandby).toHaveBeenCalledTimes(1) + visible(false) + expect(mocks.setStandby).toHaveBeenLastCalledWith({ repoId: null }) +}) + +it.each([ + { ...repo, connectionId: 'ssh' }, + { ...repo, kind: 'folder' }, + { ...repo, executionHostId: 'runtime:remote' }, + { ...repo, executionHostId: 'ssh:remote' } +])('does not prepare an ineligible owner or folder', async (target) => { + useAppStore.setState({ repos: [target as Repo] }) + render() + await settle() + expect(mocks.setStandby).not.toHaveBeenCalled() +}) + +it('honors explicit local ownership while another runtime is focused', async () => { + useAppStore.setState({ + repos: [{ ...repo, executionHostId: 'local' }], + settings: { activeRuntimeEnvironmentId: 'remote' } as AppState['settings'] + }) + render() + await settle() + expect(mocks.setStandby).toHaveBeenCalledWith({ repoId: repo.id }) +}) + +it('does not route an unowned legacy repo through a focused runtime', async () => { + useAppStore.setState({ + settings: { activeRuntimeEnvironmentId: 'remote' } as AppState['settings'] + }) + render() + await settle() + expect(mocks.setStandby).not.toHaveBeenCalled() +}) + +it('matches the persisted composer draft repo and base', async () => { + useAppStore.setState({ + newWorkspaceDraft: { repoId: second.id, baseBranch: 'release' } as AppState['newWorkspaceDraft'] + }) + render() + await settle() + expect(mocks.setStandby).toHaveBeenCalledWith({ repoId: second.id, baseBranch: 'release' }) +}) + +it.each(['web', 'missing', 'disabled'])('does nothing when unavailable: %s', async (reason) => { + mocks.web = reason === 'web' + if (reason === 'missing') { + delete window.api.worktrees.setCreateStandby + } + render() + await settle() + expect(mocks.setStandby).not.toHaveBeenCalled() +}) + +it('waits for input quiet before preparing', async () => { + render() + await act(async () => { + await vi.advanceTimersByTimeAsync(1_500) + }) + act(() => window.dispatchEvent(new KeyboardEvent('keydown', { key: 'x' }))) + await act(async () => { + await vi.advanceTimersByTimeAsync(1_000) + }) + expect(mocks.setStandby).not.toHaveBeenCalled() + await settle() + expect(mocks.setStandby).toHaveBeenCalledExactlyOnceWith({ repoId: repo.id }) +}) + +it('releases the old target before preparing the next', async () => { + render() + await settle() + act(() => useAppStore.setState({ activeRepoId: second.id })) + expect(mocks.setStandby).toHaveBeenLastCalledWith({ repoId: null }) + await settle() + expect(mocks.setStandby.mock.calls).toEqual([ + [{ repoId: repo.id }], + [{ repoId: null }], + [{ repoId: second.id }] + ]) +}) + +it('cancels a scheduled preparation on unmount', async () => { + const view = render() + view.unmount() + await settle() + expect(mocks.setStandby).not.toHaveBeenCalled() +}) + +it.each(['workspaceDir', 'nestWorkspaces', 'localWindowsRuntimeDefault'] as const)( + 'releases and re-arms when placement/runtime setting changes: %s', + async (key) => { + const settings = getDefaultSettings('/tmp') + useAppStore.setState({ settings }) + render() + await settle() + act(() => + useAppStore.setState({ + settings: { + ...settings, + [key]: + key === 'workspaceDir' + ? '/new-root' + : key === 'nestWorkspaces' + ? !settings.nestWorkspaces + : { kind: 'wsl', distro: 'Ubuntu' } + } as AppState['settings'] + }) + ) + expect(mocks.setStandby).toHaveBeenLastCalledWith({ repoId: null }) + await settle() + expect(mocks.setStandby).toHaveBeenLastCalledWith({ repoId: repo.id }) + } +) + +it('re-arms for the selected project runtime and repo placement, ignoring another project', async () => { + const project = { id: 'project', sourceRepoIds: [repo.id] } as AppState['projects'][number] + useAppStore.setState({ projects: [project] }) + render() + await settle() + act(() => + useAppStore.setState({ + projects: [ + project, + { + id: 'other', + sourceRepoIds: [second.id], + localWindowsRuntimePreference: { kind: 'windows-host' } + } as AppState['projects'][number] + ] + }) + ) + await settle() + expect(mocks.setStandby).toHaveBeenCalledTimes(1) + act(() => + useAppStore.setState({ + projects: [{ ...project, localWindowsRuntimePreference: { kind: 'windows-host' } }] + }) + ) + expect(mocks.setStandby).toHaveBeenLastCalledWith({ repoId: null }) + await settle() + act(() => useAppStore.setState({ repos: [{ ...repo, worktreeBasePath: '/new-root' }] })) + expect(mocks.setStandby).toHaveBeenLastCalledWith({ repoId: null }) + await settle() + expect(mocks.setStandby).toHaveBeenCalledTimes(5) +}) + +it('ignores unrelated settings and terminal state churn', async () => { + render() + await settle() + act(() => + useAppStore.setState({ + settings: { theme: 'dark' } as AppState['settings'], + activeWorktreeId: 'other-workspace' + }) + ) + await settle() + expect(mocks.setStandby).toHaveBeenCalledTimes(1) +}) diff --git a/src/renderer/src/app-shell/WorktreeCreateStandbyGate.tsx b/src/renderer/src/app-shell/WorktreeCreateStandbyGate.tsx new file mode 100644 index 00000000000..2b69cd4349d --- /dev/null +++ b/src/renderer/src/app-shell/WorktreeCreateStandbyGate.tsx @@ -0,0 +1,122 @@ +import { useEffect, useMemo } from 'react' +import { useShallow } from 'zustand/react/shallow' +import { useAppStore } from '../store' +import type { AppState } from '../store/types' +import { + getComposerEligibleRepos, + resolveComposerActiveRepoId, + resolveComposerGitRepoId +} from '../lib/new-workspace-composer-repo' +import { settingsForKnownRepoOwner } from '../store/slices/worktrees/listing/worktree-owner-settings' +import { getActiveRuntimeTarget } from '../runtime/runtime-rpc-client' +import { isWebClientLocation } from '../lib/web-client-location' +import { scheduleAfterInputQuiet } from '../lib/input-quiet-scheduler' +import { isWindowVisible } from '../lib/window-visibility-interval' +import { getRepoExecutionHostId } from '../../../shared/execution-host' + +type StandbyState = Pick & { + draftRepoId: string | null | undefined + draftBaseBranch: string | undefined + activeRuntimeEnvironmentId: string | null | undefined + workspaceDir: string | undefined + nestWorkspaces: boolean | undefined + localWindowsRuntimeDefault: + | NonNullable['localWindowsRuntimeDefault'] + | undefined +} + +function standbyIdentity(state: StandbyState): string | null { + const eligibleRepos = getComposerEligibleRepos(state.repos) + const repoId = resolveComposerGitRepoId({ + eligibleRepos, + activeRepoId: resolveComposerActiveRepoId(state.repos, eligibleRepos, state.activeRepoId), + draftRepoId: state.draftRepoId, + focusedHostScope: state.workspaceHostScope + }) + const repo = eligibleRepos.find((candidate) => candidate.id === repoId) + if ( + !repo || + repo.connectionId || + getRepoExecutionHostId(repo) !== 'local' || + getActiveRuntimeTarget( + settingsForKnownRepoOwner( + { activeRuntimeEnvironmentId: state.activeRuntimeEnvironmentId } as AppState['settings'], + repo + ) + ).kind !== 'local' + ) { + return null + } + const project = state.projects.find((entry) => entry.sourceRepoIds.includes(repo.id)) + return JSON.stringify({ + repoId: repo.id, + path: repo.path, + worktreeBaseRef: repo.worktreeBaseRef, + worktreeBasePath: repo.worktreeBasePath, + workspaceDir: state.workspaceDir, + nestWorkspaces: state.nestWorkspaces, + projectId: project?.id, + projectRuntime: project?.localWindowsRuntimePreference, + localWindowsRuntimeDefault: state.localWindowsRuntimeDefault, + baseBranch: state.draftRepoId === repo.id ? state.draftBaseBranch : undefined + }) +} + +export function WorktreeCreateStandbyGate({ enabled }: { enabled: boolean }): null { + // After Create, the pool's burst policy owns replenishment; unrelated workspace changes stay quiet. + const inputs = useAppStore( + useShallow((state): StandbyState => ({ + repos: state.repos, + projects: state.projects, + activeRepoId: state.activeRepoId, + workspaceHostScope: state.workspaceHostScope, + draftRepoId: state.newWorkspaceDraft?.repoId, + draftBaseBranch: state.newWorkspaceDraft?.baseBranch, + activeRuntimeEnvironmentId: state.settings?.activeRuntimeEnvironmentId, + workspaceDir: state.settings?.workspaceDir, + nestWorkspaces: state.settings?.nestWorkspaces, + localWindowsRuntimeDefault: state.settings?.localWindowsRuntimeDefault + })) + ) + const identity = useMemo(() => standbyIdentity(inputs), [inputs]) + useEffect(() => { + const setStandby = window.api?.worktrees?.setCreateStandby + if (!enabled || !identity || isWebClientLocation() || !setStandby) { + return + } + const { repoId, baseBranch } = JSON.parse(identity) as { + repoId: string + baseBranch?: string + } + let cancelScheduled: (() => void) | undefined + let requested = false + const release = (): void => { + cancelScheduled?.() + cancelScheduled = undefined + if (requested) { + requested = false + void setStandby({ repoId: null }).catch(() => {}) + } + } + const reconcile = (): void => { + release() + if (!isWindowVisible()) { + return + } + cancelScheduled = scheduleAfterInputQuiet( + () => { + requested = true + void setStandby({ repoId, ...(baseBranch ? { baseBranch } : {}) }).catch(() => {}) + }, + { delayMs: 2_000, quietMs: 2_000, idleTimeoutMs: 1_000 } + ) + } + reconcile() + document.addEventListener('visibilitychange', reconcile) + return () => { + document.removeEventListener('visibilitychange', reconcile) + release() + } + }, [enabled, identity]) + return null +}