diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 995698966a7..0406a361f96 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -305,6 +305,7 @@ inline src/renderer/src/store/slices/ui.test.ts inline src/renderer/src/store/slices/ui.ts inline src/renderer/src/store/slices/workspace-cleanup.ts inline src/renderer/src/store/slices/worktrees.test.ts +inline src/renderer/src/store/slices/worktrees.ts inline src/renderer/src/web/web-preload-api.test.ts inline src/renderer/src/web/web-preload-api.ts inline src/renderer/src/web/web-runtime-client.ts diff --git a/src/main/browser/browser-cookie-import-undecryptable.test.ts b/src/main/browser/browser-cookie-import-undecryptable.test.ts new file mode 100644 index 00000000000..8926c1250ae --- /dev/null +++ b/src/main/browser/browser-cookie-import-undecryptable.test.ts @@ -0,0 +1,184 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as NodeFs from 'node:fs' + +const { + appGetPathMock, + execFileSyncMock, + sessionFromPartitionMock, + dialogShowOpenDialogMock, + setPendingCookieImportMock, + clearPendingCookieImportMock +} = vi.hoisted(() => ({ + appGetPathMock: vi.fn(), + execFileSyncMock: vi.fn(), + sessionFromPartitionMock: vi.fn(), + dialogShowOpenDialogMock: vi.fn(), + setPendingCookieImportMock: vi.fn(), + clearPendingCookieImportMock: vi.fn() +})) + +vi.mock('./browser-session-registry', () => ({ + browserSessionRegistry: { + setPendingCookieImport: setPendingCookieImportMock, + clearPendingCookieImport: clearPendingCookieImportMock + } +})) +vi.mock('node:child_process', () => ({ execFileSync: execFileSyncMock })) +vi.mock('node:fs', async (importOriginal) => ({ ...(await importOriginal()) })) +vi.mock('electron', () => ({ + app: { getPath: appGetPathMock }, + BrowserWindow: { fromWebContents: vi.fn() }, + dialog: { showOpenDialog: dialogShowOpenDialogMock }, + session: { fromPartition: sessionFromPartitionMock } +})) +vi.mock('./browser-cookie-clear-store', () => ({ + openCookieClearStore: (targetSession: { + cookies: { + get: (filter: object) => Promise + remove: (url: string, name: string) => Promise + } + }) => ({ + get: (filter: object) => targetSession.cookies.get(filter), + remove: (url: string, name: string) => targetSession.cookies.remove(url, name), + snapshotClearIdentities: async (items: { cookie: Record; url: string }[]) => + items.map(({ cookie, url }) => ({ url, ...cookie })), + restoreClearIdentities: async () => undefined, + dispose: () => undefined + }) +})) + +import { importCookiesFromBrowser, type DetectedBrowser } from './browser-cookie-import' +import { createChromiumCookieTestDatabase } from './browser-cookie-import-test-database' +import { mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { createCipheriv, pbkdf2Sync } from 'node:crypto' + +// Why: Linux derives its cookie key with a single PBKDF2 round, unlike the 1003 macOS uses, so +// the shared macOS helper cannot produce a row this code path will decrypt. +function encryptLinuxChromiumCookie(value: string, password: string, prefix: string): Buffer { + const key = pbkdf2Sync(password, 'saltysalt', 1, 16, 'sha1') + const cipher = createCipheriv('aes-128-cbc', key, Buffer.alloc(16, ' ')) + return Buffer.concat([ + Buffer.from(prefix), + cipher.update(Buffer.from(value, 'latin1')), + cipher.final() + ]) +} + +function chromeBrowser(cookiesPath: string): DetectedBrowser { + return { + family: 'chrome', + label: 'Google Chrome', + cookiesPath, + keychainService: 'Chrome Safe Storage', + keychainAccount: 'Chrome', + profiles: [{ name: 'Default', directory: 'Default' }], + selectedProfile: 'Default' + } +} + +describe('importCookiesFromBrowser — undecryptable cookies', () => { + let tmpDir: string + let cookiesSetMock: ReturnType + let platformSpy: ReturnType + + beforeEach(() => { + tmpDir = mkdtempSync(join(tmpdir(), 'orca-linux-keyring-test-')) + cookiesSetMock = vi.fn().mockResolvedValue(undefined) + appGetPathMock.mockReturnValue(join(tmpDir, 'userData')) + sessionFromPartitionMock.mockReturnValue({ + cookies: { + get: vi.fn().mockResolvedValue([]), + set: cookiesSetMock, + remove: vi.fn().mockResolvedValue(undefined), + flushStore: vi.fn().mockResolvedValue(undefined) + }, + clearData: vi.fn().mockResolvedValue(undefined), + setUserAgent: vi.fn() + }) + platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('linux') + // Why: both secret-tool lookups failing is exactly the keyring-unavailable case. + execFileSyncMock.mockImplementation(() => { + throw new Error('secret-tool: no such service') + }) + }) + + afterEach(() => { + platformSpy.mockRestore() + vi.clearAllMocks() + rmSync(tmpDir, { recursive: true, force: true }) + }) + + function seedProfile(encryptedValue: Buffer): string { + const sourceCookiesPath = join(tmpDir, 'Chrome', 'Default', 'Network', 'Cookies') + createChromiumCookieTestDatabase(sourceCookiesPath, [ + { name: 'sid', value: '', encryptedValue } + ]).close() + createChromiumCookieTestDatabase( + join(tmpDir, 'userData', 'Partitions', 'test', 'Network', 'Cookies'), + [] + ).close() + return sourceCookiesPath + } + + it('reports the v11 cookies it could not decrypt instead of an empty success', async () => { + const sourceCookiesPath = seedProfile( + encryptLinuxChromiumCookie('secret', 'real-keyring-password', 'v11') + ) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.summary.importedCookies).toBe(0) + // Why: before the fix a key derived from the empty password decrypted nothing, and this + // branch returned with no warning at all — a green "Imported 0 cookies" toast. + expect(result.summary.warning).toEqual({ + code: 'cookies-undecryptable', + failedCookies: 1, + reason: 'linux-keyring-unavailable' + }) + }) + + it('reports Windows app-bound (v20) cookies rather than an empty success', async () => { + // Why: Chrome/Edge 140+ prefix every row with v20, which only the writing browser can unwrap. + // The version gate is a format check, so v20 reaches AES and fails like corruption. + const sourceCookiesPath = seedProfile( + Buffer.concat([Buffer.from('v20'), Buffer.from([1, 2, 3, 4, 5, 6, 7, 8])]) + ) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + expect(result.summary.importedCookies).toBe(0) + expect(result.summary.warning).toEqual({ + code: 'cookies-undecryptable', + failedCookies: 1, + reason: 'app-bound-encryption' + }) + }) + + it('still imports v10 cookies, which is what a keyring-less profile actually holds', async () => { + const sourceCookiesPath = seedProfile(encryptLinuxChromiumCookie('v10-value', 'peanuts', 'v10')) + + const result = await importCookiesFromBrowser(chromeBrowser(sourceCookiesPath), 'persist:test') + + expect(result.ok).toBe(true) + if (!result.ok) { + return + } + // Why: Chrome falls back to the "peanuts" key precisely when no keyring exists, so failing + // the whole import would break the profiles that still work today. + expect(result.summary.importedCookies).toBe(1) + expect(result.summary.warning).toBeUndefined() + expect(cookiesSetMock).toHaveBeenCalledWith( + expect.objectContaining({ name: 'sid', value: 'v10-value' }) + ) + }) +}) diff --git a/src/main/browser/browser-cookie-import.ts b/src/main/browser/browser-cookie-import.ts index 2e2f50dbdd1..fbc21ebe48d 100644 --- a/src/main/browser/browser-cookie-import.ts +++ b/src/main/browser/browser-cookie-import.ts @@ -828,6 +828,8 @@ type EncryptionKeyResult = { mode: 'aes-128-cbc' | 'aes-256-gcm' // Why: Linux v10 cookies use "peanuts" and v11 the keyring password; both keys are needed to decrypt the full set. fallbackKey?: Buffer + // Why: no keyring means no v11 key at all, so v11 rows are undecryptable rather than merely wrong. + keyringUnavailable?: boolean } export type ChromiumCookieColumnInfo = { @@ -1003,10 +1005,17 @@ function getLinuxEncryptionKey( timeout: 5_000 }).trim() } catch { - diag(' Linux keyring unavailable — v11 cookies may fail to decrypt') + diag(' Linux keyring unavailable — v11 cookies cannot be decrypted') } } + // Why: deriving a v11 key from an empty password yields a key that decrypts nothing, so every + // v11 row failed indistinguishably from a corrupt one. Hand back only the v10 key instead and + // let the caller report the keyring as the cause. + if (!keyringPassword) { + return { key: v10Key, mode: 'aes-128-cbc', keyringUnavailable: true } + } + const v11Key = pbkdf2Sync(keyringPassword, PBKDF2_SALT, 1, PBKDF2_KEY_LENGTH, 'sha1') return { key: v11Key, mode: 'aes-128-cbc', fallbackKey: v10Key } } @@ -1084,6 +1093,41 @@ function stripHmac(buf: Buffer): Buffer { return hasHmacPrefix(buf) ? buf.subarray(CHROMIUM_COOKIE_HMAC_LEN) : buf } +// Why: the version prefix is the only thing that survives a failed decrypt, so read it once and +// share it between the decrypt path and the failure attribution. +function cookieEncryptionVersion(encryptedBuffer: Buffer): string | null { + if (encryptedBuffer.length < 3) { + return null + } + const version = encryptedBuffer.subarray(0, 3).toString('utf-8') + return /^v\d\d$/.test(version) ? version : null +} + +// Why: Chrome/Edge 140+ on Windows prefix every cookie with `v20` (app-bound encryption), which +// only the writing browser can unwrap. The prefix gate below is a FORMAT check, so v20 passes it +// and fails inside AES with the v10 key — indistinguishable from corruption without this. +export function isAppBoundEncryptedCookie(encryptedBuffer: Buffer): boolean { + return cookieEncryptionVersion(encryptedBuffer) === 'v20' +} + +// Why: every caller must report the same cause, and the most specific one wins. +function buildUndecryptableWarning(counts: { + decryptFailed: number + appBoundFailed: number + keyringUnavailableFailed: number +}): BrowserCookieImportSummary['warning'] { + if (counts.decryptFailed === 0) { + return undefined + } + const reason = + counts.appBoundFailed > 0 + ? 'app-bound-encryption' + : counts.keyringUnavailableFailed > 0 + ? 'linux-keyring-unavailable' + : 'unknown' + return { code: 'cookies-undecryptable', failedCookies: counts.decryptFailed, reason } +} + function decryptCookieValueRaw( encryptedBuffer: Buffer, keyResult: EncryptionKeyResult @@ -1630,6 +1674,9 @@ export async function importCookiesFromBrowser( let imported = 0 let skipped = 0 + let decryptFailed = 0 + let appBoundFailed = 0 + let keyringUnavailableFailed = 0 let integritySkipped = 0 let nonTransplantableSkipped = 0 let memoryLoaded = 0 @@ -1699,6 +1746,15 @@ export async function importCookiesFromBrowser( if (encBuf && encBuf.length > 0) { const raw = sourceKey ? decryptCookieValueRaw(encBuf, sourceKey) : null if (!raw) { + // Why: once decrypt returns null every failure looks identical, so attribute the cause + // here while the version prefix is still in hand. Without this an undecryptable profile + // is indistinguishable from an empty one and reports success. + decryptFailed++ + if (isAppBoundEncryptedCookie(encBuf)) { + appBoundFailed++ + } else if (sourceKey?.keyringUnavailable && cookieEncryptionVersion(encBuf) === 'v11') { + keyringUnavailableFailed++ + } skipped++ continue } @@ -1765,7 +1821,14 @@ export async function importCookiesFromBrowser( ) const googleCookiesSkipped = integritySkipped + nonTransplantableSkipped + const undecryptableWarning = buildUndecryptableWarning({ + decryptFailed, + appBoundFailed, + keyringUnavailableFailed + }) + if (decryptedCookies.length === 0) { + const zeroPathWarning = undecryptableWarning closeStagingDb() discardStagingFile() return { @@ -1776,7 +1839,10 @@ export async function importCookiesFromBrowser( importedCookies: 0, skippedCookies: skipped + integritySkipped + nonTransplantableSkipped, ...(googleCookiesSkipped > 0 ? { googleCookiesSkipped } : {}), - domains: [] + domains: [], + // Why: a profile whose rows cannot be decrypted returns here, and without this it is + // reported as a successful empty import. + ...(zeroPathWarning ? { warning: zeroPathWarning } : {}) } } } @@ -1874,6 +1940,13 @@ export async function importCookiesFromBrowser( // Google-bound integrity cookies are already excluded by // isGoogleSourceBoundCookie, which is what actually prevents CookieMismatch. + // Why: a partial import still drops every undecryptable row, so silence here would report it + // as an unqualified success. The restart-fallback warning describes a lossier outcome and + // keeps precedence. + if (!warning && undecryptableWarning) { + warning = undecryptableWarning + } + const summary: BrowserCookieImportSummary = { totalCookies: sourceRows.length, importedCookies: imported, diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index ca05c8f57d4..a078563c176 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -769,7 +769,10 @@ "toast": { "restartFallbackUnavailableNone": "None of the {{value0}} cookies could be loaded, and the restart fallback was unavailable. The previous cookies for this profile were replaced. Try the import again.", "restartFallbackUnavailablePartial": "Imported {{value0}} of {{value1}} cookies. The rest could not be loaded, and the restart fallback was unavailable. Try the import again.", - "googleCookiesSkipped": "Google cookies were not imported. Open a browser in Orca on {{value0}} with this profile, then sign into Google." + "googleCookiesSkipped": "Google cookies were not imported. Open a browser in Orca on {{value0}} with this profile, then sign into Google.", + "undecryptableAppBound": "{{value0}} cookies could not be decrypted because this browser encrypts them so only it can read them (app-bound encryption). Importing from this browser is not supported on this version.", + "undecryptableKeyring": "{{value0}} cookies could not be decrypted because the system keyring was unavailable. Unlock your login keyring (or install a Secret Service provider such as gnome-keyring) and import again.", + "undecryptableUnknown": "{{value0}} cookies could not be decrypted and were skipped. Close the source browser completely and try the import again." } } } diff --git a/src/renderer/src/lib/browser-cookie-import-toast.ts b/src/renderer/src/lib/browser-cookie-import-toast.ts index 9e18e3c96c8..81bcc3d0698 100644 --- a/src/renderer/src/lib/browser-cookie-import-toast.ts +++ b/src/renderer/src/lib/browser-cookie-import-toast.ts @@ -21,6 +21,27 @@ function formatCookieImportWarning(warning: CookieImportWarning): string { value1: warning.loadedCookies + warning.failedCookies } ) + case 'cookies-undecryptable': + switch (warning.reason) { + case 'app-bound-encryption': + return translate( + 'auto.lib.browser.cookie.import.toast.undecryptableAppBound', + '{{value0}} cookies could not be decrypted because this browser encrypts them so only it can read them (app-bound encryption). Importing from this browser is not supported on this version.', + { value0: warning.failedCookies } + ) + case 'linux-keyring-unavailable': + return translate( + 'auto.lib.browser.cookie.import.toast.undecryptableKeyring', + '{{value0}} cookies could not be decrypted because the system keyring was unavailable. Unlock your login keyring (or install a Secret Service provider such as gnome-keyring) and import again.', + { value0: warning.failedCookies } + ) + default: + return translate( + 'auto.lib.browser.cookie.import.toast.undecryptableUnknown', + '{{value0}} cookies could not be decrypted and were skipped. Close the source browser completely and try the import again.', + { value0: warning.failedCookies } + ) + } } } diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 606a5a26857..2c0f2528666 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -1,118 +1,6358 @@ -import type { StateCreator } from 'zustand' +/* eslint-disable max-lines */ +import type { StateCreator, StoreApi } from 'zustand' import type { AppState } from '../types' -import type { WorktreeSlice } from './worktree-helpers' -import { worktreeSliceInitialState } from './worktrees/session/worktree-slice-initial-state' -import { createFetchDetectedWorktrees } from './worktrees/listing/fetch-detected-worktrees' -import { createFetchWorktrees } from './worktrees/listing/fetch-worktrees' -import { createFetchAllWorktrees } from './worktrees/listing/fetch-all-worktrees' +import type { FolderWorkspace } from '../../../../shared/folder-workspace-types' +import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Repo } from '../../../../shared/repo-types' +import type { WorkspaceVisibleTabType } from '../../../../shared/tab-types' +import type { LocalBaseRefRefreshResult } from '../../../../shared/worktree/base-ref-drift-types' +import type { + ForceDeleteWorktreeBranchResult, + RemoveWorktreeResult +} from '../../../../shared/worktree/create-types' +import type { WorkspaceLineage, WorktreeLineage } from '../../../../shared/worktree/lineage-types' +import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' +import type { + DetectedWorktreeListResult, + GitHubPrStartPoint, + GitPushTarget, + Worktree +} from '../../../../shared/worktree/types' +import type { RuntimeWorktreeListResult } from '../../../../shared/runtime-types' import { - createAssignWorktreeParent, - createFetchWorktreeLineage, - createUpdateWorktreeLineage -} from './worktrees/metadata/worktree-lineage-actions' -import { createUpdateWorktreeGitIdentity } from './worktrees/metadata/worktree-git-identity-update' + findWorktreeById, + applyWorktreeUpdates, + withoutErasedRequiredWorktreeFields, + getRepoIdFromWorktreeId, + type DirectSshWorktreeFetchOptions, + type WorktreeFetchOptions, + type WorktreeSlice +} from './worktree-helpers' +import { projectWorktreeTabModelReconciliation } from './tabs' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' import { - createUpdateWorktreeBaseStatus, - createUpdateWorktreeRemoteBranchConflict -} from './worktrees/metadata/worktree-base-status' -import { createPrefetchWorktreeCreateBase } from './worktrees/create/prefetch-worktree-create-base' -import { createCreateWorktree } from './worktrees/create/create-worktree' + remapClosedTerminalTabSnapshotCwds, + type ClosedTerminalTabSnapshot +} from './recently-closed-tabs' +import { findRepoForHost } from './repo-host-identity' import { - createBeginPendingWorktreeCreation, - createRemovePendingWorktreeCreation, - createSetActivePendingWorktreeCreation, - createUpdatePendingWorktreeCreation -} from './worktrees/create/pending-worktree-creation' -import { createRemoveWorktree } from './worktrees/teardown/remove-worktree' + dropWorktreeRowsForRemovedRuntimeEnvironments, + isRemovedRuntimeHostId +} from './stale-runtime-host-rows' +import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' +import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup' +import { cleanupFailedEphemeralVmWorkspace } from '@/lib/ephemeral-vm-failed-create-cleanup' +import { tabHasLivePty } from '@/lib/tab-has-live-pty' +import { disposeRemovedWorktreeParkedTerminalWatchers } from '../../components/terminal-pane/terminal-parked-watcher-registry' import { - createClearWorktreeDeleteState, - createMarkWorktreesDeleting, - createMarkWorktreesQueuedForDeletion -} from './worktrees/teardown/worktree-delete-state' -import { createForceDeletePreservedBranch } from './worktrees/teardown/force-delete-preserved-branch' -import { createUpdateWorktreeMeta } from './worktrees/metadata/update-worktree-meta' -import { createEnsureHostedReviewPushTarget } from './worktrees/metadata/hosted-review-push-target-ensure' -import { createUpdateWorktreesMeta } from './worktrees/metadata/update-worktrees-meta' -import { createSetWorktreesPinnedAndReveal } from './worktrees/session/worktree-pin-reveal' + callRuntimeRpc, + assertRuntimeEnvironmentCapability, + getActiveRuntimeTarget, + hasRuntimeRpcErrorCode, + isRuntimeScopeForbiddenError, + RuntimeRpcCallError +} from '../../runtime/runtime-rpc-client' import { - createBumpWorktreeActivity, - createClearWorktreeUnread, - createMarkWorktreeUnread, - createObserveTerminalGitHubPullRequestLink -} from './worktrees/session/worktree-unread-activity' + TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, + WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { toRuntimeWorktreeSelector } from '../../runtime/runtime-worktree-selector' +import { getHostedReviewCacheKey, refreshHostedReviewCard } from './hosted-review' +import { routeListingBranchSwitchesThroughGitIdentity } from './worktree-listing-branch-switch' +import { isPositiveHostedReviewNumber } from '../../../../shared/hosted-review' +import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from './github-cache-key' +import { moveFocusToRendererBeforeFocusedWebviewHidden } from './browser-webview-cleanup' +import { toast } from 'sonner' +import { requestVirtualizedScrollAnchorRecord } from '@/hooks/requestVirtualizedScrollAnchorRecord' +import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-activity' +import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs' +import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards' +import { forgetAgentPaneAuthorityAliasesByTabIds } from './agent-pane-authority' +import { branchName } from '@/lib/git-utils' +import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler' +import { clearSessionCommitDraftForWorktree } from '@/lib/source-control-commit-draft-session' import { - createMarkWorktreeVisited, - createPruneLastVisitedTimestamps, - createSeedActiveWorktreeLastVisitedIfMissing -} from './worktrees/session/worktree-visit-recency' -import { createSetActiveWorktree } from './worktrees/session/set-active-worktree' -import { createSetActiveFolderWorkspace } from './worktrees/session/set-active-folder-workspace' + forgetHugeRepoWarningDismissalsForWorktrees, + migrateHugeRepoWarningDismissal +} from '@/lib/source-control-huge-repo-warning-dismissals' +import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast' +import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast' +import { requestWorktreeBaseFallbackNotice } from '@/components/worktree-base-fallback-notice' +import { resolveWorktreeDisplayName } from '@/lib/worktree-default-display-name' +import { translate } from '@/i18n/i18n' import { - createAllWorktrees, - createGetKnownWorktreeById, - createPurgeWorktreeTerminalState, - createRemountTerminalTabForRecovery, - createSetRenamingWorktreeId -} from './worktrees/session/worktree-slice-lookups' -import { createPurgeStaleRuntimeHostState } from './worktrees/teardown/purge-stale-runtime-host-state' -import { createMigrateWorktreeIdentity } from './worktrees/session/migrate-worktree-identity' - + getRepoExecutionHostId, + getSettingsFocusedExecutionHostId, + LOCAL_EXECUTION_HOST_ID, + parseExecutionHostId, + toRuntimeExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' +import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' +import { + resolveWorktreeOperationRoute, + resolveWorktreeOperationRouteResult, + settingsForWorktreeOperationRoute +} from '@/lib/worktree-operation-route' +import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operation-generation' +import { getEnvironmentSshStateGeneration } from './runtime-environment-ssh' +import { getRuntimeEnvironmentConnectionGeneration } from './runtime-status' +import { + folderWorkspaceKey, + getActiveSidebarWorkspaceId, + isWorkspaceKey, + parseWorkspaceKey, + worktreeWorkspaceKey +} from '../../../../shared/workspace-scope' +import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import { + CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS, + getClientWorktreeCreateCandidate, + isRetryableWorktreeCreateConflict +} from '../../../../shared/new-workspace/worktree-create-retry-policy' +import { + classifyWorktreeForceDeleteReason, + getLockedWorktreeRemovalReason, + isLockedWorktreeRemovalError +} from '../../../../shared/worktree/removal' +import { FolderWorkspaceActivityPersistence } from './folder-workspace-activity-persistence' +import { + createDetectedWorktreeRefreshLeaseRegistry, + type DetectedWorktreeRefreshLease +} from './detected-worktree-refresh-leases' +import { getTerminalActivationSpawnSuppression } from './terminal-activation-spawn-suppression' +import { + preservedBranchCleanupKey, + type PreservedBranchCleanup +} from '../../../../shared/preserved-branch-cleanup' +import type { + HostQualifiedDetectedWorktreeResult, + HostQualifiedKnownWorktreeResult, + ListDetectedWorktreesArgs, + ProviderRequestId, + SshExecutionHostId +} from '../../../../shared/detected-worktree-provider-contract' +import type { DirectSshAuthority } from '../../../../shared/ssh-types' +import { findIndexedWorktreeOwnerForHost } from '@/lib/worktree-runtime-owner-index' +import { catalogRowsEqual, reuseEqualCatalogRows } from './worktree-catalog-reconciliation' +import { reuseEqualRecordMap } from './repo-identity-reconcile' export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers' -export { WORKTREE_REFRESH_CONCURRENCY } from './worktrees/listing/worktree-slice-constants' -export { acquireDetectedWorktreeRefreshLeaseForRepo } from './worktrees/listing/detected-worktree-refresh' -export { - getHostedReviewLinkMutationGenerationForTests, - getHostedReviewLinkWorktreeAliasCountForTests, - resetHostedReviewLinkMutationGenerationForTests -} from './worktrees/metadata/hosted-review-link-mutation' -export { - getDetachedHeadAutoDerivedDisplayNameForTests, - setDetachedHeadAutoDerivedDisplayNameForTests -} from './worktrees/metadata/detached-head-display-name' -export { resetAuthoritativelyRemovedWorktreeMemoryForTests } from './worktrees/listing/authoritative-worktree-removal-memory' -export type { DirectSshDetectedWorktreeRefresh } from './worktrees/listing/known-ssh-worktree-fetch' -export { acquireDirectSshDetectedWorktreeRefresh } from './worktrees/listing/known-ssh-worktree-fetch' + +// Why: old runtime servers only have `worktree.list`; preserve the large-list UI hydration parity used before `worktree.detectedList` existed. +const REMOTE_WORKTREE_LIST_PARITY_LIMIT = 10_000 +const WORKTREE_REMOVAL_AMBIGUOUS_ERROR = + 'Workspace identity is ambiguous across hosts. Refresh projects and try again.' +const ACTIVE_WORKTREE_TERMINAL_PREP_DELAY_MS = 300 +const ACTIVE_WORKTREE_TERMINAL_PREP_INPUT_QUIET_MS = 450 +const ACTIVE_WORKTREE_TERMINAL_PREP_IDLE_TIMEOUT_MS = 180 +const FOLDER_WORKSPACE_ACTIVITY_PERSIST_INTERVAL_MS = 1_000 +// Why: each repo's `git worktree list` is an independent main-process child; a higher ceiling cuts startup scan batches (#7225) while staying bounded against launching every git probe at once. +export const WORKTREE_REFRESH_CONCURRENCY = 8 +const pendingActivationTerminalPrepCancels = new Map void>() +const detachedHeadAutoDerivedDisplayNames = new Map() +const preservedBranchRuntimeTargetByCleanupKey = new Map< + string, + { cleanup: PreservedBranchCleanup; target: ReturnType } +>() +const folderWorkspaceWorktreeCache = new WeakMap() +const hostedReviewPushTargetLookupsInFlight = new Set() +const runtimeDetectedWorktreeRefreshesInFlight = new Map< + string, + Promise +>() +type WorktreeSliceGet = Parameters>[1] +const folderWorkspaceActivityPersistenceByStore = new WeakMap< + WorktreeSliceGet, + FolderWorkspaceActivityPersistence +>() + +function getFolderWorkspaceActivityPersistence( + get: WorktreeSliceGet +): FolderWorkspaceActivityPersistence { + const existing = folderWorkspaceActivityPersistenceByStore.get(get) + if (existing) { + return existing + } + const created = new FolderWorkspaceActivityPersistence((folderWorkspaceId, activityAt) => { + if (get().folderWorkspaces.some((workspace) => workspace.id === folderWorkspaceId)) { + void get().updateFolderWorkspace(folderWorkspaceId, { lastActivityAt: activityAt }) + } + }, FOLDER_WORKSPACE_ACTIVITY_PERSIST_INTERVAL_MS) + folderWorkspaceActivityPersistenceByStore.set(get, created) + return created +} + +type BackgroundRuntimeRefreshOptions = { + reuseRecentCompatibilityFailure?: boolean +} + +type DetectedWorktreeRefreshOptions = BackgroundRuntimeRefreshOptions & { + executionHostId: ExecutionHostId + requireAuthoritative?: boolean + directSshAuthority?: DirectSshAuthority + // Why (#10562): the caller's own view of what it is about to purge. Teardown is + // requested per caller, so this must never be shared with a coalesced scan. + connectionId?: string | null + knownWorktreeIds?: readonly string[] +} + +type AdmittedDetectedWorktreeRefresh = { + status: 'admitted' + result: DetectedWorktreeListResult + providerResult?: HostQualifiedDetectedWorktreeResult + executionHostId: ExecutionHostId + directSshAuthority?: DirectSshAuthority + runtimeAuthority?: { + environmentId: string + connectionGeneration: number + runtimeConnectionGeneration: number + } +} + +type DetectedWorktreeRefreshOutcome = + | AdmittedDetectedWorktreeRefresh + | { + status: 'not-admitted' + providerResult: HostQualifiedDetectedWorktreeResult + executionHostId: ExecutionHostId + directSshAuthority?: DirectSshAuthority + } + +async function mapReposForWorktreeRefresh( + repos: readonly TRepo[], + mapper: (repo: TRepo) => Promise +): Promise { + const results = Array(repos.length) + let nextIndex = 0 + const workerCount = Math.min(WORKTREE_REFRESH_CONCURRENCY, repos.length) + + // Why: refresh can fire during activation/startup; bound repo scans so one UI moment can't launch every git probe at once. + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < repos.length) { + const index = nextIndex + nextIndex += 1 + results[index] = await mapper(repos[index]) + } + }) + ) + + return results +} + +function shouldDeferActivationTerminalPrep(): boolean { + return typeof window !== 'undefined' && import.meta.env.MODE !== 'test' +} + +function localBaseRefRefreshFailureDetail(result: LocalBaseRefRefreshResult): string { + const ownerWorktreePath = result.ownerWorktreePath?.trim() + switch (result.status) { + case 'skipped_dirty_worktree': + // Create already succeeded — guide cleanup + manual base update, not "try again". + return ownerWorktreePath + ? translate( + 'auto.store.slices.worktrees.localBaseRefRefreshFailedDetailDirtyNamed', + 'The worktree at {{value0}} (where local {{value1}} is checked out) has uncommitted changes. Commit, stash, or discard those changes, then update local {{value1}} manually.', + { value0: ownerWorktreePath, value1: result.localBranch } + ) + : translate( + 'auto.store.slices.worktrees.localBaseRefRefreshFailedDetailDirty', + 'The worktree where local {{value0}} is checked out has uncommitted changes. Commit, stash, or discard those changes, then update local {{value0}} manually.', + { value0: result.localBranch } + ) + case 'skipped_not_fast_forward': + return translate( + 'auto.store.slices.worktrees.localBaseRefRefreshFailedDetailNotFastForward', + 'Local {{value0}} does not exist or cannot be fast-forwarded cleanly from the remote base. Check for local-only commits before updating it manually.', + { value0: result.localBranch } + ) + case 'skipped_error': + return translate( + 'auto.store.slices.worktrees.localBaseRefRefreshFailedDetailError', + 'Git returned an error while updating local {{value0}}. Check the repo for locked refs or unusual worktree state, then update local {{value0}} manually.', + { value0: result.localBranch } + ) + case 'updated': + return '' + } +} + +function showLocalBaseRefRefreshToast( + result: LocalBaseRefRefreshResult | undefined, + createdWorktree?: Pick +): void { + if (!result || result.status === 'updated') { + return + } + + const worktreeName = createdWorktree ? resolveWorktreeDisplayName(createdWorktree).trim() : '' + const detail = localBaseRefRefreshFailureDetail(result) + + // Why: Infinity so create-time failures aren't buried; id is per worktree so each create stays attributable. + toast.warning( + worktreeName + ? translate( + 'auto.store.slices.worktrees.localBaseRefRefreshFailedForWorktree', + 'Local {{value0}} was not refreshed for "{{value1}}"', + { value0: result.localBranch, value1: worktreeName } + ) + : translate('auto.store.slices.worktrees.14bc053a47', 'Local {{value0}} was not refreshed', { + value0: result.localBranch + }), + { + id: `local-base-ref-refresh-failed:${createdWorktree?.id ?? 'unknown'}:${result.localBranch}`, + description: worktreeName + ? translate( + 'auto.store.slices.worktrees.localBaseRefRefreshFailedDescriptionNamed', + 'Workspace "{{value0}}" was created from {{value1}}, but Orca could not fast-forward local {{value2}}. {{value3}}', + { + value0: worktreeName, + value1: result.baseRef, + value2: result.localBranch, + value3: detail + } + ) + : translate( + 'auto.store.slices.worktrees.903b51c2ed', + 'Workspace created from {{value0}}, but Orca could not fast-forward local {{value1}}. {{value2}}', + { value0: result.baseRef, value1: result.localBranch, value2: detail } + ), + duration: Infinity, + dismissible: true + } + ) +} + +function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): boolean { + return catalogRowsEqual(current, next) +} + +function areDetectedWorktreeResultsEqual( + current: DetectedWorktreeListResult | undefined, + next: DetectedWorktreeListResult +): boolean { + return Boolean( + current && + current.repoId === next.repoId && + current.authoritative === next.authoritative && + current.source === next.source && + catalogRowsEqual(current.worktrees, next.worktrees) + ) +} + +function toVisibleTabType(contentType: string): WorkspaceVisibleTabType { + if (contentType === 'browser' || contentType === 'terminal' || contentType === 'simulator') { + return contentType + } + return 'editor' +} + +type WorktreeWithLineage = Worktree & { + parentWorktreeId?: string | null + childWorktreeIds?: string[] + lineage?: WorktreeLineage | null +} + +function toVisibleWorktree(worktree: DetectedWorktreeListResult['worktrees'][number]): Worktree { + const { + ownership: _ownership, + selectedCheckout: _selectedCheckout, + visible: _visible, + ...base + } = worktree + return base +} + +// Why: runtime payloads describe execution from the HUB's perspective; project that location without losing the paired transport owner. +function withRepoHostOwnership< + T extends { + hostId?: ExecutionHostId + runtimeOwnerEnvironmentId?: string + projectId?: string + projectHostSetupId?: string + } +>(worktree: T, hostId: ExecutionHostId, setup?: ProjectHostSetup): T { + const parsedOwner = parseExecutionHostId(hostId) + const runtimeOwnerEnvironmentId = + parsedOwner?.kind === 'runtime' ? parsedOwner.environmentId : undefined + const worktreeHost = parseExecutionHostId(worktree.hostId) + // Why: an SSH worktree reached through a paired HUB has two owners; retain the SSH execution host and stamp the HUB transport separately. + const nextHostId = + hostId === LOCAL_EXECUTION_HOST_ID || + (runtimeOwnerEnvironmentId !== undefined && worktreeHost?.kind === 'ssh') + ? worktree.hostId + : hostId + const projectId = worktree.projectId ?? setup?.projectId + const projectHostSetupId = worktree.projectHostSetupId ?? setup?.id + if ( + nextHostId === worktree.hostId && + runtimeOwnerEnvironmentId === worktree.runtimeOwnerEnvironmentId && + projectId === worktree.projectId && + projectHostSetupId === worktree.projectHostSetupId + ) { + return worktree + } + return { + ...worktree, + ...(nextHostId ? { hostId: nextHostId } : {}), + runtimeOwnerEnvironmentId, + ...(projectId ? { projectId } : {}), + ...(projectHostSetupId ? { projectHostSetupId } : {}) + } as T +} + +function repoHostId( + state: Pick, + repoId: string, + hostId?: ExecutionHostId | null +): ExecutionHostId { + const repo = findRepoForHost(state.repos, repoId, { hostId, settings: state.settings }) + if (repo) { + return getRepoExecutionHostId(repo) + } + return hostId && parseExecutionHostId(hostId) ? hostId : LOCAL_EXECUTION_HOST_ID +} + +function repoHasExactlyOneExecutionHostOwner( + state: Pick, + repoId: string, + hostId: ExecutionHostId, + ownerWasMissingAtStart: boolean +): boolean { + const repoOwners = state.repos.filter((repo) => repo.id === repoId) + if (repoOwners.length === 0) { + return ownerWasMissingAtStart + } + const ownerHostIds = repoOwners.map((repo) => { + const hasExplicitHost = repo.executionHostId !== null && repo.executionHostId !== undefined + const explicitHost = hasExplicitHost ? parseExecutionHostId(repo.executionHostId) : null + if (hasExplicitHost && !explicitHost) { + return null + } + const rawConnectionId = repo.connectionId + const hasConnection = rawConnectionId !== null && rawConnectionId !== undefined + const connectionId = hasConnection ? rawConnectionId.trim() : null + if (hasConnection && !connectionId) { + return null + } + if (!connectionId || explicitHost?.kind === 'runtime') { + return explicitHost?.id ?? LOCAL_EXECUTION_HOST_ID + } + if (explicitHost && explicitHost.id !== toSshExecutionHostId(connectionId)) { + return null + } + return explicitHost?.id ?? toSshExecutionHostId(connectionId) + }) + return ( + ownerHostIds.every((ownerHostId) => ownerHostId !== null) && + ownerHostIds.filter((ownerHostId) => ownerHostId === hostId).length === 1 + ) +} + +function toVisibleWorktrees( + result: DetectedWorktreeListResult, + hostId: ExecutionHostId, + setup?: ProjectHostSetup +): Worktree[] { + return result.worktrees + .filter((worktree) => worktree.visible) + .map(toVisibleWorktree) + .map((worktree) => withRepoHostOwnership(worktree, hostId, setup)) +} + +function getProjectHostSetupForRepoHost( + state: Partial>, + repoId: string, + hostId: ExecutionHostId +): ProjectHostSetup | undefined { + return state.projectHostSetups?.find( + (setup) => setup.repoId === repoId && setup.hostId === hostId + ) +} + +function getHydratedSessionWorktreeIdsForRepo(state: AppState, repoId: string): string[] { + return Object.keys(state.tabsByWorktree).filter((id) => getRepoIdFromWorktreeId(id) === repoId) +} + +type WorktreeHostMatchOptions = { + unhostedWorktreesMatchHost?: boolean +} + +type RepoHostSummary = { + count: number + onlyHostId?: ExecutionHostId +} + +const repoHostSummariesByRepos = new WeakMap>() + +function getRepoHostSummaries(repos: AppState['repos']): Map { + const cached = repoHostSummariesByRepos.get(repos) + if (cached) { + return cached + } + + const summaries = new Map() + for (const repo of repos) { + const current = summaries.get(repo.id) + if (current) { + summaries.set(repo.id, { count: current.count + 1 }) + } else { + summaries.set(repo.id, { count: 1, onlyHostId: getRepoExecutionHostId(repo) }) + } + } + repoHostSummariesByRepos.set(repos, summaries) + return summaries +} + +function unhostedWorktreesMatchRefreshHost( + state: Pick, + repoId: string, + hostId: ExecutionHostId +): boolean { + if (hostId === LOCAL_EXECUTION_HOST_ID) { + return true + } + + const summary = getRepoHostSummaries(state.repos).get(repoId) + return summary?.count === 1 && summary.onlyHostId === hostId +} + +function worktreeHostMatchOptions( + state: Pick, + repoId: string, + hostId: ExecutionHostId +): WorktreeHostMatchOptions { + return { + // Why: pre-host persisted runtime/SSH worktrees lack hostId; treat them as the sole repo owner's rows but keep ambiguous duplicates local. + unhostedWorktreesMatchHost: unhostedWorktreesMatchRefreshHost(state, repoId, hostId) + } +} + +function worktreeMatchesHost( + worktree: { hostId?: ExecutionHostId; runtimeOwnerEnvironmentId?: string }, + hostId: ExecutionHostId, + options: WorktreeHostMatchOptions = {} +): boolean { + const parsedRefreshHost = parseExecutionHostId(hostId) + if (parsedRefreshHost?.kind === 'runtime') { + if (worktree.runtimeOwnerEnvironmentId) { + return worktree.runtimeOwnerEnvironmentId === parsedRefreshHost.environmentId + } + if (worktree.hostId) { + return worktree.hostId === hostId + } + return options.unhostedWorktreesMatchHost ?? false + } + if (worktree.runtimeOwnerEnvironmentId) { + return false + } + if (worktree.hostId) { + return worktree.hostId === hostId + } + return options.unhostedWorktreesMatchHost ?? hostId === LOCAL_EXECUTION_HOST_ID +} + +function mergeWorktreesForHost< + T extends { id: string; hostId?: ExecutionHostId; runtimeOwnerEnvironmentId?: string } +>( + current: readonly T[] | undefined, + refreshed: readonly T[], + hostId: ExecutionHostId, + options?: WorktreeHostMatchOptions +): T[] { + // Why: host-scoped refreshes replace that host in place so alternating local/runtime refreshes don't churn sibling row order or sortEpoch. + const existing = current ?? [] + const reconciled = reuseEqualCatalogRows( + existing.filter((worktree) => worktreeMatchesHost(worktree, hostId, options)), + refreshed + ) + const next: T[] = [] + let inserted = false + + for (const worktree of existing) { + if (worktreeMatchesHost(worktree, hostId, options)) { + if (!inserted) { + next.push(...reconciled) + inserted = true + } + continue + } + next.push(worktree) + } + + if (!inserted) { + next.push(...reconciled) + } + return existing.length === next.length && existing.every((row, index) => row === next[index]) + ? (existing as T[]) + : next +} + +function mergeDetectedWorktreesForHost( + current: DetectedWorktreeListResult | undefined, + refreshed: DetectedWorktreeListResult, + hostId: ExecutionHostId, + setup?: ProjectHostSetup, + options?: WorktreeHostMatchOptions +): DetectedWorktreeListResult { + const refreshedForHost = sanitizeHostedReviewLinksForBranchClears( + refreshed.worktrees, + current?.worktrees + ).map((worktree) => withRepoHostOwnership(worktree, hostId, setup)) + const worktrees = mergeWorktreesForHost(current?.worktrees, refreshedForHost, hostId, options) + if ( + current && + current.repoId === refreshed.repoId && + current.authoritative === refreshed.authoritative && + current.source === refreshed.source && + current.worktrees === worktrees + ) { + return current + } + return { + ...refreshed, + worktrees + } +} + +function getKnownWorktreeIdsForPurge( + state: AppState, + repoId: string, + hostId: ExecutionHostId +): string[] { + const detected = state.detectedWorktreesByRepo[repoId] + const knownIds = new Set() + const matchOptions = worktreeHostMatchOptions(state, repoId, hostId) + if (detected?.authoritative === true) { + for (const worktree of detected.worktrees) { + if (worktreeMatchesHost(worktree, hostId, matchOptions)) { + knownIds.add(worktree.id) + } + } + } else { + for (const worktree of state.worktreesByRepo[repoId] ?? []) { + if (worktreeMatchesHost(worktree, hostId, matchOptions)) { + knownIds.add(worktree.id) + } + } + } + if (!state.hasHydratedWorktreePurge && matchOptions.unhostedWorktreesMatchHost === true) { + // Why (#1158): hydration can preserve tab keys before worktree metadata exists; the first authoritative scan must still reap deleted session-only keys. + for (const id of getHydratedSessionWorktreeIdsForRepo(state, repoId)) { + knownIds.add(id) + } + } + return [...knownIds] +} + +function getRemovedWorktreeIdsAfterAuthoritativeScan( + state: AppState, + repoId: string, + detected: DetectedWorktreeListResult, + hostId: ExecutionHostId +): string[] { + if (!detected.authoritative) { + return [] + } + const detectedIds = new Set(detected.worktrees.map((worktree) => worktree.id)) + return getKnownWorktreeIdsForPurge(state, repoId, hostId).filter((id) => !detectedIds.has(id)) +} + +function toLegacyDetectedWorktreeResult( + repoId: string, + result: { worktrees: Worktree[] } +): DetectedWorktreeListResult { + return { + repoId, + authoritative: true, + source: 'session-fallback', + worktrees: result.worktrees.map((worktree) => ({ + ...worktree, + ownership: 'orca-managed', + selectedCheckout: false, + visible: true + })) + } +} + +function isRuntimeMethodNotFoundError(error: unknown): boolean { + return error instanceof RuntimeRpcCallError && error.code === 'method_not_found' +} + +// Why: teardown cannot ride the scan's coalescing (each caller has its own known-id +// snapshot), so dedupe on the request it actually produces — identical fan-out +// requests share one host sweep instead of re-scanning per caller. +const missingWorktreeTeardownsInFlight = new Map>() + +async function teardownMissingWorktreeTerminalsBestEffort( + settings: AppState['settings'], + repoId: string, + connectionId: string | null | undefined, + // Why: refreshes that never purge omit this; an absent snapshot means + // "nothing to reconcile", never "purge everything". + knownWorktreeIds: readonly string[] | undefined, + detected: DetectedWorktreeListResult +): Promise { + if (!detected.authoritative || !knownWorktreeIds || knownWorktreeIds.length === 0) { + return + } + const detectedIds = new Set(detected.worktrees.map((worktree) => worktree.id)) + const missingIds = knownWorktreeIds.filter((worktreeId) => !detectedIds.has(worktreeId)) + if (missingIds.length === 0) { + return + } + const target = getActiveRuntimeTarget(settings) + const normalizedConnectionId = connectionId ?? null + const key = [ + target.kind === 'local' ? 'local' : `runtime:${target.environmentId}`, + repoId, + normalizedConnectionId ?? '', + [...missingIds].sort().join('\n') + ].join('\0') + const existing = missingWorktreeTeardownsInFlight.get(key) + if (existing) { + return existing + } + const teardown = (async () => { + try { + await callRuntimeRpc( + target, + 'worktree.teardownMissingTerminals', + { repo: repoId, worktreeIds: missingIds, connectionId: normalizedConnectionId }, + { timeoutMs: 30_000 } + ) + } catch (error) { + if (!isRuntimeMethodNotFoundError(error)) { + console.warn(`Failed to stop terminals for missing worktrees in repo ${repoId}:`, error) + } + } + })() + missingWorktreeTeardownsInFlight.set(key, teardown) + try { + await teardown + } finally { + if (missingWorktreeTeardownsInFlight.get(key) === teardown) { + missingWorktreeTeardownsInFlight.delete(key) + } + } +} + +// Why: a mobile-scope web pairing is denied worktree/repo RPCs (else silently empty workspaces); surface one deduped toast (stable id) instead of spamming per-repo. +const RUNTIME_SCOPE_FORBIDDEN_TOAST_ID = 'runtime-scope-forbidden' + +function notifyRuntimeScopeForbiddenIfNeeded(error: unknown): boolean { + if (!isRuntimeScopeForbiddenError(error)) { + return false + } + toast.error( + translate( + 'auto.store.slices.worktrees.runtimeScopeForbiddenTitle', + 'This connection has limited (mobile) access' + ), + { + id: RUNTIME_SCOPE_FORBIDDEN_TOAST_ID, + description: translate( + 'auto.store.slices.worktrees.runtimeScopeForbiddenDescription', + 'Workspaces are unavailable on a mobile-scope pairing. Reconnect using the browser access link from Settings → Runtime Environments → Share this Orca server.' + ) + } + ) + return true +} + +function applyDetectedWorktreeUpdates( + detectedWorktreesByRepo: AppState['detectedWorktreesByRepo'], + worktreeId: string, + rawUpdates: Partial +): AppState['detectedWorktreesByRepo'] { + // Why: mirrors applyWorktreeUpdates — detected rows feed the same palette. + const updates = withoutErasedRequiredWorktreeFields(rawUpdates) + let changed = false + const nextByRepo: AppState['detectedWorktreesByRepo'] = {} + + for (const [repoId, result] of Object.entries(detectedWorktreesByRepo)) { + let repoChanged = false + const nextWorktrees = result.worktrees.map((worktree) => { + if (worktree.id !== worktreeId) { + return worktree + } + repoChanged = true + changed = true + return { ...worktree, ...updates } + }) + nextByRepo[repoId] = repoChanged ? { ...result, worktrees: nextWorktrees } : result + } + + return changed ? nextByRepo : detectedWorktreesByRepo +} + +function folderWorkspaceMatchesHost( + workspace: Pick, + executionHostId: ExecutionHostId +): boolean { + return ( + (parseExecutionHostId(workspace.executionHostId)?.id ?? + (workspace.connectionId?.trim() + ? toSshExecutionHostId(workspace.connectionId) + : LOCAL_EXECUTION_HOST_ID)) === executionHostId + ) +} + +function findKnownWorktreeById( + state: Pick, + worktreeId: string, + executionHostId?: ExecutionHostId +): Worktree | DetectedWorktreeListResult['worktrees'][number] | undefined { + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + const folderWorkspace = state.folderWorkspaces.find( + (workspace) => + workspace.id === workspaceScope.folderWorkspaceId && + (!executionHostId || folderWorkspaceMatchesHost(workspace, executionHostId)) + ) + if (!folderWorkspace) { + return undefined + } + const cached = folderWorkspaceWorktreeCache.get(folderWorkspace) + if (cached) { + return cached + } + const worktree = folderWorkspaceToWorktree(folderWorkspace) + folderWorkspaceWorktreeCache.set(folderWorkspace, worktree) + return worktree + } + const visible = executionHostId + ? (findIndexedWorktreeOwnerForHost( + state.worktreesByRepo, + worktreeId, + executionHostId + ) as Worktree | null) + : findWorktreeById(state.worktreesByRepo, worktreeId) + if (visible) { + return visible + } + for (const result of Object.values(state.detectedWorktreesByRepo)) { + const detected = result.worktrees.find( + (worktree) => + worktree.id === worktreeId && + (!executionHostId || + worktreeMatchesHost(worktree, executionHostId, { + unhostedWorktreesMatchHost: executionHostId === LOCAL_EXECUTION_HOST_ID + })) + ) + if (detected) { + return detected + } + } + return undefined +} + +function getFolderWorkspaceMetaUpdates( + updates: Partial +): Partial< + Pick< + FolderWorkspace, + | 'name' + | 'comment' + | 'isArchived' + | 'isUnread' + | 'isPinned' + | 'sortOrder' + | 'manualOrder' + | 'lastActivityAt' + | 'workspaceStatus' + | 'createdWithAgent' + | 'pendingFirstAgentMessageRename' + | 'firstAgentMessageRenameError' + | 'diffComments' + > +> { + const next: Partial< + Pick< + FolderWorkspace, + | 'name' + | 'comment' + | 'isArchived' + | 'isUnread' + | 'isPinned' + | 'sortOrder' + | 'manualOrder' + | 'lastActivityAt' + | 'workspaceStatus' + | 'createdWithAgent' + | 'pendingFirstAgentMessageRename' + | 'firstAgentMessageRenameError' + | 'diffComments' + > + > = {} + if (updates.displayName !== undefined) { + next.name = updates.displayName + next.pendingFirstAgentMessageRename = false + next.firstAgentMessageRenameError = null + } + if (updates.comment !== undefined) { + next.comment = updates.comment + next.lastActivityAt = Date.now() + } + if (updates.isArchived !== undefined) { + next.isArchived = updates.isArchived + } + if (updates.isUnread !== undefined) { + next.isUnread = updates.isUnread + } + if (updates.isPinned !== undefined) { + next.isPinned = updates.isPinned + } + if (updates.sortOrder !== undefined) { + next.sortOrder = updates.sortOrder + } + if (updates.manualOrder !== undefined) { + next.manualOrder = updates.manualOrder + } + if (updates.lastActivityAt !== undefined) { + next.lastActivityAt = updates.lastActivityAt + } + if (updates.workspaceStatus !== undefined) { + next.workspaceStatus = updates.workspaceStatus + } + if (updates.createdWithAgent !== undefined) { + next.createdWithAgent = updates.createdWithAgent + } + if (updates.pendingFirstAgentMessageRename !== undefined) { + next.pendingFirstAgentMessageRename = updates.pendingFirstAgentMessageRename + } + if (updates.firstAgentMessageRenameError !== undefined) { + next.firstAgentMessageRenameError = updates.firstAgentMessageRenameError + } + if (updates.diffComments !== undefined) { + next.diffComments = updates.diffComments + } + return next +} + +function isRuntimeSelectorNotFoundError(error: unknown): boolean { + return hasRuntimeRpcErrorCode(error, 'selector_not_found') +} + +function isRuntimeRepoNotFoundError(error: unknown): boolean { + return hasRuntimeRpcErrorCode(error, 'repo_not_found') +} + +function replaceWorktreeInRepoLists( + worktreesByRepo: Record, + updatedWorktree: Worktree +): Record { + const repoId = getRepoIdFromWorktreeId(updatedWorktree.id) + const current = worktreesByRepo[repoId] + if (!current) { + return worktreesByRepo + } + return { + ...worktreesByRepo, + [repoId]: current.map((worktree) => + worktree.id === updatedWorktree.id ? updatedWorktree : worktree + ) + } +} + +function settingsForRepoOwner( + state: Pick, + repoId: string, + hostId?: ExecutionHostId | null, + honorMissingHostId = false +) { + const repo = findRepoForHost(state.repos, repoId, { hostId, settings: state.settings }) + if (repo) { + return settingsForKnownRepoOwner(state.settings, repo) + } + const parsedHost = honorMissingHostId && hostId ? parseExecutionHostId(hostId) : null + if (parsedHost?.kind === 'runtime') { + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: parsedHost.environmentId } + : ({ activeRuntimeEnvironmentId: parsedHost.environmentId } as AppState['settings']) + } + if (parsedHost?.kind === 'local' || parsedHost?.kind === 'ssh') { + return state.settings + ? { ...state.settings, activeRuntimeEnvironmentId: null } + : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) + } + return state.settings +} + +function settingsForKnownRepoOwner( + settings: AppState['settings'], + repo: { connectionId?: string | null; executionHostId?: ExecutionHostId | null } +) { + if (!repo.executionHostId && !repo.connectionId) { + return settings + } + const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) + if (parsed?.kind === 'runtime') { + return settings + ? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId } + : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) + } + if (parsed?.kind === 'local' && settings?.activeRuntimeEnvironmentId) { + return { ...settings, activeRuntimeEnvironmentId: null } + } + if (parsed?.kind !== 'ssh') { + return settings + } + // Why: SSH repos are owned by the desktop client/SSH provider, not the focused runtime server. + return settings + ? { ...settings, activeRuntimeEnvironmentId: null } + : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) +} + +function trySettingsForWorktreeOwner( + state: Pick< + AppState, + | 'repos' + | 'settings' + | 'worktreesByRepo' + | 'detectedWorktreesByRepo' + | 'folderWorkspaces' + | 'projectGroups' + | 'restoredRuntimeHostIdByWorkspaceSessionKey' + | 'runtimeEnvironments' + | 'runtimeEnvironmentCatalogHydrated' + | 'removedRuntimeEnvironmentIds' + >, + worktreeId: string +): AppState['settings'] | null { + const route = resolveWorktreeOperationRoute(state, worktreeId) + if (!route) { + return null + } + return settingsForWorktreeOperationRoute(state.settings, route) +} + +function settingsForWorktreeOwner( + state: Parameters[0], + worktreeId: string +) { + const settings = trySettingsForWorktreeOwner(state, worktreeId) + if (!settings) { + throw new Error(WORKTREE_REMOVAL_AMBIGUOUS_ERROR) + } + return settings +} + +// Why: activity bumps fire on every PTY event, so an ambiguous workspace would warn continuously. +// One line per workspace is enough to diagnose it (#10634). +const ambiguousOwnerWarnedWorktreeIds = new Set() + +function warnAmbiguousOwnerOnce(worktreeId: string, errorLabel: string): void { + if (ambiguousOwnerWarnedWorktreeIds.has(worktreeId)) { + return + } + ambiguousOwnerWarnedWorktreeIds.add(worktreeId) + console.warn(`Skipped ${errorLabel}: workspace identity is ambiguous across hosts`, worktreeId) +} + +function persistPassiveWorktreeMetaForOwner( + get: WorktreeSliceGet, + worktreeId: string, + updates: Partial, + errorLabel: string +): void { + const ownerSettings = trySettingsForWorktreeOwner(get(), worktreeId) + if (!ownerSettings) { + warnAmbiguousOwnerOnce(worktreeId, errorLabel) + return + } + void persistWorktreeMeta(ownerSettings, worktreeId, updates).catch((err) => { + if (isRuntimeSelectorNotFoundError(err)) { + void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) + return + } + console.error(`Failed to ${errorLabel}:`, err) + void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) + }) +} + +async function listDetectedWorktreesForRepo( + settings: AppState['settings'], + repoId: string, + options: BackgroundRuntimeRefreshOptions = {} +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'local') { + throw new Error('Local detected-worktree reads require a provider lease') + } + try { + return await callRuntimeRpc( + target, + 'worktree.detectedList', + { repo: repoId }, + { + timeoutMs: 15_000, + reuseRecentCompatibilityFailure: options.reuseRecentCompatibilityFailure + } + ) + } catch (error) { + if (!isRuntimeMethodNotFoundError(error)) { + throw error + } + const legacy = await callRuntimeRpc( + target, + 'worktree.list', + { repo: repoId, limit: REMOTE_WORKTREE_LIST_PARITY_LIMIT }, + { + timeoutMs: 15_000, + reuseRecentCompatibilityFailure: options.reuseRecentCompatibilityFailure + } + ) + return toLegacyDetectedWorktreeResult(repoId, legacy) + } +} + +function detectedWorktreeRefreshKey( + settings: AppState['settings'], + repoId: string, + options: DetectedWorktreeRefreshOptions +): string { + const target = getActiveRuntimeTarget(settings) + const targetKey = target.kind === 'local' ? 'local' : `runtime:${target.environmentId}` + const parts = [ + repoId, + options.executionHostId, + targetKey, + options.requireAuthoritative === true ? 'authoritative' : 'best-effort' + ] + // Why: only remote targets run a compat preflight, so a foreground (reuse:false) refresh must re-probe not coalesce onto a stale-failure background scan; local targets have no preflight and stay coalesced. + if (target.kind === 'environment') { + parts.push(`connection:${getEnvironmentSshStateGeneration(target.environmentId)}`) + parts.push(`runtime:${getRuntimeEnvironmentConnectionGeneration(target.environmentId)}`) + parts.push(options.reuseRecentCompatibilityFailure === true ? 'reuse-failure' : 'reprobe') + } + return parts.join('\n') +} + +function isDetectedWorktreeListResult(value: unknown): value is DetectedWorktreeListResult { + if (!value || typeof value !== 'object') { + return false + } + const result = value as Partial + return ( + typeof result.repoId === 'string' && + typeof result.authoritative === 'boolean' && + (result.source === 'git' || + result.source === 'metadata-fallback' || + result.source === 'session-fallback') && + Array.isArray(result.worktrees) + ) +} + +function rejectedDetectedWorktreeProviderResult( + request: ListDetectedWorktreesArgs +): HostQualifiedDetectedWorktreeResult { + return { + providerRequestId: request.providerRequestId, + executionHostId: request.executionHostId, + status: 'rejected' + } +} + +async function startDetectedWorktreeProviderRequest( + request: ListDetectedWorktreesArgs +): Promise { + const worktreesApi = window.api.worktrees as typeof window.api.worktrees & { + listDetected?: typeof window.api.worktrees.listDetected + } + if (typeof worktreesApi.listDetected !== 'function') { + if (request.executionHostId !== LOCAL_EXECUTION_HOST_ID) { + return rejectedDetectedWorktreeProviderResult(request) + } + const worktrees = await worktreesApi.list({ repoId: request.repoId }) + return { + status: 'complete', + providerRequestId: request.providerRequestId, + repoId: request.repoId, + authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, + result: toLegacyDetectedWorktreeResult(request.repoId, { worktrees }) + } + } + const result = await worktreesApi.listDetected(request) + if (result && typeof result === 'object' && 'status' in result && 'providerRequestId' in result) { + return result as unknown as HostQualifiedDetectedWorktreeResult + } + // Why: web and older preload implementations return the legacy local shape. + if (request.executionHostId === LOCAL_EXECUTION_HOST_ID && isDetectedWorktreeListResult(result)) { + return { + status: result.authoritative ? 'complete' : 'non-authoritative', + providerRequestId: request.providerRequestId, + repoId: request.repoId, + authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, + result + } + } + return rejectedDetectedWorktreeProviderResult(request) +} + +const detectedWorktreeRefreshLeaseRegistry = createDetectedWorktreeRefreshLeaseRegistry({ + startProviderRequest: startDetectedWorktreeProviderRequest, + cancelProviderRequest: async (request) => { + await window.api.worktrees.cancelListDetected?.({ + providerRequestId: request.providerRequestId + }) + } +}) + +export function acquireDetectedWorktreeRefreshLeaseForRepo( + settings: AppState['settings'], + repoId: string, + options: DetectedWorktreeRefreshOptions +): DetectedWorktreeRefreshLease { + const parsedHost = parseExecutionHostId(options.executionHostId) + if (!parsedHost || parsedHost.kind === 'runtime') { + throw new Error('Provider leases require a local or direct SSH execution host') + } + const publicKey = detectedWorktreeRefreshKey(settings, repoId, options) + if (parsedHost.kind === 'local') { + return detectedWorktreeRefreshLeaseRegistry.acquire(publicKey, { + repoId, + executionHostId: LOCAL_EXECUTION_HOST_ID + }) + } + if ( + !options.directSshAuthority || + !directSshAuthorityIsComplete(options.directSshAuthority, parsedHost.targetId) + ) { + throw new Error('Direct SSH provider leases require exact target authority') + } + return detectedWorktreeRefreshLeaseRegistry.acquire(publicKey, { + repoId, + executionHostId: options.executionHostId as SshExecutionHostId, + expectedAuthority: { ...options.directSshAuthority } + }) +} + +function qualifiedProviderResultIsAdmitted( + result: HostQualifiedDetectedWorktreeResult, + providerRequestId: ProviderRequestId, + repoId: string, + options: DetectedWorktreeRefreshOptions +): result is Extract< + HostQualifiedDetectedWorktreeResult, + { status: 'complete' | 'non-authoritative' } +> { + if ( + result.providerRequestId !== providerRequestId || + (result.status !== 'complete' && result.status !== 'non-authoritative') || + !isDetectedWorktreeListResult(result.result) || + !result.authority || + result.repoId !== repoId || + result.result.repoId !== repoId || + result.result.authoritative !== (result.status === 'complete') + ) { + return false + } + const parsedHost = parseExecutionHostId(options.executionHostId) + if (parsedHost?.kind === 'local') { + return ( + result.authority.kind === 'local' && + result.authority.executionHostId === LOCAL_EXECUTION_HOST_ID + ) + } + const expected = options.directSshAuthority + return ( + parsedHost?.kind === 'ssh' && + expected !== undefined && + result.authority.kind === 'direct-ssh' && + result.authority.executionHostId === options.executionHostId && + result.authority.targetId === expected.targetId && + result.authority.providerEpoch === expected.providerEpoch && + result.authority.connectionGeneration === expected.connectionGeneration + ) +} + +function normalizeNotAdmittedProviderResult( + result: HostQualifiedDetectedWorktreeResult, + providerRequestId: ProviderRequestId, + executionHostId: ExecutionHostId +): HostQualifiedDetectedWorktreeResult { + if ( + result.providerRequestId === providerRequestId && + result.status !== 'complete' && + result.status !== 'non-authoritative' && + 'executionHostId' in result && + result.executionHostId === executionHostId + ) { + return result + } + return { + providerRequestId, + executionHostId, + status: 'rejected' + } +} + +async function listDetectedWorktreesForRepoCoalesced( + settings: AppState['settings'], + repoId: string, + options: DetectedWorktreeRefreshOptions +): Promise { + const key = detectedWorktreeRefreshKey(settings, repoId, options) + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'environment') { + const connectionGeneration = getEnvironmentSshStateGeneration(target.environmentId) + const runtimeConnectionGeneration = getRuntimeEnvironmentConnectionGeneration( + target.environmentId + ) + let refresh = runtimeDetectedWorktreeRefreshesInFlight.get(key) + if (!refresh) { + refresh = listDetectedWorktreesForRepo(settings, repoId, { + reuseRecentCompatibilityFailure: options.reuseRecentCompatibilityFailure + }) + runtimeDetectedWorktreeRefreshesInFlight.set(key, refresh) + } + try { + const result = await refresh + if ( + getEnvironmentSshStateGeneration(target.environmentId) !== connectionGeneration || + getRuntimeEnvironmentConnectionGeneration(target.environmentId) !== + runtimeConnectionGeneration + ) { + throw new Error('runtime_environment_generation_changed') + } + // Why (#10562): the scan coalesces, but teardown must not — each caller carries + // its own known-id snapshot and purges its own state, so a caller that joined + // an in-flight scan would otherwise purge without ever stopping those terminals. + await teardownMissingWorktreeTerminalsBestEffort( + settings, + repoId, + options.connectionId, + options.knownWorktreeIds, + result + ) + return { + status: 'admitted', + result, + executionHostId: options.executionHostId, + runtimeAuthority: { + environmentId: target.environmentId, + connectionGeneration, + runtimeConnectionGeneration + } + } + } finally { + if (runtimeDetectedWorktreeRefreshesInFlight.get(key) === refresh) { + runtimeDetectedWorktreeRefreshesInFlight.delete(key) + } + } + } + + const lease = acquireDetectedWorktreeRefreshLeaseForRepo(settings, repoId, options) + let providerResult: HostQualifiedDetectedWorktreeResult + try { + providerResult = await lease.result + } catch { + return { + status: 'not-admitted', + providerResult: { + providerRequestId: lease.providerRequestId, + executionHostId: options.executionHostId, + status: 'rejected' + }, + executionHostId: options.executionHostId, + directSshAuthority: options.directSshAuthority + } + } + if ( + !qualifiedProviderResultIsAdmitted(providerResult, lease.providerRequestId, repoId, options) + ) { + return { + status: 'not-admitted', + providerResult: normalizeNotAdmittedProviderResult( + providerResult, + lease.providerRequestId, + options.executionHostId + ), + executionHostId: options.executionHostId, + directSshAuthority: options.directSshAuthority + } + } + await teardownMissingWorktreeTerminalsBestEffort( + settings, + repoId, + options.connectionId, + options.knownWorktreeIds, + providerResult.result + ) + return { + status: 'admitted', + result: providerResult.result, + providerResult, + executionHostId: options.executionHostId, + directSshAuthority: options.directSshAuthority + } +} + +async function listWorktreeLineageForRuntime( + settings: AppState['settings'], + options: BackgroundRuntimeRefreshOptions = {} +): Promise<{ + worktreeLineageById: Readonly> + workspaceLineageByChildKey: Readonly> +}> { + const target = getActiveRuntimeTarget(settings) + type LineageListResponse = { + lineage?: Record + workspaceLineage?: Record + } + const normalizeLineageResponse = (value: Record | LineageListResponse) => + Object.hasOwn(value, 'lineage') || Object.hasOwn(value, 'workspaceLineage') + ? { + worktreeLineageById: (value as LineageListResponse).lineage ?? {}, + workspaceLineageByChildKey: (value as LineageListResponse).workspaceLineage ?? {} + } + : { + worktreeLineageById: value as Record, + workspaceLineageByChildKey: {} + } + if (target.kind === 'local') { + return normalizeLineageResponse(await window.api.worktrees.listLineage()) + } + return normalizeLineageResponse( + await callRuntimeRpc<{ + lineage: Record + workspaceLineage?: Record + }>(target, 'worktree.lineageList', undefined, { + timeoutMs: 15_000, + reuseRecentCompatibilityFailure: options.reuseRecentCompatibilityFailure + }) + ) +} + +function projectWorktreeLineageToWorkspaceLineage( + worktreeId: string, + lineage: WorktreeLineage | null, + current: Record +): Record { + const childWorkspaceKey = worktreeWorkspaceKey(worktreeId) + const next = { ...current } + if (!lineage) { + delete next[childWorkspaceKey] + return next + } + next[childWorkspaceKey] = { + childWorkspaceKey, + childInstanceId: lineage.worktreeInstanceId, + parentWorkspaceKey: worktreeWorkspaceKey(lineage.parentWorktreeId), + parentInstanceId: lineage.parentWorktreeInstanceId, + origin: lineage.origin, + capture: lineage.capture, + ...(lineage.taskId ? { taskId: lineage.taskId } : {}), + ...(lineage.orchestrationRunId ? { orchestrationRunId: lineage.orchestrationRunId } : {}), + ...(lineage.coordinatorHandle ? { coordinatorHandle: lineage.coordinatorHandle } : {}), + ...(lineage.createdByTerminalHandle + ? { createdByTerminalHandle: lineage.createdByTerminalHandle } + : {}), + createdAt: lineage.createdAt + } + return next +} + +type WorktreeLineageUpdateResult = { + target: ReturnType + lineage: WorktreeLineage | null + updatedRemoteWorktree?: WorktreeWithLineage +} + +async function setWorktreeLineageForRuntime( + settings: AppState['settings'], + worktreeId: string, + args: { parentWorktreeId?: string; noParent?: boolean } +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'local') { + return { + target, + lineage: await window.api.worktrees.updateLineage({ worktreeId, ...args }) + } + } + const result = await callRuntimeRpc<{ worktree: WorktreeWithLineage }>( + target, + 'worktree.set', + { + worktree: toRuntimeWorktreeSelector(worktreeId), + ...(args.parentWorktreeId + ? { parentWorktree: toRuntimeWorktreeSelector(args.parentWorktreeId) } + : {}), + ...(args.noParent === true ? { noParent: true } : {}) + }, + { timeoutMs: 15_000 } + ) + return { + target, + lineage: result.worktree.lineage ?? null, + updatedRemoteWorktree: result.worktree + } +} + +function projectLocalWorktreeLineageUpdate( + worktreesByRepo: Record, + worktreeId: string, + lineage: WorktreeLineage | null +): Record { + let nextByRepo = worktreesByRepo + for (const [repoId, worktrees] of Object.entries(worktreesByRepo)) { + let repoChanged = false + const projected = worktrees.map((worktree) => { + const current = worktree as WorktreeWithLineage + const hadChild = current.childWorktreeIds?.includes(worktreeId) ?? false + const isParent = + lineage?.parentWorktreeId === worktree.id && + lineage.parentWorktreeInstanceId === worktree.instanceId + let childWorktreeIds = current.childWorktreeIds + if (hadChild) { + childWorktreeIds = childWorktreeIds?.filter((id) => id !== worktreeId) + } + if (isParent && !childWorktreeIds?.includes(worktreeId)) { + childWorktreeIds = [...(childWorktreeIds ?? []), worktreeId] + } + if (worktree.id === worktreeId) { + repoChanged = true + return { + ...worktree, + parentWorktreeId: lineage?.parentWorktreeId ?? null, + lineage + } + } + if (hadChild || isParent) { + repoChanged = true + return { ...worktree, childWorktreeIds } + } + return worktree + }) + if (repoChanged) { + if (nextByRepo === worktreesByRepo) { + nextByRepo = { ...worktreesByRepo } + } + nextByRepo[repoId] = projected + } + } + return nextByRepo +} + +function applyWorktreeLineageUpdate( + set: Parameters>[0], + worktreeId: string, + result: WorktreeLineageUpdateResult +): void { + set((s) => { + const next = { ...s.worktreeLineageById } + if (result.lineage) { + next[worktreeId] = result.lineage + } else { + delete next[worktreeId] + } + const worktreesByRepo = + result.target.kind === 'local' + ? projectLocalWorktreeLineageUpdate(s.worktreesByRepo, worktreeId, result.lineage) + : result.updatedRemoteWorktree + ? replaceWorktreeInRepoLists( + s.worktreesByRepo, + withRepoHostOwnership( + result.updatedRemoteWorktree, + repoHostId(s, getRepoIdFromWorktreeId(result.updatedRemoteWorktree.id)) + ) + ) + : s.worktreesByRepo + return { + worktreeLineageById: next, + workspaceLineageByChildKey: projectWorktreeLineageToWorkspaceLineage( + worktreeId, + result.lineage, + s.workspaceLineageByChildKey + ), + worktreesByRepo, + sortEpoch: s.sortEpoch + 1 + } + }) +} + +function applyHostLineageRefresh( + set: Parameters>[0], + hostId: ExecutionHostId, + lineage: { + worktreeLineageById: Readonly> + workspaceLineageByChildKey: Readonly> + } +): void { + set((s) => { + const worktreeLineageById = mergeLineageForHost(s, hostId, lineage.worktreeLineageById) + const workspaceLineageByChildKey = mergeWorkspaceLineageForHost( + s, + hostId, + lineage.workspaceLineageByChildKey + ) + if ( + worktreeLineageById === s.worktreeLineageById && + workspaceLineageByChildKey === s.workspaceLineageByChildKey + ) { + return s + } + return { worktreeLineageById, workspaceLineageByChildKey } + }) +} + +async function refreshWorktreeLineageForSettings( + settings: AppState['settings'], + set: Parameters>[0], + options: BackgroundRuntimeRefreshOptions = {} +): Promise { + const lineage = await listWorktreeLineageForRuntime(settings, options) + applyHostLineageRefresh(set, getSettingsFocusedExecutionHostId(settings), lineage) +} + +async function refreshRemoteWorktreeLineageBestEffort( + settings: AppState['settings'], + set: (partial: Partial | ((state: AppState) => Partial)) => void +): Promise { + if (getActiveRuntimeTarget(settings).kind === 'local') { + return + } + try { + const lineage = await listWorktreeLineageForRuntime(settings, { + reuseRecentCompatibilityFailure: true + }) + applyHostLineageRefresh(set, getSettingsFocusedExecutionHostId(settings), lineage) + } catch (err) { + // Why: lineage is supplemental, so a remote timeout here must not discard a successful worktree refresh. + console.error('Failed to fetch worktree lineage:', err) + } +} + +function getWorktreeHostId( + state: Pick, + worktreeId: string +): ExecutionHostId | null { + const worktree = findWorktreeById(state.worktreesByRepo, worktreeId) + if (worktree?.hostId) { + return worktree.hostId + } + const repoId = getRepoIdFromWorktreeId(worktreeId) + const detected = state.detectedWorktreesByRepo[repoId]?.worktrees.find( + (entry) => entry.id === worktreeId + ) + if (detected?.hostId) { + return detected.hostId + } + const repo = findRepoForHost(state.repos, repoId, { settings: state.settings }) + return repo ? getRepoExecutionHostId(repo) : null +} + +function mergeLineageForHost( + state: Pick< + AppState, + 'repos' | 'settings' | 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'worktreeLineageById' + >, + hostId: ExecutionHostId, + lineage: Readonly> +): Readonly> { + const next: Record = {} + for (const [worktreeId, existing] of Object.entries(state.worktreeLineageById)) { + if (getWorktreeHostId(state, worktreeId) !== hostId) { + next[worktreeId] = existing + } + } + for (const [worktreeId, incoming] of Object.entries(lineage)) { + next[worktreeId] = incoming + } + return reuseEqualRecordMap(state.worktreeLineageById, next) +} + +function mergeWorkspaceLineageForHost( + state: Pick< + AppState, + | 'repos' + | 'settings' + | 'worktreesByRepo' + | 'detectedWorktreesByRepo' + | 'workspaceLineageByChildKey' + >, + hostId: ExecutionHostId, + lineage: Readonly> +): Readonly> { + const next: Record = {} + for (const [childKey, existing] of Object.entries(state.workspaceLineageByChildKey)) { + const childScope = parseWorkspaceKey(existing.childWorkspaceKey) + const childHostId = + childScope?.type === 'worktree' ? getWorktreeHostId(state, childScope.worktreeId) : null + // A focused host refresh can no longer prove unknown-host child rows are current. + if (childScope?.type !== 'worktree' || (childHostId !== null && childHostId !== hostId)) { + next[childKey] = existing + } + } + for (const [childKey, incoming] of Object.entries(lineage)) { + next[childKey] = incoming + } + return reuseEqualRecordMap(state.workspaceLineageByChildKey, next) +} + +async function persistWorktreeMeta( + settings: AppState['settings'], + worktreeId: string, + updates: Partial +): Promise { + const target = getActiveRuntimeTarget(settings) + if (target.kind === 'local') { + await window.api.worktrees.updateMeta({ worktreeId, updates }) + return + } + // Why: `worktree.set` parses in strip mode, so an older runtime drops the key + // and applies the rest. Both gates key off presence, not value — a dropped + // *clear* strands a stale link that the Issue row then hides. + if ( + target.kind === 'environment' && + ('linkedWorkItem' in updates || 'linkedTaskSourceContext' in updates) + ) { + await assertRuntimeEnvironmentCapability( + target.environmentId, + WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY, + 'Update the remote runtime to change this workspace’s linked issue' + ) + } + // task-source-context.v1 is a sound proxy for the Linear keys: #5322 added them + // to the schema and is an ancestor of the commit introducing that capability. + if (target.kind === 'environment' && 'linkedLinearIssue' in updates) { + await assertRuntimeEnvironmentCapability( + target.environmentId, + TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, + 'Update the remote runtime to link Linear issues' + ) + } + await callRuntimeRpc( + target, + 'worktree.set', + { + worktree: toRuntimeWorktreeSelector(worktreeId), + ...encodePushTargetClearForRuntimeRpc(updates) + }, + { timeoutMs: 15_000 } + ) +} + +// Why: an SSH per-workspace-env project's host is the runtime-owned SSH target; once that runtime is destroyed, remove the project or it lingers as a dead, never-connectable one. +async function purgeOrphanedRuntimeSshProjects( + get: () => AppState, + destroyedSshTargetIds: string[] +): Promise { + if (destroyedSshTargetIds.length === 0) { + return + } + const destroyedTargetIds = new Set(destroyedSshTargetIds) + const destroyedHostIds = new Set( + destroyedSshTargetIds.map((id) => toSshExecutionHostId(id)) + ) + const orphanedSetupIds = get() + .projectHostSetups.filter((setup) => destroyedHostIds.has(setup.hostId)) + .map((setup) => setup.id) + const purgedRepoIds = new Set() + for (const setupId of orphanedSetupIds) { + try { + const result = await get().deleteProjectHostSetup({ setupId }) + if (result?.repo) { + purgedRepoIds.add(result.repo.id) + } + } catch (error) { + console.error('Failed to purge orphaned per-workspace-env project:', error) + } + } + // A repo whose only host was the destroyed runtime can outlive its setup (pruned first by a projection refresh); remove it directly so no dead project lingers. + const orphanedRepoIds = get() + .repos.filter( + (repo) => destroyedTargetIds.has(repo.connectionId ?? '') && !purgedRepoIds.has(repo.id) + ) + .map((repo) => repo.id) + for (const repoId of orphanedRepoIds) { + try { + await get().removeProject(repoId) + } catch (error) { + console.error('Failed to purge orphaned per-workspace-env repo:', error) + } + } +} + +async function resolveGitHubReviewPushTarget( + settings: AppState['settings'], + repoId: string, + prNumber: number +): Promise { + try { + const target = getActiveRuntimeTarget(settings) + const result = + target.kind === 'local' + ? await window.api.worktrees.resolvePrBase({ repoId, prNumber }) + : await callRuntimeRpc( + target, + 'worktree.resolvePrBase', + { repo: repoId, prNumber }, + { timeoutMs: 30_000 } + ) + if ('error' in result) { + console.warn(`Failed to resolve push target for PR #${prNumber}: ${result.error}`) + return undefined + } + return result.pushTarget + } catch (error) { + console.warn( + `Failed to resolve push target for PR #${prNumber}:`, + error instanceof Error ? error.message : error + ) + return undefined + } +} + +async function resolveGitLabReviewPushTarget( + settings: AppState['settings'], + repoId: string, + mrIid: number +): Promise { + try { + const target = getActiveRuntimeTarget(settings) + const result = + target.kind === 'local' + ? await window.api.worktrees.resolveMrBase({ repoId, mrIid }) + : await callRuntimeRpc< + | { baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget } + | { + error: string + } + >(target, 'worktree.resolveMrBase', { repo: repoId, mrIid }, { timeoutMs: 30_000 }) + if ('error' in result) { + console.warn(`Failed to resolve push target for MR !${mrIid}: ${result.error}`) + return undefined + } + return result.pushTarget + } catch (error) { + console.warn( + `Failed to resolve push target for MR !${mrIid}:`, + error instanceof Error ? error.message : error + ) + return undefined + } +} + +function getHostedReviewPushTargetLookup(worktree: Worktree): { + key: string + resolve: (settings: AppState['settings']) => Promise +} | null { + const hostScope = worktree.hostId ?? '' + if (isPositiveHostedReviewNumber(worktree.linkedPR)) { + const prNumber = worktree.linkedPR + return { + key: `${worktree.id}:${hostScope}:github:${prNumber}`, + resolve: (settings) => resolveGitHubReviewPushTarget(settings, worktree.repoId, prNumber) + } + } + if (isPositiveHostedReviewNumber(worktree.linkedGitLabMR)) { + const mrIid = worktree.linkedGitLabMR + return { + key: `${worktree.id}:${hostScope}:gitlab:${mrIid}`, + resolve: (settings) => resolveGitLabReviewPushTarget(settings, worktree.repoId, mrIid) + } + } + return null +} + +type HostedReviewLinkKey = + | 'linkedPR' + | 'linkedGitLabMR' + | 'linkedBitbucketPR' + | 'linkedAzureDevOpsPR' + | 'linkedGiteaPR' + +const HOSTED_REVIEW_LINK_KEYS: readonly HostedReviewLinkKey[] = [ + 'linkedPR', + 'linkedGitLabMR', + 'linkedBitbucketPR', + 'linkedAzureDevOpsPR', + 'linkedGiteaPR' +] + +const CLEARED_HOSTED_REVIEW_LINK_UPDATES: Pick = { + linkedPR: null, + linkedGitLabMR: null, + linkedBitbucketPR: null, + linkedAzureDevOpsPR: null, + linkedGiteaPR: null, + pushTarget: undefined +} + +const hostedReviewLinkMutationGenerationByWorktreeId = new Map() +const hostedReviewLinkClearTombstonesByWorktreeId = new Map< + string, + { branch: string; branchIdentity: string; generation: number; head?: string } +>() +const hostedReviewLinkWorktreeIdAliases = new Map() + +function hasHostedReviewLinks(worktree: Worktree): boolean { + return HOSTED_REVIEW_LINK_KEYS.some((key) => worktree[key] != null) +} + +function hasBranchScopedHostedReviewContext(worktree: Worktree): boolean { + return hasHostedReviewLinks(worktree) || worktree.pushTarget !== undefined +} + +function hasHostedReviewLinkUpdates(updates: Partial): boolean { + return HOSTED_REVIEW_LINK_KEYS.some((key) => key in updates) || 'pushTarget' in updates +} + +function getHostedReviewLinkMutationGeneration(worktreeId: string): number { + return hostedReviewLinkMutationGenerationByWorktreeId.get(worktreeId) ?? 0 +} + +function bumpHostedReviewLinkMutationGeneration(worktreeId: string): void { + hostedReviewLinkMutationGenerationByWorktreeId.set( + worktreeId, + getHostedReviewLinkMutationGeneration(worktreeId) + 1 + ) + hostedReviewLinkClearTombstonesByWorktreeId.delete(worktreeId) + pruneHostedReviewLinkWorktreeAliasesForId(worktreeId) +} + +function pruneHostedReviewLinkMutationGenerations(worktreeIds: Iterable): void { + for (const worktreeId of worktreeIds) { + hostedReviewLinkMutationGenerationByWorktreeId.delete(worktreeId) + hostedReviewLinkClearTombstonesByWorktreeId.delete(worktreeId) + hostedReviewLinkWorktreeIdAliases.delete(worktreeId) + for (const [oldWorktreeId, newWorktreeId] of hostedReviewLinkWorktreeIdAliases) { + if (newWorktreeId === worktreeId) { + hostedReviewLinkWorktreeIdAliases.delete(oldWorktreeId) + } + } + } +} + +function resolveHostedReviewLinkWorktreeId(worktreeId: string): string { + let current = worktreeId + const seen = new Set() + while (!seen.has(current)) { + seen.add(current) + const next = hostedReviewLinkWorktreeIdAliases.get(current) + if (!next) { + return current + } + current = next + } + return worktreeId +} + +function pruneHostedReviewLinkWorktreeAliasesForId(worktreeId: string): void { + for (const [alias, target] of Array.from(hostedReviewLinkWorktreeIdAliases)) { + if ( + alias === worktreeId || + target === worktreeId || + resolveHostedReviewLinkWorktreeId(alias) === worktreeId + ) { + hostedReviewLinkWorktreeIdAliases.delete(alias) + } + } +} + +function migrateHostedReviewLinkMutationGeneration( + oldWorktreeId: string, + newWorktreeId: string +): void { + const tombstone = hostedReviewLinkClearTombstonesByWorktreeId.get(oldWorktreeId) + for (const [alias, target] of hostedReviewLinkWorktreeIdAliases) { + if (target === oldWorktreeId) { + if (tombstone) { + hostedReviewLinkWorktreeIdAliases.set(alias, newWorktreeId) + } else { + hostedReviewLinkWorktreeIdAliases.delete(alias) + } + } + } + const hasGeneration = hostedReviewLinkMutationGenerationByWorktreeId.has(oldWorktreeId) + if (tombstone) { + hostedReviewLinkWorktreeIdAliases.set(oldWorktreeId, newWorktreeId) + } + if (hasGeneration) { + hostedReviewLinkMutationGenerationByWorktreeId.set( + newWorktreeId, + getHostedReviewLinkMutationGeneration(oldWorktreeId) + ) + hostedReviewLinkMutationGenerationByWorktreeId.delete(oldWorktreeId) + } + if (tombstone) { + hostedReviewLinkClearTombstonesByWorktreeId.set(newWorktreeId, tombstone) + hostedReviewLinkClearTombstonesByWorktreeId.delete(oldWorktreeId) + } +} + +export function getHostedReviewLinkMutationGenerationForTests(worktreeId: string): number { + return getHostedReviewLinkMutationGeneration(worktreeId) +} + +export function getHostedReviewLinkWorktreeAliasCountForTests(): number { + return hostedReviewLinkWorktreeIdAliases.size +} + +export function resetHostedReviewLinkMutationGenerationForTests(): void { + hostedReviewLinkMutationGenerationByWorktreeId.clear() + hostedReviewLinkClearTombstonesByWorktreeId.clear() + hostedReviewLinkWorktreeIdAliases.clear() +} + +export function setDetachedHeadAutoDerivedDisplayNameForTests( + worktreeId: string, + displayName: string +): void { + detachedHeadAutoDerivedDisplayNames.set(worktreeId, displayName) +} + +export function getDetachedHeadAutoDerivedDisplayNameForTests( + worktreeId: string +): string | undefined { + return detachedHeadAutoDerivedDisplayNames.get(worktreeId) +} + +function hostedReviewLinksAreCleared(worktree: Worktree): boolean { + return HOSTED_REVIEW_LINK_KEYS.every((key) => worktree[key] == null) && !worktree.pushTarget +} + +function getHostedReviewLinkUpdates( + worktree: Worktree +): Pick { + return { + linkedPR: worktree.linkedPR ?? null, + linkedGitLabMR: worktree.linkedGitLabMR ?? null, + linkedBitbucketPR: worktree.linkedBitbucketPR ?? null, + linkedAzureDevOpsPR: worktree.linkedAzureDevOpsPR ?? null, + linkedGiteaPR: worktree.linkedGiteaPR ?? null, + pushTarget: worktree.pushTarget + } +} + +function canonicalHostedReviewBranchIdentity(branch: string): string { + return branchName(branch).trim() +} + +function rememberHostedReviewLinkClear( + worktreeId: string, + branch: string, + generation: number, + head?: string +): void { + hostedReviewLinkClearTombstonesByWorktreeId.set(worktreeId, { + branch, + branchIdentity: canonicalHostedReviewBranchIdentity(branch), + generation, + head + }) +} + +function sanitizeHostedReviewLinksForBranchClear< + T extends Pick & + Partial> +>(worktree: T, currentWorktrees?: readonly T[]): T { + const hostedReviewWorktreeId = resolveHostedReviewLinkWorktreeId(worktree.id) + const tombstone = hostedReviewLinkClearTombstonesByWorktreeId.get(hostedReviewWorktreeId) + const hasBranchScopedContext = + HOSTED_REVIEW_LINK_KEYS.some((key) => worktree[key] != null) || + worktree.pushTarget !== undefined + if ( + !tombstone || + tombstone.generation !== getHostedReviewLinkMutationGeneration(hostedReviewWorktreeId) || + !hasBranchScopedContext + ) { + return worktree + } + const current = currentWorktrees?.find( + (entry) => + entry.id === worktree.id || + resolveHostedReviewLinkWorktreeId(entry.id) === hostedReviewWorktreeId + ) + const currentClean = + current && + !HOSTED_REVIEW_LINK_KEYS.some((key) => current[key] != null) && + current.pushTarget === undefined + ? current + : null + const guardBranch = currentClean ? currentClean.branch : tombstone.branch + const guardHead = currentClean ? currentClean.head : tombstone.head + return { + ...worktree, + branch: guardBranch, + ...(guardHead !== undefined ? { head: guardHead } : {}), + ...CLEARED_HOSTED_REVIEW_LINK_UPDATES + } +} + +function sanitizeHostedReviewLinksForBranchClears< + T extends Pick & + Partial> +>(worktrees: readonly T[], currentWorktrees?: readonly T[]): T[] { + let changed = false + const sanitized = worktrees.map((worktree) => { + const next = sanitizeHostedReviewLinksForBranchClear(worktree, currentWorktrees) + if (next !== worktree) { + changed = true + } + return next + }) + return changed ? sanitized : [...worktrees] +} + +function applyHostedReviewLinkClear( + set: Parameters>[0], + worktreeId: string +): void { + set((s) => { + const nextWorktrees = applyWorktreeUpdates( + s.worktreesByRepo, + worktreeId, + CLEARED_HOSTED_REVIEW_LINK_UPDATES + ) + const nextDetectedWorktrees = applyDetectedWorktreeUpdates( + s.detectedWorktreesByRepo, + worktreeId, + CLEARED_HOSTED_REVIEW_LINK_UPDATES + ) + if ( + nextWorktrees === s.worktreesByRepo && + nextDetectedWorktrees === s.detectedWorktreesByRepo + ) { + return {} + } + return { + ...(nextWorktrees !== s.worktreesByRepo + ? { worktreesByRepo: nextWorktrees, sortEpoch: s.sortEpoch + 1 } + : {}), + ...(nextDetectedWorktrees !== s.detectedWorktreesByRepo + ? { detectedWorktreesByRepo: nextDetectedWorktrees } + : {}) + } + }) +} + +function getPositiveHostedReviewLinkUpdateKey( + updates: Partial +): HostedReviewLinkKey | null { + for (const key of HOSTED_REVIEW_LINK_KEYS) { + if (isPositiveHostedReviewNumber(updates[key])) { + return key + } + } + return null +} + +function clearOlderHostedReviewLinksForReplacement( + updates: Partial, + existingWorktree: Worktree +): Partial { + const replacementKey = getPositiveHostedReviewLinkUpdateKey(updates) + if (!replacementKey) { + return updates + } + let normalized = updates + for (const key of HOSTED_REVIEW_LINK_KEYS) { + if (key === replacementKey || existingWorktree[key] == null) { + continue + } + // Why: one branch pushes to one hosted-review head; stale provider links would win the target lookup after replacement. + normalized = normalized === updates ? { ...updates } : normalized + normalized[key] = null + } + return normalized +} + +function getHostedReviewLinkForMetaRefresh( + updates: Partial, + existingWorktree: Worktree | undefined, + key: HostedReviewLinkKey +): number | null { + return Object.hasOwn(updates, key) ? (updates[key] ?? null) : (existingWorktree?.[key] ?? null) +} + +function hasExplicitPushTargetClear(updates: Partial): boolean { + return Object.hasOwn(updates, 'pushTarget') && updates.pushTarget === undefined +} + +type RuntimeWorktreeMetaUpdates = Omit, 'pushTarget'> & { + pushTarget?: GitPushTarget | null +} + +function encodePushTargetClearForRuntimeRpc( + updates: Partial +): RuntimeWorktreeMetaUpdates { + if (!hasExplicitPushTargetClear(updates)) { + return updates + } + // Why: remote runtime RPC is JSON-shaped and drops undefined, so null is the wire signal for clearing persisted pushTarget metadata. + return { ...updates, pushTarget: null } +} + +// Every worktree-id-keyed store map the rename path re-keys, so a new `*ByWorktree` map isn't silently missed. +// Tab-id/file-id-keyed maps are deliberately excluded: tabs and files keep their ids across a rename. +const WORKTREE_ID_KEYED_MAP_KEYS = [ + 'worktreeLineageById', + 'tabsByWorktree', + 'deleteStateByWorktreeId', + 'baseStatusByWorktreeId', + 'remoteBranchConflictByWorktreeId', + 'fileSearchStateByWorktree', + 'browserTabsByWorktree', + 'recentlyClosedBrowserTabsByWorktree', + 'activeBrowserTabIdByWorktree', + 'activeFileIdByWorktree', + 'activeTabTypeByWorktree', + 'activeTabIdByWorktree', + 'tabBarOrderByWorktree', + 'pendingReconnectTabByWorktree', + 'rightSidebarTabByWorktree', + 'rightSidebarExplorerViewByWorktree', + 'unifiedTabsByWorktree', + 'groupsByWorktree', + 'layoutByWorktree', + 'activeGroupIdByWorktree', + 'gitStatusByWorktree', + 'gitStatusHeadByWorktree', + 'gitBranchLineTotalByWorktree', + 'gitIgnoredPathsByWorktree', + 'gitConflictOperationByWorktree', + 'trackedConflictPathsByWorktree', + 'gitBranchChangesByWorktree', + 'gitBranchCompareSummaryByWorktree', + 'gitBranchCompareRequestKeyByWorktree', + 'gitBranchCompareRequestStatusHeadByWorktree', + 'showDotfilesByWorktree', + 'expandedDirs', + 'lastVisitedAtByWorktreeId', + 'defaultTerminalTabsAppliedByWorktreeId', + 'recentlyClosedTabKindsByWorktree' +] as const satisfies readonly (keyof AppState)[] + +/** + * Re-key every worktree-id-keyed map from `oldWorktreeId` to `newWorktreeId` after a folder + * rename. Tab-id/file-id-keyed maps and active/renaming pointers stay put since tabs/files keep their ids. + * Main-process counterpart: `Store.migrateWorktreeIdentity` in persistence.ts. + */ +function buildWorktreeRenameState( + s: AppState, + oldWorktreeId: string, + newWorktreeId: string +): Partial { + if (oldWorktreeId === newWorktreeId) { + return {} + } + const renamed: Record = {} + const renameKey = ( + key: keyof AppState, + mapValue: (value: T) => T = (value) => value + ): void => { + const map = s[key as keyof AppState] as Record | undefined + if (!map || !(oldWorktreeId in map)) { + return + } + const next = { ...map } + next[newWorktreeId] = mapValue(next[oldWorktreeId] as T) + delete next[oldWorktreeId] + renamed[key] = next + } + const withNewWorktreeId = (value: T): T => + value.worktreeId === oldWorktreeId ? { ...value, worktreeId: newWorktreeId } : value + const renameValueByKey: Partial> = { + tabsByWorktree: (tabs: { worktreeId: string }[]) => tabs.map(withNewWorktreeId), + browserTabsByWorktree: (workspaces: { worktreeId: string }[]) => + workspaces.map(withNewWorktreeId), + recentlyClosedBrowserTabsByWorktree: ( + snapshots: { workspace: { worktreeId: string }; pages: { worktreeId: string }[] }[] + ) => + snapshots.map((snapshot) => ({ + ...snapshot, + workspace: withNewWorktreeId(snapshot.workspace), + pages: snapshot.pages.map(withNewWorktreeId) + })), + fileSearchStateByWorktree: (searchState: AppState['fileSearchStateByWorktree'][string]) => ({ + ...searchState, + resultOwner: searchState.resultOwner ? withNewWorktreeId(searchState.resultOwner) : null + }), + unifiedTabsByWorktree: (tabs: { worktreeId: string }[]) => tabs.map(withNewWorktreeId), + groupsByWorktree: (groups: { worktreeId: string }[]) => groups.map(withNewWorktreeId) + } + for (const key of WORKTREE_ID_KEYED_MAP_KEYS) { + renameKey(key, renameValueByKey[key] as ((value: unknown) => unknown) | undefined) + } + // Re-key on rename so a renamed worktree keeps its editor-undo + push/pull state. + renameKey('recentlyClosedEditorTabsByWorktree', (files: { worktreeId: string }[]) => + files.map(withNewWorktreeId) + ) + // Why: terminal reopen snapshots hold absolute startupCwd paths under the old folder; remap or Cmd+Shift+T respawns into a directory that no longer exists after the rename. + const oldWorktreePath = splitWorktreeIdForFilesystem(oldWorktreeId)?.worktreePath + const newWorktreePath = splitWorktreeIdForFilesystem(newWorktreeId)?.worktreePath + renameKey('recentlyClosedTerminalTabsByWorktree', (snapshots: ClosedTerminalTabSnapshot[]) => + oldWorktreePath && newWorktreePath + ? remapClosedTerminalTabSnapshotCwds(snapshots, oldWorktreePath, newWorktreePath) + : snapshots + ) + renameKey('remoteStatusesByWorktree') + + const openFiles = s.openFiles?.some((f) => f.worktreeId === oldWorktreeId) + ? s.openFiles.map((f) => + f.worktreeId === oldWorktreeId ? { ...f, worktreeId: newWorktreeId } : f + ) + : s.openFiles + const currentBrowserPagesByWorkspace = s.browserPagesByWorkspace ?? {} + const browserPagesByWorkspace = Object.values(currentBrowserPagesByWorkspace).some((pages) => + pages.some((page) => page.worktreeId === oldWorktreeId) + ) + ? Object.fromEntries( + Object.entries(currentBrowserPagesByWorkspace).map(([workspaceId, pages]) => [ + workspaceId, + pages.map(withNewWorktreeId) + ]) + ) + : s.browserPagesByWorkspace + const currentRecentlyClosedBrowserPagesByWorkspace = s.recentlyClosedBrowserPagesByWorkspace ?? {} + const recentlyClosedBrowserPagesByWorkspace = Object.values( + currentRecentlyClosedBrowserPagesByWorkspace + ).some((pages) => pages.some((page) => page.worktreeId === oldWorktreeId)) + ? Object.fromEntries( + Object.entries(currentRecentlyClosedBrowserPagesByWorkspace).map(([workspaceId, pages]) => [ + workspaceId, + pages.map(withNewWorktreeId) + ]) + ) + : s.recentlyClosedBrowserPagesByWorkspace + let everActivated = s.everActivatedWorktreeIds + if (everActivated.has(oldWorktreeId)) { + everActivated = new Set(everActivated) + everActivated.delete(oldWorktreeId) + everActivated.add(newWorktreeId) + } + const pendingReconnectWorktreeIds = s.pendingReconnectWorktreeIds?.includes(oldWorktreeId) + ? s.pendingReconnectWorktreeIds.map((id) => (id === oldWorktreeId ? newWorktreeId : id)) + : s.pendingReconnectWorktreeIds + const currentSleepingAgentSessionsByPaneKey = s.sleepingAgentSessionsByPaneKey ?? {} + const sleepingAgentSessionsByPaneKey = Object.values(currentSleepingAgentSessionsByPaneKey).some( + (record) => record.worktreeId === oldWorktreeId + ) + ? Object.fromEntries( + Object.entries(currentSleepingAgentSessionsByPaneKey).map(([paneKey, record]) => [ + paneKey, + record.worktreeId === oldWorktreeId ? { ...record, worktreeId: newWorktreeId } : record + ]) + ) + : s.sleepingAgentSessionsByPaneKey + + return { + ...(renamed as Partial), + ...(openFiles !== s.openFiles ? { openFiles } : {}), + ...(browserPagesByWorkspace !== s.browserPagesByWorkspace ? { browserPagesByWorkspace } : {}), + ...(recentlyClosedBrowserPagesByWorkspace !== s.recentlyClosedBrowserPagesByWorkspace + ? { recentlyClosedBrowserPagesByWorkspace } + : {}), + ...(everActivated !== s.everActivatedWorktreeIds + ? { everActivatedWorktreeIds: everActivated } + : {}), + ...(pendingReconnectWorktreeIds !== s.pendingReconnectWorktreeIds + ? { pendingReconnectWorktreeIds } + : {}), + ...(sleepingAgentSessionsByPaneKey !== s.sleepingAgentSessionsByPaneKey + ? { sleepingAgentSessionsByPaneKey } + : {}), + ...(s.activeWorktreeId === oldWorktreeId ? { activeWorktreeId: newWorktreeId } : {}), + // The active workspace key derives from the worktree id, so keep it in sync when the active worktree is renamed. + ...(s.activeWorkspaceKey === worktreeWorkspaceKey(oldWorktreeId) + ? { activeWorkspaceKey: worktreeWorkspaceKey(newWorktreeId) } + : {}), + ...(s.renamingWorktreeId?.worktreeId === oldWorktreeId + ? { renamingWorktreeId: { ...s.renamingWorktreeId, worktreeId: newWorktreeId } } + : {}) + } +} + +function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial { + const worktreeIdSet = new Set(worktreeIds) + pruneHostedReviewLinkMutationGenerations(worktreeIdSet) + // Why: every authoritative and explicit purge converges here, so a deleted path can't inherit stale UI state. + forgetHugeRepoWarningDismissalsForWorktrees(worktreeIdSet) + + // Collect every tab id (and removed file id) we are about to orphan. + const doomedTabIds = new Set() + // Why: some terminal/agent maps are keyed by ptyId, not tabId; collect durable wake hints too since slept panes have left the live index. + const doomedPtyIds = new Set() + const addDoomedPtyId = (ptyId: string | null | undefined): void => { + if (!ptyId) { + return + } + doomedPtyIds.add(ptyId) + } + const addDoomedTabPtyIds = (tabId: string, tabPtyId: string | null | undefined): void => { + for (const ptyId of s.ptyIdsByTabId?.[tabId] ?? []) { + addDoomedPtyId(ptyId) + } + addDoomedPtyId(tabPtyId) + addDoomedPtyId(s.lastKnownRelayPtyIdByTabId?.[tabId]) + for (const ptyId of Object.values(s.terminalLayoutsByTabId?.[tabId]?.ptyIdsByLeafId ?? {})) { + addDoomedPtyId(ptyId) + } + } + const doomedBrowserWorkspaceIds = new Set() + const doomedPageIds = new Set() + const removedFileIds = new Set() + for (const id of worktreeIdSet) { + for (const tab of s.tabsByWorktree[id] ?? []) { + doomedTabIds.add(tab.id) + // Null-tolerant like the omit* helpers below: some callers pass partial state omitting this slice (production store always inits to {}). + addDoomedTabPtyIds(tab.id, tab.ptyId) + // Why: a removed worktree's panes are gone, so drop their hibernation output epochs from the module-level map (a future pane mints a fresh leafId at epoch 0). + forgetAgentHibernationTabOutput(tab.id) + } + for (const workspace of s.browserTabsByWorktree[id] ?? []) { + doomedBrowserWorkspaceIds.add(workspace.id) + } + // Why: drop the auto-derived detached-HEAD display name so the module-level map doesn't retain removed worktrees for the session. + detachedHeadAutoDerivedDisplayNames.delete(id) + } + // Why: same rationale for doomed tabs' foreground last-seen timestamps and agent-startup delivery guards — retired tab ids never recur. + forgetForegroundTerminalTabs(doomedTabIds) + forgetAgentStartupDeliveriesForTabs(doomedTabIds) + // Why: pane-authority aliases outlive the store maps they route to, so a purged + // tab would leave a permanent entry pointing at a pane that no longer exists. + forgetAgentPaneAuthorityAliasesByTabIds(doomedTabIds) + // Why: per-page browser maps are keyed by page id, so collect every page of a doomed workspace to evict here (the authoritative-scan reconcile skips closeBrowserTab's cleanup). + for (const workspaceId of doomedBrowserWorkspaceIds) { + for (const page of s.browserPagesByWorkspace[workspaceId] ?? []) { + doomedPageIds.add(page.id) + } + } + for (const file of s.openFiles) { + if (worktreeIdSet.has(file.worktreeId)) { + removedFileIds.add(file.id) + if (file.markdownPreviewSourceFileId) { + removedFileIds.add(file.markdownPreviewSourceFileId) + } + } + } + + const omitByWorktree = (obj: Record): Record => { + let changed = false + const out = { ...obj } + for (const id of worktreeIdSet) { + if (id in out) { + delete out[id] + changed = true + } + } + return changed ? out : obj + } + const omitWorkspaceLineageByWorktree = ( + obj: Record + ): Record => { + let changed = false + const out = { ...obj } + for (const id of worktreeIdSet) { + const childKey = isWorkspaceKey(id) ? id : worktreeWorkspaceKey(id) + if (childKey in out) { + delete out[childKey] + changed = true + } + } + return changed ? out : obj + } + const pruneRightSidebarTabByWorktree = (): AppState['rightSidebarTabByWorktree'] => { + const omitted = omitByWorktree(s.rightSidebarTabByWorktree) + let changed = omitted !== s.rightSidebarTabByWorktree + const out: AppState['rightSidebarTabByWorktree'] = {} + for (const [id, tab] of Object.entries(omitted)) { + if ( + tab === 'explorer' || + tab === 'vault' || + tab === 'workspaces' || + tab === 'source-control' || + tab === 'checks' || + tab === 'ports' + ) { + out[id] = tab + } else { + changed = true + } + } + return changed ? out : omitted + } + const omitByTabId = (obj: Record): Record => { + let changed = false + const out = { ...obj } + for (const tabId of doomedTabIds) { + if (tabId in out) { + delete out[tabId] + changed = true + } + } + return changed ? out : obj + } + const survivingTabIds = new Set( + Object.entries(s.tabsByWorktree) + .filter(([worktreeId]) => !worktreeIdSet.has(worktreeId)) + .flatMap(([, tabs]) => tabs.map((tab) => tab.id)) + ) + const omitRetiredDirectSshLedgerByTabId = (obj: Record): Record => { + let changed = false + const out = { ...obj } + for (const tabId of doomedTabIds) { + if (!survivingTabIds.has(tabId) && tabId in out) { + delete out[tabId] + changed = true + } + } + return changed ? out : obj + } + const omitByPtyId = (obj: Record): Record => { + let changed = false + const out = { ...obj } + for (const ptyId of doomedPtyIds) { + if (ptyId in out) { + delete out[ptyId] + changed = true + } + } + return changed ? out : obj + } + // Pane-scoped maps are keyed `${tabId}:${leafId}`; tabId never contains ":", so the prefix before the first ":" is the owning tab. + const omitByPaneKeyTabPrefix = (obj: Record): Record => { + // Null-tolerant like omitByTabId: some worktree-isolation callers omit these slices (production store always inits to {}). + if (!obj) { + return obj + } + let changed = false + const out = { ...obj } + for (const paneKey of Object.keys(obj)) { + const sep = paneKey.indexOf(':') + if (sep > 0 && doomedTabIds.has(paneKey.slice(0, sep))) { + delete out[paneKey] + changed = true + } + } + return changed ? out : obj + } + const omitByBrowserWorkspaceId = (obj: Record): Record => { + let changed = false + const out = { ...obj } + for (const workspaceId of doomedBrowserWorkspaceIds) { + if (workspaceId in out) { + delete out[workspaceId] + changed = true + } + } + return changed ? out : obj + } + const omitByPageId = (obj: Record): Record => { + let changed = false + const out = { ...obj } + for (const pageId of doomedPageIds) { + if (pageId in out) { + delete out[pageId] + changed = true + } + } + return changed ? out : obj + } + const omitByFileId = (obj: Record): Record => { + let changed = false + const out = { ...obj } + for (const fileId of removedFileIds) { + if (fileId in out) { + delete out[fileId] + changed = true + } + } + return changed ? out : obj + } + + const nextOpenFiles = s.openFiles.some((f) => worktreeIdSet.has(f.worktreeId)) + ? s.openFiles.filter((f) => !worktreeIdSet.has(f.worktreeId)) + : s.openFiles + + const removedActive = s.activeWorktreeId != null && worktreeIdSet.has(s.activeWorktreeId) + const activeFileCleared = s.activeFileId != null && removedFileIds.has(s.activeFileId) + const activeTabCleared = s.activeTabId != null && doomedTabIds.has(s.activeTabId) + + const nextEverActivatedWorktreeIds = (() => { + let hit = false + for (const id of worktreeIdSet) { + if (s.everActivatedWorktreeIds.has(id)) { + hit = true + break + } + } + if (!hit) { + return s.everActivatedWorktreeIds + } + const next = new Set(s.everActivatedWorktreeIds) + for (const id of worktreeIdSet) { + next.delete(id) + } + return next + })() + const nextAgentStatusByPaneKey = omitByPaneKeyTabPrefix(s.agentStatusByPaneKey) + + return { + // Worktree-scoped terminal/tab state + worktreeLineageById: omitByWorktree(s.worktreeLineageById), + workspaceLineageByChildKey: omitWorkspaceLineageByWorktree(s.workspaceLineageByChildKey), + tabsByWorktree: omitByWorktree(s.tabsByWorktree), + terminalLayoutsByTabId: omitByTabId(s.terminalLayoutsByTabId), + ptyIdsByTabId: omitByTabId(s.ptyIdsByTabId), + runtimePaneTitlesByTabId: omitByTabId(s.runtimePaneTitlesByTabId), + automaticAgentResumeClaimsByTabId: omitByTabId(s.automaticAgentResumeClaimsByTabId), + nativeChatLaunchPromptByTabId: omitByTabId(s.nativeChatLaunchPromptByTabId), + nativeChatLaunchDraftByTabId: omitByTabId(s.nativeChatLaunchDraftByTabId), + // Why: bulk/hydration purge runs no terminal teardown, so it must drop the per-tab pane-expand flags itself. + expandedPaneByTabId: omitByTabId(s.expandedPaneByTabId), + canExpandPaneByTabId: omitByTabId(s.canExpandPaneByTabId), + // Why: these per-tab/per-pty terminal+agent maps evict on the single removeWorktree teardown path; the bulk reconcile / remove-project / hydration-stale paths run no teardown, so without these each strands an entry per tab/pane of externally-removed worktrees. + lastKnownRelayPtyIdByTabId: omitByTabId(s.lastKnownRelayPtyIdByTabId), + // Why: liveness-authoritative reconnect maps (orphan sweep reads them); drop purged tabs' entries here too so a re-materialized id can't inherit phantom liveness. + pendingReconnectPtyIdByTabId: omitByTabId(s.pendingReconnectPtyIdByTabId), + deferredSshSessionIdsByTabId: omitByTabId(s.deferredSshSessionIdsByTabId), + pendingInitialCwdByTabId: omitByTabId(s.pendingInitialCwdByTabId), + pendingIssueCommandSplitByTabId: omitByTabId(s.pendingIssueCommandSplitByTabId), + pendingSetupSplitByTabId: omitByTabId(s.pendingSetupSplitByTabId), + pendingStartupByTabId: omitByTabId(s.pendingStartupByTabId), + directSshPaneRetryByTabId: omitRetiredDirectSshLedgerByTabId(s.directSshPaneRetryByTabId), + directSshLivePtyBindingByTabId: omitRetiredDirectSshLedgerByTabId( + s.directSshLivePtyBindingByTabId + ), + directSshPaneRetryHistoryByTabId: omitRetiredDirectSshLedgerByTabId( + s.directSshPaneRetryHistoryByTabId + ), + codexRestartNoticeByPtyId: omitByPtyId(s.codexRestartNoticeByPtyId), + migrationUnsupportedByPtyId: omitByPtyId(s.migrationUnsupportedByPtyId), + suppressedPtyExitIds: omitByPtyId(s.suppressedPtyExitIds), + pendingCodexPaneRestartIds: omitByPtyId(s.pendingCodexPaneRestartIds), + // Why: these agent-status/unread/input maps clear only on the single removeWorktree teardown path; the bulk reconcile / remove-project / hydration-stale paths run no teardown, so without this they orphan an entry per agent pane (plus a phantom unread badge). + // retainedAgentsByPaneKey and runtimeAgentOrchestrationByPaneKey are omitted here — both self-heal (pruneRetainedAgents on worktreesByRepo change; runtime map replaced wholesale each sync). + agentStatusByPaneKey: nextAgentStatusByPaneKey, + ...(nextAgentStatusByPaneKey !== s.agentStatusByPaneKey + ? { agentStatusEpoch: s.agentStatusEpoch + 1 } + : {}), + agentLaunchConfigByPaneKey: omitByPaneKeyTabPrefix(s.agentLaunchConfigByPaneKey), + acknowledgedAgentsByPaneKey: omitByPaneKeyTabPrefix(s.acknowledgedAgentsByPaneKey), + paneForegroundAgentByPaneKey: omitByPaneKeyTabPrefix(s.paneForegroundAgentByPaneKey), + sleepingAgentSessionsByPaneKey: omitByPaneKeyTabPrefix(s.sleepingAgentSessionsByPaneKey), + unreadTerminalTabs: omitByTabId(s.unreadTerminalTabs), + unreadTerminalPanes: omitByPaneKeyTabPrefix(s.unreadTerminalPanes), + unreadAgentCompletionPanes: omitByPaneKeyTabPrefix(s.unreadAgentCompletionPanes), + lastTerminalInputAtByPaneKey: omitByPaneKeyTabPrefix(s.lastTerminalInputAtByPaneKey), + // Delete state + deleteStateByWorktreeId: omitByWorktree(s.deleteStateByWorktreeId), + baseStatusByWorktreeId: omitByWorktree(s.baseStatusByWorktreeId), + remoteBranchConflictByWorktreeId: omitByWorktree(s.remoteBranchConflictByWorktreeId), + // File search + fileSearchStateByWorktree: omitByWorktree(s.fileSearchStateByWorktree), + // Browser state + browserTabsByWorktree: omitByWorktree(s.browserTabsByWorktree), + browserPagesByWorkspace: omitByBrowserWorkspaceId(s.browserPagesByWorkspace), + recentlyClosedBrowserTabsByWorktree: omitByWorktree(s.recentlyClosedBrowserTabsByWorktree), + activeBrowserTabIdByWorktree: omitByWorktree(s.activeBrowserTabIdByWorktree), + // Why: keyed by page/workspace id, only cleaned by closeBrowserTab on the single-removal path; the bulk reconcile missed them, orphaning an entry per page of externally-removed worktrees. + browserAnnotationsByPageId: omitByPageId(s.browserAnnotationsByPageId), + remoteBrowserPageHandlesByPageId: omitByPageId(s.remoteBrowserPageHandlesByPageId), + pendingAddressBarFocusByPageId: omitByPageId(s.pendingAddressBarFocusByPageId), + // createBrowserTab writes both the workspace id and the page id into this map. + pendingAddressBarFocusByTabId: omitByPageId( + omitByBrowserWorkspaceId(s.pendingAddressBarFocusByTabId) + ), + recentlyClosedBrowserPagesByWorkspace: omitByBrowserWorkspaceId( + s.recentlyClosedBrowserPagesByWorkspace + ), + // Editor state + activeFileIdByWorktree: omitByWorktree(s.activeFileIdByWorktree), + activeTabTypeByWorktree: omitByWorktree(s.activeTabTypeByWorktree), + activeTabIdByWorktree: omitByWorktree(s.activeTabIdByWorktree), + tabBarOrderByWorktree: omitByWorktree(s.tabBarOrderByWorktree), + pendingReconnectTabByWorktree: omitByWorktree(s.pendingReconnectTabByWorktree), + rightSidebarTabByWorktree: pruneRightSidebarTabByWorktree(), + rightSidebarExplorerViewByWorktree: omitByWorktree(s.rightSidebarExplorerViewByWorktree ?? {}), + // Split-tab / unified tab state + unifiedTabsByWorktree: omitByWorktree(s.unifiedTabsByWorktree), + groupsByWorktree: omitByWorktree(s.groupsByWorktree), + layoutByWorktree: omitByWorktree(s.layoutByWorktree), + activeGroupIdByWorktree: omitByWorktree(s.activeGroupIdByWorktree), + // Git status caches + gitStatusByWorktree: omitByWorktree(s.gitStatusByWorktree), + // Why: keyed by worktreeId; re-keyed on rename but missed by both removal paths (upstream-status entry). + remoteStatusesByWorktree: omitByWorktree(s.remoteStatusesByWorktree), + gitStatusHeadByWorktree: omitByWorktree(s.gitStatusHeadByWorktree), + gitBranchLineTotalByWorktree: omitByWorktree(s.gitBranchLineTotalByWorktree), + gitIgnoredPathsByWorktree: omitByWorktree(s.gitIgnoredPathsByWorktree), + gitConflictOperationByWorktree: omitByWorktree(s.gitConflictOperationByWorktree), + trackedConflictPathsByWorktree: omitByWorktree(s.trackedConflictPathsByWorktree), + gitBranchChangesByWorktree: omitByWorktree(s.gitBranchChangesByWorktree), + gitBranchCompareSummaryByWorktree: omitByWorktree(s.gitBranchCompareSummaryByWorktree), + gitBranchCompareRequestKeyByWorktree: omitByWorktree(s.gitBranchCompareRequestKeyByWorktree), + gitBranchCompareRequestStatusHeadByWorktree: omitByWorktree( + s.gitBranchCompareRequestStatusHeadByWorktree + ), + // Why: keyed by worktreeId; without this it leaks a huge-status marker per removed worktree. + gitStatusHugeByWorktree: omitByWorktree(s.gitStatusHugeByWorktree), + showDotfilesByWorktree: omitByWorktree(s.showDotfilesByWorktree), + expandedDirs: omitByWorktree(s.expandedDirs), + // Per-file editor state for removed files + editorDrafts: omitByFileId(s.editorDrafts), + markdownViewMode: omitByFileId(s.markdownViewMode), + markdownFrontmatterVisible: omitByFileId(s.markdownFrontmatterVisible), + // Why: keyed by fileId; the bulk reconcile path previously kept these, leaking a cursor-line / view-mode entry per removed file. + editorCursorLine: omitByFileId(s.editorCursorLine), + editorViewMode: omitByFileId(s.editorViewMode), + // Why: keyed by worktreeId; re-keyed on rename but missed by both removal paths (editor-undo / Cmd+Shift+T snapshots). + recentlyClosedEditorTabsByWorktree: omitByWorktree(s.recentlyClosedEditorTabsByWorktree), + recentlyClosedTerminalTabsByWorktree: omitByWorktree(s.recentlyClosedTerminalTabsByWorktree), + recentlyClosedTabKindsByWorktree: omitByWorktree(s.recentlyClosedTabKindsByWorktree), + // Top-level actives + openFiles: nextOpenFiles, + everActivatedWorktreeIds: nextEverActivatedWorktreeIds, + lastVisitedAtByWorktreeId: omitByWorktree(s.lastVisitedAtByWorktreeId), + // Why: keyed by worktreeId; re-keyed on rename but missed by both removal paths (write-once default-terminal guard). + defaultTerminalTabsAppliedByWorktreeId: omitByWorktree( + s.defaultTerminalTabsAppliedByWorktreeId + ), + activeWorktreeId: removedActive ? null : s.activeWorktreeId, + activeWorkspaceExecutionHostId: removedActive ? null : s.activeWorkspaceExecutionHostId, + activeWorkspaceKey: (() => { + if (s.activeWorkspaceKey && worktreeIdSet.has(s.activeWorkspaceKey)) { + return null + } + const activeScope = s.activeWorkspaceKey ? parseWorkspaceKey(s.activeWorkspaceKey) : null + return activeScope?.type === 'worktree' && worktreeIdSet.has(activeScope.worktreeId) + ? null + : s.activeWorkspaceKey + })(), + activeFileId: activeFileCleared ? null : s.activeFileId, + activeBrowserTabId: removedActive ? null : s.activeBrowserTabId, + activeTabId: activeTabCleared ? null : s.activeTabId, + activeTabType: removedActive || activeFileCleared ? 'terminal' : s.activeTabType + } +} + +function directSshAuthorityIsComplete( + authority: DirectSshAuthority, + expectedTargetId: string +): boolean { + if ( + authority.targetId !== expectedTargetId || + typeof authority.providerEpoch !== 'string' || + authority.providerEpoch.length === 0 || + !Number.isSafeInteger(authority.connectionGeneration) || + authority.connectionGeneration < 0 + ) { + return false + } + return true +} + +function getCurrentDirectSshAuthority( + state: Pick, + hostId: ExecutionHostId +): DirectSshAuthority | null { + const parsedHost = parseExecutionHostId(hostId) + if (parsedHost?.kind !== 'ssh') { + return null + } + const connection = state.sshConnectionStates?.get(parsedHost.targetId) + if (connection?.status !== 'connected') { + return null + } + const authority = { + targetId: parsedHost.targetId, + providerEpoch: connection.providerEpoch, + connectionGeneration: connection.connectionGeneration + } as DirectSshAuthority + if (!directSshAuthorityIsComplete(authority, parsedHost.targetId)) { + return null + } + return { + ...authority + } +} + +function directSshAuthoritiesEqual( + left: DirectSshAuthority | null | undefined, + right: DirectSshAuthority | null | undefined +): boolean { + if (!left || !right) { + return false + } + return ( + left.targetId === right.targetId && + left.providerEpoch === right.providerEpoch && + left.connectionGeneration === right.connectionGeneration + ) +} + +function isCurrentDetectedWorktreeRefresh( + state: Pick, + refresh: AdmittedDetectedWorktreeRefresh +): boolean { + if (refresh.directSshAuthority) { + return directSshAuthoritiesEqual( + getCurrentDirectSshAuthority(state, refresh.executionHostId), + refresh.directSshAuthority + ) + } + if (refresh.runtimeAuthority) { + return ( + getEnvironmentSshStateGeneration(refresh.runtimeAuthority.environmentId) === + refresh.runtimeAuthority.connectionGeneration && + getRuntimeEnvironmentConnectionGeneration(refresh.runtimeAuthority.environmentId) === + refresh.runtimeAuthority.runtimeConnectionGeneration + ) + } + return true +} + +function staleDetectedWorktreeProviderResult( + refresh: AdmittedDetectedWorktreeRefresh +): HostQualifiedDetectedWorktreeResult | undefined { + return refresh.providerResult + ? { + providerRequestId: refresh.providerResult.providerRequestId, + executionHostId: refresh.executionHostId, + status: 'stale' + } + : undefined +} + +function preserveConcurrentManualOrder( + incoming: readonly T[], + requestStarted: readonly Worktree[] | undefined, + current: readonly Worktree[] | undefined, + matchesRefreshHost: (worktree: Worktree) => boolean +): T[] { + if (!requestStarted || !current) { + return [...incoming] + } + const startedById = new Map( + requestStarted.filter(matchesRefreshHost).map((worktree) => [worktree.id, worktree]) + ) + const currentById = new Map( + current.filter(matchesRefreshHost).map((worktree) => [worktree.id, worktree]) + ) + return incoming.map((worktree) => { + const started = startedById.get(worktree.id) + const latest = currentById.get(worktree.id) + if (!started || !latest || started.manualOrder === latest.manualOrder) { + return worktree + } + // Why: a refresh response may predate a completed drag; the renderer's optimistic rank is newer. + return { ...worktree, manualOrder: latest.manualOrder } + }) +} + +type FencedWorktreeMergeArgs = { + repoId: string + hostId: ExecutionHostId + ownerWasMissingAtStart: boolean + missingDirectSshOwnerReposSnapshot?: AppState['repos'] + requestStartedWorktrees: readonly Worktree[] | undefined + setup?: ProjectHostSetup + refresh: AdmittedDetectedWorktreeRefresh + purgeRemovedWorktrees?: boolean +} + +function mergeFetchedWorktrees( + set: Parameters>[0], + args: FencedWorktreeMergeArgs +): boolean { + let admitted = false + let authoritativelyRemovedIds: readonly string[] = [] + let authoritativelySeenIds: readonly string[] = [] + set((s) => { + if ( + !isCurrentDetectedWorktreeRefresh(s, args.refresh) || + !repoHasExactlyOneExecutionHostOwner( + s, + args.repoId, + args.hostId, + args.ownerWasMissingAtStart && + (!args.refresh.directSshAuthority || s.repos === args.missingDirectSshOwnerReposSnapshot) + ) + ) { + return s + } + admitted = true + const matchOptions = worktreeHostMatchOptions(s, args.repoId, args.hostId) + const currentWorktrees = s.worktreesByRepo[args.repoId] + const refreshResult = { + ...args.refresh.result, + worktrees: preserveConcurrentManualOrder( + args.refresh.result.worktrees, + args.requestStartedWorktrees, + currentWorktrees, + (worktree) => worktreeMatchesHost(worktree, args.hostId, matchOptions) + ) + } + let incoming = toVisibleWorktrees(refreshResult, args.hostId, args.setup) + incoming = routeListingBranchSwitchesThroughGitIdentity({ + requestStarted: args.requestStartedWorktrees, + current: s.worktreesByRepo[args.repoId], + incoming, + matchesRefreshHost: (worktree) => worktreeMatchesHost(worktree, args.hostId, matchOptions), + hasBranchScopedReviewContext: hasBranchScopedHostedReviewContext, + updateWorktreeGitIdentity: s.updateWorktreeGitIdentity + }) + const worktrees = sanitizeHostedReviewLinksForBranchClears( + incoming, + s.worktreesByRepo[args.repoId] + ) + const currentForHost = (s.worktreesByRepo[args.repoId] ?? []).filter((worktree) => + worktreeMatchesHost(worktree, args.hostId, matchOptions) + ) + const mergedDetected = mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[args.repoId], + refreshResult, + args.hostId, + args.setup, + matchOptions + ) + if (!args.refresh.result.authoritative && worktrees.length === 0 && currentForHost.length > 0) { + return areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[args.repoId], mergedDetected) + ? s + : { + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [args.repoId]: mergedDetected + } + } + } + const mergedWorktrees = mergeWorktreesForHost( + s.worktreesByRepo[args.repoId], + worktrees, + args.hostId, + matchOptions + ) + const removedIds = + args.purgeRemovedWorktrees === false + ? [] + : getRemovedWorktreeIdsAfterAuthoritativeScan( + s, + args.repoId, + args.refresh.result, + args.hostId + ) + authoritativelyRemovedIds = removedIds + if (args.refresh.result.authoritative) { + authoritativelySeenIds = args.refresh.result.worktrees.map((worktree) => worktree.id) + } + const worktreesChanged = !areWorktreesEqual(s.worktreesByRepo[args.repoId], mergedWorktrees) + const detectedChanged = !areDetectedWorktreeResultsEqual( + s.detectedWorktreesByRepo[args.repoId], + mergedDetected + ) + if (!worktreesChanged && !detectedChanged && removedIds.length === 0) { + return s + } + return { + ...(worktreesChanged + ? { + worktreesByRepo: { + ...s.worktreesByRepo, + [args.repoId]: mergedWorktrees + }, + sortEpoch: s.sortEpoch + 1 + } + : {}), + ...(detectedChanged + ? { + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [args.repoId]: mergedDetected + } + } + : {}), + ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) + } + }) + if (admitted) { + // Why: applied outside the updater so a repeated updater call cannot double-apply the removal memory. + forgetAuthoritativelyRemovedWorktrees(args.hostId, authoritativelySeenIds) + rememberAuthoritativelyRemovedWorktrees(args.hostId, authoritativelyRemovedIds) + forgetPersistedWorktreeMetaForRemovals(args.repoId, args.hostId, authoritativelyRemovedIds) + } + return admitted +} + +// Why: main retires the persisted SSH metadata a scan proved gone, but that IPC is async and the next fallback +// read can already be in flight, so this session memory covers the window until the delete lands. It is not the +// durable half: reloads start empty and rely on the metadata itself being gone. +const AUTHORITATIVE_REMOVAL_MEMORY_LIMIT = 512 +const authoritativelyRemovedWorktreeIdsByHost = new Map>() + +function rememberAuthoritativelyRemovedWorktrees( + hostId: ExecutionHostId, + worktreeIds: readonly string[] +): void { + if (worktreeIds.length === 0) { + return + } + const removed = authoritativelyRemovedWorktreeIdsByHost.get(hostId) ?? new Set() + for (const worktreeId of worktreeIds) { + // Why: re-insert so a re-removed id moves to the back of the insertion order the cap evicts from. + removed.delete(worktreeId) + removed.add(worktreeId) + } + for (const oldest of removed) { + if (removed.size <= AUTHORITATIVE_REMOVAL_MEMORY_LIMIT) { + break + } + removed.delete(oldest) + } + authoritativelyRemovedWorktreeIdsByHost.set(hostId, removed) +} + +// Why: a remote path can be recreated, so a scan that reports it again retracts the deletion verdict. +function forgetAuthoritativelyRemovedWorktrees( + hostId: ExecutionHostId, + worktreeIds: Iterable +): void { + const removed = authoritativelyRemovedWorktreeIdsByHost.get(hostId) + if (!removed) { + return + } + for (const worktreeId of worktreeIds) { + removed.delete(worktreeId) + } + if (removed.size === 0) { + authoritativelyRemovedWorktreeIdsByHost.delete(hostId) + } +} + +/** Test-only: module-level removal memory would otherwise leak across cases in one file. */ +export function resetAuthoritativelyRemovedWorktreeMemoryForTests(): void { + authoritativelyRemovedWorktreeIdsByHost.clear() +} + +// Why: SSH WorktreeMeta is exempt from gcStaleWorktreeMeta (persistence.ts:407,415) and outlives the remote +// worktree, so a scan-proven removal must retire the metadata itself — otherwise the next launch's fallback +// re-lists the deleted row before the host connects, and the in-memory suppression above is already gone. +function forgetPersistedWorktreeMetaForRemovals( + repoId: string, + hostId: ExecutionHostId, + worktreeIds: readonly string[] +): void { + const parsedHost = parseExecutionHostId(hostId) + if (worktreeIds.length === 0 || parsedHost?.kind !== 'ssh') { + return + } + const forget = window.api.worktrees.forgetRemovedForExecutionHost + if (typeof forget !== 'function') { + return + } + void forget({ repoId, executionHostId: parsedHost.id, worktreeIds: [...worktreeIds] }).catch( + (err) => { + console.warn(`Failed to forget metadata for removed worktrees in repo ${repoId}:`, err) + } + ) +} + +function appendMissingWorktreesForHost< + T extends { id: string; hostId?: ExecutionHostId; runtimeOwnerEnvironmentId?: string } +>( + current: readonly T[] | undefined, + incoming: readonly T[], + hostId: ExecutionHostId, + options: WorktreeHostMatchOptions +): T[] { + const existing = current ?? [] + const existingHostIds = new Set( + existing + .filter((worktree) => worktreeMatchesHost(worktree, hostId, options)) + .map(({ id }) => id) + ) + const missing = incoming.filter((worktree) => !existingHostIds.has(worktree.id)) + if (missing.length === 0) { + return [...existing] + } + // Why: land inside the host's block like mergeWorktreesForHost does, else these rows sit past sibling hosts and visibly jump once the authoritative scan splices them back. + const lastHostIndex = existing.findLastIndex((worktree) => + worktreeMatchesHost(worktree, hostId, options) + ) + if (lastHostIndex === -1) { + return [...existing, ...missing] + } + return [...existing.slice(0, lastHostIndex + 1), ...missing, ...existing.slice(lastHostIndex + 1)] +} + +function isAdmittedKnownSshWorktreeResult( + result: HostQualifiedKnownWorktreeResult, + repoId: string, + executionHostId: SshExecutionHostId +): result is Extract { + return ( + result.status === 'complete' && + result.repoId === repoId && + result.executionHostId === executionHostId && + isDetectedWorktreeListResult(result.result) && + result.result.repoId === repoId && + result.result.authoritative === false + ) +} + +const inflightKnownSshWorktreeFetches = new Map< + string, + Promise +>() + +// Why: the authoritative path dedupes through listDetectedWorktreesForRepoCoalesced; without a matching guard +// the four refresh triggers can each issue this IPC and its merge for the same repo/host. +async function fetchKnownSshWorktreesForRepo( + set: Parameters>[0], + repoId: string, + executionHostId: SshExecutionHostId +): Promise { + const coalesceKey = `${repoId}\0${executionHostId}` + const inflight = inflightKnownSshWorktreeFetches.get(coalesceKey) + if (inflight) { + return await inflight + } + const request = runKnownSshWorktreeFetch(set, repoId, executionHostId).finally(() => { + inflightKnownSshWorktreeFetches.delete(coalesceKey) + }) + inflightKnownSshWorktreeFetches.set(coalesceKey, request) + return await request +} + +async function runKnownSshWorktreeFetch( + set: Parameters>[0], + repoId: string, + executionHostId: SshExecutionHostId +): Promise { + // Why: reads the local store only, so a runtime-hub session (whose repo ids live on the hub) always gets 'rejected' and keeps the pre-existing no-op. + const listKnown = window.api.worktrees.listKnownForExecutionHost + if (typeof listKnown !== 'function') { + return null + } + const result = await listKnown({ repoId, executionHostId }) + if (!isAdmittedKnownSshWorktreeResult(result, repoId, executionHostId)) { + return null + } + // Why: persisted SSH metadata outlives the remote worktree, so drop rows a completed scan already proved gone. + const suppressedIds = authoritativelyRemovedWorktreeIdsByHost.get(executionHostId) + const known = + suppressedIds && suppressedIds.size > 0 + ? { + ...result.result, + worktrees: result.result.worktrees.filter((worktree) => !suppressedIds.has(worktree.id)) + } + : result.result + let admitted = false + set((state) => { + // Why: the provider can connect during the await; authoritative rows already replaced this host, so appending stale metadata would resurrect purged worktrees. + if ( + getCurrentDirectSshAuthority(state, executionHostId) || + !repoHasExactlyOneExecutionHostOwner(state, repoId, executionHostId, false) + ) { + return state + } + admitted = true + const setup = getProjectHostSetupForRepoHost(state, repoId, executionHostId) + const matchOptions = worktreeHostMatchOptions(state, repoId, executionHostId) + const incomingDetected = known.worktrees.map((worktree) => + withRepoHostOwnership(worktree, executionHostId, setup) + ) + const priorDetected = state.detectedWorktreesByRepo[repoId] + // Why: only the rows are ours to merge. This entry is keyed by repo alone, so adopting the fallback's + // authoritative/source would demote a sibling host's completed scan and blank every authoritative-gated surface. + const detected = { + ...(priorDetected ?? known), + worktrees: appendMissingWorktreesForHost( + priorDetected?.worktrees, + incomingDetected, + executionHostId, + matchOptions + ) + } + const worktrees = appendMissingWorktreesForHost( + state.worktreesByRepo[repoId], + toVisibleWorktrees(known, executionHostId, setup), + executionHostId, + matchOptions + ) + const worktreesChanged = !areWorktreesEqual(state.worktreesByRepo[repoId], worktrees) + const detectedChanged = !areDetectedWorktreeResultsEqual( + state.detectedWorktreesByRepo[repoId], + detected + ) + if (!worktreesChanged && !detectedChanged) { + return state + } + return { + ...(worktreesChanged + ? { + worktreesByRepo: { ...state.worktreesByRepo, [repoId]: worktrees }, + sortEpoch: state.sortEpoch + 1 + } + : {}), + ...(detectedChanged + ? { detectedWorktreesByRepo: { ...state.detectedWorktreesByRepo, [repoId]: detected } } + : {}) + } + }) + return admitted ? known : null +} + +export type DirectSshDetectedWorktreeRefresh = { + waiterLeaseId: DetectedWorktreeRefreshLease['waiterLeaseId'] + providerRequestId: ProviderRequestId + result: Promise + release: DetectedWorktreeRefreshLease['release'] + merge(result: HostQualifiedDetectedWorktreeResult): HostQualifiedDetectedWorktreeResult +} + +export function acquireDirectSshDetectedWorktreeRefresh( + store: Pick, 'getState' | 'setState'>, + request: { + repoId: string + executionHostId: SshExecutionHostId + authority: DirectSshAuthority + requireAuthoritative?: boolean + } +): DirectSshDetectedWorktreeRefresh { + const requestStartedState = store.getState() + const requestStartedWorktrees = requestStartedState.worktreesByRepo[request.repoId] + const ownerWasMissingAtStart = !requestStartedState.repos.some( + (repo) => repo.id === request.repoId + ) + const setup = getProjectHostSetupForRepoHost( + requestStartedState, + request.repoId, + request.executionHostId + ) + const settings = settingsForRepoOwner( + requestStartedState, + request.repoId, + request.executionHostId + ) + const options: DetectedWorktreeRefreshOptions = { + executionHostId: request.executionHostId, + directSshAuthority: request.authority, + requireAuthoritative: request.requireAuthoritative + } + const lease = acquireDetectedWorktreeRefreshLeaseForRepo(settings, request.repoId, options) + let mergedResult: HostQualifiedDetectedWorktreeResult | undefined + + return { + waiterLeaseId: lease.waiterLeaseId, + providerRequestId: lease.providerRequestId, + result: lease.result, + release: lease.release, + merge: (providerResult) => { + if (mergedResult) { + return mergedResult + } + if ( + !qualifiedProviderResultIsAdmitted( + providerResult, + lease.providerRequestId, + request.repoId, + options + ) + ) { + mergedResult = normalizeNotAdmittedProviderResult( + providerResult, + lease.providerRequestId, + request.executionHostId + ) + return mergedResult + } + if (request.requireAuthoritative && providerResult.status !== 'complete') { + mergedResult = providerResult + return mergedResult + } + const refresh: AdmittedDetectedWorktreeRefresh = { + status: 'admitted', + result: providerResult.result, + providerResult, + executionHostId: request.executionHostId, + directSshAuthority: request.authority + } + const admitted = mergeFetchedWorktrees( + store.setState as Parameters>[0], + { + repoId: request.repoId, + hostId: request.executionHostId, + ownerWasMissingAtStart, + requestStartedWorktrees, + setup, + refresh + } + ) + mergedResult = admitted + ? providerResult + : (staleDetectedWorktreeProviderResult(refresh) ?? providerResult) + return mergedResult + } + } +} export const createWorktreeSlice: StateCreator = (set, get) => ({ - ...worktreeSliceInitialState, - fetchDetectedWorktrees: createFetchDetectedWorktrees(set, get), - fetchWorktrees: createFetchWorktrees(set, get), - fetchAllWorktrees: createFetchAllWorktrees(set, get), - fetchWorktreeLineage: createFetchWorktreeLineage(set, get), - updateWorktreeLineage: createUpdateWorktreeLineage(set, get), - assignWorktreeParent: createAssignWorktreeParent(set, get), - updateWorktreeGitIdentity: createUpdateWorktreeGitIdentity(set, get), - updateWorktreeBaseStatus: createUpdateWorktreeBaseStatus(set, get), - updateWorktreeRemoteBranchConflict: createUpdateWorktreeRemoteBranchConflict(set, get), - prefetchWorktreeCreateBase: createPrefetchWorktreeCreateBase(set, get), - createWorktree: createCreateWorktree(set, get), - beginPendingWorktreeCreation: createBeginPendingWorktreeCreation(set, get), - updatePendingWorktreeCreation: createUpdatePendingWorktreeCreation(set, get), - removePendingWorktreeCreation: createRemovePendingWorktreeCreation(set, get), - setActivePendingWorktreeCreation: createSetActivePendingWorktreeCreation(set, get), - removeWorktree: createRemoveWorktree(set, get), - markWorktreesDeleting: createMarkWorktreesDeleting(set, get), - markWorktreesQueuedForDeletion: createMarkWorktreesQueuedForDeletion(set, get), - forceDeletePreservedBranch: createForceDeletePreservedBranch(set, get), - clearWorktreeDeleteState: createClearWorktreeDeleteState(set, get), - updateWorktreeMeta: createUpdateWorktreeMeta(set, get), - ensureHostedReviewPushTarget: createEnsureHostedReviewPushTarget(set, get), - updateWorktreesMeta: createUpdateWorktreesMeta(set, get), - setWorktreesPinnedAndReveal: createSetWorktreesPinnedAndReveal(set, get), - markWorktreeUnread: createMarkWorktreeUnread(set, get), - observeTerminalGitHubPullRequestLink: createObserveTerminalGitHubPullRequestLink(set, get), - clearWorktreeUnread: createClearWorktreeUnread(set, get), - bumpWorktreeActivity: createBumpWorktreeActivity(set, get), - markWorktreeVisited: createMarkWorktreeVisited(set, get), - pruneLastVisitedTimestamps: createPruneLastVisitedTimestamps(set, get), - seedActiveWorktreeLastVisitedIfMissing: createSeedActiveWorktreeLastVisitedIfMissing(set, get), - setRenamingWorktreeId: createSetRenamingWorktreeId(set, get), - remountTerminalTabForRecovery: createRemountTerminalTabForRecovery(set, get), - setActiveWorktree: createSetActiveWorktree(set, get), - setActiveFolderWorkspace: createSetActiveFolderWorkspace(set, get), - allWorktrees: createAllWorktrees(set, get), - getKnownWorktreeById: createGetKnownWorktreeById(set, get), - purgeWorktreeTerminalState: createPurgeWorktreeTerminalState(set, get), - purgeStaleRuntimeHostState: createPurgeStaleRuntimeHostState(set, get), - migrateWorktreeIdentity: createMigrateWorktreeIdentity(set, get) + worktreesByRepo: {}, + detectedWorktreesByRepo: {}, + worktreeLineageById: {}, + workspaceLineageByChildKey: {}, + activeWorktreeId: null, + activeWorkspaceKey: null, + activeWorkspaceExecutionHostId: null, + pendingWorktreeCreations: {}, + activePendingCreationId: null, + renamingWorktreeId: null, + deleteStateByWorktreeId: {}, + baseStatusByWorktreeId: {}, + remoteBranchConflictByWorktreeId: {}, + sortEpoch: 0, + everActivatedWorktreeIds: new Set(), + lastVisitedAtByWorktreeId: {}, + hasHydratedWorktreePurge: false, + startupWorktreeRefreshCompleted: false, + + fetchDetectedWorktrees: async (repoId) => { + try { + const ownerState = get() + const hostId = repoHostId(ownerState, repoId) + const ownerWasMissingAtStart = !ownerState.repos.some((repo) => repo.id === repoId) + const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId) + const repoOwner = findRepoForHost(ownerState.repos, repoId, { + hostId, + settings: ownerState.settings + }) + const parsedHost = parseExecutionHostId(hostId) + const directSshAuthority = + parsedHost?.kind === 'ssh' + ? (getCurrentDirectSshAuthority(ownerState, hostId) ?? undefined) + : undefined + if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + // Why: this function's contract is detected-only. The fallback runs for its store side effect, but + // callers keep seeing null as they did before the metadata path existed. + await fetchKnownSshWorktreesForRepo(set, repoId, parsedHost.id) + return null + } + const refresh = await listDetectedWorktreesForRepoCoalesced( + settingsForRepoOwner(ownerState, repoId, hostId), + repoId, + { + executionHostId: hostId, + directSshAuthority, + connectionId: repoOwner?.connectionId, + knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId) + } + ) + if (refresh.status !== 'admitted') { + return null + } + let admitted = false + set((s) => { + if ( + !isCurrentDetectedWorktreeRefresh(s, refresh) || + !repoHasExactlyOneExecutionHostOwner( + s, + repoId, + hostId, + ownerWasMissingAtStart && !refresh.directSshAuthority + ) + ) { + return s + } + admitted = true + // Why: detected-only refreshes can overlap host-scoped visible refreshes; merge detected state so SSH/runtime rows aren't clobbered. + const mergedDetected = mergeDetectedWorktreesForHost( + s.detectedWorktreesByRepo[repoId], + refresh.result, + hostId, + setup, + worktreeHostMatchOptions(s, repoId, hostId) + ) + return areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[repoId], mergedDetected) + ? s + : { + detectedWorktreesByRepo: { + ...s.detectedWorktreesByRepo, + [repoId]: mergedDetected + } + } + }) + return admitted ? refresh.result : null + } catch (err) { + if (notifyRuntimeScopeForbiddenIfNeeded(err)) { + return null + } + console.error(`Failed to fetch detected worktrees for repo ${repoId}:`, err) + return null + } + }, + + fetchWorktrees: (async ( + repoId: string, + options?: WorktreeFetchOptions | DirectSshWorktreeFetchOptions + ) => { + const directCallerAuthority = + options && 'directSshAuthority' in options ? options.directSshAuthority : undefined + try { + const ownerState = get() + const requestStartedWorktrees = ownerState.worktreesByRepo[repoId] + const repoOwners = ownerState.repos.filter((repo) => repo.id === repoId) + const ownerWasMissingAtStart = repoOwners.length === 0 + const hasLocalOwner = repoOwners.some( + (repo) => getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID + ) + // Why: a local event may share its repo id with the focused runtime; prefer + // the local owner without redirecting runtime/SSH-only repos. + const useLocalOwner = + options?.forceLocalOwner === true && (hasLocalOwner || repoOwners.length === 0) + const hostId = useLocalOwner + ? LOCAL_EXECUTION_HOST_ID + : repoHostId(ownerState, repoId, options?.executionHostId) + const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId) + const repoOwner = findRepoForHost(ownerState.repos, repoId, { + hostId, + settings: ownerState.settings + }) + const ownerSettings = settingsForRepoOwner( + ownerState, + repoId, + hostId, + ownerWasMissingAtStart && (useLocalOwner || options?.executionHostId !== undefined) + ) + const settings = + useLocalOwner && ownerSettings?.activeRuntimeEnvironmentId + ? { ...ownerSettings, activeRuntimeEnvironmentId: null } + : ownerSettings + const parsedHost = parseExecutionHostId(hostId) + const directSshAuthority = + parsedHost?.kind === 'ssh' + ? (directCallerAuthority ?? getCurrentDirectSshAuthority(ownerState, hostId) ?? undefined) + : undefined + if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + // Why: requireAuthoritative callers asked for authoritative-or-nothing, so writing non-authoritative + // rows as a side effect before returning false would silently weaken that contract. + if (!options?.requireAuthoritative) { + await fetchKnownSshWorktreesForRepo(set, repoId, parsedHost.id) + } + return false + } + const refresh = await listDetectedWorktreesForRepoCoalesced(settings, repoId, { + executionHostId: hostId, + requireAuthoritative: options?.requireAuthoritative, + directSshAuthority, + connectionId: repoOwner?.connectionId, + knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId) + }) + if (refresh.status !== 'admitted') { + return directCallerAuthority ? refresh.providerResult : false + } + if (options?.requireAuthoritative && !refresh.result.authoritative) { + return directCallerAuthority ? refresh.providerResult : false + } + const admitted = mergeFetchedWorktrees(set, { + repoId, + hostId, + ownerWasMissingAtStart, + missingDirectSshOwnerReposSnapshot: + ownerWasMissingAtStart && options?.executionHostId === hostId + ? ownerState.repos + : undefined, + requestStartedWorktrees, + setup, + refresh + }) + if (!admitted) { + return directCallerAuthority + ? (staleDetectedWorktreeProviderResult(refresh) ?? false) + : false + } + // Direct SSH lineage requires its own qualified authority result. + // Bulk runtime callers apply one final host-wide snapshot after all repo merges. + if (!directSshAuthority && !options?.suppressRemoteLineageRefresh) { + await refreshRemoteWorktreeLineageBestEffort(settings, set) + } + return directCallerAuthority ? refresh.providerResult! : refresh.result.authoritative + } catch (err) { + if (notifyRuntimeScopeForbiddenIfNeeded(err)) { + return false + } + console.error(`Failed to fetch worktrees for repo ${repoId}:`, err) + return false + } + }) as WorktreeSlice['fetchWorktrees'], + + fetchAllWorktrees: async (options) => { + const repos = options?.visibilityOwnerHostId + ? get().repos.filter((repo) => { + const repoHost = parseExecutionHostId(getRepoExecutionHostId(repo)) + const ownerHost = parseExecutionHostId(options.visibilityOwnerHostId) + return ownerHost?.kind === 'runtime' + ? repoHost?.kind === 'runtime' && repoHost.environmentId === ownerHost.environmentId + : repoHost?.kind !== 'runtime' + }) + : get().repos + + // Why: after the one-shot hydration purge, later calls only refresh cached lists — no IPC double-probe for the per-repo success signal. + if (get().hasHydratedWorktreePurge || options?.visibilityOwnerHostId) { + await mapReposForWorktreeRefresh(repos, async (r) => { + try { + const requestStartedState = get() + const requestStartedWorktrees = requestStartedState.worktreesByRepo[r.id] + const hostId = getRepoExecutionHostId(r) + const setup = getProjectHostSetupForRepoHost(requestStartedState, r.id, hostId) + const settings = settingsForKnownRepoOwner(requestStartedState.settings, r) + const parsedHost = parseExecutionHostId(hostId) + const directSshAuthority = + parsedHost?.kind === 'ssh' + ? (getCurrentDirectSshAuthority(requestStartedState, hostId) ?? undefined) + : undefined + if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + await fetchKnownSshWorktreesForRepo(set, r.id, parsedHost.id) + return + } + const refresh = await listDetectedWorktreesForRepoCoalesced(settings, r.id, { + executionHostId: hostId, + reuseRecentCompatibilityFailure: true, + directSshAuthority, + connectionId: r.connectionId, + knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId) + }) + if (refresh.status !== 'admitted') { + return + } + mergeFetchedWorktrees(set, { + repoId: r.id, + hostId, + ownerWasMissingAtStart: false, + requestStartedWorktrees, + setup, + refresh + }) + } catch (err) { + if (notifyRuntimeScopeForbiddenIfNeeded(err)) { + return + } + console.error(`Failed to fetch worktrees for repo ${r.id}:`, err) + } + }) + return + } + + // Why: a pre-fix upgrade can persist tabsByWorktree entries for worktrees deleted last session; without this hydration purge they leave zombie PTYs misclassified as "bound" (design §2c) until a second restart. + // Safety gate: fetchWorktrees swallows IPC errors and short-circuits on empty-replace, so probe the IPC directly for the per-repo success signal instead of re-listing. + const results = await mapReposForWorktreeRefresh( + repos, + async ( + r + ): Promise< + | { repoId: string; ok: boolean; detected: DetectedWorktreeListResult } + | { repoId: string; ok: false } + > => { + try { + const requestStartedState = get() + const requestStartedWorktrees = requestStartedState.worktreesByRepo[r.id] + const hostId = getRepoExecutionHostId(r) + const setup = getProjectHostSetupForRepoHost(requestStartedState, r.id, hostId) + const parsedHost = parseExecutionHostId(hostId) + const directSshAuthority = + parsedHost?.kind === 'ssh' + ? (getCurrentDirectSshAuthority(requestStartedState, hostId) ?? undefined) + : undefined + if (parsedHost?.kind === 'ssh' && !directSshAuthority) { + await fetchKnownSshWorktreesForRepo(set, r.id, parsedHost.id) + return { repoId: r.id, ok: false as const } + } + const refresh = await listDetectedWorktreesForRepoCoalesced( + settingsForKnownRepoOwner(requestStartedState.settings, r), + r.id, + { + executionHostId: hostId, + reuseRecentCompatibilityFailure: true, + directSshAuthority, + connectionId: r.connectionId, + knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId) + } + ) + if (refresh.status !== 'admitted') { + return { repoId: r.id, ok: false as const } + } + const admitted = mergeFetchedWorktrees(set, { + repoId: r.id, + hostId, + ownerWasMissingAtStart: false, + requestStartedWorktrees, + setup, + refresh, + purgeRemovedWorktrees: false + }) + if (!admitted) { + return { repoId: r.id, ok: false as const } + } + return { + repoId: r.id, + ok: refresh.result.authoritative, + detected: refresh.result + } + } catch (err) { + console.error(`Failed to fetch worktrees for repo ${r.id}:`, err) + return { repoId: r.id, ok: false as const } + } + } + ) + + const hasAnyDetectedWorktree = results.some( + (result) => 'detected' in result && result.ok && result.detected.worktrees.length > 0 + ) + const allSucceeded = results.length > 0 && results.every((r) => r.ok) && hasAnyDetectedWorktree + if (!allSucceeded) { + // Defer; try again on the next fetchAllWorktrees call. + return + } + if ( + options?.hydrationPurge === 'defer' || + get().workspaceSessionReady === false || + get().hydrationSucceeded === false + ) { + // Why: startup refreshes local repos first; defer the one-shot purge to the later all-host refresh, once remote worktree ids are known. + return + } + const validIds = new Set() + // Why: floating is persisted renderer state, not a repo worktree an authoritative scan returns. + validIds.add(FLOATING_TERMINAL_WORKTREE_ID) + // Why: folder workspaces persist tabs under `folder:` keys that authoritative repo scans never return. + for (const workspace of get().folderWorkspaces ?? []) { + validIds.add(folderWorkspaceKey(workspace.id)) + } + for (const key of Object.keys(get().restoredRuntimeHostIdByWorkspaceSessionKey ?? {})) { + if (parseWorkspaceKey(key)?.type === 'folder') { + validIds.add(key) + } + } + for (const result of Object.values(get().detectedWorktreesByRepo)) { + if (!result.authoritative) { + continue + } + for (const w of result.worktrees) { + validIds.add(w.id) + } + } + const stale = Object.keys(get().tabsByWorktree).filter((id) => !validIds.has(id)) + if (stale.length > 0) { + console.warn( + `[worktree-purge] hydration-time purge removing stale state for ${stale.length} worktree(s):`, + stale + ) + get().purgeWorktreeTerminalState(stale) + } + set({ hasHydratedWorktreePurge: true }) + }, + + fetchWorktreeLineage: async (options) => { + try { + // Why: lineage is a focused-host refresh; host-merge so other hosts' fetched lineage is preserved. + const ownerSettings = get().settings + const parsedHost = options?.executionHostId + ? parseExecutionHostId(options.executionHostId) + : null + const activeRuntimeEnvironmentId = + parsedHost?.kind === 'runtime' + ? parsedHost.environmentId + : parsedHost || options?.forceLocalOwner + ? null + : ownerSettings?.activeRuntimeEnvironmentId + const settings = ownerSettings + ? { ...ownerSettings, activeRuntimeEnvironmentId } + : ({ activeRuntimeEnvironmentId } as AppState['settings']) + await refreshWorktreeLineageForSettings(settings, set, { + reuseRecentCompatibilityFailure: true + }) + } catch (err) { + console.error('Failed to fetch worktree lineage:', err) + } + }, + + updateWorktreeLineage: async (worktreeId, args) => { + const ownerSettings = settingsForWorktreeOwner(get(), worktreeId) + try { + applyWorktreeLineageUpdate( + set, + worktreeId, + await setWorktreeLineageForRuntime(ownerSettings, worktreeId, args) + ) + } catch (err) { + console.error('Failed to update worktree lineage:', err) + await refreshWorktreeLineageForSettings(ownerSettings, set) + } + }, + + assignWorktreeParent: async (worktreeId, args) => { + const ownerSettings = settingsForWorktreeOwner(get(), worktreeId) + try { + applyWorktreeLineageUpdate( + set, + worktreeId, + await setWorktreeLineageForRuntime(ownerSettings, worktreeId, args) + ) + } catch (err) { + console.error('Failed to assign worktree parent:', err) + await refreshWorktreeLineageForSettings(ownerSettings, set) + throw err + } + }, + + updateWorktreeGitIdentity: (worktreeId, identity) => { + let shouldPersistHostedReviewClear = false + let clearedBranch: string | null = null + let clearGeneration = getHostedReviewLinkMutationGeneration(worktreeId) + const repoId = getRepoIdFromWorktreeId(worktreeId) + const existing = get().worktreesByRepo[repoId]?.find((worktree) => worktree.id === worktreeId) + if (!existing) { + return + } + const expectedHead = identity.head ?? existing.head + const expectedBranch = identity.branch === null ? '' : (identity.branch ?? existing.branch) + if (expectedHead === existing.head && expectedBranch === existing.branch) { + return + } + + set((s) => { + const current = s.worktreesByRepo[repoId] + if (!current) { + return s + } + + let changed = false + const next = current.map((worktree) => { + if (worktree.id !== worktreeId) { + return worktree + } + const nextHead = identity.head ?? worktree.head + const nextBranch = identity.branch === null ? '' : (identity.branch ?? worktree.branch) + if (nextHead === worktree.head && nextBranch === worktree.branch) { + return worktree + } + changed = true + const hostedReviewBranchChanged = + canonicalHostedReviewBranchIdentity(nextBranch) !== + canonicalHostedReviewBranchIdentity(worktree.branch) + const shouldClearHostedReviewContext = + hostedReviewBranchChanged && hasBranchScopedHostedReviewContext(worktree) + if (shouldClearHostedReviewContext) { + shouldPersistHostedReviewClear = true + clearedBranch = nextBranch + clearGeneration = getHostedReviewLinkMutationGeneration(worktreeId) + rememberHostedReviewLinkClear(worktreeId, nextBranch, clearGeneration, nextHead) + } else { + const tombstone = hostedReviewLinkClearTombstonesByWorktreeId.get(worktreeId) + if (tombstone) { + const nextBranchIdentity = canonicalHostedReviewBranchIdentity(nextBranch) + hostedReviewLinkClearTombstonesByWorktreeId.set(worktreeId, { + ...tombstone, + branch: nextBranch, + branchIdentity: nextBranchIdentity, + head: nextHead + }) + if (hostedReviewBranchChanged) { + shouldPersistHostedReviewClear = true + clearedBranch = nextBranch + clearGeneration = tombstone.generation + } + } + } + // Why: terminal branch switches only patch branch/head here; re-derive auto titles like full listing does. + const currentBranchName = branchName(worktree.branch) + const wasAutoDerived = worktree.displayName === currentBranchName + const wasDetachedAutoDerived = + worktree.branch === '' && + nextBranch !== '' && + detachedHeadAutoDerivedDisplayNames.get(worktreeId) === worktree.displayName + const nextDisplayName = + (wasAutoDerived || wasDetachedAutoDerived) && nextBranch + ? branchName(nextBranch) + : worktree.displayName + if (identity.branch === null && wasAutoDerived) { + detachedHeadAutoDerivedDisplayNames.set(worktreeId, worktree.displayName) + } else if (identity.branch !== undefined) { + detachedHeadAutoDerivedDisplayNames.delete(worktreeId) + } + return { + ...worktree, + head: nextHead, + branch: nextBranch, + displayName: nextDisplayName, + // Why: linked reviews are branch-scoped; keeping the old link on a branch switch would refresh the old PR. + ...(shouldClearHostedReviewContext ? CLEARED_HOSTED_REVIEW_LINK_UPDATES : {}) + } + }) + + if (!changed) { + return s + } + + return { + worktreesByRepo: { ...s.worktreesByRepo, [repoId]: next }, + sortEpoch: s.sortEpoch + 1 + } + }) + if (!shouldPersistHostedReviewClear || clearedBranch === null) { + return + } + + void Promise.resolve() + .then(async () => { + let currentWorktreeId = resolveHostedReviewLinkWorktreeId(worktreeId) + const persistedWorktreeIds = new Set() + while (true) { + currentWorktreeId = resolveHostedReviewLinkWorktreeId(currentWorktreeId) + if (persistedWorktreeIds.has(currentWorktreeId)) { + return + } + persistedWorktreeIds.add(currentWorktreeId) + let current = get().getKnownWorktreeById(currentWorktreeId) + if ( + !current || + current.branch !== clearedBranch || + getHostedReviewLinkMutationGeneration(currentWorktreeId) !== clearGeneration + ) { + return + } + if (!hostedReviewLinksAreCleared(current as Worktree)) { + // Why: a refetch can rehydrate stale linked-review metadata before this async clear starts; clear it again. + applyHostedReviewLinkClear(set, currentWorktreeId) + current = get().getKnownWorktreeById(currentWorktreeId) + if (!current || current.branch !== clearedBranch) { + return + } + } + await persistWorktreeMeta( + settingsForWorktreeOwner(get(), currentWorktreeId), + currentWorktreeId, + CLEARED_HOSTED_REVIEW_LINK_UPDATES + ) + const migratedWorktreeId = resolveHostedReviewLinkWorktreeId(currentWorktreeId) + if (migratedWorktreeId === currentWorktreeId) { + break + } + // Why: worktree creation can migrate ids mid-IPC; persist the clear under the new durable id too. + currentWorktreeId = migratedWorktreeId + } + const latest = get().getKnownWorktreeById(currentWorktreeId) + if ( + !latest || + latest.branch !== clearedBranch || + hostedReviewLinksAreCleared(latest as Worktree) + ) { + return + } + if (getHostedReviewLinkMutationGeneration(currentWorktreeId) !== clearGeneration) { + // Why: a delayed branch-switch clear must not win over a newer manual relink. + await persistWorktreeMeta( + settingsForWorktreeOwner(get(), currentWorktreeId), + currentWorktreeId, + getHostedReviewLinkUpdates(latest as Worktree) + ) + return + } + // Why: a refetch can rehydrate old metadata before the branch-switch clear reaches disk; don't write the stale link back. + applyHostedReviewLinkClear(set, currentWorktreeId) + }) + .catch((err) => { + if (isRuntimeSelectorNotFoundError(err)) { + void get().fetchWorktrees( + getRepoIdFromWorktreeId(resolveHostedReviewLinkWorktreeId(worktreeId)) + ) + return + } + console.error('Failed to persist branch-scoped review link clear:', err) + }) + }, + + updateWorktreeBaseStatus: (event) => { + set((s) => ({ + baseStatusByWorktreeId: { + ...s.baseStatusByWorktreeId, + [event.worktreeId]: event + } + })) + }, + + updateWorktreeRemoteBranchConflict: (event) => { + set((s) => ({ + remoteBranchConflictByWorktreeId: { + ...s.remoteBranchConflictByWorktreeId, + [event.worktreeId]: event + } + })) + }, + + prefetchWorktreeCreateBase: async (repoId, baseBranch) => { + try { + const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) + if (target.kind === 'local') { + await window.api.worktrees.prefetchCreateBase({ + repoId, + ...(baseBranch ? { baseBranch } : {}) + }) + return + } + await callRuntimeRpc( + target, + 'worktree.prefetchCreateBase', + { repo: repoId, ...(baseBranch ? { baseBranch } : {}) }, + { timeoutMs: 30_000 } + ) + } catch { + // Why: prefetch is only a latency hedge; the create path awaits the same refresh and owns error reporting. + } + }, + + createWorktree: async ( + repoId, + name, + baseBranch, + setupDecision = 'inherit', + sparseCheckout, + telemetrySource, + displayName, + linkedIssue, + linkedPR, + pushTarget, + createdWithAgent, + linkedLinearIssue, + branchNameOverride, + workspaceStatus, + linkedGitLabMR, + linkedGitLabIssue, + startup, + pendingFirstAgentMessageRename, + creationId, + linkedLinearIssueWorkspaceId, + linkedLinearIssueOrganizationUrlKey, + linkedBitbucketPR, + linkedAzureDevOpsPR, + linkedGiteaPR, + compareBaseRef, + options + ) => { + const automationProvenanceRequest = options?.automationProvenanceRequest + const linkedWorkItem = options?.linkedWorkItem + const linkedTaskSourceContext = options?.linkedTaskSourceContext + const startupDraft = options?.startupDraft + const provisionedRoot = options?.provisionedRoot + try { + for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) { + const candidateName = getClientWorktreeCreateCandidate(name, attempt) + // Why: older runtimes reject exact PR branch overrides on collision, so retry both branch and worktree names. + const candidateBranchNameOverride = branchNameOverride + ? getClientWorktreeCreateCandidate(branchNameOverride, attempt) + : undefined + try { + // Why: manual sort is user-authored order; stamp new workspaces at the top rather than relying on sortOrder fallback. + const manualOrder = get().sortBy === 'manual' ? Date.now() : undefined + const activeScope = parseWorkspaceKey(get().activeWorkspaceKey ?? '') + const parentWorkspace = + activeScope?.type === 'folder' + ? folderWorkspaceKey(activeScope.folderWorkspaceId) + : undefined + const createArgs = { + repoId, + name: candidateName, + baseBranch, + ...(compareBaseRef ? { compareBaseRef } : {}), + ...(candidateBranchNameOverride + ? { branchNameOverride: candidateBranchNameOverride } + : {}), + setupDecision, + sparseCheckout, + ...(displayName ? { displayName } : {}), + ...(telemetrySource ? { telemetrySource } : {}), + ...(linkedIssue !== undefined ? { linkedIssue } : {}), + ...(linkedPR !== undefined ? { linkedPR } : {}), + ...(pushTarget ? { pushTarget } : {}), + ...(createdWithAgent ? { createdWithAgent } : {}), + ...(pendingFirstAgentMessageRename === true && createdWithAgent + ? { pendingFirstAgentMessageRename: true } + : {}), + ...(linkedLinearIssue !== undefined ? { linkedLinearIssue } : {}), + ...(linkedLinearIssueWorkspaceId !== undefined ? { linkedLinearIssueWorkspaceId } : {}), + ...(linkedLinearIssueOrganizationUrlKey !== undefined + ? { linkedLinearIssueOrganizationUrlKey } + : {}), + ...(manualOrder !== undefined ? { manualOrder } : {}), + ...(parentWorkspace ? { parentWorkspace } : {}), + ...(workspaceStatus !== undefined ? { workspaceStatus } : {}), + ...(linkedGitLabMR !== undefined ? { linkedGitLabMR } : {}), + ...(linkedGitLabIssue !== undefined ? { linkedGitLabIssue } : {}), + ...(linkedBitbucketPR !== undefined ? { linkedBitbucketPR } : {}), + ...(linkedAzureDevOpsPR !== undefined ? { linkedAzureDevOpsPR } : {}), + ...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}), + ...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}), + ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}), + ...(startup ? { startup } : {}), + ...(creationId ? { creationId } : {}), + ...(automationProvenanceRequest ? { automationProvenanceRequest } : {}) + } + const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) + if ( + target.kind === 'environment' && + (linkedWorkItem?.provider === 'jira' || linkedTaskSourceContext?.provider === 'jira') + ) { + await assertRuntimeEnvironmentCapability( + target.environmentId, + WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY, + 'Update the remote runtime to link Jira' + ) + } + if (provisionedRoot && target.kind !== 'local') { + throw new Error('Provisioned-root recipes currently require a direct SSH connection.') + } + const result = provisionedRoot + ? await window.api.worktrees.adoptProvisionedRoot({ + ...createArgs, + ...provisionedRoot + }) + : target.kind === 'local' + ? await window.api.worktrees.create(createArgs) + : await callRuntimeRpc>>( + target, + 'worktree.create', + { + repo: repoId, + name: candidateName, + baseBranch, + ...(compareBaseRef ? { compareBaseRef } : {}), + ...(candidateBranchNameOverride + ? { branchNameOverride: candidateBranchNameOverride } + : {}), + setupDecision, + sparseCheckout, + ...(displayName ? { displayName } : {}), + ...(telemetrySource ? { telemetrySource } : {}), + ...(linkedIssue !== undefined ? { linkedIssue } : {}), + ...(linkedPR !== undefined ? { linkedPR } : {}), + ...(pushTarget ? { pushTarget } : {}), + ...(createdWithAgent ? { createdWithAgent } : {}), + ...(pendingFirstAgentMessageRename === true && createdWithAgent + ? { pendingFirstAgentMessageRename: true } + : {}), + ...(linkedLinearIssue !== undefined ? { linkedLinearIssue } : {}), + ...(linkedLinearIssueWorkspaceId !== undefined + ? { linkedLinearIssueWorkspaceId } + : {}), + ...(linkedLinearIssueOrganizationUrlKey !== undefined + ? { linkedLinearIssueOrganizationUrlKey } + : {}), + ...(manualOrder !== undefined ? { manualOrder } : {}), + ...(parentWorkspace ? { parentWorkspace } : {}), + ...(workspaceStatus !== undefined ? { workspaceStatus } : {}), + ...(linkedGitLabMR !== undefined ? { linkedGitLabMR } : {}), + ...(linkedGitLabIssue !== undefined ? { linkedGitLabIssue } : {}), + ...(linkedBitbucketPR !== undefined ? { linkedBitbucketPR } : {}), + ...(linkedAzureDevOpsPR !== undefined ? { linkedAzureDevOpsPR } : {}), + ...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}), + ...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}), + ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}), + ...(startupDraft ? { startupDraft } : {}), + ...(automationProvenanceRequest ? { automationProvenanceRequest } : {}), + ...(startup + ? { + startupCommand: startup.command, + ...(startup.env ? { startupEnv: startup.env } : {}), + ...(startup.launchConfig + ? { startupLaunchConfig: startup.launchConfig } + : {}), + ...(startup.startupCommandDelivery + ? { startupCommandDelivery: startup.startupCommandDelivery } + : {}), + activate: true + } + : {}) + }, + { timeoutMs: 10 * 60_000 } + ) + // Why: worktrees.onChanged can add this worktree before this callback runs; appending blindly would duplicate it (React key clash). + set((s) => { + const hostId = repoHostId(s, repoId) + const createdWorktree = withRepoHostOwnership( + result.worktree, + hostId, + getProjectHostSetupForRepoHost(s, repoId, hostId) + ) + const current = s.worktreesByRepo[repoId] ?? [] + const alreadyPresent = current.some((w) => w.id === createdWorktree.id) + const nextWorktrees = alreadyPresent + ? current.map((worktree) => + worktree.id === createdWorktree.id + ? { ...worktree, ...createdWorktree } + : worktree + ) + : [...current, createdWorktree] + return { + worktreesByRepo: { + ...s.worktreesByRepo, + [repoId]: nextWorktrees + }, + ...(result.workspaceLineage + ? { + workspaceLineageByChildKey: { + ...s.workspaceLineageByChildKey, + [result.workspaceLineage.childWorkspaceKey]: result.workspaceLineage + } + } + : {}), + ...(result.initialBaseStatus + ? { + baseStatusByWorktreeId: { + ...s.baseStatusByWorktreeId, + [result.worktree.id]: + s.baseStatusByWorktreeId[result.worktree.id] ?? result.initialBaseStatus + } + } + : {}), + sortEpoch: s.sortEpoch + 1 + } + }) + showLocalBaseRefRefreshToast(result.localBaseRefRefresh, result.worktree) + if (result.baseFallback) { + requestWorktreeBaseFallbackNotice(result.baseFallback) + } + showLocalBaseRefUpdateSuggestionToast(result.localBaseRefUpdateSuggestion, { + updateSettings: get().updateSettings, + getSettings: () => get().settings, + openSettingsPage: get().openSettingsPage, + openSettingsTarget: get().openSettingsTarget + }) + return result + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const shouldRetry = isRetryableWorktreeCreateConflict(message) + if (!shouldRetry || attempt === CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS - 1) { + throw error + } + } + } + + throw new Error('Failed to create worktree after retrying branch conflicts.') + } catch (err) { + console.error('Failed to create worktree:', err) + throw err + } + }, + + beginPendingWorktreeCreation: (entry) => { + set((s) => ({ + pendingWorktreeCreations: { ...s.pendingWorktreeCreations, [entry.creationId]: entry }, + activePendingCreationId: entry.creationId + })) + }, + + updatePendingWorktreeCreation: (creationId, patch) => { + set((s) => { + const entry = s.pendingWorktreeCreations[creationId] + if (!entry) { + return {} + } + // Why: the main process re-emits the same phase; skip no-op writes so the strip and panel don't re-render. + const hasChange = (Object.keys(patch) as (keyof typeof patch)[]).some( + (key) => patch[key] !== entry[key] + ) + if (!hasChange) { + return {} + } + return { + pendingWorktreeCreations: { + ...s.pendingWorktreeCreations, + [creationId]: { ...entry, ...patch } + } + } + }) + }, + + removePendingWorktreeCreation: (creationId, options) => { + let removedEntry: AppState['pendingWorktreeCreations'][string] | undefined + set((s) => { + const entry = s.pendingWorktreeCreations[creationId] + if (!entry) { + return {} + } + removedEntry = entry + const { [creationId]: _removed, ...rest } = s.pendingWorktreeCreations + return { + pendingWorktreeCreations: rest, + // Why: only clear the active surface if it pointed here, so dismissing a background creation doesn't yank the user away. + ...(s.activePendingCreationId === creationId ? { activePendingCreationId: null } : {}) + } + }) + if (!removedEntry || options?.cleanupVm === false || typeof window === 'undefined') { + return + } + if (removedEntry.phase === 'provisioning-vm' && window.api?.ephemeralVm?.cancelProvision) { + void window.api.ephemeralVm + .cancelProvision({ provisionId: creationId }) + .catch(() => undefined) + } + if (!removedEntry.request.ephemeralVmRuntimeId || !window.api?.ephemeralVm?.cleanup) { + return + } + void cleanupFailedEphemeralVmWorkspace(removedEntry.request, { + deleteProjectHostSetup: (setupId) => get().deleteProjectHostSetup({ setupId }), + cleanupRuntime: (runtimeId) => window.api.ephemeralVm.cleanup({ runtimeId }), + reportSetupError: (error) => + console.error('Failed to remove cancelled provisioned-root project setup:', error), + reportRuntimeError: (error) => + console.error('Failed to clean up cancelled ephemeral VM runtime:', error) + }) + }, + + setActivePendingWorktreeCreation: (creationId) => { + set((s) => { + if (creationId !== null && !s.pendingWorktreeCreations[creationId]) { + return {} + } + return { activePendingCreationId: creationId } + }) + }, + + removeWorktree: async (worktreeId, force, options) => { + const forgetLocalOnly = options?.mode === 'forget-local' + const removalRoute = resolveWorktreeOperationRoute(get(), worktreeId) + if (!forgetLocalOnly && !removalRoute) { + return { ok: false, error: WORKTREE_REMOVAL_AMBIGUOUS_ERROR } + } + const hostId = removalRoute?.executionHostId ?? undefined + const removalGenerationGuard = removalRoute + ? captureWorktreeOperationGenerationGuard( + get, + worktreeId, + removalRoute, + () => new Error(WORKTREE_REMOVAL_AMBIGUOUS_ERROR) + ) + : null + set((s) => ({ + deleteStateByWorktreeId: { + ...s.deleteStateByWorktreeId, + [worktreeId]: { + isDeleting: true, + phase: 'deleting', + error: null, + canForceDelete: false, + forceDeleteReason: null + } + } + })) + + try { + // Why: forget-local touches no remote, so there's no archive hook to run or trust prompt needed. + const skipArchive = forgetLocalOnly + ? true + : (await ensureHooksConfirmed( + get(), + getRepoIdFromWorktreeId(worktreeId), + 'archive', + hostId, + removalRoute?.runtimeEnvironmentId + )) === 'skip' + + const worktreeBeforeRemoval = get() + .allWorktrees() + .find((entry) => entry.id === worktreeId) + const terminalPtyIdsBeforeRemoval = (get().tabsByWorktree[worktreeId] ?? []).flatMap( + (tab) => get().ptyIdsByTabId[tab.id] ?? [] + ) + if (!forgetLocalOnly) { + removalGenerationGuard?.assertCurrent() + } + // Why: forget-local clears Orca's records via local IPC regardless of host — the remote is gone or unreachable. + const target = getActiveRuntimeTarget( + removalRoute + ? settingsForWorktreeOperationRoute(get().settings, removalRoute) + : get().settings + ? { ...get().settings, activeRuntimeEnvironmentId: null } + : { activeRuntimeEnvironmentId: null } + ) + let removalResult: RemoveWorktreeResult + let snapshotPruneHandledByLocalMain = forgetLocalOnly || target.kind === 'local' + try { + removalResult = await (forgetLocalOnly + ? window.api.worktrees.forgetLocal({ + worktreeId, + hostId, + ...(options?.snapshotPruneBatchId + ? { snapshotPruneBatchId: options.snapshotPruneBatchId } + : {}) + }) + : target.kind === 'local' + ? (removalGenerationGuard?.assertCurrent(), + window.api.worktrees.remove({ + worktreeId, + hostId, + force, + allowUnverifiedPtyStop: options?.allowUnverifiedPtyStop === true, + skipArchive, + ...(options?.snapshotPruneBatchId + ? { snapshotPruneBatchId: options.snapshotPruneBatchId } + : {}) + })) + : (removalGenerationGuard?.assertCurrent(), + callRuntimeRpc( + target, + 'worktree.rm', + { + worktree: toRuntimeWorktreeSelector(worktreeId), + ...(hostId ? { hostId } : {}), + force, + allowUnverifiedPtyStop: options?.allowUnverifiedPtyStop === true, + runHooks: !skipArchive + }, + { timeoutMs: 60_000 } + ))) + } catch (error) { + if ( + !forgetLocalOnly && + target.kind !== 'local' && + (isRuntimeRepoNotFoundError(error) || isRuntimeSelectorNotFoundError(error)) + ) { + // Missing means stale mirror; ambiguous or changed ownership must fail closed. + const currentResolution = resolveWorktreeOperationRouteResult(get(), worktreeId) + if (currentResolution.kind === 'ambiguous') { + throw error + } + if (currentResolution.kind === 'resolved') { + removalGenerationGuard?.assertCurrent() + } + try { + removalResult = await window.api.worktrees.forgetLocal({ + worktreeId, + hostId, + ...(options?.snapshotPruneBatchId + ? { snapshotPruneBatchId: options.snapshotPruneBatchId } + : {}) + }) + snapshotPruneHandledByLocalMain = true + } catch (fallbackError) { + // Preserve the remote verdict as fallback failure context. + throw new Error( + fallbackError instanceof Error ? fallbackError.message : String(fallbackError), + { cause: error } + ) + } + } else { + throw error + } + } + + if (!snapshotPruneHandledByLocalMain) { + try { + await window.api.workspaceCleanup?.recordRemovalSnapshotPrune?.({ + // Why: a single (unbatched) remote delete must still drop the row + // from the local persisted snapshots or it resurrects from cache; + // an unknown batch id degrades to an immediate one-off prune. The + // id must stay bounded — main rejects batch ids over 128 chars, + // so it cannot embed the unbounded worktreeId. + batchId: options?.snapshotPruneBatchId ?? `single-removal:${crypto.randomUUID()}`, + worktreeId, + ...(hostId ? { executionHostId: hostId } : {}) + }) + } catch (error) { + console.warn('Failed to record workspace cleanup snapshot prune:', error) + } + } + + // Why: invalidate stale probes once deletion is authoritative, so an old toast can't mutate a same-path replacement. + forgetHugeRepoWarningDismissalsForWorktrees([worktreeId]) + // Why: forget-local is legal while the host is unreachable, so record the removal here too — otherwise an + // in-flight metadata read that snapshotted this row re-appends it, and disconnected polls never drop it. + if (hostId && parseExecutionHostId(hostId)?.kind === 'ssh') { + rememberAuthoritativelyRemovedWorktrees(hostId, [worktreeId]) + } + + const worktreeDisplayName = worktreeBeforeRemoval?.displayName?.trim() + if (worktreeDisplayName) { + try { + await window.api.automations?.snapshotWorkspaceName?.({ + workspaceId: worktreeId, + displayName: worktreeDisplayName + }) + } catch (error) { + // Why: snapshotting automation labels is best-effort; a stale preload/test harness must not block removal. + console.warn('Failed to snapshot automation workspace name:', error) + } + } + + // Why: renderer state follows the successful backend result, so blocked dirty deletes keep their terminals intact. + // Why browsers first: unregister Chromium guests before other teardown can intercept them (avoids a browser-state race). + await get().shutdownWorktreeBrowsers(worktreeId) + await get().shutdownWorktreeTerminals(worktreeId, { + shutdownReason: 'remove-worktree', + // The backend removal above already killed the workspace's PTYs. + backendOwnsPtyTeardown: true + }) + // Why: dispose the SSH relay AFTER terminal teardown so a still-mounted pane can't hit a gone relay and toast "SSH not active". + const runtimeCleanup = await cleanupEphemeralVmRuntimesForDeleted({ + workspaceIds: [worktreeId] + }) + // Remove the orphaned project for the destroyed SSH target so it can't surface as a dead project in the composer. + await purgeOrphanedRuntimeSshProjects(get, runtimeCleanup.destroyedSshTargetIds) + const tabs = get().tabsByWorktree[worktreeId] ?? [] + const tabIds = new Set(tabs.map((t) => t.id)) + + // Why: this path deletes tabsByWorktree wholesale (not via closeTab), so purge the module-level tab maps here too. + detachedHeadAutoDerivedDisplayNames.delete(worktreeId) + forgetForegroundTerminalTabs(tabIds) + forgetAgentStartupDeliveriesForTabs(tabIds) + + // Why: snapshot the sidebar top-row anchor in the same tick we remove the row; recording at click time goes stale across the await. + requestVirtualizedScrollAnchorRecord('[data-worktree-sidebar]') + + // Why: dispose parked terminal watchers only on explicit deletion; identity migration/remounts must keep buffered PTY state. + disposeRemovedWorktreeParkedTerminalWatchers(worktreeId, terminalPtyIdsBeforeRemoval) + set((s) => { + const next = { ...s.worktreesByRepo } + for (const repoId of Object.keys(next)) { + next[repoId] = next[repoId].filter((w) => w.id !== worktreeId) + } + const nextTabs = { ...s.tabsByWorktree } + delete nextTabs[worktreeId] + const nextLayouts = { ...s.terminalLayoutsByTabId } + const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } + const nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId } + const nextAutomaticAgentResumeClaimsByTabId = { + ...s.automaticAgentResumeClaimsByTabId + } + const nextNativeChatLaunchPromptByTabId = { ...s.nativeChatLaunchPromptByTabId } + const nextNativeChatLaunchDraftByTabId = { ...s.nativeChatLaunchDraftByTabId } + // Why: closeTab deletes these per-tab maps but removeWorktree missed them, leaking a split pane's expand flags. + const nextExpandedPaneByTabId = { ...s.expandedPaneByTabId } + const nextCanExpandPaneByTabId = { ...s.canExpandPaneByTabId } + for (const tabId of tabIds) { + delete nextLayouts[tabId] + delete nextPtyIdsByTabId[tabId] + delete nextRuntimePaneTitlesByTabId[tabId] + delete nextAutomaticAgentResumeClaimsByTabId[tabId] + delete nextNativeChatLaunchPromptByTabId[tabId] + delete nextNativeChatLaunchDraftByTabId[tabId] + delete nextExpandedPaneByTabId[tabId] + delete nextCanExpandPaneByTabId[tabId] + } + const nextDeleteState = { ...s.deleteStateByWorktreeId } + delete nextDeleteState[worktreeId] + const nextLineage = { ...s.worktreeLineageById } + delete nextLineage[worktreeId] + const nextWorkspaceLineage = { ...s.workspaceLineageByChildKey } + delete nextWorkspaceLineage[worktreeWorkspaceKey(worktreeId)] + // Clean up editor files belonging to this worktree + const newOpenFiles = s.openFiles.filter((f) => f.worktreeId !== worktreeId) + const nextBrowserTabsByWorktree = { ...s.browserTabsByWorktree } + delete nextBrowserTabsByWorktree[worktreeId] + const nextActiveFileIdByWorktree = { ...s.activeFileIdByWorktree } + delete nextActiveFileIdByWorktree[worktreeId] + const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree } + delete nextActiveBrowserTabIdByWorktree[worktreeId] + // Why: closeBrowserTab records a Cmd+Shift+T undo snapshot, but a deleted worktree's tabs can't be restored; purge it. + const nextRecentlyClosedBrowserTabsByWorktree = { + ...s.recentlyClosedBrowserTabsByWorktree + } + delete nextRecentlyClosedBrowserTabsByWorktree[worktreeId] + const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } + delete nextActiveTabTypeByWorktree[worktreeId] + const nextActiveTabIdByWorktree = { ...s.activeTabIdByWorktree } + delete nextActiveTabIdByWorktree[worktreeId] + const nextTabBarOrderByWorktree = { ...s.tabBarOrderByWorktree } + // Why: the tab strip persists visual order per worktree; drop the entry so stale tab IDs aren't retained. + delete nextTabBarOrderByWorktree[worktreeId] + const nextPendingReconnectTabByWorktree = { ...s.pendingReconnectTabByWorktree } + delete nextPendingReconnectTabByWorktree[worktreeId] + // Why: split-tab layout/group state is worktree-owned; leaving it makes a deleted worktree look restorable. + const nextUnifiedTabsByWorktree = { ...s.unifiedTabsByWorktree } + delete nextUnifiedTabsByWorktree[worktreeId] + const nextGroupsByWorktree = { ...s.groupsByWorktree } + delete nextGroupsByWorktree[worktreeId] + const nextLayoutByWorktree = { ...s.layoutByWorktree } + delete nextLayoutByWorktree[worktreeId] + const nextActiveGroupIdByWorktree = { ...s.activeGroupIdByWorktree } + delete nextActiveGroupIdByWorktree[worktreeId] + // Why: git status/compare caches stop refreshing once the worktree is deleted; remove them so no stale badges/diffs linger. + const nextGitStatusByWorktree = { ...s.gitStatusByWorktree } + delete nextGitStatusByWorktree[worktreeId] + const nextGitStatusHeadByWorktree = { ...s.gitStatusHeadByWorktree } + delete nextGitStatusHeadByWorktree[worktreeId] + const nextGitBranchLineTotalByWorktree = { ...s.gitBranchLineTotalByWorktree } + delete nextGitBranchLineTotalByWorktree[worktreeId] + const nextGitIgnoredPathsByWorktree = { ...s.gitIgnoredPathsByWorktree } + delete nextGitIgnoredPathsByWorktree[worktreeId] + const nextGitConflictOperationByWorktree = { ...s.gitConflictOperationByWorktree } + delete nextGitConflictOperationByWorktree[worktreeId] + const nextTrackedConflictPathsByWorktree = { ...s.trackedConflictPathsByWorktree } + delete nextTrackedConflictPathsByWorktree[worktreeId] + const nextGitBranchChangesByWorktree = { ...s.gitBranchChangesByWorktree } + delete nextGitBranchChangesByWorktree[worktreeId] + const nextGitBranchCompareSummaryByWorktree = { ...s.gitBranchCompareSummaryByWorktree } + delete nextGitBranchCompareSummaryByWorktree[worktreeId] + const nextGitBranchCompareRequestKeyByWorktree = { + ...s.gitBranchCompareRequestKeyByWorktree + } + delete nextGitBranchCompareRequestKeyByWorktree[worktreeId] + const nextGitBranchCompareRequestStatusHeadByWorktree = { + ...s.gitBranchCompareRequestStatusHeadByWorktree + } + delete nextGitBranchCompareRequestStatusHeadByWorktree[worktreeId] + // Why: clean up per-file editor state for the removed worktree so stale drafts/view modes don't accumulate. + const removedFileIds = new Set() + for (const file of s.openFiles) { + if (file.worktreeId !== worktreeId) { + continue + } + removedFileIds.add(file.id) + if (file.markdownPreviewSourceFileId) { + removedFileIds.add(file.markdownPreviewSourceFileId) + } + } + const nextEditorDrafts = removedFileIds.size > 0 ? { ...s.editorDrafts } : s.editorDrafts + const nextMarkdownViewMode = + removedFileIds.size > 0 ? { ...s.markdownViewMode } : s.markdownViewMode + const nextEditorViewMode = + removedFileIds.size > 0 ? { ...s.editorViewMode } : s.editorViewMode + const nextMarkdownFrontmatterVisible = + removedFileIds.size > 0 + ? { ...s.markdownFrontmatterVisible } + : s.markdownFrontmatterVisible + // Why: editorCursorLine is keyed by fileId; clear it with the other per-file state so it doesn't leak. + const nextEditorCursorLine = + removedFileIds.size > 0 ? { ...s.editorCursorLine } : s.editorCursorLine + if (removedFileIds.size > 0) { + for (const fileId of removedFileIds) { + delete nextEditorDrafts[fileId] + delete nextMarkdownViewMode[fileId] + delete nextEditorViewMode[fileId] + delete nextMarkdownFrontmatterVisible[fileId] + delete nextEditorCursorLine[fileId] + } + } + const nextExpandedDirs = { ...s.expandedDirs } + delete nextExpandedDirs[worktreeId] + const nextShowDotfilesByWorktree = { ...s.showDotfilesByWorktree } + delete nextShowDotfilesByWorktree[worktreeId] + // Why: clear the huge-status marker so it doesn't linger after the worktree is gone. + const nextGitStatusHugeByWorktree = { ...s.gitStatusHugeByWorktree } + delete nextGitStatusHugeByWorktree[worktreeId] + const nextRightSidebarExplorerViewByWorktree = { + ...s.rightSidebarExplorerViewByWorktree + } + delete nextRightSidebarExplorerViewByWorktree[worktreeId] + // If the active file belonged to the removed worktree, clear it + const activeFileCleared = s.activeFileId + ? s.openFiles.some((f) => f.id === s.activeFileId && f.worktreeId === worktreeId) + : false + const removedActiveWorktree = s.activeWorktreeId === worktreeId + const nextEverActivatedWorktreeIds = s.everActivatedWorktreeIds.has(worktreeId) + ? new Set([...s.everActivatedWorktreeIds].filter((id) => id !== worktreeId)) + : s.everActivatedWorktreeIds + const nextLastVisitedAtByWorktreeId = + worktreeId in s.lastVisitedAtByWorktreeId + ? (() => { + const next = { ...s.lastVisitedAtByWorktreeId } + delete next[worktreeId] + return next + })() + : s.lastVisitedAtByWorktreeId + return { + worktreesByRepo: next, + worktreeLineageById: nextLineage, + workspaceLineageByChildKey: nextWorkspaceLineage, + tabsByWorktree: nextTabs, + ptyIdsByTabId: nextPtyIdsByTabId, + runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId, + automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, + nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, + nativeChatLaunchDraftByTabId: nextNativeChatLaunchDraftByTabId, + terminalLayoutsByTabId: nextLayouts, + expandedPaneByTabId: nextExpandedPaneByTabId, + canExpandPaneByTabId: nextCanExpandPaneByTabId, + deleteStateByWorktreeId: nextDeleteState, + baseStatusByWorktreeId: (() => { + const nextStatus = { ...s.baseStatusByWorktreeId } + delete nextStatus[worktreeId] + return nextStatus + })(), + remoteBranchConflictByWorktreeId: (() => { + const nextConflict = { ...s.remoteBranchConflictByWorktreeId } + delete nextConflict[worktreeId] + return nextConflict + })(), + fileSearchStateByWorktree: (() => { + const nextSearch = { ...s.fileSearchStateByWorktree } + // Why: file search state is worktree-scoped; clear it so another worktree can't inherit stale matches. + delete nextSearch[worktreeId] + return nextSearch + })(), + // Why: these worktree-keyed maps are re-keyed on rename but were missed by removal, leaking one entry each. + remoteStatusesByWorktree: (() => { + const next = { ...s.remoteStatusesByWorktree } + delete next[worktreeId] + return next + })(), + recentlyClosedEditorTabsByWorktree: (() => { + const next = { ...s.recentlyClosedEditorTabsByWorktree } + delete next[worktreeId] + return next + })(), + recentlyClosedTerminalTabsByWorktree: (() => { + const next = { ...s.recentlyClosedTerminalTabsByWorktree } + delete next[worktreeId] + return next + })(), + // Why: a deleted worktree's tabs can never be reopened; purge the kind list with the snapshot stacks above. + recentlyClosedTabKindsByWorktree: (() => { + const next = { ...s.recentlyClosedTabKindsByWorktree } + delete next[worktreeId] + return next + })(), + defaultTerminalTabsAppliedByWorktreeId: (() => { + const next = { ...s.defaultTerminalTabsAppliedByWorktreeId } + delete next[worktreeId] + return next + })(), + activeWorktreeId: removedActiveWorktree ? null : s.activeWorktreeId, + activeWorkspaceExecutionHostId: removedActiveWorktree + ? null + : s.activeWorkspaceExecutionHostId, + activeTabId: s.activeTabId && tabIds.has(s.activeTabId) ? null : s.activeTabId, + openFiles: newOpenFiles, + browserTabsByWorktree: nextBrowserTabsByWorktree, + recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree, + activeFileIdByWorktree: nextActiveFileIdByWorktree, + activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree, + activeTabTypeByWorktree: nextActiveTabTypeByWorktree, + rightSidebarExplorerViewByWorktree: nextRightSidebarExplorerViewByWorktree, + activeTabIdByWorktree: nextActiveTabIdByWorktree, + tabBarOrderByWorktree: nextTabBarOrderByWorktree, + pendingReconnectTabByWorktree: nextPendingReconnectTabByWorktree, + unifiedTabsByWorktree: nextUnifiedTabsByWorktree, + groupsByWorktree: nextGroupsByWorktree, + layoutByWorktree: nextLayoutByWorktree, + activeGroupIdByWorktree: nextActiveGroupIdByWorktree, + editorDrafts: nextEditorDrafts, + markdownViewMode: nextMarkdownViewMode, + editorViewMode: nextEditorViewMode, + markdownFrontmatterVisible: nextMarkdownFrontmatterVisible, + editorCursorLine: nextEditorCursorLine, + showDotfilesByWorktree: nextShowDotfilesByWorktree, + expandedDirs: nextExpandedDirs, + gitStatusHugeByWorktree: nextGitStatusHugeByWorktree, + gitStatusByWorktree: nextGitStatusByWorktree, + gitStatusHeadByWorktree: nextGitStatusHeadByWorktree, + gitBranchLineTotalByWorktree: nextGitBranchLineTotalByWorktree, + gitIgnoredPathsByWorktree: nextGitIgnoredPathsByWorktree, + gitConflictOperationByWorktree: nextGitConflictOperationByWorktree, + trackedConflictPathsByWorktree: nextTrackedConflictPathsByWorktree, + gitBranchChangesByWorktree: nextGitBranchChangesByWorktree, + gitBranchCompareSummaryByWorktree: nextGitBranchCompareSummaryByWorktree, + gitBranchCompareRequestKeyByWorktree: nextGitBranchCompareRequestKeyByWorktree, + gitBranchCompareRequestStatusHeadByWorktree: + nextGitBranchCompareRequestStatusHeadByWorktree, + activeFileId: activeFileCleared ? null : s.activeFileId, + activeBrowserTabId: removedActiveWorktree ? null : s.activeBrowserTabId, + activeTabType: removedActiveWorktree || activeFileCleared ? 'terminal' : s.activeTabType, + everActivatedWorktreeIds: nextEverActivatedWorktreeIds, + lastVisitedAtByWorktreeId: nextLastVisitedAtByWorktreeId, + sortEpoch: s.sortEpoch + 1 + } + }) + get().removeWorkspaceSpaceWorktrees?.([worktreeId]) + // Why: PR/commit-message generation records are keyed by worktree; prune to the surviving set so they don't leak. + const liveWorktreeKeys = new Set( + get() + .allWorktrees() + .map((w) => w.id) + ) + // Optional-chained: minimal store assemblies (some unit tests) omit the generation slices. + get().prunePullRequestGenerationRecords?.(liveWorktreeKeys) + get().pruneCommitMessageGenerationRecords?.(liveWorktreeKeys) + // Why: Source Control may be unmounted during deletion, so it can't be the only stale-draft cleanup path. + clearSessionCommitDraftForWorktree(worktreeId) + const preservedBranch = removalResult?.preservedBranch + const cleanup = preservedBranch + ? { + worktreeId, + branchName: preservedBranch.branchName, + expectedHead: preservedBranch.head, + ...(hostId ? { hostId } : {}), + ...(removalRoute?.runtimeEnvironmentId + ? { runtimeEnvironmentId: removalRoute.runtimeEnvironmentId } + : {}) + } + : null + if (preservedBranch) { + preservedBranchRuntimeTargetByCleanupKey.set(preservedBranchCleanupKey(cleanup!), { + cleanup: cleanup!, + target + }) + } + if (preservedBranch && options?.suppressPreservedBranchToast !== true) { + showPreservedBranchToast(removalResult, worktreeBeforeRemoval, (branch, expectedHead) => { + void get().forceDeletePreservedBranch(worktreeId, branch, expectedHead, { + ...(hostId ? { hostId } : {}), + ...(removalRoute?.runtimeEnvironmentId + ? { runtimeEnvironmentId: removalRoute.runtimeEnvironmentId } + : {}) + }) + }) + } + pruneHostedReviewLinkMutationGenerations([worktreeId]) + return preservedBranch && cleanup + ? { + ok: true as const, + preservedBranch: { + ...preservedBranch, + ...(cleanup.hostId ? { hostId: cleanup.hostId } : {}), + ...(cleanup.runtimeEnvironmentId + ? { runtimeEnvironmentId: cleanup.runtimeEnvironmentId } + : {}) + } + } + : { ok: true as const } + } catch (err) { + // Why: git refusing a non-force delete for dirty/untracked files is a handled user decision, not an app error. + console.warn('Failed to remove worktree:', err) + const error = err instanceof Error ? err.message : String(err) + const forceDeleteReason = classifyWorktreeForceDeleteReason( + error, + force, + options?.allowUnverifiedPtyStop === true + ) + const locked = isLockedWorktreeRemovalError(error) + set((s) => ({ + deleteStateByWorktreeId: { + ...s.deleteStateByWorktreeId, + [worktreeId]: { + isDeleting: false, + error, + canForceDelete: forceDeleteReason !== null, + forceDeleteReason, + ...(locked ? { lockReason: getLockedWorktreeRemovalReason(error) } : {}) + } + } + })) + return { ok: false as const, error } + } + }, + + markWorktreesDeleting: (worktreeIds) => { + if (worktreeIds.length === 0) { + return + } + set((s) => { + const nextDeleteState = { ...s.deleteStateByWorktreeId } + let changed = false + for (const worktreeId of new Set(worktreeIds)) { + const current = nextDeleteState[worktreeId] + if (current?.isDeleting && current.error === null && !current.canForceDelete) { + continue + } + nextDeleteState[worktreeId] = { + isDeleting: true, + phase: 'deleting', + error: null, + canForceDelete: false, + forceDeleteReason: null + } + changed = true + } + return changed ? { deleteStateByWorktreeId: nextDeleteState } : {} + }) + }, + + markWorktreesQueuedForDeletion: (worktreeIds) => { + if (worktreeIds.length === 0) { + return + } + set((s) => { + const nextDeleteState = { ...s.deleteStateByWorktreeId } + let changed = false + for (const worktreeId of new Set(worktreeIds)) { + const current = nextDeleteState[worktreeId] + if (current?.isDeleting && current.error === null && !current.canForceDelete) { + continue + } + nextDeleteState[worktreeId] = { + isDeleting: true, + phase: 'queued', + error: null, + canForceDelete: false, + forceDeleteReason: null + } + changed = true + } + return changed ? { deleteStateByWorktreeId: nextDeleteState } : {} + }) + }, + + forceDeletePreservedBranch: async (worktreeId, branchName, expectedHead, options) => { + try { + const requestedCleanup: PreservedBranchCleanup = { + worktreeId, + branchName, + expectedHead, + ...(options?.hostId ? { hostId: options.hostId } : {}), + ...(options?.runtimeEnvironmentId + ? { runtimeEnvironmentId: options.runtimeEnvironmentId } + : {}) + } + const requestedCleanupKey = preservedBranchCleanupKey(requestedCleanup) + const exactRetainedTarget = preservedBranchRuntimeTargetByCleanupKey.get(requestedCleanupKey) + const matchingRetainedTargets = exactRetainedTarget + ? [exactRetainedTarget] + : [...preservedBranchRuntimeTargetByCleanupKey.values()].filter( + ({ cleanup }) => + cleanup.worktreeId === worktreeId && + cleanup.branchName === branchName && + cleanup.expectedHead === expectedHead + ) + const retainedTarget = + exactRetainedTarget ?? + (options?.hostId || options?.runtimeEnvironmentId || matchingRetainedTargets.length !== 1 + ? undefined + : matchingRetainedTargets[0]) + if ((options?.hostId || options?.runtimeEnvironmentId) && !retainedTarget) { + throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) + } + const cleanupHostId = options?.hostId ?? retainedTarget?.cleanup.hostId + // Why: the removed row no longer records its nested HUB owner, so retain the deletion-time route. + const target = + retainedTarget?.target ?? + getActiveRuntimeTarget(settingsForWorktreeOwner(get(), worktreeId)) + const result = await (target.kind === 'local' + ? window.api.worktrees.forceDeletePreservedBranch({ + worktreeId, + branchName, + expectedHead, + ...(cleanupHostId ? { hostId: cleanupHostId } : {}) + }) + : callRuntimeRpc( + target, + 'worktree.forceDeleteBranch', + { + worktree: toRuntimeWorktreeSelector(worktreeId), + branchName, + expectedHead, + ...(cleanupHostId ? { hostId: cleanupHostId } : {}) + }, + { timeoutMs: 15_000 } + )) + if (options?.suppressToast !== true) { + toast.success(translate('auto.store.slices.worktrees.19db0085fb', 'Local branch deleted'), { + description: translate( + 'auto.store.slices.worktrees.5a58e03a26', + 'Deleted "{{value0}}".', + { value0: branchName } + ) + }) + } + preservedBranchRuntimeTargetByCleanupKey.delete( + retainedTarget ? preservedBranchCleanupKey(retainedTarget.cleanup) : requestedCleanupKey + ) + return { ok: true as const, ...result } + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + if (options?.suppressToast !== true) { + toast.error( + translate('auto.store.slices.worktrees.0216895fb5', 'Failed to delete branch'), + { + description: error + } + ) + } + return { ok: false as const, error } + } + }, + + clearWorktreeDeleteState: (worktreeId) => { + set((s) => { + if (!s.deleteStateByWorktreeId[worktreeId]) { + return {} + } + const next = { ...s.deleteStateByWorktreeId } + delete next[worktreeId] + return { deleteStateByWorktreeId: next } + }) + }, + + updateWorktreeMeta: async (worktreeId, updates, options) => { + const shouldApplyUpdate = options?.shouldApply + const existingWorktree = get().getKnownWorktreeById(worktreeId) + if (shouldApplyUpdate && !shouldApplyUpdate(existingWorktree)) { + return { ok: true } + } + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + const folderUpdates = getFolderWorkspaceMetaUpdates(updates) + if (Object.keys(folderUpdates).length === 0) { + return { ok: true } + } + try { + // Why: a rejected folder update reconciles the optimistic write away, so + // reporting ok would show the dialog a save that silently undid itself. + const updated = await get().updateFolderWorkspace( + workspaceScope.folderWorkspaceId, + folderUpdates + ) + return updated + ? { ok: true } + : { + ok: false, + error: translate( + 'auto.store.slices.worktrees.a17f4d2e93', + 'Could not update this workspace.' + ) + } + } catch (err) { + console.error('Failed to update folder workspace meta:', err) + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } + } + const normalizedUpdates = existingWorktree + ? clearOlderHostedReviewLinksForReplacement(updates, existingWorktree) + : updates + // Why: manual PR linking supplies only the number; resolve the head branch so Push targets the review branch. + const linkedPrForPushTarget = isPositiveHostedReviewNumber(normalizedUpdates.linkedPR) + ? normalizedUpdates.linkedPR + : null + const resolvedPushTarget = + linkedPrForPushTarget !== null && + normalizedUpdates.pushTarget === undefined && + existingWorktree && + !existingWorktree.pushTarget + ? await resolveGitHubReviewPushTarget( + settingsForWorktreeOwner(get(), worktreeId), + existingWorktree.repoId, + linkedPrForPushTarget + ) + : undefined + const existingHostedReviewPushTargetLookup = existingWorktree + ? getHostedReviewPushTargetLookup(existingWorktree) + : null + const nextHostedReviewPushTargetLookup = existingWorktree + ? getHostedReviewPushTargetLookup({ ...existingWorktree, ...normalizedUpdates }) + : null + // Why: a pushTarget derived from a linked review must not keep steering pushes after it's unlinked or replaced. + const shouldClearStaleHostedReviewPushTarget = + Boolean(existingWorktree?.pushTarget) && + normalizedUpdates.pushTarget === undefined && + resolvedPushTarget === undefined && + existingHostedReviewPushTargetLookup !== null && + existingHostedReviewPushTargetLookup.key !== nextHostedReviewPushTargetLookup?.key + const worktreeForUpdate = get().getKnownWorktreeById(worktreeId) + if (shouldApplyUpdate && !shouldApplyUpdate(worktreeForUpdate)) { + return { ok: true } + } + const shouldRefreshHostedReview = + (normalizedUpdates.linkedPR === null && worktreeForUpdate?.linkedPR !== null) || + (normalizedUpdates.linkedGitLabMR === null && + (worktreeForUpdate?.linkedGitLabMR ?? null) !== null) || + (normalizedUpdates.linkedBitbucketPR === null && + (worktreeForUpdate?.linkedBitbucketPR ?? null) !== null) || + (normalizedUpdates.linkedAzureDevOpsPR === null && + (worktreeForUpdate?.linkedAzureDevOpsPR ?? null) !== null) || + (normalizedUpdates.linkedGiteaPR === null && + (worktreeForUpdate?.linkedGiteaPR ?? null) !== null) + const reviewRepo = shouldRefreshHostedReview + ? get().repos.find((repo) => repo.id === worktreeForUpdate?.repoId) + : undefined + const reviewBranch = worktreeForUpdate?.branch.replace(/^refs\/heads\//, '') + + // Why: bump lastActivityAt on comment edits so the time-decay sort doesn't drop a just-touched worktree. + const targetEnriched = resolvedPushTarget + ? { ...normalizedUpdates, pushTarget: resolvedPushTarget } + : shouldClearStaleHostedReviewPushTarget + ? { ...normalizedUpdates, pushTarget: undefined } + : normalizedUpdates + const renameCleared = + 'displayName' in targetEnriched + ? { + ...targetEnriched, + pendingFirstAgentMessageRename: false, + firstAgentMessageRenameError: null + } + : targetEnriched + const enriched = + 'comment' in renameCleared ? { ...renameCleared, lastActivityAt: Date.now() } : renameCleared + + let didApply = false + set((s) => { + if (shouldApplyUpdate && !shouldApplyUpdate(findKnownWorktreeById(s, worktreeId))) { + return {} + } + didApply = true + const nextWorktrees = applyWorktreeUpdates(s.worktreesByRepo, worktreeId, enriched) + const nextDetectedWorktrees = applyDetectedWorktreeUpdates( + s.detectedWorktreesByRepo, + worktreeId, + enriched + ) + const cacheKey = + reviewRepo && reviewBranch + ? getHostedReviewCacheKey( + reviewRepo.path, + reviewBranch, + s.settings, + reviewRepo.id, + reviewRepo.connectionId, + reviewRepo.executionHostId, + true + ) + : null + const prCacheKey = + reviewRepo && reviewBranch + ? getGitHubPRCacheKey( + reviewRepo.path, + reviewRepo.id, + reviewBranch, + s.settings, + reviewRepo.connectionId, + reviewRepo.executionHostId, + true + ) + : null + const prCacheKeys = + reviewRepo && reviewBranch + ? [ + prCacheKey, + getLegacyGitHubPRCacheKey(reviewRepo.path, reviewRepo.id, reviewBranch), + getLegacyGitHubPRCacheKey(reviewRepo.path, undefined, reviewBranch) + ].filter((key): key is string => Boolean(key)) + : [] + const hostedReviewCache = s.hostedReviewCache ?? {} + const prCache = s.prCache ?? {} + if ( + nextWorktrees === s.worktreesByRepo && + nextDetectedWorktrees === s.detectedWorktreesByRepo && + !cacheKey && + !prCacheKey + ) { + return {} + } + + const nextHostedReviewCache = + cacheKey && hostedReviewCache[cacheKey] + ? (() => { + const next = { ...hostedReviewCache } + delete next[cacheKey] + return next + })() + : hostedReviewCache + const nextPRCache = prCacheKeys.some((key) => prCache[key]) + ? (() => { + const next = { ...prCache } + for (const key of prCacheKeys) { + delete next[key] + } + return next + })() + : prCache + + return { + ...(nextWorktrees !== s.worktreesByRepo + ? { worktreesByRepo: nextWorktrees, sortEpoch: s.sortEpoch + 1 } + : {}), + ...(nextDetectedWorktrees !== s.detectedWorktreesByRepo + ? { detectedWorktreesByRepo: nextDetectedWorktrees } + : {}), + ...(nextHostedReviewCache !== hostedReviewCache + ? { hostedReviewCache: nextHostedReviewCache } + : {}), + ...(nextPRCache !== prCache ? { prCache: nextPRCache } : {}) + } + }) + if (shouldApplyUpdate && !didApply) { + return { ok: true } + } + if (hasHostedReviewLinkUpdates(enriched)) { + bumpHostedReviewLinkMutationGeneration(worktreeId) + } + + try { + await persistWorktreeMeta(settingsForWorktreeOwner(get(), worktreeId), worktreeId, enriched) + if ( + !options?.suppressHostedReviewRefresh && + reviewRepo && + reviewBranch && + typeof get().fetchHostedReviewForBranch === 'function' + ) { + // Why: refetch against post-update links so a cache entry from the previous provider link can't keep showing the removed review. + void get().fetchHostedReviewForBranch(reviewRepo.path, reviewBranch, { + repoId: reviewRepo.id, + linkedGitHubPR: getHostedReviewLinkForMetaRefresh( + targetEnriched, + worktreeForUpdate, + 'linkedPR' + ), + linkedGitLabMR: getHostedReviewLinkForMetaRefresh( + targetEnriched, + worktreeForUpdate, + 'linkedGitLabMR' + ), + linkedBitbucketPR: getHostedReviewLinkForMetaRefresh( + targetEnriched, + worktreeForUpdate, + 'linkedBitbucketPR' + ), + linkedAzureDevOpsPR: getHostedReviewLinkForMetaRefresh( + targetEnriched, + worktreeForUpdate, + 'linkedAzureDevOpsPR' + ), + linkedGiteaPR: getHostedReviewLinkForMetaRefresh( + targetEnriched, + worktreeForUpdate, + 'linkedGiteaPR' + ), + force: true + }) + } + } catch (err) { + if (isRuntimeSelectorNotFoundError(err)) { + void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) + return { + ok: false, + error: translate( + 'auto.store.slices.worktrees.c6cf133786', + 'This workspace is no longer available.' + ) + } + } + console.error('Failed to update worktree meta:', err) + void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) + // Why: the refetch above reverts the optimistic write, so a caller that + // closes its surface on this path shows the user a save that undid itself. + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } + return { ok: true } + }, + + ensureHostedReviewPushTarget: async (worktreeId) => { + const worktree = get().getKnownWorktreeById(worktreeId) + if (!worktree || worktree.pushTarget) { + return + } + const lookup = getHostedReviewPushTargetLookup(worktree) + if (!lookup || hostedReviewPushTargetLookupsInFlight.has(lookup.key)) { + return + } + hostedReviewPushTargetLookupsInFlight.add(lookup.key) + try { + const resolvedPushTarget = await lookup.resolve(settingsForWorktreeOwner(get(), worktreeId)) + if (!resolvedPushTarget) { + return + } + const current = get().getKnownWorktreeById(worktreeId) + if (!current || current.pushTarget) { + return + } + const currentLookup = getHostedReviewPushTargetLookup(current) + if (currentLookup?.key !== lookup.key) { + return + } + // Why: restore the review head push target so push/status stay aligned after metadata loss. + await get().updateWorktreeMeta(worktreeId, { pushTarget: resolvedPushTarget }) + } finally { + hostedReviewPushTargetLookupsInFlight.delete(lookup.key) + } + }, + + updateWorktreesMeta: async (updatesByWorktreeId) => { + if (updatesByWorktreeId.size === 0) { + return + } + + set((s) => { + let nextWorktrees = s.worktreesByRepo + let nextDetectedWorktrees = s.detectedWorktreesByRepo + for (const [worktreeId, updates] of updatesByWorktreeId) { + nextWorktrees = applyWorktreeUpdates(nextWorktrees, worktreeId, updates) + nextDetectedWorktrees = applyDetectedWorktreeUpdates( + nextDetectedWorktrees, + worktreeId, + updates + ) + } + return nextWorktrees === s.worktreesByRepo && + nextDetectedWorktrees === s.detectedWorktreesByRepo + ? {} + : { + ...(nextWorktrees !== s.worktreesByRepo + ? { worktreesByRepo: nextWorktrees, sortEpoch: s.sortEpoch + 1 } + : {}), + ...(nextDetectedWorktrees !== s.detectedWorktreesByRepo + ? { detectedWorktreesByRepo: nextDetectedWorktrees } + : {}) + } + }) + + await Promise.all( + Array.from(updatesByWorktreeId, async ([worktreeId, updates]) => { + try { + await persistWorktreeMeta( + settingsForWorktreeOwner(get(), worktreeId), + worktreeId, + updates + ) + } catch (err) { + if (isRuntimeSelectorNotFoundError(err)) { + void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) + return + } + console.error('Failed to update worktree meta:', err) + void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) + } + }) + ) + }, + + setWorktreesPinnedAndReveal: (worktreeIds, isPinned) => { + // Only follow a toggled row with the viewport when it's the focused worktree, not an unfocused card. + const activeSidebarWorktreeId = getActiveSidebarWorkspaceId( + get().activeWorkspaceKey, + get().activeWorktreeId + ) + // Skip worktrees already in the target state so a no-op toggle doesn't scroll the viewport away. + const updates = new Map>() + let didChange = false + let revealWorktreeId: string | null = null + for (const worktreeId of worktreeIds) { + const current = get().getKnownWorktreeById(worktreeId) + if (!current || current.isPinned === isPinned) { + continue + } + didChange = true + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + void get().updateWorktreeMeta(worktreeId, { isPinned }) + } else { + updates.set(worktreeId, { isPinned }) + } + if (revealWorktreeId === null && worktreeId === activeSidebarWorktreeId) { + revealWorktreeId = worktreeId + } + } + if (!didChange) { + return + } + // updateWorktreesMeta applies the store update synchronously, so the reveal below sees the row already rendered. + void get().updateWorktreesMeta(updates) + if (revealWorktreeId !== null) { + get().revealWorktreeInSidebar(revealWorktreeId, { behavior: 'smooth', highlight: true }) + } + }, + + markWorktreeUnread: (worktreeId) => { + // Why: attention dot stays until the user engages the worktree; cleared by pane interaction or activation. + const now = Date.now() + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + const folderWorkspaceId = workspaceScope.folderWorkspaceId + let shouldPersist = false + set((s) => { + const folderWorkspace = s.folderWorkspaces.find( + (workspace) => workspace.id === folderWorkspaceId + ) + if (!folderWorkspace || folderWorkspace.isUnread) { + return s + } + shouldPersist = true + return { + folderWorkspaces: s.folderWorkspaces.map((workspace) => + workspace.id === folderWorkspaceId + ? { ...workspace, isUnread: true, lastActivityAt: now } + : workspace + ), + sortEpoch: s.sortEpoch + 1 + } + }) + if (!shouldPersist) { + return + } + void get().updateFolderWorkspace(folderWorkspaceId, { + isUnread: true, + lastActivityAt: now + }) + return + } + let shouldPersist = false + set((s) => { + const worktree = findKnownWorktreeById(s, worktreeId) + if (!worktree || worktree.isUnread) { + return {} + } + shouldPersist = true + const nextWorktrees = applyWorktreeUpdates(s.worktreesByRepo, worktreeId, { + isUnread: true, + lastActivityAt: now + }) + const nextDetectedWorktrees = applyDetectedWorktreeUpdates( + s.detectedWorktreesByRepo, + worktreeId, + { + isUnread: true, + lastActivityAt: now + } + ) + return { + ...(nextWorktrees !== s.worktreesByRepo + ? { worktreesByRepo: nextWorktrees, sortEpoch: s.sortEpoch + 1 } + : {}), + ...(nextDetectedWorktrees !== s.detectedWorktreesByRepo + ? { detectedWorktreesByRepo: nextDetectedWorktrees } + : {}) + } + }) + + if (!shouldPersist) { + return + } + + persistPassiveWorktreeMetaForOwner( + get, + worktreeId, + { isUnread: true, lastActivityAt: now }, + 'persist unread worktree state' + ) + }, + + observeTerminalGitHubPullRequestLink: (worktreeId, link) => { + const state = get() + const worktree = findKnownWorktreeById(state, worktreeId) + if (!worktree || worktree.isBare || worktree.isArchived) { + return + } + const repo = state.repos.find((candidate) => candidate.id === worktree.repoId) + if (!repo || (repo.kind && repo.kind !== 'git')) { + return + } + if (typeof worktree.linkedPR === 'number' && worktree.linkedPR !== link.number) { + return + } + + const branch = branchName(worktree.branch) + const alreadyLinked = worktree.linkedPR === link.number + + const fetchPRForBranch = get().fetchPRForBranch + if (typeof fetchPRForBranch === 'function') { + void fetchPRForBranch(repo.path, branch, { + force: true, + repoId: repo.id, + worktreeId, + linkedPRNumber: alreadyLinked ? link.number : null, + fallbackPRNumber: null, + fallbackPRSource: alreadyLinked ? null : 'explicit' + }).then((pr) => { + if (!alreadyLinked && pr?.number === link.number) { + // Why: terminal output can carry arbitrary PR URLs (docs/agents/logs). + // Persist only after branch lookup confirms it and the user hasn't picked another PR mid-flight. + void get().updateWorktreeMeta( + worktreeId, + { linkedPR: link.number }, + { + shouldApply: (currentWorktree) => + Boolean( + currentWorktree && + !currentWorktree.isBare && + !currentWorktree.isArchived && + (currentWorktree.linkedPR == null || currentWorktree.linkedPR === link.number) + ) + } + ) + } + }) + return + } + + const fetchHostedReviewForBranch = get().fetchHostedReviewForBranch + if (typeof fetchHostedReviewForBranch === 'function') { + // Why: full app stores have fetchPRForBranch (syncs the hosted-review cache); this is only a slice-test fallback. + void refreshHostedReviewCard(fetchHostedReviewForBranch, { + repoPath: repo.path, + repoId: repo.id, + branch, + linkedGitHubPR: alreadyLinked ? link.number : null, + fallbackGitHubPR: null, + linkedGitLabMR: worktree.linkedGitLabMR ?? null + }) + } + }, + + clearWorktreeUnread: (worktreeId) => { + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + const folderWorkspaceId = workspaceScope.folderWorkspaceId + const folderWorkspace = get().folderWorkspaces.find( + (workspace) => workspace.id === folderWorkspaceId + ) + if (!folderWorkspace?.isUnread) { + return + } + // Why: flip locally first — this runs per keystroke, so the guard above must dedupe before the IPC round-trip lands. + set((s) => ({ + folderWorkspaces: s.folderWorkspaces.map((workspace) => + workspace.id === folderWorkspaceId ? { ...workspace, isUnread: false } : workspace + ) + })) + void get().updateFolderWorkspace(folderWorkspaceId, { isUnread: false }) + return + } + let shouldPersist = false + set((s) => { + const worktree = findKnownWorktreeById(s, worktreeId) + if (!worktree || !worktree.isUnread) { + // Why: return `s` (not {}) to keep the object reference on this hot-path no-op (every keystroke), avoiding selector churn. + return s + } + shouldPersist = true + const nextWorktrees = applyWorktreeUpdates(s.worktreesByRepo, worktreeId, { + isUnread: false + }) + const nextDetectedWorktrees = applyDetectedWorktreeUpdates( + s.detectedWorktreesByRepo, + worktreeId, + { + isUnread: false + } + ) + return { + ...(nextWorktrees !== s.worktreesByRepo ? { worktreesByRepo: nextWorktrees } : {}), + ...(nextDetectedWorktrees !== s.detectedWorktreesByRepo + ? { detectedWorktreesByRepo: nextDetectedWorktrees } + : {}) + } + }) + + if (!shouldPersist) { + return + } + + persistPassiveWorktreeMetaForOwner( + get, + worktreeId, + { isUnread: false }, + 'persist cleared unread worktree state' + ) + }, + + bumpWorktreeActivity: (worktreeId) => { + const now = Date.now() + const workspaceScope = parseWorkspaceKey(worktreeId) + if (workspaceScope?.type === 'folder') { + // Why: folder meta lives on the FolderWorkspace record — persistWorktreeMeta would write a + // worktreeMeta['folder:…'] row that folderWorkspaces:list never reads back (#10251). + const folderWorkspaceId = workspaceScope.folderWorkspaceId + let shouldPersist = false + set((s) => { + if (!s.folderWorkspaces.some((workspace) => workspace.id === folderWorkspaceId)) { + return s + } + shouldPersist = true + const isActive = s.activeWorktreeId === worktreeId + return { + folderWorkspaces: s.folderWorkspaces.map((workspace) => + workspace.id === folderWorkspaceId ? { ...workspace, lastActivityAt: now } : workspace + ), + // Why: active-workspace PTY events are click side-effects, so they must not reorder it. + ...(isActive ? {} : { sortEpoch: s.sortEpoch + 1 }) + } + }) + if (shouldPersist) { + getFolderWorkspaceActivityPersistence(get).record(folderWorkspaceId, now) + } + return + } + let shouldPersist = false + set((s) => { + const worktree = findKnownWorktreeById(s, worktreeId) + if (!worktree) { + return {} + } + shouldPersist = true + // Why: skip sortEpoch bump for the active worktree — its PTY events are click side-effects (reorder-on-click bug, PR #209). + // lastActivityAt is still persisted so the next background-driven sortEpoch bump includes this worktree's score. + const isActive = s.activeWorktreeId === worktreeId + const nextWorktrees = applyWorktreeUpdates(s.worktreesByRepo, worktreeId, { + lastActivityAt: now + }) + const nextDetectedWorktrees = applyDetectedWorktreeUpdates( + s.detectedWorktreesByRepo, + worktreeId, + { + lastActivityAt: now + } + ) + return { + ...(nextWorktrees !== s.worktreesByRepo + ? { + worktreesByRepo: nextWorktrees, + ...(isActive ? {} : { sortEpoch: s.sortEpoch + 1 }) + } + : {}), + ...(nextDetectedWorktrees !== s.detectedWorktreesByRepo + ? { detectedWorktreesByRepo: nextDetectedWorktrees } + : {}) + } + }) + + if (!shouldPersist) { + return + } + + const ownerSettings = trySettingsForWorktreeOwner(get(), worktreeId) + if (!ownerSettings) { + warnAmbiguousOwnerOnce(worktreeId, 'persist worktree activity timestamp') + return + } + void persistWorktreeMeta(ownerSettings, worktreeId, { + lastActivityAt: now + }).catch((err) => { + if (isRuntimeSelectorNotFoundError(err)) { + return + } + console.error('Failed to persist worktree activity timestamp:', err) + void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) + }) + }, + + markWorktreeVisited: (worktreeId, visitedAt) => { + // Why: Cmd+J empty-query ordering needs a focus-recency signal distinct from lastActivityAt (background PTY/activity). + // Monotonic: CLI/IPC activations can race, so older timestamps must not regress. See docs/cmd-j-empty-query-ordering.md. + set((s) => { + const now = visitedAt ?? Date.now() + const prev = s.lastVisitedAtByWorktreeId[worktreeId] ?? 0 + if (!(now > prev)) { + return {} + } + return { + lastVisitedAtByWorktreeId: { + ...s.lastVisitedAtByWorktreeId, + [worktreeId]: now + } + } + }) + }, + + pruneLastVisitedTimestamps: () => { + set((s) => { + // Why: prune per-repo, not globally — SSH repos aren't hydrated at startup, so a global prune would wipe SSH focus-recency. + // Only drop for repos with a populated/authoritative list; a missing repoId means not-yet-hydrated (defer). + const validIdsByRepo = new Map>() + for (const [repoId, list] of Object.entries(s.worktreesByRepo)) { + if (s.detectedWorktreesByRepo[repoId]) { + continue + } + validIdsByRepo.set(repoId, new Set(list.map((worktree) => worktree.id))) + } + for (const [repoId, result] of Object.entries(s.detectedWorktreesByRepo)) { + if (result.authoritative) { + validIdsByRepo.set(repoId, new Set(result.worktrees.map((worktree) => worktree.id))) + } + } + let changed = false + const next: Record = {} + for (const [id, ts] of Object.entries(s.lastVisitedAtByWorktreeId)) { + const repoId = getRepoIdFromWorktreeId(id) + const repoIds = validIdsByRepo.get(repoId) + if (!repoIds) { + // Repo not yet hydrated (e.g. SSH not connected). Keep the entry. + next[id] = ts + continue + } + if (repoIds.has(id)) { + next[id] = ts + } else { + changed = true + } + } + const patch: { + lastVisitedAtByWorktreeId?: Record + activeWorktreeId?: null + activeWorkspaceExecutionHostId?: null + } = {} + if (changed) { + patch.lastVisitedAtByWorktreeId = next + } + // Why: the persisted active-worktree pointer is a `${repoId}::${path}` id + // that nothing else reconciles here. The main-process Store clears a stale + // pointer when a repo is removed (removeWorkspaceSessionOwner nulls + // activeWorktreeId), but the web client keeps it in localStorage and gets + // no such load-time GC — so a pointer to a worktree the server no longer + // reports lingers and can surface a phantom/duplicate workspace. Clear it + // once its repo is hydrated and the worktree is confirmed gone (defer while + // the repo is unhydrated, mirroring the timestamp rule above). + const activeId = s.activeWorktreeId + if (activeId) { + const activeRepoWorktreeIds = validIdsByRepo.get(getRepoIdFromWorktreeId(activeId)) + if (activeRepoWorktreeIds && !activeRepoWorktreeIds.has(activeId)) { + patch.activeWorktreeId = null + patch.activeWorkspaceExecutionHostId = null + } + } + return Object.keys(patch).length > 0 ? patch : {} + }) + }, + + seedActiveWorktreeLastVisitedIfMissing: () => { + set((s) => { + const id = s.activeWorktreeId + if (!id) { + return {} + } + if (s.lastVisitedAtByWorktreeId[id] != null) { + return {} + } + return { + lastVisitedAtByWorktreeId: { + ...s.lastVisitedAtByWorktreeId, + [id]: Date.now() + } + } + }) + }, + + setRenamingWorktreeId: (request) => { + set({ + renamingWorktreeId: typeof request === 'string' ? { worktreeId: request } : request + }) + }, + + remountTerminalTabForRecovery: (tabId) => { + let remounted = false + set((s) => { + for (const [worktreeId, tabs] of Object.entries(s.tabsByWorktree)) { + const index = tabs.findIndex((tab) => tab.id === tabId) + if (index === -1) { + continue + } + const tab = tabs[index] + const nextTabs = tabs.slice() + nextTabs[index] = { + ...tab, + // Why: bump generation to remount a pane whose renderer died while its PTY stayed alive, so it reattaches, not spawns. + generation: (tab.generation ?? 0) + 1, + // Why: recovery isn't a user interaction — suppress its PTY updates from reshuffling Recent, like activation remounts. + pendingActivationSpawn: getTerminalActivationSpawnSuppression( + s.terminalLayoutsByTabId[tab.id] + ) + } + remounted = true + return { + tabsByWorktree: { + ...s.tabsByWorktree, + [worktreeId]: nextTabs + } + } + } + return {} + }) + return remounted + }, + + setActiveWorktree: (worktreeId, executionHostId, options) => { + const stateTransition = options?.stateTransition?.(get()) + if (stateTransition && !stateTransition.activate) { + if (Object.keys(stateTransition.patch).length > 0) { + set(stateTransition.patch) + } + return false + } + const workspaceScope = worktreeId ? parseWorkspaceKey(worktreeId) : null + if (worktreeId && shouldDeferActivationTerminalPrep()) { + markInputQuietSchedulerInput() + } + + if (get().activeWorktreeId !== worktreeId) { + moveFocusToRendererBeforeFocusedWebviewHidden() + } + let shouldClearUnread = false + let shouldPrepareTerminalTabs = false + let shouldTagTerminalTabs = false + set((current) => { + const transitioned = stateTransition + ? ({ ...current, ...stateTransition.patch } as AppState) + : current + const reconciliation = worktreeId + ? projectWorktreeTabModelReconciliation(transitioned, worktreeId) + : null + const reconciliationChanged = Boolean( + reconciliation && Object.keys(reconciliation.patch).length > 0 + ) + const s = + reconciliation && reconciliationChanged + ? ({ ...transitioned, ...reconciliation.patch } as AppState) + : transitioned + const reconciledActiveTabId = reconciliation?.activeRenderableTabId ?? null + if (!worktreeId) { + return { + ...stateTransition?.patch, + activeWorktreeId: null, + activeWorkspaceKey: null, + activeWorkspaceExecutionHostId: null, + // Why: clearing/activating a worktree must dismiss the background-creation panel so the user isn't stranded on it. + activePendingCreationId: null + } + } + + const worktree = findKnownWorktreeById(s, worktreeId, executionHostId) + shouldClearUnread = Boolean(worktree?.isUnread) + + // Why: Search lives under Explorer, so the files/search sub-route must switch with the worktree, not leak the prior one. + const restoredRightSidebarExplorerView = + s.rightSidebarExplorerViewByWorktree?.[worktreeId] ?? 'files' + const restoredFileId = s.activeFileIdByWorktree[worktreeId] ?? null + const restoredBrowserTabId = s.activeBrowserTabIdByWorktree[worktreeId] ?? null + const restoredTabType = s.activeTabTypeByWorktree[worktreeId] ?? 'terminal' + const activeGroupId = + s.activeGroupIdByWorktree[worktreeId] ?? s.groupsByWorktree[worktreeId]?.[0]?.id ?? null + const activeGroup = activeGroupId + ? ((s.groupsByWorktree[worktreeId] ?? []).find((group) => group.id === activeGroupId) ?? + null) + : null + const activeUnifiedTabId = + stateTransition?.preferredActiveUnifiedTabId ?? + reconciledActiveTabId ?? + activeGroup?.activeTabId ?? + null + const activeUnifiedTab = + activeUnifiedTabId != null + ? ((s.unifiedTabsByWorktree[worktreeId] ?? []).find( + (tab) => + tab.id === activeUnifiedTabId && (!activeGroup || tab.groupId === activeGroup.id) + ) ?? null) + : null + // Verify the restored file still exists in openFiles + const fileStillOpen = restoredFileId + ? s.openFiles.some((f) => f.id === restoredFileId && f.worktreeId === worktreeId) + : false + const browserTabs = s.browserTabsByWorktree[worktreeId] ?? [] + const browserTabStillOpen = restoredBrowserTabId + ? browserTabs.some((tab) => tab.id === restoredBrowserTabId) + : false + const hasGroupOwnedSurface = + (s.groupsByWorktree[worktreeId]?.length ?? 0) > 0 || Boolean(s.layoutByWorktree[worktreeId]) + + // Why: restore from the reconciled tab-group model first; preferring legacy fallbacks can show a blank worktree. + let activeFileId: string | null + let activeBrowserTabId: string | null + let activeTabType: WorkspaceVisibleTabType + if (activeUnifiedTab) { + activeFileId = + activeUnifiedTab.contentType === 'editor' || + activeUnifiedTab.contentType === 'diff' || + activeUnifiedTab.contentType === 'conflict-review' || + activeUnifiedTab.contentType === 'check-details' + ? activeUnifiedTab.entityId + : fileStillOpen + ? restoredFileId + : null + activeBrowserTabId = + activeUnifiedTab.contentType === 'browser' + ? activeUnifiedTab.entityId + : browserTabStillOpen + ? restoredBrowserTabId + : (browserTabs[0]?.id ?? null) + activeTabType = toVisibleTabType(activeUnifiedTab.contentType) + } else if (hasGroupOwnedSurface) { + activeFileId = fileStillOpen ? restoredFileId : null + activeBrowserTabId = browserTabStillOpen + ? restoredBrowserTabId + : (browserTabs[0]?.id ?? null) + activeTabType = 'terminal' + } else if (restoredTabType === 'terminal') { + activeFileId = fileStillOpen ? restoredFileId : null + activeBrowserTabId = browserTabStillOpen + ? restoredBrowserTabId + : (browserTabs[0]?.id ?? null) + activeTabType = 'terminal' + } else if (restoredTabType === 'browser' && browserTabStillOpen) { + activeFileId = fileStillOpen ? restoredFileId : null + activeBrowserTabId = restoredBrowserTabId + activeTabType = 'browser' + } else if (restoredTabType === 'editor' && fileStillOpen) { + activeFileId = restoredFileId + activeBrowserTabId = browserTabStillOpen + ? restoredBrowserTabId + : (browserTabs[0]?.id ?? null) + activeTabType = 'editor' + } else if (browserTabStillOpen) { + activeFileId = null + activeBrowserTabId = restoredBrowserTabId + activeTabType = 'browser' + } else if (fileStillOpen) { + activeFileId = restoredFileId + activeBrowserTabId = browserTabs[0]?.id ?? null + activeTabType = 'editor' + } else { + const fallbackFile = s.openFiles.find((f) => f.worktreeId === worktreeId) + const fallbackBrowserTab = browserTabs[0] ?? null + activeFileId = fallbackFile?.id ?? null + activeBrowserTabId = browserTabStillOpen + ? restoredBrowserTabId + : (fallbackBrowserTab?.id ?? null) + activeTabType = fallbackFile ? 'editor' : fallbackBrowserTab ? 'browser' : 'terminal' + } + + // Why: restore the last-active terminal tab so the user returns to where they left, not tab 0. + const restoredTabId = s.activeTabIdByWorktree[worktreeId] ?? null + const worktreeTabs = s.tabsByWorktree[worktreeId] ?? [] + const tabStillExists = restoredTabId + ? worktreeTabs.some((t) => t.id === restoredTabId) + : false + const activeTabId = + activeUnifiedTab?.contentType === 'terminal' + ? activeUnifiedTab.entityId + : tabStillExists + ? restoredTabId + : (worktreeTabs[0]?.id ?? null) + + // Why: focus isn't smart-sort activity — writing lastActivityAt here caused the "jump after focus" bug; only clear unread. + const metaUpdates: Partial = shouldClearUnread ? { isUnread: false } : {} + + // Why: prep is deferred (shell render deferred below) so it waits for input quiet instead of blocking the click. + // Why first-activation guard, not tab.ptyId==null: reconnectPersistedTerminals repopulates ptyId before mount. + // Tag every tab on FIRST activation so reattach/fresh-spawn updateTabPtyId suppresses activity + sortEpoch bumps. + // Generation is only bumped when no tab has a live PTY — a live remount would kill the user's shell. + const tabs = s.tabsByWorktree[worktreeId ?? ''] ?? [] + const allDead = + worktreeId != null && + tabs.length > 0 && + tabs.every((tab) => !tabHasLivePty(s.ptyIdsByTabId, tab.id)) + const isFirstActivation = worktreeId != null && !s.everActivatedWorktreeIds.has(worktreeId) + const shouldTagTabs = worktreeId != null && tabs.length > 0 && isFirstActivation + // Why: bump generation in the same set() as activation so a dead-transport pane can't go visible-but-dead before remount. + shouldPrepareTerminalTabs = Boolean( + worktreeId && tabs.length > 0 && shouldTagTabs && !allDead + ) + shouldTagTerminalTabs = shouldTagTabs + const nextEverActivated = isFirstActivation + ? new Set([...s.everActivatedWorktreeIds, worktreeId!]) + : s.everActivatedWorktreeIds + const nextWorktrees = shouldClearUnread + ? applyWorktreeUpdates(s.worktreesByRepo, worktreeId, metaUpdates) + : s.worktreesByRepo + const nextDetectedWorktrees = shouldClearUnread + ? applyDetectedWorktreeUpdates(s.detectedWorktreesByRepo, worktreeId, metaUpdates) + : s.detectedWorktreesByRepo + const nextFolderWorkspaces = + shouldClearUnread && workspaceScope?.type === 'folder' + ? s.folderWorkspaces.map((workspace) => + workspace.id === workspaceScope.folderWorkspaceId + ? { ...workspace, isUnread: false } + : workspace + ) + : s.folderWorkspaces + const nextActiveRepoId = + workspaceScope?.type === 'folder' + ? null + : stateTransition + ? (worktree?.repoId ?? s.activeRepoId) + : s.activeRepoId + const tabsByWorktreeUpdate = + allDead && worktreeId != null + ? { + tabsByWorktree: { + ...s.tabsByWorktree, + [worktreeId]: tabs.map((tab) => ({ + ...tab, + generation: (tab.generation ?? 0) + 1, + pendingActivationSpawn: getTerminalActivationSpawnSuppression( + s.terminalLayoutsByTabId[tab.id] + ) + })) + } + } + : {} + + const nextActiveTabTypeByWorktree = + s.activeTabTypeByWorktree[worktreeId] === activeTabType + ? s.activeTabTypeByWorktree + : { ...s.activeTabTypeByWorktree, [worktreeId]: activeTabType } + const hasStateChange = + s.activeWorktreeId !== worktreeId || + s.activeWorkspaceExecutionHostId !== (executionHostId ?? null) || + // Why: a pending-creation panel can show over the prior worktree; a non-null activePendingCreationId counts as a change. + s.activePendingCreationId !== null || + s.activeFileId !== activeFileId || + s.activeBrowserTabId !== activeBrowserTabId || + s.activeTabType !== activeTabType || + s.rightSidebarExplorerView !== restoredRightSidebarExplorerView || + s.activeTabId !== activeTabId || + nextActiveTabTypeByWorktree !== s.activeTabTypeByWorktree || + nextEverActivated !== s.everActivatedWorktreeIds || + nextWorktrees !== s.worktreesByRepo || + nextDetectedWorktrees !== s.detectedWorktreesByRepo || + nextFolderWorkspaces !== s.folderWorkspaces || + nextActiveRepoId !== s.activeRepoId || + reconciliationChanged || + stateTransition !== undefined + if (!hasStateChange) { + // Why: preserve the root Zustand reference on a no-op re-activation so session persistence/runtime sync don't fan out. + return s + } + + return { + ...stateTransition?.patch, + ...reconciliation?.patch, + activeRepoId: nextActiveRepoId, + activeWorktreeId: worktreeId, + activeWorkspaceKey: isWorkspaceKey(worktreeId) + ? worktreeId + : worktreeWorkspaceKey(worktreeId), + activeWorkspaceExecutionHostId: executionHostId ?? null, + activePendingCreationId: null, + activeFileId, + activeBrowserTabId, + activeTabType, + activeTabTypeByWorktree: nextActiveTabTypeByWorktree, + rightSidebarExplorerView: restoredRightSidebarExplorerView, + activeTabId, + everActivatedWorktreeIds: nextEverActivated, + ...(nextWorktrees !== s.worktreesByRepo ? { worktreesByRepo: nextWorktrees } : {}), + ...(nextDetectedWorktrees !== s.detectedWorktreesByRepo + ? { detectedWorktreesByRepo: nextDetectedWorktrees } + : {}), + ...(nextFolderWorkspaces !== s.folderWorkspaces + ? { folderWorkspaces: nextFolderWorkspaces } + : {}), + ...tabsByWorktreeUpdate + } + }) + + if (worktreeId && shouldPrepareTerminalTabs) { + const prepareTerminalTabs = (): void => { + pendingActivationTerminalPrepCancels.delete(worktreeId) + set((s) => { + if (s.activeWorktreeId !== worktreeId) { + return {} + } + const tabs = s.tabsByWorktree[worktreeId] ?? [] + if (tabs.length === 0) { + return {} + } + const allDead = tabs.every((tab) => !tabHasLivePty(s.ptyIdsByTabId, tab.id)) + if (!allDead && !shouldTagTerminalTabs) { + return {} + } + return { + tabsByWorktree: { + ...s.tabsByWorktree, + [worktreeId]: tabs.map((tab) => ({ + ...tab, + ...(allDead ? { generation: (tab.generation ?? 0) + 1 } : {}), + // Why: slept terminal remount/spawn is click-driven wake work; tag its PTY updates so they don't reshuffle Recent. + pendingActivationSpawn: getTerminalActivationSpawnSuppression( + s.terminalLayoutsByTabId[tab.id] + ) + })) + } + } + }) + } + + const cancelExistingPrep = pendingActivationTerminalPrepCancels.get(worktreeId) + if (cancelExistingPrep) { + cancelExistingPrep() + } + if (shouldDeferActivationTerminalPrep()) { + pendingActivationTerminalPrepCancels.set( + worktreeId, + scheduleAfterInputQuiet(prepareTerminalTabs, { + delayMs: ACTIVE_WORKTREE_TERMINAL_PREP_DELAY_MS, + quietMs: ACTIVE_WORKTREE_TERMINAL_PREP_INPUT_QUIET_MS, + idleTimeoutMs: ACTIVE_WORKTREE_TERMINAL_PREP_IDLE_TIMEOUT_MS + }) + ) + } else { + prepareTerminalTabs() + } + } + + // Why: activation is explicit enough to revalidate PR state now; the coordinator still coalesces and rate-guards. + if (worktreeId) { + get().refreshGitHubForWorktreeIfStale(worktreeId) + } + + if (!worktreeId || !get().getKnownWorktreeById(worktreeId, executionHostId)) { + return true + } + + if (shouldClearUnread) { + if (workspaceScope?.type === 'folder') { + void get().updateFolderWorkspace(workspaceScope.folderWorkspaceId, { isUnread: false }) + return true + } + persistPassiveWorktreeMetaForOwner( + get, + worktreeId, + { isUnread: false }, + 'persist worktree activation state' + ) + } + return true + }, + + setActiveFolderWorkspace: (folderWorkspaceId, executionHostId) => { + const workspaceKey = folderWorkspaceKey(folderWorkspaceId) + const workspace = findKnownWorktreeById(get(), workspaceKey, executionHostId) + if (!workspace) { + return + } + if (shouldDeferActivationTerminalPrep()) { + markInputQuietSchedulerInput() + } + if (get().activeWorktreeId !== workspaceKey) { + moveFocusToRendererBeforeFocusedWebviewHidden() + } + const reconciledActiveTabId = + get().reconcileWorktreeTabModel(workspaceKey).activeRenderableTabId + set((s) => { + const restoredFileId = s.activeFileIdByWorktree[workspaceKey] ?? null + const restoredBrowserTabId = s.activeBrowserTabIdByWorktree[workspaceKey] ?? null + const restoredTabType = s.activeTabTypeByWorktree[workspaceKey] ?? 'terminal' + const activeGroupId = + s.activeGroupIdByWorktree[workspaceKey] ?? s.groupsByWorktree[workspaceKey]?.[0]?.id ?? null + const activeGroup = activeGroupId + ? ((s.groupsByWorktree[workspaceKey] ?? []).find((group) => group.id === activeGroupId) ?? + null) + : null + const activeUnifiedTabId = reconciledActiveTabId ?? activeGroup?.activeTabId ?? null + const activeUnifiedTab = + activeUnifiedTabId != null + ? ((s.unifiedTabsByWorktree[workspaceKey] ?? []).find( + (tab) => + tab.id === activeUnifiedTabId && (!activeGroup || tab.groupId === activeGroup.id) + ) ?? null) + : null + const fileStillOpen = restoredFileId + ? s.openFiles.some((file) => file.id === restoredFileId && file.worktreeId === workspaceKey) + : false + const browserTabs = s.browserTabsByWorktree[workspaceKey] ?? [] + const browserTabStillOpen = restoredBrowserTabId + ? browserTabs.some((tab) => tab.id === restoredBrowserTabId) + : false + const worktreeTabs = s.tabsByWorktree[workspaceKey] ?? [] + const restoredTabId = s.activeTabIdByWorktree[workspaceKey] ?? null + const tabStillExists = restoredTabId + ? worktreeTabs.some((tab) => tab.id === restoredTabId) + : false + const activeFileId = + activeUnifiedTab?.contentType === 'editor' || + activeUnifiedTab?.contentType === 'diff' || + activeUnifiedTab?.contentType === 'conflict-review' || + activeUnifiedTab?.contentType === 'check-details' + ? activeUnifiedTab.entityId + : fileStillOpen + ? restoredFileId + : null + const activeBrowserTabId = + activeUnifiedTab?.contentType === 'browser' + ? activeUnifiedTab.entityId + : browserTabStillOpen + ? restoredBrowserTabId + : (browserTabs[0]?.id ?? null) + const activeTabType = + activeUnifiedTab?.contentType === 'terminal' + ? 'terminal' + : activeUnifiedTab?.contentType === 'browser' + ? 'browser' + : activeUnifiedTab + ? 'editor' + : restoredTabType === 'browser' && browserTabStillOpen + ? 'browser' + : restoredTabType === 'editor' && fileStillOpen + ? 'editor' + : fileStillOpen + ? 'editor' + : browserTabs.length > 0 + ? 'browser' + : 'terminal' + const activeTabId = + activeUnifiedTab?.contentType === 'terminal' + ? activeUnifiedTab.entityId + : tabStillExists + ? restoredTabId + : (worktreeTabs[0]?.id ?? null) + const nextEverActivated = s.everActivatedWorktreeIds.has(workspaceKey) + ? s.everActivatedWorktreeIds + : new Set([...s.everActivatedWorktreeIds, workspaceKey]) + return { + activeRepoId: null, + activeWorktreeId: workspaceKey, + activeWorkspaceKey: workspaceKey, + activeWorkspaceExecutionHostId: executionHostId ?? null, + activePendingCreationId: null, + activeFileId, + activeBrowserTabId, + activeTabType, + activeTabTypeByWorktree: + s.activeTabTypeByWorktree[workspaceKey] === activeTabType + ? s.activeTabTypeByWorktree + : { ...s.activeTabTypeByWorktree, [workspaceKey]: activeTabType }, + activeTabId, + everActivatedWorktreeIds: nextEverActivated, + folderWorkspaces: workspace.isUnread + ? s.folderWorkspaces.map((entry) => + entry.id === folderWorkspaceId && + (!executionHostId || folderWorkspaceMatchesHost(entry, executionHostId)) + ? { ...entry, isUnread: false } + : entry + ) + : s.folderWorkspaces + } + }) + if (workspace.isUnread) { + void get().updateFolderWorkspace( + folderWorkspaceId, + { isUnread: false }, + executionHostId ? { executionHostId } : undefined + ) + } + }, + + allWorktrees: () => Object.values(get().worktreesByRepo).flat(), + + getKnownWorktreeById: (worktreeId, executionHostId) => + findKnownWorktreeById(get(), worktreeId, executionHostId), + + purgeWorktreeTerminalState: (worktreeIds: string[]) => { + const purgeableWorktreeIds = worktreeIds.filter((id) => id !== FLOATING_TERMINAL_WORKTREE_ID) + if (purgeableWorktreeIds.length === 0) { + return + } + set((s) => buildWorktreePurgeState(s, purgeableWorktreeIds)) + }, + + purgeStaleRuntimeHostState: (removedEnvironmentIds) => { + const removed = new Set(removedEnvironmentIds) + if (removed.size === 0) { + return + } + set((s) => { + const repoIdsWithRemovedOwners = new Set() + const survivingRepoIds = new Set() + const repoIdsWithSurvivingOwners = new Set() + const survivingRepos: Repo[] = [] + for (const repo of s.repos) { + if (isRemovedRuntimeHostId(getRepoExecutionHostId(repo), removed)) { + repoIdsWithRemovedOwners.add(repo.id) + } else { + survivingRepos.push(repo) + survivingRepoIds.add(repo.id) + repoIdsWithSurvivingOwners.add(repo.id) + } + } + const reposChanged = survivingRepos.length !== s.repos.length + + // Why: a repoId-less setup on the removed host can still split a surviving project group, so drop every setup it owns. + const survivingSetups: ProjectHostSetup[] = [] + for (const setup of s.projectHostSetups) { + if (isRemovedRuntimeHostId(setup.hostId, removed)) { + if (setup.repoId) { + repoIdsWithRemovedOwners.add(setup.repoId) + } + } else { + survivingSetups.push(setup) + if (setup.repoId) { + repoIdsWithSurvivingOwners.add(setup.repoId) + } + } + } + const setupsChanged = survivingSetups.length !== s.projectHostSetups.length + const detectedRows: Record = + Object.fromEntries( + Object.entries(s.detectedWorktreesByRepo).map(([repoId, result]) => [ + repoId, + result.worktrees + ]) + ) + // Why: repo/setup catalogs can lag session hydration, so hosted worktree rows are ownership evidence during that gap. + const recordWorktreeOwners = ( + rowsByRepo: Record< + string, + readonly { hostId?: ExecutionHostId; runtimeOwnerEnvironmentId?: string }[] + > + ): void => { + for (const [repoId, rows] of Object.entries(rowsByRepo)) { + for (const row of rows) { + if (!row.hostId && !row.runtimeOwnerEnvironmentId) { + continue + } + const ownerWasRemoved = row.runtimeOwnerEnvironmentId + ? removed.has(row.runtimeOwnerEnvironmentId) + : isRemovedRuntimeHostId(row.hostId, removed) + const ownerSet = ownerWasRemoved ? repoIdsWithRemovedOwners : repoIdsWithSurvivingOwners + ownerSet.add(repoId) + } + } + } + recordWorktreeOwners(s.worktreesByRepo) + recordWorktreeOwners(detectedRows) + + const sessionWorktreeIdsOwnedByRemovedHosts = new Set() + let survivingRestoredSessionOwners = s.restoredRuntimeHostIdByWorkspaceSessionKey + for (const [workspaceKey, hostId] of Object.entries( + s.restoredRuntimeHostIdByWorkspaceSessionKey + )) { + const scope = parseWorkspaceKey(workspaceKey) + if (scope?.type === 'folder') { + continue + } + const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceKey + const repoId = getRepoIdFromWorktreeId(worktreeId) + if (!isRemovedRuntimeHostId(hostId, removed)) { + // Why: restored sessions can be the only surviving-owner evidence before catalogs load. + repoIdsWithSurvivingOwners.add(repoId) + continue + } + sessionWorktreeIdsOwnedByRemovedHosts.add(worktreeId) + repoIdsWithRemovedOwners.add(repoId) + if (survivingRestoredSessionOwners === s.restoredRuntimeHostIdByWorkspaceSessionKey) { + survivingRestoredSessionOwners = { ...survivingRestoredSessionOwners } + } + delete survivingRestoredSessionOwners[workspaceKey] + } + + // Why: legacy rows predate host stamps; every owner record must agree no host survives before an unhosted row is retired. + const repoIdsWithoutSurvivingOwners = new Set(repoIdsWithRemovedOwners) + for (const repoId of repoIdsWithSurvivingOwners) { + repoIdsWithoutSurvivingOwners.delete(repoId) + } + + const worktreeDrop = dropWorktreeRowsForRemovedRuntimeEnvironments( + s.worktreesByRepo, + removed, + repoIdsWithoutSurvivingOwners + ) + const detectedDrop = dropWorktreeRowsForRemovedRuntimeEnvironments( + detectedRows, + removed, + repoIdsWithoutSurvivingOwners + ) + + const worktreesChanged = worktreeDrop.rowsByRepo !== s.worktreesByRepo + const detectedChanged = detectedDrop.rowsByRepo !== detectedRows + + const removedWorktreeIds = new Set([ + ...worktreeDrop.removedWorktreeIds, + ...detectedDrop.removedWorktreeIds, + ...sessionWorktreeIdsOwnedByRemovedHosts + ]) + // Why: terminal tabs hydrate before worktree metadata, so session-only ids for owner-less repos still need purging. + if (repoIdsWithoutSurvivingOwners.size > 0) { + for (const worktreeId of Object.keys(s.tabsByWorktree)) { + const scope = parseWorkspaceKey(worktreeId) + const rawWorktreeId = scope?.type === 'worktree' ? scope.worktreeId : worktreeId + if ( + scope?.type !== 'folder' && + repoIdsWithoutSurvivingOwners.has(getRepoIdFromWorktreeId(rawWorktreeId)) + ) { + removedWorktreeIds.add(rawWorktreeId) + } + } + } + // Why: bare-id state follows an exact survivor unless the restored-session partition proves it belonged to the removed host. + for (const rows of Object.values(worktreeDrop.rowsByRepo)) { + for (const row of rows) { + if (!sessionWorktreeIdsOwnedByRemovedHosts.has(row.id)) { + removedWorktreeIds.delete(row.id) + } + } + } + for (const rows of Object.values(detectedDrop.rowsByRepo)) { + for (const row of rows) { + if (!sessionWorktreeIdsOwnedByRemovedHosts.has(row.id)) { + removedWorktreeIds.delete(row.id) + } + } + } + const purgeState = + removedWorktreeIds.size > 0 ? buildWorktreePurgeState(s, [...removedWorktreeIds]) : {} + + const restoredSessionOwnersChanged = + survivingRestoredSessionOwners !== s.restoredRuntimeHostIdByWorkspaceSessionKey + let survivingVisibilityDefaults = s.worktreeVisibilityDefaultsByHost + for (const environmentId of removed) { + const hostId = toRuntimeExecutionHostId(environmentId) + if (hostId in survivingVisibilityDefaults) { + if (survivingVisibilityDefaults === s.worktreeVisibilityDefaultsByHost) { + survivingVisibilityDefaults = { ...survivingVisibilityDefaults } + } + delete survivingVisibilityDefaults[hostId] + } + } + const visibilityDefaultsChanged = + survivingVisibilityDefaults !== s.worktreeVisibilityDefaultsByHost + const visibilitySupportChanged = removed.has( + s.worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId ?? '' + ) + const visibilitySourceSupportChanged = removed.has( + s.worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId ?? '' + ) + if ( + !reposChanged && + !setupsChanged && + !worktreesChanged && + !detectedChanged && + !restoredSessionOwnersChanged && + !visibilityDefaultsChanged && + !visibilitySupportChanged && + !visibilitySourceSupportChanged && + removedWorktreeIds.size === 0 + ) { + return s + } + + const detectedWorktreesByRepo = detectedChanged + ? Object.fromEntries( + Object.entries(s.detectedWorktreesByRepo).map(([repoId, result]) => [ + repoId, + { ...result, worktrees: detectedDrop.rowsByRepo[repoId] } + ]) + ) + : s.detectedWorktreesByRepo + + const rowsChanged = worktreesChanged || detectedChanged + return { + ...purgeState, + ...(reposChanged ? { repos: survivingRepos } : {}), + ...(setupsChanged ? { projectHostSetups: survivingSetups } : {}), + ...(worktreesChanged ? { worktreesByRepo: worktreeDrop.rowsByRepo } : {}), + ...(detectedChanged ? { detectedWorktreesByRepo } : {}), + ...(restoredSessionOwnersChanged + ? { restoredRuntimeHostIdByWorkspaceSessionKey: survivingRestoredSessionOwners } + : {}), + ...(visibilityDefaultsChanged + ? { worktreeVisibilityDefaultsByHost: survivingVisibilityDefaults } + : {}), + ...(visibilitySupportChanged + ? { worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: null } + : {}), + ...(visibilitySourceSupportChanged + ? { worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: null } + : {}), + ...(rowsChanged ? { sortEpoch: s.sortEpoch + 1 } : {}), + // Why: mirror validateRepoScopedUi so a filtered/active sidebar can't reference a purged repo id. + ...(reposChanged + ? { + activeRepoId: + s.activeRepoId && survivingRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, + filterRepoIds: s.filterRepoIds.filter((repoId) => survivingRepoIds.has(repoId)) + } + : {}) + } + }) + }, + + migrateWorktreeIdentity: (oldWorktreeId: string, newWorktreeId: string) => { + if (oldWorktreeId === newWorktreeId) { + return + } + // Why: invalidate pre-rename toast actions before publishing the new path, carrying the dismissal forward. + migrateHugeRepoWarningDismissal(oldWorktreeId, newWorktreeId) + set((s) => buildWorktreeRenameState(s, oldWorktreeId, newWorktreeId)) + migrateHostedReviewLinkMutationGeneration(oldWorktreeId, newWorktreeId) + } }) diff --git a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts b/src/renderer/src/store/slices/worktrees/create/create-worktree.ts deleted file mode 100644 index 0ecdbf6bae1..00000000000 --- a/src/renderer/src/store/slices/worktrees/create/create-worktree.ts +++ /dev/null @@ -1,261 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import { - CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS, - getClientWorktreeCreateCandidate, - isRetryableWorktreeCreateConflict -} from '../../../../../../shared/new-workspace/worktree-create-retry-policy' -import { parseWorkspaceKey, folderWorkspaceKey } from '../../../../../../shared/workspace-scope' -import { - assertRuntimeEnvironmentCapability, - callRuntimeRpc, - getActiveRuntimeTarget -} from '../../../../runtime/runtime-rpc-client' -import { WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY } from '../../../../../../shared/protocol-version' -import { showLocalBaseRefUpdateSuggestionToast } from '@/components/sidebar/local-base-ref-suggestion-toast' -import { requestWorktreeBaseFallbackNotice } from '@/components/worktree-base-fallback-notice' -import { showLocalBaseRefRefreshToast } from './local-base-ref-refresh-toast' -import { - getProjectHostSetupForRepoHost, - repoHostId, - withRepoHostOwnership -} from '../listing/worktree-host-ownership' -import { settingsForRepoOwner } from '../listing/worktree-owner-settings' - -export function createCreateWorktree( - set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['createWorktree'] { - return async ( - repoId, - name, - baseBranch, - setupDecision = 'inherit', - sparseCheckout, - telemetrySource, - displayName, - linkedIssue, - linkedPR, - pushTarget, - createdWithAgent, - linkedLinearIssue, - branchNameOverride, - workspaceStatus, - linkedGitLabMR, - linkedGitLabIssue, - startup, - pendingFirstAgentMessageRename, - creationId, - linkedLinearIssueWorkspaceId, - linkedLinearIssueOrganizationUrlKey, - linkedBitbucketPR, - linkedAzureDevOpsPR, - linkedGiteaPR, - compareBaseRef, - options - ) => { - const automationProvenanceRequest = options?.automationProvenanceRequest - const linkedWorkItem = options?.linkedWorkItem - const linkedTaskSourceContext = options?.linkedTaskSourceContext - const startupDraft = options?.startupDraft - const provisionedRoot = options?.provisionedRoot - try { - for (let attempt = 0; attempt < CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS; attempt += 1) { - const candidateName = getClientWorktreeCreateCandidate(name, attempt) - // Why: older runtimes reject exact PR branch overrides on collision, so retry both branch and worktree names. - const candidateBranchNameOverride = branchNameOverride - ? getClientWorktreeCreateCandidate(branchNameOverride, attempt) - : undefined - try { - // Why: manual sort is user-authored order; stamp new workspaces at the top rather than relying on sortOrder fallback. - const manualOrder = get().sortBy === 'manual' ? Date.now() : undefined - const activeScope = parseWorkspaceKey(get().activeWorkspaceKey ?? '') - const parentWorkspace = - activeScope?.type === 'folder' - ? folderWorkspaceKey(activeScope.folderWorkspaceId) - : undefined - const createArgs = { - repoId, - name: candidateName, - baseBranch, - ...(compareBaseRef ? { compareBaseRef } : {}), - ...(candidateBranchNameOverride - ? { branchNameOverride: candidateBranchNameOverride } - : {}), - setupDecision, - sparseCheckout, - ...(displayName ? { displayName } : {}), - ...(telemetrySource ? { telemetrySource } : {}), - ...(linkedIssue !== undefined ? { linkedIssue } : {}), - ...(linkedPR !== undefined ? { linkedPR } : {}), - ...(pushTarget ? { pushTarget } : {}), - ...(createdWithAgent ? { createdWithAgent } : {}), - ...(pendingFirstAgentMessageRename === true && createdWithAgent - ? { pendingFirstAgentMessageRename: true } - : {}), - ...(linkedLinearIssue !== undefined ? { linkedLinearIssue } : {}), - ...(linkedLinearIssueWorkspaceId !== undefined ? { linkedLinearIssueWorkspaceId } : {}), - ...(linkedLinearIssueOrganizationUrlKey !== undefined - ? { linkedLinearIssueOrganizationUrlKey } - : {}), - ...(manualOrder !== undefined ? { manualOrder } : {}), - ...(parentWorkspace ? { parentWorkspace } : {}), - ...(workspaceStatus !== undefined ? { workspaceStatus } : {}), - ...(linkedGitLabMR !== undefined ? { linkedGitLabMR } : {}), - ...(linkedGitLabIssue !== undefined ? { linkedGitLabIssue } : {}), - ...(linkedBitbucketPR !== undefined ? { linkedBitbucketPR } : {}), - ...(linkedAzureDevOpsPR !== undefined ? { linkedAzureDevOpsPR } : {}), - ...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}), - ...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}), - ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}), - ...(startup ? { startup } : {}), - ...(creationId ? { creationId } : {}), - ...(automationProvenanceRequest ? { automationProvenanceRequest } : {}) - } - const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) - if ( - target.kind === 'environment' && - (linkedWorkItem?.provider === 'jira' || linkedTaskSourceContext?.provider === 'jira') - ) { - await assertRuntimeEnvironmentCapability( - target.environmentId, - WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY, - 'Update the remote runtime to link Jira' - ) - } - if (provisionedRoot && target.kind !== 'local') { - throw new Error('Provisioned-root recipes currently require a direct SSH connection.') - } - const result = provisionedRoot - ? await window.api.worktrees.adoptProvisionedRoot({ - ...createArgs, - ...provisionedRoot - }) - : target.kind === 'local' - ? await window.api.worktrees.create(createArgs) - : await callRuntimeRpc>>( - target, - 'worktree.create', - { - repo: repoId, - name: candidateName, - baseBranch, - ...(compareBaseRef ? { compareBaseRef } : {}), - ...(candidateBranchNameOverride - ? { branchNameOverride: candidateBranchNameOverride } - : {}), - setupDecision, - sparseCheckout, - ...(displayName ? { displayName } : {}), - ...(telemetrySource ? { telemetrySource } : {}), - ...(linkedIssue !== undefined ? { linkedIssue } : {}), - ...(linkedPR !== undefined ? { linkedPR } : {}), - ...(pushTarget ? { pushTarget } : {}), - ...(createdWithAgent ? { createdWithAgent } : {}), - ...(pendingFirstAgentMessageRename === true && createdWithAgent - ? { pendingFirstAgentMessageRename: true } - : {}), - ...(linkedLinearIssue !== undefined ? { linkedLinearIssue } : {}), - ...(linkedLinearIssueWorkspaceId !== undefined - ? { linkedLinearIssueWorkspaceId } - : {}), - ...(linkedLinearIssueOrganizationUrlKey !== undefined - ? { linkedLinearIssueOrganizationUrlKey } - : {}), - ...(manualOrder !== undefined ? { manualOrder } : {}), - ...(parentWorkspace ? { parentWorkspace } : {}), - ...(workspaceStatus !== undefined ? { workspaceStatus } : {}), - ...(linkedGitLabMR !== undefined ? { linkedGitLabMR } : {}), - ...(linkedGitLabIssue !== undefined ? { linkedGitLabIssue } : {}), - ...(linkedBitbucketPR !== undefined ? { linkedBitbucketPR } : {}), - ...(linkedAzureDevOpsPR !== undefined ? { linkedAzureDevOpsPR } : {}), - ...(linkedGiteaPR !== undefined ? { linkedGiteaPR } : {}), - ...(linkedWorkItem !== undefined ? { linkedWorkItem } : {}), - ...(linkedTaskSourceContext !== undefined ? { linkedTaskSourceContext } : {}), - ...(startupDraft ? { startupDraft } : {}), - ...(automationProvenanceRequest ? { automationProvenanceRequest } : {}), - ...(startup - ? { - startupCommand: startup.command, - ...(startup.env ? { startupEnv: startup.env } : {}), - ...(startup.launchConfig - ? { startupLaunchConfig: startup.launchConfig } - : {}), - ...(startup.startupCommandDelivery - ? { startupCommandDelivery: startup.startupCommandDelivery } - : {}), - activate: true - } - : {}) - }, - { timeoutMs: 10 * 60_000 } - ) - // Why: worktrees.onChanged can add this worktree before this callback runs; appending blindly would duplicate it (React key clash). - set((s) => { - const hostId = repoHostId(s, repoId) - const createdWorktree = withRepoHostOwnership( - result.worktree, - hostId, - getProjectHostSetupForRepoHost(s, repoId, hostId) - ) - const current = s.worktreesByRepo[repoId] ?? [] - const alreadyPresent = current.some((w) => w.id === createdWorktree.id) - const nextWorktrees = alreadyPresent - ? current.map((worktree) => - worktree.id === createdWorktree.id - ? { ...worktree, ...createdWorktree } - : worktree - ) - : [...current, createdWorktree] - return { - worktreesByRepo: { - ...s.worktreesByRepo, - [repoId]: nextWorktrees - }, - ...(result.workspaceLineage - ? { - workspaceLineageByChildKey: { - ...s.workspaceLineageByChildKey, - [result.workspaceLineage.childWorkspaceKey]: result.workspaceLineage - } - } - : {}), - ...(result.initialBaseStatus - ? { - baseStatusByWorktreeId: { - ...s.baseStatusByWorktreeId, - [result.worktree.id]: - s.baseStatusByWorktreeId[result.worktree.id] ?? result.initialBaseStatus - } - } - : {}), - sortEpoch: s.sortEpoch + 1 - } - }) - showLocalBaseRefRefreshToast(result.localBaseRefRefresh, result.worktree) - if (result.baseFallback) { - requestWorktreeBaseFallbackNotice(result.baseFallback) - } - showLocalBaseRefUpdateSuggestionToast(result.localBaseRefUpdateSuggestion, { - updateSettings: get().updateSettings, - getSettings: () => get().settings, - openSettingsPage: get().openSettingsPage, - openSettingsTarget: get().openSettingsTarget - }) - return result - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - const shouldRetry = isRetryableWorktreeCreateConflict(message) - if (!shouldRetry || attempt === CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS - 1) { - throw error - } - } - } - - throw new Error('Failed to create worktree after retrying branch conflicts.') - } catch (err) { - console.error('Failed to create worktree:', err) - throw err - } - } -} diff --git a/src/renderer/src/store/slices/worktrees/create/local-base-ref-refresh-toast.ts b/src/renderer/src/store/slices/worktrees/create/local-base-ref-refresh-toast.ts deleted file mode 100644 index 5e7ab555c23..00000000000 --- a/src/renderer/src/store/slices/worktrees/create/local-base-ref-refresh-toast.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { toast } from 'sonner' -import { translate } from '@/i18n/i18n' -import { resolveWorktreeDisplayName } from '@/lib/worktree-default-display-name' -import type { LocalBaseRefRefreshResult } from '../../../../../../shared/worktree/base-ref-drift-types' -import type { Worktree } from '../../../../../../shared/worktree/types' - -export function localBaseRefRefreshFailureDetail(result: LocalBaseRefRefreshResult): string { - const ownerWorktreePath = result.ownerWorktreePath?.trim() - switch (result.status) { - case 'skipped_dirty_worktree': - // Create already succeeded — guide cleanup + manual base update, not "try again". - return ownerWorktreePath - ? translate( - 'auto.store.slices.worktrees.localBaseRefRefreshFailedDetailDirtyNamed', - 'The worktree at {{value0}} (where local {{value1}} is checked out) has uncommitted changes. Commit, stash, or discard those changes, then update local {{value1}} manually.', - { value0: ownerWorktreePath, value1: result.localBranch } - ) - : translate( - 'auto.store.slices.worktrees.localBaseRefRefreshFailedDetailDirty', - 'The worktree where local {{value0}} is checked out has uncommitted changes. Commit, stash, or discard those changes, then update local {{value0}} manually.', - { value0: result.localBranch } - ) - case 'skipped_not_fast_forward': - return translate( - 'auto.store.slices.worktrees.localBaseRefRefreshFailedDetailNotFastForward', - 'Local {{value0}} does not exist or cannot be fast-forwarded cleanly from the remote base. Check for local-only commits before updating it manually.', - { value0: result.localBranch } - ) - case 'skipped_error': - return translate( - 'auto.store.slices.worktrees.localBaseRefRefreshFailedDetailError', - 'Git returned an error while updating local {{value0}}. Check the repo for locked refs or unusual worktree state, then update local {{value0}} manually.', - { value0: result.localBranch } - ) - case 'updated': - return '' - } -} - -export function showLocalBaseRefRefreshToast( - result: LocalBaseRefRefreshResult | undefined, - createdWorktree?: Pick -): void { - if (!result || result.status === 'updated') { - return - } - - const worktreeName = createdWorktree ? resolveWorktreeDisplayName(createdWorktree).trim() : '' - const detail = localBaseRefRefreshFailureDetail(result) - - // Why: Infinity so create-time failures aren't buried; id is per worktree so each create stays attributable. - toast.warning( - worktreeName - ? translate( - 'auto.store.slices.worktrees.localBaseRefRefreshFailedForWorktree', - 'Local {{value0}} was not refreshed for "{{value1}}"', - { value0: result.localBranch, value1: worktreeName } - ) - : translate('auto.store.slices.worktrees.14bc053a47', 'Local {{value0}} was not refreshed', { - value0: result.localBranch - }), - { - id: `local-base-ref-refresh-failed:${createdWorktree?.id ?? 'unknown'}:${result.localBranch}`, - description: worktreeName - ? translate( - 'auto.store.slices.worktrees.localBaseRefRefreshFailedDescriptionNamed', - 'Workspace "{{value0}}" was created from {{value1}}, but Orca could not fast-forward local {{value2}}. {{value3}}', - { - value0: worktreeName, - value1: result.baseRef, - value2: result.localBranch, - value3: detail - } - ) - : translate( - 'auto.store.slices.worktrees.903b51c2ed', - 'Workspace created from {{value0}}, but Orca could not fast-forward local {{value1}}. {{value2}}', - { value0: result.baseRef, value1: result.localBranch, value2: detail } - ), - duration: Infinity, - dismissible: true - } - ) -} diff --git a/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts b/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts deleted file mode 100644 index 28530219cce..00000000000 --- a/src/renderer/src/store/slices/worktrees/create/pending-worktree-creation.ts +++ /dev/null @@ -1,98 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import type { AppState } from '../../../types' -import { cleanupFailedEphemeralVmWorkspace } from '@/lib/ephemeral-vm-failed-create-cleanup' - -export function createBeginPendingWorktreeCreation( - set: WorktreeSliceSet, - _get: WorktreeSliceGet -): WorktreeSlice['beginPendingWorktreeCreation'] { - return (entry) => { - set((s) => ({ - pendingWorktreeCreations: { ...s.pendingWorktreeCreations, [entry.creationId]: entry }, - activePendingCreationId: entry.creationId - })) - } -} - -export function createUpdatePendingWorktreeCreation( - set: WorktreeSliceSet, - _get: WorktreeSliceGet -): WorktreeSlice['updatePendingWorktreeCreation'] { - return (creationId, patch) => { - set((s) => { - const entry = s.pendingWorktreeCreations[creationId] - if (!entry) { - return {} - } - // Why: the main process re-emits the same phase; skip no-op writes so the strip and panel don't re-render. - const hasChange = (Object.keys(patch) as (keyof typeof patch)[]).some( - (key) => patch[key] !== entry[key] - ) - if (!hasChange) { - return {} - } - return { - pendingWorktreeCreations: { - ...s.pendingWorktreeCreations, - [creationId]: { ...entry, ...patch } - } - } - }) - } -} - -export function createRemovePendingWorktreeCreation( - set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['removePendingWorktreeCreation'] { - return (creationId, options) => { - let removedEntry: AppState['pendingWorktreeCreations'][string] | undefined - set((s) => { - const entry = s.pendingWorktreeCreations[creationId] - if (!entry) { - return {} - } - removedEntry = entry - const { [creationId]: _removed, ...rest } = s.pendingWorktreeCreations - return { - pendingWorktreeCreations: rest, - // Why: only clear the active surface if it pointed here, so dismissing a background creation doesn't yank the user away. - ...(s.activePendingCreationId === creationId ? { activePendingCreationId: null } : {}) - } - }) - if (!removedEntry || options?.cleanupVm === false || typeof window === 'undefined') { - return - } - if (removedEntry.phase === 'provisioning-vm' && window.api?.ephemeralVm?.cancelProvision) { - void window.api.ephemeralVm - .cancelProvision({ provisionId: creationId }) - .catch(() => undefined) - } - if (!removedEntry.request.ephemeralVmRuntimeId || !window.api?.ephemeralVm?.cleanup) { - return - } - void cleanupFailedEphemeralVmWorkspace(removedEntry.request, { - deleteProjectHostSetup: (setupId) => get().deleteProjectHostSetup({ setupId }), - cleanupRuntime: (runtimeId) => window.api.ephemeralVm.cleanup({ runtimeId }), - reportSetupError: (error) => - console.error('Failed to remove cancelled provisioned-root project setup:', error), - reportRuntimeError: (error) => - console.error('Failed to clean up cancelled ephemeral VM runtime:', error) - }) - } -} - -export function createSetActivePendingWorktreeCreation( - set: WorktreeSliceSet, - _get: WorktreeSliceGet -): WorktreeSlice['setActivePendingWorktreeCreation'] { - return (creationId) => { - set((s) => { - if (creationId !== null && !s.pendingWorktreeCreations[creationId]) { - return {} - } - return { activePendingCreationId: creationId } - }) - } -} diff --git a/src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts b/src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts deleted file mode 100644 index 721a7e6d6be..00000000000 --- a/src/renderer/src/store/slices/worktrees/create/prefetch-worktree-create-base.ts +++ /dev/null @@ -1,30 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' -import { settingsForRepoOwner } from '../listing/worktree-owner-settings' - -export function createPrefetchWorktreeCreateBase( - _set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['prefetchWorktreeCreateBase'] { - return async (repoId, baseBranch) => { - try { - const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), repoId)) - if (target.kind === 'local') { - await window.api.worktrees.prefetchCreateBase({ - repoId, - ...(baseBranch ? { baseBranch } : {}) - }) - return - } - await callRuntimeRpc( - target, - 'worktree.prefetchCreateBase', - { repo: repoId, ...(baseBranch ? { baseBranch } : {}) }, - { timeoutMs: 30_000 } - ) - } catch { - // Why: prefetch is only a latency hedge; the create path awaits the same refresh and owns error reporting. - } - } -} diff --git a/src/renderer/src/store/slices/worktrees/listing/authoritative-worktree-removal-memory.ts b/src/renderer/src/store/slices/worktrees/listing/authoritative-worktree-removal-memory.ts deleted file mode 100644 index 9793adb0e16..00000000000 --- a/src/renderer/src/store/slices/worktrees/listing/authoritative-worktree-removal-memory.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { parseExecutionHostId, type ExecutionHostId } from '../../../../../../shared/execution-host' -import { AUTHORITATIVE_REMOVAL_MEMORY_LIMIT } from './worktree-slice-constants' - -// Why: main retires the persisted SSH metadata a scan proved gone, but that IPC is async and the next fallback -// read can already be in flight, so this session memory covers the window until the delete lands. It is not the -// durable half: reloads start empty and rely on the metadata itself being gone. -const authoritativelyRemovedWorktreeIdsByHost = new Map>() - -export function rememberAuthoritativelyRemovedWorktrees( - hostId: ExecutionHostId, - worktreeIds: readonly string[] -): void { - if (worktreeIds.length === 0) { - return - } - const removed = authoritativelyRemovedWorktreeIdsByHost.get(hostId) ?? new Set() - for (const worktreeId of worktreeIds) { - // Why: re-insert so a re-removed id moves to the back of the insertion order the cap evicts from. - removed.delete(worktreeId) - removed.add(worktreeId) - } - for (const oldest of removed) { - if (removed.size <= AUTHORITATIVE_REMOVAL_MEMORY_LIMIT) { - break - } - removed.delete(oldest) - } - authoritativelyRemovedWorktreeIdsByHost.set(hostId, removed) -} - -// Why: a remote path can be recreated, so a scan that reports it again retracts the deletion verdict. -export function forgetAuthoritativelyRemovedWorktrees( - hostId: ExecutionHostId, - worktreeIds: Iterable -): void { - const removed = authoritativelyRemovedWorktreeIdsByHost.get(hostId) - if (!removed) { - return - } - for (const worktreeId of worktreeIds) { - removed.delete(worktreeId) - } - if (removed.size === 0) { - authoritativelyRemovedWorktreeIdsByHost.delete(hostId) - } -} - -/** Test-only: module-level removal memory would otherwise leak across cases in one file. */ -export function resetAuthoritativelyRemovedWorktreeMemoryForTests(): void { - authoritativelyRemovedWorktreeIdsByHost.clear() -} - -// Why: SSH WorktreeMeta is exempt from gcStaleWorktreeMeta (persistence.ts:407,415) and outlives the remote -// worktree, so a scan-proven removal must retire the metadata itself — otherwise the next launch's fallback -// re-lists the deleted row before the host connects, and the in-memory suppression above is already gone. -export function forgetPersistedWorktreeMetaForRemovals( - repoId: string, - hostId: ExecutionHostId, - worktreeIds: readonly string[] -): void { - const parsedHost = parseExecutionHostId(hostId) - if (worktreeIds.length === 0 || parsedHost?.kind !== 'ssh') { - return - } - const forget = window.api.worktrees.forgetRemovedForExecutionHost - if (typeof forget !== 'function') { - return - } - void forget({ repoId, executionHostId: parsedHost.id, worktreeIds: [...worktreeIds] }).catch( - (err) => { - console.warn(`Failed to forget metadata for removed worktrees in repo ${repoId}:`, err) - } - ) -} - -export function getAuthoritativelyRemovedWorktreeIds( - hostId: ExecutionHostId -): Set | undefined { - return authoritativelyRemovedWorktreeIdsByHost.get(hostId) -} diff --git a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-host-merge.ts b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-host-merge.ts deleted file mode 100644 index 073bd712f32..00000000000 --- a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-host-merge.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { ProjectHostSetup } from '../../../../../../shared/project-types' -import type { DetectedWorktreeListResult } from '../../../../../../shared/worktree/types' -import type { ExecutionHostId } from '../../../../../../shared/execution-host' -import { sanitizeHostedReviewLinksForBranchClears } from '../metadata/hosted-review-link-mutation' -import { mergeWorktreesForHost, withRepoHostOwnership } from './worktree-host-ownership' -import type { WorktreeHostMatchOptions } from './worktree-slice-types' - -export function mergeDetectedWorktreesForHost( - current: DetectedWorktreeListResult | undefined, - refreshed: DetectedWorktreeListResult, - hostId: ExecutionHostId, - setup?: ProjectHostSetup, - options?: WorktreeHostMatchOptions -): DetectedWorktreeListResult { - const refreshedForHost = sanitizeHostedReviewLinksForBranchClears( - refreshed.worktrees, - current?.worktrees - ).map((worktree) => withRepoHostOwnership(worktree, hostId, setup)) - const worktrees = mergeWorktreesForHost(current?.worktrees, refreshedForHost, hostId, options) - if ( - current && - current.repoId === refreshed.repoId && - current.authoritative === refreshed.authoritative && - current.source === refreshed.source && - current.worktrees === worktrees - ) { - return current - } - return { - ...refreshed, - worktrees - } -} diff --git a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts deleted file mode 100644 index d8d32bb78e2..00000000000 --- a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-meta.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { AppState } from '../../../types' -import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types' -import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types' -import type { DetectedWorktreeListResult, Worktree } from '../../../../../../shared/worktree/types' -import type { ExecutionHostId } from '../../../../../../shared/execution-host' -import { - LOCAL_EXECUTION_HOST_ID, - parseExecutionHostId, - toSshExecutionHostId -} from '../../../../../../shared/execution-host' -import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope' -import { folderWorkspaceToWorktree } from '../../../../../../shared/folder-workspace-worktree' -import { findIndexedWorktreeOwnerForHost } from '@/lib/worktree-runtime-owner-index' -import { findWorktreeById, withoutErasedRequiredWorktreeFields } from '../../worktree-helpers' -import { worktreeMatchesHost } from './worktree-host-ownership' - -const folderWorkspaceWorktreeCache = new WeakMap() - -export function applyDetectedWorktreeUpdates( - detectedWorktreesByRepo: AppState['detectedWorktreesByRepo'], - worktreeId: string, - rawUpdates: Partial -): AppState['detectedWorktreesByRepo'] { - // Why: mirrors applyWorktreeUpdates — detected rows feed the same palette. - const updates = withoutErasedRequiredWorktreeFields(rawUpdates) - let changed = false - const nextByRepo: AppState['detectedWorktreesByRepo'] = {} - - for (const [repoId, result] of Object.entries(detectedWorktreesByRepo)) { - let repoChanged = false - const nextWorktrees = result.worktrees.map((worktree) => { - if (worktree.id !== worktreeId) { - return worktree - } - repoChanged = true - changed = true - return { ...worktree, ...updates } - }) - nextByRepo[repoId] = repoChanged ? { ...result, worktrees: nextWorktrees } : result - } - - return changed ? nextByRepo : detectedWorktreesByRepo -} - -export function folderWorkspaceMatchesHost( - workspace: Pick, - executionHostId: ExecutionHostId -): boolean { - return ( - (parseExecutionHostId(workspace.executionHostId)?.id ?? - (workspace.connectionId?.trim() - ? toSshExecutionHostId(workspace.connectionId) - : LOCAL_EXECUTION_HOST_ID)) === executionHostId - ) -} - -export function findKnownWorktreeById( - state: Pick, - worktreeId: string, - executionHostId?: ExecutionHostId -): Worktree | DetectedWorktreeListResult['worktrees'][number] | undefined { - const workspaceScope = parseWorkspaceKey(worktreeId) - if (workspaceScope?.type === 'folder') { - const folderWorkspace = state.folderWorkspaces.find( - (workspace) => - workspace.id === workspaceScope.folderWorkspaceId && - (!executionHostId || folderWorkspaceMatchesHost(workspace, executionHostId)) - ) - if (!folderWorkspace) { - return undefined - } - const cached = folderWorkspaceWorktreeCache.get(folderWorkspace) - if (cached) { - return cached - } - const worktree = folderWorkspaceToWorktree(folderWorkspace) - folderWorkspaceWorktreeCache.set(folderWorkspace, worktree) - return worktree - } - const visible = executionHostId - ? (findIndexedWorktreeOwnerForHost( - state.worktreesByRepo, - worktreeId, - executionHostId - ) as Worktree | null) - : findWorktreeById(state.worktreesByRepo, worktreeId) - if (visible) { - return visible - } - for (const result of Object.values(state.detectedWorktreesByRepo)) { - const detected = result.worktrees.find( - (worktree) => - worktree.id === worktreeId && - (!executionHostId || - worktreeMatchesHost(worktree, executionHostId, { - unhostedWorktreesMatchHost: executionHostId === LOCAL_EXECUTION_HOST_ID - })) - ) - if (detected) { - return detected - } - } - return undefined -} - -export function getFolderWorkspaceMetaUpdates( - updates: Partial -): Partial< - Pick< - FolderWorkspace, - | 'name' - | 'comment' - | 'isArchived' - | 'isUnread' - | 'isPinned' - | 'sortOrder' - | 'manualOrder' - | 'lastActivityAt' - | 'workspaceStatus' - | 'createdWithAgent' - | 'pendingFirstAgentMessageRename' - | 'firstAgentMessageRenameError' - | 'diffComments' - > -> { - const next: Partial< - Pick< - FolderWorkspace, - | 'name' - | 'comment' - | 'isArchived' - | 'isUnread' - | 'isPinned' - | 'sortOrder' - | 'manualOrder' - | 'lastActivityAt' - | 'workspaceStatus' - | 'createdWithAgent' - | 'pendingFirstAgentMessageRename' - | 'firstAgentMessageRenameError' - | 'diffComments' - > - > = {} - if (updates.displayName !== undefined) { - next.name = updates.displayName - next.pendingFirstAgentMessageRename = false - next.firstAgentMessageRenameError = null - } - if (updates.comment !== undefined) { - next.comment = updates.comment - next.lastActivityAt = Date.now() - } - if (updates.isArchived !== undefined) { - next.isArchived = updates.isArchived - } - if (updates.isUnread !== undefined) { - next.isUnread = updates.isUnread - } - if (updates.isPinned !== undefined) { - next.isPinned = updates.isPinned - } - if (updates.sortOrder !== undefined) { - next.sortOrder = updates.sortOrder - } - if (updates.manualOrder !== undefined) { - next.manualOrder = updates.manualOrder - } - if (updates.lastActivityAt !== undefined) { - next.lastActivityAt = updates.lastActivityAt - } - if (updates.workspaceStatus !== undefined) { - next.workspaceStatus = updates.workspaceStatus - } - if (updates.createdWithAgent !== undefined) { - next.createdWithAgent = updates.createdWithAgent - } - if (updates.pendingFirstAgentMessageRename !== undefined) { - next.pendingFirstAgentMessageRename = updates.pendingFirstAgentMessageRename - } - if (updates.firstAgentMessageRenameError !== undefined) { - next.firstAgentMessageRenameError = updates.firstAgentMessageRenameError - } - if (updates.diffComments !== undefined) { - next.diffComments = updates.diffComments - } - return next -} diff --git a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts deleted file mode 100644 index e85add4fc85..00000000000 --- a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-provider-request.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' -import { getEnvironmentSshStateGeneration } from '../../runtime-environment-ssh' -import { getRuntimeEnvironmentConnectionGeneration } from '../../runtime-status' -import { LOCAL_EXECUTION_HOST_ID } from '../../../../../../shared/execution-host' -import type { AppState } from '../../../types' -import type { DetectedWorktreeListResult } from '../../../../../../shared/worktree/types' -import type { RuntimeWorktreeListResult } from '../../../../../../shared/runtime-types' -import type { - HostQualifiedDetectedWorktreeResult, - ListDetectedWorktreesArgs -} from '../../../../../../shared/detected-worktree-provider-contract' -import { REMOTE_WORKTREE_LIST_PARITY_LIMIT } from './worktree-slice-constants' -import type { - BackgroundRuntimeRefreshOptions, - DetectedWorktreeRefreshOptions -} from './worktree-slice-types' -import { isRuntimeMethodNotFoundError } from './runtime-worktree-rpc-errors' -import { toLegacyDetectedWorktreeResult } from './worktree-host-ownership' - -export async function listDetectedWorktreesForRepo( - settings: AppState['settings'], - repoId: string, - options: BackgroundRuntimeRefreshOptions = {} -): Promise { - const target = getActiveRuntimeTarget(settings) - if (target.kind === 'local') { - throw new Error('Local detected-worktree reads require a provider lease') - } - try { - return await callRuntimeRpc( - target, - 'worktree.detectedList', - { repo: repoId }, - { - timeoutMs: 15_000, - reuseRecentCompatibilityFailure: options.reuseRecentCompatibilityFailure - } - ) - } catch (error) { - if (!isRuntimeMethodNotFoundError(error)) { - throw error - } - const legacy = await callRuntimeRpc( - target, - 'worktree.list', - { repo: repoId, limit: REMOTE_WORKTREE_LIST_PARITY_LIMIT }, - { - timeoutMs: 15_000, - reuseRecentCompatibilityFailure: options.reuseRecentCompatibilityFailure - } - ) - return toLegacyDetectedWorktreeResult(repoId, legacy) - } -} - -export function detectedWorktreeRefreshKey( - settings: AppState['settings'], - repoId: string, - options: DetectedWorktreeRefreshOptions -): string { - const target = getActiveRuntimeTarget(settings) - const targetKey = target.kind === 'local' ? 'local' : `runtime:${target.environmentId}` - const parts = [ - repoId, - options.executionHostId, - targetKey, - options.requireAuthoritative === true ? 'authoritative' : 'best-effort' - ] - // Why: only remote targets run a compat preflight, so a foreground (reuse:false) refresh must re-probe not coalesce onto a stale-failure background scan; local targets have no preflight and stay coalesced. - if (target.kind === 'environment') { - parts.push(`connection:${getEnvironmentSshStateGeneration(target.environmentId)}`) - parts.push(`runtime:${getRuntimeEnvironmentConnectionGeneration(target.environmentId)}`) - parts.push(options.reuseRecentCompatibilityFailure === true ? 'reuse-failure' : 'reprobe') - } - return parts.join('\n') -} - -export function isDetectedWorktreeListResult(value: unknown): value is DetectedWorktreeListResult { - if (!value || typeof value !== 'object') { - return false - } - const result = value as Partial - return ( - typeof result.repoId === 'string' && - typeof result.authoritative === 'boolean' && - (result.source === 'git' || - result.source === 'metadata-fallback' || - result.source === 'session-fallback') && - Array.isArray(result.worktrees) - ) -} - -export function rejectedDetectedWorktreeProviderResult( - request: ListDetectedWorktreesArgs -): HostQualifiedDetectedWorktreeResult { - return { - providerRequestId: request.providerRequestId, - executionHostId: request.executionHostId, - status: 'rejected' - } -} - -export async function startDetectedWorktreeProviderRequest( - request: ListDetectedWorktreesArgs -): Promise { - const worktreesApi = window.api.worktrees as typeof window.api.worktrees & { - listDetected?: typeof window.api.worktrees.listDetected - } - if (typeof worktreesApi.listDetected !== 'function') { - if (request.executionHostId !== LOCAL_EXECUTION_HOST_ID) { - return rejectedDetectedWorktreeProviderResult(request) - } - const worktrees = await worktreesApi.list({ repoId: request.repoId }) - return { - status: 'complete', - providerRequestId: request.providerRequestId, - repoId: request.repoId, - authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, - result: toLegacyDetectedWorktreeResult(request.repoId, { worktrees }) - } - } - const result = await worktreesApi.listDetected(request) - if (result && typeof result === 'object' && 'status' in result && 'providerRequestId' in result) { - return result as unknown as HostQualifiedDetectedWorktreeResult - } - // Why: web and older preload implementations return the legacy local shape. - if (request.executionHostId === LOCAL_EXECUTION_HOST_ID && isDetectedWorktreeListResult(result)) { - return { - status: result.authoritative ? 'complete' : 'non-authoritative', - providerRequestId: request.providerRequestId, - repoId: request.repoId, - authority: { kind: 'local', executionHostId: LOCAL_EXECUTION_HOST_ID }, - result - } - } - return rejectedDetectedWorktreeProviderResult(request) -} diff --git a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh-admission.ts b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh-admission.ts deleted file mode 100644 index 12bc6714f3f..00000000000 --- a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh-admission.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { getEnvironmentSshStateGeneration } from '../../runtime-environment-ssh' -import { getRuntimeEnvironmentConnectionGeneration } from '../../runtime-status' -import type { AppState } from '../../../types' -import type { HostQualifiedDetectedWorktreeResult } from '../../../../../../shared/detected-worktree-provider-contract' -import type { AdmittedDetectedWorktreeRefresh } from './worktree-slice-types' -import { directSshAuthoritiesEqual, getCurrentDirectSshAuthority } from './direct-ssh-authority' - -export function isCurrentDetectedWorktreeRefresh( - state: Pick, - refresh: AdmittedDetectedWorktreeRefresh -): boolean { - if (refresh.directSshAuthority) { - return directSshAuthoritiesEqual( - getCurrentDirectSshAuthority(state, refresh.executionHostId), - refresh.directSshAuthority - ) - } - if (refresh.runtimeAuthority) { - return ( - getEnvironmentSshStateGeneration(refresh.runtimeAuthority.environmentId) === - refresh.runtimeAuthority.connectionGeneration && - getRuntimeEnvironmentConnectionGeneration(refresh.runtimeAuthority.environmentId) === - refresh.runtimeAuthority.runtimeConnectionGeneration - ) - } - return true -} - -export function staleDetectedWorktreeProviderResult( - refresh: AdmittedDetectedWorktreeRefresh -): HostQualifiedDetectedWorktreeResult | undefined { - return refresh.providerResult - ? { - providerRequestId: refresh.providerResult.providerRequestId, - executionHostId: refresh.executionHostId, - status: 'stale' - } - : undefined -} diff --git a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts b/src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts deleted file mode 100644 index 113e680232e..00000000000 --- a/src/renderer/src/store/slices/worktrees/listing/detected-worktree-refresh.ts +++ /dev/null @@ -1,235 +0,0 @@ -import { getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' -import { getEnvironmentSshStateGeneration } from '../../runtime-environment-ssh' -import { getRuntimeEnvironmentConnectionGeneration } from '../../runtime-status' -import { - createDetectedWorktreeRefreshLeaseRegistry, - type DetectedWorktreeRefreshLease -} from '../../detected-worktree-refresh-leases' -import { - LOCAL_EXECUTION_HOST_ID, - parseExecutionHostId, - type ExecutionHostId -} from '../../../../../../shared/execution-host' -import type { AppState } from '../../../types' -import type { DetectedWorktreeListResult } from '../../../../../../shared/worktree/types' -import type { - HostQualifiedDetectedWorktreeResult, - ProviderRequestId, - SshExecutionHostId -} from '../../../../../../shared/detected-worktree-provider-contract' -import type { - DetectedWorktreeRefreshOptions, - DetectedWorktreeRefreshOutcome -} from './worktree-slice-types' -import { teardownMissingWorktreeTerminalsBestEffort } from '../teardown/missing-worktree-terminal-teardown' -import { directSshAuthorityIsComplete } from './direct-ssh-authority' -import { - detectedWorktreeRefreshKey, - isDetectedWorktreeListResult, - listDetectedWorktreesForRepo, - startDetectedWorktreeProviderRequest -} from './detected-worktree-provider-request' - -const runtimeDetectedWorktreeRefreshesInFlight = new Map< - string, - Promise ->() - -export const detectedWorktreeRefreshLeaseRegistry = createDetectedWorktreeRefreshLeaseRegistry({ - startProviderRequest: startDetectedWorktreeProviderRequest, - cancelProviderRequest: async (request) => { - await window.api.worktrees.cancelListDetected?.({ - providerRequestId: request.providerRequestId - }) - } -}) - -export function acquireDetectedWorktreeRefreshLeaseForRepo( - settings: AppState['settings'], - repoId: string, - options: DetectedWorktreeRefreshOptions -): DetectedWorktreeRefreshLease { - const parsedHost = parseExecutionHostId(options.executionHostId) - if (!parsedHost || parsedHost.kind === 'runtime') { - throw new Error('Provider leases require a local or direct SSH execution host') - } - const publicKey = detectedWorktreeRefreshKey(settings, repoId, options) - if (parsedHost.kind === 'local') { - return detectedWorktreeRefreshLeaseRegistry.acquire(publicKey, { - repoId, - executionHostId: LOCAL_EXECUTION_HOST_ID - }) - } - if ( - !options.directSshAuthority || - !directSshAuthorityIsComplete(options.directSshAuthority, parsedHost.targetId) - ) { - throw new Error('Direct SSH provider leases require exact target authority') - } - return detectedWorktreeRefreshLeaseRegistry.acquire(publicKey, { - repoId, - executionHostId: options.executionHostId as SshExecutionHostId, - expectedAuthority: { ...options.directSshAuthority } - }) -} - -export function qualifiedProviderResultIsAdmitted( - result: HostQualifiedDetectedWorktreeResult, - providerRequestId: ProviderRequestId, - repoId: string, - options: DetectedWorktreeRefreshOptions -): result is Extract< - HostQualifiedDetectedWorktreeResult, - { status: 'complete' | 'non-authoritative' } -> { - if ( - result.providerRequestId !== providerRequestId || - (result.status !== 'complete' && result.status !== 'non-authoritative') || - !isDetectedWorktreeListResult(result.result) || - !result.authority || - result.repoId !== repoId || - result.result.repoId !== repoId || - result.result.authoritative !== (result.status === 'complete') - ) { - return false - } - const parsedHost = parseExecutionHostId(options.executionHostId) - if (parsedHost?.kind === 'local') { - return ( - result.authority.kind === 'local' && - result.authority.executionHostId === LOCAL_EXECUTION_HOST_ID - ) - } - const expected = options.directSshAuthority - return ( - parsedHost?.kind === 'ssh' && - expected !== undefined && - result.authority.kind === 'direct-ssh' && - result.authority.executionHostId === options.executionHostId && - result.authority.targetId === expected.targetId && - result.authority.providerEpoch === expected.providerEpoch && - result.authority.connectionGeneration === expected.connectionGeneration - ) -} - -export function normalizeNotAdmittedProviderResult( - result: HostQualifiedDetectedWorktreeResult, - providerRequestId: ProviderRequestId, - executionHostId: ExecutionHostId -): HostQualifiedDetectedWorktreeResult { - if ( - result.providerRequestId === providerRequestId && - result.status !== 'complete' && - result.status !== 'non-authoritative' && - 'executionHostId' in result && - result.executionHostId === executionHostId - ) { - return result - } - return { - providerRequestId, - executionHostId, - status: 'rejected' - } -} - -export async function listDetectedWorktreesForRepoCoalesced( - settings: AppState['settings'], - repoId: string, - options: DetectedWorktreeRefreshOptions -): Promise { - const key = detectedWorktreeRefreshKey(settings, repoId, options) - const target = getActiveRuntimeTarget(settings) - if (target.kind === 'environment') { - const connectionGeneration = getEnvironmentSshStateGeneration(target.environmentId) - const runtimeConnectionGeneration = getRuntimeEnvironmentConnectionGeneration( - target.environmentId - ) - let refresh = runtimeDetectedWorktreeRefreshesInFlight.get(key) - if (!refresh) { - refresh = listDetectedWorktreesForRepo(settings, repoId, { - reuseRecentCompatibilityFailure: options.reuseRecentCompatibilityFailure - }) - runtimeDetectedWorktreeRefreshesInFlight.set(key, refresh) - } - try { - const result = await refresh - if ( - getEnvironmentSshStateGeneration(target.environmentId) !== connectionGeneration || - getRuntimeEnvironmentConnectionGeneration(target.environmentId) !== - runtimeConnectionGeneration - ) { - throw new Error('runtime_environment_generation_changed') - } - // Why (#10562): the scan coalesces, but teardown must not — each caller carries - // its own known-id snapshot and purges its own state, so a caller that joined - // an in-flight scan would otherwise purge without ever stopping those terminals. - await teardownMissingWorktreeTerminalsBestEffort( - settings, - repoId, - options.connectionId, - options.knownWorktreeIds, - result - ) - return { - status: 'admitted', - result, - executionHostId: options.executionHostId, - runtimeAuthority: { - environmentId: target.environmentId, - connectionGeneration, - runtimeConnectionGeneration - } - } - } finally { - if (runtimeDetectedWorktreeRefreshesInFlight.get(key) === refresh) { - runtimeDetectedWorktreeRefreshesInFlight.delete(key) - } - } - } - - const lease = acquireDetectedWorktreeRefreshLeaseForRepo(settings, repoId, options) - let providerResult: HostQualifiedDetectedWorktreeResult - try { - providerResult = await lease.result - } catch { - return { - status: 'not-admitted', - providerResult: { - providerRequestId: lease.providerRequestId, - executionHostId: options.executionHostId, - status: 'rejected' - }, - executionHostId: options.executionHostId, - directSshAuthority: options.directSshAuthority - } - } - if ( - !qualifiedProviderResultIsAdmitted(providerResult, lease.providerRequestId, repoId, options) - ) { - return { - status: 'not-admitted', - providerResult: normalizeNotAdmittedProviderResult( - providerResult, - lease.providerRequestId, - options.executionHostId - ), - executionHostId: options.executionHostId, - directSshAuthority: options.directSshAuthority - } - } - await teardownMissingWorktreeTerminalsBestEffort( - settings, - repoId, - options.connectionId, - options.knownWorktreeIds, - providerResult.result - ) - return { - status: 'admitted', - result: providerResult.result, - providerResult, - executionHostId: options.executionHostId, - directSshAuthority: options.directSshAuthority - } -} diff --git a/src/renderer/src/store/slices/worktrees/listing/direct-ssh-authority.ts b/src/renderer/src/store/slices/worktrees/listing/direct-ssh-authority.ts deleted file mode 100644 index 6902efc74f2..00000000000 --- a/src/renderer/src/store/slices/worktrees/listing/direct-ssh-authority.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { parseExecutionHostId, type ExecutionHostId } from '../../../../../../shared/execution-host' -import type { DirectSshAuthority } from '../../../../../../shared/ssh-types' -import type { AppState } from '../../../types' - -export function directSshAuthorityIsComplete( - authority: DirectSshAuthority, - expectedTargetId: string -): boolean { - if ( - authority.targetId !== expectedTargetId || - typeof authority.providerEpoch !== 'string' || - authority.providerEpoch.length === 0 || - !Number.isSafeInteger(authority.connectionGeneration) || - authority.connectionGeneration < 0 - ) { - return false - } - return true -} - -export function getCurrentDirectSshAuthority( - state: Pick, - hostId: ExecutionHostId -): DirectSshAuthority | null { - const parsedHost = parseExecutionHostId(hostId) - if (parsedHost?.kind !== 'ssh') { - return null - } - const connection = state.sshConnectionStates?.get(parsedHost.targetId) - if (connection?.status !== 'connected') { - return null - } - const authority = { - targetId: parsedHost.targetId, - providerEpoch: connection.providerEpoch, - connectionGeneration: connection.connectionGeneration - } as DirectSshAuthority - if (!directSshAuthorityIsComplete(authority, parsedHost.targetId)) { - return null - } - return { - ...authority - } -} - -export function directSshAuthoritiesEqual( - left: DirectSshAuthority | null | undefined, - right: DirectSshAuthority | null | undefined -): boolean { - if (!left || !right) { - return false - } - return ( - left.targetId === right.targetId && - left.providerEpoch === right.providerEpoch && - left.connectionGeneration === right.connectionGeneration - ) -} diff --git a/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts b/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts deleted file mode 100644 index 31123fd2076..00000000000 --- a/src/renderer/src/store/slices/worktrees/listing/fetch-all-worktrees.ts +++ /dev/null @@ -1,191 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from './worktree-slice-types' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../../shared/constants' -import { - getRepoExecutionHostId, - parseExecutionHostId -} from '../../../../../../shared/execution-host' -import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../../../shared/workspace-scope' -import type { DetectedWorktreeListResult } from '../../../../../../shared/worktree/types' -import { getCurrentDirectSshAuthority } from './direct-ssh-authority' -import { listDetectedWorktreesForRepoCoalesced } from './detected-worktree-refresh' -import { - getKnownWorktreeIdsForPurge, - getProjectHostSetupForRepoHost -} from './worktree-host-ownership' -import { fetchKnownSshWorktreesForRepo } from './known-ssh-worktree-fetch' -import { mergeFetchedWorktrees } from './fetched-worktree-merge' -import { notifyRuntimeScopeForbiddenIfNeeded } from './runtime-scope-forbidden-toast' -import { mapReposForWorktreeRefresh } from './worktree-refresh-pool' -import { settingsForKnownRepoOwner } from './worktree-owner-settings' - -export function createFetchAllWorktrees( - set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['fetchAllWorktrees'] { - return async (options) => { - const repos = options?.visibilityOwnerHostId - ? get().repos.filter((repo) => { - const repoHost = parseExecutionHostId(getRepoExecutionHostId(repo)) - const ownerHost = parseExecutionHostId(options.visibilityOwnerHostId) - return ownerHost?.kind === 'runtime' - ? repoHost?.kind === 'runtime' && repoHost.environmentId === ownerHost.environmentId - : repoHost?.kind !== 'runtime' - }) - : get().repos - - // Why: after the one-shot hydration purge, later calls only refresh cached lists — no IPC double-probe for the per-repo success signal. - if (get().hasHydratedWorktreePurge || options?.visibilityOwnerHostId) { - await mapReposForWorktreeRefresh(repos, async (r) => { - try { - const requestStartedState = get() - const requestStartedWorktrees = requestStartedState.worktreesByRepo[r.id] - const hostId = getRepoExecutionHostId(r) - const setup = getProjectHostSetupForRepoHost(requestStartedState, r.id, hostId) - const settings = settingsForKnownRepoOwner(requestStartedState.settings, r) - const parsedHost = parseExecutionHostId(hostId) - const directSshAuthority = - parsedHost?.kind === 'ssh' - ? (getCurrentDirectSshAuthority(requestStartedState, hostId) ?? undefined) - : undefined - if (parsedHost?.kind === 'ssh' && !directSshAuthority) { - await fetchKnownSshWorktreesForRepo(set, r.id, parsedHost.id) - return - } - const refresh = await listDetectedWorktreesForRepoCoalesced(settings, r.id, { - executionHostId: hostId, - reuseRecentCompatibilityFailure: true, - directSshAuthority, - connectionId: r.connectionId, - knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId) - }) - if (refresh.status !== 'admitted') { - return - } - mergeFetchedWorktrees(set, { - repoId: r.id, - hostId, - ownerWasMissingAtStart: false, - requestStartedWorktrees, - setup, - refresh - }) - } catch (err) { - if (notifyRuntimeScopeForbiddenIfNeeded(err)) { - return - } - console.error(`Failed to fetch worktrees for repo ${r.id}:`, err) - } - }) - return - } - - // Why: a pre-fix upgrade can persist tabsByWorktree entries for worktrees deleted last session; without this hydration purge they leave zombie PTYs misclassified as "bound" (design §2c) until a second restart. - // Safety gate: fetchWorktrees swallows IPC errors and short-circuits on empty-replace, so probe the IPC directly for the per-repo success signal instead of re-listing. - const results = await mapReposForWorktreeRefresh( - repos, - async ( - r - ): Promise< - | { repoId: string; ok: boolean; detected: DetectedWorktreeListResult } - | { repoId: string; ok: false } - > => { - try { - const requestStartedState = get() - const requestStartedWorktrees = requestStartedState.worktreesByRepo[r.id] - const hostId = getRepoExecutionHostId(r) - const setup = getProjectHostSetupForRepoHost(requestStartedState, r.id, hostId) - const parsedHost = parseExecutionHostId(hostId) - const directSshAuthority = - parsedHost?.kind === 'ssh' - ? (getCurrentDirectSshAuthority(requestStartedState, hostId) ?? undefined) - : undefined - if (parsedHost?.kind === 'ssh' && !directSshAuthority) { - await fetchKnownSshWorktreesForRepo(set, r.id, parsedHost.id) - return { repoId: r.id, ok: false as const } - } - const refresh = await listDetectedWorktreesForRepoCoalesced( - settingsForKnownRepoOwner(requestStartedState.settings, r), - r.id, - { - executionHostId: hostId, - reuseRecentCompatibilityFailure: true, - directSshAuthority, - connectionId: r.connectionId, - knownWorktreeIds: getKnownWorktreeIdsForPurge(requestStartedState, r.id, hostId) - } - ) - if (refresh.status !== 'admitted') { - return { repoId: r.id, ok: false as const } - } - const admitted = mergeFetchedWorktrees(set, { - repoId: r.id, - hostId, - ownerWasMissingAtStart: false, - requestStartedWorktrees, - setup, - refresh, - purgeRemovedWorktrees: false - }) - if (!admitted) { - return { repoId: r.id, ok: false as const } - } - return { - repoId: r.id, - ok: refresh.result.authoritative, - detected: refresh.result - } - } catch (err) { - console.error(`Failed to fetch worktrees for repo ${r.id}:`, err) - return { repoId: r.id, ok: false as const } - } - } - ) - - const hasAnyDetectedWorktree = results.some( - (result) => 'detected' in result && result.ok && result.detected.worktrees.length > 0 - ) - const allSucceeded = results.length > 0 && results.every((r) => r.ok) && hasAnyDetectedWorktree - if (!allSucceeded) { - // Defer; try again on the next fetchAllWorktrees call. - return - } - if ( - options?.hydrationPurge === 'defer' || - get().workspaceSessionReady === false || - get().hydrationSucceeded === false - ) { - // Why: startup refreshes local repos first; defer the one-shot purge to the later all-host refresh, once remote worktree ids are known. - return - } - const validIds = new Set() - // Why: floating is persisted renderer state, not a repo worktree an authoritative scan returns. - validIds.add(FLOATING_TERMINAL_WORKTREE_ID) - // Why: folder workspaces persist tabs under `folder:` keys that authoritative repo scans never return. - for (const workspace of get().folderWorkspaces ?? []) { - validIds.add(folderWorkspaceKey(workspace.id)) - } - for (const key of Object.keys(get().restoredRuntimeHostIdByWorkspaceSessionKey ?? {})) { - if (parseWorkspaceKey(key)?.type === 'folder') { - validIds.add(key) - } - } - for (const result of Object.values(get().detectedWorktreesByRepo)) { - if (!result.authoritative) { - continue - } - for (const w of result.worktrees) { - validIds.add(w.id) - } - } - const stale = Object.keys(get().tabsByWorktree).filter((id) => !validIds.has(id)) - if (stale.length > 0) { - console.warn( - `[worktree-purge] hydration-time purge removing stale state for ${stale.length} worktree(s):`, - stale - ) - get().purgeWorktreeTerminalState(stale) - } - set({ hasHydratedWorktreePurge: true }) - } -} diff --git a/src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts b/src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts deleted file mode 100644 index 5d66dd86aa0..00000000000 --- a/src/renderer/src/store/slices/worktrees/listing/fetch-detected-worktrees.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from './worktree-slice-types' -import { parseExecutionHostId } from '../../../../../../shared/execution-host' -import { findRepoForHost } from '../../repo-host-identity' -import { getCurrentDirectSshAuthority } from './direct-ssh-authority' -import { listDetectedWorktreesForRepoCoalesced } from './detected-worktree-refresh' -import { isCurrentDetectedWorktreeRefresh } from './detected-worktree-refresh-admission' -import { mergeDetectedWorktreesForHost } from './detected-worktree-host-merge' -import { areDetectedWorktreeResultsEqual } from './worktree-catalog-visibility' -import { - getKnownWorktreeIdsForPurge, - getProjectHostSetupForRepoHost, - repoHasExactlyOneExecutionHostOwner, - repoHostId, - worktreeHostMatchOptions -} from './worktree-host-ownership' -import { fetchKnownSshWorktreesForRepo } from './known-ssh-worktree-fetch' -import { notifyRuntimeScopeForbiddenIfNeeded } from './runtime-scope-forbidden-toast' -import { settingsForRepoOwner } from './worktree-owner-settings' - -export function createFetchDetectedWorktrees( - set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['fetchDetectedWorktrees'] { - return async (repoId) => { - try { - const ownerState = get() - const hostId = repoHostId(ownerState, repoId) - const ownerWasMissingAtStart = !ownerState.repos.some((repo) => repo.id === repoId) - const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId) - const repoOwner = findRepoForHost(ownerState.repos, repoId, { - hostId, - settings: ownerState.settings - }) - const parsedHost = parseExecutionHostId(hostId) - const directSshAuthority = - parsedHost?.kind === 'ssh' - ? (getCurrentDirectSshAuthority(ownerState, hostId) ?? undefined) - : undefined - if (parsedHost?.kind === 'ssh' && !directSshAuthority) { - // Why: this function's contract is detected-only. The fallback runs for its store side effect, but - // callers keep seeing null as they did before the metadata path existed. - await fetchKnownSshWorktreesForRepo(set, repoId, parsedHost.id) - return null - } - const refresh = await listDetectedWorktreesForRepoCoalesced( - settingsForRepoOwner(ownerState, repoId, hostId), - repoId, - { - executionHostId: hostId, - directSshAuthority, - connectionId: repoOwner?.connectionId, - knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId) - } - ) - if (refresh.status !== 'admitted') { - return null - } - let admitted = false - set((s) => { - if ( - !isCurrentDetectedWorktreeRefresh(s, refresh) || - !repoHasExactlyOneExecutionHostOwner( - s, - repoId, - hostId, - ownerWasMissingAtStart && !refresh.directSshAuthority - ) - ) { - return s - } - admitted = true - // Why: detected-only refreshes can overlap host-scoped visible refreshes; merge detected state so SSH/runtime rows aren't clobbered. - const mergedDetected = mergeDetectedWorktreesForHost( - s.detectedWorktreesByRepo[repoId], - refresh.result, - hostId, - setup, - worktreeHostMatchOptions(s, repoId, hostId) - ) - return areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[repoId], mergedDetected) - ? s - : { - detectedWorktreesByRepo: { - ...s.detectedWorktreesByRepo, - [repoId]: mergedDetected - } - } - }) - return admitted ? refresh.result : null - } catch (err) { - if (notifyRuntimeScopeForbiddenIfNeeded(err)) { - return null - } - console.error(`Failed to fetch detected worktrees for repo ${repoId}:`, err) - return null - } - } -} diff --git a/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts b/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts deleted file mode 100644 index b812ea170ac..00000000000 --- a/src/renderer/src/store/slices/worktrees/listing/fetch-worktrees.ts +++ /dev/null @@ -1,124 +0,0 @@ -import type { - DirectSshWorktreeFetchOptions, - WorktreeFetchOptions, - WorktreeSlice -} from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from './worktree-slice-types' -import { - LOCAL_EXECUTION_HOST_ID, - getRepoExecutionHostId, - parseExecutionHostId -} from '../../../../../../shared/execution-host' -import { findRepoForHost } from '../../repo-host-identity' -import { getCurrentDirectSshAuthority } from './direct-ssh-authority' -import { listDetectedWorktreesForRepoCoalesced } from './detected-worktree-refresh' -import { staleDetectedWorktreeProviderResult } from './detected-worktree-refresh-admission' -import { - getKnownWorktreeIdsForPurge, - getProjectHostSetupForRepoHost, - repoHostId -} from './worktree-host-ownership' -import { fetchKnownSshWorktreesForRepo } from './known-ssh-worktree-fetch' -import { mergeFetchedWorktrees } from './fetched-worktree-merge' -import { notifyRuntimeScopeForbiddenIfNeeded } from './runtime-scope-forbidden-toast' -import { refreshRemoteWorktreeLineageBestEffort } from '../metadata/worktree-lineage-refresh' -import { settingsForRepoOwner } from './worktree-owner-settings' - -export function createFetchWorktrees( - set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['fetchWorktrees'] { - return (async ( - repoId: string, - options?: WorktreeFetchOptions | DirectSshWorktreeFetchOptions - ) => { - const directCallerAuthority = - options && 'directSshAuthority' in options ? options.directSshAuthority : undefined - try { - const ownerState = get() - const requestStartedWorktrees = ownerState.worktreesByRepo[repoId] - const repoOwners = ownerState.repos.filter((repo) => repo.id === repoId) - const ownerWasMissingAtStart = repoOwners.length === 0 - const hasLocalOwner = repoOwners.some( - (repo) => getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID - ) - // Why: a local event may share its repo id with the focused runtime; prefer - // the local owner without redirecting runtime/SSH-only repos. - const useLocalOwner = - options?.forceLocalOwner === true && (hasLocalOwner || repoOwners.length === 0) - const hostId = useLocalOwner - ? LOCAL_EXECUTION_HOST_ID - : repoHostId(ownerState, repoId, options?.executionHostId) - const setup = getProjectHostSetupForRepoHost(ownerState, repoId, hostId) - const repoOwner = findRepoForHost(ownerState.repos, repoId, { - hostId, - settings: ownerState.settings - }) - const ownerSettings = settingsForRepoOwner( - ownerState, - repoId, - hostId, - ownerWasMissingAtStart && (useLocalOwner || options?.executionHostId !== undefined) - ) - const settings = - useLocalOwner && ownerSettings?.activeRuntimeEnvironmentId - ? { ...ownerSettings, activeRuntimeEnvironmentId: null } - : ownerSettings - const parsedHost = parseExecutionHostId(hostId) - const directSshAuthority = - parsedHost?.kind === 'ssh' - ? (directCallerAuthority ?? getCurrentDirectSshAuthority(ownerState, hostId) ?? undefined) - : undefined - if (parsedHost?.kind === 'ssh' && !directSshAuthority) { - // Why: requireAuthoritative callers asked for authoritative-or-nothing, so writing non-authoritative - // rows as a side effect before returning false would silently weaken that contract. - if (!options?.requireAuthoritative) { - await fetchKnownSshWorktreesForRepo(set, repoId, parsedHost.id) - } - return false - } - const refresh = await listDetectedWorktreesForRepoCoalesced(settings, repoId, { - executionHostId: hostId, - requireAuthoritative: options?.requireAuthoritative, - directSshAuthority, - connectionId: repoOwner?.connectionId, - knownWorktreeIds: getKnownWorktreeIdsForPurge(ownerState, repoId, hostId) - }) - if (refresh.status !== 'admitted') { - return directCallerAuthority ? refresh.providerResult : false - } - if (options?.requireAuthoritative && !refresh.result.authoritative) { - return directCallerAuthority ? refresh.providerResult : false - } - const admitted = mergeFetchedWorktrees(set, { - repoId, - hostId, - ownerWasMissingAtStart, - missingDirectSshOwnerReposSnapshot: - ownerWasMissingAtStart && options?.executionHostId === hostId - ? ownerState.repos - : undefined, - requestStartedWorktrees, - setup, - refresh - }) - if (!admitted) { - return directCallerAuthority - ? (staleDetectedWorktreeProviderResult(refresh) ?? false) - : false - } - // Direct SSH lineage requires its own qualified authority result. - // Bulk runtime callers apply one final host-wide snapshot after all repo merges. - if (!directSshAuthority && !options?.suppressRemoteLineageRefresh) { - await refreshRemoteWorktreeLineageBestEffort(settings, set) - } - return directCallerAuthority ? refresh.providerResult! : refresh.result.authoritative - } catch (err) { - if (notifyRuntimeScopeForbiddenIfNeeded(err)) { - return false - } - console.error(`Failed to fetch worktrees for repo ${repoId}:`, err) - return false - } - }) as WorktreeSlice['fetchWorktrees'] -} diff --git a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts deleted file mode 100644 index 2311aaf0294..00000000000 --- a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts +++ /dev/null @@ -1,175 +0,0 @@ -import type { StateCreator } from 'zustand' -import type { AppState } from '../../../types' -import type { WorktreeSlice } from '../../worktree-helpers' -import type { Worktree } from '../../../../../../shared/worktree/types' -import { routeListingBranchSwitchesThroughGitIdentity } from '../../worktree-listing-branch-switch' -import { areDetectedWorktreeResultsEqual, areWorktreesEqual } from './worktree-catalog-visibility' -import { mergeDetectedWorktreesForHost } from './detected-worktree-host-merge' -import { - getRemovedWorktreeIdsAfterAuthoritativeScan, - mergeWorktreesForHost, - repoHasExactlyOneExecutionHostOwner, - toVisibleWorktrees, - worktreeHostMatchOptions, - worktreeMatchesHost -} from './worktree-host-ownership' -import { - hasBranchScopedHostedReviewContext, - sanitizeHostedReviewLinksForBranchClears -} from '../metadata/hosted-review-link-mutation' -import { isCurrentDetectedWorktreeRefresh } from './detected-worktree-refresh-admission' -import { buildWorktreePurgeState } from '../teardown/worktree-purge-state' -import { - forgetAuthoritativelyRemovedWorktrees, - forgetPersistedWorktreeMetaForRemovals, - rememberAuthoritativelyRemovedWorktrees -} from './authoritative-worktree-removal-memory' -import type { FencedWorktreeMergeArgs } from './worktree-slice-types' - -export function preserveConcurrentManualOrder( - incoming: readonly T[], - requestStarted: readonly Worktree[] | undefined, - current: readonly Worktree[] | undefined, - matchesRefreshHost: (worktree: Worktree) => boolean -): T[] { - if (!requestStarted || !current) { - return [...incoming] - } - const startedById = new Map( - requestStarted.filter(matchesRefreshHost).map((worktree) => [worktree.id, worktree]) - ) - const currentById = new Map( - current.filter(matchesRefreshHost).map((worktree) => [worktree.id, worktree]) - ) - return incoming.map((worktree) => { - const started = startedById.get(worktree.id) - const latest = currentById.get(worktree.id) - if (!started || !latest || started.manualOrder === latest.manualOrder) { - return worktree - } - // Why: a refresh response may predate a completed drag; the renderer's optimistic rank is newer. - return { ...worktree, manualOrder: latest.manualOrder } - }) -} - -export function mergeFetchedWorktrees( - set: Parameters>[0], - args: FencedWorktreeMergeArgs -): boolean { - let admitted = false - let authoritativelyRemovedIds: readonly string[] = [] - let authoritativelySeenIds: readonly string[] = [] - set((s) => { - if ( - !isCurrentDetectedWorktreeRefresh(s, args.refresh) || - !repoHasExactlyOneExecutionHostOwner( - s, - args.repoId, - args.hostId, - args.ownerWasMissingAtStart && - (!args.refresh.directSshAuthority || s.repos === args.missingDirectSshOwnerReposSnapshot) - ) - ) { - return s - } - admitted = true - const matchOptions = worktreeHostMatchOptions(s, args.repoId, args.hostId) - const currentWorktrees = s.worktreesByRepo[args.repoId] - const refreshResult = { - ...args.refresh.result, - worktrees: preserveConcurrentManualOrder( - args.refresh.result.worktrees, - args.requestStartedWorktrees, - currentWorktrees, - (worktree) => worktreeMatchesHost(worktree, args.hostId, matchOptions) - ) - } - let incoming = toVisibleWorktrees(refreshResult, args.hostId, args.setup) - incoming = routeListingBranchSwitchesThroughGitIdentity({ - requestStarted: args.requestStartedWorktrees, - current: s.worktreesByRepo[args.repoId], - incoming, - matchesRefreshHost: (worktree) => worktreeMatchesHost(worktree, args.hostId, matchOptions), - hasBranchScopedReviewContext: hasBranchScopedHostedReviewContext, - updateWorktreeGitIdentity: s.updateWorktreeGitIdentity - }) - const worktrees = sanitizeHostedReviewLinksForBranchClears( - incoming, - s.worktreesByRepo[args.repoId] - ) - const currentForHost = (s.worktreesByRepo[args.repoId] ?? []).filter((worktree) => - worktreeMatchesHost(worktree, args.hostId, matchOptions) - ) - const mergedDetected = mergeDetectedWorktreesForHost( - s.detectedWorktreesByRepo[args.repoId], - refreshResult, - args.hostId, - args.setup, - matchOptions - ) - if (!args.refresh.result.authoritative && worktrees.length === 0 && currentForHost.length > 0) { - return areDetectedWorktreeResultsEqual(s.detectedWorktreesByRepo[args.repoId], mergedDetected) - ? s - : { - detectedWorktreesByRepo: { - ...s.detectedWorktreesByRepo, - [args.repoId]: mergedDetected - } - } - } - const mergedWorktrees = mergeWorktreesForHost( - s.worktreesByRepo[args.repoId], - worktrees, - args.hostId, - matchOptions - ) - const removedIds = - args.purgeRemovedWorktrees === false - ? [] - : getRemovedWorktreeIdsAfterAuthoritativeScan( - s, - args.repoId, - args.refresh.result, - args.hostId - ) - authoritativelyRemovedIds = removedIds - if (args.refresh.result.authoritative) { - authoritativelySeenIds = args.refresh.result.worktrees.map((worktree) => worktree.id) - } - const worktreesChanged = !areWorktreesEqual(s.worktreesByRepo[args.repoId], mergedWorktrees) - const detectedChanged = !areDetectedWorktreeResultsEqual( - s.detectedWorktreesByRepo[args.repoId], - mergedDetected - ) - if (!worktreesChanged && !detectedChanged && removedIds.length === 0) { - return s - } - return { - ...(worktreesChanged - ? { - worktreesByRepo: { - ...s.worktreesByRepo, - [args.repoId]: mergedWorktrees - }, - sortEpoch: s.sortEpoch + 1 - } - : {}), - ...(detectedChanged - ? { - detectedWorktreesByRepo: { - ...s.detectedWorktreesByRepo, - [args.repoId]: mergedDetected - } - } - : {}), - ...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {}) - } - }) - if (admitted) { - // Why: applied outside the updater so a repeated updater call cannot double-apply the removal memory. - forgetAuthoritativelyRemovedWorktrees(args.hostId, authoritativelySeenIds) - rememberAuthoritativelyRemovedWorktrees(args.hostId, authoritativelyRemovedIds) - forgetPersistedWorktreeMetaForRemovals(args.repoId, args.hostId, authoritativelyRemovedIds) - } - return admitted -} diff --git a/src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts b/src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts deleted file mode 100644 index 74ac8f1f815..00000000000 --- a/src/renderer/src/store/slices/worktrees/listing/known-ssh-worktree-fetch.ts +++ /dev/null @@ -1,278 +0,0 @@ -import type { StateCreator, StoreApi } from 'zustand' -import type { AppState } from '../../../types' -import type { WorktreeSlice } from '../../worktree-helpers' -import type { DetectedWorktreeListResult } from '../../../../../../shared/worktree/types' -import type { - HostQualifiedDetectedWorktreeResult, - HostQualifiedKnownWorktreeResult, - ProviderRequestId, - SshExecutionHostId -} from '../../../../../../shared/detected-worktree-provider-contract' -import type { DirectSshAuthority } from '../../../../../../shared/ssh-types' -import type { DetectedWorktreeRefreshLease } from '../../detected-worktree-refresh-leases' -import { areDetectedWorktreeResultsEqual, areWorktreesEqual } from './worktree-catalog-visibility' -import { - getProjectHostSetupForRepoHost, - repoHasExactlyOneExecutionHostOwner, - toVisibleWorktrees, - withRepoHostOwnership, - worktreeHostMatchOptions, - worktreeMatchesHost -} from './worktree-host-ownership' -import { settingsForRepoOwner } from './worktree-owner-settings' -import { getCurrentDirectSshAuthority } from './direct-ssh-authority' -import { - acquireDetectedWorktreeRefreshLeaseForRepo, - normalizeNotAdmittedProviderResult, - qualifiedProviderResultIsAdmitted -} from './detected-worktree-refresh' -import { isDetectedWorktreeListResult } from './detected-worktree-provider-request' -import { staleDetectedWorktreeProviderResult } from './detected-worktree-refresh-admission' -import { mergeFetchedWorktrees } from './fetched-worktree-merge' -import { getAuthoritativelyRemovedWorktreeIds } from './authoritative-worktree-removal-memory' -import type { - AdmittedDetectedWorktreeRefresh, - DetectedWorktreeRefreshOptions, - WorktreeHostMatchOptions -} from './worktree-slice-types' -import type { ExecutionHostId } from '../../../../../../shared/execution-host' - -export function appendMissingWorktreesForHost< - T extends { id: string; hostId?: ExecutionHostId; runtimeOwnerEnvironmentId?: string } ->( - current: readonly T[] | undefined, - incoming: readonly T[], - hostId: ExecutionHostId, - options: WorktreeHostMatchOptions -): T[] { - const existing = current ?? [] - const existingHostIds = new Set( - existing - .filter((worktree) => worktreeMatchesHost(worktree, hostId, options)) - .map(({ id }) => id) - ) - const missing = incoming.filter((worktree) => !existingHostIds.has(worktree.id)) - if (missing.length === 0) { - return [...existing] - } - // Why: land inside the host's block like mergeWorktreesForHost does, else these rows sit past sibling hosts and visibly jump once the authoritative scan splices them back. - const lastHostIndex = existing.findLastIndex((worktree) => - worktreeMatchesHost(worktree, hostId, options) - ) - if (lastHostIndex === -1) { - return [...existing, ...missing] - } - return [...existing.slice(0, lastHostIndex + 1), ...missing, ...existing.slice(lastHostIndex + 1)] -} - -export function isAdmittedKnownSshWorktreeResult( - result: HostQualifiedKnownWorktreeResult, - repoId: string, - executionHostId: SshExecutionHostId -): result is Extract { - return ( - result.status === 'complete' && - result.repoId === repoId && - result.executionHostId === executionHostId && - isDetectedWorktreeListResult(result.result) && - result.result.repoId === repoId && - result.result.authoritative === false - ) -} - -const inflightKnownSshWorktreeFetches = new Map< - string, - Promise ->() - -// Why: the authoritative path dedupes through listDetectedWorktreesForRepoCoalesced; without a matching guard -// the four refresh triggers can each issue this IPC and its merge for the same repo/host. -export async function fetchKnownSshWorktreesForRepo( - set: Parameters>[0], - repoId: string, - executionHostId: SshExecutionHostId -): Promise { - const coalesceKey = `${repoId}\0${executionHostId}` - const inflight = inflightKnownSshWorktreeFetches.get(coalesceKey) - if (inflight) { - return await inflight - } - const request = runKnownSshWorktreeFetch(set, repoId, executionHostId).finally(() => { - inflightKnownSshWorktreeFetches.delete(coalesceKey) - }) - inflightKnownSshWorktreeFetches.set(coalesceKey, request) - return await request -} - -export async function runKnownSshWorktreeFetch( - set: Parameters>[0], - repoId: string, - executionHostId: SshExecutionHostId -): Promise { - // Why: reads the local store only, so a runtime-hub session (whose repo ids live on the hub) always gets 'rejected' and keeps the pre-existing no-op. - const listKnown = window.api.worktrees.listKnownForExecutionHost - if (typeof listKnown !== 'function') { - return null - } - const result = await listKnown({ repoId, executionHostId }) - if (!isAdmittedKnownSshWorktreeResult(result, repoId, executionHostId)) { - return null - } - // Why: persisted SSH metadata outlives the remote worktree, so drop rows a completed scan already proved gone. - const suppressedIds = getAuthoritativelyRemovedWorktreeIds(executionHostId) - const known = - suppressedIds && suppressedIds.size > 0 - ? { - ...result.result, - worktrees: result.result.worktrees.filter((worktree) => !suppressedIds.has(worktree.id)) - } - : result.result - let admitted = false - set((state) => { - // Why: the provider can connect during the await; authoritative rows already replaced this host, so appending stale metadata would resurrect purged worktrees. - if ( - getCurrentDirectSshAuthority(state, executionHostId) || - !repoHasExactlyOneExecutionHostOwner(state, repoId, executionHostId, false) - ) { - return state - } - admitted = true - const setup = getProjectHostSetupForRepoHost(state, repoId, executionHostId) - const matchOptions = worktreeHostMatchOptions(state, repoId, executionHostId) - const incomingDetected = known.worktrees.map((worktree) => - withRepoHostOwnership(worktree, executionHostId, setup) - ) - const priorDetected = state.detectedWorktreesByRepo[repoId] - // Why: only the rows are ours to merge. This entry is keyed by repo alone, so adopting the fallback's - // authoritative/source would demote a sibling host's completed scan and blank every authoritative-gated surface. - const detected = { - ...(priorDetected ?? known), - worktrees: appendMissingWorktreesForHost( - priorDetected?.worktrees, - incomingDetected, - executionHostId, - matchOptions - ) - } - const worktrees = appendMissingWorktreesForHost( - state.worktreesByRepo[repoId], - toVisibleWorktrees(known, executionHostId, setup), - executionHostId, - matchOptions - ) - const worktreesChanged = !areWorktreesEqual(state.worktreesByRepo[repoId], worktrees) - const detectedChanged = !areDetectedWorktreeResultsEqual( - state.detectedWorktreesByRepo[repoId], - detected - ) - if (!worktreesChanged && !detectedChanged) { - return state - } - return { - ...(worktreesChanged - ? { - worktreesByRepo: { ...state.worktreesByRepo, [repoId]: worktrees }, - sortEpoch: state.sortEpoch + 1 - } - : {}), - ...(detectedChanged - ? { detectedWorktreesByRepo: { ...state.detectedWorktreesByRepo, [repoId]: detected } } - : {}) - } - }) - return admitted ? known : null -} - -export type DirectSshDetectedWorktreeRefresh = { - waiterLeaseId: DetectedWorktreeRefreshLease['waiterLeaseId'] - providerRequestId: ProviderRequestId - result: Promise - release: DetectedWorktreeRefreshLease['release'] - merge(result: HostQualifiedDetectedWorktreeResult): HostQualifiedDetectedWorktreeResult -} - -export function acquireDirectSshDetectedWorktreeRefresh( - store: Pick, 'getState' | 'setState'>, - request: { - repoId: string - executionHostId: SshExecutionHostId - authority: DirectSshAuthority - requireAuthoritative?: boolean - } -): DirectSshDetectedWorktreeRefresh { - const requestStartedState = store.getState() - const requestStartedWorktrees = requestStartedState.worktreesByRepo[request.repoId] - const ownerWasMissingAtStart = !requestStartedState.repos.some( - (repo) => repo.id === request.repoId - ) - const setup = getProjectHostSetupForRepoHost( - requestStartedState, - request.repoId, - request.executionHostId - ) - const settings = settingsForRepoOwner( - requestStartedState, - request.repoId, - request.executionHostId - ) - const options: DetectedWorktreeRefreshOptions = { - executionHostId: request.executionHostId, - directSshAuthority: request.authority, - requireAuthoritative: request.requireAuthoritative - } - const lease = acquireDetectedWorktreeRefreshLeaseForRepo(settings, request.repoId, options) - let mergedResult: HostQualifiedDetectedWorktreeResult | undefined - - return { - waiterLeaseId: lease.waiterLeaseId, - providerRequestId: lease.providerRequestId, - result: lease.result, - release: lease.release, - merge: (providerResult) => { - if (mergedResult) { - return mergedResult - } - if ( - !qualifiedProviderResultIsAdmitted( - providerResult, - lease.providerRequestId, - request.repoId, - options - ) - ) { - mergedResult = normalizeNotAdmittedProviderResult( - providerResult, - lease.providerRequestId, - request.executionHostId - ) - return mergedResult - } - if (request.requireAuthoritative && providerResult.status !== 'complete') { - mergedResult = providerResult - return mergedResult - } - const refresh: AdmittedDetectedWorktreeRefresh = { - status: 'admitted', - result: providerResult.result, - providerResult, - executionHostId: request.executionHostId, - directSshAuthority: request.authority - } - const admitted = mergeFetchedWorktrees( - store.setState as Parameters>[0], - { - repoId: request.repoId, - hostId: request.executionHostId, - ownerWasMissingAtStart, - requestStartedWorktrees, - setup, - refresh - } - ) - mergedResult = admitted - ? providerResult - : (staleDetectedWorktreeProviderResult(refresh) ?? providerResult) - return mergedResult - } - } -} diff --git a/src/renderer/src/store/slices/worktrees/listing/runtime-scope-forbidden-toast.ts b/src/renderer/src/store/slices/worktrees/listing/runtime-scope-forbidden-toast.ts deleted file mode 100644 index 70327313620..00000000000 --- a/src/renderer/src/store/slices/worktrees/listing/runtime-scope-forbidden-toast.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { toast } from 'sonner' -import { translate } from '@/i18n/i18n' -import { isRuntimeScopeForbiddenError } from '../../../../runtime/runtime-rpc-client' -import { RUNTIME_SCOPE_FORBIDDEN_TOAST_ID } from './worktree-slice-constants' - -export function notifyRuntimeScopeForbiddenIfNeeded(error: unknown): boolean { - if (!isRuntimeScopeForbiddenError(error)) { - return false - } - toast.error( - translate( - 'auto.store.slices.worktrees.runtimeScopeForbiddenTitle', - 'This connection has limited (mobile) access' - ), - { - id: RUNTIME_SCOPE_FORBIDDEN_TOAST_ID, - description: translate( - 'auto.store.slices.worktrees.runtimeScopeForbiddenDescription', - 'Workspaces are unavailable on a mobile-scope pairing. Reconnect using the browser access link from Settings → Runtime Environments → Share this Orca server.' - ) - } - ) - return true -} diff --git a/src/renderer/src/store/slices/worktrees/listing/runtime-worktree-rpc-errors.ts b/src/renderer/src/store/slices/worktrees/listing/runtime-worktree-rpc-errors.ts deleted file mode 100644 index b5cf89e47d8..00000000000 --- a/src/renderer/src/store/slices/worktrees/listing/runtime-worktree-rpc-errors.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { hasRuntimeRpcErrorCode, RuntimeRpcCallError } from '../../../../runtime/runtime-rpc-client' - -export function isRuntimeMethodNotFoundError(error: unknown): boolean { - return error instanceof RuntimeRpcCallError && error.code === 'method_not_found' -} - -export function isRuntimeSelectorNotFoundError(error: unknown): boolean { - return hasRuntimeRpcErrorCode(error, 'selector_not_found') -} - -export function isRuntimeRepoNotFoundError(error: unknown): boolean { - return hasRuntimeRpcErrorCode(error, 'repo_not_found') -} diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-catalog-visibility.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-catalog-visibility.ts deleted file mode 100644 index 90838e5397f..00000000000 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-catalog-visibility.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { catalogRowsEqual } from '../../worktree-catalog-reconciliation' -import type { WorkspaceVisibleTabType } from '../../../../../../shared/tab-types' -import type { DetectedWorktreeListResult, Worktree } from '../../../../../../shared/worktree/types' - -export function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): boolean { - return catalogRowsEqual(current, next) -} - -export function areDetectedWorktreeResultsEqual( - current: DetectedWorktreeListResult | undefined, - next: DetectedWorktreeListResult -): boolean { - return Boolean( - current && - current.repoId === next.repoId && - current.authoritative === next.authoritative && - current.source === next.source && - catalogRowsEqual(current.worktrees, next.worktrees) - ) -} - -export function toVisibleTabType(contentType: string): WorkspaceVisibleTabType { - if (contentType === 'browser' || contentType === 'terminal' || contentType === 'simulator') { - return contentType - } - return 'editor' -} - -export function toVisibleWorktree( - worktree: DetectedWorktreeListResult['worktrees'][number] -): Worktree { - const { - ownership: _ownership, - selectedCheckout: _selectedCheckout, - visible: _visible, - ...base - } = worktree - return base -} diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts deleted file mode 100644 index d5fe96141b9..00000000000 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts +++ /dev/null @@ -1,293 +0,0 @@ -import type { AppState } from '../../../types' -import type { ProjectHostSetup } from '../../../../../../shared/project-types' -import type { DetectedWorktreeListResult, Worktree } from '../../../../../../shared/worktree/types' -import { findRepoForHost } from '../../repo-host-identity' -import { reuseEqualCatalogRows } from '../../worktree-catalog-reconciliation' -import { getRepoIdFromWorktreeId } from '../../worktree-helpers' -import { - getRepoExecutionHostId, - LOCAL_EXECUTION_HOST_ID, - parseExecutionHostId, - toSshExecutionHostId, - type ExecutionHostId -} from '../../../../../../shared/execution-host' -import { toVisibleWorktree } from './worktree-catalog-visibility' -import type { WorktreeHostMatchOptions, RepoHostSummary } from './worktree-slice-types' - -export function withRepoHostOwnership< - T extends { - hostId?: ExecutionHostId - runtimeOwnerEnvironmentId?: string - projectId?: string - projectHostSetupId?: string - } ->(worktree: T, hostId: ExecutionHostId, setup?: ProjectHostSetup): T { - const parsedOwner = parseExecutionHostId(hostId) - const runtimeOwnerEnvironmentId = - parsedOwner?.kind === 'runtime' ? parsedOwner.environmentId : undefined - const worktreeHost = parseExecutionHostId(worktree.hostId) - // Why: an SSH worktree reached through a paired HUB has two owners; retain the SSH execution host and stamp the HUB transport separately. - const nextHostId = - hostId === LOCAL_EXECUTION_HOST_ID || - (runtimeOwnerEnvironmentId !== undefined && worktreeHost?.kind === 'ssh') - ? worktree.hostId - : hostId - const projectId = worktree.projectId ?? setup?.projectId - const projectHostSetupId = worktree.projectHostSetupId ?? setup?.id - if ( - nextHostId === worktree.hostId && - runtimeOwnerEnvironmentId === worktree.runtimeOwnerEnvironmentId && - projectId === worktree.projectId && - projectHostSetupId === worktree.projectHostSetupId - ) { - return worktree - } - return { - ...worktree, - ...(nextHostId ? { hostId: nextHostId } : {}), - runtimeOwnerEnvironmentId, - ...(projectId ? { projectId } : {}), - ...(projectHostSetupId ? { projectHostSetupId } : {}) - } as T -} - -export function repoHostId( - state: Pick, - repoId: string, - hostId?: ExecutionHostId | null -): ExecutionHostId { - const repo = findRepoForHost(state.repos, repoId, { hostId, settings: state.settings }) - if (repo) { - return getRepoExecutionHostId(repo) - } - return hostId && parseExecutionHostId(hostId) ? hostId : LOCAL_EXECUTION_HOST_ID -} - -export function repoHasExactlyOneExecutionHostOwner( - state: Pick, - repoId: string, - hostId: ExecutionHostId, - ownerWasMissingAtStart: boolean -): boolean { - const repoOwners = state.repos.filter((repo) => repo.id === repoId) - if (repoOwners.length === 0) { - return ownerWasMissingAtStart - } - const ownerHostIds = repoOwners.map((repo) => { - const hasExplicitHost = repo.executionHostId !== null && repo.executionHostId !== undefined - const explicitHost = hasExplicitHost ? parseExecutionHostId(repo.executionHostId) : null - if (hasExplicitHost && !explicitHost) { - return null - } - const rawConnectionId = repo.connectionId - const hasConnection = rawConnectionId !== null && rawConnectionId !== undefined - const connectionId = hasConnection ? rawConnectionId.trim() : null - if (hasConnection && !connectionId) { - return null - } - if (!connectionId || explicitHost?.kind === 'runtime') { - return explicitHost?.id ?? LOCAL_EXECUTION_HOST_ID - } - if (explicitHost && explicitHost.id !== toSshExecutionHostId(connectionId)) { - return null - } - return explicitHost?.id ?? toSshExecutionHostId(connectionId) - }) - return ( - ownerHostIds.every((ownerHostId) => ownerHostId !== null) && - ownerHostIds.filter((ownerHostId) => ownerHostId === hostId).length === 1 - ) -} - -export function toVisibleWorktrees( - result: DetectedWorktreeListResult, - hostId: ExecutionHostId, - setup?: ProjectHostSetup -): Worktree[] { - return result.worktrees - .filter((worktree) => worktree.visible) - .map(toVisibleWorktree) - .map((worktree) => withRepoHostOwnership(worktree, hostId, setup)) -} - -export function getProjectHostSetupForRepoHost( - state: Partial>, - repoId: string, - hostId: ExecutionHostId -): ProjectHostSetup | undefined { - return state.projectHostSetups?.find( - (setup) => setup.repoId === repoId && setup.hostId === hostId - ) -} - -export function getHydratedSessionWorktreeIdsForRepo(state: AppState, repoId: string): string[] { - return Object.keys(state.tabsByWorktree).filter((id) => getRepoIdFromWorktreeId(id) === repoId) -} - -export const repoHostSummariesByRepos = new WeakMap< - AppState['repos'], - Map ->() - -export function getRepoHostSummaries(repos: AppState['repos']): Map { - const cached = repoHostSummariesByRepos.get(repos) - if (cached) { - return cached - } - - const summaries = new Map() - for (const repo of repos) { - const current = summaries.get(repo.id) - if (current) { - summaries.set(repo.id, { count: current.count + 1 }) - } else { - summaries.set(repo.id, { count: 1, onlyHostId: getRepoExecutionHostId(repo) }) - } - } - repoHostSummariesByRepos.set(repos, summaries) - return summaries -} - -export function unhostedWorktreesMatchRefreshHost( - state: Pick, - repoId: string, - hostId: ExecutionHostId -): boolean { - if (hostId === LOCAL_EXECUTION_HOST_ID) { - return true - } - - const summary = getRepoHostSummaries(state.repos).get(repoId) - return summary?.count === 1 && summary.onlyHostId === hostId -} - -export function worktreeHostMatchOptions( - state: Pick, - repoId: string, - hostId: ExecutionHostId -): WorktreeHostMatchOptions { - return { - // Why: pre-host persisted runtime/SSH worktrees lack hostId; treat them as the sole repo owner's rows but keep ambiguous duplicates local. - unhostedWorktreesMatchHost: unhostedWorktreesMatchRefreshHost(state, repoId, hostId) - } -} - -export function worktreeMatchesHost( - worktree: { hostId?: ExecutionHostId; runtimeOwnerEnvironmentId?: string }, - hostId: ExecutionHostId, - options: WorktreeHostMatchOptions = {} -): boolean { - const parsedRefreshHost = parseExecutionHostId(hostId) - if (parsedRefreshHost?.kind === 'runtime') { - if (worktree.runtimeOwnerEnvironmentId) { - return worktree.runtimeOwnerEnvironmentId === parsedRefreshHost.environmentId - } - if (worktree.hostId) { - return worktree.hostId === hostId - } - return options.unhostedWorktreesMatchHost ?? false - } - if (worktree.runtimeOwnerEnvironmentId) { - return false - } - if (worktree.hostId) { - return worktree.hostId === hostId - } - return options.unhostedWorktreesMatchHost ?? hostId === LOCAL_EXECUTION_HOST_ID -} - -export function mergeWorktreesForHost< - T extends { id: string; hostId?: ExecutionHostId; runtimeOwnerEnvironmentId?: string } ->( - current: readonly T[] | undefined, - refreshed: readonly T[], - hostId: ExecutionHostId, - options?: WorktreeHostMatchOptions -): T[] { - // Why: host-scoped refreshes replace that host in place so alternating local/runtime refreshes don't churn sibling row order or sortEpoch. - const existing = current ?? [] - const reconciled = reuseEqualCatalogRows( - existing.filter((worktree) => worktreeMatchesHost(worktree, hostId, options)), - refreshed - ) - const next: T[] = [] - let inserted = false - - for (const worktree of existing) { - if (worktreeMatchesHost(worktree, hostId, options)) { - if (!inserted) { - next.push(...reconciled) - inserted = true - } - continue - } - next.push(worktree) - } - - if (!inserted) { - next.push(...reconciled) - } - return existing.length === next.length && existing.every((row, index) => row === next[index]) - ? (existing as T[]) - : next -} - -export function getKnownWorktreeIdsForPurge( - state: AppState, - repoId: string, - hostId: ExecutionHostId -): string[] { - const detected = state.detectedWorktreesByRepo[repoId] - const knownIds = new Set() - const matchOptions = worktreeHostMatchOptions(state, repoId, hostId) - if (detected?.authoritative === true) { - for (const worktree of detected.worktrees) { - if (worktreeMatchesHost(worktree, hostId, matchOptions)) { - knownIds.add(worktree.id) - } - } - } else { - for (const worktree of state.worktreesByRepo[repoId] ?? []) { - if (worktreeMatchesHost(worktree, hostId, matchOptions)) { - knownIds.add(worktree.id) - } - } - } - if (!state.hasHydratedWorktreePurge && matchOptions.unhostedWorktreesMatchHost === true) { - // Why (#1158): hydration can preserve tab keys before worktree metadata exists; the first authoritative scan must still reap deleted session-only keys. - for (const id of getHydratedSessionWorktreeIdsForRepo(state, repoId)) { - knownIds.add(id) - } - } - return [...knownIds] -} - -export function getRemovedWorktreeIdsAfterAuthoritativeScan( - state: AppState, - repoId: string, - detected: DetectedWorktreeListResult, - hostId: ExecutionHostId -): string[] { - if (!detected.authoritative) { - return [] - } - const detectedIds = new Set(detected.worktrees.map((worktree) => worktree.id)) - return getKnownWorktreeIdsForPurge(state, repoId, hostId).filter((id) => !detectedIds.has(id)) -} - -export function toLegacyDetectedWorktreeResult( - repoId: string, - result: { worktrees: Worktree[] } -): DetectedWorktreeListResult { - return { - repoId, - authoritative: true, - source: 'session-fallback', - worktrees: result.worktrees.map((worktree) => ({ - ...worktree, - ownership: 'orca-managed', - selectedCheckout: false, - visible: true - })) - } -} diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts deleted file mode 100644 index ae691e4a424..00000000000 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-owner-settings.ts +++ /dev/null @@ -1,151 +0,0 @@ -import type { AppState } from '../../../types' -import type { Worktree } from '../../../../../../shared/worktree/types' -import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types' -import { getRepoIdFromWorktreeId } from '../../worktree-helpers' -import { findRepoForHost } from '../../repo-host-identity' -import { - getRepoExecutionHostId, - parseExecutionHostId, - type ExecutionHostId -} from '../../../../../../shared/execution-host' -import { - resolveWorktreeOperationRoute, - settingsForWorktreeOperationRoute -} from '@/lib/worktree-operation-route' -import { WORKTREE_REMOVAL_AMBIGUOUS_ERROR } from './worktree-slice-constants' -import { isRuntimeSelectorNotFoundError } from './runtime-worktree-rpc-errors' -import { persistWorktreeMeta } from '../metadata/worktree-meta-persist' -import type { WorktreeSliceGet } from './worktree-slice-types' - -export function replaceWorktreeInRepoLists( - worktreesByRepo: Record, - updatedWorktree: Worktree -): Record { - const repoId = getRepoIdFromWorktreeId(updatedWorktree.id) - const current = worktreesByRepo[repoId] - if (!current) { - return worktreesByRepo - } - return { - ...worktreesByRepo, - [repoId]: current.map((worktree) => - worktree.id === updatedWorktree.id ? updatedWorktree : worktree - ) - } -} - -export function settingsForRepoOwner( - state: Pick, - repoId: string, - hostId?: ExecutionHostId | null, - honorMissingHostId = false -) { - const repo = findRepoForHost(state.repos, repoId, { hostId, settings: state.settings }) - if (repo) { - return settingsForKnownRepoOwner(state.settings, repo) - } - const parsedHost = honorMissingHostId && hostId ? parseExecutionHostId(hostId) : null - if (parsedHost?.kind === 'runtime') { - return state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: parsedHost.environmentId } - : ({ activeRuntimeEnvironmentId: parsedHost.environmentId } as AppState['settings']) - } - if (parsedHost?.kind === 'local' || parsedHost?.kind === 'ssh') { - return state.settings - ? { ...state.settings, activeRuntimeEnvironmentId: null } - : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) - } - return state.settings -} - -export function settingsForKnownRepoOwner( - settings: AppState['settings'], - repo: { connectionId?: string | null; executionHostId?: ExecutionHostId | null } -) { - if (!repo.executionHostId && !repo.connectionId) { - return settings - } - const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) - if (parsed?.kind === 'runtime') { - return settings - ? { ...settings, activeRuntimeEnvironmentId: parsed.environmentId } - : ({ activeRuntimeEnvironmentId: parsed.environmentId } as AppState['settings']) - } - if (parsed?.kind === 'local' && settings?.activeRuntimeEnvironmentId) { - return { ...settings, activeRuntimeEnvironmentId: null } - } - if (parsed?.kind !== 'ssh') { - return settings - } - // Why: SSH repos are owned by the desktop client/SSH provider, not the focused runtime server. - return settings - ? { ...settings, activeRuntimeEnvironmentId: null } - : ({ activeRuntimeEnvironmentId: null } as AppState['settings']) -} - -export function trySettingsForWorktreeOwner( - state: Pick< - AppState, - | 'repos' - | 'settings' - | 'worktreesByRepo' - | 'detectedWorktreesByRepo' - | 'folderWorkspaces' - | 'projectGroups' - | 'restoredRuntimeHostIdByWorkspaceSessionKey' - | 'runtimeEnvironments' - | 'runtimeEnvironmentCatalogHydrated' - | 'removedRuntimeEnvironmentIds' - >, - worktreeId: string -): AppState['settings'] | null { - const route = resolveWorktreeOperationRoute(state, worktreeId) - if (!route) { - return null - } - return settingsForWorktreeOperationRoute(state.settings, route) -} - -export function settingsForWorktreeOwner( - state: Parameters[0], - worktreeId: string -) { - const settings = trySettingsForWorktreeOwner(state, worktreeId) - if (!settings) { - throw new Error(WORKTREE_REMOVAL_AMBIGUOUS_ERROR) - } - return settings -} - -// Why: activity bumps fire on every PTY event, so an ambiguous workspace would warn continuously. -// One line per workspace is enough to diagnose it (#10634). -export const ambiguousOwnerWarnedWorktreeIds = new Set() - -export function warnAmbiguousOwnerOnce(worktreeId: string, errorLabel: string): void { - if (ambiguousOwnerWarnedWorktreeIds.has(worktreeId)) { - return - } - ambiguousOwnerWarnedWorktreeIds.add(worktreeId) - console.warn(`Skipped ${errorLabel}: workspace identity is ambiguous across hosts`, worktreeId) -} - -export function persistPassiveWorktreeMetaForOwner( - get: WorktreeSliceGet, - worktreeId: string, - updates: Partial, - errorLabel: string -): void { - const ownerSettings = trySettingsForWorktreeOwner(get(), worktreeId) - if (!ownerSettings) { - warnAmbiguousOwnerOnce(worktreeId, errorLabel) - return - } - void persistWorktreeMeta(ownerSettings, worktreeId, updates).catch((err) => { - if (isRuntimeSelectorNotFoundError(err)) { - void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) - return - } - console.error(`Failed to ${errorLabel}:`, err) - void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) - }) -} diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-refresh-pool.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-refresh-pool.ts deleted file mode 100644 index efa50048dee..00000000000 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-refresh-pool.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { WORKTREE_REFRESH_CONCURRENCY } from './worktree-slice-constants' - -export async function mapReposForWorktreeRefresh( - repos: readonly TRepo[], - mapper: (repo: TRepo) => Promise -): Promise { - const results = Array(repos.length) - let nextIndex = 0 - const workerCount = Math.min(WORKTREE_REFRESH_CONCURRENCY, repos.length) - - // Why: refresh can fire during activation/startup; bound repo scans so one UI moment can't launch every git probe at once. - await Promise.all( - Array.from({ length: workerCount }, async () => { - while (nextIndex < repos.length) { - const index = nextIndex - nextIndex += 1 - results[index] = await mapper(repos[index]) - } - }) - ) - - return results -} diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-slice-constants.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-slice-constants.ts deleted file mode 100644 index 2a04ebae503..00000000000 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-slice-constants.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Why: old runtime servers only have `worktree.list`; preserve the large-list UI hydration parity used before `worktree.detectedList` existed. -export const REMOTE_WORKTREE_LIST_PARITY_LIMIT = 10_000 -export const WORKTREE_REMOVAL_AMBIGUOUS_ERROR = - 'Workspace identity is ambiguous across hosts. Refresh projects and try again.' -export const ACTIVE_WORKTREE_TERMINAL_PREP_DELAY_MS = 300 -export const ACTIVE_WORKTREE_TERMINAL_PREP_INPUT_QUIET_MS = 450 -export const ACTIVE_WORKTREE_TERMINAL_PREP_IDLE_TIMEOUT_MS = 180 -export const FOLDER_WORKSPACE_ACTIVITY_PERSIST_INTERVAL_MS = 1_000 -// Why: each repo's `git worktree list` is an independent main-process child; a higher ceiling cuts startup scan batches (#7225) while staying bounded against launching every git probe at once. -export const WORKTREE_REFRESH_CONCURRENCY = 8 -// Why: a mobile-scope web pairing is denied worktree/repo RPCs (else silently empty workspaces); surface one deduped toast (stable id) instead of spamming per-repo. -export const RUNTIME_SCOPE_FORBIDDEN_TOAST_ID = 'runtime-scope-forbidden' -// Why: main retires the persisted SSH metadata a scan proved gone, but that IPC is async and the next fallback -// read can already be in flight, so this session memory covers the window until the delete lands. It is not the -// durable half: reloads start empty and rely on the metadata itself being gone. -export const AUTHORITATIVE_REMOVAL_MEMORY_LIMIT = 512 diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-slice-types.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-slice-types.ts deleted file mode 100644 index a3785cde58d..00000000000 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-slice-types.ts +++ /dev/null @@ -1,97 +0,0 @@ -import type { StateCreator } from 'zustand' -import type { AppState } from '../../../types' -import type { WorktreeSlice } from '../../worktree-helpers' -import type { - DetectedWorktreeListResult, - GitPushTarget, - Worktree -} from '../../../../../../shared/worktree/types' -import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types' -import type { ExecutionHostId } from '../../../../../../shared/execution-host' -import type { DirectSshAuthority } from '../../../../../../shared/ssh-types' -import type { HostQualifiedDetectedWorktreeResult } from '../../../../../../shared/detected-worktree-provider-contract' -import type { ProjectHostSetup } from '../../../../../../shared/project-types' -import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types' -import type { getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' - -export type WorktreeSliceGet = Parameters>[1] -export type WorktreeSliceSet = Parameters>[0] - -export type BackgroundRuntimeRefreshOptions = { - reuseRecentCompatibilityFailure?: boolean -} - -export type DetectedWorktreeRefreshOptions = BackgroundRuntimeRefreshOptions & { - executionHostId: ExecutionHostId - requireAuthoritative?: boolean - directSshAuthority?: DirectSshAuthority - // Why (#10562): the caller's own view of what it is about to purge. Teardown is - // requested per caller, so this must never be shared with a coalesced scan. - connectionId?: string | null - knownWorktreeIds?: readonly string[] -} - -export type AdmittedDetectedWorktreeRefresh = { - status: 'admitted' - result: DetectedWorktreeListResult - providerResult?: HostQualifiedDetectedWorktreeResult - executionHostId: ExecutionHostId - directSshAuthority?: DirectSshAuthority - runtimeAuthority?: { - environmentId: string - connectionGeneration: number - runtimeConnectionGeneration: number - } -} - -export type DetectedWorktreeRefreshOutcome = - | AdmittedDetectedWorktreeRefresh - | { - status: 'not-admitted' - providerResult: HostQualifiedDetectedWorktreeResult - executionHostId: ExecutionHostId - directSshAuthority?: DirectSshAuthority - } - -export type WorktreeWithLineage = Worktree & { - parentWorktreeId?: string | null - childWorktreeIds?: string[] - lineage?: WorktreeLineage | null -} - -export type WorktreeHostMatchOptions = { - unhostedWorktreesMatchHost?: boolean -} - -export type RepoHostSummary = { - count: number - onlyHostId?: ExecutionHostId -} - -export type WorktreeLineageUpdateResult = { - target: ReturnType - lineage: WorktreeLineage | null - updatedRemoteWorktree?: WorktreeWithLineage -} - -export type HostedReviewLinkKey = - | 'linkedPR' - | 'linkedGitLabMR' - | 'linkedBitbucketPR' - | 'linkedAzureDevOpsPR' - | 'linkedGiteaPR' - -export type RuntimeWorktreeMetaUpdates = Omit, 'pushTarget'> & { - pushTarget?: GitPushTarget | null -} - -export type FencedWorktreeMergeArgs = { - repoId: string - hostId: ExecutionHostId - ownerWasMissingAtStart: boolean - missingDirectSshOwnerReposSnapshot?: AppState['repos'] - requestStartedWorktrees: readonly Worktree[] | undefined - setup?: ProjectHostSetup - refresh: AdmittedDetectedWorktreeRefresh - purgeRemovedWorktrees?: boolean -} diff --git a/src/renderer/src/store/slices/worktrees/metadata/detached-head-display-name.ts b/src/renderer/src/store/slices/worktrees/metadata/detached-head-display-name.ts deleted file mode 100644 index db4016b4b6d..00000000000 --- a/src/renderer/src/store/slices/worktrees/metadata/detached-head-display-name.ts +++ /dev/null @@ -1,18 +0,0 @@ -export const detachedHeadAutoDerivedDisplayNames = new Map() - -export function setDetachedHeadAutoDerivedDisplayNameForTests( - worktreeId: string, - displayName: string -): void { - detachedHeadAutoDerivedDisplayNames.set(worktreeId, displayName) -} - -export function getDetachedHeadAutoDerivedDisplayNameForTests( - worktreeId: string -): string | undefined { - return detachedHeadAutoDerivedDisplayNames.get(worktreeId) -} - -export function forgetDetachedHeadAutoDerivedDisplayName(worktreeId: string): void { - detachedHeadAutoDerivedDisplayNames.delete(worktreeId) -} diff --git a/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts b/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts deleted file mode 100644 index 6923737f0dc..00000000000 --- a/src/renderer/src/store/slices/worktrees/metadata/hosted-review-link-mutation.ts +++ /dev/null @@ -1,321 +0,0 @@ -import type { StateCreator } from 'zustand' -import type { AppState } from '../../../types' -import type { WorktreeSlice } from '../../worktree-helpers' -import { applyWorktreeUpdates } from '../../worktree-helpers' -import { isPositiveHostedReviewNumber } from '../../../../../../shared/hosted-review' -import { branchName } from '@/lib/git-utils' -import type { Worktree } from '../../../../../../shared/worktree/types' -import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types' -import { applyDetectedWorktreeUpdates } from '../listing/detected-worktree-meta' -import type { - HostedReviewLinkKey, - RuntimeWorktreeMetaUpdates -} from '../listing/worktree-slice-types' - -export const HOSTED_REVIEW_LINK_KEYS: readonly HostedReviewLinkKey[] = [ - 'linkedPR', - 'linkedGitLabMR', - 'linkedBitbucketPR', - 'linkedAzureDevOpsPR', - 'linkedGiteaPR' -] - -export const CLEARED_HOSTED_REVIEW_LINK_UPDATES: Pick< - WorktreeMeta, - HostedReviewLinkKey | 'pushTarget' -> = { - linkedPR: null, - linkedGitLabMR: null, - linkedBitbucketPR: null, - linkedAzureDevOpsPR: null, - linkedGiteaPR: null, - pushTarget: undefined -} - -export const hostedReviewLinkMutationGenerationByWorktreeId = new Map() -export const hostedReviewLinkClearTombstonesByWorktreeId = new Map< - string, - { branch: string; branchIdentity: string; generation: number; head?: string } ->() -export const hostedReviewLinkWorktreeIdAliases = new Map() - -export function hasHostedReviewLinks(worktree: Worktree): boolean { - return HOSTED_REVIEW_LINK_KEYS.some((key) => worktree[key] != null) -} - -export function hasBranchScopedHostedReviewContext(worktree: Worktree): boolean { - return hasHostedReviewLinks(worktree) || worktree.pushTarget !== undefined -} - -export function hasHostedReviewLinkUpdates(updates: Partial): boolean { - return HOSTED_REVIEW_LINK_KEYS.some((key) => key in updates) || 'pushTarget' in updates -} - -export function getHostedReviewLinkMutationGeneration(worktreeId: string): number { - return hostedReviewLinkMutationGenerationByWorktreeId.get(worktreeId) ?? 0 -} - -export function bumpHostedReviewLinkMutationGeneration(worktreeId: string): void { - hostedReviewLinkMutationGenerationByWorktreeId.set( - worktreeId, - getHostedReviewLinkMutationGeneration(worktreeId) + 1 - ) - hostedReviewLinkClearTombstonesByWorktreeId.delete(worktreeId) - pruneHostedReviewLinkWorktreeAliasesForId(worktreeId) -} - -export function pruneHostedReviewLinkMutationGenerations(worktreeIds: Iterable): void { - for (const worktreeId of worktreeIds) { - hostedReviewLinkMutationGenerationByWorktreeId.delete(worktreeId) - hostedReviewLinkClearTombstonesByWorktreeId.delete(worktreeId) - hostedReviewLinkWorktreeIdAliases.delete(worktreeId) - for (const [oldWorktreeId, newWorktreeId] of hostedReviewLinkWorktreeIdAliases) { - if (newWorktreeId === worktreeId) { - hostedReviewLinkWorktreeIdAliases.delete(oldWorktreeId) - } - } - } -} - -export function resolveHostedReviewLinkWorktreeId(worktreeId: string): string { - let current = worktreeId - const seen = new Set() - while (!seen.has(current)) { - seen.add(current) - const next = hostedReviewLinkWorktreeIdAliases.get(current) - if (!next) { - return current - } - current = next - } - return worktreeId -} - -export function pruneHostedReviewLinkWorktreeAliasesForId(worktreeId: string): void { - for (const [alias, target] of Array.from(hostedReviewLinkWorktreeIdAliases)) { - if ( - alias === worktreeId || - target === worktreeId || - resolveHostedReviewLinkWorktreeId(alias) === worktreeId - ) { - hostedReviewLinkWorktreeIdAliases.delete(alias) - } - } -} - -export function migrateHostedReviewLinkMutationGeneration( - oldWorktreeId: string, - newWorktreeId: string -): void { - const tombstone = hostedReviewLinkClearTombstonesByWorktreeId.get(oldWorktreeId) - for (const [alias, target] of hostedReviewLinkWorktreeIdAliases) { - if (target === oldWorktreeId) { - if (tombstone) { - hostedReviewLinkWorktreeIdAliases.set(alias, newWorktreeId) - } else { - hostedReviewLinkWorktreeIdAliases.delete(alias) - } - } - } - const hasGeneration = hostedReviewLinkMutationGenerationByWorktreeId.has(oldWorktreeId) - if (tombstone) { - hostedReviewLinkWorktreeIdAliases.set(oldWorktreeId, newWorktreeId) - } - if (hasGeneration) { - hostedReviewLinkMutationGenerationByWorktreeId.set( - newWorktreeId, - getHostedReviewLinkMutationGeneration(oldWorktreeId) - ) - hostedReviewLinkMutationGenerationByWorktreeId.delete(oldWorktreeId) - } - if (tombstone) { - hostedReviewLinkClearTombstonesByWorktreeId.set(newWorktreeId, tombstone) - hostedReviewLinkClearTombstonesByWorktreeId.delete(oldWorktreeId) - } -} - -export function getHostedReviewLinkMutationGenerationForTests(worktreeId: string): number { - return getHostedReviewLinkMutationGeneration(worktreeId) -} - -export function getHostedReviewLinkWorktreeAliasCountForTests(): number { - return hostedReviewLinkWorktreeIdAliases.size -} - -export function resetHostedReviewLinkMutationGenerationForTests(): void { - hostedReviewLinkMutationGenerationByWorktreeId.clear() - hostedReviewLinkClearTombstonesByWorktreeId.clear() - hostedReviewLinkWorktreeIdAliases.clear() -} - -export function hostedReviewLinksAreCleared(worktree: Worktree): boolean { - return HOSTED_REVIEW_LINK_KEYS.every((key) => worktree[key] == null) && !worktree.pushTarget -} - -export function getHostedReviewLinkUpdates( - worktree: Worktree -): Pick { - return { - linkedPR: worktree.linkedPR ?? null, - linkedGitLabMR: worktree.linkedGitLabMR ?? null, - linkedBitbucketPR: worktree.linkedBitbucketPR ?? null, - linkedAzureDevOpsPR: worktree.linkedAzureDevOpsPR ?? null, - linkedGiteaPR: worktree.linkedGiteaPR ?? null, - pushTarget: worktree.pushTarget - } -} - -export function canonicalHostedReviewBranchIdentity(branch: string): string { - return branchName(branch).trim() -} - -export function rememberHostedReviewLinkClear( - worktreeId: string, - branch: string, - generation: number, - head?: string -): void { - hostedReviewLinkClearTombstonesByWorktreeId.set(worktreeId, { - branch, - branchIdentity: canonicalHostedReviewBranchIdentity(branch), - generation, - head - }) -} - -export function sanitizeHostedReviewLinksForBranchClear< - T extends Pick & - Partial> ->(worktree: T, currentWorktrees?: readonly T[]): T { - const hostedReviewWorktreeId = resolveHostedReviewLinkWorktreeId(worktree.id) - const tombstone = hostedReviewLinkClearTombstonesByWorktreeId.get(hostedReviewWorktreeId) - const hasBranchScopedContext = - HOSTED_REVIEW_LINK_KEYS.some((key) => worktree[key] != null) || - worktree.pushTarget !== undefined - if ( - !tombstone || - tombstone.generation !== getHostedReviewLinkMutationGeneration(hostedReviewWorktreeId) || - !hasBranchScopedContext - ) { - return worktree - } - const current = currentWorktrees?.find( - (entry) => - entry.id === worktree.id || - resolveHostedReviewLinkWorktreeId(entry.id) === hostedReviewWorktreeId - ) - const currentClean = - current && - !HOSTED_REVIEW_LINK_KEYS.some((key) => current[key] != null) && - current.pushTarget === undefined - ? current - : null - const guardBranch = currentClean ? currentClean.branch : tombstone.branch - const guardHead = currentClean ? currentClean.head : tombstone.head - return { - ...worktree, - branch: guardBranch, - ...(guardHead !== undefined ? { head: guardHead } : {}), - ...CLEARED_HOSTED_REVIEW_LINK_UPDATES - } -} - -export function sanitizeHostedReviewLinksForBranchClears< - T extends Pick & - Partial> ->(worktrees: readonly T[], currentWorktrees?: readonly T[]): T[] { - let changed = false - const sanitized = worktrees.map((worktree) => { - const next = sanitizeHostedReviewLinksForBranchClear(worktree, currentWorktrees) - if (next !== worktree) { - changed = true - } - return next - }) - return changed ? sanitized : [...worktrees] -} - -export function applyHostedReviewLinkClear( - set: Parameters>[0], - worktreeId: string -): void { - set((s) => { - const nextWorktrees = applyWorktreeUpdates( - s.worktreesByRepo, - worktreeId, - CLEARED_HOSTED_REVIEW_LINK_UPDATES - ) - const nextDetectedWorktrees = applyDetectedWorktreeUpdates( - s.detectedWorktreesByRepo, - worktreeId, - CLEARED_HOSTED_REVIEW_LINK_UPDATES - ) - if ( - nextWorktrees === s.worktreesByRepo && - nextDetectedWorktrees === s.detectedWorktreesByRepo - ) { - return {} - } - return { - ...(nextWorktrees !== s.worktreesByRepo - ? { worktreesByRepo: nextWorktrees, sortEpoch: s.sortEpoch + 1 } - : {}), - ...(nextDetectedWorktrees !== s.detectedWorktreesByRepo - ? { detectedWorktreesByRepo: nextDetectedWorktrees } - : {}) - } - }) -} - -export function getPositiveHostedReviewLinkUpdateKey( - updates: Partial -): HostedReviewLinkKey | null { - for (const key of HOSTED_REVIEW_LINK_KEYS) { - if (isPositiveHostedReviewNumber(updates[key])) { - return key - } - } - return null -} - -export function clearOlderHostedReviewLinksForReplacement( - updates: Partial, - existingWorktree: Worktree -): Partial { - const replacementKey = getPositiveHostedReviewLinkUpdateKey(updates) - if (!replacementKey) { - return updates - } - let normalized = updates - for (const key of HOSTED_REVIEW_LINK_KEYS) { - if (key === replacementKey || existingWorktree[key] == null) { - continue - } - // Why: one branch pushes to one hosted-review head; stale provider links would win the target lookup after replacement. - normalized = normalized === updates ? { ...updates } : normalized - normalized[key] = null - } - return normalized -} - -export function getHostedReviewLinkForMetaRefresh( - updates: Partial, - existingWorktree: Worktree | undefined, - key: HostedReviewLinkKey -): number | null { - return Object.hasOwn(updates, key) ? (updates[key] ?? null) : (existingWorktree?.[key] ?? null) -} - -export function hasExplicitPushTargetClear(updates: Partial): boolean { - return Object.hasOwn(updates, 'pushTarget') && updates.pushTarget === undefined -} - -export function encodePushTargetClearForRuntimeRpc( - updates: Partial -): RuntimeWorktreeMetaUpdates { - if (!hasExplicitPushTargetClear(updates)) { - return updates - } - // Why: remote runtime RPC is JSON-shaped and drops undefined, so null is the wire signal for clearing persisted pushTarget metadata. - return { ...updates, pushTarget: null } -} diff --git a/src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target-ensure.ts b/src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target-ensure.ts deleted file mode 100644 index 3c35cd36273..00000000000 --- a/src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target-ensure.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import { - getHostedReviewPushTargetLookup, - hostedReviewPushTargetLookupsInFlight -} from './hosted-review-push-target' -import { settingsForWorktreeOwner } from '../listing/worktree-owner-settings' - -export function createEnsureHostedReviewPushTarget( - _set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['ensureHostedReviewPushTarget'] { - return async (worktreeId) => { - const worktree = get().getKnownWorktreeById(worktreeId) - if (!worktree || worktree.pushTarget) { - return - } - const lookup = getHostedReviewPushTargetLookup(worktree) - if (!lookup || hostedReviewPushTargetLookupsInFlight.has(lookup.key)) { - return - } - hostedReviewPushTargetLookupsInFlight.add(lookup.key) - try { - const resolvedPushTarget = await lookup.resolve(settingsForWorktreeOwner(get(), worktreeId)) - if (!resolvedPushTarget) { - return - } - const current = get().getKnownWorktreeById(worktreeId) - if (!current || current.pushTarget) { - return - } - const currentLookup = getHostedReviewPushTargetLookup(current) - if (currentLookup?.key !== lookup.key) { - return - } - // Why: restore the review head push target so push/status stay aligned after metadata loss. - await get().updateWorktreeMeta(worktreeId, { pushTarget: resolvedPushTarget }) - } finally { - hostedReviewPushTargetLookupsInFlight.delete(lookup.key) - } - } -} diff --git a/src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target.ts b/src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target.ts deleted file mode 100644 index 4aec39272d1..00000000000 --- a/src/renderer/src/store/slices/worktrees/metadata/hosted-review-push-target.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' -import { isPositiveHostedReviewNumber } from '../../../../../../shared/hosted-review' -import type { AppState } from '../../../types' -import type { - GitHubPrStartPoint, - GitPushTarget, - Worktree -} from '../../../../../../shared/worktree/types' - -export const hostedReviewPushTargetLookupsInFlight = new Set() - -export async function resolveGitHubReviewPushTarget( - settings: AppState['settings'], - repoId: string, - prNumber: number -): Promise { - try { - const target = getActiveRuntimeTarget(settings) - const result = - target.kind === 'local' - ? await window.api.worktrees.resolvePrBase({ repoId, prNumber }) - : await callRuntimeRpc( - target, - 'worktree.resolvePrBase', - { repo: repoId, prNumber }, - { timeoutMs: 30_000 } - ) - if ('error' in result) { - console.warn(`Failed to resolve push target for PR #${prNumber}: ${result.error}`) - return undefined - } - return result.pushTarget - } catch (error) { - console.warn( - `Failed to resolve push target for PR #${prNumber}:`, - error instanceof Error ? error.message : error - ) - return undefined - } -} - -export async function resolveGitLabReviewPushTarget( - settings: AppState['settings'], - repoId: string, - mrIid: number -): Promise { - try { - const target = getActiveRuntimeTarget(settings) - const result = - target.kind === 'local' - ? await window.api.worktrees.resolveMrBase({ repoId, mrIid }) - : await callRuntimeRpc< - | { baseBranch: string; compareBaseRef?: string; pushTarget?: GitPushTarget } - | { - error: string - } - >(target, 'worktree.resolveMrBase', { repo: repoId, mrIid }, { timeoutMs: 30_000 }) - if ('error' in result) { - console.warn(`Failed to resolve push target for MR !${mrIid}: ${result.error}`) - return undefined - } - return result.pushTarget - } catch (error) { - console.warn( - `Failed to resolve push target for MR !${mrIid}:`, - error instanceof Error ? error.message : error - ) - return undefined - } -} - -export function getHostedReviewPushTargetLookup(worktree: Worktree): { - key: string - resolve: (settings: AppState['settings']) => Promise -} | null { - const hostScope = worktree.hostId ?? '' - if (isPositiveHostedReviewNumber(worktree.linkedPR)) { - const prNumber = worktree.linkedPR - return { - key: `${worktree.id}:${hostScope}:github:${prNumber}`, - resolve: (settings) => resolveGitHubReviewPushTarget(settings, worktree.repoId, prNumber) - } - } - if (isPositiveHostedReviewNumber(worktree.linkedGitLabMR)) { - const mrIid = worktree.linkedGitLabMR - return { - key: `${worktree.id}:${hostScope}:gitlab:${mrIid}`, - resolve: (settings) => resolveGitLabReviewPushTarget(settings, worktree.repoId, mrIid) - } - } - return null -} diff --git a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts b/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts deleted file mode 100644 index caa90a8f7f3..00000000000 --- a/src/renderer/src/store/slices/worktrees/metadata/update-worktree-meta.ts +++ /dev/null @@ -1,283 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import { translate } from '@/i18n/i18n' -import { isPositiveHostedReviewNumber } from '../../../../../../shared/hosted-review' -import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope' -import { applyWorktreeUpdates, getRepoIdFromWorktreeId } from '../../worktree-helpers' -import { getHostedReviewCacheKey } from '../../hosted-review' -import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../github-cache-key' -import { - applyDetectedWorktreeUpdates, - findKnownWorktreeById, - getFolderWorkspaceMetaUpdates -} from '../listing/detected-worktree-meta' -import { - bumpHostedReviewLinkMutationGeneration, - clearOlderHostedReviewLinksForReplacement, - getHostedReviewLinkForMetaRefresh, - hasHostedReviewLinkUpdates -} from './hosted-review-link-mutation' -import { - getHostedReviewPushTargetLookup, - resolveGitHubReviewPushTarget -} from './hosted-review-push-target' -import { persistWorktreeMeta } from './worktree-meta-persist' -import { isRuntimeSelectorNotFoundError } from '../listing/runtime-worktree-rpc-errors' -import { settingsForWorktreeOwner } from '../listing/worktree-owner-settings' - -export function createUpdateWorktreeMeta( - set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['updateWorktreeMeta'] { - return async (worktreeId, updates, options) => { - const shouldApplyUpdate = options?.shouldApply - const existingWorktree = get().getKnownWorktreeById(worktreeId) - if (shouldApplyUpdate && !shouldApplyUpdate(existingWorktree)) { - return { ok: true } - } - const workspaceScope = parseWorkspaceKey(worktreeId) - if (workspaceScope?.type === 'folder') { - const folderUpdates = getFolderWorkspaceMetaUpdates(updates) - if (Object.keys(folderUpdates).length === 0) { - return { ok: true } - } - try { - // Why: a rejected folder update reconciles the optimistic write away, so - // reporting ok would show the dialog a save that silently undid itself. - const updated = await get().updateFolderWorkspace( - workspaceScope.folderWorkspaceId, - folderUpdates - ) - return updated - ? { ok: true } - : { - ok: false, - error: translate( - 'auto.store.slices.worktrees.a17f4d2e93', - 'Could not update this workspace.' - ) - } - } catch (err) { - console.error('Failed to update folder workspace meta:', err) - return { ok: false, error: err instanceof Error ? err.message : String(err) } - } - } - const normalizedUpdates = existingWorktree - ? clearOlderHostedReviewLinksForReplacement(updates, existingWorktree) - : updates - // Why: manual PR linking supplies only the number; resolve the head branch so Push targets the review branch. - const linkedPrForPushTarget = isPositiveHostedReviewNumber(normalizedUpdates.linkedPR) - ? normalizedUpdates.linkedPR - : null - const resolvedPushTarget = - linkedPrForPushTarget !== null && - normalizedUpdates.pushTarget === undefined && - existingWorktree && - !existingWorktree.pushTarget - ? await resolveGitHubReviewPushTarget( - settingsForWorktreeOwner(get(), worktreeId), - existingWorktree.repoId, - linkedPrForPushTarget - ) - : undefined - const existingHostedReviewPushTargetLookup = existingWorktree - ? getHostedReviewPushTargetLookup(existingWorktree) - : null - const nextHostedReviewPushTargetLookup = existingWorktree - ? getHostedReviewPushTargetLookup({ ...existingWorktree, ...normalizedUpdates }) - : null - // Why: a pushTarget derived from a linked review must not keep steering pushes after it's unlinked or replaced. - const shouldClearStaleHostedReviewPushTarget = - Boolean(existingWorktree?.pushTarget) && - normalizedUpdates.pushTarget === undefined && - resolvedPushTarget === undefined && - existingHostedReviewPushTargetLookup !== null && - existingHostedReviewPushTargetLookup.key !== nextHostedReviewPushTargetLookup?.key - const worktreeForUpdate = get().getKnownWorktreeById(worktreeId) - if (shouldApplyUpdate && !shouldApplyUpdate(worktreeForUpdate)) { - return { ok: true } - } - const shouldRefreshHostedReview = - (normalizedUpdates.linkedPR === null && worktreeForUpdate?.linkedPR !== null) || - (normalizedUpdates.linkedGitLabMR === null && - (worktreeForUpdate?.linkedGitLabMR ?? null) !== null) || - (normalizedUpdates.linkedBitbucketPR === null && - (worktreeForUpdate?.linkedBitbucketPR ?? null) !== null) || - (normalizedUpdates.linkedAzureDevOpsPR === null && - (worktreeForUpdate?.linkedAzureDevOpsPR ?? null) !== null) || - (normalizedUpdates.linkedGiteaPR === null && - (worktreeForUpdate?.linkedGiteaPR ?? null) !== null) - const reviewRepo = shouldRefreshHostedReview - ? get().repos.find((repo) => repo.id === worktreeForUpdate?.repoId) - : undefined - const reviewBranch = worktreeForUpdate?.branch.replace(/^refs\/heads\//, '') - - // Why: bump lastActivityAt on comment edits so the time-decay sort doesn't drop a just-touched worktree. - const targetEnriched = resolvedPushTarget - ? { ...normalizedUpdates, pushTarget: resolvedPushTarget } - : shouldClearStaleHostedReviewPushTarget - ? { ...normalizedUpdates, pushTarget: undefined } - : normalizedUpdates - const renameCleared = - 'displayName' in targetEnriched - ? { - ...targetEnriched, - pendingFirstAgentMessageRename: false, - firstAgentMessageRenameError: null - } - : targetEnriched - const enriched = - 'comment' in renameCleared ? { ...renameCleared, lastActivityAt: Date.now() } : renameCleared - - let didApply = false - set((s) => { - if (shouldApplyUpdate && !shouldApplyUpdate(findKnownWorktreeById(s, worktreeId))) { - return {} - } - didApply = true - const nextWorktrees = applyWorktreeUpdates(s.worktreesByRepo, worktreeId, enriched) - const nextDetectedWorktrees = applyDetectedWorktreeUpdates( - s.detectedWorktreesByRepo, - worktreeId, - enriched - ) - const cacheKey = - reviewRepo && reviewBranch - ? getHostedReviewCacheKey( - reviewRepo.path, - reviewBranch, - s.settings, - reviewRepo.id, - reviewRepo.connectionId, - reviewRepo.executionHostId, - true - ) - : null - const prCacheKey = - reviewRepo && reviewBranch - ? getGitHubPRCacheKey( - reviewRepo.path, - reviewRepo.id, - reviewBranch, - s.settings, - reviewRepo.connectionId, - reviewRepo.executionHostId, - true - ) - : null - const prCacheKeys = - reviewRepo && reviewBranch - ? [ - prCacheKey, - getLegacyGitHubPRCacheKey(reviewRepo.path, reviewRepo.id, reviewBranch), - getLegacyGitHubPRCacheKey(reviewRepo.path, undefined, reviewBranch) - ].filter((key): key is string => Boolean(key)) - : [] - const hostedReviewCache = s.hostedReviewCache ?? {} - const prCache = s.prCache ?? {} - if ( - nextWorktrees === s.worktreesByRepo && - nextDetectedWorktrees === s.detectedWorktreesByRepo && - !cacheKey && - !prCacheKey - ) { - return {} - } - - const nextHostedReviewCache = - cacheKey && hostedReviewCache[cacheKey] - ? (() => { - const next = { ...hostedReviewCache } - delete next[cacheKey] - return next - })() - : hostedReviewCache - const nextPRCache = prCacheKeys.some((key) => prCache[key]) - ? (() => { - const next = { ...prCache } - for (const key of prCacheKeys) { - delete next[key] - } - return next - })() - : prCache - - return { - ...(nextWorktrees !== s.worktreesByRepo - ? { worktreesByRepo: nextWorktrees, sortEpoch: s.sortEpoch + 1 } - : {}), - ...(nextDetectedWorktrees !== s.detectedWorktreesByRepo - ? { detectedWorktreesByRepo: nextDetectedWorktrees } - : {}), - ...(nextHostedReviewCache !== hostedReviewCache - ? { hostedReviewCache: nextHostedReviewCache } - : {}), - ...(nextPRCache !== prCache ? { prCache: nextPRCache } : {}) - } - }) - if (shouldApplyUpdate && !didApply) { - return { ok: true } - } - if (hasHostedReviewLinkUpdates(enriched)) { - bumpHostedReviewLinkMutationGeneration(worktreeId) - } - - try { - await persistWorktreeMeta(settingsForWorktreeOwner(get(), worktreeId), worktreeId, enriched) - if ( - !options?.suppressHostedReviewRefresh && - reviewRepo && - reviewBranch && - typeof get().fetchHostedReviewForBranch === 'function' - ) { - // Why: refetch against post-update links so a cache entry from the previous provider link can't keep showing the removed review. - void get().fetchHostedReviewForBranch(reviewRepo.path, reviewBranch, { - repoId: reviewRepo.id, - linkedGitHubPR: getHostedReviewLinkForMetaRefresh( - targetEnriched, - worktreeForUpdate, - 'linkedPR' - ), - linkedGitLabMR: getHostedReviewLinkForMetaRefresh( - targetEnriched, - worktreeForUpdate, - 'linkedGitLabMR' - ), - linkedBitbucketPR: getHostedReviewLinkForMetaRefresh( - targetEnriched, - worktreeForUpdate, - 'linkedBitbucketPR' - ), - linkedAzureDevOpsPR: getHostedReviewLinkForMetaRefresh( - targetEnriched, - worktreeForUpdate, - 'linkedAzureDevOpsPR' - ), - linkedGiteaPR: getHostedReviewLinkForMetaRefresh( - targetEnriched, - worktreeForUpdate, - 'linkedGiteaPR' - ), - force: true - }) - } - } catch (err) { - if (isRuntimeSelectorNotFoundError(err)) { - void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) - return { - ok: false, - error: translate( - 'auto.store.slices.worktrees.c6cf133786', - 'This workspace is no longer available.' - ) - } - } - console.error('Failed to update worktree meta:', err) - void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) - // Why: the refetch above reverts the optimistic write, so a caller that - // closes its surface on this path shows the user a save that undid itself. - return { ok: false, error: err instanceof Error ? err.message : String(err) } - } - return { ok: true } - } -} diff --git a/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts b/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts deleted file mode 100644 index 33f4a224bdd..00000000000 --- a/src/renderer/src/store/slices/worktrees/metadata/update-worktrees-meta.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import { applyWorktreeUpdates, getRepoIdFromWorktreeId } from '../../worktree-helpers' -import { applyDetectedWorktreeUpdates } from '../listing/detected-worktree-meta' -import { persistWorktreeMeta } from './worktree-meta-persist' -import { isRuntimeSelectorNotFoundError } from '../listing/runtime-worktree-rpc-errors' -import { settingsForWorktreeOwner } from '../listing/worktree-owner-settings' - -export function createUpdateWorktreesMeta( - set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['updateWorktreesMeta'] { - return async (updatesByWorktreeId) => { - if (updatesByWorktreeId.size === 0) { - return - } - - set((s) => { - let nextWorktrees = s.worktreesByRepo - let nextDetectedWorktrees = s.detectedWorktreesByRepo - for (const [worktreeId, updates] of updatesByWorktreeId) { - nextWorktrees = applyWorktreeUpdates(nextWorktrees, worktreeId, updates) - nextDetectedWorktrees = applyDetectedWorktreeUpdates( - nextDetectedWorktrees, - worktreeId, - updates - ) - } - return nextWorktrees === s.worktreesByRepo && - nextDetectedWorktrees === s.detectedWorktreesByRepo - ? {} - : { - ...(nextWorktrees !== s.worktreesByRepo - ? { worktreesByRepo: nextWorktrees, sortEpoch: s.sortEpoch + 1 } - : {}), - ...(nextDetectedWorktrees !== s.detectedWorktreesByRepo - ? { detectedWorktreesByRepo: nextDetectedWorktrees } - : {}) - } - }) - - await Promise.all( - Array.from(updatesByWorktreeId, async ([worktreeId, updates]) => { - try { - await persistWorktreeMeta( - settingsForWorktreeOwner(get(), worktreeId), - worktreeId, - updates - ) - } catch (err) { - if (isRuntimeSelectorNotFoundError(err)) { - void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) - return - } - console.error('Failed to update worktree meta:', err) - void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) - } - }) - ) - } -} diff --git a/src/renderer/src/store/slices/worktrees/metadata/worktree-base-status.ts b/src/renderer/src/store/slices/worktrees/metadata/worktree-base-status.ts deleted file mode 100644 index 5ae92f4eed5..00000000000 --- a/src/renderer/src/store/slices/worktrees/metadata/worktree-base-status.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -export function createUpdateWorktreeBaseStatus( - set: WorktreeSliceSet, - _get: WorktreeSliceGet -): WorktreeSlice['updateWorktreeBaseStatus'] { - return (event) => { - set((s) => ({ - baseStatusByWorktreeId: { - ...s.baseStatusByWorktreeId, - [event.worktreeId]: event - } - })) - } -} - -export function createUpdateWorktreeRemoteBranchConflict( - set: WorktreeSliceSet, - _get: WorktreeSliceGet -): WorktreeSlice['updateWorktreeRemoteBranchConflict'] { - return (event) => { - set((s) => ({ - remoteBranchConflictByWorktreeId: { - ...s.remoteBranchConflictByWorktreeId, - [event.worktreeId]: event - } - })) - } -} diff --git a/src/renderer/src/store/slices/worktrees/metadata/worktree-git-identity-update.ts b/src/renderer/src/store/slices/worktrees/metadata/worktree-git-identity-update.ts deleted file mode 100644 index b4e80783739..00000000000 --- a/src/renderer/src/store/slices/worktrees/metadata/worktree-git-identity-update.ts +++ /dev/null @@ -1,193 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import type { Worktree } from '../../../../../../shared/worktree/types' -import { getRepoIdFromWorktreeId } from '../../worktree-helpers' -import { branchName } from '@/lib/git-utils' -import { detachedHeadAutoDerivedDisplayNames } from './detached-head-display-name' -import { - applyHostedReviewLinkClear, - canonicalHostedReviewBranchIdentity, - CLEARED_HOSTED_REVIEW_LINK_UPDATES, - getHostedReviewLinkMutationGeneration, - getHostedReviewLinkUpdates, - hasBranchScopedHostedReviewContext, - hostedReviewLinkClearTombstonesByWorktreeId, - hostedReviewLinksAreCleared, - rememberHostedReviewLinkClear, - resolveHostedReviewLinkWorktreeId -} from './hosted-review-link-mutation' -import { persistWorktreeMeta } from './worktree-meta-persist' -import { isRuntimeSelectorNotFoundError } from '../listing/runtime-worktree-rpc-errors' -import { settingsForWorktreeOwner } from '../listing/worktree-owner-settings' - -export function createUpdateWorktreeGitIdentity( - set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['updateWorktreeGitIdentity'] { - return (worktreeId, identity) => { - let shouldPersistHostedReviewClear = false - let clearedBranch: string | null = null - let clearGeneration = getHostedReviewLinkMutationGeneration(worktreeId) - const repoId = getRepoIdFromWorktreeId(worktreeId) - const existing = get().worktreesByRepo[repoId]?.find((worktree) => worktree.id === worktreeId) - if (!existing) { - return - } - const expectedHead = identity.head ?? existing.head - const expectedBranch = identity.branch === null ? '' : (identity.branch ?? existing.branch) - if (expectedHead === existing.head && expectedBranch === existing.branch) { - return - } - - set((s) => { - const current = s.worktreesByRepo[repoId] - if (!current) { - return s - } - - let changed = false - const next = current.map((worktree) => { - if (worktree.id !== worktreeId) { - return worktree - } - const nextHead = identity.head ?? worktree.head - const nextBranch = identity.branch === null ? '' : (identity.branch ?? worktree.branch) - if (nextHead === worktree.head && nextBranch === worktree.branch) { - return worktree - } - changed = true - const hostedReviewBranchChanged = - canonicalHostedReviewBranchIdentity(nextBranch) !== - canonicalHostedReviewBranchIdentity(worktree.branch) - const shouldClearHostedReviewContext = - hostedReviewBranchChanged && hasBranchScopedHostedReviewContext(worktree) - if (shouldClearHostedReviewContext) { - shouldPersistHostedReviewClear = true - clearedBranch = nextBranch - clearGeneration = getHostedReviewLinkMutationGeneration(worktreeId) - rememberHostedReviewLinkClear(worktreeId, nextBranch, clearGeneration, nextHead) - } else { - const tombstone = hostedReviewLinkClearTombstonesByWorktreeId.get(worktreeId) - if (tombstone) { - const nextBranchIdentity = canonicalHostedReviewBranchIdentity(nextBranch) - hostedReviewLinkClearTombstonesByWorktreeId.set(worktreeId, { - ...tombstone, - branch: nextBranch, - branchIdentity: nextBranchIdentity, - head: nextHead - }) - if (hostedReviewBranchChanged) { - shouldPersistHostedReviewClear = true - clearedBranch = nextBranch - clearGeneration = tombstone.generation - } - } - } - // Why: terminal branch switches only patch branch/head here; re-derive auto titles like full listing does. - const currentBranchName = branchName(worktree.branch) - const wasAutoDerived = worktree.displayName === currentBranchName - const wasDetachedAutoDerived = - worktree.branch === '' && - nextBranch !== '' && - detachedHeadAutoDerivedDisplayNames.get(worktreeId) === worktree.displayName - const nextDisplayName = - (wasAutoDerived || wasDetachedAutoDerived) && nextBranch - ? branchName(nextBranch) - : worktree.displayName - if (identity.branch === null && wasAutoDerived) { - detachedHeadAutoDerivedDisplayNames.set(worktreeId, worktree.displayName) - } else if (identity.branch !== undefined) { - detachedHeadAutoDerivedDisplayNames.delete(worktreeId) - } - return { - ...worktree, - head: nextHead, - branch: nextBranch, - displayName: nextDisplayName, - // Why: linked reviews are branch-scoped; keeping the old link on a branch switch would refresh the old PR. - ...(shouldClearHostedReviewContext ? CLEARED_HOSTED_REVIEW_LINK_UPDATES : {}) - } - }) - - if (!changed) { - return s - } - - return { - worktreesByRepo: { ...s.worktreesByRepo, [repoId]: next }, - sortEpoch: s.sortEpoch + 1 - } - }) - if (!shouldPersistHostedReviewClear || clearedBranch === null) { - return - } - - void Promise.resolve() - .then(async () => { - let currentWorktreeId = resolveHostedReviewLinkWorktreeId(worktreeId) - const persistedWorktreeIds = new Set() - while (true) { - currentWorktreeId = resolveHostedReviewLinkWorktreeId(currentWorktreeId) - if (persistedWorktreeIds.has(currentWorktreeId)) { - return - } - persistedWorktreeIds.add(currentWorktreeId) - let current = get().getKnownWorktreeById(currentWorktreeId) - if ( - !current || - current.branch !== clearedBranch || - getHostedReviewLinkMutationGeneration(currentWorktreeId) !== clearGeneration - ) { - return - } - if (!hostedReviewLinksAreCleared(current as Worktree)) { - // Why: a refetch can rehydrate stale linked-review metadata before this async clear starts; clear it again. - applyHostedReviewLinkClear(set, currentWorktreeId) - current = get().getKnownWorktreeById(currentWorktreeId) - if (!current || current.branch !== clearedBranch) { - return - } - } - await persistWorktreeMeta( - settingsForWorktreeOwner(get(), currentWorktreeId), - currentWorktreeId, - CLEARED_HOSTED_REVIEW_LINK_UPDATES - ) - const migratedWorktreeId = resolveHostedReviewLinkWorktreeId(currentWorktreeId) - if (migratedWorktreeId === currentWorktreeId) { - break - } - // Why: worktree creation can migrate ids mid-IPC; persist the clear under the new durable id too. - currentWorktreeId = migratedWorktreeId - } - const latest = get().getKnownWorktreeById(currentWorktreeId) - if ( - !latest || - latest.branch !== clearedBranch || - hostedReviewLinksAreCleared(latest as Worktree) - ) { - return - } - if (getHostedReviewLinkMutationGeneration(currentWorktreeId) !== clearGeneration) { - // Why: a delayed branch-switch clear must not win over a newer manual relink. - await persistWorktreeMeta( - settingsForWorktreeOwner(get(), currentWorktreeId), - currentWorktreeId, - getHostedReviewLinkUpdates(latest as Worktree) - ) - return - } - // Why: a refetch can rehydrate old metadata before the branch-switch clear reaches disk; don't write the stale link back. - applyHostedReviewLinkClear(set, currentWorktreeId) - }) - .catch((err) => { - if (isRuntimeSelectorNotFoundError(err)) { - void get().fetchWorktrees( - getRepoIdFromWorktreeId(resolveHostedReviewLinkWorktreeId(worktreeId)) - ) - return - } - console.error('Failed to persist branch-scoped review link clear:', err) - }) - } -} diff --git a/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts b/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts deleted file mode 100644 index cbfe0da9cb5..00000000000 --- a/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-actions.ts +++ /dev/null @@ -1,78 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import { parseExecutionHostId } from '../../../../../../shared/execution-host' -import type { AppState } from '../../../types' -import { - applyWorktreeLineageUpdate, - refreshWorktreeLineageForSettings, - setWorktreeLineageForRuntime -} from './worktree-lineage-refresh' -import { settingsForWorktreeOwner } from '../listing/worktree-owner-settings' - -export function createFetchWorktreeLineage( - set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['fetchWorktreeLineage'] { - return async (options) => { - try { - // Why: lineage is a focused-host refresh; host-merge so other hosts' fetched lineage is preserved. - const ownerSettings = get().settings - const parsedHost = options?.executionHostId - ? parseExecutionHostId(options.executionHostId) - : null - const activeRuntimeEnvironmentId = - parsedHost?.kind === 'runtime' - ? parsedHost.environmentId - : parsedHost || options?.forceLocalOwner - ? null - : ownerSettings?.activeRuntimeEnvironmentId - const settings = ownerSettings - ? { ...ownerSettings, activeRuntimeEnvironmentId } - : ({ activeRuntimeEnvironmentId } as AppState['settings']) - await refreshWorktreeLineageForSettings(settings, set, { - reuseRecentCompatibilityFailure: true - }) - } catch (err) { - console.error('Failed to fetch worktree lineage:', err) - } - } -} - -export function createUpdateWorktreeLineage( - set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['updateWorktreeLineage'] { - return async (worktreeId, args) => { - const ownerSettings = settingsForWorktreeOwner(get(), worktreeId) - try { - applyWorktreeLineageUpdate( - set, - worktreeId, - await setWorktreeLineageForRuntime(ownerSettings, worktreeId, args) - ) - } catch (err) { - console.error('Failed to update worktree lineage:', err) - await refreshWorktreeLineageForSettings(ownerSettings, set) - } - } -} - -export function createAssignWorktreeParent( - set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['assignWorktreeParent'] { - return async (worktreeId, args) => { - const ownerSettings = settingsForWorktreeOwner(get(), worktreeId) - try { - applyWorktreeLineageUpdate( - set, - worktreeId, - await setWorktreeLineageForRuntime(ownerSettings, worktreeId, args) - ) - } catch (err) { - console.error('Failed to assign worktree parent:', err) - await refreshWorktreeLineageForSettings(ownerSettings, set) - throw err - } - } -} diff --git a/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-host-merge.ts b/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-host-merge.ts deleted file mode 100644 index 26470ea043d..00000000000 --- a/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-host-merge.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { AppState } from '../../../types' -import type { - WorkspaceLineage, - WorktreeLineage -} from '../../../../../../shared/worktree/lineage-types' -import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope' -import { - getRepoExecutionHostId, - type ExecutionHostId -} from '../../../../../../shared/execution-host' -import { findWorktreeById, getRepoIdFromWorktreeId } from '../../worktree-helpers' -import { findRepoForHost } from '../../repo-host-identity' -import { reuseEqualRecordMap } from '../../repo-identity-reconcile' - -export function getWorktreeHostId( - state: Pick, - worktreeId: string -): ExecutionHostId | null { - const worktree = findWorktreeById(state.worktreesByRepo, worktreeId) - if (worktree?.hostId) { - return worktree.hostId - } - const repoId = getRepoIdFromWorktreeId(worktreeId) - const detected = state.detectedWorktreesByRepo[repoId]?.worktrees.find( - (entry) => entry.id === worktreeId - ) - if (detected?.hostId) { - return detected.hostId - } - const repo = findRepoForHost(state.repos, repoId, { settings: state.settings }) - return repo ? getRepoExecutionHostId(repo) : null -} - -export function mergeLineageForHost( - state: Pick< - AppState, - 'repos' | 'settings' | 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'worktreeLineageById' - >, - hostId: ExecutionHostId, - lineage: Readonly> -): Readonly> { - const next: Record = {} - for (const [worktreeId, existing] of Object.entries(state.worktreeLineageById)) { - if (getWorktreeHostId(state, worktreeId) !== hostId) { - next[worktreeId] = existing - } - } - for (const [worktreeId, incoming] of Object.entries(lineage)) { - next[worktreeId] = incoming - } - return reuseEqualRecordMap(state.worktreeLineageById, next) -} - -export function mergeWorkspaceLineageForHost( - state: Pick< - AppState, - | 'repos' - | 'settings' - | 'worktreesByRepo' - | 'detectedWorktreesByRepo' - | 'workspaceLineageByChildKey' - >, - hostId: ExecutionHostId, - lineage: Readonly> -): Readonly> { - const next: Record = {} - for (const [childKey, existing] of Object.entries(state.workspaceLineageByChildKey)) { - const childScope = parseWorkspaceKey(existing.childWorkspaceKey) - const childHostId = - childScope?.type === 'worktree' ? getWorktreeHostId(state, childScope.worktreeId) : null - // A focused host refresh can no longer prove unknown-host child rows are current. - if (childScope?.type !== 'worktree' || (childHostId !== null && childHostId !== hostId)) { - next[childKey] = existing - } - } - for (const [childKey, incoming] of Object.entries(lineage)) { - next[childKey] = incoming - } - return reuseEqualRecordMap(state.workspaceLineageByChildKey, next) -} diff --git a/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts b/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts deleted file mode 100644 index 26b68647f70..00000000000 --- a/src/renderer/src/store/slices/worktrees/metadata/worktree-lineage-refresh.ts +++ /dev/null @@ -1,253 +0,0 @@ -import type { StateCreator } from 'zustand' -import type { AppState } from '../../../types' -import type { - WorkspaceLineage, - WorktreeLineage -} from '../../../../../../shared/worktree/lineage-types' -import type { Worktree } from '../../../../../../shared/worktree/types' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' -import { toRuntimeWorktreeSelector } from '../../../../runtime/runtime-worktree-selector' -import { worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope' -import { - getSettingsFocusedExecutionHostId, - type ExecutionHostId -} from '../../../../../../shared/execution-host' -import { getRepoIdFromWorktreeId } from '../../worktree-helpers' -import { replaceWorktreeInRepoLists } from '../listing/worktree-owner-settings' -import { repoHostId, withRepoHostOwnership } from '../listing/worktree-host-ownership' -import { mergeLineageForHost, mergeWorkspaceLineageForHost } from './worktree-lineage-host-merge' -import type { - BackgroundRuntimeRefreshOptions, - WorktreeLineageUpdateResult, - WorktreeWithLineage -} from '../listing/worktree-slice-types' - -export async function listWorktreeLineageForRuntime( - settings: AppState['settings'], - options: BackgroundRuntimeRefreshOptions = {} -): Promise<{ - worktreeLineageById: Readonly> - workspaceLineageByChildKey: Readonly> -}> { - const target = getActiveRuntimeTarget(settings) - type LineageListResponse = { - lineage?: Record - workspaceLineage?: Record - } - const normalizeLineageResponse = (value: Record | LineageListResponse) => - Object.hasOwn(value, 'lineage') || Object.hasOwn(value, 'workspaceLineage') - ? { - worktreeLineageById: (value as LineageListResponse).lineage ?? {}, - workspaceLineageByChildKey: (value as LineageListResponse).workspaceLineage ?? {} - } - : { - worktreeLineageById: value as Record, - workspaceLineageByChildKey: {} - } - if (target.kind === 'local') { - return normalizeLineageResponse(await window.api.worktrees.listLineage()) - } - return normalizeLineageResponse( - await callRuntimeRpc<{ - lineage: Record - workspaceLineage?: Record - }>(target, 'worktree.lineageList', undefined, { - timeoutMs: 15_000, - reuseRecentCompatibilityFailure: options.reuseRecentCompatibilityFailure - }) - ) -} - -export function projectWorktreeLineageToWorkspaceLineage( - worktreeId: string, - lineage: WorktreeLineage | null, - current: Record -): Record { - const childWorkspaceKey = worktreeWorkspaceKey(worktreeId) - const next = { ...current } - if (!lineage) { - delete next[childWorkspaceKey] - return next - } - next[childWorkspaceKey] = { - childWorkspaceKey, - childInstanceId: lineage.worktreeInstanceId, - parentWorkspaceKey: worktreeWorkspaceKey(lineage.parentWorktreeId), - parentInstanceId: lineage.parentWorktreeInstanceId, - origin: lineage.origin, - capture: lineage.capture, - ...(lineage.taskId ? { taskId: lineage.taskId } : {}), - ...(lineage.orchestrationRunId ? { orchestrationRunId: lineage.orchestrationRunId } : {}), - ...(lineage.coordinatorHandle ? { coordinatorHandle: lineage.coordinatorHandle } : {}), - ...(lineage.createdByTerminalHandle - ? { createdByTerminalHandle: lineage.createdByTerminalHandle } - : {}), - createdAt: lineage.createdAt - } - return next -} - -export async function setWorktreeLineageForRuntime( - settings: AppState['settings'], - worktreeId: string, - args: { parentWorktreeId?: string; noParent?: boolean } -): Promise { - const target = getActiveRuntimeTarget(settings) - if (target.kind === 'local') { - return { - target, - lineage: await window.api.worktrees.updateLineage({ worktreeId, ...args }) - } - } - const result = await callRuntimeRpc<{ worktree: WorktreeWithLineage }>( - target, - 'worktree.set', - { - worktree: toRuntimeWorktreeSelector(worktreeId), - ...(args.parentWorktreeId - ? { parentWorktree: toRuntimeWorktreeSelector(args.parentWorktreeId) } - : {}), - ...(args.noParent === true ? { noParent: true } : {}) - }, - { timeoutMs: 15_000 } - ) - return { - target, - lineage: result.worktree.lineage ?? null, - updatedRemoteWorktree: result.worktree - } -} - -export function projectLocalWorktreeLineageUpdate( - worktreesByRepo: Record, - worktreeId: string, - lineage: WorktreeLineage | null -): Record { - let nextByRepo = worktreesByRepo - for (const [repoId, worktrees] of Object.entries(worktreesByRepo)) { - let repoChanged = false - const projected = worktrees.map((worktree) => { - const current = worktree as WorktreeWithLineage - const hadChild = current.childWorktreeIds?.includes(worktreeId) ?? false - const isParent = - lineage?.parentWorktreeId === worktree.id && - lineage.parentWorktreeInstanceId === worktree.instanceId - let childWorktreeIds = current.childWorktreeIds - if (hadChild) { - childWorktreeIds = childWorktreeIds?.filter((id) => id !== worktreeId) - } - if (isParent && !childWorktreeIds?.includes(worktreeId)) { - childWorktreeIds = [...(childWorktreeIds ?? []), worktreeId] - } - if (worktree.id === worktreeId) { - repoChanged = true - return { - ...worktree, - parentWorktreeId: lineage?.parentWorktreeId ?? null, - lineage - } - } - if (hadChild || isParent) { - repoChanged = true - return { ...worktree, childWorktreeIds } - } - return worktree - }) - if (repoChanged) { - if (nextByRepo === worktreesByRepo) { - nextByRepo = { ...worktreesByRepo } - } - nextByRepo[repoId] = projected - } - } - return nextByRepo -} - -export function applyWorktreeLineageUpdate( - set: Parameters>[0], - worktreeId: string, - result: WorktreeLineageUpdateResult -): void { - set((s) => { - const next = { ...s.worktreeLineageById } - if (result.lineage) { - next[worktreeId] = result.lineage - } else { - delete next[worktreeId] - } - const worktreesByRepo = - result.target.kind === 'local' - ? projectLocalWorktreeLineageUpdate(s.worktreesByRepo, worktreeId, result.lineage) - : result.updatedRemoteWorktree - ? replaceWorktreeInRepoLists( - s.worktreesByRepo, - withRepoHostOwnership( - result.updatedRemoteWorktree, - repoHostId(s, getRepoIdFromWorktreeId(result.updatedRemoteWorktree.id)) - ) - ) - : s.worktreesByRepo - return { - worktreeLineageById: next, - workspaceLineageByChildKey: projectWorktreeLineageToWorkspaceLineage( - worktreeId, - result.lineage, - s.workspaceLineageByChildKey - ), - worktreesByRepo, - sortEpoch: s.sortEpoch + 1 - } - }) -} - -export function applyHostLineageRefresh( - set: Parameters>[0], - hostId: ExecutionHostId, - lineage: { - worktreeLineageById: Readonly> - workspaceLineageByChildKey: Readonly> - } -): void { - set((s) => { - const worktreeLineageById = mergeLineageForHost(s, hostId, lineage.worktreeLineageById) - const workspaceLineageByChildKey = mergeWorkspaceLineageForHost( - s, - hostId, - lineage.workspaceLineageByChildKey - ) - if ( - worktreeLineageById === s.worktreeLineageById && - workspaceLineageByChildKey === s.workspaceLineageByChildKey - ) { - return s - } - return { worktreeLineageById, workspaceLineageByChildKey } - }) -} - -export async function refreshWorktreeLineageForSettings( - settings: AppState['settings'], - set: Parameters>[0], - options: BackgroundRuntimeRefreshOptions = {} -): Promise { - const lineage = await listWorktreeLineageForRuntime(settings, options) - applyHostLineageRefresh(set, getSettingsFocusedExecutionHostId(settings), lineage) -} - -export async function refreshRemoteWorktreeLineageBestEffort( - settings: AppState['settings'], - set: (partial: Partial | ((state: AppState) => Partial)) => void -): Promise { - if (getActiveRuntimeTarget(settings).kind === 'local') { - return - } - try { - const lineage = await listWorktreeLineageForRuntime(settings, { - reuseRecentCompatibilityFailure: true - }) - applyHostLineageRefresh(set, getSettingsFocusedExecutionHostId(settings), lineage) - } catch (err) { - // Why: lineage is supplemental, so a remote timeout here must not discard a successful worktree refresh. - console.error('Failed to fetch worktree lineage:', err) - } -} diff --git a/src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts b/src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts deleted file mode 100644 index 149f5f05c03..00000000000 --- a/src/renderer/src/store/slices/worktrees/metadata/worktree-meta-persist.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { - assertRuntimeEnvironmentCapability, - callRuntimeRpc, - getActiveRuntimeTarget -} from '../../../../runtime/runtime-rpc-client' -import { - TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, - WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY -} from '../../../../../../shared/protocol-version' -import { toRuntimeWorktreeSelector } from '../../../../runtime/runtime-worktree-selector' -import type { AppState } from '../../../types' -import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types' -import { encodePushTargetClearForRuntimeRpc } from './hosted-review-link-mutation' - -export async function persistWorktreeMeta( - settings: AppState['settings'], - worktreeId: string, - updates: Partial -): Promise { - const target = getActiveRuntimeTarget(settings) - if (target.kind === 'local') { - await window.api.worktrees.updateMeta({ worktreeId, updates }) - return - } - // Why: `worktree.set` parses in strip mode, so an older runtime drops the key - // and applies the rest. Both gates key off presence, not value — a dropped - // *clear* strands a stale link that the Issue row then hides. - if ( - target.kind === 'environment' && - ('linkedWorkItem' in updates || 'linkedTaskSourceContext' in updates) - ) { - await assertRuntimeEnvironmentCapability( - target.environmentId, - WORKTREE_LINKED_WORK_ITEM_CONTEXT_RUNTIME_CAPABILITY, - 'Update the remote runtime to change this workspace’s linked issue' - ) - } - // task-source-context.v1 is a sound proxy for the Linear keys: #5322 added them - // to the schema and is an ancestor of the commit introducing that capability. - if (target.kind === 'environment' && 'linkedLinearIssue' in updates) { - await assertRuntimeEnvironmentCapability( - target.environmentId, - TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, - 'Update the remote runtime to link Linear issues' - ) - } - await callRuntimeRpc( - target, - 'worktree.set', - { - worktree: toRuntimeWorktreeSelector(worktreeId), - ...encodePushTargetClearForRuntimeRpc(updates) - }, - { timeoutMs: 15_000 } - ) -} diff --git a/src/renderer/src/store/slices/worktrees/session/activation-terminal-prep.ts b/src/renderer/src/store/slices/worktrees/session/activation-terminal-prep.ts deleted file mode 100644 index 1b23a6c4459..00000000000 --- a/src/renderer/src/store/slices/worktrees/session/activation-terminal-prep.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const pendingActivationTerminalPrepCancels = new Map void>() - -export function shouldDeferActivationTerminalPrep(): boolean { - return typeof window !== 'undefined' && import.meta.env.MODE !== 'test' -} diff --git a/src/renderer/src/store/slices/worktrees/session/active-worktree-surface.ts b/src/renderer/src/store/slices/worktrees/session/active-worktree-surface.ts deleted file mode 100644 index 01a969d39d5..00000000000 --- a/src/renderer/src/store/slices/worktrees/session/active-worktree-surface.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { AppState } from '../../../types' -import type { WorkspaceVisibleTabType } from '../../../../../../shared/tab-types' -import { toVisibleTabType } from '../listing/worktree-catalog-visibility' - -export function resolveActivatedWorktreeSurface( - s: AppState, - worktreeId: string, - preferredActiveUnifiedTabId: string | undefined, - reconciledActiveTabId: string | null -): { - restoredRightSidebarExplorerView: NonNullable< - AppState['rightSidebarExplorerViewByWorktree'] - >[string] - activeFileId: string | null - activeBrowserTabId: string | null - activeTabType: WorkspaceVisibleTabType - activeTabId: string | null -} { - // Why: Search lives under Explorer, so the files/search sub-route must switch with the worktree, not leak the prior one. - const restoredRightSidebarExplorerView = - s.rightSidebarExplorerViewByWorktree?.[worktreeId] ?? 'files' - const restoredFileId = s.activeFileIdByWorktree[worktreeId] ?? null - const restoredBrowserTabId = s.activeBrowserTabIdByWorktree[worktreeId] ?? null - const restoredTabType = s.activeTabTypeByWorktree[worktreeId] ?? 'terminal' - const activeGroupId = - s.activeGroupIdByWorktree[worktreeId] ?? s.groupsByWorktree[worktreeId]?.[0]?.id ?? null - const activeGroup = activeGroupId - ? ((s.groupsByWorktree[worktreeId] ?? []).find((group) => group.id === activeGroupId) ?? null) - : null - const activeUnifiedTabId = - preferredActiveUnifiedTabId ?? reconciledActiveTabId ?? activeGroup?.activeTabId ?? null - const activeUnifiedTab = - activeUnifiedTabId != null - ? ((s.unifiedTabsByWorktree[worktreeId] ?? []).find( - (tab) => tab.id === activeUnifiedTabId && (!activeGroup || tab.groupId === activeGroup.id) - ) ?? null) - : null - // Verify the restored file still exists in openFiles - const fileStillOpen = restoredFileId - ? s.openFiles.some((f) => f.id === restoredFileId && f.worktreeId === worktreeId) - : false - const browserTabs = s.browserTabsByWorktree[worktreeId] ?? [] - const browserTabStillOpen = restoredBrowserTabId - ? browserTabs.some((tab) => tab.id === restoredBrowserTabId) - : false - const hasGroupOwnedSurface = - (s.groupsByWorktree[worktreeId]?.length ?? 0) > 0 || Boolean(s.layoutByWorktree[worktreeId]) - - // Why: restore from the reconciled tab-group model first; preferring legacy fallbacks can show a blank worktree. - let activeFileId: string | null - let activeBrowserTabId: string | null - let activeTabType: WorkspaceVisibleTabType - if (activeUnifiedTab) { - activeFileId = - activeUnifiedTab.contentType === 'editor' || - activeUnifiedTab.contentType === 'diff' || - activeUnifiedTab.contentType === 'conflict-review' || - activeUnifiedTab.contentType === 'check-details' - ? activeUnifiedTab.entityId - : fileStillOpen - ? restoredFileId - : null - activeBrowserTabId = - activeUnifiedTab.contentType === 'browser' - ? activeUnifiedTab.entityId - : browserTabStillOpen - ? restoredBrowserTabId - : (browserTabs[0]?.id ?? null) - activeTabType = toVisibleTabType(activeUnifiedTab.contentType) - } else if (hasGroupOwnedSurface) { - activeFileId = fileStillOpen ? restoredFileId : null - activeBrowserTabId = browserTabStillOpen ? restoredBrowserTabId : (browserTabs[0]?.id ?? null) - activeTabType = 'terminal' - } else if (restoredTabType === 'terminal') { - activeFileId = fileStillOpen ? restoredFileId : null - activeBrowserTabId = browserTabStillOpen ? restoredBrowserTabId : (browserTabs[0]?.id ?? null) - activeTabType = 'terminal' - } else if (restoredTabType === 'browser' && browserTabStillOpen) { - activeFileId = fileStillOpen ? restoredFileId : null - activeBrowserTabId = restoredBrowserTabId - activeTabType = 'browser' - } else if (restoredTabType === 'editor' && fileStillOpen) { - activeFileId = restoredFileId - activeBrowserTabId = browserTabStillOpen ? restoredBrowserTabId : (browserTabs[0]?.id ?? null) - activeTabType = 'editor' - } else if (browserTabStillOpen) { - activeFileId = null - activeBrowserTabId = restoredBrowserTabId - activeTabType = 'browser' - } else if (fileStillOpen) { - activeFileId = restoredFileId - activeBrowserTabId = browserTabs[0]?.id ?? null - activeTabType = 'editor' - } else { - const fallbackFile = s.openFiles.find((f) => f.worktreeId === worktreeId) - const fallbackBrowserTab = browserTabs[0] ?? null - activeFileId = fallbackFile?.id ?? null - activeBrowserTabId = browserTabStillOpen - ? restoredBrowserTabId - : (fallbackBrowserTab?.id ?? null) - activeTabType = fallbackFile ? 'editor' : fallbackBrowserTab ? 'browser' : 'terminal' - } - - // Why: restore the last-active terminal tab so the user returns to where they left, not tab 0. - const restoredTabId = s.activeTabIdByWorktree[worktreeId] ?? null - const worktreeTabs = s.tabsByWorktree[worktreeId] ?? [] - const tabStillExists = restoredTabId ? worktreeTabs.some((t) => t.id === restoredTabId) : false - const activeTabId = - activeUnifiedTab?.contentType === 'terminal' - ? activeUnifiedTab.entityId - : tabStillExists - ? restoredTabId - : (worktreeTabs[0]?.id ?? null) - - return { - restoredRightSidebarExplorerView, - activeFileId, - activeBrowserTabId, - activeTabType, - activeTabId - } -} diff --git a/src/renderer/src/store/slices/worktrees/session/folder-workspace-activity.ts b/src/renderer/src/store/slices/worktrees/session/folder-workspace-activity.ts deleted file mode 100644 index 48969db5e1c..00000000000 --- a/src/renderer/src/store/slices/worktrees/session/folder-workspace-activity.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { FolderWorkspaceActivityPersistence } from '../../folder-workspace-activity-persistence' -import { FOLDER_WORKSPACE_ACTIVITY_PERSIST_INTERVAL_MS } from '../listing/worktree-slice-constants' -import type { WorktreeSliceGet } from '../listing/worktree-slice-types' - -const folderWorkspaceActivityPersistenceByStore = new WeakMap< - WorktreeSliceGet, - FolderWorkspaceActivityPersistence ->() - -export function getFolderWorkspaceActivityPersistence( - get: WorktreeSliceGet -): FolderWorkspaceActivityPersistence { - const existing = folderWorkspaceActivityPersistenceByStore.get(get) - if (existing) { - return existing - } - const created = new FolderWorkspaceActivityPersistence((folderWorkspaceId, activityAt) => { - if (get().folderWorkspaces.some((workspace) => workspace.id === folderWorkspaceId)) { - void get().updateFolderWorkspace(folderWorkspaceId, { lastActivityAt: activityAt }) - } - }, FOLDER_WORKSPACE_ACTIVITY_PERSIST_INTERVAL_MS) - folderWorkspaceActivityPersistenceByStore.set(get, created) - return created -} diff --git a/src/renderer/src/store/slices/worktrees/session/migrate-worktree-identity.ts b/src/renderer/src/store/slices/worktrees/session/migrate-worktree-identity.ts deleted file mode 100644 index 75d6059f309..00000000000 --- a/src/renderer/src/store/slices/worktrees/session/migrate-worktree-identity.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import { migrateHugeRepoWarningDismissal } from '@/lib/source-control-huge-repo-warning-dismissals' -import { migrateHostedReviewLinkMutationGeneration } from '../metadata/hosted-review-link-mutation' -import { buildWorktreeRenameState } from './worktree-identity-rename-state' - -export function createMigrateWorktreeIdentity( - set: WorktreeSliceSet, - _get: WorktreeSliceGet -): WorktreeSlice['migrateWorktreeIdentity'] { - return (oldWorktreeId: string, newWorktreeId: string) => { - if (oldWorktreeId === newWorktreeId) { - return - } - // Why: invalidate pre-rename toast actions before publishing the new path, carrying the dismissal forward. - migrateHugeRepoWarningDismissal(oldWorktreeId, newWorktreeId) - set((s) => buildWorktreeRenameState(s, oldWorktreeId, newWorktreeId)) - migrateHostedReviewLinkMutationGeneration(oldWorktreeId, newWorktreeId) - } -} diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts b/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts deleted file mode 100644 index 1ca9f11ee5a..00000000000 --- a/src/renderer/src/store/slices/worktrees/session/set-active-folder-workspace.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope' -import { markInputQuietSchedulerInput } from '@/lib/input-quiet-scheduler' -import { moveFocusToRendererBeforeFocusedWebviewHidden } from '../../browser-webview-cleanup' -import { - findKnownWorktreeById, - folderWorkspaceMatchesHost -} from '../listing/detected-worktree-meta' -import { shouldDeferActivationTerminalPrep } from './activation-terminal-prep' - -export function createSetActiveFolderWorkspace( - set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['setActiveFolderWorkspace'] { - return (folderWorkspaceId, executionHostId) => { - const workspaceKey = folderWorkspaceKey(folderWorkspaceId) - const workspace = findKnownWorktreeById(get(), workspaceKey, executionHostId) - if (!workspace) { - return - } - if (shouldDeferActivationTerminalPrep()) { - markInputQuietSchedulerInput() - } - if (get().activeWorktreeId !== workspaceKey) { - moveFocusToRendererBeforeFocusedWebviewHidden() - } - const reconciledActiveTabId = - get().reconcileWorktreeTabModel(workspaceKey).activeRenderableTabId - set((s) => { - const restoredFileId = s.activeFileIdByWorktree[workspaceKey] ?? null - const restoredBrowserTabId = s.activeBrowserTabIdByWorktree[workspaceKey] ?? null - const restoredTabType = s.activeTabTypeByWorktree[workspaceKey] ?? 'terminal' - const activeGroupId = - s.activeGroupIdByWorktree[workspaceKey] ?? s.groupsByWorktree[workspaceKey]?.[0]?.id ?? null - const activeGroup = activeGroupId - ? ((s.groupsByWorktree[workspaceKey] ?? []).find((group) => group.id === activeGroupId) ?? - null) - : null - const activeUnifiedTabId = reconciledActiveTabId ?? activeGroup?.activeTabId ?? null - const activeUnifiedTab = - activeUnifiedTabId != null - ? ((s.unifiedTabsByWorktree[workspaceKey] ?? []).find( - (tab) => - tab.id === activeUnifiedTabId && (!activeGroup || tab.groupId === activeGroup.id) - ) ?? null) - : null - const fileStillOpen = restoredFileId - ? s.openFiles.some((file) => file.id === restoredFileId && file.worktreeId === workspaceKey) - : false - const browserTabs = s.browserTabsByWorktree[workspaceKey] ?? [] - const browserTabStillOpen = restoredBrowserTabId - ? browserTabs.some((tab) => tab.id === restoredBrowserTabId) - : false - const worktreeTabs = s.tabsByWorktree[workspaceKey] ?? [] - const restoredTabId = s.activeTabIdByWorktree[workspaceKey] ?? null - const tabStillExists = restoredTabId - ? worktreeTabs.some((tab) => tab.id === restoredTabId) - : false - const activeFileId = - activeUnifiedTab?.contentType === 'editor' || - activeUnifiedTab?.contentType === 'diff' || - activeUnifiedTab?.contentType === 'conflict-review' || - activeUnifiedTab?.contentType === 'check-details' - ? activeUnifiedTab.entityId - : fileStillOpen - ? restoredFileId - : null - const activeBrowserTabId = - activeUnifiedTab?.contentType === 'browser' - ? activeUnifiedTab.entityId - : browserTabStillOpen - ? restoredBrowserTabId - : (browserTabs[0]?.id ?? null) - const activeTabType = - activeUnifiedTab?.contentType === 'terminal' - ? 'terminal' - : activeUnifiedTab?.contentType === 'browser' - ? 'browser' - : activeUnifiedTab - ? 'editor' - : restoredTabType === 'browser' && browserTabStillOpen - ? 'browser' - : restoredTabType === 'editor' && fileStillOpen - ? 'editor' - : fileStillOpen - ? 'editor' - : browserTabs.length > 0 - ? 'browser' - : 'terminal' - const activeTabId = - activeUnifiedTab?.contentType === 'terminal' - ? activeUnifiedTab.entityId - : tabStillExists - ? restoredTabId - : (worktreeTabs[0]?.id ?? null) - const nextEverActivated = s.everActivatedWorktreeIds.has(workspaceKey) - ? s.everActivatedWorktreeIds - : new Set([...s.everActivatedWorktreeIds, workspaceKey]) - return { - activeRepoId: null, - activeWorktreeId: workspaceKey, - activeWorkspaceKey: workspaceKey, - activeWorkspaceExecutionHostId: executionHostId ?? null, - activePendingCreationId: null, - activeFileId, - activeBrowserTabId, - activeTabType, - activeTabTypeByWorktree: - s.activeTabTypeByWorktree[workspaceKey] === activeTabType - ? s.activeTabTypeByWorktree - : { ...s.activeTabTypeByWorktree, [workspaceKey]: activeTabType }, - activeTabId, - everActivatedWorktreeIds: nextEverActivated, - folderWorkspaces: workspace.isUnread - ? s.folderWorkspaces.map((entry) => - entry.id === folderWorkspaceId && - (!executionHostId || folderWorkspaceMatchesHost(entry, executionHostId)) - ? { ...entry, isUnread: false } - : entry - ) - : s.folderWorkspaces - } - }) - if (workspace.isUnread) { - void get().updateFolderWorkspace( - folderWorkspaceId, - { isUnread: false }, - executionHostId ? { executionHostId } : undefined - ) - } - } -} diff --git a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts b/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts deleted file mode 100644 index b4ec0b0f99a..00000000000 --- a/src/renderer/src/store/slices/worktrees/session/set-active-worktree.ts +++ /dev/null @@ -1,281 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import type { AppState } from '../../../types' -import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types' -import { applyWorktreeUpdates } from '../../worktree-helpers' -import { projectWorktreeTabModelReconciliation } from '../../tabs' -import { moveFocusToRendererBeforeFocusedWebviewHidden } from '../../browser-webview-cleanup' -import { tabHasLivePty } from '@/lib/tab-has-live-pty' -import { markInputQuietSchedulerInput, scheduleAfterInputQuiet } from '@/lib/input-quiet-scheduler' -import { - ACTIVE_WORKTREE_TERMINAL_PREP_DELAY_MS, - ACTIVE_WORKTREE_TERMINAL_PREP_IDLE_TIMEOUT_MS, - ACTIVE_WORKTREE_TERMINAL_PREP_INPUT_QUIET_MS -} from '../listing/worktree-slice-constants' -import { getTerminalActivationSpawnSuppression } from '../../terminal-activation-spawn-suppression' -import { - isWorkspaceKey, - parseWorkspaceKey, - worktreeWorkspaceKey -} from '../../../../../../shared/workspace-scope' -import { - applyDetectedWorktreeUpdates, - findKnownWorktreeById -} from '../listing/detected-worktree-meta' -import { persistPassiveWorktreeMetaForOwner } from '../listing/worktree-owner-settings' -import { resolveActivatedWorktreeSurface } from './active-worktree-surface' -import { - pendingActivationTerminalPrepCancels, - shouldDeferActivationTerminalPrep -} from './activation-terminal-prep' - -export function createSetActiveWorktree( - set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['setActiveWorktree'] { - return (worktreeId, executionHostId, options) => { - const stateTransition = options?.stateTransition?.(get()) - if (stateTransition && !stateTransition.activate) { - if (Object.keys(stateTransition.patch).length > 0) { - set(stateTransition.patch) - } - return false - } - const workspaceScope = worktreeId ? parseWorkspaceKey(worktreeId) : null - if (worktreeId && shouldDeferActivationTerminalPrep()) { - markInputQuietSchedulerInput() - } - - if (get().activeWorktreeId !== worktreeId) { - moveFocusToRendererBeforeFocusedWebviewHidden() - } - let shouldClearUnread = false - let shouldPrepareTerminalTabs = false - let shouldTagTerminalTabs = false - set((current) => { - const transitioned = stateTransition - ? ({ ...current, ...stateTransition.patch } as AppState) - : current - const reconciliation = worktreeId - ? projectWorktreeTabModelReconciliation(transitioned, worktreeId) - : null - const reconciliationChanged = Boolean( - reconciliation && Object.keys(reconciliation.patch).length > 0 - ) - const s = - reconciliation && reconciliationChanged - ? ({ ...transitioned, ...reconciliation.patch } as AppState) - : transitioned - const reconciledActiveTabId = reconciliation?.activeRenderableTabId ?? null - if (!worktreeId) { - return { - ...stateTransition?.patch, - activeWorktreeId: null, - activeWorkspaceKey: null, - activeWorkspaceExecutionHostId: null, - // Why: clearing/activating a worktree must dismiss the background-creation panel so the user isn't stranded on it. - activePendingCreationId: null - } - } - - const worktree = findKnownWorktreeById(s, worktreeId, executionHostId) - shouldClearUnread = Boolean(worktree?.isUnread) - const { - restoredRightSidebarExplorerView, - activeFileId, - activeBrowserTabId, - activeTabType, - activeTabId - } = resolveActivatedWorktreeSurface( - s, - worktreeId, - stateTransition?.preferredActiveUnifiedTabId, - reconciledActiveTabId - ) - - // Why: focus isn't smart-sort activity — writing lastActivityAt here caused the "jump after focus" bug; only clear unread. - const metaUpdates: Partial = shouldClearUnread ? { isUnread: false } : {} - - // Why: prep is deferred (shell render deferred below) so it waits for input quiet instead of blocking the click. - // Why first-activation guard, not tab.ptyId==null: reconnectPersistedTerminals repopulates ptyId before mount. - // Tag every tab on FIRST activation so reattach/fresh-spawn updateTabPtyId suppresses activity + sortEpoch bumps. - // Generation is only bumped when no tab has a live PTY — a live remount would kill the user's shell. - const tabs = s.tabsByWorktree[worktreeId ?? ''] ?? [] - const allDead = - worktreeId != null && - tabs.length > 0 && - tabs.every((tab) => !tabHasLivePty(s.ptyIdsByTabId, tab.id)) - const isFirstActivation = worktreeId != null && !s.everActivatedWorktreeIds.has(worktreeId) - const shouldTagTabs = worktreeId != null && tabs.length > 0 && isFirstActivation - // Why: bump generation in the same set() as activation so a dead-transport pane can't go visible-but-dead before remount. - shouldPrepareTerminalTabs = Boolean( - worktreeId && tabs.length > 0 && shouldTagTabs && !allDead - ) - shouldTagTerminalTabs = shouldTagTabs - const nextEverActivated = isFirstActivation - ? new Set([...s.everActivatedWorktreeIds, worktreeId!]) - : s.everActivatedWorktreeIds - const nextWorktrees = shouldClearUnread - ? applyWorktreeUpdates(s.worktreesByRepo, worktreeId, metaUpdates) - : s.worktreesByRepo - const nextDetectedWorktrees = shouldClearUnread - ? applyDetectedWorktreeUpdates(s.detectedWorktreesByRepo, worktreeId, metaUpdates) - : s.detectedWorktreesByRepo - const nextFolderWorkspaces = - shouldClearUnread && workspaceScope?.type === 'folder' - ? s.folderWorkspaces.map((workspace) => - workspace.id === workspaceScope.folderWorkspaceId - ? { ...workspace, isUnread: false } - : workspace - ) - : s.folderWorkspaces - const nextActiveRepoId = - workspaceScope?.type === 'folder' - ? null - : stateTransition - ? (worktree?.repoId ?? s.activeRepoId) - : s.activeRepoId - const tabsByWorktreeUpdate = - allDead && worktreeId != null - ? { - tabsByWorktree: { - ...s.tabsByWorktree, - [worktreeId]: tabs.map((tab) => ({ - ...tab, - generation: (tab.generation ?? 0) + 1, - pendingActivationSpawn: getTerminalActivationSpawnSuppression( - s.terminalLayoutsByTabId[tab.id] - ) - })) - } - } - : {} - - const nextActiveTabTypeByWorktree = - s.activeTabTypeByWorktree[worktreeId] === activeTabType - ? s.activeTabTypeByWorktree - : { ...s.activeTabTypeByWorktree, [worktreeId]: activeTabType } - const hasStateChange = - s.activeWorktreeId !== worktreeId || - s.activeWorkspaceExecutionHostId !== (executionHostId ?? null) || - // Why: a pending-creation panel can show over the prior worktree; a non-null activePendingCreationId counts as a change. - s.activePendingCreationId !== null || - s.activeFileId !== activeFileId || - s.activeBrowserTabId !== activeBrowserTabId || - s.activeTabType !== activeTabType || - s.rightSidebarExplorerView !== restoredRightSidebarExplorerView || - s.activeTabId !== activeTabId || - nextActiveTabTypeByWorktree !== s.activeTabTypeByWorktree || - nextEverActivated !== s.everActivatedWorktreeIds || - nextWorktrees !== s.worktreesByRepo || - nextDetectedWorktrees !== s.detectedWorktreesByRepo || - nextFolderWorkspaces !== s.folderWorkspaces || - nextActiveRepoId !== s.activeRepoId || - reconciliationChanged || - stateTransition !== undefined - if (!hasStateChange) { - // Why: preserve the root Zustand reference on a no-op re-activation so session persistence/runtime sync don't fan out. - return s - } - - return { - ...stateTransition?.patch, - ...reconciliation?.patch, - activeRepoId: nextActiveRepoId, - activeWorktreeId: worktreeId, - activeWorkspaceKey: isWorkspaceKey(worktreeId) - ? worktreeId - : worktreeWorkspaceKey(worktreeId), - activeWorkspaceExecutionHostId: executionHostId ?? null, - activePendingCreationId: null, - activeFileId, - activeBrowserTabId, - activeTabType, - activeTabTypeByWorktree: nextActiveTabTypeByWorktree, - rightSidebarExplorerView: restoredRightSidebarExplorerView, - activeTabId, - everActivatedWorktreeIds: nextEverActivated, - ...(nextWorktrees !== s.worktreesByRepo ? { worktreesByRepo: nextWorktrees } : {}), - ...(nextDetectedWorktrees !== s.detectedWorktreesByRepo - ? { detectedWorktreesByRepo: nextDetectedWorktrees } - : {}), - ...(nextFolderWorkspaces !== s.folderWorkspaces - ? { folderWorkspaces: nextFolderWorkspaces } - : {}), - ...tabsByWorktreeUpdate - } - }) - - if (worktreeId && shouldPrepareTerminalTabs) { - const prepareTerminalTabs = (): void => { - pendingActivationTerminalPrepCancels.delete(worktreeId) - set((s) => { - if (s.activeWorktreeId !== worktreeId) { - return {} - } - const tabs = s.tabsByWorktree[worktreeId] ?? [] - if (tabs.length === 0) { - return {} - } - const allDead = tabs.every((tab) => !tabHasLivePty(s.ptyIdsByTabId, tab.id)) - if (!allDead && !shouldTagTerminalTabs) { - return {} - } - return { - tabsByWorktree: { - ...s.tabsByWorktree, - [worktreeId]: tabs.map((tab) => ({ - ...tab, - ...(allDead ? { generation: (tab.generation ?? 0) + 1 } : {}), - // Why: slept terminal remount/spawn is click-driven wake work; tag its PTY updates so they don't reshuffle Recent. - pendingActivationSpawn: getTerminalActivationSpawnSuppression( - s.terminalLayoutsByTabId[tab.id] - ) - })) - } - } - }) - } - - const cancelExistingPrep = pendingActivationTerminalPrepCancels.get(worktreeId) - if (cancelExistingPrep) { - cancelExistingPrep() - } - if (shouldDeferActivationTerminalPrep()) { - pendingActivationTerminalPrepCancels.set( - worktreeId, - scheduleAfterInputQuiet(prepareTerminalTabs, { - delayMs: ACTIVE_WORKTREE_TERMINAL_PREP_DELAY_MS, - quietMs: ACTIVE_WORKTREE_TERMINAL_PREP_INPUT_QUIET_MS, - idleTimeoutMs: ACTIVE_WORKTREE_TERMINAL_PREP_IDLE_TIMEOUT_MS - }) - ) - } else { - prepareTerminalTabs() - } - } - - // Why: activation is explicit enough to revalidate PR state now; the coordinator still coalesces and rate-guards. - if (worktreeId) { - get().refreshGitHubForWorktreeIfStale(worktreeId) - } - - if (!worktreeId || !get().getKnownWorktreeById(worktreeId, executionHostId)) { - return true - } - - if (shouldClearUnread) { - if (workspaceScope?.type === 'folder') { - void get().updateFolderWorkspace(workspaceScope.folderWorkspaceId, { isUnread: false }) - return true - } - persistPassiveWorktreeMetaForOwner( - get, - worktreeId, - { isUnread: false }, - 'persist worktree activation state' - ) - } - return true - } -} diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts deleted file mode 100644 index 6bb6d6bbc8f..00000000000 --- a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts +++ /dev/null @@ -1,187 +0,0 @@ -import type { AppState } from '../../../types' -import { splitWorktreeIdForFilesystem } from '../../../../../../shared/worktree/id' -import { worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope' -import { - remapClosedTerminalTabSnapshotCwds, - type ClosedTerminalTabSnapshot -} from '../../recently-closed-tabs' - -// Every worktree-id-keyed store map the rename path re-keys, so a new `*ByWorktree` map isn't silently missed. -// Tab-id/file-id-keyed maps are deliberately excluded: tabs and files keep their ids across a rename. -const WORKTREE_ID_KEYED_MAP_KEYS = [ - 'worktreeLineageById', - 'tabsByWorktree', - 'deleteStateByWorktreeId', - 'baseStatusByWorktreeId', - 'remoteBranchConflictByWorktreeId', - 'fileSearchStateByWorktree', - 'browserTabsByWorktree', - 'recentlyClosedBrowserTabsByWorktree', - 'activeBrowserTabIdByWorktree', - 'activeFileIdByWorktree', - 'activeTabTypeByWorktree', - 'activeTabIdByWorktree', - 'tabBarOrderByWorktree', - 'pendingReconnectTabByWorktree', - 'rightSidebarTabByWorktree', - 'rightSidebarExplorerViewByWorktree', - 'unifiedTabsByWorktree', - 'groupsByWorktree', - 'layoutByWorktree', - 'activeGroupIdByWorktree', - 'gitStatusByWorktree', - 'gitStatusHeadByWorktree', - 'gitBranchLineTotalByWorktree', - 'gitIgnoredPathsByWorktree', - 'gitConflictOperationByWorktree', - 'trackedConflictPathsByWorktree', - 'gitBranchChangesByWorktree', - 'gitBranchCompareSummaryByWorktree', - 'gitBranchCompareRequestKeyByWorktree', - 'gitBranchCompareRequestStatusHeadByWorktree', - 'showDotfilesByWorktree', - 'expandedDirs', - 'lastVisitedAtByWorktreeId', - 'defaultTerminalTabsAppliedByWorktreeId', - 'recentlyClosedTabKindsByWorktree' -] as const satisfies readonly (keyof AppState)[] - -/** - * Re-key every worktree-id-keyed map from `oldWorktreeId` to `newWorktreeId` after a folder - * rename. Tab-id/file-id-keyed maps and active/renaming pointers stay put since tabs/files keep their ids. - * Main-process counterpart: `Store.migrateWorktreeIdentity` in persistence.ts. - */ -export function buildWorktreeRenameState( - s: AppState, - oldWorktreeId: string, - newWorktreeId: string -): Partial { - if (oldWorktreeId === newWorktreeId) { - return {} - } - const renamed: Record = {} - const renameKey = ( - key: keyof AppState, - mapValue: (value: T) => T = (value) => value - ): void => { - const map = s[key as keyof AppState] as Record | undefined - if (!map || !(oldWorktreeId in map)) { - return - } - const next = { ...map } - next[newWorktreeId] = mapValue(next[oldWorktreeId] as T) - delete next[oldWorktreeId] - renamed[key] = next - } - const withNewWorktreeId = (value: T): T => - value.worktreeId === oldWorktreeId ? { ...value, worktreeId: newWorktreeId } : value - const renameValueByKey: Partial> = { - tabsByWorktree: (tabs: { worktreeId: string }[]) => tabs.map(withNewWorktreeId), - browserTabsByWorktree: (workspaces: { worktreeId: string }[]) => - workspaces.map(withNewWorktreeId), - recentlyClosedBrowserTabsByWorktree: ( - snapshots: { workspace: { worktreeId: string }; pages: { worktreeId: string }[] }[] - ) => - snapshots.map((snapshot) => ({ - ...snapshot, - workspace: withNewWorktreeId(snapshot.workspace), - pages: snapshot.pages.map(withNewWorktreeId) - })), - fileSearchStateByWorktree: (searchState: AppState['fileSearchStateByWorktree'][string]) => ({ - ...searchState, - resultOwner: searchState.resultOwner ? withNewWorktreeId(searchState.resultOwner) : null - }), - unifiedTabsByWorktree: (tabs: { worktreeId: string }[]) => tabs.map(withNewWorktreeId), - groupsByWorktree: (groups: { worktreeId: string }[]) => groups.map(withNewWorktreeId) - } - for (const key of WORKTREE_ID_KEYED_MAP_KEYS) { - renameKey(key, renameValueByKey[key] as ((value: unknown) => unknown) | undefined) - } - // Re-key on rename so a renamed worktree keeps its editor-undo + push/pull state. - renameKey('recentlyClosedEditorTabsByWorktree', (files: { worktreeId: string }[]) => - files.map(withNewWorktreeId) - ) - // Why: terminal reopen snapshots hold absolute startupCwd paths under the old folder; remap or Cmd+Shift+T respawns into a directory that no longer exists after the rename. - const oldWorktreePath = splitWorktreeIdForFilesystem(oldWorktreeId)?.worktreePath - const newWorktreePath = splitWorktreeIdForFilesystem(newWorktreeId)?.worktreePath - renameKey('recentlyClosedTerminalTabsByWorktree', (snapshots: ClosedTerminalTabSnapshot[]) => - oldWorktreePath && newWorktreePath - ? remapClosedTerminalTabSnapshotCwds(snapshots, oldWorktreePath, newWorktreePath) - : snapshots - ) - renameKey('remoteStatusesByWorktree') - - const openFiles = s.openFiles?.some((f) => f.worktreeId === oldWorktreeId) - ? s.openFiles.map((f) => - f.worktreeId === oldWorktreeId ? { ...f, worktreeId: newWorktreeId } : f - ) - : s.openFiles - const currentBrowserPagesByWorkspace = s.browserPagesByWorkspace ?? {} - const browserPagesByWorkspace = Object.values(currentBrowserPagesByWorkspace).some((pages) => - pages.some((page) => page.worktreeId === oldWorktreeId) - ) - ? Object.fromEntries( - Object.entries(currentBrowserPagesByWorkspace).map(([workspaceId, pages]) => [ - workspaceId, - pages.map(withNewWorktreeId) - ]) - ) - : s.browserPagesByWorkspace - const currentRecentlyClosedBrowserPagesByWorkspace = s.recentlyClosedBrowserPagesByWorkspace ?? {} - const recentlyClosedBrowserPagesByWorkspace = Object.values( - currentRecentlyClosedBrowserPagesByWorkspace - ).some((pages) => pages.some((page) => page.worktreeId === oldWorktreeId)) - ? Object.fromEntries( - Object.entries(currentRecentlyClosedBrowserPagesByWorkspace).map(([workspaceId, pages]) => [ - workspaceId, - pages.map(withNewWorktreeId) - ]) - ) - : s.recentlyClosedBrowserPagesByWorkspace - let everActivated = s.everActivatedWorktreeIds - if (everActivated.has(oldWorktreeId)) { - everActivated = new Set(everActivated) - everActivated.delete(oldWorktreeId) - everActivated.add(newWorktreeId) - } - const pendingReconnectWorktreeIds = s.pendingReconnectWorktreeIds?.includes(oldWorktreeId) - ? s.pendingReconnectWorktreeIds.map((id) => (id === oldWorktreeId ? newWorktreeId : id)) - : s.pendingReconnectWorktreeIds - const currentSleepingAgentSessionsByPaneKey = s.sleepingAgentSessionsByPaneKey ?? {} - const sleepingAgentSessionsByPaneKey = Object.values(currentSleepingAgentSessionsByPaneKey).some( - (record) => record.worktreeId === oldWorktreeId - ) - ? Object.fromEntries( - Object.entries(currentSleepingAgentSessionsByPaneKey).map(([paneKey, record]) => [ - paneKey, - record.worktreeId === oldWorktreeId ? { ...record, worktreeId: newWorktreeId } : record - ]) - ) - : s.sleepingAgentSessionsByPaneKey - - return { - ...(renamed as Partial), - ...(openFiles !== s.openFiles ? { openFiles } : {}), - ...(browserPagesByWorkspace !== s.browserPagesByWorkspace ? { browserPagesByWorkspace } : {}), - ...(recentlyClosedBrowserPagesByWorkspace !== s.recentlyClosedBrowserPagesByWorkspace - ? { recentlyClosedBrowserPagesByWorkspace } - : {}), - ...(everActivated !== s.everActivatedWorktreeIds - ? { everActivatedWorktreeIds: everActivated } - : {}), - ...(pendingReconnectWorktreeIds !== s.pendingReconnectWorktreeIds - ? { pendingReconnectWorktreeIds } - : {}), - ...(sleepingAgentSessionsByPaneKey !== s.sleepingAgentSessionsByPaneKey - ? { sleepingAgentSessionsByPaneKey } - : {}), - ...(s.activeWorktreeId === oldWorktreeId ? { activeWorktreeId: newWorktreeId } : {}), - // The active workspace key derives from the worktree id, so keep it in sync when the active worktree is renamed. - ...(s.activeWorkspaceKey === worktreeWorkspaceKey(oldWorktreeId) - ? { activeWorkspaceKey: worktreeWorkspaceKey(newWorktreeId) } - : {}), - ...(s.renamingWorktreeId?.worktreeId === oldWorktreeId - ? { renamingWorktreeId: { ...s.renamingWorktreeId, worktreeId: newWorktreeId } } - : {}) - } -} diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-pin-reveal.ts b/src/renderer/src/store/slices/worktrees/session/worktree-pin-reveal.ts deleted file mode 100644 index cbd4767f299..00000000000 --- a/src/renderer/src/store/slices/worktrees/session/worktree-pin-reveal.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import { - getActiveSidebarWorkspaceId, - parseWorkspaceKey -} from '../../../../../../shared/workspace-scope' -import type { WorktreeMeta } from '../../../../../../shared/worktree/meta-types' - -export function createSetWorktreesPinnedAndReveal( - _set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['setWorktreesPinnedAndReveal'] { - return (worktreeIds, isPinned) => { - // Only follow a toggled row with the viewport when it's the focused worktree, not an unfocused card. - const activeSidebarWorktreeId = getActiveSidebarWorkspaceId( - get().activeWorkspaceKey, - get().activeWorktreeId - ) - // Skip worktrees already in the target state so a no-op toggle doesn't scroll the viewport away. - const updates = new Map>() - let didChange = false - let revealWorktreeId: string | null = null - for (const worktreeId of worktreeIds) { - const current = get().getKnownWorktreeById(worktreeId) - if (!current || current.isPinned === isPinned) { - continue - } - didChange = true - const workspaceScope = parseWorkspaceKey(worktreeId) - if (workspaceScope?.type === 'folder') { - void get().updateWorktreeMeta(worktreeId, { isPinned }) - } else { - updates.set(worktreeId, { isPinned }) - } - if (revealWorktreeId === null && worktreeId === activeSidebarWorktreeId) { - revealWorktreeId = worktreeId - } - } - if (!didChange) { - return - } - // updateWorktreesMeta applies the store update synchronously, so the reveal below sees the row already rendered. - void get().updateWorktreesMeta(updates) - if (revealWorktreeId !== null) { - get().revealWorktreeInSidebar(revealWorktreeId, { behavior: 'smooth', highlight: true }) - } - } -} diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-initial-state.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-initial-state.ts deleted file mode 100644 index 73dfa75478f..00000000000 --- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-initial-state.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' - -export const worktreeSliceInitialState: Pick< - WorktreeSlice, - | 'worktreesByRepo' - | 'detectedWorktreesByRepo' - | 'worktreeLineageById' - | 'workspaceLineageByChildKey' - | 'activeWorktreeId' - | 'activeWorkspaceKey' - | 'activeWorkspaceExecutionHostId' - | 'pendingWorktreeCreations' - | 'activePendingCreationId' - | 'renamingWorktreeId' - | 'deleteStateByWorktreeId' - | 'baseStatusByWorktreeId' - | 'remoteBranchConflictByWorktreeId' - | 'sortEpoch' - | 'everActivatedWorktreeIds' - | 'lastVisitedAtByWorktreeId' - | 'hasHydratedWorktreePurge' - | 'startupWorktreeRefreshCompleted' -> = { - worktreesByRepo: {}, - detectedWorktreesByRepo: {}, - worktreeLineageById: {}, - workspaceLineageByChildKey: {}, - activeWorktreeId: null, - activeWorkspaceKey: null, - activeWorkspaceExecutionHostId: null, - pendingWorktreeCreations: {}, - activePendingCreationId: null, - renamingWorktreeId: null, - deleteStateByWorktreeId: {}, - baseStatusByWorktreeId: {}, - remoteBranchConflictByWorktreeId: {}, - sortEpoch: 0, - everActivatedWorktreeIds: new Set(), - lastVisitedAtByWorktreeId: {}, - hasHydratedWorktreePurge: false, - startupWorktreeRefreshCompleted: false -} diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts deleted file mode 100644 index 112c4da1aff..00000000000 --- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts +++ /dev/null @@ -1,81 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../../../shared/constants' -import { getTerminalActivationSpawnSuppression } from '../../terminal-activation-spawn-suppression' -import { findKnownWorktreeById } from '../listing/detected-worktree-meta' -import { buildWorktreePurgeState } from '../teardown/worktree-purge-state' - -export function createSetRenamingWorktreeId( - set: WorktreeSliceSet, - _get: WorktreeSliceGet -): WorktreeSlice['setRenamingWorktreeId'] { - return (request) => { - set({ - renamingWorktreeId: typeof request === 'string' ? { worktreeId: request } : request - }) - } -} - -export function createRemountTerminalTabForRecovery( - set: WorktreeSliceSet, - _get: WorktreeSliceGet -): WorktreeSlice['remountTerminalTabForRecovery'] { - return (tabId) => { - let remounted = false - set((s) => { - for (const [worktreeId, tabs] of Object.entries(s.tabsByWorktree)) { - const index = tabs.findIndex((tab) => tab.id === tabId) - if (index === -1) { - continue - } - const tab = tabs[index] - const nextTabs = tabs.slice() - nextTabs[index] = { - ...tab, - // Why: bump generation to remount a pane whose renderer died while its PTY stayed alive, so it reattaches, not spawns. - generation: (tab.generation ?? 0) + 1, - // Why: recovery isn't a user interaction — suppress its PTY updates from reshuffling Recent, like activation remounts. - pendingActivationSpawn: getTerminalActivationSpawnSuppression( - s.terminalLayoutsByTabId[tab.id] - ) - } - remounted = true - return { - tabsByWorktree: { - ...s.tabsByWorktree, - [worktreeId]: nextTabs - } - } - } - return {} - }) - return remounted - } -} - -export function createAllWorktrees( - _set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['allWorktrees'] { - return () => Object.values(get().worktreesByRepo).flat() -} - -export function createGetKnownWorktreeById( - _set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['getKnownWorktreeById'] { - return (worktreeId, executionHostId) => findKnownWorktreeById(get(), worktreeId, executionHostId) -} - -export function createPurgeWorktreeTerminalState( - set: WorktreeSliceSet, - _get: WorktreeSliceGet -): WorktreeSlice['purgeWorktreeTerminalState'] { - return (worktreeIds: string[]) => { - const purgeableWorktreeIds = worktreeIds.filter((id) => id !== FLOATING_TERMINAL_WORKTREE_ID) - if (purgeableWorktreeIds.length === 0) { - return - } - set((s) => buildWorktreePurgeState(s, purgeableWorktreeIds)) - } -} diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts b/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts deleted file mode 100644 index fa05be0e2b9..00000000000 --- a/src/renderer/src/store/slices/worktrees/session/worktree-unread-activity.ts +++ /dev/null @@ -1,311 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope' -import { applyWorktreeUpdates, getRepoIdFromWorktreeId } from '../../worktree-helpers' -import { branchName } from '@/lib/git-utils' -import { refreshHostedReviewCard } from '../../hosted-review' -import { - applyDetectedWorktreeUpdates, - findKnownWorktreeById -} from '../listing/detected-worktree-meta' -import { getFolderWorkspaceActivityPersistence } from './folder-workspace-activity' -import { - persistPassiveWorktreeMetaForOwner, - trySettingsForWorktreeOwner, - warnAmbiguousOwnerOnce -} from '../listing/worktree-owner-settings' -import { persistWorktreeMeta } from '../metadata/worktree-meta-persist' -import { isRuntimeSelectorNotFoundError } from '../listing/runtime-worktree-rpc-errors' - -export function createMarkWorktreeUnread( - set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['markWorktreeUnread'] { - return (worktreeId) => { - // Why: attention dot stays until the user engages the worktree; cleared by pane interaction or activation. - const now = Date.now() - const workspaceScope = parseWorkspaceKey(worktreeId) - if (workspaceScope?.type === 'folder') { - const folderWorkspaceId = workspaceScope.folderWorkspaceId - let shouldPersist = false - set((s) => { - const folderWorkspace = s.folderWorkspaces.find( - (workspace) => workspace.id === folderWorkspaceId - ) - if (!folderWorkspace || folderWorkspace.isUnread) { - return s - } - shouldPersist = true - return { - folderWorkspaces: s.folderWorkspaces.map((workspace) => - workspace.id === folderWorkspaceId - ? { ...workspace, isUnread: true, lastActivityAt: now } - : workspace - ), - sortEpoch: s.sortEpoch + 1 - } - }) - if (!shouldPersist) { - return - } - void get().updateFolderWorkspace(folderWorkspaceId, { - isUnread: true, - lastActivityAt: now - }) - return - } - let shouldPersist = false - set((s) => { - const worktree = findKnownWorktreeById(s, worktreeId) - if (!worktree || worktree.isUnread) { - return {} - } - shouldPersist = true - const nextWorktrees = applyWorktreeUpdates(s.worktreesByRepo, worktreeId, { - isUnread: true, - lastActivityAt: now - }) - const nextDetectedWorktrees = applyDetectedWorktreeUpdates( - s.detectedWorktreesByRepo, - worktreeId, - { - isUnread: true, - lastActivityAt: now - } - ) - return { - ...(nextWorktrees !== s.worktreesByRepo - ? { worktreesByRepo: nextWorktrees, sortEpoch: s.sortEpoch + 1 } - : {}), - ...(nextDetectedWorktrees !== s.detectedWorktreesByRepo - ? { detectedWorktreesByRepo: nextDetectedWorktrees } - : {}) - } - }) - - if (!shouldPersist) { - return - } - - persistPassiveWorktreeMetaForOwner( - get, - worktreeId, - { isUnread: true, lastActivityAt: now }, - 'persist unread worktree state' - ) - } -} - -export function createObserveTerminalGitHubPullRequestLink( - _set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['observeTerminalGitHubPullRequestLink'] { - return (worktreeId, link) => { - const state = get() - const worktree = findKnownWorktreeById(state, worktreeId) - if (!worktree || worktree.isBare || worktree.isArchived) { - return - } - const repo = state.repos.find((candidate) => candidate.id === worktree.repoId) - if (!repo || (repo.kind && repo.kind !== 'git')) { - return - } - if (typeof worktree.linkedPR === 'number' && worktree.linkedPR !== link.number) { - return - } - - const branch = branchName(worktree.branch) - const alreadyLinked = worktree.linkedPR === link.number - - const fetchPRForBranch = get().fetchPRForBranch - if (typeof fetchPRForBranch === 'function') { - void fetchPRForBranch(repo.path, branch, { - force: true, - repoId: repo.id, - worktreeId, - linkedPRNumber: alreadyLinked ? link.number : null, - fallbackPRNumber: null, - fallbackPRSource: alreadyLinked ? null : 'explicit' - }).then((pr) => { - if (!alreadyLinked && pr?.number === link.number) { - // Why: terminal output can carry arbitrary PR URLs (docs/agents/logs). - // Persist only after branch lookup confirms it and the user hasn't picked another PR mid-flight. - void get().updateWorktreeMeta( - worktreeId, - { linkedPR: link.number }, - { - shouldApply: (currentWorktree) => - Boolean( - currentWorktree && - !currentWorktree.isBare && - !currentWorktree.isArchived && - (currentWorktree.linkedPR == null || currentWorktree.linkedPR === link.number) - ) - } - ) - } - }) - return - } - - const fetchHostedReviewForBranch = get().fetchHostedReviewForBranch - if (typeof fetchHostedReviewForBranch === 'function') { - // Why: full app stores have fetchPRForBranch (syncs the hosted-review cache); this is only a slice-test fallback. - void refreshHostedReviewCard(fetchHostedReviewForBranch, { - repoPath: repo.path, - repoId: repo.id, - branch, - linkedGitHubPR: alreadyLinked ? link.number : null, - fallbackGitHubPR: null, - linkedGitLabMR: worktree.linkedGitLabMR ?? null - }) - } - } -} - -export function createClearWorktreeUnread( - set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['clearWorktreeUnread'] { - return (worktreeId) => { - const workspaceScope = parseWorkspaceKey(worktreeId) - if (workspaceScope?.type === 'folder') { - const folderWorkspaceId = workspaceScope.folderWorkspaceId - const folderWorkspace = get().folderWorkspaces.find( - (workspace) => workspace.id === folderWorkspaceId - ) - if (!folderWorkspace?.isUnread) { - return - } - // Why: flip locally first — this runs per keystroke, so the guard above must dedupe before the IPC round-trip lands. - set((s) => ({ - folderWorkspaces: s.folderWorkspaces.map((workspace) => - workspace.id === folderWorkspaceId ? { ...workspace, isUnread: false } : workspace - ) - })) - void get().updateFolderWorkspace(folderWorkspaceId, { isUnread: false }) - return - } - let shouldPersist = false - set((s) => { - const worktree = findKnownWorktreeById(s, worktreeId) - if (!worktree || !worktree.isUnread) { - // Why: return `s` (not {}) to keep the object reference on this hot-path no-op (every keystroke), avoiding selector churn. - return s - } - shouldPersist = true - const nextWorktrees = applyWorktreeUpdates(s.worktreesByRepo, worktreeId, { - isUnread: false - }) - const nextDetectedWorktrees = applyDetectedWorktreeUpdates( - s.detectedWorktreesByRepo, - worktreeId, - { - isUnread: false - } - ) - return { - ...(nextWorktrees !== s.worktreesByRepo ? { worktreesByRepo: nextWorktrees } : {}), - ...(nextDetectedWorktrees !== s.detectedWorktreesByRepo - ? { detectedWorktreesByRepo: nextDetectedWorktrees } - : {}) - } - }) - - if (!shouldPersist) { - return - } - - persistPassiveWorktreeMetaForOwner( - get, - worktreeId, - { isUnread: false }, - 'persist cleared unread worktree state' - ) - } -} - -export function createBumpWorktreeActivity( - set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['bumpWorktreeActivity'] { - return (worktreeId) => { - const now = Date.now() - const workspaceScope = parseWorkspaceKey(worktreeId) - if (workspaceScope?.type === 'folder') { - // Why: folder meta lives on the FolderWorkspace record — persistWorktreeMeta would write a - // worktreeMeta['folder:…'] row that folderWorkspaces:list never reads back (#10251). - const folderWorkspaceId = workspaceScope.folderWorkspaceId - let shouldPersist = false - set((s) => { - if (!s.folderWorkspaces.some((workspace) => workspace.id === folderWorkspaceId)) { - return s - } - shouldPersist = true - const isActive = s.activeWorktreeId === worktreeId - return { - folderWorkspaces: s.folderWorkspaces.map((workspace) => - workspace.id === folderWorkspaceId ? { ...workspace, lastActivityAt: now } : workspace - ), - // Why: active-workspace PTY events are click side-effects, so they must not reorder it. - ...(isActive ? {} : { sortEpoch: s.sortEpoch + 1 }) - } - }) - if (shouldPersist) { - getFolderWorkspaceActivityPersistence(get).record(folderWorkspaceId, now) - } - return - } - let shouldPersist = false - set((s) => { - const worktree = findKnownWorktreeById(s, worktreeId) - if (!worktree) { - return {} - } - shouldPersist = true - // Why: skip sortEpoch bump for the active worktree — its PTY events are click side-effects (reorder-on-click bug, PR #209). - // lastActivityAt is still persisted so the next background-driven sortEpoch bump includes this worktree's score. - const isActive = s.activeWorktreeId === worktreeId - const nextWorktrees = applyWorktreeUpdates(s.worktreesByRepo, worktreeId, { - lastActivityAt: now - }) - const nextDetectedWorktrees = applyDetectedWorktreeUpdates( - s.detectedWorktreesByRepo, - worktreeId, - { - lastActivityAt: now - } - ) - return { - ...(nextWorktrees !== s.worktreesByRepo - ? { - worktreesByRepo: nextWorktrees, - ...(isActive ? {} : { sortEpoch: s.sortEpoch + 1 }) - } - : {}), - ...(nextDetectedWorktrees !== s.detectedWorktreesByRepo - ? { detectedWorktreesByRepo: nextDetectedWorktrees } - : {}) - } - }) - - if (!shouldPersist) { - return - } - - const ownerSettings = trySettingsForWorktreeOwner(get(), worktreeId) - if (!ownerSettings) { - warnAmbiguousOwnerOnce(worktreeId, 'persist worktree activity timestamp') - return - } - void persistWorktreeMeta(ownerSettings, worktreeId, { - lastActivityAt: now - }).catch((err) => { - if (isRuntimeSelectorNotFoundError(err)) { - return - } - console.error('Failed to persist worktree activity timestamp:', err) - void get().fetchWorktrees(getRepoIdFromWorktreeId(worktreeId)) - }) - } -} diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts b/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts deleted file mode 100644 index 0a365896b92..00000000000 --- a/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts +++ /dev/null @@ -1,114 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import { getRepoIdFromWorktreeId } from '../../worktree-helpers' - -export function createMarkWorktreeVisited( - set: WorktreeSliceSet, - _get: WorktreeSliceGet -): WorktreeSlice['markWorktreeVisited'] { - return (worktreeId, visitedAt) => { - // Why: Cmd+J empty-query ordering needs a focus-recency signal distinct from lastActivityAt (background PTY/activity). - // Monotonic: CLI/IPC activations can race, so older timestamps must not regress. See docs/cmd-j-empty-query-ordering.md. - set((s) => { - const now = visitedAt ?? Date.now() - const prev = s.lastVisitedAtByWorktreeId[worktreeId] ?? 0 - if (!(now > prev)) { - return {} - } - return { - lastVisitedAtByWorktreeId: { - ...s.lastVisitedAtByWorktreeId, - [worktreeId]: now - } - } - }) - } -} - -export function createPruneLastVisitedTimestamps( - set: WorktreeSliceSet, - _get: WorktreeSliceGet -): WorktreeSlice['pruneLastVisitedTimestamps'] { - return () => { - set((s) => { - // Why: prune per-repo, not globally — SSH repos aren't hydrated at startup, so a global prune would wipe SSH focus-recency. - // Only drop for repos with a populated/authoritative list; a missing repoId means not-yet-hydrated (defer). - const validIdsByRepo = new Map>() - for (const [repoId, list] of Object.entries(s.worktreesByRepo)) { - if (s.detectedWorktreesByRepo[repoId]) { - continue - } - validIdsByRepo.set(repoId, new Set(list.map((worktree) => worktree.id))) - } - for (const [repoId, result] of Object.entries(s.detectedWorktreesByRepo)) { - if (result.authoritative) { - validIdsByRepo.set(repoId, new Set(result.worktrees.map((worktree) => worktree.id))) - } - } - let changed = false - const next: Record = {} - for (const [id, ts] of Object.entries(s.lastVisitedAtByWorktreeId)) { - const repoId = getRepoIdFromWorktreeId(id) - const repoIds = validIdsByRepo.get(repoId) - if (!repoIds) { - // Repo not yet hydrated (e.g. SSH not connected). Keep the entry. - next[id] = ts - continue - } - if (repoIds.has(id)) { - next[id] = ts - } else { - changed = true - } - } - const patch: { - lastVisitedAtByWorktreeId?: Record - activeWorktreeId?: null - activeWorkspaceExecutionHostId?: null - } = {} - if (changed) { - patch.lastVisitedAtByWorktreeId = next - } - // Why: the persisted active-worktree pointer is a `${repoId}::${path}` id - // that nothing else reconciles here. The main-process Store clears a stale - // pointer when a repo is removed (removeWorkspaceSessionOwner nulls - // activeWorktreeId), but the web client keeps it in localStorage and gets - // no such load-time GC — so a pointer to a worktree the server no longer - // reports lingers and can surface a phantom/duplicate workspace. Clear it - // once its repo is hydrated and the worktree is confirmed gone (defer while - // the repo is unhydrated, mirroring the timestamp rule above). - const activeId = s.activeWorktreeId - if (activeId) { - const activeRepoWorktreeIds = validIdsByRepo.get(getRepoIdFromWorktreeId(activeId)) - if (activeRepoWorktreeIds && !activeRepoWorktreeIds.has(activeId)) { - patch.activeWorktreeId = null - patch.activeWorkspaceExecutionHostId = null - } - } - return Object.keys(patch).length > 0 ? patch : {} - }) - } -} - -export function createSeedActiveWorktreeLastVisitedIfMissing( - set: WorktreeSliceSet, - _get: WorktreeSliceGet -): WorktreeSlice['seedActiveWorktreeLastVisitedIfMissing'] { - return () => { - set((s) => { - const id = s.activeWorktreeId - if (!id) { - return {} - } - if (s.lastVisitedAtByWorktreeId[id] != null) { - return {} - } - return { - lastVisitedAtByWorktreeId: { - ...s.lastVisitedAtByWorktreeId, - [id]: Date.now() - } - } - }) - } -} diff --git a/src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts b/src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts deleted file mode 100644 index d263ae03415..00000000000 --- a/src/renderer/src/store/slices/worktrees/teardown/force-delete-preserved-branch.ts +++ /dev/null @@ -1,95 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import { toast } from 'sonner' -import { translate } from '@/i18n/i18n' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' -import { toRuntimeWorktreeSelector } from '../../../../runtime/runtime-worktree-selector' -import { preservedBranchCleanupKey } from '../../../../../../shared/preserved-branch-cleanup' -import type { ForceDeleteWorktreeBranchResult } from '../../../../../../shared/worktree/create-types' -import type { PreservedBranchCleanup } from '../../../../../../shared/preserved-branch-cleanup' -import { preservedBranchRuntimeTargetByCleanupKey } from './preserved-branch-cleanup-target' -import { settingsForWorktreeOwner } from '../listing/worktree-owner-settings' - -export function createForceDeletePreservedBranch( - _set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['forceDeletePreservedBranch'] { - return async (worktreeId, branchName, expectedHead, options) => { - try { - const requestedCleanup: PreservedBranchCleanup = { - worktreeId, - branchName, - expectedHead, - ...(options?.hostId ? { hostId: options.hostId } : {}), - ...(options?.runtimeEnvironmentId - ? { runtimeEnvironmentId: options.runtimeEnvironmentId } - : {}) - } - const requestedCleanupKey = preservedBranchCleanupKey(requestedCleanup) - const exactRetainedTarget = preservedBranchRuntimeTargetByCleanupKey.get(requestedCleanupKey) - const matchingRetainedTargets = exactRetainedTarget - ? [exactRetainedTarget] - : [...preservedBranchRuntimeTargetByCleanupKey.values()].filter( - ({ cleanup }) => - cleanup.worktreeId === worktreeId && - cleanup.branchName === branchName && - cleanup.expectedHead === expectedHead - ) - const retainedTarget = - exactRetainedTarget ?? - (options?.hostId || options?.runtimeEnvironmentId || matchingRetainedTargets.length !== 1 - ? undefined - : matchingRetainedTargets[0]) - if ((options?.hostId || options?.runtimeEnvironmentId) && !retainedTarget) { - throw new Error(`No preserved branch cleanup is pending for "${branchName}".`) - } - const cleanupHostId = options?.hostId ?? retainedTarget?.cleanup.hostId - // Why: the removed row no longer records its nested HUB owner, so retain the deletion-time route. - const target = - retainedTarget?.target ?? - getActiveRuntimeTarget(settingsForWorktreeOwner(get(), worktreeId)) - const result = await (target.kind === 'local' - ? window.api.worktrees.forceDeletePreservedBranch({ - worktreeId, - branchName, - expectedHead, - ...(cleanupHostId ? { hostId: cleanupHostId } : {}) - }) - : callRuntimeRpc( - target, - 'worktree.forceDeleteBranch', - { - worktree: toRuntimeWorktreeSelector(worktreeId), - branchName, - expectedHead, - ...(cleanupHostId ? { hostId: cleanupHostId } : {}) - }, - { timeoutMs: 15_000 } - )) - if (options?.suppressToast !== true) { - toast.success(translate('auto.store.slices.worktrees.19db0085fb', 'Local branch deleted'), { - description: translate( - 'auto.store.slices.worktrees.5a58e03a26', - 'Deleted "{{value0}}".', - { value0: branchName } - ) - }) - } - preservedBranchRuntimeTargetByCleanupKey.delete( - retainedTarget ? preservedBranchCleanupKey(retainedTarget.cleanup) : requestedCleanupKey - ) - return { ok: true as const, ...result } - } catch (err) { - const error = err instanceof Error ? err.message : String(err) - if (options?.suppressToast !== true) { - toast.error( - translate('auto.store.slices.worktrees.0216895fb5', 'Failed to delete branch'), - { - description: error - } - ) - } - return { ok: false as const, error } - } - } -} diff --git a/src/renderer/src/store/slices/worktrees/teardown/missing-worktree-terminal-teardown.ts b/src/renderer/src/store/slices/worktrees/teardown/missing-worktree-terminal-teardown.ts deleted file mode 100644 index fef22fcdec9..00000000000 --- a/src/renderer/src/store/slices/worktrees/teardown/missing-worktree-terminal-teardown.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' -import type { AppState } from '../../../types' -import type { DetectedWorktreeListResult } from '../../../../../../shared/worktree/types' -import { isRuntimeMethodNotFoundError } from '../listing/runtime-worktree-rpc-errors' - -// Why: teardown cannot ride the scan's coalescing (each caller has its own known-id -// snapshot), so dedupe on the request it actually produces — identical fan-out -// requests share one host sweep instead of re-scanning per caller. -const missingWorktreeTeardownsInFlight = new Map>() - -export async function teardownMissingWorktreeTerminalsBestEffort( - settings: AppState['settings'], - repoId: string, - connectionId: string | null | undefined, - // Why: refreshes that never purge omit this; an absent snapshot means - // "nothing to reconcile", never "purge everything". - knownWorktreeIds: readonly string[] | undefined, - detected: DetectedWorktreeListResult -): Promise { - if (!detected.authoritative || !knownWorktreeIds || knownWorktreeIds.length === 0) { - return - } - const detectedIds = new Set(detected.worktrees.map((worktree) => worktree.id)) - const missingIds = knownWorktreeIds.filter((worktreeId) => !detectedIds.has(worktreeId)) - if (missingIds.length === 0) { - return - } - const target = getActiveRuntimeTarget(settings) - const normalizedConnectionId = connectionId ?? null - const key = [ - target.kind === 'local' ? 'local' : `runtime:${target.environmentId}`, - repoId, - normalizedConnectionId ?? '', - [...missingIds].sort().join('\n') - ].join('\0') - const existing = missingWorktreeTeardownsInFlight.get(key) - if (existing) { - return existing - } - const teardown = (async () => { - try { - await callRuntimeRpc( - target, - 'worktree.teardownMissingTerminals', - { repo: repoId, worktreeIds: missingIds, connectionId: normalizedConnectionId }, - { timeoutMs: 30_000 } - ) - } catch (error) { - if (!isRuntimeMethodNotFoundError(error)) { - console.warn(`Failed to stop terminals for missing worktrees in repo ${repoId}:`, error) - } - } - })() - missingWorktreeTeardownsInFlight.set(key, teardown) - try { - await teardown - } finally { - if (missingWorktreeTeardownsInFlight.get(key) === teardown) { - missingWorktreeTeardownsInFlight.delete(key) - } - } -} diff --git a/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts b/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts deleted file mode 100644 index 9de7013cd16..00000000000 --- a/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { toSshExecutionHostId, type ExecutionHostId } from '../../../../../../shared/execution-host' -import type { AppState } from '../../../types' - -// Why: an SSH per-workspace-env project's host is the runtime-owned SSH target; once that runtime is destroyed, remove the project or it lingers as a dead, never-connectable one. -export async function purgeOrphanedRuntimeSshProjects( - get: () => AppState, - destroyedSshTargetIds: string[] -): Promise { - if (destroyedSshTargetIds.length === 0) { - return - } - const destroyedTargetIds = new Set(destroyedSshTargetIds) - const destroyedHostIds = new Set( - destroyedSshTargetIds.map((id) => toSshExecutionHostId(id)) - ) - const orphanedSetupIds = get() - .projectHostSetups.filter((setup) => destroyedHostIds.has(setup.hostId)) - .map((setup) => setup.id) - const purgedRepoIds = new Set() - for (const setupId of orphanedSetupIds) { - try { - const result = await get().deleteProjectHostSetup({ setupId }) - if (result?.repo) { - purgedRepoIds.add(result.repo.id) - } - } catch (error) { - console.error('Failed to purge orphaned per-workspace-env project:', error) - } - } - // A repo whose only host was the destroyed runtime can outlive its setup (pruned first by a projection refresh); remove it directly so no dead project lingers. - const orphanedRepoIds = get() - .repos.filter( - (repo) => destroyedTargetIds.has(repo.connectionId ?? '') && !purgedRepoIds.has(repo.id) - ) - .map((repo) => repo.id) - for (const repoId of orphanedRepoIds) { - try { - await get().removeProject(repoId) - } catch (error) { - console.error('Failed to purge orphaned per-workspace-env repo:', error) - } - } -} diff --git a/src/renderer/src/store/slices/worktrees/teardown/preserved-branch-cleanup-target.ts b/src/renderer/src/store/slices/worktrees/teardown/preserved-branch-cleanup-target.ts deleted file mode 100644 index 3cc0b25d843..00000000000 --- a/src/renderer/src/store/slices/worktrees/teardown/preserved-branch-cleanup-target.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' -import type { PreservedBranchCleanup } from '../../../../../../shared/preserved-branch-cleanup' - -export const preservedBranchRuntimeTargetByCleanupKey = new Map< - string, - { cleanup: PreservedBranchCleanup; target: ReturnType } ->() diff --git a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts deleted file mode 100644 index a23f0707d91..00000000000 --- a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts +++ /dev/null @@ -1,243 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import type { ProjectHostSetup } from '../../../../../../shared/project-types' -import type { Repo } from '../../../../../../shared/repo-types' -import type { DetectedWorktreeListResult } from '../../../../../../shared/worktree/types' -import type { ExecutionHostId } from '../../../../../../shared/execution-host' -import { - getRepoExecutionHostId, - toRuntimeExecutionHostId -} from '../../../../../../shared/execution-host' -import { getRepoIdFromWorktreeId } from '../../worktree-helpers' -import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope' -import { - dropWorktreeRowsForRemovedRuntimeEnvironments, - isRemovedRuntimeHostId -} from '../../stale-runtime-host-rows' -import { buildWorktreePurgeState } from './worktree-purge-state' - -export function createPurgeStaleRuntimeHostState( - set: WorktreeSliceSet, - _get: WorktreeSliceGet -): WorktreeSlice['purgeStaleRuntimeHostState'] { - return (removedEnvironmentIds) => { - const removed = new Set(removedEnvironmentIds) - if (removed.size === 0) { - return - } - set((s) => { - const repoIdsWithRemovedOwners = new Set() - const survivingRepoIds = new Set() - const repoIdsWithSurvivingOwners = new Set() - const survivingRepos: Repo[] = [] - for (const repo of s.repos) { - if (isRemovedRuntimeHostId(getRepoExecutionHostId(repo), removed)) { - repoIdsWithRemovedOwners.add(repo.id) - } else { - survivingRepos.push(repo) - survivingRepoIds.add(repo.id) - repoIdsWithSurvivingOwners.add(repo.id) - } - } - const reposChanged = survivingRepos.length !== s.repos.length - - // Why: a repoId-less setup on the removed host can still split a surviving project group, so drop every setup it owns. - const survivingSetups: ProjectHostSetup[] = [] - for (const setup of s.projectHostSetups) { - if (isRemovedRuntimeHostId(setup.hostId, removed)) { - if (setup.repoId) { - repoIdsWithRemovedOwners.add(setup.repoId) - } - } else { - survivingSetups.push(setup) - if (setup.repoId) { - repoIdsWithSurvivingOwners.add(setup.repoId) - } - } - } - const setupsChanged = survivingSetups.length !== s.projectHostSetups.length - const detectedRows: Record = - Object.fromEntries( - Object.entries(s.detectedWorktreesByRepo).map(([repoId, result]) => [ - repoId, - result.worktrees - ]) - ) - // Why: repo/setup catalogs can lag session hydration, so hosted worktree rows are ownership evidence during that gap. - const recordWorktreeOwners = ( - rowsByRepo: Record< - string, - readonly { hostId?: ExecutionHostId; runtimeOwnerEnvironmentId?: string }[] - > - ): void => { - for (const [repoId, rows] of Object.entries(rowsByRepo)) { - for (const row of rows) { - if (!row.hostId && !row.runtimeOwnerEnvironmentId) { - continue - } - const ownerWasRemoved = row.runtimeOwnerEnvironmentId - ? removed.has(row.runtimeOwnerEnvironmentId) - : isRemovedRuntimeHostId(row.hostId, removed) - const ownerSet = ownerWasRemoved ? repoIdsWithRemovedOwners : repoIdsWithSurvivingOwners - ownerSet.add(repoId) - } - } - } - recordWorktreeOwners(s.worktreesByRepo) - recordWorktreeOwners(detectedRows) - - const sessionWorktreeIdsOwnedByRemovedHosts = new Set() - let survivingRestoredSessionOwners = s.restoredRuntimeHostIdByWorkspaceSessionKey - for (const [workspaceKey, hostId] of Object.entries( - s.restoredRuntimeHostIdByWorkspaceSessionKey - )) { - const scope = parseWorkspaceKey(workspaceKey) - if (scope?.type === 'folder') { - continue - } - const worktreeId = scope?.type === 'worktree' ? scope.worktreeId : workspaceKey - const repoId = getRepoIdFromWorktreeId(worktreeId) - if (!isRemovedRuntimeHostId(hostId, removed)) { - // Why: restored sessions can be the only surviving-owner evidence before catalogs load. - repoIdsWithSurvivingOwners.add(repoId) - continue - } - sessionWorktreeIdsOwnedByRemovedHosts.add(worktreeId) - repoIdsWithRemovedOwners.add(repoId) - if (survivingRestoredSessionOwners === s.restoredRuntimeHostIdByWorkspaceSessionKey) { - survivingRestoredSessionOwners = { ...survivingRestoredSessionOwners } - } - delete survivingRestoredSessionOwners[workspaceKey] - } - - // Why: legacy rows predate host stamps; every owner record must agree no host survives before an unhosted row is retired. - const repoIdsWithoutSurvivingOwners = new Set(repoIdsWithRemovedOwners) - for (const repoId of repoIdsWithSurvivingOwners) { - repoIdsWithoutSurvivingOwners.delete(repoId) - } - - const worktreeDrop = dropWorktreeRowsForRemovedRuntimeEnvironments( - s.worktreesByRepo, - removed, - repoIdsWithoutSurvivingOwners - ) - const detectedDrop = dropWorktreeRowsForRemovedRuntimeEnvironments( - detectedRows, - removed, - repoIdsWithoutSurvivingOwners - ) - - const worktreesChanged = worktreeDrop.rowsByRepo !== s.worktreesByRepo - const detectedChanged = detectedDrop.rowsByRepo !== detectedRows - - const removedWorktreeIds = new Set([ - ...worktreeDrop.removedWorktreeIds, - ...detectedDrop.removedWorktreeIds, - ...sessionWorktreeIdsOwnedByRemovedHosts - ]) - // Why: terminal tabs hydrate before worktree metadata, so session-only ids for owner-less repos still need purging. - if (repoIdsWithoutSurvivingOwners.size > 0) { - for (const worktreeId of Object.keys(s.tabsByWorktree)) { - const scope = parseWorkspaceKey(worktreeId) - const rawWorktreeId = scope?.type === 'worktree' ? scope.worktreeId : worktreeId - if ( - scope?.type !== 'folder' && - repoIdsWithoutSurvivingOwners.has(getRepoIdFromWorktreeId(rawWorktreeId)) - ) { - removedWorktreeIds.add(rawWorktreeId) - } - } - } - // Why: bare-id state follows an exact survivor unless the restored-session partition proves it belonged to the removed host. - for (const rows of Object.values(worktreeDrop.rowsByRepo)) { - for (const row of rows) { - if (!sessionWorktreeIdsOwnedByRemovedHosts.has(row.id)) { - removedWorktreeIds.delete(row.id) - } - } - } - for (const rows of Object.values(detectedDrop.rowsByRepo)) { - for (const row of rows) { - if (!sessionWorktreeIdsOwnedByRemovedHosts.has(row.id)) { - removedWorktreeIds.delete(row.id) - } - } - } - const purgeState = - removedWorktreeIds.size > 0 ? buildWorktreePurgeState(s, [...removedWorktreeIds]) : {} - - const restoredSessionOwnersChanged = - survivingRestoredSessionOwners !== s.restoredRuntimeHostIdByWorkspaceSessionKey - let survivingVisibilityDefaults = s.worktreeVisibilityDefaultsByHost - for (const environmentId of removed) { - const hostId = toRuntimeExecutionHostId(environmentId) - if (hostId in survivingVisibilityDefaults) { - if (survivingVisibilityDefaults === s.worktreeVisibilityDefaultsByHost) { - survivingVisibilityDefaults = { ...survivingVisibilityDefaults } - } - delete survivingVisibilityDefaults[hostId] - } - } - const visibilityDefaultsChanged = - survivingVisibilityDefaults !== s.worktreeVisibilityDefaultsByHost - const visibilitySupportChanged = removed.has( - s.worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId ?? '' - ) - const visibilitySourceSupportChanged = removed.has( - s.worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId ?? '' - ) - if ( - !reposChanged && - !setupsChanged && - !worktreesChanged && - !detectedChanged && - !restoredSessionOwnersChanged && - !visibilityDefaultsChanged && - !visibilitySupportChanged && - !visibilitySourceSupportChanged && - removedWorktreeIds.size === 0 - ) { - return s - } - - const detectedWorktreesByRepo = detectedChanged - ? Object.fromEntries( - Object.entries(s.detectedWorktreesByRepo).map(([repoId, result]) => [ - repoId, - { ...result, worktrees: detectedDrop.rowsByRepo[repoId] } - ]) - ) - : s.detectedWorktreesByRepo - - const rowsChanged = worktreesChanged || detectedChanged - return { - ...purgeState, - ...(reposChanged ? { repos: survivingRepos } : {}), - ...(setupsChanged ? { projectHostSetups: survivingSetups } : {}), - ...(worktreesChanged ? { worktreesByRepo: worktreeDrop.rowsByRepo } : {}), - ...(detectedChanged ? { detectedWorktreesByRepo } : {}), - ...(restoredSessionOwnersChanged - ? { restoredRuntimeHostIdByWorkspaceSessionKey: survivingRestoredSessionOwners } - : {}), - ...(visibilityDefaultsChanged - ? { worktreeVisibilityDefaultsByHost: survivingVisibilityDefaults } - : {}), - ...(visibilitySupportChanged - ? { worktreeVisibilityDefaultsSupportedRuntimeEnvironmentId: null } - : {}), - ...(visibilitySourceSupportChanged - ? { worktreeVisibilitySourceDefaultsSupportedRuntimeEnvironmentId: null } - : {}), - ...(rowsChanged ? { sortEpoch: s.sortEpoch + 1 } : {}), - // Why: mirror validateRepoScopedUi so a filtered/active sidebar can't reference a purged repo id. - ...(reposChanged - ? { - activeRepoId: - s.activeRepoId && survivingRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, - filterRepoIds: s.filterRepoIds.filter((repoId) => survivingRepoIds.has(repoId)) - } - : {}) - } - }) - } -} diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts deleted file mode 100644 index f433162297b..00000000000 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope' -import type { WorktreeSliceSet } from '../listing/worktree-slice-types' - -export function applyRemoveWorktreeSuccessState( - set: WorktreeSliceSet, - worktreeId: string, - tabIds: Set -): void { - set((s) => { - const next = { ...s.worktreesByRepo } - for (const repoId of Object.keys(next)) { - next[repoId] = next[repoId].filter((w) => w.id !== worktreeId) - } - const nextTabs = { ...s.tabsByWorktree } - delete nextTabs[worktreeId] - const nextLayouts = { ...s.terminalLayoutsByTabId } - const nextPtyIdsByTabId = { ...s.ptyIdsByTabId } - const nextRuntimePaneTitlesByTabId = { ...s.runtimePaneTitlesByTabId } - const nextAutomaticAgentResumeClaimsByTabId = { - ...s.automaticAgentResumeClaimsByTabId - } - const nextNativeChatLaunchPromptByTabId = { ...s.nativeChatLaunchPromptByTabId } - const nextNativeChatLaunchDraftByTabId = { ...s.nativeChatLaunchDraftByTabId } - // Why: closeTab deletes these per-tab maps but removeWorktree missed them, leaking a split pane's expand flags. - const nextExpandedPaneByTabId = { ...s.expandedPaneByTabId } - const nextCanExpandPaneByTabId = { ...s.canExpandPaneByTabId } - for (const tabId of tabIds) { - delete nextLayouts[tabId] - delete nextPtyIdsByTabId[tabId] - delete nextRuntimePaneTitlesByTabId[tabId] - delete nextAutomaticAgentResumeClaimsByTabId[tabId] - delete nextNativeChatLaunchPromptByTabId[tabId] - delete nextNativeChatLaunchDraftByTabId[tabId] - delete nextExpandedPaneByTabId[tabId] - delete nextCanExpandPaneByTabId[tabId] - } - const nextDeleteState = { ...s.deleteStateByWorktreeId } - delete nextDeleteState[worktreeId] - const nextLineage = { ...s.worktreeLineageById } - delete nextLineage[worktreeId] - const nextWorkspaceLineage = { ...s.workspaceLineageByChildKey } - delete nextWorkspaceLineage[worktreeWorkspaceKey(worktreeId)] - // Clean up editor files belonging to this worktree - const newOpenFiles = s.openFiles.filter((f) => f.worktreeId !== worktreeId) - const nextBrowserTabsByWorktree = { ...s.browserTabsByWorktree } - delete nextBrowserTabsByWorktree[worktreeId] - const nextActiveFileIdByWorktree = { ...s.activeFileIdByWorktree } - delete nextActiveFileIdByWorktree[worktreeId] - const nextActiveBrowserTabIdByWorktree = { ...s.activeBrowserTabIdByWorktree } - delete nextActiveBrowserTabIdByWorktree[worktreeId] - // Why: closeBrowserTab records a Cmd+Shift+T undo snapshot, but a deleted worktree's tabs can't be restored; purge it. - const nextRecentlyClosedBrowserTabsByWorktree = { - ...s.recentlyClosedBrowserTabsByWorktree - } - delete nextRecentlyClosedBrowserTabsByWorktree[worktreeId] - const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree } - delete nextActiveTabTypeByWorktree[worktreeId] - const nextActiveTabIdByWorktree = { ...s.activeTabIdByWorktree } - delete nextActiveTabIdByWorktree[worktreeId] - const nextTabBarOrderByWorktree = { ...s.tabBarOrderByWorktree } - // Why: the tab strip persists visual order per worktree; drop the entry so stale tab IDs aren't retained. - delete nextTabBarOrderByWorktree[worktreeId] - const nextPendingReconnectTabByWorktree = { ...s.pendingReconnectTabByWorktree } - delete nextPendingReconnectTabByWorktree[worktreeId] - // Why: split-tab layout/group state is worktree-owned; leaving it makes a deleted worktree look restorable. - const nextUnifiedTabsByWorktree = { ...s.unifiedTabsByWorktree } - delete nextUnifiedTabsByWorktree[worktreeId] - const nextGroupsByWorktree = { ...s.groupsByWorktree } - delete nextGroupsByWorktree[worktreeId] - const nextLayoutByWorktree = { ...s.layoutByWorktree } - delete nextLayoutByWorktree[worktreeId] - const nextActiveGroupIdByWorktree = { ...s.activeGroupIdByWorktree } - delete nextActiveGroupIdByWorktree[worktreeId] - // Why: git status/compare caches stop refreshing once the worktree is deleted; remove them so no stale badges/diffs linger. - const nextGitStatusByWorktree = { ...s.gitStatusByWorktree } - delete nextGitStatusByWorktree[worktreeId] - const nextGitStatusHeadByWorktree = { ...s.gitStatusHeadByWorktree } - delete nextGitStatusHeadByWorktree[worktreeId] - const nextGitBranchLineTotalByWorktree = { ...s.gitBranchLineTotalByWorktree } - delete nextGitBranchLineTotalByWorktree[worktreeId] - const nextGitIgnoredPathsByWorktree = { ...s.gitIgnoredPathsByWorktree } - delete nextGitIgnoredPathsByWorktree[worktreeId] - const nextGitConflictOperationByWorktree = { ...s.gitConflictOperationByWorktree } - delete nextGitConflictOperationByWorktree[worktreeId] - const nextTrackedConflictPathsByWorktree = { ...s.trackedConflictPathsByWorktree } - delete nextTrackedConflictPathsByWorktree[worktreeId] - const nextGitBranchChangesByWorktree = { ...s.gitBranchChangesByWorktree } - delete nextGitBranchChangesByWorktree[worktreeId] - const nextGitBranchCompareSummaryByWorktree = { ...s.gitBranchCompareSummaryByWorktree } - delete nextGitBranchCompareSummaryByWorktree[worktreeId] - const nextGitBranchCompareRequestKeyByWorktree = { - ...s.gitBranchCompareRequestKeyByWorktree - } - delete nextGitBranchCompareRequestKeyByWorktree[worktreeId] - const nextGitBranchCompareRequestStatusHeadByWorktree = { - ...s.gitBranchCompareRequestStatusHeadByWorktree - } - delete nextGitBranchCompareRequestStatusHeadByWorktree[worktreeId] - // Why: clean up per-file editor state for the removed worktree so stale drafts/view modes don't accumulate. - const removedFileIds = new Set() - for (const file of s.openFiles) { - if (file.worktreeId !== worktreeId) { - continue - } - removedFileIds.add(file.id) - if (file.markdownPreviewSourceFileId) { - removedFileIds.add(file.markdownPreviewSourceFileId) - } - } - const nextEditorDrafts = removedFileIds.size > 0 ? { ...s.editorDrafts } : s.editorDrafts - const nextMarkdownViewMode = - removedFileIds.size > 0 ? { ...s.markdownViewMode } : s.markdownViewMode - const nextEditorViewMode = removedFileIds.size > 0 ? { ...s.editorViewMode } : s.editorViewMode - const nextMarkdownFrontmatterVisible = - removedFileIds.size > 0 ? { ...s.markdownFrontmatterVisible } : s.markdownFrontmatterVisible - // Why: editorCursorLine is keyed by fileId; clear it with the other per-file state so it doesn't leak. - const nextEditorCursorLine = - removedFileIds.size > 0 ? { ...s.editorCursorLine } : s.editorCursorLine - if (removedFileIds.size > 0) { - for (const fileId of removedFileIds) { - delete nextEditorDrafts[fileId] - delete nextMarkdownViewMode[fileId] - delete nextEditorViewMode[fileId] - delete nextMarkdownFrontmatterVisible[fileId] - delete nextEditorCursorLine[fileId] - } - } - const nextExpandedDirs = { ...s.expandedDirs } - delete nextExpandedDirs[worktreeId] - const nextShowDotfilesByWorktree = { ...s.showDotfilesByWorktree } - delete nextShowDotfilesByWorktree[worktreeId] - // Why: clear the huge-status marker so it doesn't linger after the worktree is gone. - const nextGitStatusHugeByWorktree = { ...s.gitStatusHugeByWorktree } - delete nextGitStatusHugeByWorktree[worktreeId] - const nextRightSidebarExplorerViewByWorktree = { - ...s.rightSidebarExplorerViewByWorktree - } - delete nextRightSidebarExplorerViewByWorktree[worktreeId] - // If the active file belonged to the removed worktree, clear it - const activeFileCleared = s.activeFileId - ? s.openFiles.some((f) => f.id === s.activeFileId && f.worktreeId === worktreeId) - : false - const removedActiveWorktree = s.activeWorktreeId === worktreeId - const nextEverActivatedWorktreeIds = s.everActivatedWorktreeIds.has(worktreeId) - ? new Set([...s.everActivatedWorktreeIds].filter((id) => id !== worktreeId)) - : s.everActivatedWorktreeIds - const nextLastVisitedAtByWorktreeId = - worktreeId in s.lastVisitedAtByWorktreeId - ? (() => { - const next = { ...s.lastVisitedAtByWorktreeId } - delete next[worktreeId] - return next - })() - : s.lastVisitedAtByWorktreeId - return { - worktreesByRepo: next, - worktreeLineageById: nextLineage, - workspaceLineageByChildKey: nextWorkspaceLineage, - tabsByWorktree: nextTabs, - ptyIdsByTabId: nextPtyIdsByTabId, - runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId, - automaticAgentResumeClaimsByTabId: nextAutomaticAgentResumeClaimsByTabId, - nativeChatLaunchPromptByTabId: nextNativeChatLaunchPromptByTabId, - nativeChatLaunchDraftByTabId: nextNativeChatLaunchDraftByTabId, - terminalLayoutsByTabId: nextLayouts, - expandedPaneByTabId: nextExpandedPaneByTabId, - canExpandPaneByTabId: nextCanExpandPaneByTabId, - deleteStateByWorktreeId: nextDeleteState, - baseStatusByWorktreeId: (() => { - const nextStatus = { ...s.baseStatusByWorktreeId } - delete nextStatus[worktreeId] - return nextStatus - })(), - remoteBranchConflictByWorktreeId: (() => { - const nextConflict = { ...s.remoteBranchConflictByWorktreeId } - delete nextConflict[worktreeId] - return nextConflict - })(), - fileSearchStateByWorktree: (() => { - const nextSearch = { ...s.fileSearchStateByWorktree } - // Why: file search state is worktree-scoped; clear it so another worktree can't inherit stale matches. - delete nextSearch[worktreeId] - return nextSearch - })(), - // Why: these worktree-keyed maps are re-keyed on rename but were missed by removal, leaking one entry each. - remoteStatusesByWorktree: (() => { - const next = { ...s.remoteStatusesByWorktree } - delete next[worktreeId] - return next - })(), - recentlyClosedEditorTabsByWorktree: (() => { - const next = { ...s.recentlyClosedEditorTabsByWorktree } - delete next[worktreeId] - return next - })(), - recentlyClosedTerminalTabsByWorktree: (() => { - const next = { ...s.recentlyClosedTerminalTabsByWorktree } - delete next[worktreeId] - return next - })(), - // Why: a deleted worktree's tabs can never be reopened; purge the kind list with the snapshot stacks above. - recentlyClosedTabKindsByWorktree: (() => { - const next = { ...s.recentlyClosedTabKindsByWorktree } - delete next[worktreeId] - return next - })(), - defaultTerminalTabsAppliedByWorktreeId: (() => { - const next = { ...s.defaultTerminalTabsAppliedByWorktreeId } - delete next[worktreeId] - return next - })(), - activeWorktreeId: removedActiveWorktree ? null : s.activeWorktreeId, - activeWorkspaceExecutionHostId: removedActiveWorktree - ? null - : s.activeWorkspaceExecutionHostId, - activeTabId: s.activeTabId && tabIds.has(s.activeTabId) ? null : s.activeTabId, - openFiles: newOpenFiles, - browserTabsByWorktree: nextBrowserTabsByWorktree, - recentlyClosedBrowserTabsByWorktree: nextRecentlyClosedBrowserTabsByWorktree, - activeFileIdByWorktree: nextActiveFileIdByWorktree, - activeBrowserTabIdByWorktree: nextActiveBrowserTabIdByWorktree, - activeTabTypeByWorktree: nextActiveTabTypeByWorktree, - rightSidebarExplorerViewByWorktree: nextRightSidebarExplorerViewByWorktree, - activeTabIdByWorktree: nextActiveTabIdByWorktree, - tabBarOrderByWorktree: nextTabBarOrderByWorktree, - pendingReconnectTabByWorktree: nextPendingReconnectTabByWorktree, - unifiedTabsByWorktree: nextUnifiedTabsByWorktree, - groupsByWorktree: nextGroupsByWorktree, - layoutByWorktree: nextLayoutByWorktree, - activeGroupIdByWorktree: nextActiveGroupIdByWorktree, - editorDrafts: nextEditorDrafts, - markdownViewMode: nextMarkdownViewMode, - editorViewMode: nextEditorViewMode, - markdownFrontmatterVisible: nextMarkdownFrontmatterVisible, - editorCursorLine: nextEditorCursorLine, - showDotfilesByWorktree: nextShowDotfilesByWorktree, - expandedDirs: nextExpandedDirs, - gitStatusHugeByWorktree: nextGitStatusHugeByWorktree, - gitStatusByWorktree: nextGitStatusByWorktree, - gitStatusHeadByWorktree: nextGitStatusHeadByWorktree, - gitBranchLineTotalByWorktree: nextGitBranchLineTotalByWorktree, - gitIgnoredPathsByWorktree: nextGitIgnoredPathsByWorktree, - gitConflictOperationByWorktree: nextGitConflictOperationByWorktree, - trackedConflictPathsByWorktree: nextTrackedConflictPathsByWorktree, - gitBranchChangesByWorktree: nextGitBranchChangesByWorktree, - gitBranchCompareSummaryByWorktree: nextGitBranchCompareSummaryByWorktree, - gitBranchCompareRequestKeyByWorktree: nextGitBranchCompareRequestKeyByWorktree, - gitBranchCompareRequestStatusHeadByWorktree: nextGitBranchCompareRequestStatusHeadByWorktree, - activeFileId: activeFileCleared ? null : s.activeFileId, - activeBrowserTabId: removedActiveWorktree ? null : s.activeBrowserTabId, - activeTabType: removedActiveWorktree || activeFileCleared ? 'terminal' : s.activeTabType, - everActivatedWorktreeIds: nextEverActivatedWorktreeIds, - lastVisitedAtByWorktreeId: nextLastVisitedAtByWorktreeId, - sortEpoch: s.sortEpoch + 1 - } - }) -} diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts deleted file mode 100644 index c3934c2c6a4..00000000000 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree.ts +++ /dev/null @@ -1,317 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -import type { RemoveWorktreeResult } from '../../../../../../shared/worktree/create-types' -import { getRepoIdFromWorktreeId } from '../../worktree-helpers' -import { parseExecutionHostId } from '../../../../../../shared/execution-host' -import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' -import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup' -import { disposeRemovedWorktreeParkedTerminalWatchers } from '../../../../components/terminal-pane/terminal-parked-watcher-registry' -import { callRuntimeRpc, getActiveRuntimeTarget } from '../../../../runtime/runtime-rpc-client' -import { toRuntimeWorktreeSelector } from '../../../../runtime/runtime-worktree-selector' -import { requestVirtualizedScrollAnchorRecord } from '@/hooks/requestVirtualizedScrollAnchorRecord' -import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs' -import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards' -import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-control-huge-repo-warning-dismissals' -import { clearSessionCommitDraftForWorktree } from '@/lib/source-control-commit-draft-session' -import { showPreservedBranchToast } from '@/components/sidebar/preserved-branch-toast' -import { - resolveWorktreeOperationRoute, - resolveWorktreeOperationRouteResult, - settingsForWorktreeOperationRoute -} from '@/lib/worktree-operation-route' -import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operation-generation' -import { - classifyWorktreeForceDeleteReason, - getLockedWorktreeRemovalReason, - isLockedWorktreeRemovalError -} from '../../../../../../shared/worktree/removal' -import { preservedBranchCleanupKey } from '../../../../../../shared/preserved-branch-cleanup' -import { WORKTREE_REMOVAL_AMBIGUOUS_ERROR } from '../listing/worktree-slice-constants' -import { detachedHeadAutoDerivedDisplayNames } from '../metadata/detached-head-display-name' -import { pruneHostedReviewLinkMutationGenerations } from '../metadata/hosted-review-link-mutation' -import { rememberAuthoritativelyRemovedWorktrees } from '../listing/authoritative-worktree-removal-memory' -import { purgeOrphanedRuntimeSshProjects } from './orphaned-runtime-ssh-project-purge' -import { preservedBranchRuntimeTargetByCleanupKey } from './preserved-branch-cleanup-target' -import { - isRuntimeRepoNotFoundError, - isRuntimeSelectorNotFoundError -} from '../listing/runtime-worktree-rpc-errors' -import { applyRemoveWorktreeSuccessState } from './remove-worktree-store-cleanup' - -export function createRemoveWorktree( - set: WorktreeSliceSet, - get: WorktreeSliceGet -): WorktreeSlice['removeWorktree'] { - return async (worktreeId, force, options) => { - const forgetLocalOnly = options?.mode === 'forget-local' - const removalRoute = resolveWorktreeOperationRoute(get(), worktreeId) - if (!forgetLocalOnly && !removalRoute) { - return { ok: false, error: WORKTREE_REMOVAL_AMBIGUOUS_ERROR } - } - const hostId = removalRoute?.executionHostId ?? undefined - const removalGenerationGuard = removalRoute - ? captureWorktreeOperationGenerationGuard( - get, - worktreeId, - removalRoute, - () => new Error(WORKTREE_REMOVAL_AMBIGUOUS_ERROR) - ) - : null - set((s) => ({ - deleteStateByWorktreeId: { - ...s.deleteStateByWorktreeId, - [worktreeId]: { - isDeleting: true, - phase: 'deleting', - error: null, - canForceDelete: false, - forceDeleteReason: null - } - } - })) - - try { - // Why: forget-local touches no remote, so there's no archive hook to run or trust prompt needed. - const skipArchive = forgetLocalOnly - ? true - : (await ensureHooksConfirmed( - get(), - getRepoIdFromWorktreeId(worktreeId), - 'archive', - hostId, - removalRoute?.runtimeEnvironmentId - )) === 'skip' - - const worktreeBeforeRemoval = get() - .allWorktrees() - .find((entry) => entry.id === worktreeId) - const terminalPtyIdsBeforeRemoval = (get().tabsByWorktree[worktreeId] ?? []).flatMap( - (tab) => get().ptyIdsByTabId[tab.id] ?? [] - ) - if (!forgetLocalOnly) { - removalGenerationGuard?.assertCurrent() - } - // Why: forget-local clears Orca's records via local IPC regardless of host — the remote is gone or unreachable. - const target = getActiveRuntimeTarget( - removalRoute - ? settingsForWorktreeOperationRoute(get().settings, removalRoute) - : get().settings - ? { ...get().settings, activeRuntimeEnvironmentId: null } - : { activeRuntimeEnvironmentId: null } - ) - let removalResult: RemoveWorktreeResult - let snapshotPruneHandledByLocalMain = forgetLocalOnly || target.kind === 'local' - try { - removalResult = await (forgetLocalOnly - ? window.api.worktrees.forgetLocal({ - worktreeId, - hostId, - ...(options?.snapshotPruneBatchId - ? { snapshotPruneBatchId: options.snapshotPruneBatchId } - : {}) - }) - : target.kind === 'local' - ? (removalGenerationGuard?.assertCurrent(), - window.api.worktrees.remove({ - worktreeId, - hostId, - force, - allowUnverifiedPtyStop: options?.allowUnverifiedPtyStop === true, - skipArchive, - ...(options?.snapshotPruneBatchId - ? { snapshotPruneBatchId: options.snapshotPruneBatchId } - : {}) - })) - : (removalGenerationGuard?.assertCurrent(), - callRuntimeRpc( - target, - 'worktree.rm', - { - worktree: toRuntimeWorktreeSelector(worktreeId), - ...(hostId ? { hostId } : {}), - force, - allowUnverifiedPtyStop: options?.allowUnverifiedPtyStop === true, - runHooks: !skipArchive - }, - { timeoutMs: 60_000 } - ))) - } catch (error) { - if ( - !forgetLocalOnly && - target.kind !== 'local' && - (isRuntimeRepoNotFoundError(error) || isRuntimeSelectorNotFoundError(error)) - ) { - // Missing means stale mirror; ambiguous or changed ownership must fail closed. - const currentResolution = resolveWorktreeOperationRouteResult(get(), worktreeId) - if (currentResolution.kind === 'ambiguous') { - throw error - } - if (currentResolution.kind === 'resolved') { - removalGenerationGuard?.assertCurrent() - } - try { - removalResult = await window.api.worktrees.forgetLocal({ - worktreeId, - hostId, - ...(options?.snapshotPruneBatchId - ? { snapshotPruneBatchId: options.snapshotPruneBatchId } - : {}) - }) - snapshotPruneHandledByLocalMain = true - } catch (fallbackError) { - // Preserve the remote verdict as fallback failure context. - throw new Error( - fallbackError instanceof Error ? fallbackError.message : String(fallbackError), - { cause: error } - ) - } - } else { - throw error - } - } - - if (!snapshotPruneHandledByLocalMain) { - try { - await window.api.workspaceCleanup?.recordRemovalSnapshotPrune?.({ - // Why: a single (unbatched) remote delete must still drop the row - // from the local persisted snapshots or it resurrects from cache; - // an unknown batch id degrades to an immediate one-off prune. The - // id must stay bounded — main rejects batch ids over 128 chars, - // so it cannot embed the unbounded worktreeId. - batchId: options?.snapshotPruneBatchId ?? `single-removal:${crypto.randomUUID()}`, - worktreeId, - ...(hostId ? { executionHostId: hostId } : {}) - }) - } catch (error) { - console.warn('Failed to record workspace cleanup snapshot prune:', error) - } - } - - // Why: invalidate stale probes once deletion is authoritative, so an old toast can't mutate a same-path replacement. - forgetHugeRepoWarningDismissalsForWorktrees([worktreeId]) - // Why: forget-local is legal while the host is unreachable, so record the removal here too — otherwise an - // in-flight metadata read that snapshotted this row re-appends it, and disconnected polls never drop it. - if (hostId && parseExecutionHostId(hostId)?.kind === 'ssh') { - rememberAuthoritativelyRemovedWorktrees(hostId, [worktreeId]) - } - - const worktreeDisplayName = worktreeBeforeRemoval?.displayName?.trim() - if (worktreeDisplayName) { - try { - await window.api.automations?.snapshotWorkspaceName?.({ - workspaceId: worktreeId, - displayName: worktreeDisplayName - }) - } catch (error) { - // Why: snapshotting automation labels is best-effort; a stale preload/test harness must not block removal. - console.warn('Failed to snapshot automation workspace name:', error) - } - } - - // Why: renderer state follows the successful backend result, so blocked dirty deletes keep their terminals intact. - // Why browsers first: unregister Chromium guests before other teardown can intercept them (avoids a browser-state race). - await get().shutdownWorktreeBrowsers(worktreeId) - await get().shutdownWorktreeTerminals(worktreeId, { - shutdownReason: 'remove-worktree', - // The backend removal above already killed the workspace's PTYs. - backendOwnsPtyTeardown: true - }) - // Why: dispose the SSH relay AFTER terminal teardown so a still-mounted pane can't hit a gone relay and toast "SSH not active". - const runtimeCleanup = await cleanupEphemeralVmRuntimesForDeleted({ - workspaceIds: [worktreeId] - }) - // Remove the orphaned project for the destroyed SSH target so it can't surface as a dead project in the composer. - await purgeOrphanedRuntimeSshProjects(get, runtimeCleanup.destroyedSshTargetIds) - const tabs = get().tabsByWorktree[worktreeId] ?? [] - const tabIds = new Set(tabs.map((t) => t.id)) - - // Why: this path deletes tabsByWorktree wholesale (not via closeTab), so purge the module-level tab maps here too. - detachedHeadAutoDerivedDisplayNames.delete(worktreeId) - forgetForegroundTerminalTabs(tabIds) - forgetAgentStartupDeliveriesForTabs(tabIds) - - // Why: snapshot the sidebar top-row anchor in the same tick we remove the row; recording at click time goes stale across the await. - requestVirtualizedScrollAnchorRecord('[data-worktree-sidebar]') - - // Why: dispose parked terminal watchers only on explicit deletion; identity migration/remounts must keep buffered PTY state. - disposeRemovedWorktreeParkedTerminalWatchers(worktreeId, terminalPtyIdsBeforeRemoval) - applyRemoveWorktreeSuccessState(set, worktreeId, tabIds) - get().removeWorkspaceSpaceWorktrees?.([worktreeId]) - // Why: PR/commit-message generation records are keyed by worktree; prune to the surviving set so they don't leak. - const liveWorktreeKeys = new Set( - get() - .allWorktrees() - .map((w) => w.id) - ) - // Optional-chained: minimal store assemblies (some unit tests) omit the generation slices. - get().prunePullRequestGenerationRecords?.(liveWorktreeKeys) - get().pruneCommitMessageGenerationRecords?.(liveWorktreeKeys) - // Why: Source Control may be unmounted during deletion, so it can't be the only stale-draft cleanup path. - clearSessionCommitDraftForWorktree(worktreeId) - const preservedBranch = removalResult?.preservedBranch - const cleanup = preservedBranch - ? { - worktreeId, - branchName: preservedBranch.branchName, - expectedHead: preservedBranch.head, - ...(hostId ? { hostId } : {}), - ...(removalRoute?.runtimeEnvironmentId - ? { runtimeEnvironmentId: removalRoute.runtimeEnvironmentId } - : {}) - } - : null - if (preservedBranch) { - preservedBranchRuntimeTargetByCleanupKey.set(preservedBranchCleanupKey(cleanup!), { - cleanup: cleanup!, - target - }) - } - if (preservedBranch && options?.suppressPreservedBranchToast !== true) { - showPreservedBranchToast(removalResult, worktreeBeforeRemoval, (branch, expectedHead) => { - void get().forceDeletePreservedBranch(worktreeId, branch, expectedHead, { - ...(hostId ? { hostId } : {}), - ...(removalRoute?.runtimeEnvironmentId - ? { runtimeEnvironmentId: removalRoute.runtimeEnvironmentId } - : {}) - }) - }) - } - pruneHostedReviewLinkMutationGenerations([worktreeId]) - return preservedBranch && cleanup - ? { - ok: true as const, - preservedBranch: { - ...preservedBranch, - ...(cleanup.hostId ? { hostId: cleanup.hostId } : {}), - ...(cleanup.runtimeEnvironmentId - ? { runtimeEnvironmentId: cleanup.runtimeEnvironmentId } - : {}) - } - } - : { ok: true as const } - } catch (err) { - // Why: git refusing a non-force delete for dirty/untracked files is a handled user decision, not an app error. - console.warn('Failed to remove worktree:', err) - const error = err instanceof Error ? err.message : String(err) - const forceDeleteReason = classifyWorktreeForceDeleteReason( - error, - force, - options?.allowUnverifiedPtyStop === true - ) - const locked = isLockedWorktreeRemovalError(error) - set((s) => ({ - deleteStateByWorktreeId: { - ...s.deleteStateByWorktreeId, - [worktreeId]: { - isDeleting: false, - error, - canForceDelete: forceDeleteReason !== null, - forceDeleteReason, - ...(locked ? { lockReason: getLockedWorktreeRemovalReason(error) } : {}) - } - } - })) - return { ok: false as const, error } - } - } -} diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-delete-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-delete-state.ts deleted file mode 100644 index 4d7074988d7..00000000000 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-delete-state.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { WorktreeSlice } from '../../worktree-helpers' -import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types' -export function createMarkWorktreesDeleting( - set: WorktreeSliceSet, - _get: WorktreeSliceGet -): WorktreeSlice['markWorktreesDeleting'] { - return (worktreeIds) => { - if (worktreeIds.length === 0) { - return - } - set((s) => { - const nextDeleteState = { ...s.deleteStateByWorktreeId } - let changed = false - for (const worktreeId of new Set(worktreeIds)) { - const current = nextDeleteState[worktreeId] - if (current?.isDeleting && current.error === null && !current.canForceDelete) { - continue - } - nextDeleteState[worktreeId] = { - isDeleting: true, - phase: 'deleting', - error: null, - canForceDelete: false, - forceDeleteReason: null - } - changed = true - } - return changed ? { deleteStateByWorktreeId: nextDeleteState } : {} - }) - } -} - -export function createMarkWorktreesQueuedForDeletion( - set: WorktreeSliceSet, - _get: WorktreeSliceGet -): WorktreeSlice['markWorktreesQueuedForDeletion'] { - return (worktreeIds) => { - if (worktreeIds.length === 0) { - return - } - set((s) => { - const nextDeleteState = { ...s.deleteStateByWorktreeId } - let changed = false - for (const worktreeId of new Set(worktreeIds)) { - const current = nextDeleteState[worktreeId] - if (current?.isDeleting && current.error === null && !current.canForceDelete) { - continue - } - nextDeleteState[worktreeId] = { - isDeleting: true, - phase: 'queued', - error: null, - canForceDelete: false, - forceDeleteReason: null - } - changed = true - } - return changed ? { deleteStateByWorktreeId: nextDeleteState } : {} - }) - } -} - -export function createClearWorktreeDeleteState( - set: WorktreeSliceSet, - _get: WorktreeSliceGet -): WorktreeSlice['clearWorktreeDeleteState'] { - return (worktreeId) => { - set((s) => { - if (!s.deleteStateByWorktreeId[worktreeId]) { - return {} - } - const next = { ...s.deleteStateByWorktreeId } - delete next[worktreeId] - return { deleteStateByWorktreeId: next } - }) - } -} diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-doomed-ids.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-doomed-ids.ts deleted file mode 100644 index 1b0110c3b92..00000000000 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-doomed-ids.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { AppState } from '../../../types' -import { forgetAgentHibernationTabOutput } from '@/lib/agent-hibernation-output-activity' -import { forgetForegroundTerminalTabs } from '@/lib/foreground-terminal-tabs' -import { forgetAgentStartupDeliveriesForTabs } from '@/lib/agent-startup-delivery-guards' -import { forgetAgentPaneAuthorityAliasesByTabIds } from '../../agent-pane-authority' -import { forgetDetachedHeadAutoDerivedDisplayName } from '../metadata/detached-head-display-name' - -export type WorktreePurgeDoomedIds = { - doomedTabIds: Set - doomedPtyIds: Set - doomedBrowserWorkspaceIds: Set - doomedPageIds: Set - removedFileIds: Set -} - -export function collectWorktreePurgeDoomedIds( - s: AppState, - worktreeIdSet: Set -): WorktreePurgeDoomedIds { - // Collect every tab id (and removed file id) we are about to orphan. - const doomedTabIds = new Set() - // Why: some terminal/agent maps are keyed by ptyId, not tabId; collect durable wake hints too since slept panes have left the live index. - const doomedPtyIds = new Set() - const addDoomedPtyId = (ptyId: string | null | undefined): void => { - if (!ptyId) { - return - } - doomedPtyIds.add(ptyId) - } - const addDoomedTabPtyIds = (tabId: string, tabPtyId: string | null | undefined): void => { - for (const ptyId of s.ptyIdsByTabId?.[tabId] ?? []) { - addDoomedPtyId(ptyId) - } - addDoomedPtyId(tabPtyId) - addDoomedPtyId(s.lastKnownRelayPtyIdByTabId?.[tabId]) - for (const ptyId of Object.values(s.terminalLayoutsByTabId?.[tabId]?.ptyIdsByLeafId ?? {})) { - addDoomedPtyId(ptyId) - } - } - const doomedBrowserWorkspaceIds = new Set() - const doomedPageIds = new Set() - const removedFileIds = new Set() - for (const id of worktreeIdSet) { - for (const tab of s.tabsByWorktree[id] ?? []) { - doomedTabIds.add(tab.id) - // Null-tolerant like the omit* helpers below: some callers pass partial state omitting this slice (production store always inits to {}). - addDoomedTabPtyIds(tab.id, tab.ptyId) - // Why: a removed worktree's panes are gone, so drop their hibernation output epochs from the module-level map (a future pane mints a fresh leafId at epoch 0). - forgetAgentHibernationTabOutput(tab.id) - } - for (const workspace of s.browserTabsByWorktree[id] ?? []) { - doomedBrowserWorkspaceIds.add(workspace.id) - } - // Why: drop the auto-derived detached-HEAD display name so the module-level map doesn't retain removed worktrees for the session. - forgetDetachedHeadAutoDerivedDisplayName(id) - } - // Why: same rationale for doomed tabs' foreground last-seen timestamps and agent-startup delivery guards — retired tab ids never recur. - forgetForegroundTerminalTabs(doomedTabIds) - forgetAgentStartupDeliveriesForTabs(doomedTabIds) - // Why: pane-authority aliases outlive the store maps they route to, so a purged - // tab would leave a permanent entry pointing at a pane that no longer exists. - forgetAgentPaneAuthorityAliasesByTabIds(doomedTabIds) - // Why: per-page browser maps are keyed by page id, so collect every page of a doomed workspace to evict here (the authoritative-scan reconcile skips closeBrowserTab's cleanup). - for (const workspaceId of doomedBrowserWorkspaceIds) { - for (const page of s.browserPagesByWorkspace[workspaceId] ?? []) { - doomedPageIds.add(page.id) - } - } - for (const file of s.openFiles) { - if (worktreeIdSet.has(file.worktreeId)) { - removedFileIds.add(file.id) - if (file.markdownPreviewSourceFileId) { - removedFileIds.add(file.markdownPreviewSourceFileId) - } - } - } - return { - doomedTabIds, - doomedPtyIds, - doomedBrowserWorkspaceIds, - doomedPageIds, - removedFileIds - } -} diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-omitters.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-omitters.ts deleted file mode 100644 index 661f416595b..00000000000 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-omitters.ts +++ /dev/null @@ -1,159 +0,0 @@ -import type { AppState } from '../../../types' -import type { WorkspaceLineage } from '../../../../../../shared/worktree/lineage-types' -import { isWorkspaceKey, worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope' -import type { WorktreePurgeDoomedIds } from './worktree-purge-doomed-ids' - -export function createWorktreePurgeOmitters( - s: AppState, - worktreeIdSet: Set, - doomed: WorktreePurgeDoomedIds -) { - const { doomedTabIds, doomedPtyIds, doomedBrowserWorkspaceIds, doomedPageIds, removedFileIds } = - doomed - const omitByWorktree = (obj: Record): Record => { - let changed = false - const out = { ...obj } - for (const id of worktreeIdSet) { - if (id in out) { - delete out[id] - changed = true - } - } - return changed ? out : obj - } - const omitWorkspaceLineageByWorktree = ( - obj: Record - ): Record => { - let changed = false - const out = { ...obj } - for (const id of worktreeIdSet) { - const childKey = isWorkspaceKey(id) ? id : worktreeWorkspaceKey(id) - if (childKey in out) { - delete out[childKey] - changed = true - } - } - return changed ? out : obj - } - const pruneRightSidebarTabByWorktree = (): AppState['rightSidebarTabByWorktree'] => { - const omitted = omitByWorktree(s.rightSidebarTabByWorktree) - let changed = omitted !== s.rightSidebarTabByWorktree - const out: AppState['rightSidebarTabByWorktree'] = {} - for (const [id, tab] of Object.entries(omitted)) { - if ( - tab === 'explorer' || - tab === 'vault' || - tab === 'workspaces' || - tab === 'source-control' || - tab === 'checks' || - tab === 'ports' - ) { - out[id] = tab - } else { - changed = true - } - } - return changed ? out : omitted - } - const omitByTabId = (obj: Record): Record => { - let changed = false - const out = { ...obj } - for (const tabId of doomedTabIds) { - if (tabId in out) { - delete out[tabId] - changed = true - } - } - return changed ? out : obj - } - const survivingTabIds = new Set( - Object.entries(s.tabsByWorktree) - .filter(([worktreeId]) => !worktreeIdSet.has(worktreeId)) - .flatMap(([, tabs]) => tabs.map((tab) => tab.id)) - ) - const omitRetiredDirectSshLedgerByTabId = (obj: Record): Record => { - let changed = false - const out = { ...obj } - for (const tabId of doomedTabIds) { - if (!survivingTabIds.has(tabId) && tabId in out) { - delete out[tabId] - changed = true - } - } - return changed ? out : obj - } - const omitByPtyId = (obj: Record): Record => { - let changed = false - const out = { ...obj } - for (const ptyId of doomedPtyIds) { - if (ptyId in out) { - delete out[ptyId] - changed = true - } - } - return changed ? out : obj - } - // Pane-scoped maps are keyed `${tabId}:${leafId}`; tabId never contains ":", so the prefix before the first ":" is the owning tab. - const omitByPaneKeyTabPrefix = (obj: Record): Record => { - // Null-tolerant like omitByTabId: some worktree-isolation callers omit these slices (production store always inits to {}). - if (!obj) { - return obj - } - let changed = false - const out = { ...obj } - for (const paneKey of Object.keys(obj)) { - const sep = paneKey.indexOf(':') - if (sep > 0 && doomedTabIds.has(paneKey.slice(0, sep))) { - delete out[paneKey] - changed = true - } - } - return changed ? out : obj - } - const omitByBrowserWorkspaceId = (obj: Record): Record => { - let changed = false - const out = { ...obj } - for (const workspaceId of doomedBrowserWorkspaceIds) { - if (workspaceId in out) { - delete out[workspaceId] - changed = true - } - } - return changed ? out : obj - } - const omitByPageId = (obj: Record): Record => { - let changed = false - const out = { ...obj } - for (const pageId of doomedPageIds) { - if (pageId in out) { - delete out[pageId] - changed = true - } - } - return changed ? out : obj - } - const omitByFileId = (obj: Record): Record => { - let changed = false - const out = { ...obj } - for (const fileId of removedFileIds) { - if (fileId in out) { - delete out[fileId] - changed = true - } - } - return changed ? out : obj - } - - return { - omitByWorktree, - omitWorkspaceLineageByWorktree, - pruneRightSidebarTabByWorktree, - omitByTabId, - omitRetiredDirectSshLedgerByTabId, - omitByPtyId, - omitByPaneKeyTabPrefix, - omitByBrowserWorkspaceId, - omitByPageId, - omitByFileId - } -} diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts deleted file mode 100644 index 958c914315f..00000000000 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ /dev/null @@ -1,192 +0,0 @@ -import type { AppState } from '../../../types' -import { forgetHugeRepoWarningDismissalsForWorktrees } from '@/lib/source-control-huge-repo-warning-dismissals' -import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope' -import { pruneHostedReviewLinkMutationGenerations } from '../metadata/hosted-review-link-mutation' -import { collectWorktreePurgeDoomedIds } from './worktree-purge-doomed-ids' -import { createWorktreePurgeOmitters } from './worktree-purge-omitters' - -export function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial { - const worktreeIdSet = new Set(worktreeIds) - pruneHostedReviewLinkMutationGenerations(worktreeIdSet) - // Why: every authoritative and explicit purge converges here, so a deleted path can't inherit stale UI state. - forgetHugeRepoWarningDismissalsForWorktrees(worktreeIdSet) - - const doomed = collectWorktreePurgeDoomedIds(s, worktreeIdSet) - const { - omitByWorktree, - omitWorkspaceLineageByWorktree, - pruneRightSidebarTabByWorktree, - omitByTabId, - omitRetiredDirectSshLedgerByTabId, - omitByPtyId, - omitByPaneKeyTabPrefix, - omitByBrowserWorkspaceId, - omitByPageId, - omitByFileId - } = createWorktreePurgeOmitters(s, worktreeIdSet, doomed) - - const nextOpenFiles = s.openFiles.some((f) => worktreeIdSet.has(f.worktreeId)) - ? s.openFiles.filter((f) => !worktreeIdSet.has(f.worktreeId)) - : s.openFiles - - const removedActive = s.activeWorktreeId != null && worktreeIdSet.has(s.activeWorktreeId) - const activeFileCleared = s.activeFileId != null && doomed.removedFileIds.has(s.activeFileId) - const activeTabCleared = s.activeTabId != null && doomed.doomedTabIds.has(s.activeTabId) - - const nextEverActivatedWorktreeIds = (() => { - let hit = false - for (const id of worktreeIdSet) { - if (s.everActivatedWorktreeIds.has(id)) { - hit = true - break - } - } - if (!hit) { - return s.everActivatedWorktreeIds - } - const next = new Set(s.everActivatedWorktreeIds) - for (const id of worktreeIdSet) { - next.delete(id) - } - return next - })() - const nextAgentStatusByPaneKey = omitByPaneKeyTabPrefix(s.agentStatusByPaneKey) - - return { - // Worktree-scoped terminal/tab state - worktreeLineageById: omitByWorktree(s.worktreeLineageById), - workspaceLineageByChildKey: omitWorkspaceLineageByWorktree(s.workspaceLineageByChildKey), - tabsByWorktree: omitByWorktree(s.tabsByWorktree), - terminalLayoutsByTabId: omitByTabId(s.terminalLayoutsByTabId), - ptyIdsByTabId: omitByTabId(s.ptyIdsByTabId), - runtimePaneTitlesByTabId: omitByTabId(s.runtimePaneTitlesByTabId), - automaticAgentResumeClaimsByTabId: omitByTabId(s.automaticAgentResumeClaimsByTabId), - nativeChatLaunchPromptByTabId: omitByTabId(s.nativeChatLaunchPromptByTabId), - nativeChatLaunchDraftByTabId: omitByTabId(s.nativeChatLaunchDraftByTabId), - // Why: bulk/hydration purge runs no terminal teardown, so it must drop the per-tab pane-expand flags itself. - expandedPaneByTabId: omitByTabId(s.expandedPaneByTabId), - canExpandPaneByTabId: omitByTabId(s.canExpandPaneByTabId), - // Why: these per-tab/per-pty terminal+agent maps evict on the single removeWorktree teardown path; the bulk reconcile / remove-project / hydration-stale paths run no teardown, so without these each strands an entry per tab/pane of externally-removed worktrees. - lastKnownRelayPtyIdByTabId: omitByTabId(s.lastKnownRelayPtyIdByTabId), - // Why: liveness-authoritative reconnect maps (orphan sweep reads them); drop purged tabs' entries here too so a re-materialized id can't inherit phantom liveness. - pendingReconnectPtyIdByTabId: omitByTabId(s.pendingReconnectPtyIdByTabId), - deferredSshSessionIdsByTabId: omitByTabId(s.deferredSshSessionIdsByTabId), - pendingInitialCwdByTabId: omitByTabId(s.pendingInitialCwdByTabId), - pendingIssueCommandSplitByTabId: omitByTabId(s.pendingIssueCommandSplitByTabId), - pendingSetupSplitByTabId: omitByTabId(s.pendingSetupSplitByTabId), - pendingStartupByTabId: omitByTabId(s.pendingStartupByTabId), - directSshPaneRetryByTabId: omitRetiredDirectSshLedgerByTabId(s.directSshPaneRetryByTabId), - directSshLivePtyBindingByTabId: omitRetiredDirectSshLedgerByTabId( - s.directSshLivePtyBindingByTabId - ), - directSshPaneRetryHistoryByTabId: omitRetiredDirectSshLedgerByTabId( - s.directSshPaneRetryHistoryByTabId - ), - codexRestartNoticeByPtyId: omitByPtyId(s.codexRestartNoticeByPtyId), - migrationUnsupportedByPtyId: omitByPtyId(s.migrationUnsupportedByPtyId), - suppressedPtyExitIds: omitByPtyId(s.suppressedPtyExitIds), - pendingCodexPaneRestartIds: omitByPtyId(s.pendingCodexPaneRestartIds), - // Why: these agent-status/unread/input maps clear only on the single removeWorktree teardown path; the bulk reconcile / remove-project / hydration-stale paths run no teardown, so without this they orphan an entry per agent pane (plus a phantom unread badge). - // retainedAgentsByPaneKey and runtimeAgentOrchestrationByPaneKey are omitted here — both self-heal (pruneRetainedAgents on worktreesByRepo change; runtime map replaced wholesale each sync). - agentStatusByPaneKey: nextAgentStatusByPaneKey, - ...(nextAgentStatusByPaneKey !== s.agentStatusByPaneKey - ? { agentStatusEpoch: s.agentStatusEpoch + 1 } - : {}), - agentLaunchConfigByPaneKey: omitByPaneKeyTabPrefix(s.agentLaunchConfigByPaneKey), - acknowledgedAgentsByPaneKey: omitByPaneKeyTabPrefix(s.acknowledgedAgentsByPaneKey), - paneForegroundAgentByPaneKey: omitByPaneKeyTabPrefix(s.paneForegroundAgentByPaneKey), - sleepingAgentSessionsByPaneKey: omitByPaneKeyTabPrefix(s.sleepingAgentSessionsByPaneKey), - unreadTerminalTabs: omitByTabId(s.unreadTerminalTabs), - unreadTerminalPanes: omitByPaneKeyTabPrefix(s.unreadTerminalPanes), - unreadAgentCompletionPanes: omitByPaneKeyTabPrefix(s.unreadAgentCompletionPanes), - lastTerminalInputAtByPaneKey: omitByPaneKeyTabPrefix(s.lastTerminalInputAtByPaneKey), - // Delete state - deleteStateByWorktreeId: omitByWorktree(s.deleteStateByWorktreeId), - baseStatusByWorktreeId: omitByWorktree(s.baseStatusByWorktreeId), - remoteBranchConflictByWorktreeId: omitByWorktree(s.remoteBranchConflictByWorktreeId), - // File search - fileSearchStateByWorktree: omitByWorktree(s.fileSearchStateByWorktree), - // Browser state - browserTabsByWorktree: omitByWorktree(s.browserTabsByWorktree), - browserPagesByWorkspace: omitByBrowserWorkspaceId(s.browserPagesByWorkspace), - recentlyClosedBrowserTabsByWorktree: omitByWorktree(s.recentlyClosedBrowserTabsByWorktree), - activeBrowserTabIdByWorktree: omitByWorktree(s.activeBrowserTabIdByWorktree), - // Why: keyed by page/workspace id, only cleaned by closeBrowserTab on the single-removal path; the bulk reconcile missed them, orphaning an entry per page of externally-removed worktrees. - browserAnnotationsByPageId: omitByPageId(s.browserAnnotationsByPageId), - remoteBrowserPageHandlesByPageId: omitByPageId(s.remoteBrowserPageHandlesByPageId), - pendingAddressBarFocusByPageId: omitByPageId(s.pendingAddressBarFocusByPageId), - // createBrowserTab writes both the workspace id and the page id into this map. - pendingAddressBarFocusByTabId: omitByPageId( - omitByBrowserWorkspaceId(s.pendingAddressBarFocusByTabId) - ), - recentlyClosedBrowserPagesByWorkspace: omitByBrowserWorkspaceId( - s.recentlyClosedBrowserPagesByWorkspace - ), - // Editor state - activeFileIdByWorktree: omitByWorktree(s.activeFileIdByWorktree), - activeTabTypeByWorktree: omitByWorktree(s.activeTabTypeByWorktree), - activeTabIdByWorktree: omitByWorktree(s.activeTabIdByWorktree), - tabBarOrderByWorktree: omitByWorktree(s.tabBarOrderByWorktree), - pendingReconnectTabByWorktree: omitByWorktree(s.pendingReconnectTabByWorktree), - rightSidebarTabByWorktree: pruneRightSidebarTabByWorktree(), - rightSidebarExplorerViewByWorktree: omitByWorktree(s.rightSidebarExplorerViewByWorktree ?? {}), - // Split-tab / unified tab state - unifiedTabsByWorktree: omitByWorktree(s.unifiedTabsByWorktree), - groupsByWorktree: omitByWorktree(s.groupsByWorktree), - layoutByWorktree: omitByWorktree(s.layoutByWorktree), - activeGroupIdByWorktree: omitByWorktree(s.activeGroupIdByWorktree), - // Git status caches - gitStatusByWorktree: omitByWorktree(s.gitStatusByWorktree), - // Why: keyed by worktreeId; re-keyed on rename but missed by both removal paths (upstream-status entry). - remoteStatusesByWorktree: omitByWorktree(s.remoteStatusesByWorktree), - gitStatusHeadByWorktree: omitByWorktree(s.gitStatusHeadByWorktree), - gitBranchLineTotalByWorktree: omitByWorktree(s.gitBranchLineTotalByWorktree), - gitIgnoredPathsByWorktree: omitByWorktree(s.gitIgnoredPathsByWorktree), - gitConflictOperationByWorktree: omitByWorktree(s.gitConflictOperationByWorktree), - trackedConflictPathsByWorktree: omitByWorktree(s.trackedConflictPathsByWorktree), - gitBranchChangesByWorktree: omitByWorktree(s.gitBranchChangesByWorktree), - gitBranchCompareSummaryByWorktree: omitByWorktree(s.gitBranchCompareSummaryByWorktree), - gitBranchCompareRequestKeyByWorktree: omitByWorktree(s.gitBranchCompareRequestKeyByWorktree), - gitBranchCompareRequestStatusHeadByWorktree: omitByWorktree( - s.gitBranchCompareRequestStatusHeadByWorktree - ), - // Why: keyed by worktreeId; without this it leaks a huge-status marker per removed worktree. - gitStatusHugeByWorktree: omitByWorktree(s.gitStatusHugeByWorktree), - showDotfilesByWorktree: omitByWorktree(s.showDotfilesByWorktree), - expandedDirs: omitByWorktree(s.expandedDirs), - // Per-file editor state for removed files - editorDrafts: omitByFileId(s.editorDrafts), - markdownViewMode: omitByFileId(s.markdownViewMode), - markdownFrontmatterVisible: omitByFileId(s.markdownFrontmatterVisible), - // Why: keyed by fileId; the bulk reconcile path previously kept these, leaking a cursor-line / view-mode entry per removed file. - editorCursorLine: omitByFileId(s.editorCursorLine), - editorViewMode: omitByFileId(s.editorViewMode), - // Why: keyed by worktreeId; re-keyed on rename but missed by both removal paths (editor-undo / Cmd+Shift+T snapshots). - recentlyClosedEditorTabsByWorktree: omitByWorktree(s.recentlyClosedEditorTabsByWorktree), - recentlyClosedTerminalTabsByWorktree: omitByWorktree(s.recentlyClosedTerminalTabsByWorktree), - recentlyClosedTabKindsByWorktree: omitByWorktree(s.recentlyClosedTabKindsByWorktree), - // Top-level actives - openFiles: nextOpenFiles, - everActivatedWorktreeIds: nextEverActivatedWorktreeIds, - lastVisitedAtByWorktreeId: omitByWorktree(s.lastVisitedAtByWorktreeId), - // Why: keyed by worktreeId; re-keyed on rename but missed by both removal paths (write-once default-terminal guard). - defaultTerminalTabsAppliedByWorktreeId: omitByWorktree( - s.defaultTerminalTabsAppliedByWorktreeId - ), - activeWorktreeId: removedActive ? null : s.activeWorktreeId, - activeWorkspaceExecutionHostId: removedActive ? null : s.activeWorkspaceExecutionHostId, - activeWorkspaceKey: (() => { - if (s.activeWorkspaceKey && worktreeIdSet.has(s.activeWorkspaceKey)) { - return null - } - const activeScope = s.activeWorkspaceKey ? parseWorkspaceKey(s.activeWorkspaceKey) : null - return activeScope?.type === 'worktree' && worktreeIdSet.has(activeScope.worktreeId) - ? null - : s.activeWorkspaceKey - })(), - activeFileId: activeFileCleared ? null : s.activeFileId, - activeBrowserTabId: removedActive ? null : s.activeBrowserTabId, - activeTabId: activeTabCleared ? null : s.activeTabId, - activeTabType: removedActive || activeFileCleared ? 'terminal' : s.activeTabType - } -} diff --git a/src/shared/browser-workspace-types.ts b/src/shared/browser-workspace-types.ts index 67619decd58..825297578f3 100644 --- a/src/shared/browser-workspace-types.ts +++ b/src/shared/browser-workspace-types.ts @@ -143,11 +143,20 @@ export type BrowserCookieImportSummary = { skippedCookies: number googleCookiesSkipped?: number domains: string[] - warning?: { - code: 'restart-fallback-unavailable' - loadedCookies: number - failedCookies: number - } + warning?: + | { + code: 'restart-fallback-unavailable' + loadedCookies: number + failedCookies: number + } + | { + // Why: a row that will not decrypt is indistinguishable from a corrupt one by the time + // decrypt returns null, so the cause is captured at the point of failure and reported + // here. Without this an undecryptable profile is reported as a successful empty import. + code: 'cookies-undecryptable' + failedCookies: number + reason: 'app-bound-encryption' | 'linux-keyring-unavailable' | 'unknown' + } } export type BrowserCookieImportResult =