fix(browser): report cookies an import could not decrypt

Supersedes #13193, which reported only the Windows v20 case.

Nothing distinguished "decryption failed" from "no cookies present". A row that
would not decrypt was folded into the generic `skipped` counter, and a profile
whose rows all failed returned ok:true with importedCookies:0 and no warning —
a green "Imported 0 cookies from Google Chrome." The two situations produce
opposite result shapes and the worse one reported success.

Attribute the cause at the point of failure, while the version prefix is still
in hand, and surface it as one `cookies-undecryptable` warning carrying the
reason. Covers all three known causes rather than one prefix:

- app-bound-encryption: Chrome/Edge 140+ on Windows write `v20`, which only the
  writing browser can unwrap. The version gate is a FORMAT check (`/^v\d\d$/`),
  so v20 passed it and failed inside AES like corruption.
- linux-keyring-unavailable: getLinuxEncryptionKey derived the v11 key from an
  empty password when both secret-tool lookups failed, so it never returned null
  and the "Could not access encryption key" guard was unreachable on Linux.
- unknown: any other cause still warns instead of reporting success.

Deliberately not a hard failure on Linux: Chrome falls back to the "peanuts" v10
key precisely when no keyring exists, so those profiles still import. Pinned by
a regression test.

Refs #13192, #14181
This commit is contained in:
Brennan Benson
2026-08-14 18:46:36 -07:00
parent 8b31910ac1
commit 9853d05c0d
66 changed files with 6645 additions and 7289 deletions
+1
View File
@@ -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
@@ -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<typeof NodeFs>()) }))
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<unknown>
remove: (url: string, name: string) => Promise<void>
}
}) => ({
get: (filter: object) => targetSession.cookies.get(filter),
remove: (url: string, name: string) => targetSession.cookies.remove(url, name),
snapshotClearIdentities: async (items: { cookie: Record<string, unknown>; 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<typeof vi.fn>
let platformSpy: ReturnType<typeof vi.spyOn>
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' })
)
})
})
+75 -2
View File
@@ -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,
+4 -1
View File
@@ -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."
}
}
}
@@ -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 }
)
}
}
}
File diff suppressed because it is too large Load Diff
@@ -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<Awaited<ReturnType<typeof window.api.worktrees.create>>>(
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
}
}
}
@@ -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<Worktree, 'id' | 'displayName' | 'branch' | 'path'>
): 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
}
)
}
@@ -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 }
})
}
}
@@ -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.
}
}
}
@@ -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<ExecutionHostId, Set<string>>()
export function rememberAuthoritativelyRemovedWorktrees(
hostId: ExecutionHostId,
worktreeIds: readonly string[]
): void {
if (worktreeIds.length === 0) {
return
}
const removed = authoritativelyRemovedWorktreeIdsByHost.get(hostId) ?? new Set<string>()
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<string>
): 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<string> | undefined {
return authoritativelyRemovedWorktreeIdsByHost.get(hostId)
}
@@ -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
}
}
@@ -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<FolderWorkspace, Worktree>()
export function applyDetectedWorktreeUpdates(
detectedWorktreesByRepo: AppState['detectedWorktreesByRepo'],
worktreeId: string,
rawUpdates: Partial<WorktreeMeta>
): 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<FolderWorkspace, 'connectionId' | 'executionHostId'>,
executionHostId: ExecutionHostId
): boolean {
return (
(parseExecutionHostId(workspace.executionHostId)?.id ??
(workspace.connectionId?.trim()
? toSshExecutionHostId(workspace.connectionId)
: LOCAL_EXECUTION_HOST_ID)) === executionHostId
)
}
export function findKnownWorktreeById(
state: Pick<AppState, 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces'>,
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<WorktreeMeta>
): 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
}
@@ -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<DetectedWorktreeListResult> {
const target = getActiveRuntimeTarget(settings)
if (target.kind === 'local') {
throw new Error('Local detected-worktree reads require a provider lease')
}
try {
return await callRuntimeRpc<DetectedWorktreeListResult>(
target,
'worktree.detectedList',
{ repo: repoId },
{
timeoutMs: 15_000,
reuseRecentCompatibilityFailure: options.reuseRecentCompatibilityFailure
}
)
} catch (error) {
if (!isRuntimeMethodNotFoundError(error)) {
throw error
}
const legacy = await callRuntimeRpc<RuntimeWorktreeListResult>(
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<DetectedWorktreeListResult>
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<HostQualifiedDetectedWorktreeResult> {
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)
}
@@ -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<AppState, 'sshConnectionStates'>,
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
}
@@ -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<DetectedWorktreeListResult>
>()
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<DetectedWorktreeRefreshOutcome> {
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
}
}
@@ -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<AppState, 'sshConnectionStates'>,
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
)
}
@@ -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<string>()
// 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:<id>` 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 })
}
}
@@ -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
}
}
}
@@ -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']
}
@@ -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<T extends Worktree>(
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<StateCreator<AppState, [], [], WorktreeSlice>>[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
}
@@ -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<HostQualifiedKnownWorktreeResult, { status: 'complete' }> {
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<DetectedWorktreeListResult | null>
>()
// 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<StateCreator<AppState, [], [], WorktreeSlice>>[0],
repoId: string,
executionHostId: SshExecutionHostId
): Promise<DetectedWorktreeListResult | null> {
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<StateCreator<AppState, [], [], WorktreeSlice>>[0],
repoId: string,
executionHostId: SshExecutionHostId
): Promise<DetectedWorktreeListResult | null> {
// 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<HostQualifiedDetectedWorktreeResult>
release: DetectedWorktreeRefreshLease['release']
merge(result: HostQualifiedDetectedWorktreeResult): HostQualifiedDetectedWorktreeResult
}
export function acquireDirectSshDetectedWorktreeRefresh(
store: Pick<StoreApi<AppState>, '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<StateCreator<AppState, [], [], WorktreeSlice>>[0],
{
repoId: request.repoId,
hostId: request.executionHostId,
ownerWasMissingAtStart,
requestStartedWorktrees,
setup,
refresh
}
)
mergedResult = admitted
? providerResult
: (staleDetectedWorktreeProviderResult(refresh) ?? providerResult)
return mergedResult
}
}
}
@@ -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
}
@@ -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')
}
@@ -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
}
@@ -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<AppState, 'repos' | 'settings'>,
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<AppState, 'repos'>,
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<Pick<AppState, 'projectHostSetups'>>,
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<string, RepoHostSummary>
>()
export function getRepoHostSummaries(repos: AppState['repos']): Map<string, RepoHostSummary> {
const cached = repoHostSummariesByRepos.get(repos)
if (cached) {
return cached
}
const summaries = new Map<string, RepoHostSummary>()
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<AppState, 'repos'>,
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<AppState, 'repos'>,
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<string>()
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
}))
}
}
@@ -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<string, Worktree[]>,
updatedWorktree: Worktree
): Record<string, Worktree[]> {
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<AppState, 'repos' | 'settings'>,
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<typeof trySettingsForWorktreeOwner>[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<string>()
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<WorktreeMeta>,
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))
})
}
@@ -1,23 +0,0 @@
import { WORKTREE_REFRESH_CONCURRENCY } from './worktree-slice-constants'
export async function mapReposForWorktreeRefresh<TRepo extends { id: string }, TResult>(
repos: readonly TRepo[],
mapper: (repo: TRepo) => Promise<TResult>
): Promise<TResult[]> {
const results = Array<TResult>(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
}
@@ -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
@@ -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<StateCreator<AppState>>[1]
export type WorktreeSliceSet = Parameters<StateCreator<AppState, [], [], WorktreeSlice>>[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<typeof getActiveRuntimeTarget>
lineage: WorktreeLineage | null
updatedRemoteWorktree?: WorktreeWithLineage
}
export type HostedReviewLinkKey =
| 'linkedPR'
| 'linkedGitLabMR'
| 'linkedBitbucketPR'
| 'linkedAzureDevOpsPR'
| 'linkedGiteaPR'
export type RuntimeWorktreeMetaUpdates = Omit<Partial<WorktreeMeta>, '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
}
@@ -1,18 +0,0 @@
export const detachedHeadAutoDerivedDisplayNames = new Map<string, string>()
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)
}
@@ -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<string, number>()
export const hostedReviewLinkClearTombstonesByWorktreeId = new Map<
string,
{ branch: string; branchIdentity: string; generation: number; head?: string }
>()
export const hostedReviewLinkWorktreeIdAliases = new Map<string, string>()
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<WorktreeMeta>): 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<string>): 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<string>()
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<WorktreeMeta, HostedReviewLinkKey | 'pushTarget'> {
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<Worktree, 'id' | 'branch'> &
Partial<Pick<Worktree, HostedReviewLinkKey | 'pushTarget' | 'head'>>
>(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<Worktree, 'id' | 'branch'> &
Partial<Pick<Worktree, HostedReviewLinkKey | 'pushTarget' | 'head'>>
>(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<StateCreator<AppState, [], [], WorktreeSlice>>[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<WorktreeMeta>
): HostedReviewLinkKey | null {
for (const key of HOSTED_REVIEW_LINK_KEYS) {
if (isPositiveHostedReviewNumber(updates[key])) {
return key
}
}
return null
}
export function clearOlderHostedReviewLinksForReplacement(
updates: Partial<WorktreeMeta>,
existingWorktree: Worktree
): Partial<WorktreeMeta> {
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<WorktreeMeta>,
existingWorktree: Worktree | undefined,
key: HostedReviewLinkKey
): number | null {
return Object.hasOwn(updates, key) ? (updates[key] ?? null) : (existingWorktree?.[key] ?? null)
}
export function hasExplicitPushTargetClear(updates: Partial<WorktreeMeta>): boolean {
return Object.hasOwn(updates, 'pushTarget') && updates.pushTarget === undefined
}
export function encodePushTargetClearForRuntimeRpc(
updates: Partial<WorktreeMeta>
): 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 }
}
@@ -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)
}
}
}
@@ -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<string>()
export async function resolveGitHubReviewPushTarget(
settings: AppState['settings'],
repoId: string,
prNumber: number
): Promise<GitPushTarget | undefined> {
try {
const target = getActiveRuntimeTarget(settings)
const result =
target.kind === 'local'
? await window.api.worktrees.resolvePrBase({ repoId, prNumber })
: await callRuntimeRpc<GitHubPrStartPoint | { error: string }>(
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<GitPushTarget | undefined> {
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<GitPushTarget | undefined>
} | 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
}
@@ -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 }
}
}
@@ -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))
}
})
)
}
}
@@ -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
}
}))
}
}
@@ -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<string>()
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)
})
}
}
@@ -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
}
}
}
@@ -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<AppState, 'repos' | 'settings' | 'worktreesByRepo' | 'detectedWorktreesByRepo'>,
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<Record<string, WorktreeLineage>>
): Readonly<Record<string, WorktreeLineage>> {
const next: Record<string, WorktreeLineage> = {}
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<Record<string, WorkspaceLineage>>
): Readonly<Record<string, WorkspaceLineage>> {
const next: Record<string, WorkspaceLineage> = {}
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)
}
@@ -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<Record<string, WorktreeLineage>>
workspaceLineageByChildKey: Readonly<Record<string, WorkspaceLineage>>
}> {
const target = getActiveRuntimeTarget(settings)
type LineageListResponse = {
lineage?: Record<string, WorktreeLineage>
workspaceLineage?: Record<string, WorkspaceLineage>
}
const normalizeLineageResponse = (value: Record<string, WorktreeLineage> | LineageListResponse) =>
Object.hasOwn(value, 'lineage') || Object.hasOwn(value, 'workspaceLineage')
? {
worktreeLineageById: (value as LineageListResponse).lineage ?? {},
workspaceLineageByChildKey: (value as LineageListResponse).workspaceLineage ?? {}
}
: {
worktreeLineageById: value as Record<string, WorktreeLineage>,
workspaceLineageByChildKey: {}
}
if (target.kind === 'local') {
return normalizeLineageResponse(await window.api.worktrees.listLineage())
}
return normalizeLineageResponse(
await callRuntimeRpc<{
lineage: Record<string, WorktreeLineage>
workspaceLineage?: Record<string, WorkspaceLineage>
}>(target, 'worktree.lineageList', undefined, {
timeoutMs: 15_000,
reuseRecentCompatibilityFailure: options.reuseRecentCompatibilityFailure
})
)
}
export function projectWorktreeLineageToWorkspaceLineage(
worktreeId: string,
lineage: WorktreeLineage | null,
current: Record<string, WorkspaceLineage>
): Record<string, WorkspaceLineage> {
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<WorktreeLineageUpdateResult> {
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<string, Worktree[]>,
worktreeId: string,
lineage: WorktreeLineage | null
): Record<string, Worktree[]> {
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<StateCreator<AppState>>[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<StateCreator<AppState>>[0],
hostId: ExecutionHostId,
lineage: {
worktreeLineageById: Readonly<Record<string, WorktreeLineage>>
workspaceLineageByChildKey: Readonly<Record<string, WorkspaceLineage>>
}
): 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<StateCreator<AppState>>[0],
options: BackgroundRuntimeRefreshOptions = {}
): Promise<void> {
const lineage = await listWorktreeLineageForRuntime(settings, options)
applyHostLineageRefresh(set, getSettingsFocusedExecutionHostId(settings), lineage)
}
export async function refreshRemoteWorktreeLineageBestEffort(
settings: AppState['settings'],
set: (partial: Partial<AppState> | ((state: AppState) => Partial<AppState>)) => void
): Promise<void> {
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)
}
}
@@ -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<WorktreeMeta>
): Promise<void> {
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 }
)
}
@@ -1,5 +0,0 @@
export const pendingActivationTerminalPrepCancels = new Map<string, () => void>()
export function shouldDeferActivationTerminalPrep(): boolean {
return typeof window !== 'undefined' && import.meta.env.MODE !== 'test'
}
@@ -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
}
}
@@ -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
}
@@ -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)
}
}
@@ -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
)
}
}
}
@@ -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<WorktreeMeta> = 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
}
}
@@ -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<AppState> {
if (oldWorktreeId === newWorktreeId) {
return {}
}
const renamed: Record<string, unknown> = {}
const renameKey = <T>(
key: keyof AppState,
mapValue: (value: T) => T = (value) => value
): void => {
const map = s[key as keyof AppState] as Record<string, unknown> | 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 = <T extends { worktreeId: string }>(value: T): T =>
value.worktreeId === oldWorktreeId ? { ...value, worktreeId: newWorktreeId } : value
const renameValueByKey: Partial<Record<(typeof WORKTREE_ID_KEYED_MAP_KEYS)[number], unknown>> = {
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<AppState>),
...(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 } }
: {})
}
}
@@ -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<string, Partial<WorktreeMeta>>()
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 })
}
}
}
@@ -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<string>(),
lastVisitedAtByWorktreeId: {},
hasHydratedWorktreePurge: false,
startupWorktreeRefreshCompleted: false
}
@@ -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))
}
}
@@ -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))
})
}
}
@@ -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<string, Set<string>>()
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<string, number> = {}
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<string, number>
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()
}
}
})
}
}
@@ -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<ForceDeleteWorktreeBranchResult>(
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 }
}
}
}
@@ -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<string, Promise<void>>()
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<void> {
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)
}
}
}
@@ -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<void> {
if (destroyedSshTargetIds.length === 0) {
return
}
const destroyedTargetIds = new Set(destroyedSshTargetIds)
const destroyedHostIds = new Set<ExecutionHostId>(
destroyedSshTargetIds.map((id) => toSshExecutionHostId(id))
)
const orphanedSetupIds = get()
.projectHostSetups.filter((setup) => destroyedHostIds.has(setup.hostId))
.map((setup) => setup.id)
const purgedRepoIds = new Set<string>()
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)
}
}
}
@@ -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<typeof getActiveRuntimeTarget> }
>()
@@ -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<string>()
const survivingRepoIds = new Set<string>()
const repoIdsWithSurvivingOwners = new Set<string>()
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<string, DetectedWorktreeListResult['worktrees']> =
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<string>()
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))
}
: {})
}
})
}
}
@@ -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<string>
): 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<string>()
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
}
})
}
@@ -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<RemoveWorktreeResult>(
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 }
}
}
}
@@ -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 }
})
}
}
@@ -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<string>
doomedPtyIds: Set<string>
doomedBrowserWorkspaceIds: Set<string>
doomedPageIds: Set<string>
removedFileIds: Set<string>
}
export function collectWorktreePurgeDoomedIds(
s: AppState,
worktreeIdSet: Set<string>
): WorktreePurgeDoomedIds {
// Collect every tab id (and removed file id) we are about to orphan.
const doomedTabIds = new Set<string>()
// 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<string>()
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<string>()
const doomedPageIds = new Set<string>()
const removedFileIds = new Set<string>()
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
}
}
@@ -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<string>,
doomed: WorktreePurgeDoomedIds
) {
const { doomedTabIds, doomedPtyIds, doomedBrowserWorkspaceIds, doomedPageIds, removedFileIds } =
doomed
const omitByWorktree = <T>(obj: Record<string, T>): Record<string, T> => {
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<string, WorkspaceLineage>
): Record<string, WorkspaceLineage> => {
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 = <T>(obj: Record<string, T>): Record<string, T> => {
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 = <T>(obj: Record<string, T>): Record<string, T> => {
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 = <T>(obj: Record<string, T>): Record<string, T> => {
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 = <T>(obj: Record<string, T>): Record<string, T> => {
// 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 = <T>(obj: Record<string, T>): Record<string, T> => {
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 = <T>(obj: Record<string, T>): Record<string, T> => {
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 = <T>(obj: Record<string, T>): Record<string, T> => {
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
}
}
@@ -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<AppState> {
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
}
}
+14 -5
View File
@@ -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 =