diff --git a/src/main/ipc/remote-workspace.ts b/src/main/ipc/remote-workspace.ts index 527a001bb67..2b8966c3297 100644 --- a/src/main/ipc/remote-workspace.ts +++ b/src/main/ipc/remote-workspace.ts @@ -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 | 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 }) } diff --git a/src/main/orca-profiles/profile-project-session-transfer.ts b/src/main/orca-profiles/profile-project-session-transfer.ts index d5884096bd9..da55794b551 100644 --- a/src/main/orca-profiles/profile-project-session-transfer.ts +++ b/src/main/orca-profiles/profile-project-session-transfer.ts @@ -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 ) } diff --git a/src/main/orca-profiles/profile-project-transfer-worktree-ids.ts b/src/main/orca-profiles/profile-project-transfer-worktree-ids.ts index 51d87cfc668..485861d748d 100644 --- a/src/main/orca-profiles/profile-project-transfer-worktree-ids.ts +++ b/src/main/orca-profiles/profile-project-transfer-worktree-ids.ts @@ -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 | 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)) { diff --git a/src/main/orca-profiles/profile-project-worktree-identity.ts b/src/main/orca-profiles/profile-project-worktree-identity.ts index cdc8d9182d2..1586a0e0120 100644 --- a/src/main/orca-profiles/profile-project-worktree-identity.ts +++ b/src/main/orca-profiles/profile-project-worktree-identity.ts @@ -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 @@ -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) diff --git a/src/main/persistence/restoring-sessions/session-owner-fields.ts b/src/main/persistence/restoring-sessions/session-owner-fields.ts index ee06ada964b..c18d50ae612 100644 --- a/src/main/persistence/restoring-sessions/session-owner-fields.ts +++ b/src/main/persistence/restoring-sessions/session-owner-fields.ts @@ -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] diff --git a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts index f12da044c15..b00bdf5a1d4 100644 --- a/src/main/persistence/tracking-repos/repo-worktree-pruning.ts +++ b/src/main/persistence/tracking-repos/repo-worktree-pruning.ts @@ -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() const collectPrefixedKeys = (keys: Iterable): 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) } } diff --git a/src/main/persistence/tracking-repos/worktree-identity-migration.ts b/src/main/persistence/tracking-repos/worktree-identity-migration.ts index f8256c0c4fe..4e41a0a178f 100644 --- a/src/main/persistence/tracking-repos/worktree-identity-migration.ts +++ b/src/main/persistence/tracking-repos/worktree-identity-migration.ts @@ -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)) { diff --git a/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts index 1993e044f4b..214b6c2c72c 100644 --- a/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts +++ b/src/renderer/src/components/sidebar/active-worktree-focus-after-delete.ts @@ -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 @@ -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 } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-row-caches.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-row-caches.ts index 07f1c041ab9..2ac23e11ff8 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-row-caches.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facet-row-caches.ts @@ -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) ?? diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts index c52a9a020e6..8928b8b2b49 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-facets.ts @@ -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) diff --git a/src/renderer/src/hooks/remote-workspace-session-merge.ts b/src/renderer/src/hooks/remote-workspace-session-merge.ts index 4a2b087817f..731b1412068 100644 --- a/src/renderer/src/hooks/remote-workspace-session-merge.ts +++ b/src/renderer/src/hooks/remote-workspace-session-merge.ts @@ -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, liveTabsByWorktree: AppState['tabsByWorktree'], - preserveLocalTerminalTabIds: ReadonlySet + preserveLocalTerminalTabIds: ReadonlySet, + 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 | undefined + ): Record => + 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: { diff --git a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts index 1ffad891d89..16929fd6379 100644 --- a/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts +++ b/src/renderer/src/hooks/remote-workspace-snapshot-apply.ts @@ -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 diff --git a/src/renderer/src/hooks/useIpcEvents.ts b/src/renderer/src/hooks/useIpcEvents.ts index 333f5b51a69..86e64026a2b 100644 --- a/src/renderer/src/hooks/useIpcEvents.ts +++ b/src/renderer/src/hooks/useIpcEvents.ts @@ -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) } } diff --git a/src/renderer/src/lib/order-empty-query-worktrees.ts b/src/renderer/src/lib/order-empty-query-worktrees.ts index 08f31534145..54a116d73ce 100644 --- a/src/renderer/src/lib/order-empty-query-worktrees.ts +++ b/src/renderer/src/lib/order-empty-query-worktrees.ts @@ -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 diff --git a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts index 90a7b88fdc6..4b71f771c55 100644 --- a/src/renderer/src/lib/recent-workspace-tab-rows.test.ts +++ b/src/renderer/src/lib/recent-workspace-tab-rows.test.ts @@ -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')] diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index b84b50909f7..9d8cdb34857 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -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 { } function normalizeWorkspaceSessionKeyForOwnerMap(value: string): string { + if (isWorktreeHostIdentity(value)) { + return getWorktreeIdFromHostIdentity(value) + } const scope = parseWorkspaceKey(value) return scope?.type === 'worktree' ? scope.worktreeId : value } diff --git a/src/renderer/src/lib/workspace-session-host-split.ts b/src/renderer/src/lib/workspace-session-host-split.ts index 103a1abcf63..c4242221b2f 100644 --- a/src/renderer/src/lib/workspace-session-host-split.ts +++ b/src/renderer/src/lib/workspace-session-host-split.ts @@ -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)) { diff --git a/src/renderer/src/lib/worktree-visit-recency.test.ts b/src/renderer/src/lib/worktree-visit-recency.test.ts new file mode 100644 index 00000000000..4b431886a1a --- /dev/null +++ b/src/renderer/src/lib/worktree-visit-recency.test.ts @@ -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 + }) + }) +}) diff --git a/src/renderer/src/lib/worktree-visit-recency.ts b/src/renderer/src/lib/worktree-visit-recency.ts new file mode 100644 index 00000000000..0fa9533ce33 --- /dev/null +++ b/src/renderer/src/lib/worktree-visit-recency.ts @@ -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 + +/** + * 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> | 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>, + worktreeIds: ReadonlySet, + hostId?: ExecutionHostId +): Record { + 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>, + targets: readonly WorktreeVisitOwner[] +): Record { + let changed = false + const next: Record = {} + const qualified = new Set() + const legacyIds = new Set() + 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) +} diff --git a/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts b/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts index d70f253b94a..71cde0bac72 100644 --- a/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts +++ b/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts @@ -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) diff --git a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts index 5eba51545d4..1c8cd78be57 100644 --- a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts +++ b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts @@ -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', diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index f0fd6fcbae8..05784e5b482 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -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 = (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 = (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 } diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index b93fa13571e..7a607201688 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -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 && diff --git a/src/renderer/src/store/slices/worktree-delete-state-types.ts b/src/renderer/src/store/slices/worktree-delete-state-types.ts new file mode 100644 index 00000000000..85178bc15dc --- /dev/null +++ b/src/renderer/src/store/slices/worktree-delete-state-types.ts @@ -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 diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index a67bcfdfd6b..fb368bec070 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -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 - 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 /** * 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 diff --git a/src/renderer/src/store/slices/worktree-purge-target.ts b/src/renderer/src/store/slices/worktree-purge-target.ts new file mode 100644 index 00000000000..031765acccf --- /dev/null +++ b/src/renderer/src/store/slices/worktree-purge-target.ts @@ -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 + +/** Strings remain supported for callers that only have a raw worktree id. */ +export type WorktreePurgeTargets = readonly (string | WorktreePurgeTarget)[] diff --git a/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts b/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts index c2519d6909a..b845b76e594 100644 --- a/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts +++ b/src/renderer/src/store/slices/worktrees-workspace-selection-state.test.ts @@ -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) + + 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) + + 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' }) diff --git a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts index 2311aaf0294..46b4f2fa6b4 100644 --- a/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts +++ b/src/renderer/src/store/slices/worktrees/listing/fetched-worktree-merge.ts @@ -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) { diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts index b2b25992d7a..1a841fbdfc9 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-identity-rename-state.ts @@ -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) diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts index 112c4da1aff..0bdb750f252 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-slice-lookups.ts @@ -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)) } } diff --git a/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts b/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts index 76776597d7e..5cfc5eef3d2 100644 --- a/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts +++ b/src/renderer/src/store/slices/worktrees/session/worktree-visit-recency.ts @@ -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>() + const validWorktreeIds = new Set() + const validVisitKeys = new Set() + 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 = {} - 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() } } }) diff --git a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts index a23f0707d91..8894499b714 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts @@ -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() const sessionWorktreeIdsOwnedByRemovedHosts = new Set() 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 diff --git a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts index 37486927717..08613ebdc45 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/remove-worktree-store-cleanup.ts @@ -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 + tabIds: Set, + 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, diff --git a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts index 971d8f06397..329266ae369 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/removed-worktree-renderer-teardown.ts @@ -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] ) diff --git a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts index 4445dd306bc..68b5b2883c3 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/worktree-purge-state.ts @@ -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 { - const worktreeIdSet = new Set(worktreeIds) +export function buildWorktreePurgeState( + s: AppState, + worktreeTargets: WorktreePurgeTargets +): Partial { + 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 diff --git a/src/shared/remote-workspace-session-projection.ts b/src/shared/remote-workspace-session-projection.ts index 3b60d9044ff..95b9fd18dc4 100644 --- a/src/shared/remote-workspace-session-projection.ts +++ b/src/shared/remote-workspace-session-projection.ts @@ -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 = {} - 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 } } diff --git a/src/shared/workspace-session-state-types.ts b/src/shared/workspace-session-state-types.ts index bc20b7c5459..4b4c07ba908 100644 --- a/src/shared/workspace-session-state-types.ts +++ b/src/shared/workspace-session-state-types.ts @@ -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 /** Worktrees whose repo-defined default terminal tabs have already been * considered. Persisted so closing all tabs and re-opening the workspace diff --git a/src/shared/worktree/host-qualified-identity.ts b/src/shared/worktree/host-qualified-identity.ts index 7e144c6336d..bf145dd116e 100644 --- a/src/shared/worktree/host-qualified-identity.ts +++ b/src/shared/worktree/host-qualified-identity.ts @@ -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) + ) +}