mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix(cmd-j): preserve host scope during worktree purge
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user