From ac68eead64a4111a85fa45d26593718599e89152 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 13 Aug 2026 00:18:47 -0700 Subject: [PATCH] perf(renderer): keep worktree lineage map identity on no-op refresh * perf(renderer): keep worktree lineage map identity on no-op refresh A host-scoped lineage refresh always spread IPC clones into new maps, so WorktreeList Object.is subscriptions missed every time even when capture, taskId, and coordinatorHandle were unchanged. Reuse structurally equal records, return the previous maps when nothing moved, and skip set() when both maps are still current so a no-op refresh is actually a no-op. Co-authored-by: Orca * type(store): mark worktree lineage maps readonly Widen reuseEqualRecordMap and the renderer lineage store fields to Readonly> so a reused no-op refresh map cannot be mutated in place. Co-authored-by: Orca * type(store): build lineage overlays on mutable records mergeExactHostLineage was writing into AppState map types after they became Readonly. Use local mutable Records, then reuseEqualRecordMap. Co-authored-by: Orca * fix(renderer): keep direct-ssh host hydration under the max-lines cap The lineage overlay rewrite pushed the file to 305 counted lines against the 300 cap, failing oxlint. Fold the duplicated per-map filter/overlay into one scoped helper so both maps share it. Co-authored-by: Orca * perf(renderer): drop dead reuseEqualRecordMap key loop A key missing from next always shows up as either a key-count mismatch or a failed previous lookup in the first pass, so the second pass over the previous keys could never flip identical. Cover the identity contract with focused unit tests, including the same-count key swap that the removed loop appeared to guard. Co-authored-by: Orca --------- Co-authored-by: Orca --- .../hooks/direct-ssh-host-hydration.test.ts | 103 +++++++++- .../src/hooks/direct-ssh-host-hydration.ts | 58 +++--- .../slices/repo-identity-reconcile.test.ts | 53 ++++- .../store/slices/repo-identity-reconcile.ts | 25 +++ .../src/store/slices/worktree-helpers.ts | 4 +- .../src/store/slices/worktrees.test.ts | 194 ++++++++++++++++++ src/renderer/src/store/slices/worktrees.ts | 68 +++--- 7 files changed, 446 insertions(+), 59 deletions(-) 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(