From a57c19aa91f87adf6abc4b151a9e87a4a18f4d48 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 6 Nov 2026 00:49:05 -0500 Subject: [PATCH] Resolve workspace lineage parents in one shared module (#38835) Inert refactor: move the same-store parent rule and merged resolver into src/shared/worktree/workspace-lineage-graph.ts and add one row identity rule. No behaviour change. --- .../src/worktree/mobile-workspace-lineage.ts | 23 ++--- .../worktree-context-menu-policy.ts | 2 +- .../worktree-lineage-projection.ts | 85 +++---------------- .../cross-store-lineage-resolution.test.ts | 31 ++++++- .../cross-store-lineage-resolution.ts | 15 ++-- src/shared/worktree/lineage-row-identity.ts | 16 ++++ .../worktree/workspace-lineage-graph.ts | 74 ++++++++++++++++ 7 files changed, 153 insertions(+), 93 deletions(-) create mode 100644 src/shared/worktree/lineage-row-identity.ts create mode 100644 src/shared/worktree/workspace-lineage-graph.ts diff --git a/mobile/src/worktree/mobile-workspace-lineage.ts b/mobile/src/worktree/mobile-workspace-lineage.ts index 2e7fc13d150..1dcec10958e 100644 --- a/mobile/src/worktree/mobile-workspace-lineage.ts +++ b/mobile/src/worktree/mobile-workspace-lineage.ts @@ -50,17 +50,20 @@ function getCrossStoreLineageParents( worktrees: readonly Worktree[], stores: MobileLineageStores ): Map { + // Folder workspaces never nest rows here, so their cross-machine children stay flat as before. const resolutionRows = new Map( - worktrees.map((worktree) => [ - getWorktreeRowIdentity(worktree), - { - worktree, - id: worktree.worktreeId, - // Rows the host lists without a host id are its own: only relayed server rows name one. - hostId: worktree.hostId ?? LOCAL_EXECUTION_HOST_ID, - instanceId: worktree.worktreeInstanceId - } - ]) + worktrees + .filter((worktree) => worktree.workspaceKind !== 'folder-workspace') + .map((worktree) => [ + getWorktreeRowIdentity(worktree), + { + worktree, + id: worktree.worktreeId, + // Rows the host lists without a host id are its own: only relayed server rows name one. + hostId: worktree.hostId ?? LOCAL_EXECUTION_HOST_ID, + instanceId: worktree.worktreeInstanceId + } + ]) ) if (![...resolutionRows.values()].some((row) => row.worktree.crossStoreParent)) { return new Map() diff --git a/src/renderer/src/components/sidebar/workspace-actions/context-menu/worktree-context-menu-policy.ts b/src/renderer/src/components/sidebar/workspace-actions/context-menu/worktree-context-menu-policy.ts index bffcdd80e00..ff7becfda5f 100644 --- a/src/renderer/src/components/sidebar/workspace-actions/context-menu/worktree-context-menu-policy.ts +++ b/src/renderer/src/components/sidebar/workspace-actions/context-menu/worktree-context-menu-policy.ts @@ -7,7 +7,7 @@ import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types' -import { getProjectedWorktreeLineage } from '../../../../../../shared/sidebar-list/worktree-lineage-projection' +import { getProjectedWorktreeLineage } from '../../../../../../shared/worktree/workspace-lineage-graph' import '../../workspace-status/presentation/workspace-status' import { getWorkspaceStatus } from '../../../../../../shared/workspace-statuses' import { translate } from '@/i18n/i18n' diff --git a/src/shared/sidebar-list/worktree-lineage-projection.ts b/src/shared/sidebar-list/worktree-lineage-projection.ts index c15d3971c45..99641b74dcb 100644 --- a/src/shared/sidebar-list/worktree-lineage-projection.ts +++ b/src/shared/sidebar-list/worktree-lineage-projection.ts @@ -1,18 +1,17 @@ import { - getCyclicLineageChildKeys, getCyclicWorktreeLineageChildIds, isValidResolvedWorktreeLineageEdge } from '../resolved-worktree-lineage' import type { ExecutionHostId } from '../execution-host' -import { - composeWorktreeHostIdentity, - getWorktreeHostIdentity -} from '../worktree/host-qualified-identity' +import { getWorktreeHostIdentity } from '../worktree/host-qualified-identity' import type { WorktreeLineage } from '../worktree/lineage-types' +import type { CrossStoreLineageView } from '../worktree/cross-store-lineage-resolution' import { - resolveCrossStoreLineageParents, - type CrossStoreLineageView -} from '../worktree/cross-store-lineage-resolution' + getProjectedWorktreeLineage, + getSameStoreLineageParent, + resolveWorkspaceLineageParents, + type WorktreeWithInlineLineage +} from '../worktree/workspace-lineage-graph' import type { Worktree } from '../worktree/types' export type LineageRenderInfo = @@ -20,18 +19,6 @@ export type LineageRenderInfo = | { state: 'valid'; lineage: WorktreeLineage; parent: Worktree } | { state: 'missing'; lineage: WorktreeLineage } -type WorktreeWithResolvedLineage = Worktree & { lineage?: WorktreeLineage | null } - -export function getProjectedWorktreeLineage( - worktree: Worktree, - lineageById: Readonly> -): WorktreeLineage | null | undefined { - if (Object.hasOwn(lineageById, worktree.id)) { - return lineageById[worktree.id] - } - return (worktree as WorktreeWithResolvedLineage).lineage -} - type LineageProjection = { cyclicLineageIds?: Set childrenByParentId?: Map @@ -148,7 +135,7 @@ export function getHostScopedWorktreeLineageInputs( worktreeMap.set(worktree.id, worktree) const projected = lineageById[worktree.id] // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolved rows may carry an inline lineage the Worktree type omits; it is only read, never trusted over a matching projection. - const inline = (worktree as WorktreeWithResolvedLineage).lineage + const inline = (worktree as WorktreeWithInlineLineage).lineage const lineage = projected?.worktreeInstanceId === worktree.instanceId ? projected : inline if (lineage) { hostLineageById[worktree.id] = lineage @@ -178,55 +165,9 @@ export function getWorktreeLineageAncestors( return ancestors } -/** Nest on the worktree’s own host; legacy rows without host IDs nest only under equally unqualified parents. */ -export function getSidebarLineageParent( - worktree: Worktree, - lineageById: Readonly>, - rowsByHostIdentity: ReadonlyMap -): Worktree | undefined { - const projected = getProjectedWorktreeLineage(worktree, lineageById) - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolved rows may carry an inline lineage the Worktree type omits; it is only read, never trusted over a matching projection. - const inline = (worktree as WorktreeWithResolvedLineage).lineage - const lineage = projected?.worktreeInstanceId === worktree.instanceId ? projected : inline - if (!lineage) { - return undefined - } - const parent = rowsByHostIdentity.get( - composeWorktreeHostIdentity(worktree.hostId, lineage.parentWorktreeId) - ) - return parent && isValidResolvedWorktreeLineageEdge(worktree, parent, lineage) - ? parent - : undefined -} - /** Child host identity → parent host identity, resolved once over every visible store's complete rows. */ export type SidebarLineageParentIdentities = ReadonlyMap -function resolveParentsByIdentity( - lineageById: Readonly>, - rowsByHostIdentity: ReadonlyMap, - crossStoreLineage?: CrossStoreLineageView -): Map { - const parentByIdentity = crossStoreLineage - ? resolveCrossStoreLineageParents(rowsByHostIdentity, crossStoreLineage) - : new Map() - for (const [identity, row] of rowsByHostIdentity) { - const parent = getSidebarLineageParent(row, lineageById, rowsByHostIdentity) - if (parent) { - parentByIdentity.set(identity, parent) - } - } - const cyclic = getCyclicLineageChildKeys( - new Map( - [...parentByIdentity].map(([identity, parent]) => [identity, getWorktreeHostIdentity(parent)]) - ) - ) - for (const identity of cyclic) { - parentByIdentity.delete(identity) - } - return parentByIdentity -} - /** Same-store edges merged with cross-store records (a valid same-store edge wins), cycles dropped. * Pass every visible store's unfiltered rows: a filtered-out twin must still make a parent ambiguous. */ export function resolveSidebarLineageParentIdentities( @@ -235,9 +176,9 @@ export function resolveSidebarLineageParentIdentities( crossStoreLineage: CrossStoreLineageView ): SidebarLineageParentIdentities { return new Map( - [...resolveParentsByIdentity(lineageById, completeRowsByHostIdentity, crossStoreLineage)].map( - ([identity, parent]) => [identity, getWorktreeHostIdentity(parent)] - ) + [ + ...resolveWorkspaceLineageParents(lineageById, completeRowsByHostIdentity, crossStoreLineage) + ].map(([identity, parent]) => [identity, getWorktreeHostIdentity(parent)]) ) } @@ -256,7 +197,7 @@ export function getSidebarLineageParentsByIdentity( return cached } if (!parentIdentities) { - const parentByIdentity = resolveParentsByIdentity(lineageById, rowsByHostIdentity) + const parentByIdentity = resolveWorkspaceLineageParents(lineageById, rowsByHostIdentity) projection.sidebarParentByIdentity = parentByIdentity return parentByIdentity } @@ -284,7 +225,7 @@ export function getSidebarLineageAncestors( const seen = new Set([getWorktreeHostIdentity(worktree)]) let current = worktree while (!cyclicLineageIds.has(current.id)) { - const parent = getSidebarLineageParent(current, lineageById, rowsByHostIdentity) + const parent = getSameStoreLineageParent(current, lineageById, rowsByHostIdentity) if (!parent || seen.has(getWorktreeHostIdentity(parent))) { break } diff --git a/src/shared/worktree/cross-store-lineage-resolution.test.ts b/src/shared/worktree/cross-store-lineage-resolution.test.ts index 6243a97bf24..4704ab4ae73 100644 --- a/src/shared/worktree/cross-store-lineage-resolution.test.ts +++ b/src/shared/worktree/cross-store-lineage-resolution.test.ts @@ -9,7 +9,11 @@ import { resolveSidebarLineageParentIdentities } from '../sidebar-list/worktree-lineage-projection' import { getWorktreeHostIdentity } from './host-qualified-identity' -import type { CrossStoreLineageView, LineageStoreView } from './cross-store-lineage-resolution' +import { + resolveCrossStoreLineageParents, + type CrossStoreLineageView, + type LineageStoreView +} from './cross-store-lineage-resolution' import type { CrossStoreWorkspaceLineage, WorktreeLineage } from './lineage-types' import type { Worktree } from './types' @@ -133,6 +137,31 @@ describe('cross-store parent resolution', () => { ).toEqual({}) }) + it('resolves a folder workspace parent by its folder id', () => { + const { instanceId: _none, ...folderRow } = row('folder:3b8f6c2e', '') + const folderRecords = { + 'worktree:repo9::/work/fix-login-tests': { + ...records['worktree:repo9::/work/fix-login-tests'], + parent: { + installationId: MAC_INSTALL, + workspaceKey: 'folder:3b8f6c2e', + instanceId: '3b8f6c2e' + } + } + } + const rows = new Map( + [folderRow, serverChild].map((worktree) => [getWorktreeHostIdentity(worktree), worktree]) + ) + const parents = resolveCrossStoreLineageParents( + rows, + new Map([ + ['', store(MAC_INSTALL)], + ['build-box', store(SERVER_INSTALL, folderRecords)] + ]) + ) + expect([...parents.values()].map((parent) => parent.id)).toEqual(['folder:3b8f6c2e']) + }) + it('resolves within the child store when both ends share it; a same-store edge wins', () => { const serverParent = row('repo9::/work/fix-login', 'parent-7c1e', 'build-box') const view = new Map([ diff --git a/src/shared/worktree/cross-store-lineage-resolution.ts b/src/shared/worktree/cross-store-lineage-resolution.ts index 5eb7ec7c28a..a9bfe2ae37f 100644 --- a/src/shared/worktree/cross-store-lineage-resolution.ts +++ b/src/shared/worktree/cross-store-lineage-resolution.ts @@ -4,8 +4,9 @@ import { lastVerifiedRuntimeStatus, type RuntimeEnvironmentStatus } from '../remote-runtime/host-status/runtime-host-status' -import { worktreeWorkspaceKey, type WorkspaceKey } from '../workspace-scope' +import type { WorkspaceKey } from '../workspace-scope' import { isCrossStoreWorkspaceLineage, validCrossStoreLineage } from './cross-store-lineage-records' +import { lineageInstanceIdOfRow, workspaceKeyOfRow } from './lineage-row-identity' import type { Worktree } from './types' /** The viewer's own store; other stores are keyed by their paired runtime environment id. */ @@ -36,11 +37,6 @@ export function lineageStoreKeyOfRow(row: LineageStoreRow): string | undefined { return host.kind === 'runtime' ? host.environmentId : OWN_LINEAGE_STORE_KEY } -function workspaceKeyOfRow(row: Pick): WorkspaceKey { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: folder rows carry their folder WorkspaceKey as id (folderWorkspaceToWorktree). - return row.id.startsWith('folder:') ? (row.id as WorkspaceKey) : worktreeWorkspaceKey(row.id) -} - /** Paired stores are identified by the installationId each server last reported. An offline server keeps * its last-known identity; a removed, retired or refusing one is left out, so it neither nests nor parents. */ export function buildCrossStoreLineageView(args: { @@ -109,7 +105,8 @@ export function resolveCrossStoreLineageParents>() const rowsByStoreAndInstance = new Map>() for (const [identity, row] of rowsByIdentity) { - if (!row.instanceId) { + const instanceId = lineageInstanceIdOfRow(row) + if (!instanceId) { continue } const storeKey = lineageStoreKeyOfRow(row) @@ -125,7 +122,7 @@ export function resolveCrossStoreLineageParents() for (const [storeKey, store] of view) { @@ -147,7 +144,7 @@ export function resolveCrossStoreLineageParents row.instanceId === lineage.childInstanceId) + ?.filter(([, row]) => lineageInstanceIdOfRow(row) === lineage.childInstanceId) ) // Two stores reporting one installationId are a copied profile; neither is the parent's. const parentStoreKey = soleRow(storeKeysByInstallationId.get(lineage.parent.installationId)) diff --git a/src/shared/worktree/lineage-row-identity.ts b/src/shared/worktree/lineage-row-identity.ts new file mode 100644 index 00000000000..198d9f271d9 --- /dev/null +++ b/src/shared/worktree/lineage-row-identity.ts @@ -0,0 +1,16 @@ +import { parseWorkspaceKey, worktreeWorkspaceKey, type WorkspaceKey } from '../workspace-scope' +import type { Worktree } from './types' + +export function workspaceKeyOfRow(row: Pick): WorkspaceKey { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: folder rows carry their folder WorkspaceKey as id (folderWorkspaceToWorktree). + return row.id.startsWith('folder:') ? (row.id as WorkspaceKey) : worktreeWorkspaceKey(row.id) +} + +/** A row's cross-store instance identity. A folder workspace's id is minted per creation and never + * reused, so it is the folder's instance; folder rows carry no instanceId of their own. */ +export function lineageInstanceIdOfRow( + row: Pick +): string | undefined { + const scope = parseWorkspaceKey(row.id) + return scope?.type === 'folder' ? scope.folderWorkspaceId : row.instanceId || undefined +} diff --git a/src/shared/worktree/workspace-lineage-graph.ts b/src/shared/worktree/workspace-lineage-graph.ts new file mode 100644 index 00000000000..604c963eb51 --- /dev/null +++ b/src/shared/worktree/workspace-lineage-graph.ts @@ -0,0 +1,74 @@ +import { + getCyclicLineageChildKeys, + isValidResolvedWorktreeLineageEdge +} from '../resolved-worktree-lineage' +import { composeWorktreeHostIdentity, getWorktreeHostIdentity } from './host-qualified-identity' +import type { WorktreeLineage } from './lineage-types' +import { + resolveCrossStoreLineageParents, + type CrossStoreLineageView +} from './cross-store-lineage-resolution' +import type { Worktree } from './types' + +export type WorktreeWithInlineLineage = Worktree & { lineage?: WorktreeLineage | null } + +export function getProjectedWorktreeLineage( + worktree: Worktree, + lineageById: Readonly> +): WorktreeLineage | null | undefined { + if (Object.hasOwn(lineageById, worktree.id)) { + return lineageById[worktree.id] + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolved rows may carry an inline lineage the Worktree type omits; it is only read. + return (worktree as WorktreeWithInlineLineage).lineage +} + +/** Nest on the worktree’s own host; legacy rows without host IDs nest only under equally unqualified parents. */ +export function getSameStoreLineageParent( + worktree: Worktree, + lineageById: Readonly>, + rowsByHostIdentity: ReadonlyMap +): Worktree | undefined { + const projected = getProjectedWorktreeLineage(worktree, lineageById) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: resolved rows may carry an inline lineage the Worktree type omits; it is only read, never trusted over a matching projection. + const inline = (worktree as WorktreeWithInlineLineage).lineage + const lineage = projected?.worktreeInstanceId === worktree.instanceId ? projected : inline + if (!lineage) { + return undefined + } + const parent = rowsByHostIdentity.get( + composeWorktreeHostIdentity(worktree.hostId, lineage.parentWorktreeId) + ) + return parent && isValidResolvedWorktreeLineageEdge(worktree, parent, lineage) + ? parent + : undefined +} + +/** Every parent the viewer can see, by child host identity: same-store edges from saved lineage merged + * with cross-store records (a valid same-store edge wins), cycles dropped. Rows may include folder + * workspace rows; their identity is lineageInstanceIdOfRow. Pass every visible store's unfiltered rows: + * a filtered-out twin must still make a parent ambiguous. */ +export function resolveWorkspaceLineageParents( + lineageById: Readonly>, + rowsByHostIdentity: ReadonlyMap, + crossStoreLineage?: CrossStoreLineageView +): Map { + const parentByIdentity = crossStoreLineage + ? resolveCrossStoreLineageParents(rowsByHostIdentity, crossStoreLineage) + : new Map() + for (const [identity, row] of rowsByHostIdentity) { + const parent = getSameStoreLineageParent(row, lineageById, rowsByHostIdentity) + if (parent) { + parentByIdentity.set(identity, parent) + } + } + const cyclic = getCyclicLineageChildKeys( + new Map( + [...parentByIdentity].map(([identity, parent]) => [identity, getWorktreeHostIdentity(parent)]) + ) + ) + for (const identity of cyclic) { + parentByIdentity.delete(identity) + } + return parentByIdentity +}