fix(cmd-j): preserve host scope during worktree purge

This commit is contained in:
Jinjing
2026-08-24 09:28:28 -07:00
parent 14d1833248
commit 91e94bd210
38 changed files with 668 additions and 83 deletions
+12 -2
View File
@@ -9,6 +9,7 @@ import type {
} from '../../shared/remote-workspace-types'
import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types'
import { getRepoIdFromWorktreeId } from '../../shared/worktree/id'
import { parseExecutionHostId } from '../../shared/execution-host'
import { getRemoteWorkspaceNamespace } from './remote-workspace-namespace'
import { registerRemoteWorkspaceNotificationHandler } from './remote-workspace-events'
import { CLIENT_ID } from './remote-workspace-client-identity'
@@ -55,7 +56,15 @@ function getExplicitHydratedTargetIds(value: unknown): Set<string> | null {
return new Set(value)
}
function targetForWorktree(store: Store, worktreeId: string): string | null {
function targetForWorktree(
store: Store,
worktreeId: string,
executionHostId?: string
): string | null {
const parsedHostId = parseExecutionHostId(executionHostId)
if (parsedHostId?.kind === 'ssh') {
return parsedHostId.targetId
}
const repoId = getRepoIdFromWorktreeId(worktreeId)
return store.getRepo(repoId)?.connectionId ?? null
}
@@ -66,7 +75,8 @@ function exportSessionForTarget(
session: WorkspaceSessionState
): RemoteWorkspaceSession {
return exportRemoteWorkspaceSession(session, {
isTargetWorktree: (worktreeId) => targetForWorktree(store, worktreeId) === targetId
isTargetWorktree: (worktreeId, executionHostId) =>
targetForWorktree(store, worktreeId, executionHostId) === targetId
})
}
@@ -39,6 +39,7 @@ function hasTransferredSessionState(session: WorkspaceSessionState): boolean {
Object.keys(session.browserTabsByWorktree ?? {}).length > 0 ||
Object.keys(session.unifiedTabs ?? {}).length > 0 ||
Object.keys(session.tabGroups ?? {}).length > 0 ||
Object.keys(session.lastVisitedAtByWorktreeId ?? {}).length > 0 ||
Object.keys(session.terminalTopologyRevisionByRepoId ?? {}).length > 0
)
}
@@ -2,6 +2,10 @@ import type { PersistedState } from '../../shared/persisted-state-types'
import { parseWorkspaceKey } from '../../shared/workspace-scope'
import type { TransferProfileState } from './profile-project-state-file'
import { isRepoWorktreeId } from './profile-project-worktree-identity'
import {
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../shared/worktree/host-qualified-identity'
export function collectTransferWorktreeIds(
state: TransferProfileState,
@@ -51,8 +55,9 @@ function collectSessionWorktreeIds(
}
const addOwnerKeys = (record: Record<string, unknown> | undefined): void => {
for (const key of Object.keys(record ?? {})) {
if (isRepoWorktreeId(repoId, key)) {
ids.add(key)
const rawKey = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key
if (isRepoWorktreeId(repoId, rawKey)) {
ids.add(rawKey)
}
const parsed = parseWorkspaceKey(key)
if (parsed?.type === 'worktree' && isRepoWorktreeId(repoId, parsed.worktreeId)) {
@@ -4,6 +4,10 @@ import type { WorkspaceKey } from '../../shared/folder-workspace-types'
import type { Repo } from '../../shared/repo-types'
import { parseWorkspaceKey, worktreeWorkspaceKey } from '../../shared/workspace-scope'
import { WORKTREE_ID_SEPARATOR } from '../../shared/worktree/id'
import {
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../shared/worktree/host-qualified-identity'
export function repoPhysicalKey(
repo: Pick<Repo, 'path' | 'connectionId' | 'executionHostId'>
@@ -46,8 +50,14 @@ export function rekeyOwnerKey(
newRepoId: string,
ownerKey: string
): string | null {
if (isRepoWorktreeId(oldRepoId, ownerKey)) {
return rekeyWorktreeId(oldRepoId, newRepoId, ownerKey)
const rawOwnerKey = isWorktreeHostIdentity(ownerKey)
? getWorktreeIdFromHostIdentity(ownerKey)
: ownerKey
if (isRepoWorktreeId(oldRepoId, rawOwnerKey)) {
const rekeyed = rekeyWorktreeId(oldRepoId, newRepoId, rawOwnerKey)
return isWorktreeHostIdentity(ownerKey)
? `${ownerKey.slice(0, ownerKey.length - rawOwnerKey.length)}${rekeyed}`
: rekeyed
}
const parsed = parseWorkspaceKey(ownerKey)
if (parsed?.type === 'worktree' && isRepoWorktreeId(oldRepoId, parsed.worktreeId)) {
@@ -57,7 +67,10 @@ export function rekeyOwnerKey(
}
export function ownerKeyBelongsToRepo(ownerKey: string, repoId: string): boolean {
if (isRepoWorktreeId(repoId, ownerKey)) {
const rawOwnerKey = isWorktreeHostIdentity(ownerKey)
? getWorktreeIdFromHostIdentity(ownerKey)
: ownerKey
if (isRepoWorktreeId(repoId, rawOwnerKey)) {
return true
}
const parsed = parseWorkspaceKey(ownerKey)
@@ -1,6 +1,10 @@
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
import {
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
export function createMinimalPersistedTerminalTab(args: {
worktreeId: string
@@ -93,7 +97,12 @@ export function deleteOwnerKeyedSessionFields(
delete next.activeGroupIdByWorktree[ownerKey]
}
if (next.lastVisitedAtByWorktreeId) {
delete next.lastVisitedAtByWorktreeId[ownerKey]
for (const key of Object.keys(next.lastVisitedAtByWorktreeId)) {
const rawId = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key
if (key === ownerKey || rawId === ownerKey) {
delete next.lastVisitedAtByWorktreeId[key]
}
}
}
if (next.defaultTerminalTabsAppliedByWorktreeId) {
delete next.defaultTerminalTabsAppliedByWorktreeId[ownerKey]
@@ -3,6 +3,10 @@ import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/e
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
import { removeWorkspaceSessionOwners } from '../restoring-sessions/session-owner-removal'
import {
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
export function pruneWorktreeStateForRepo(
state: StoreOwnedPersistedState,
@@ -36,7 +40,12 @@ export function pruneWorktreeStateForRepo(
const ownerKeysToPrune = new Set<string>()
const collectPrefixedKeys = (keys: Iterable<string>): void => {
for (const key of keys) {
if (key.startsWith(prefix)) {
const rawKey = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key
const keyHost = isWorktreeHostIdentity(key) ? key.slice(0, key.indexOf('|')) : null
const belongsToPrunedHost =
hostId === null ||
(keyHost === null ? hostId === LOCAL_EXECUTION_HOST_ID : keyHost === hostId)
if (belongsToPrunedHost && rawKey.startsWith(prefix)) {
ownerKeysToPrune.add(key)
}
}
@@ -1,6 +1,10 @@
import type { WorkspaceKey } from '../../../shared/folder-workspace-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { worktreeWorkspaceKey } from '../../../shared/workspace-scope'
import {
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
import type { StoreOwnedPersistedState } from '../loading-store/store-owned-state'
/**
@@ -94,7 +98,26 @@ export function migrateWorktreeIdentity(
moveSessionKey(session.tabGroups, (groups) => groups.map(withNewWorktreeId)) || sessionChanged
sessionChanged = moveSessionKey(session.tabGroupLayouts) || sessionChanged
sessionChanged = moveSessionKey(session.activeGroupIdByWorktree) || sessionChanged
sessionChanged = moveSessionKey(session.lastVisitedAtByWorktreeId) || sessionChanged
if (session.lastVisitedAtByWorktreeId) {
const nextRecency = { ...session.lastVisitedAtByWorktreeId }
let recencyChanged = false
for (const [key, value] of Object.entries(session.lastVisitedAtByWorktreeId)) {
const rawId = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key
if (rawId !== oldWorktreeId) {
continue
}
const nextKey = isWorktreeHostIdentity(key)
? `${key.slice(0, key.length - rawId.length)}${newWorktreeId}`
: newWorktreeId
nextRecency[nextKey] = value
delete nextRecency[key]
recencyChanged = true
}
if (recencyChanged) {
session.lastVisitedAtByWorktreeId = nextRecency
sessionChanged = true
}
}
sessionChanged =
moveSessionKey(session.defaultTerminalTabsAppliedByWorktreeId) || sessionChanged
if (session.activeWorktreeIdsOnShutdown?.includes(oldWorktreeId)) {
@@ -4,6 +4,7 @@ import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { isRuntimeOwnedSshTargetId, parseExecutionHostId } from '../../../../shared/execution-host'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import { getWorktreeVisitTimestamp } from '@/lib/worktree-visit-recency'
import { getDeleteStateForWorktreeHost } from './worktree-delete-state-host-match'
type AppStoreState = ReturnType<typeof useAppStore.getState>
@@ -56,7 +57,9 @@ function pickNextWorktreeIdAfterDelete(
if (others.length > 0) {
const lastVisited = state.lastVisitedAtByWorktreeId
const [mostRecent] = [...others].sort(
(a, b) => (lastVisited[b.id] ?? 0) - (lastVisited[a.id] ?? 0)
(a, b) =>
(getWorktreeVisitTimestamp(lastVisited, b) ?? 0) -
(getWorktreeVisitTimestamp(lastVisited, a) ?? 0)
)
return mostRecent.id
}
@@ -17,6 +17,7 @@ import {
getWorkspaceCleanupCandidateIdentity,
getWorkspaceCleanupHostIdentity
} from './workspace-cleanup-host-identity'
import { getWorktreeVisitTimestamp } from '@/lib/worktree-visit-recency'
/**
* Streaming ticks replace only the candidate objects they touched, so all
@@ -105,7 +106,10 @@ export function computeWorkspaceCleanupFacetList(args: {
const sizeBytes =
sources.sizeBytesByWorktreeId.get(hostIdentity) ??
sources.sizeBytesByWorktreeId.get(candidate.worktreeId)
const lastVisitedAt = sources.lastVisitedAtByWorktreeId[candidate.worktreeId]
const lastVisitedAt = getWorktreeVisitTimestamp(sources.lastVisitedAtByWorktreeId, {
id: candidate.worktreeId,
hostId: getWorkspaceCleanupCandidateHostId(candidate)
})
const agentState = sources.liveAgentStatusByWorktreeId.get(candidate.worktreeId)
const review =
sources.reviewInfoByWorktreeId.get(hostIdentity) ??
@@ -22,6 +22,7 @@ import {
getWorkspaceCleanupHostIdentity,
type WorkspaceCleanupWorktreeFacts
} from './workspace-cleanup-host-identity'
import { getWorktreeVisitTimestamp } from '@/lib/worktree-visit-recency'
export type { WorkspaceCleanupWorktreeFacts } from './workspace-cleanup-host-identity'
export type WorkspaceCleanupFacetSources = {
@@ -128,7 +129,12 @@ export function buildWorkspaceCleanupFacets(
isSelectable: canSelectWorkspaceCleanupCandidate(candidate),
lastActivityAt: candidate.lastActivityAt,
createdAt: toFiniteOrNull(worktree?.createdAt ?? candidate.createdAt),
lastVisitedAt: toFiniteOrNull(sources.lastVisitedAtByWorktreeId?.[candidate.worktreeId]),
lastVisitedAt: toFiniteOrNull(
getWorktreeVisitTimestamp(sources.lastVisitedAtByWorktreeId, {
id: candidate.worktreeId,
hostId: worktree?.hostId ?? getWorkspaceCleanupCandidateHostId(candidate)
})
),
sizeBytes: toFiniteOrNull(
sources.sizeBytesByWorktreeId?.get(hostIdentity) ??
sources.sizeBytesByWorktreeId?.get(candidate.worktreeId)
@@ -1,7 +1,12 @@
import type { TerminalTab } from '../../../shared/terminal-tab-types'
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { worktreeWorkspaceKey } from '../../../shared/workspace-scope'
import { splitWorktreeId } from '../../../shared/worktree/id'
import {
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
import type { AppState } from '../store/types'
function preserveNewerLocalTerminalFields(remote: TerminalTab, local: TerminalTab): TerminalTab {
@@ -20,7 +25,8 @@ export function mergeDirectSshRemoteWorkspaceSession(
remote: WorkspaceSessionState,
replaceWorktreeIds: ReadonlySet<string>,
liveTabsByWorktree: AppState['tabsByWorktree'],
preserveLocalTerminalTabIds: ReadonlySet<string>
preserveLocalTerminalTabIds: ReadonlySet<string>,
replaceExecutionHostId?: ExecutionHostId
): WorkspaceSessionState {
const currentTabsById = new Map(
[...replaceWorktreeIds]
@@ -146,6 +152,25 @@ export function mergeDirectSshRemoteWorkspaceSession(
Object.fromEntries(
Object.entries(record ?? {}).filter(([worktreeId]) => !replaceWorktreeIds.has(worktreeId))
)
const omitTargetVisitRecency = (
record: Record<string, number> | undefined
): Record<string, number> =>
Object.fromEntries(
Object.entries(record ?? {}).filter(([key]) => {
const worktreeId = isWorktreeHostIdentity(key) ? getWorktreeIdFromHostIdentity(key) : key
if (!replaceWorktreeIds.has(worktreeId)) {
return true
}
// A direct SSH snapshot replaces one host's row. Legacy bare keys
// remain because they may be the only recency evidence for a sibling
// host; a qualified key is removed only for the target host.
return Boolean(
replaceExecutionHostId &&
(!isWorktreeHostIdentity(key) ||
key.slice(0, key.indexOf('|')) !== replaceExecutionHostId)
)
})
)
const terminalLayoutsByTabId = {
...Object.fromEntries(
Object.entries(current.terminalLayoutsByTabId).filter(
@@ -226,7 +251,7 @@ export function mergeDirectSshRemoteWorkspaceSession(
)
},
lastVisitedAtByWorktreeId: {
...omitTargetWorktrees(current.lastVisitedAtByWorktreeId),
...omitTargetVisitRecency(current.lastVisitedAtByWorktreeId),
...remote.lastVisitedAtByWorktreeId
},
defaultTerminalTabsAppliedByWorktreeId: {
@@ -2,6 +2,7 @@ import type { StoreApi } from 'zustand'
import type { RemoteWorkspaceSnapshot } from '../../../shared/remote-workspace-types'
import { importRemoteWorkspaceSession } from '../../../shared/remote-workspace-session-projection'
import type { DirectSshAuthority } from '../../../shared/ssh-types'
import { toSshExecutionHostId } from '../../../shared/execution-host'
import { translate } from '@/i18n/i18n'
import { buildWorkspaceSessionPayload } from '../lib/workspace-session'
import { resolveDirectSshTargetScope } from '../lib/direct-ssh-target-scope'
@@ -109,14 +110,16 @@ export async function applyDirectSshRemoteWorkspaceSnapshot({
const state = store.getState()
const worktreeIds = exactTargetWorktreeIds(state, authority)
const remoteSession = importRemoteWorkspaceSession(snapshot.session, {
resolveWorktreeId: uniqueWorktreeIdByPath(worktreeIds)
resolveWorktreeId: uniqueWorktreeIdByPath(worktreeIds),
executionHostId: toSshExecutionHostId(authority.targetId)
})
const merged = mergeDirectSshRemoteWorkspaceSession(
buildWorkspaceSessionPayload(state),
remoteSession,
worktreeIds,
state.tabsByWorktree,
currentRecoveryTabIds(state, authority, worktreeIds)
currentRecoveryTabIds(state, authority, worktreeIds),
toSshExecutionHostId(authority.targetId)
)
if (!isArrivalCurrent(authority.targetId, arrival) || !isPreparationTokenCurrent(token)) {
return
+6 -1
View File
@@ -924,7 +924,12 @@ export function useIpcEvents(): void {
`[worktree-purge] diff-based purge removing state for ${removed.length} worktree(s):`,
removed
)
afterState.purgeWorktreeTerminalState(removed)
const purgeHostId =
options?.executionHostId ??
(options?.forceLocalOwner ? LOCAL_EXECUTION_HOST_ID : undefined)
afterState.purgeWorktreeTerminalState(
purgeHostId ? removed.map((id) => ({ id, hostId: purgeHostId })) : removed
)
afterState.removeWorkspaceSpaceWorktrees(removed)
}
}
@@ -2,6 +2,7 @@ import type { ExecutionHostId } from '../../../shared/execution-host'
import type { Worktree } from '../../../shared/worktree/types'
import { isPaletteCurrentWorktree } from './palette-repo-resolution'
import { compareWorktreeDisplayName } from './worktree-display-name-order'
import { getWorktreeVisitTimestamp } from './worktree-visit-recency'
export type OrderEmptyQueryInputs = {
visibleWorktrees: readonly Worktree[]
@@ -51,8 +52,8 @@ export function orderEmptyQueryWorktrees(inputs: OrderEmptyQueryInputs): OrderEm
// visited SSH worktree below the fold — the exact bug the feature
// fixes. Compare presence first, then value within each tier.
const sorted = [...switchable].sort((a, b) => {
const aVisited = lastVisitedAtByWorktreeId[a.id]
const bVisited = lastVisitedAtByWorktreeId[b.id]
const aVisited = getWorktreeVisitTimestamp(lastVisitedAtByWorktreeId, a)
const bVisited = getWorktreeVisitTimestamp(lastVisitedAtByWorktreeId, b)
if (aVisited != null && bVisited != null) {
if (bVisited !== aVisited) {
return bVisited - aVisited
@@ -130,6 +130,22 @@ describe('orderRecentWorkspaceTabs', () => {
).toEqual(['warm', 'cold'])
})
it('uses host-qualified recency for same-id worktree rows', () => {
const rows = [
row('local', { worktreeId: 'repo::/app', worktreeHostId: 'local' }),
row('ssh', { worktreeId: 'repo::/app', worktreeHostId: 'ssh:builder' })
]
expect(
order(rows, sources([]), {
lastVisitedAtByWorktreeId: {
'local|repo::/app': NOW - 1_000,
'ssh:builder|repo::/app': NOW
}
})
).toEqual(['ssh', 'local'])
})
it('prefers any visited worktree over a never-visited one', () => {
const rows = [row('never'), row('ancient')]
@@ -11,6 +11,10 @@ import {
} from '../../../shared/execution-host'
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id'
import {
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
import {
mergeWorkspaceSessionsFromHosts,
splitWorkspaceSessionByHost,
@@ -76,6 +80,9 @@ function isPlainRecord(value: unknown): value is Record<string, unknown> {
}
function normalizeWorkspaceSessionKeyForOwnerMap(value: string): string {
if (isWorktreeHostIdentity(value)) {
return getWorktreeIdFromHostIdentity(value)
}
const scope = parseWorkspaceKey(value)
return scope?.type === 'worktree' ? scope.worktreeId : value
}
@@ -1,5 +1,10 @@
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
import { LOCAL_EXECUTION_HOST_ID, type ExecutionHostId } from '../../../shared/execution-host'
import {
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
type ExecutionHostId
} from '../../../shared/execution-host'
import { isWorktreeHostIdentity } from '../../../shared/worktree/host-qualified-identity'
import {
GLOBAL_WORKSPACE_SESSION_FIELDS,
WORKSPACE_SESSION_FIELD_OWNERSHIP
@@ -84,6 +89,25 @@ function assignWorktreeKeyed(
}
}
function assignVisitRecencyByHost(
slices: HostSessionSlices,
template: WorkspaceSessionState,
value: unknown,
ctx: SplitContext
): void {
if (!isWorkspaceSessionRecord(value)) {
return
}
for (const [key, entry] of Object.entries(value)) {
const host = isWorktreeHostIdentity(key)
? (parseExecutionHostId(key.slice(0, key.indexOf('|')))?.id ?? LOCAL_EXECUTION_HOST_ID)
: ctx.hostIdByWorktreeId(key)
const slice = ensureSlice(slices, host, template) as WorkspaceSessionRecord
const target = (slice.lastVisitedAtByWorktreeId ??= {}) as WorkspaceSessionRecord
target[key] = entry
}
}
function assignKeyedByResolvedWorktree(
slices: HostSessionSlices,
template: WorkspaceSessionState,
@@ -157,7 +181,11 @@ export function splitWorkspaceSessionByHost(
// Main preserves this field while rebasing renderer writes onto host authority.
break
case 'worktreeKeyed':
assignWorktreeKeyed(slices, template, field, value, ctx)
if (field === 'lastVisitedAtByWorktreeId') {
assignVisitRecencyByHost(slices, template, value, ctx)
} else {
assignWorktreeKeyed(slices, template, field, value, ctx)
}
break
case 'worktreeArray': {
if (!Array.isArray(value)) {
@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest'
import {
getWorktreeVisitKey,
getWorktreeVisitTimestamp,
removeWorktreeVisitEntries
} from './worktree-visit-recency'
describe('worktree visit recency identities', () => {
it('keeps same-id host twins independent when reading and writing keys', () => {
const id = 'repo::/srv/app'
const localKey = getWorktreeVisitKey(id, 'local')
const sshKey = getWorktreeVisitKey(id, 'ssh:builder')
const timestamps = { [localKey]: 100, [sshKey]: 200 }
expect(localKey).toBe(`local|${id}`)
expect(sshKey).toBe(`ssh:builder|${id}`)
expect(getWorktreeVisitTimestamp(timestamps, { id, hostId: 'local' })).toBe(100)
expect(getWorktreeVisitTimestamp(timestamps, { id, hostId: 'ssh:builder' })).toBe(200)
})
it('falls back to a legacy bare key only when the host key is absent', () => {
const id = 'repo::/srv/app'
expect(getWorktreeVisitTimestamp({ [id]: 100 }, { id, hostId: 'ssh:builder' })).toBe(100)
expect(
getWorktreeVisitTimestamp(
{ [id]: 100, [getWorktreeVisitKey(id, 'ssh:builder')]: 200 },
{ id, hostId: 'ssh:builder' }
)
).toBe(200)
})
it('removes one host key without deleting the other host twin or legacy fallback', () => {
const id = 'repo::/srv/app'
const timestamps = {
[id]: 50,
[getWorktreeVisitKey(id, 'local')]: 100,
[getWorktreeVisitKey(id, 'ssh:builder')]: 200
}
expect(removeWorktreeVisitEntries(timestamps, new Set([id]), 'local')).toEqual({
[id]: 50,
[getWorktreeVisitKey(id, 'ssh:builder')]: 200
})
})
})
@@ -0,0 +1,103 @@
import type { ExecutionHostId } from '../../../shared/execution-host'
import {
composeWorktreeHostIdentity,
getWorktreeHostIdentity,
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from '../../../shared/worktree/host-qualified-identity'
import type { Worktree } from '../../../shared/worktree/types'
/** The persisted focus-recency map is host-qualified for new writes. */
export type WorktreeVisitOwner = Pick<Worktree, 'id' | 'hostId'>
/**
* Read recency for one visible row.
*
* The bare lookup is deliberately second: it keeps sessions written before
* host-qualified recency useful without letting a legacy value override a
* newer host-specific visit.
*/
export function getWorktreeVisitTimestamp(
timestamps: Readonly<Record<string, number>> | undefined,
worktree: WorktreeVisitOwner
): number | undefined {
if (!timestamps) {
return undefined
}
const qualified = timestamps[getWorktreeHostIdentity(worktree)]
return qualified ?? timestamps[worktree.id]
}
/**
* Key used for a newly stamped visit. Unknown-host rows keep the legacy bare
* key because there is no safe host qualification to persist; once a row has
* host evidence, all new writes use the canonical `${host}|${id}` form.
*/
export function getWorktreeVisitKey(worktreeId: string, hostId?: ExecutionHostId): string {
return hostId ? composeWorktreeHostIdentity(hostId, worktreeId) : worktreeId
}
/**
* Decode only keys that use the canonical host-qualified shape. Bare legacy
* ids are returned unchanged, including ids that happen to contain `|`.
*/
export function getWorktreeIdFromVisitKey(key: string): string {
const separator = key.indexOf('|')
if (separator === -1) {
return key
}
// Empty host is the canonical unknown-host bucket. Known host ids are the
// other valid prefixes; a legacy worktree id may itself contain `|`.
if (isWorktreeHostIdentity(key)) {
return getWorktreeIdFromHostIdentity(key)
}
return key
}
export function isHostQualifiedVisitKey(key: string): boolean {
return getWorktreeIdFromVisitKey(key) !== key
}
/** Remove all recency entries belonging to one or more raw worktree ids. */
export function removeWorktreeVisitEntries(
timestamps: Readonly<Record<string, number>>,
worktreeIds: ReadonlySet<string>,
hostId?: ExecutionHostId
): Record<string, number> {
return removeWorktreeVisitEntriesForTargets(
timestamps,
[...worktreeIds].map((id) => ({ id, hostId }))
)
}
/**
* Remove recency for bulk purge targets. A host-qualified target only removes
* that host's key; an unknown-host target deliberately retains the legacy
* raw-id behavior because no owner can safely be inferred.
*/
export function removeWorktreeVisitEntriesForTargets(
timestamps: Readonly<Record<string, number>>,
targets: readonly WorktreeVisitOwner[]
): Record<string, number> {
let changed = false
const next: Record<string, number> = {}
const qualified = new Set<string>()
const legacyIds = new Set<string>()
for (const target of targets) {
if (target.hostId) {
qualified.add(getWorktreeVisitKey(target.id, target.hostId))
} else {
legacyIds.add(target.id)
}
}
for (const [key, timestamp] of Object.entries(timestamps)) {
const rawId = getWorktreeIdFromVisitKey(key)
const remove = qualified.has(key) || legacyIds.has(rawId)
if (remove) {
changed = true
continue
}
next[key] = timestamp
}
return changed ? next : (timestamps as Record<string, number>)
}
@@ -257,6 +257,11 @@ describe('purgeStaleRuntimeHostState', () => {
]
},
tabsByWorktree: { [worktreeId]: tabs },
lastVisitedAtByWorktreeId: {
[worktreeId]: 50,
[`${RUNTIME_A}|${worktreeId}`]: 100,
[`${RUNTIME_B}|${worktreeId}`]: 200
},
restoredRuntimeHostIdByWorkspaceSessionKey: { [worktreeId]: RUNTIME_B },
activeWorktreeId: worktreeId,
activeWorkspaceKey: worktreeWorkspaceKey(worktreeId)
@@ -269,6 +274,10 @@ describe('purgeStaleRuntimeHostState', () => {
expect(s.worktreesByRepo.shared).toHaveLength(1)
expect(s.worktreesByRepo.shared[0]?.hostId).toBe(RUNTIME_B)
expect(s.tabsByWorktree[worktreeId]).toBe(tabs)
expect(s.lastVisitedAtByWorktreeId).toEqual({
[worktreeId]: 50,
[`${RUNTIME_B}|${worktreeId}`]: 200
})
expect(s.restoredRuntimeHostIdByWorkspaceSessionKey).toBe(restoredOwners)
expect(s.restoredRuntimeHostIdByWorkspaceSessionKey[worktreeId]).toBe(RUNTIME_B)
expect(s.activeWorktreeId).toBe(worktreeId)
@@ -366,6 +366,31 @@ describe('repo slice host identity routing', () => {
expect(ptyKill).not.toHaveBeenCalledWith('remote-pty')
})
it('preserves qualified recency for an exact-id sibling host', async () => {
const worktreeId = 'same-repo::/shared/wt'
const localWorktree = makeWorktree({ id: worktreeId, repoId: 'same-repo' })
const remoteWorktree = makeWorktree({
id: worktreeId,
repoId: 'same-repo',
hostId: 'runtime:env-1'
})
const store = createTestStore()
store.setState({
repos: [localDuplicate, remoteDuplicate],
worktreesByRepo: { 'same-repo': [localWorktree, remoteWorktree] },
lastVisitedAtByWorktreeId: {
[`local|${worktreeId}`]: 10,
[`runtime:env-1|${worktreeId}`]: 20
}
})
await store.getState().removeProject('same-repo', { hostId: 'local' })
expect(store.getState().lastVisitedAtByWorktreeId).toEqual({
[`runtime:env-1|${worktreeId}`]: 20
})
})
it('removeProject with an explicit hostId routes to that host, not the focused one', async () => {
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-explicit-host',
+8 -2
View File
@@ -50,6 +50,7 @@ import { applyManualRepoOrder, getManualRepoOrder } from '../../../../shared/man
import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups'
import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
import { getWorktreeIdFromVisitKey, getWorktreeVisitKey } from '@/lib/worktree-visit-recency'
import { structuralValuesEqual } from '../../../../shared/structural-value-equality'
import { selectProjectGroupRemovalTargets } from './project-group-removal-targets'
import {
@@ -3756,6 +3757,9 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}
// Why: editor state is worktree-scoped; clear the repo's open files + active-file tracking so orphans don't linger in the session save.
const worktreeIdSet = new Set(worktreeIds)
const removedVisitKeys = new Set(
worktreeIds.map((worktreeId) => getWorktreeVisitKey(worktreeId, ownerHostId))
)
const nextOpenFiles = s.openFiles.filter((f) => !worktreeIdSet.has(f.worktreeId))
const nextActiveFileIdByWorktree = { ...s.activeFileIdByWorktree }
const nextActiveTabTypeByWorktree = { ...s.activeTabTypeByWorktree }
@@ -3771,9 +3775,11 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
const repoIdFullyRemoved = !nextRepos.some((r) => r.id === projectId)
let nextLastVisitedAtByWorktreeId = s.lastVisitedAtByWorktreeId
for (const id of Object.keys(s.lastVisitedAtByWorktreeId)) {
const rawId = getWorktreeIdFromVisitKey(id)
if (
worktreeIdSet.has(id) ||
(repoIdFullyRemoved && getRepoIdFromWorktreeId(id) === projectId)
(ownerHostId && removedVisitKeys.has(id)) ||
(!ownerHostId && worktreeIdSet.has(rawId)) ||
(repoIdFullyRemoved && getRepoIdFromWorktreeId(rawId) === projectId)
) {
if (nextLastVisitedAtByWorktreeId === s.lastVisitedAtByWorktreeId) {
nextLastVisitedAtByWorktreeId = { ...s.lastVisitedAtByWorktreeId }
@@ -43,6 +43,7 @@ import {
throwIfWorkspaceCleanupScanSuperseded
} from './workspace-cleanup-broad-scan-registry'
import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence'
import { getWorktreeVisitTimestamp } from '@/lib/worktree-visit-recency'
import type { PreservedBranchCleanup } from '@/lib/preserved-branch-cleanup'
export type WorkspaceCleanupFailure = {
@@ -821,7 +822,11 @@ function getInitialWorkspaceCleanupGitDeferrals(state: AppState): string[] {
const hasVisibleContext =
openEditorWorktreeIds.has(worktreeId) ||
(state.browserTabsByWorktree[worktreeId]?.length ?? 0) > 0
const lastVisitedAt = state.lastVisitedAtByWorktreeId[worktreeId] ?? 0
const lastVisitedAt =
getWorktreeVisitTimestamp(state.lastVisitedAtByWorktreeId, {
id: worktreeId,
hostId: state.getKnownWorktreeById(worktreeId)?.hostId
}) ?? 0
if (
hasVisibleContext &&
lastVisitedAt > 0 &&
@@ -991,7 +996,11 @@ function getWorkspaceCleanupLocalStateSignature(
browserTabCount: (state.browserTabsByWorktree[worktreeId] ?? []).length,
retainedDoneAgentPaneKeys,
agentStatuses,
lastVisitedAt: state.lastVisitedAtByWorktreeId[worktreeId] ?? 0,
lastVisitedAt:
getWorktreeVisitTimestamp(state.lastVisitedAtByWorktreeId, {
id: worktreeId,
hostId: state.getKnownWorktreeById(worktreeId)?.hostId
}) ?? 0,
viewed: state.workspaceCleanupViewedCandidates[worktreeId] ?? null,
dismissal
})
@@ -1036,7 +1045,11 @@ async function enrichWorkspaceCleanupCandidate(
blockers.push('terminal-liveness-unknown')
}
const lastVisitedAt = state.lastVisitedAtByWorktreeId[candidate.worktreeId] ?? 0
const lastVisitedAt =
getWorktreeVisitTimestamp(state.lastVisitedAtByWorktreeId, {
id: candidate.worktreeId,
hostId: getWorkspaceCleanupCandidateHostId(candidate)
}) ?? 0
const hasVisibleContext = cleanEditorTabCount > 0 || browserTabCount > 0
if (
hasVisibleContext &&
@@ -0,0 +1,15 @@
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { WorktreeForceDeleteReason } from '../../../../shared/worktree/removal'
import type { Worktree } from '../../../../shared/worktree/types'
export type WorktreeDeleteState = {
isDeleting: boolean
phase?: 'deleting' | 'queued'
executionHostId?: ExecutionHostId | null
error: string | null
canForceDelete: boolean
forceDeleteReason: WorktreeForceDeleteReason | null
lockReason?: string | null
}
export type WorktreeDeleteStateTarget = Pick<Worktree, 'id' | 'hostId'>
@@ -24,10 +24,7 @@ import type {
Worktree
} from '../../../../shared/worktree/types'
import type { TaskSourceContext } from '../../../../shared/task-source-context'
import type {
WorktreeForceDeleteReason,
WorktreeRemovalTarget
} from '../../../../shared/worktree/removal'
import type { WorktreeRemovalTarget } from '../../../../shared/worktree/removal'
import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { RemoveWorktreeOptions } from './worktree-removal-options'
@@ -43,20 +40,12 @@ import type {
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
import type { AppState } from '../types'
import type { WorktreeRefreshAllOptions } from './worktree-refresh-options'
export type { WorktreePurgeTarget, WorktreePurgeTargets } from './worktree-purge-target'
import type { WorktreePurgeTargets } from './worktree-purge-target'
export type { WorktreeDeleteState, WorktreeDeleteStateTarget } from './worktree-delete-state-types'
import type { WorktreeDeleteState, WorktreeDeleteStateTarget } from './worktree-delete-state-types'
export { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
export type WorktreeDeleteState = {
isDeleting: boolean
phase?: 'deleting' | 'queued'
executionHostId?: ExecutionHostId | null
error: string | null
canForceDelete: boolean
forceDeleteReason: WorktreeForceDeleteReason | null
lockReason?: string | null
}
export type WorktreeDeleteStateTarget = Pick<Worktree, 'id' | 'hostId'>
import type { RendererRemoveWorktreeResult } from './renderer-remove-worktree-result'
export type WorktreeFetchOptions = {
@@ -147,6 +136,7 @@ export type WorktreeSlice = {
* (activateAndRevealWorktree), NOT from background activity events or raw
* `setActiveWorktree` calls. See docs/cmd-j-empty-query-ordering.md.
*/
/** New host-qualified rows use `${host}|${worktreeId}`; legacy bare ids remain readable. */
lastVisitedAtByWorktreeId: Record<string, number>
/**
* Guards the one-shot hydration-time purge in `fetchAllWorktrees`. Set to
@@ -296,7 +286,11 @@ export type WorktreeSlice = {
* stored value. Called from user-initiated activations only. See
* docs/cmd-j-empty-query-ordering.md.
*/
markWorktreeVisited: (worktreeId: string, visitedAt?: number) => void
markWorktreeVisited: (
worktreeId: string,
visitedAt?: number,
executionHostId?: ExecutionHostId
) => void
/**
* Drop `lastVisitedAtByWorktreeId` entries whose worktree IDs no longer
* exist. Must be called AFTER worktree hydration completes — repos load
@@ -337,7 +331,7 @@ export type WorktreeSlice = {
* Called by the `worktrees:changed` listener on server-side deletions and
* one-shot at hydration time. See design §4.4.
*/
purgeWorktreeTerminalState: (worktreeIds: string[]) => void
purgeWorktreeTerminalState: (worktreeTargets: WorktreePurgeTargets) => void
/**
* Retires every client-store row (repos, project host setups, worktree +
* detected-worktree rows, and their tab/PTY/browser/editor cascade) owned by a
@@ -0,0 +1,7 @@
import type { Worktree } from '../../../../shared/worktree/types'
/** A purge target with optional host ownership for host-scoped state. */
export type WorktreePurgeTarget = Pick<Worktree, 'id' | 'hostId'>
/** Strings remain supported for callers that only have a raw worktree id. */
export type WorktreePurgeTargets = readonly (string | WorktreePurgeTarget)[]
@@ -115,6 +115,18 @@ describe('setActiveWorktree focus handling', () => {
})
describe('markWorktreeVisited', () => {
it('stamps a host-qualified key when the worktree owner is known', () => {
const store = createTestStore()
const worktree = makeWorktree({ id: 'repo1::/hosted', repoId: 'repo1', hostId: 'ssh:builder' })
store.setState({ worktreesByRepo: { repo1: [worktree] } } as Partial<AppState>)
store.getState().markWorktreeVisited(worktree.id, 1000)
expect(store.getState().lastVisitedAtByWorktreeId).toEqual({
[`ssh:builder|${worktree.id}`]: 1000
})
})
it('is monotonic: an older timestamp does not regress the stored value', () => {
const store = createTestStore()
store.getState().markWorktreeVisited('wt-1', 1000)
@@ -155,6 +167,28 @@ describe('markWorktreeVisited', () => {
expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ 'repo1::/a': 100 })
})
it('prunes host-qualified recency by host while retaining a same-id twin', () => {
const store = createTestStore()
const id = 'repo1::/shared'
const local = makeWorktree({ id, repoId: 'repo1', path: '/shared', hostId: 'local' })
const ssh = makeWorktree({ id, repoId: 'repo1', path: '/shared', hostId: 'ssh:builder' })
store.setState({
worktreesByRepo: { repo1: [local, ssh] },
lastVisitedAtByWorktreeId: {
[`local|${id}`]: 100,
[`ssh:builder|${id}`]: 200,
'local|repo1::/gone': 300
}
} as Partial<AppState>)
store.getState().pruneLastVisitedTimestamps()
expect(store.getState().lastVisitedAtByWorktreeId).toEqual({
[`local|${id}`]: 100,
[`ssh:builder|${id}`]: 200
})
})
it('pruneLastVisitedTimestamps preserves entries for not-yet-hydrated repos (e.g. SSH pre-connect)', () => {
const store = createTestStore()
const wt = makeWorktree({ id: 'repo1::/a', repoId: 'repo1', path: '/a' })
@@ -162,7 +162,12 @@ export function mergeFetchedWorktrees(
}
}
: {}),
...(removedIds.length > 0 ? buildWorktreePurgeState(s, removedIds) : {})
...(removedIds.length > 0
? buildWorktreePurgeState(
s,
removedIds.map((id) => ({ id, hostId: args.hostId }))
)
: {})
}
})
if (admitted) {
@@ -1,6 +1,7 @@
import type { AppState } from '../../../types'
import { splitWorktreeIdForFilesystem } from '../../../../../../shared/worktree/id'
import { worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope'
import { getWorktreeIdFromVisitKey } from '@/lib/worktree-visit-recency'
import {
remapClosedTerminalTabSnapshotCwds,
type ClosedTerminalTabSnapshot
@@ -41,7 +42,6 @@ const WORKTREE_ID_KEYED_MAP_KEYS = [
'gitBranchCompareRequestStatusHeadByWorktree',
'showDotfilesByWorktree',
'expandedDirs',
'lastVisitedAtByWorktreeId',
'defaultTerminalTabsAppliedByWorktreeId',
'recentlyClosedTabKindsByWorktree'
] as const satisfies readonly (keyof AppState)[]
@@ -98,6 +98,24 @@ export function buildWorktreeRenameState(
for (const key of WORKTREE_ID_KEYED_MAP_KEYS) {
renameKey(key, renameValueByKey[key] as ((value: unknown) => unknown) | undefined)
}
// Recency keys may carry a host prefix. Preserve that prefix while moving
// the path-derived id so a rename cannot merge host twins.
const nextVisitRecency = { ...s.lastVisitedAtByWorktreeId }
let visitRecencyChanged = false
for (const [key, value] of Object.entries(s.lastVisitedAtByWorktreeId)) {
const rawId = getWorktreeIdFromVisitKey(key)
if (rawId !== oldWorktreeId) {
continue
}
const nextKey =
rawId === key ? newWorktreeId : `${key.slice(0, key.length - rawId.length)}${newWorktreeId}`
nextVisitRecency[nextKey] = value
delete nextVisitRecency[key]
visitRecencyChanged = true
}
if (visitRecencyChanged) {
renamed.lastVisitedAtByWorktreeId = nextVisitRecency
}
// Re-key on rename so a renamed worktree keeps its editor-undo + push/pull state.
renameKey('recentlyClosedEditorTabsByWorktree', (files: { worktreeId: string }[]) =>
files.map(withNewWorktreeId)
@@ -71,11 +71,14 @@ 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 (worktreeTargets) => {
const purgeableWorktreeTargets = worktreeTargets.filter((target) => {
const worktreeId = typeof target === 'string' ? target : target.id
return worktreeId !== FLOATING_TERMINAL_WORKTREE_ID
})
if (purgeableWorktreeTargets.length === 0) {
return
}
set((s) => buildWorktreePurgeState(s, purgeableWorktreeIds))
set((s) => buildWorktreePurgeState(s, purgeableWorktreeTargets))
}
}
@@ -2,24 +2,39 @@ import type { WorktreeSlice } from '../../worktree-helpers'
import type { WorktreeSliceGet, WorktreeSliceSet } from '../listing/worktree-slice-types'
import { getRepoIdFromWorktreeId } from '../../worktree-helpers'
import { worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import {
getWorktreeIdFromVisitKey,
getWorktreeVisitKey,
getWorktreeVisitTimestamp
} from '@/lib/worktree-visit-recency'
export function createMarkWorktreeVisited(
set: WorktreeSliceSet,
_get: WorktreeSliceGet
): WorktreeSlice['markWorktreeVisited'] {
return (worktreeId, visitedAt) => {
return (worktreeId, visitedAt, executionHostId) => {
// 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
const ownerHostId =
executionHostId ??
(s.activeWorktreeId === worktreeId ? s.activeWorkspaceExecutionHostId : undefined) ??
s.getKnownWorktreeById(worktreeId, executionHostId)?.hostId
const visitKey = getWorktreeVisitKey(worktreeId, ownerHostId)
const prev =
getWorktreeVisitTimestamp(s.lastVisitedAtByWorktreeId, {
id: worktreeId,
hostId: ownerHostId
}) ?? 0
if (!(now > prev)) {
return {}
}
return {
lastVisitedAtByWorktreeId: {
...s.lastVisitedAtByWorktreeId,
[worktreeId]: now
[visitKey]: now
}
}
})
@@ -35,30 +50,41 @@ export function createPruneLastVisitedTimestamps(
// 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>>()
const validWorktreeIds = new Set<string>()
const validVisitKeys = new Set<string>()
const addValidWorktree = (worktree: { id: string; hostId?: ExecutionHostId }): void => {
validWorktreeIds.add(worktree.id)
validVisitKeys.add(getWorktreeVisitKey(worktree.id, worktree.hostId))
}
for (const [repoId, list] of Object.entries(s.worktreesByRepo)) {
// An empty list is the not-yet-hydrated shape, not an authoritative "no worktrees".
if (s.detectedWorktreesByRepo[repoId] || list.length === 0) {
continue
}
validIdsByRepo.set(repoId, new Set(list.map((worktree) => worktree.id)))
list.forEach(addValidWorktree)
}
for (const [repoId, result] of Object.entries(s.detectedWorktreesByRepo)) {
if (result.authoritative) {
validIdsByRepo.set(repoId, new Set(result.worktrees.map((worktree) => worktree.id)))
result.worktrees.forEach(addValidWorktree)
}
}
let changed = false
const next: Record<string, number> = {}
for (const [id, ts] of Object.entries(s.lastVisitedAtByWorktreeId)) {
for (const [key, ts] of Object.entries(s.lastVisitedAtByWorktreeId)) {
const id = getWorktreeIdFromVisitKey(key)
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
next[key] = ts
continue
}
if (repoIds.has(id)) {
next[id] = ts
// Legacy bare keys remain valid when any host still publishes the id;
// qualified keys must match the exact host-qualified row.
if (getWorktreeIdFromVisitKey(key) === key ? repoIds.has(id) : validVisitKeys.has(key)) {
next[key] = ts
} else {
changed = true
}
@@ -113,13 +139,15 @@ export function createSeedActiveWorktreeLastVisitedIfMissing(
if (!id) {
return {}
}
if (s.lastVisitedAtByWorktreeId[id] != null) {
const hostId = s.activeWorkspaceExecutionHostId ?? s.getKnownWorktreeById(id)?.hostId
const key = getWorktreeVisitKey(id, hostId)
if (getWorktreeVisitTimestamp(s.lastVisitedAtByWorktreeId, { id, hostId }) != null) {
return {}
}
return {
lastVisitedAtByWorktreeId: {
...s.lastVisitedAtByWorktreeId,
[id]: Date.now()
[key]: Date.now()
}
}
})
@@ -1,4 +1,4 @@
import type { WorktreeSlice } from '../../worktree-helpers'
import type { WorktreePurgeTarget, 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'
@@ -15,6 +15,7 @@ import {
isRemovedRuntimeHostId
} from '../../stale-runtime-host-rows'
import { buildWorktreePurgeState } from './worktree-purge-state'
import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency'
export function createPurgeStaleRuntimeHostState(
set: WorktreeSliceSet,
@@ -86,6 +87,8 @@ export function createPurgeStaleRuntimeHostState(
recordWorktreeOwners(s.worktreesByRepo)
recordWorktreeOwners(detectedRows)
const removedWorktreeTargets: WorktreePurgeTarget[] = []
const seenRemovedWorktreeTargets = new Set<string>()
const sessionWorktreeIdsOwnedByRemovedHosts = new Set<string>()
let survivingRestoredSessionOwners = s.restoredRuntimeHostIdByWorkspaceSessionKey
for (const [workspaceKey, hostId] of Object.entries(
@@ -103,6 +106,12 @@ export function createPurgeStaleRuntimeHostState(
continue
}
sessionWorktreeIdsOwnedByRemovedHosts.add(worktreeId)
// Restored session ownership is authoritative even before catalog rows hydrate.
const identity = `${hostId}\u0000${worktreeId}`
if (!seenRemovedWorktreeTargets.has(identity)) {
seenRemovedWorktreeTargets.add(identity)
removedWorktreeTargets.push({ id: worktreeId, hostId })
}
repoIdsWithRemovedOwners.add(repoId)
if (survivingRestoredSessionOwners === s.restoredRuntimeHostIdByWorkspaceSessionKey) {
survivingRestoredSessionOwners = { ...survivingRestoredSessionOwners }
@@ -116,6 +125,55 @@ export function createPurgeStaleRuntimeHostState(
repoIdsWithoutSurvivingOwners.delete(repoId)
}
// Preserve host ownership for recency teardown. The other bulk maps are
// keyed by raw worktree id, but a host-qualified visit must not be
// removed when an id twin survives on another host.
const addRemovedWorktreeTarget = (
worktreeId: string,
hostId: ExecutionHostId | undefined
): void => {
const identity = `${hostId ?? ''}\u0000${worktreeId}`
if (seenRemovedWorktreeTargets.has(identity)) {
return
}
seenRemovedWorktreeTargets.add(identity)
removedWorktreeTargets.push(hostId ? { id: worktreeId, hostId } : { id: worktreeId })
}
const recordRemovedRowTargets = (
rowsByRepo: Record<
string,
readonly {
id: string
hostId?: ExecutionHostId
runtimeOwnerEnvironmentId?: string
}[]
>
): void => {
for (const [repoId, rows] of Object.entries(rowsByRepo)) {
for (const row of rows) {
const removedOwner =
(row.runtimeOwnerEnvironmentId !== undefined &&
removed.has(row.runtimeOwnerEnvironmentId)) ||
isRemovedRuntimeHostId(row.hostId, removed) ||
(row.hostId === undefined && repoIdsWithoutSurvivingOwners.has(repoId))
if (!removedOwner) {
continue
}
// A row's execution host is the recency owner. Runtime ownership
// is the only host evidence available for legacy rows without it.
addRemovedWorktreeTarget(
row.id,
row.hostId ??
(row.runtimeOwnerEnvironmentId
? toRuntimeExecutionHostId(row.runtimeOwnerEnvironmentId)
: undefined)
)
}
}
}
recordRemovedRowTargets(s.worktreesByRepo)
recordRemovedRowTargets(detectedRows)
const worktreeDrop = dropWorktreeRowsForRemovedRuntimeEnvironments(
s.worktreesByRepo,
removed,
@@ -163,8 +221,24 @@ export function createPurgeStaleRuntimeHostState(
}
}
}
const purgeState =
removedWorktreeIds.size > 0 ? buildWorktreePurgeState(s, [...removedWorktreeIds]) : {}
const purgeTargets = [...removedWorktreeIds].flatMap((worktreeId) => {
const targets = removedWorktreeTargets.filter((target) => target.id === worktreeId)
return targets.length > 0 ? targets : [{ id: worktreeId }]
})
const purgeState = purgeTargets.length > 0 ? buildWorktreePurgeState(s, purgeTargets) : {}
const visitPurgeTargets = [
...removedWorktreeTargets,
...purgeTargets.filter(
(target) => !removedWorktreeTargets.some((candidate) => candidate.id === target.id)
)
]
const nextLastVisitedAtByWorktreeId = removeWorktreeVisitEntriesForTargets(
s.lastVisitedAtByWorktreeId,
visitPurgeTargets
)
if (nextLastVisitedAtByWorktreeId !== s.lastVisitedAtByWorktreeId) {
purgeState.lastVisitedAtByWorktreeId = nextLastVisitedAtByWorktreeId
}
const restoredSessionOwnersChanged =
survivingRestoredSessionOwners !== s.restoredRuntimeHostIdByWorkspaceSessionKey
@@ -1,11 +1,14 @@
import { worktreeWorkspaceKey } from '../../../../../../shared/workspace-scope'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import type { WorktreeSliceSet } from '../listing/worktree-slice-types'
import { removeDeleteStatesForWorktreeIds } from './worktree-delete-state'
import { removeWorktreeVisitEntries } from '@/lib/worktree-visit-recency'
export function applyRemoveWorktreeSuccessState(
set: WorktreeSliceSet,
worktreeId: string,
tabIds: Set<string>
tabIds: Set<string>,
executionHostId?: ExecutionHostId
): void {
set((s) => {
const next = { ...s.worktreesByRepo }
@@ -147,14 +150,11 @@ export function applyRemoveWorktreeSuccessState(
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
const nextLastVisitedAtByWorktreeId = removeWorktreeVisitEntries(
s.lastVisitedAtByWorktreeId,
new Set([worktreeId]),
executionHostId
)
return {
worktreesByRepo: next,
worktreeLineageById: nextLineage,
@@ -59,7 +59,7 @@ export async function tearDownRemovedWorktreeRendererState(args: {
// Why: dispose parked terminal watchers only on explicit deletion; identity migration/remounts must keep buffered PTY state.
disposeRemovedWorktreeParkedTerminalWatchers(worktreeId, terminalPtyIdsBeforeRemoval)
applyRemoveWorktreeSuccessState(set, worktreeId, tabIds)
applyRemoveWorktreeSuccessState(set, worktreeId, tabIds, requiredExecutionHostId ?? hostId)
get().removeWorkspaceSpaceWorktrees?.(
hostId ? [{ id: worktreeId, executionHostId: hostId }] : [worktreeId]
)
@@ -1,13 +1,21 @@
import type { AppState } from '../../../types'
import type { WorktreePurgeTarget, WorktreePurgeTargets } from '../../worktree-helpers'
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'
import { removeDeleteStatesForWorktreeIds } from './worktree-delete-state'
import { removeWorktreeVisitEntriesForTargets } from '@/lib/worktree-visit-recency'
export function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Partial<AppState> {
const worktreeIdSet = new Set(worktreeIds)
export function buildWorktreePurgeState(
s: AppState,
worktreeTargets: WorktreePurgeTargets
): Partial<AppState> {
const normalizedTargets: WorktreePurgeTarget[] = worktreeTargets.map((target) =>
typeof target === 'string' ? { id: target } : target
)
const worktreeIdSet = new Set(normalizedTargets.map((target) => target.id))
pruneHostedReviewLinkMutationGenerations(worktreeIdSet)
// Why: every authoritative and explicit purge converges here, so a deleted path can't inherit stale UI state.
forgetHugeRepoWarningDismissalsForWorktrees(worktreeIdSet)
@@ -172,7 +180,10 @@ export function buildWorktreePurgeState(s: AppState, worktreeIds: string[]): Par
// Top-level actives
openFiles: nextOpenFiles,
everActivatedWorktreeIds: nextEverActivatedWorktreeIds,
lastVisitedAtByWorktreeId: omitByWorktree(s.lastVisitedAtByWorktreeId),
lastVisitedAtByWorktreeId: removeWorktreeVisitEntriesForTargets(
s.lastVisitedAtByWorktreeId,
normalizedTargets
),
// Why: keyed by worktreeId; re-keyed on rename but missed by both removal paths (write-once default-terminal guard).
defaultTerminalTabsAppliedByWorktreeId: omitByWorktree(
s.defaultTerminalTabsAppliedByWorktreeId
@@ -3,13 +3,21 @@ import type { RemoteWorkspaceSession, RemoteWorkspaceTerminalTab } from './remot
import type { TerminalTab } from './terminal-tab-types'
import type { WorkspaceSessionState } from './workspace-session-state-types'
import { splitWorktreeId } from './worktree/id'
import type { ExecutionHostId } from './execution-host'
import {
composeWorktreeHostIdentity,
getWorktreeIdFromHostIdentity,
isWorktreeHostIdentity
} from './worktree/host-qualified-identity'
type ExportOptions = {
isTargetWorktree: (worktreeId: string) => boolean
isTargetWorktree: (worktreeId: string, executionHostId?: ExecutionHostId) => boolean
}
type ImportOptions = {
resolveWorktreeId: (worktreePath: string) => string | null
/** Host owning the projected snapshot; absent preserves the legacy bare key. */
executionHostId?: ExecutionHostId
}
function worktreePathFromId(worktreeId: string): string | null {
@@ -70,8 +78,14 @@ export function exportRemoteWorkspaceSession(
}
const lastVisitedAtByWorktreePath: Record<string, number> = {}
for (const [worktreeId, timestamp] of Object.entries(session.lastVisitedAtByWorktreeId ?? {})) {
if (!options.isTargetWorktree(worktreeId)) {
for (const [visitKey, timestamp] of Object.entries(session.lastVisitedAtByWorktreeId ?? {})) {
const worktreeId = isWorktreeHostIdentity(visitKey)
? getWorktreeIdFromHostIdentity(visitKey)
: visitKey
const executionHostId = isWorktreeHostIdentity(visitKey)
? (visitKey.slice(0, visitKey.indexOf('|')) as ExecutionHostId)
: undefined
if (!options.isTargetWorktree(worktreeId, executionHostId)) {
continue
}
const worktreePath = worktreePathFromId(worktreeId)
@@ -101,7 +115,7 @@ export function exportRemoteWorkspaceSession(
)
),
activeWorktreePathsOnShutdown: session.activeWorktreeIdsOnShutdown
?.filter(options.isTargetWorktree)
?.filter((worktreeId) => options.isTargetWorktree(worktreeId))
.map(worktreePathFromId)
.filter((path): path is string => Boolean(path)),
activeTabIdByWorktreePath,
@@ -166,7 +180,10 @@ export function importRemoteWorkspaceSession(
)) {
const worktreeId = resolvePath(worktreePath)
if (worktreeId) {
lastVisitedAtByWorktreeId[worktreeId] = timestamp
const key = options.executionHostId
? composeWorktreeHostIdentity(options.executionHostId, worktreeId)
: worktreeId
lastVisitedAtByWorktreeId[key] = timestamp
}
}
+3 -1
View File
@@ -84,7 +84,9 @@ export type WorkspaceSessionState = {
* and worktreeNavHistory (Back/Forward stack). See
* docs/cmd-j-empty-query-ordering.md. Absent in sessions written by
* older builds — hydration tolerates missing/partial maps and the
* active worktree is seeded on first restore. */
* active worktree is seeded on first restore. New host-qualified keys use
* `${executionHostId}|${worktreeId}`; legacy bare keys remain readable
* during migration and remote snapshot projection. */
lastVisitedAtByWorktreeId?: Record<string, number>
/** Worktrees whose repo-defined default terminal tabs have already been
* considered. Persisted so closing all tabs and re-opening the workspace
+10 -1
View File
@@ -1,4 +1,4 @@
import type { ExecutionHostId } from '../execution-host'
import { parseExecutionHostId, type ExecutionHostId } from '../execution-host'
import type { Worktree } from './types'
/**
@@ -49,3 +49,12 @@ export function composeWorktreeHostIdentity(
export function getWorktreeIdFromHostIdentity(identity: string): string {
return identity.slice(identity.indexOf(HOST_SEPARATOR) + 1)
}
/** True only for the canonical host-qualified form, not a legacy id containing `|`. */
export function isWorktreeHostIdentity(identity: string): boolean {
const separator = identity.indexOf(HOST_SEPARATOR)
return (
separator === 0 ||
(separator > 0 && parseExecutionHostId(identity.slice(0, separator)) !== null)
)
}