diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts b/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts index 71d29e256e6..3f8a00813eb 100644 --- a/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts +++ b/src/renderer/src/hooks/direct-ssh-host-hydration.test.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { HostLineageSnapshot } from '../../../shared/host-lineage-contract' import type { HostRepoCatalogSnapshot } from '../../../shared/host-repo-catalog-contract' import type { DirectSshAuthority, SshProviderEpoch } from '../../../shared/ssh-types' -import type { Repo } from '../../../shared/types' +import type { Repo, WorktreeLineage, WorkspaceLineage } from '../../../shared/types' import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope' import type { AppState } from '../store/types' import { createDirectSshHostHydration } from './direct-ssh-host-hydration' @@ -40,6 +40,34 @@ function state(overrides: Record = {}): AppState { } as unknown as AppState } +function productionLineage(worktreeId: string, parentWorktreeId: string): WorktreeLineage { + return { + worktreeId, + worktreeInstanceId: `${worktreeId}-instance`, + parentWorktreeId, + parentWorktreeInstanceId: `${parentWorktreeId}-instance`, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'explicit' }, + taskId: 'task-1', + coordinatorHandle: 'coord-1', + createdAt: 1 + } +} + +function productionWorkspaceLineage(childId: string, parentId: string): WorkspaceLineage { + return { + childWorkspaceKey: worktreeWorkspaceKey(childId), + childInstanceId: `${childId}-instance`, + parentWorkspaceKey: worktreeWorkspaceKey(parentId), + parentInstanceId: `${parentId}-instance`, + origin: 'orchestration', + capture: { source: 'orchestration-context', confidence: 'explicit' }, + taskId: 'task-1', + coordinatorHandle: 'coord-1', + createdAt: 1 + } +} + function hostSnapshot( owner: DirectSshAuthority, repos: ReturnType[] @@ -341,6 +369,79 @@ describe('createDirectSshHostHydration', () => { }) }) + it('keeps lineage map identity for a cloned no-op host snapshot', async () => { + const owner = authority() + const hostLineage = productionLineage('a::/work', 'a::/parent') + const foreignLineage = productionLineage('b::/work', 'b::/parent') + const hostWorkspace = productionWorkspaceLineage('a::/work', 'a::/parent') + const foreignWorkspace = productionWorkspaceLineage('b::/work', 'b::/parent') + const store = createStore(() => + state({ + repos: [repo('a', 'target-a'), repo('b', 'target-b')], + worktreesByRepo: { + a: [{ id: 'a::/work', repoId: 'a', hostId: 'ssh:target-a' }], + b: [{ id: 'b::/work', repoId: 'b', hostId: 'ssh:target-b' }] + }, + worktreeLineageById: { + 'a::/work': hostLineage, + 'b::/work': foreignLineage + }, + workspaceLineageByChildKey: { + [worktreeWorkspaceKey('a::/work')]: hostWorkspace, + [worktreeWorkspaceKey('b::/work')]: foreignWorkspace + } + }) + ) + const snapshot: HostLineageSnapshot = { + authoritative: true, + authority: { + kind: 'direct-ssh', + executionHostId: 'ssh:target-a', + ...owner + }, + worktreeLineageById: { + 'a::/work': hostLineage + }, + workspaceLineageByChildKey: { + [worktreeWorkspaceKey('a::/work')]: hostWorkspace + } + } + let publications = 0 + store.subscribe(() => { + publications += 1 + }) + const hydration = createDirectSshHostHydration({ + store, + listRepos: vi.fn(), + listLineage: vi.fn(async () => structuredClone(snapshot)), + isCurrentAuthority: () => true + }) + const beforeLineage = store.getState().worktreeLineageById + const beforeWorkspace = store.getState().workspaceLineageByChildKey + + await expect( + hydration.readHostScopedLineage({ + ...owner, + catalogRevision: 0, + repoRefs: [{ repoId: 'a', executionHostId: 'ssh:target-a' }], + authorityRequirement: 'required', + reason: 'reconnect' + }) + ).resolves.toBe('complete') + + expect(store.getState().worktreeLineageById).toBe(beforeLineage) + expect(store.getState().workspaceLineageByChildKey).toBe(beforeWorkspace) + expect(store.getState().worktreeLineageById['a::/work']).toBe(hostLineage) + expect(store.getState().worktreeLineageById['b::/work']).toBe(foreignLineage) + expect(store.getState().workspaceLineageByChildKey[worktreeWorkspaceKey('a::/work')]).toBe( + hostWorkspace + ) + expect(store.getState().workspaceLineageByChildKey[worktreeWorkspaceKey('b::/work')]).toBe( + foreignWorkspace + ) + expect(publications).toBe(0) + }) + it('rejects lineage captured before a newer same-authority catalog revision', async () => { const owner = authority() const store = createStore(() => diff --git a/src/renderer/src/hooks/direct-ssh-host-hydration.ts b/src/renderer/src/hooks/direct-ssh-host-hydration.ts index d3a5a2bc366..069b3b3d323 100644 --- a/src/renderer/src/hooks/direct-ssh-host-hydration.ts +++ b/src/renderer/src/hooks/direct-ssh-host-hydration.ts @@ -5,6 +5,7 @@ import type { HostRepoCatalogSnapshot } from '../../../shared/host-repo-catalog- import type { DirectSshAuthority } from '../../../shared/ssh-types' import { isWorkspaceKey } from '../../../shared/workspace-scope' import type { AppState } from '../store/types' +import { reuseEqualRecordMap } from '../store/slices/repo-identity-reconcile' import type { DirectSshLineageOutcome, DirectSshPreparationInput, @@ -71,6 +72,20 @@ function mergeExactHostCatalog(state: AppState, snapshot: HostRepoCatalogSnapsho } } +// Why: the host owns every in-scope key, so drop the stale in-scope rows, overlay the snapshot's, +// and leave out-of-scope hosts alone. Reusing equal rows keeps a cloned no-op read from republishing. +function overlayHostScopedLineage( + previous: Readonly>, + incoming: Readonly>, + isHostScoped: (key: string) => boolean +): Readonly> { + const next: Record = Object.fromEntries([ + ...Object.entries(previous).filter(([key]) => !isHostScoped(key)), + ...Object.entries(incoming).filter(([key]) => isHostScoped(key)) + ]) + return reuseEqualRecordMap(previous, next) +} + function mergeExactHostLineage( state: AppState, snapshot: Extract, @@ -78,38 +93,23 @@ function mergeExactHostLineage( catalogRevision: number ): AppState { const scope = directSshHostHydrationScope(state, authority, catalogRevision) - const worktreeLineageById = Object.fromEntries( - Object.entries(state.worktreeLineageById).filter( - ([worktreeId]) => !scope.gitWorktreeIds.has(worktreeId) - ) + const worktreeLineageById = overlayHostScopedLineage( + state.worktreeLineageById, + snapshot.worktreeLineageById, + (worktreeId) => scope.gitWorktreeIds.has(worktreeId) ) - const workspaceLineageByChildKey = Object.fromEntries( - Object.entries(state.workspaceLineageByChildKey).filter( - ([childKey]) => !isWorkspaceKey(childKey) || !scope.lineageWorkspaceKeys.has(childKey) - ) + const workspaceLineageByChildKey = overlayHostScopedLineage( + state.workspaceLineageByChildKey, + snapshot.workspaceLineageByChildKey, + (childKey) => isWorkspaceKey(childKey) && scope.lineageWorkspaceKeys.has(childKey) ) - const incomingWorktreeLineage = Object.fromEntries( - Object.entries(snapshot.worktreeLineageById).filter(([worktreeId]) => - scope.gitWorktreeIds.has(worktreeId) - ) - ) - const incomingWorkspaceLineage = Object.fromEntries( - Object.entries(snapshot.workspaceLineageByChildKey).filter( - ([workspaceKey]) => - isWorkspaceKey(workspaceKey) && scope.lineageWorkspaceKeys.has(workspaceKey) - ) - ) - return { - ...state, - worktreeLineageById: { - ...worktreeLineageById, - ...incomingWorktreeLineage - }, - workspaceLineageByChildKey: { - ...workspaceLineageByChildKey, - ...incomingWorkspaceLineage - } + if ( + worktreeLineageById === state.worktreeLineageById && + workspaceLineageByChildKey === state.workspaceLineageByChildKey + ) { + return state } + return { ...state, worktreeLineageById, workspaceLineageByChildKey } } export function createDirectSshHostHydration( diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.test.ts b/src/renderer/src/store/slices/repo-identity-reconcile.test.ts index dda221e18b0..ecc1fa7d59e 100644 --- a/src/renderer/src/store/slices/repo-identity-reconcile.test.ts +++ b/src/renderer/src/store/slices/repo-identity-reconcile.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import fs from 'node:fs' import type { Repo } from '../../../../shared/types' -import { reconcileFetchedRepos } from './repo-identity-reconcile' +import { reconcileFetchedRepos, reuseEqualRecordMap } from './repo-identity-reconcile' function makeRepo(id: string, overrides: Partial = {}): Repo { return { id, path: `/${id}`, displayName: id, badgeColor: '#000', addedAt: 1, ...overrides } @@ -123,3 +123,54 @@ describe('reconcileFetchedRepos', () => { expect(source).not.toMatch(/previous\.find|next\.find|findIndex/) }) }) + +describe('reuseEqualRecordMap', () => { + const row = (value: string): { value: string; nested: { tags: string[] } } => ({ + value, + nested: { tags: [value, 'shared'] } + }) + + it('returns the previous map when every key holds an equal value', () => { + const previous = { a: row('a'), b: row('b') } + expect(reuseEqualRecordMap(previous, { a: row('a'), b: row('b') })).toBe(previous) + }) + + it('reuses the previous entry for structurally equal nested values', () => { + const previous = { a: row('a'), b: row('b') } + const result = reuseEqualRecordMap(previous, { a: row('a'), b: row('changed') }) + expect(result).not.toBe(previous) + expect(result.a).toBe(previous.a) + expect(result.b).toEqual(row('changed')) + }) + + it('returns a new map when a value changed', () => { + const previous = { a: row('a') } + const result = reuseEqualRecordMap(previous, { a: row('changed') }) + expect(result).not.toBe(previous) + expect(result.a).toEqual(row('changed')) + }) + + it('returns a new map when a key is added', () => { + const previous = { a: row('a') } + const result = reuseEqualRecordMap(previous, { a: row('a'), b: row('b') }) + expect(result).not.toBe(previous) + expect(result.a).toBe(previous.a) + expect(Object.keys(result)).toEqual(['a', 'b']) + }) + + it('returns a new map when a key is removed', () => { + const previous = { a: row('a'), b: row('b') } + const result = reuseEqualRecordMap(previous, { a: row('a') }) + expect(result).not.toBe(previous) + expect(Object.keys(result)).toEqual(['a']) + }) + + it('returns a new map when a key is swapped for a same-count replacement', () => { + // Why: equal key counts alone must not read as unchanged — the replacement key misses the + // previous lookup, which is why no second pass over the previous keys is needed. + const previous = { a: row('a'), b: row('b') } + const result = reuseEqualRecordMap(previous, { a: row('a'), c: row('b') }) + expect(result).not.toBe(previous) + expect(Object.keys(result)).toEqual(['a', 'c']) + }) +}) diff --git a/src/renderer/src/store/slices/repo-identity-reconcile.ts b/src/renderer/src/store/slices/repo-identity-reconcile.ts index f61107c2673..db59e619d97 100644 --- a/src/renderer/src/store/slices/repo-identity-reconcile.ts +++ b/src/renderer/src/store/slices/repo-identity-reconcile.ts @@ -79,3 +79,28 @@ export function reconcileFetchedRepos( ): readonly Repo[] { return reconcileCatalogRows(previous, next, getRepoHostIdentity) } + +/** + * Reuses equal record values from `previous` — and the whole map when nothing + * changed — so a cloned no-op refresh leaves Object.is subscribers untouched. + */ +export function reuseEqualRecordMap( + previous: Readonly>, + next: Readonly> +): Readonly> { + const nextKeys = Object.keys(next) + // Why: a matching key count plus every `next` key resolving to an equal `previous` entry below + // means the key sets match, so a removed key always lands as either a count or a lookup miss. + let identical = nextKeys.length === Object.keys(previous).length + const reconciled: Record = {} + for (const key of nextKeys) { + const existing = Object.hasOwn(previous, key) ? previous[key] : undefined + if (existing !== undefined && areValuesEqual(existing, next[key])) { + reconciled[key] = existing + continue + } + identical = false + reconciled[key] = next[key] + } + return identical ? previous : reconciled +} diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 9675be9a693..c2980357ee8 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -89,8 +89,8 @@ export type ActiveWorktreeStateTransition = (state: AppState) => { export type WorktreeSlice = { worktreesByRepo: Record detectedWorktreesByRepo: Record - worktreeLineageById: Record - workspaceLineageByChildKey: Record + worktreeLineageById: Readonly> + workspaceLineageByChildKey: Readonly> activeWorktreeId: string | null activeWorkspaceKey: WorkspaceKey | null activeWorkspaceExecutionHostId: ExecutionHostId | null diff --git a/src/renderer/src/store/slices/worktrees.test.ts b/src/renderer/src/store/slices/worktrees.test.ts index 5ebe9a06116..6077204ba51 100644 --- a/src/renderer/src/store/slices/worktrees.test.ts +++ b/src/renderer/src/store/slices/worktrees.test.ts @@ -3187,6 +3187,71 @@ describe('fetchWorktrees', () => { expect(store.getState().sortEpoch).toBe(7) }) + it('keeps remote lineage map identity when a cloned payload is unchanged', async () => { + const store = createTestStore() + const worktree = makeWorktree({ + id: 'repo1::/remote/wt1', + repoId: 'repo1', + path: '/remote/wt1', + branch: 'refs/heads/remote', + hostId: 'runtime:env-1' + }) + const lineage = makeLineage({ + worktreeId: worktree.id, + parentWorktreeId: 'repo1::/remote/parent', + capture: { source: 'orchestration-context', confidence: 'explicit' }, + taskId: 'task-42', + coordinatorHandle: 'coord-1' + }) + const workspaceLineage = makeWorkspaceLineage({ + childWorkspaceKey: worktreeWorkspaceKey(worktree.id), + parentWorkspaceKey: worktreeWorkspaceKey(lineage.parentWorktreeId), + childInstanceId: lineage.worktreeInstanceId, + parentInstanceId: lineage.parentWorktreeInstanceId, + origin: lineage.origin, + capture: lineage.capture, + taskId: lineage.taskId, + coordinatorHandle: lineage.coordinatorHandle, + createdAt: lineage.createdAt + }) + const detected = makeDetectedResult('repo1', [worktree]) + store.setState({ + settings: { activeRuntimeEnvironmentId: 'env-1' } as never, + worktreesByRepo: { repo1: [worktree] }, + detectedWorktreesByRepo: { repo1: detected }, + worktreeLineageById: { [lineage.worktreeId]: lineage }, + workspaceLineageByChildKey: { + [workspaceLineage.childWorkspaceKey]: workspaceLineage + }, + sortEpoch: 7 + } as Partial) + runtimeEnvironmentCall.mockImplementation(({ method }: RuntimeEnvironmentCallRequest) => { + const result = + method === 'worktree.lineageList' + ? structuredClone({ + lineage: { [lineage.worktreeId]: lineage }, + workspaceLineage: { [workspaceLineage.childWorkspaceKey]: workspaceLineage } + }) + : structuredClone(detected) + return Promise.resolve({ + id: 'rpc-1', + ok: true, + result, + _meta: { runtimeId: 'runtime-remote' } + }) + }) + const before = store.getState() + + await store.getState().fetchWorktrees('repo1') + + expect(store.getState().worktreeLineageById).toBe(before.worktreeLineageById) + expect(store.getState().workspaceLineageByChildKey).toBe(before.workspaceLineageByChildKey) + expect(store.getState().worktreeLineageById[lineage.worktreeId]).toBe(lineage) + expect(store.getState().workspaceLineageByChildKey[workspaceLineage.childWorkspaceKey]).toBe( + workspaceLineage + ) + }) + it('defers remote lineage when a caller owns the final host refresh', async () => { const store = createTestStore() const worktree = makeWorktree({ @@ -3319,6 +3384,135 @@ describe('worktree lineage state', () => { }) }) + it('keeps lineage map and entry identity across a cloned no-op refresh', async () => { + const lineage = makeLineage({ + capture: { source: 'orchestration-context', confidence: 'explicit' }, + taskId: 'task-42', + coordinatorHandle: 'coord-1' + }) + const workspaceLineage = makeWorkspaceLineage({ + childWorkspaceKey: worktreeWorkspaceKey(lineage.worktreeId), + parentWorkspaceKey: worktreeWorkspaceKey(lineage.parentWorktreeId), + childInstanceId: lineage.worktreeInstanceId, + parentInstanceId: lineage.parentWorktreeInstanceId, + origin: lineage.origin, + capture: lineage.capture, + taskId: lineage.taskId, + coordinatorHandle: lineage.coordinatorHandle, + createdAt: lineage.createdAt + }) + const store = createLocalLineageTestStore(lineage) + const payload = { + lineage: { [lineage.worktreeId]: lineage }, + workspaceLineage: { [workspaceLineage.childWorkspaceKey]: workspaceLineage } + } + mockApi.worktrees.listLineage.mockImplementation(async () => structuredClone(payload)) + + await store.getState().fetchWorktreeLineage() + const afterFirst = store.getState() + const lineageById = afterFirst.worktreeLineageById + const workspaceByKey = afterFirst.workspaceLineageByChildKey + const lineageEntry = lineageById[lineage.worktreeId] + const workspaceEntry = workspaceByKey[workspaceLineage.childWorkspaceKey] + expect(lineageEntry).toEqual(lineage) + expect(lineageEntry).not.toBe(lineage) + const subscriber = vi.fn() + const unsubscribe = store.subscribe(subscriber) + + await store.getState().fetchWorktreeLineage() + unsubscribe() + + expect(store.getState().worktreeLineageById).toBe(lineageById) + expect(store.getState().workspaceLineageByChildKey).toBe(workspaceByKey) + expect(store.getState().worktreeLineageById[lineage.worktreeId]).toBe(lineageEntry) + expect(store.getState().workspaceLineageByChildKey[workspaceLineage.childWorkspaceKey]).toBe( + workspaceEntry + ) + expect(subscriber).not.toHaveBeenCalled() + expect(mockApi.worktrees.listLineage).toHaveBeenCalledTimes(2) + }) + + it('preserves other-host lineage row identity across a cloned same-host refresh', async () => { + const store = createTestStore() + const localWorktree = makeWorktree({ + id: 'repo1::/local/child', + repoId: 'repo1', + hostId: LOCAL_EXECUTION_HOST_ID + }) + const sshWorktree = makeWorktree({ + id: 'repo2::/ssh/child', + repoId: 'repo2', + hostId: 'ssh:ssh-1' + }) + const localLineage = makeLineage({ + worktreeId: localWorktree.id, + parentWorktreeId: 'repo1::/local/parent', + capture: { source: 'orchestration-context', confidence: 'explicit' }, + taskId: 'task-local', + coordinatorHandle: 'coord-local' + }) + const sshLineage = makeLineage({ + worktreeId: sshWorktree.id, + parentWorktreeId: 'repo2::/ssh/parent', + capture: { source: 'cwd-context', confidence: 'inferred' }, + taskId: 'task-ssh', + coordinatorHandle: 'coord-ssh' + }) + const localWorkspace = makeWorkspaceLineage({ + childWorkspaceKey: worktreeWorkspaceKey(localWorktree.id), + parentWorkspaceKey: worktreeWorkspaceKey(localLineage.parentWorktreeId), + childInstanceId: localLineage.worktreeInstanceId, + parentInstanceId: localLineage.parentWorktreeInstanceId, + origin: localLineage.origin, + capture: localLineage.capture, + taskId: localLineage.taskId, + coordinatorHandle: localLineage.coordinatorHandle + }) + const sshWorkspace = makeWorkspaceLineage({ + childWorkspaceKey: worktreeWorkspaceKey(sshWorktree.id), + parentWorkspaceKey: worktreeWorkspaceKey(sshLineage.parentWorktreeId), + childInstanceId: sshLineage.worktreeInstanceId, + parentInstanceId: sshLineage.parentWorktreeInstanceId, + origin: sshLineage.origin, + capture: sshLineage.capture, + taskId: sshLineage.taskId, + coordinatorHandle: sshLineage.coordinatorHandle + }) + store.setState({ + worktreesByRepo: { + repo1: [localWorktree], + repo2: [sshWorktree] + }, + worktreeLineageById: { + [localWorktree.id]: localLineage, + [sshWorktree.id]: sshLineage + }, + workspaceLineageByChildKey: { + [localWorkspace.childWorkspaceKey]: localWorkspace, + [sshWorkspace.childWorkspaceKey]: sshWorkspace + } + } as Partial) + const payload = { + lineage: { [localWorktree.id]: localLineage }, + workspaceLineage: { [localWorkspace.childWorkspaceKey]: localWorkspace } + } + mockApi.worktrees.listLineage.mockImplementation(async () => structuredClone(payload)) + const before = store.getState() + + await store.getState().fetchWorktreeLineage() + + expect(store.getState().worktreeLineageById).toBe(before.worktreeLineageById) + expect(store.getState().workspaceLineageByChildKey).toBe(before.workspaceLineageByChildKey) + expect(store.getState().worktreeLineageById[localWorktree.id]).toBe(localLineage) + expect(store.getState().worktreeLineageById[sshWorktree.id]).toBe(sshLineage) + expect(store.getState().workspaceLineageByChildKey[localWorkspace.childWorkspaceKey]).toBe( + localWorkspace + ) + expect(store.getState().workspaceLineageByChildKey[sshWorkspace.childWorkspaceKey]).toBe( + sshWorkspace + ) + }) + it('clears workspace lineage on successful old-shape lineage refresh', async () => { const store = createTestStore() const lineage = makeLineage() diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 9c1fc6691ec..00e421cb7df 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -133,6 +133,7 @@ import type { import type { DirectSshAuthority } from '../../../../shared/ssh-types' import { findIndexedWorktreeOwnerForHost } from '@/lib/worktree-runtime-owner-index' import { catalogRowsEqual, reuseEqualCatalogRows } from './worktree-catalog-reconciliation' +import { reuseEqualRecordMap } from './repo-identity-reconcile' export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers' // Why: old runtime servers only have `worktree.list`; preserve the large-list UI hydration parity used before `worktree.detectedList` existed. @@ -1390,8 +1391,8 @@ async function listWorktreeLineageForRuntime( settings: AppState['settings'], options: BackgroundRuntimeRefreshOptions = {} ): Promise<{ - worktreeLineageById: Record - workspaceLineageByChildKey: Record + worktreeLineageById: Readonly> + workspaceLineageByChildKey: Readonly> }> { const target = getActiveRuntimeTarget(settings) type LineageListResponse = { @@ -1570,21 +1571,38 @@ function applyWorktreeLineageUpdate( }) } +function applyHostLineageRefresh( + set: Parameters>[0], + hostId: ExecutionHostId, + lineage: { + worktreeLineageById: Readonly> + workspaceLineageByChildKey: Readonly> + } +): void { + set((s) => { + const worktreeLineageById = mergeLineageForHost(s, hostId, lineage.worktreeLineageById) + const workspaceLineageByChildKey = mergeWorkspaceLineageForHost( + s, + hostId, + lineage.workspaceLineageByChildKey + ) + if ( + worktreeLineageById === s.worktreeLineageById && + workspaceLineageByChildKey === s.workspaceLineageByChildKey + ) { + return s + } + return { worktreeLineageById, workspaceLineageByChildKey } + }) +} + async function refreshWorktreeLineageForSettings( settings: AppState['settings'], set: Parameters>[0], options: BackgroundRuntimeRefreshOptions = {} ): Promise { const lineage = await listWorktreeLineageForRuntime(settings, options) - const hostId = getSettingsFocusedExecutionHostId(settings) - set((s) => ({ - worktreeLineageById: mergeLineageForHost(s, hostId, lineage.worktreeLineageById), - workspaceLineageByChildKey: mergeWorkspaceLineageForHost( - s, - hostId, - lineage.workspaceLineageByChildKey - ) - })) + applyHostLineageRefresh(set, getSettingsFocusedExecutionHostId(settings), lineage) } async function refreshRemoteWorktreeLineageBestEffort( @@ -1598,15 +1616,7 @@ async function refreshRemoteWorktreeLineageBestEffort( const lineage = await listWorktreeLineageForRuntime(settings, { reuseRecentCompatibilityFailure: true }) - const hostId = getSettingsFocusedExecutionHostId(settings) - set((s) => ({ - worktreeLineageById: mergeLineageForHost(s, hostId, lineage.worktreeLineageById), - workspaceLineageByChildKey: mergeWorkspaceLineageForHost( - s, - hostId, - lineage.workspaceLineageByChildKey - ) - })) + applyHostLineageRefresh(set, getSettingsFocusedExecutionHostId(settings), lineage) } catch (err) { // Why: lineage is supplemental, so a remote timeout here must not discard a successful worktree refresh. console.error('Failed to fetch worktree lineage:', err) @@ -1638,15 +1648,18 @@ function mergeLineageForHost( 'repos' | 'settings' | 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'worktreeLineageById' >, hostId: ExecutionHostId, - lineage: Record -): Record { + lineage: Readonly> +): Readonly> { const next: Record = {} for (const [worktreeId, existing] of Object.entries(state.worktreeLineageById)) { if (getWorktreeHostId(state, worktreeId) !== hostId) { next[worktreeId] = existing } } - return { ...next, ...lineage } + for (const [worktreeId, incoming] of Object.entries(lineage)) { + next[worktreeId] = incoming + } + return reuseEqualRecordMap(state.worktreeLineageById, next) } function mergeWorkspaceLineageForHost( @@ -1659,8 +1672,8 @@ function mergeWorkspaceLineageForHost( | 'workspaceLineageByChildKey' >, hostId: ExecutionHostId, - lineage: Record -): Record { + lineage: Readonly> +): Readonly> { const next: Record = {} for (const [childKey, existing] of Object.entries(state.workspaceLineageByChildKey)) { const childScope = parseWorkspaceKey(existing.childWorkspaceKey) @@ -1671,7 +1684,10 @@ function mergeWorkspaceLineageForHost( next[childKey] = existing } } - return { ...next, ...lineage } + for (const [childKey, incoming] of Object.entries(lineage)) { + next[childKey] = incoming + } + return reuseEqualRecordMap(state.workspaceLineageByChildKey, next) } async function persistWorktreeMeta(