From 9510726341de09586e4fb4323eefb1001ffe8d2b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:15:30 -0700 Subject: [PATCH] Stop host status changes from rebuilding the sidebar workspace list (#34500) * Stop unrelated host status publications rebuilding sidebar workspace state * Follow sidebar selector lint conventions * Scope sidebar status reads to mounted consumers * Cover each unrelated sidebar status publication * Fix sidebar isolation lint and formatting * Type the composed sidebar header fixture without assertions * Remove unused React imports from sidebar contract tests * Reuse folder record selector and prune stale focus allowance --- config/focus-setting-read-baseline.txt | 1 - .../sidebar/sidebar-host-options.ts | 29 +- .../worktree-list-groups-test-fixtures.ts | 38 + .../listing/host-status-isolation.test.tsx | 690 ++++++++++++++++++ .../listing/host-status-test-fixtures.ts | 194 +++++ .../listing/sidebar-list-model.ts | 28 + .../listing/use-folder-path-statuses.ts | 99 ++- .../worktree-list/listing/use-section-rows.ts | 129 ++-- .../listing/use-visible-worktrees.ts | 37 +- .../worktree-list/rows/SectionHeader.tsx | 12 +- .../rows/project-header-host-context.test.ts | 34 +- .../rows/repo-header-project-actions.tsx | 7 +- .../rows/repo-header-ssh-isolation.test.tsx | 283 +++++++ .../viewport/VirtualizedWorktreeViewport.tsx | 40 +- .../header-folder-composition.test.tsx | 347 +++++++++ .../use-project-header-host-contexts.ts | 48 ++ .../viewport/virtual-row-context.ts | 3 - .../src/hooks/use-repo-ssh-gate-resolver.ts | 21 - src/renderer/src/hooks/use-repo-ssh-gate.ts | 56 ++ ...t-session-host-capability-scaling.test.tsx | 96 +-- .../folder-path-status-ownership.test.ts | 150 +++- .../folder-workspaces/folder-path-status.ts | 31 +- 22 files changed, 2118 insertions(+), 255 deletions(-) create mode 100644 src/renderer/src/components/sidebar/worktree-list/listing/host-status-isolation.test.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/listing/host-status-test-fixtures.ts create mode 100644 src/renderer/src/components/sidebar/worktree-list/rows/repo-header-ssh-isolation.test.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/viewport/header-folder-composition.test.tsx create mode 100644 src/renderer/src/components/sidebar/worktree-list/viewport/use-project-header-host-contexts.ts delete mode 100644 src/renderer/src/hooks/use-repo-ssh-gate-resolver.ts create mode 100644 src/renderer/src/hooks/use-repo-ssh-gate.ts diff --git a/config/focus-setting-read-baseline.txt b/config/focus-setting-read-baseline.txt index 03a3734884d..5949a9a02fd 100644 --- a/config/focus-setting-read-baseline.txt +++ b/config/focus-setting-read-baseline.txt @@ -138,7 +138,6 @@ 1 src/renderer/src/components/sidebar/worktree-filter-visibility.ts 1 src/renderer/src/components/sidebar/worktree-list/listing/use-filters.ts 1 src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts -1 src/renderer/src/components/sidebar/worktree-list/listing/use-window-list-model.ts 1 src/renderer/src/components/sidebar/worktree-list/rows/ProjectGroupDialogs.tsx 1 src/renderer/src/components/sidebar/worktree-visibility-host-target.ts 1 src/renderer/src/components/sidebar/WorktreeList.tsx diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.ts b/src/renderer/src/components/sidebar/sidebar-host-options.ts index 8c6f4b022ec..a91bc206bc6 100644 --- a/src/renderer/src/components/sidebar/sidebar-host-options.ts +++ b/src/renderer/src/components/sidebar/sidebar-host-options.ts @@ -1,3 +1,4 @@ +import { shallow } from 'zustand/shallow' import type { GlobalSettings } from '../../../../shared/global-settings-types' import type { Repo } from '../../../../shared/repo-types' import type { WorkspaceHostScope } from '../../../../shared/ui-chrome-types' @@ -9,7 +10,8 @@ import { } from '../../../../shared/execution-host' import { buildExecutionHostRegistry, - type ExecutionHostHealth + type ExecutionHostHealth, + type ExecutionHostRegistryEntry } from '../../../../shared/execution-host-registry' import { getExecutionHostHealthLabel } from '../../../../shared/execution-host-health' import { pickerExecutionHosts } from '../../../../shared/managed-orcad-execution-host' @@ -214,3 +216,28 @@ export function getSidebarHostHealthLabel( ): string { return health === 'mixed' ? 'Mixed' : getExecutionHostHealthLabel(health, blockedReason) } + +// Diagnostics do not change host choices; retain every other exposed registry field. +export function sidebarHostSemantics(host: SidebarHostOption & ExecutionHostRegistryEntry) { + const { remoteControlState, ...option } = host + return { + ...option, + remoteControlState: remoteControlState && { + state: remoteControlState.state, + lastError: remoteControlState.lastError, + lastClose: remoteControlState.lastClose, + lastCloseCause: remoteControlState.lastCloseCause + } + } +} + +export function createSidebarHostLabelsSelector() { + let result = new Map() + return (hosts: readonly SidebarHostOption[]): ReadonlyMap => { + const next = new Map(hosts.map((host) => [host.id, host.label])) + if (!shallow(result, next)) { + result = next + } + return result + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list-groups-test-fixtures.ts b/src/renderer/src/components/sidebar/worktree-list-groups-test-fixtures.ts index 6e4d9bc8ba7..5f63554984c 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups-test-fixtures.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups-test-fixtures.ts @@ -1,3 +1,5 @@ +import type { GroupHeaderRow } from '../../../../shared/sidebar-list/row-types' +import type { SidebarHostOption } from './sidebar-host-options' import { getExecutionHostLabel } from '../../../../shared/execution-host' import type { Project, ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' @@ -100,3 +102,39 @@ export function makeDetectedWorktree(overrides: Partial = {}): ...overrides } } + +export function collapsedSameNameProjectHeaders() { + const local = { ...repo, displayName: 'app' } + const paired: Repo = { ...local, id: 'home-app', executionHostId: 'runtime:home' } + const hosts: SidebarHostOption[] = [ + { + id: 'local', + label: 'Local Mac', + detail: 'This computer', + kind: 'local', + health: 'local', + presence: 'local' + }, + { + id: 'runtime:home', + label: 'Home', + detail: 'home.example', + kind: 'runtime', + health: 'disconnected', + presence: 'configured' + } + ] + function header(value: Repo): GroupHeaderRow { + return { + type: 'header', + key: `repo:${value.id}`, + label: value.displayName, + count: 1, + icon: { kind: 'project' }, + repo: value + } + } + const repoMap = new Map([local, paired].map((value) => [value.id, value])) + const rows = [header(local), header(paired)] + return { local, paired, hosts, header, repoMap, rows } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/host-status-isolation.test.tsx b/src/renderer/src/components/sidebar/worktree-list/listing/host-status-isolation.test.tsx new file mode 100644 index 00000000000..08ee1383ad8 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/listing/host-status-isolation.test.tsx @@ -0,0 +1,690 @@ +// @vitest-environment happy-dom +import { act, cleanup, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { + environment, + status, + publish, + ssh, + repo, + worktree, + visibilityArgs, + sectionArgs, + sidebarCorpus, + countArrayEntries, + countMapEntries +} from './host-status-test-fixtures' +import { useProjectHeaderHostContexts } from '../viewport/use-project-header-host-contexts' +import { useVisibleSidebarWorktrees } from './use-visible-worktrees' +import { useSidebarSectionRows } from './use-section-rows' +import { useFolderWorkspacePathStatusRows } from './use-folder-path-statuses' +import { createProjectGroup } from '../../../../../../shared/project-groups' +import type * as FolderStatus from '@/store/folder-workspaces/folder-path-status' +import type * as RuntimeRpcClient from '@/runtime/runtime-rpc-client' +import { useSidebarHostVisibleScope } from './use-host-visible-scope' +import { makeFolderWorkspace } from '../../../worktree-jump-palette-test-fixtures' +import type { ProjectGroup } from '../../../../../../shared/project-group-types' +import type * as SshGate from '@/lib/repo-ssh-connection' +import type * as DeviceVisibility from '../../../../../../shared/workspace-creator-visibility' +import type * as Registry from '../../../../../../shared/execution-host-registry' +import type * as RowPlan from '../../../../../../shared/sidebar-list/sidebar-row-plan' +import type * as Visibility from '../../visible-worktrees' +import type * as HeaderContext from '../rows/project-header-host-context' +import { collapsedSameNameProjectHeaders } from '../../worktree-list-groups-test-fixtures' + +const runtimeRpc = vi.hoisted(() => ({ callRuntimeRpc: vi.fn() })) +vi.mock('@/runtime/runtime-rpc-client', async (original) => ({ + ...(await original()), + callRuntimeRpc: runtimeRpc.callRuntimeRpc +})) +const counts = vi.hoisted(() => ({ + devices: 0, + deviceEntries: 0, + registry: 0, + registryEntries: 0, + plan: 0, + visibility: 0, + gates: 0, + contexts: 0, + dependencies: 0, + dependencyEntries: 0, + snapshots: 0, + statusReads: 0, + contextEntries: 0 +})) +vi.mock('@/store/folder-workspaces/folder-path-status', async (original) => { + const actual = await original() + return { + ...actual, + getFolderWorkspaceStatusRequestDependencies: ( + ...args: Parameters + ) => { + counts.dependencies++ + return actual.getFolderWorkspaceStatusRequestDependencies( + { + ...args[0], + repos: countArrayEntries(args[0].repos, () => counts.dependencyEntries++), + projectGroups: countArrayEntries(args[0].projectGroups, () => counts.dependencyEntries++), + folderWorkspaces: countArrayEntries( + args[0].folderWorkspaces, + () => counts.dependencyEntries++ + ) + }, + args[1], + args[2] + ) + }, + getFolderWorkspaceStatusRequestSnapshot: ( + ...args: Parameters + ) => { + counts.snapshots++ + return actual.getFolderWorkspaceStatusRequestSnapshot(...args) + } + } +}) +vi.mock('@/lib/repo-ssh-connection', async (original) => { + const actual = await original() + return { + ...actual, + selectRepoSshGateInput: (...args: Parameters) => { + counts.gates++ + return actual.selectRepoSshGateInput(...args) + } + } +}) +vi.mock('../../../../../../shared/workspace-creator-visibility', async (original) => { + const actual = await original() + return { + ...actual, + getPairedDeviceIdsByEnvironment: ( + ...args: Parameters + ) => { + counts.devices++ + return actual.getPairedDeviceIdsByEnvironment( + countArrayEntries(args[0], () => counts.deviceEntries++), + args[1] + ) + } + } +}) +vi.mock('../../../../../../shared/execution-host-registry', async (original) => { + const actual = await original() + return { + ...actual, + buildExecutionHostRegistry: (...args: Parameters) => { + counts.registry++ + const input = args[0] + return actual.buildExecutionHostRegistry({ + ...input, + repos: countArrayEntries(input.repos, () => counts.registryEntries++), + runtimeEnvironments: + input.runtimeEnvironments && + countArrayEntries(input.runtimeEnvironments, () => counts.registryEntries++), + runtimeStatusByEnvironmentId: + input.runtimeStatusByEnvironmentId && + countMapEntries(input.runtimeStatusByEnvironmentId, () => counts.registryEntries++) + }) + } + } +}) +vi.mock('../../../../../../shared/sidebar-list/sidebar-row-plan', async (original) => { + const actual = await original() + return { + ...actual, + planSidebarRows: (...args: Parameters) => { + counts.plan++ + return actual.planSidebarRows(...args) + } + } +}) +vi.mock('../../visible-worktrees', async (original) => { + const actual = await original() + return { + ...actual, + computeVisibleWorktrees: (...args: Parameters) => { + counts.visibility++ + return actual.computeVisibleWorktrees(...args) + } + } +}) + +vi.mock('../rows/project-header-host-context', async (original) => { + const actual = await original() + return { + ...actual, + getProjectHeaderHostContexts: ( + args: Parameters[0] + ) => { + counts.contexts++ + return actual.getProjectHeaderHostContexts({ + ...args, + rows: countArrayEntries(args.rows, () => counts.contextEntries++), + hosts: countArrayEntries(args.hosts, () => counts.contextEntries++), + repoMap: countMapEntries(args.repoMap, () => counts.contextEntries++), + runtimeEnvironments: + args.runtimeEnvironments && + countArrayEntries(args.runtimeEnvironments, () => counts.contextEntries++) + }) + } + } +}) + +const initial = useAppStore.getInitialState() +beforeEach(() => { + useAppStore.setState(initial, true) + useAppStore.setState({ + runtimeEnvironments: [environment('A', 'mine'), environment('B')], + worktreesByRepo: { [repo.id]: [worktree] } + }) + publish('A', 1, { status: { ...status('A'), pairedDeviceId: 'mine' } }) + publish('B', 1) + useAppStore.getState().setEnvironmentSshTargetsMetadata('A', []) + useAppStore.getState().setEnvironmentSshConnectionState('A', 'target', ssh('connected')) + Object.assign(counts, { + devices: 0, + deviceEntries: 0, + registry: 0, + registryEntries: 0, + plan: 0, + visibility: 0, + gates: 0, + contexts: 0, + dependencies: 0, + dependencyEntries: 0, + snapshots: 0, + statusReads: 0, + contextEntries: 0 + }) +}) +afterEach(() => { + cleanup() + useAppStore.setState(initial, true) +}) + +it('does zero device scans while off and preserves equal identities while on, including catalog fallback', () => { + let renders = 0 + const folders = [ + makeFolderWorkspace({ + executionHostId: 'runtime:A', + creatorProvenance: { kind: 'paired-device', deviceId: 'mine' } + }) + ] + const scope = { + defaultHostId: 'local' as const, + repos: [repo], + projectGroups: [], + folderWorkspaces: folders + } + const { result, rerender } = renderHook( + (args) => { + renders++ + const visible = useVisibleSidebarWorktrees(args) + const hostScope = useSidebarHostVisibleScope({ + ...scope, + filterState: args.filterState, + pairedDeviceIdsByEnvironment: visible.pairedDeviceIdsByEnvironment + }) + return { ...visible, folders: hostScope.visibleFolderWorkspacesForRows } + }, + { initialProps: visibilityArgs } + ) + const first = result.current + const before = renders + for (let flip = 0; flip < 10; flip++) { + act(() => publish('B', flip + 2, { status: flip % 2 ? null : status('B') })) + } + expect(renders).toBe(before) + expect(result.current).toBe(first) + expect(counts.devices).toBe(0) + expect(counts.deviceEntries).toBe(0) + rerender({ + ...visibilityArgs, + filterState: { ...visibilityArgs.filterState, hideWorkspacesFromOtherDevices: true } + }) + const on = result.current + const onRenders = renders + const scans = counts.visibility + for (let flip = 0; flip < 10; flip++) { + act(() => publish('B', flip + 12, { verification: flip % 2 ? 'unavailable' : 'verified' })) + } + act(() => publish('A', 2, { status: { ...status('A'), pairedDeviceId: 'mine' } })) + expect(renders).toBe(onRenders) + expect(result.current).toBe(on) + expect(counts.visibility).toBe(scans) + const deviceScans = counts.devices + act(() => useAppStore.setState({ sshTargetLabels: new Map([['other', 'Other']]) })) + expect(counts.devices).toBe(deviceScans) + act(() => publish('A', 3, { status: { ...status('A'), pairedDeviceId: 'someone-else' } })) + expect(result.current.visibleWorktrees).toEqual([]) + expect(result.current.folders).toEqual([]) + rerender(visibilityArgs) + expect(result.current.folders).toEqual(folders) + rerender({ + ...visibilityArgs, + filterState: { ...visibilityArgs.filterState, hideWorkspacesFromOtherDevices: true } + }) + expect(result.current.folders).toEqual([]) + act(() => + useAppStore.setState({ + runtimeEnvironments: [environment('A', 'mine'), environment('B')], + runtimeStatusByEnvironmentId: new Map() + }) + ) + expect(result.current.pairedDeviceIdsByEnvironment.get('A')).toBe('mine') + expect(result.current.visibleWorktrees).toEqual([worktree]) + expect(result.current.folders).toEqual(folders) + act(() => useAppStore.setState({ runtimeEnvironments: [] })) + expect(result.current.pairedDeviceIdsByEnvironment.size).toBe(0) + rerender(visibilityArgs) + expect(result.current.folders).toEqual(folders) + const off = counts.devices + act(() => useAppStore.setState({ runtimeEnvironments: [environment('A', 'new')] })) + expect(counts.devices).toBe(off) +}) + +it('keeps equal host options and health-only changes out of the workspace row plan', () => { + const args = sectionArgs() + let renders = 0 + const { result } = renderHook(() => { + renders++ + return useSidebarSectionRows(args) + }) + const first = result.current + const before = renders + const plans = counts.plan + for (let flip = 0; flip < 10; flip++) { + act(() => publish('B', flip + 2, { status: { ...status('B'), liveTabCount: flip } })) + } + act(() => publish('A', 2, { status: { ...status('A'), liveTabCount: 99 } })) + expect(renders).toBe(before) + expect(result.current).toBe(first) + expect(counts.plan).toBe(plans) + const builds = counts.registry + act(() => useAppStore.setState({ sshStateByEnvironment: new Map() })) + expect(counts.registry).toBe(builds) + act(() => publish('A', 3, { verification: 'checking', transport: 'connecting' })) + expect(result.current.hostOptions.find((host) => host.id === 'runtime:A')?.health).toBe( + 'connecting' + ) + expect(counts.plan).toBe(plans) + expect(result.current.occurrencePlan).toBe(first.occurrencePlan) + act(() => + useAppStore.setState({ + runtimeEnvironments: [environment('A'), { ...environment('B'), name: 'Renamed' }] + }) + ) + expect(result.current.hostOptions.find((host) => host.id === 'runtime:B')?.label).toBe('Renamed') + expect(counts.plan).toBe(plans + 1) + act(() => + publish('A', 4, { + status: { ...status('A'), capabilities: ['new-capability'], hostPlatform: 'linux' } + }) + ) + expect(result.current.hostOptions.find((host) => host.id === 'runtime:A')).toMatchObject({ + capabilities: ['new-capability'], + platform: 'linux' + }) + act(() => + useAppStore.setState({ + runtimeEnvironments: [], + runtimeStatusByEnvironmentId: new Map([ + ['status-only', { status: status('status-only'), checkedAt: 1 }] + ]) + }) + ) + expect(result.current.hostOptions.map((host) => host.id)).toContain('runtime:status-only') + expect(result.current.hostOptions.map((host) => host.id)).not.toContain('runtime:B') +}) + +it('keeps compatibility, blocked guidance, aliases and diagnostic equality live in the host projection', () => { + const control = { + state: 'ready' as const, + pendingRequestCount: 0, + subscriptionCount: 0, + reconnectAttempt: 0, + lastConnectedAt: 1, + lastClose: null, + lastError: null, + lastRttMs: 1 + } + publish('A', 2, { remoteControl: control }) + const args = sectionArgs() + let renders = 0 + const { result } = renderHook(() => { + renders++ + return useSidebarSectionRows(args) + }) + const before = renders + const options = result.current.hostOptions + act(() => + publish('A', 3, { + remoteControl: { ...control, lastRttMs: 99, pendingRequestCount: 10, subscriptionCount: 5 } + }) + ) + expect(renders).toBe(before) + expect(result.current.hostOptions).toBe(options) + act(() => publish('A', 4, { verification: 'blocked', blockedReason: 'access-revoked' })) + expect(result.current.hostOptions.find((host) => host.id === 'runtime:A')).toMatchObject({ + health: 'blocked', + blockedReason: 'access-revoked' + }) + act(() => publish('A', 5, { status: { ...status('A'), runtimeProtocolVersion: 0 } })) + expect( + result.current.hostOptions.find((host) => host.id === 'runtime:A')?.compatibility + ).toMatchObject({ kind: 'blocked', reason: 'server-too-old' }) + act(() => + useAppStore.setState({ + sshTargetLabels: new Map([['managed', 'My machine']]), + runtimeEnvironments: [ + { + ...environment('A'), + orcadDeployment: { + sshTargetId: 'managed', + sshTargetGeneration: 1, + localPort: 7001, + remotePort: 7002 + } + } + ] + }) + ) + expect(result.current.hostOptions.find((host) => host.id === 'runtime:A')).toMatchObject({ + label: 'My machine', + aliasHostIds: ['ssh:managed'] + }) + act(() => + useAppStore.setState({ + sshConnectionStates: new Map([ + [ + 'managed', + { + ...ssh('connected', 'managed'), + managedServer: { kind: 'relay', reason: 'orcad_unavailable' } + } + ] + ]) + }) + ) + expect(result.current.hostOptions.find((host) => host.id === 'ssh:managed')).toMatchObject({ + aliasHostIds: ['runtime:A'] + }) +}) + +it.each([ + { repos: 10, hosts: 10 }, + { repos: 100, hosts: 10 }, + { repos: 10, hosts: 100 }, + { repos: 100, hosts: 100 } +])( + 'bounds one sidebar publication workload with $repos repos, $hosts hosts and three workspaces per repo', + ({ repos: repoCount, hosts: hostCount }) => { + const args = sidebarCorpus(repoCount, hostCount) + const dependencyCount = hostCount === 100 ? repoCount : 1 + const projectGroups: ProjectGroup[] = Array.from({ length: repoCount }, (_, index) => ({ + ...createProjectGroup({ + name: `Group ${index}`, + parentPath: `/folders/${index}`, + createdFrom: 'folder-scan', + tabOrder: index, + now: 1 + }), + id: `group-${index}`, + executionHostId: 'runtime:host-0', + connectionId: `target-${index % dependencyCount}` + })) + projectGroups.push({ + ...projectGroups[0]!, + id: 'nested', + parentPath: null, + parentGroupId: 'group-0' + }) + const folderWorkspaces = Array.from({ length: repoCount }, (_, index) => + makeFolderWorkspace({ + id: `folder-${index}`, + projectGroupId: 'group-0', + executionHostId: 'runtime:host-0', + folderPath: `/folders/0/${index}` + }) + ) + args.repos[0] = { ...args.repos[0]!, projectGroupId: 'nested' } + args.repoMap.set(args.repos[0]!.id, args.repos[0]!) + useAppStore.setState({ repos: args.repos, projectGroups, folderWorkspaces }) + // Keep requests pending: count production discovery/action snapshots without settlement writes. + const callRuntimeRpc = vi.fn(() => new Promise(() => {})) + runtimeRpc.callRuntimeRpc.mockImplementation(callRuntimeRpc) + const fetch = vi.fn(useAppStore.getState().fetchFolderWorkspacePathStatus) + useAppStore.setState({ fetchFolderWorkspacePathStatus: fetch }) + const folderArgs = { + allRepoIds: args.repos.map((repo) => repo.id), + repoMap: args.repoMap, + projectGroups, + folderWorkspaces + } + const visibleArgs = { + ...visibilityArgs, + repoMap: args.repoMap, + sortedIds: args.worktrees.map((item) => item.id), + filterState: { ...visibilityArgs.filterState, hideWorkspacesFromOtherDevices: true } + } + let renders = 0 + const { result } = renderHook(() => { + renders++ + const visible = useVisibleSidebarWorktrees(visibleArgs) + const section = useSidebarSectionRows({ ...args, worktrees: visible.visibleWorktrees }) + return { + visible, + section, + folder: useFolderWorkspacePathStatusRows(folderArgs), + contexts: useProjectHeaderHostContexts({ + rows: section.sectionRows, + hosts: section.hostOptions, + repoMap: args.repoMap + }) + } + }) + const before = { ...counts } + expect(counts.dependencies).toBe(2 * repoCount) + expect(counts.dependencyEntries).toBeGreaterThan(2 * repoCount * repoCount) + expect(counts.dependencyEntries).toBeLessThanOrEqual( + 2 * repoCount * (5 * projectGroups.length + 2 * repoCount) + ) + expect(fetch).toHaveBeenCalledTimes(2 * repoCount) + expect( + fetch.mock.calls.every( + ([, options]) => options.force && options.runtimeEnvironmentId === 'host-0' + ) + ).toBe(true) + const first = result.current + const settledRenders = renders + const fleet = hostCount + 1 + expect(first.visible.visibleWorktrees).toHaveLength(3 * repoCount) + expect(first.section.sectionRows).toHaveLength(repoCount) + expect(first.contexts.size).toBe(repoCount) + for (let sequence = 2; sequence < 12; sequence++) { + act(() => publish('B', sequence, { status: { ...status('B'), liveTabCount: sequence } })) + } + expect(renders).toBe(settledRenders) + expect(result.current).toBe(first) + // Expensive verdicts must not grow with targets owned by unchanged hosts. + expect(counts.gates - before.gates).toBe(0) + expect(counts.devices - before.devices).toBe(10) + expect(counts.deviceEntries - before.deviceEntries).toBe(10 * fleet) + expect(counts.registry - before.registry).toBe(10) + expect(counts.registryEntries - before.registryEntries).toBe(10 * (3 * fleet + 2 * repoCount)) + expect(counts.contexts).toBe(before.contexts) + expect(counts.contextEntries).toBe(before.contextEntries) + expect(counts.plan).toBe(before.plan) + expect(counts.visibility).toBe(before.visibility) + const snapshots = { ...counts } + function statusMap(value: 'connected' | 'disconnected') { + const map = new Map([['outside', ssh(value, 'outside')]]) + const get = map.get.bind(map) + map.get = (key) => { + if (key.startsWith('target-')) { + counts.statusReads++ + } + return get(key) + } + return map + } + for (let sequence = 0; sequence < 10; sequence++) { + act(() => + useAppStore.setState({ + sshConnectionStates: statusMap(sequence % 2 ? 'connected' : 'disconnected') + }) + ) + } + expect(renders).toBe(settledRenders) + expect(result.current).toBe(first) + expect(counts.gates - snapshots.gates).toBe(0) + expect(counts.contexts - snapshots.contexts).toBe(10) + expect(counts.contextEntries - snapshots.contextEntries).toBe( + 10 * (2 * fleet + 1 + 2 * repoCount) + ) + expect(counts.registry - snapshots.registry).toBe(10) + expect(counts.registryEntries - snapshots.registryEntries).toBe( + 10 * (3 * fleet + 2 * repoCount) + ) + expect(counts.devices).toBe(snapshots.devices) + expect(counts.plan).toBe(before.plan) + expect(counts.dependencies).toBe(before.dependencies) + expect(counts.dependencyEntries).toBe(before.dependencyEntries) + expect(counts.snapshots).toBe(before.snapshots) + expect(fetch).toHaveBeenCalledTimes(2 * repoCount) + expect(counts.statusReads - snapshots.statusReads).toBe(10 * dependencyCount) + const localSsh = { ...counts } + for (let sequence = 0; sequence < 10; sequence++) { + act(() => + useAppStore + .getState() + .setEnvironmentSshConnectionState( + 'B', + 'outside', + ssh(sequence % 2 ? 'connected' : 'disconnected', 'outside') + ) + ) + } + expect(renders).toBe(settledRenders) + expect(result.current).toBe(first) + expect(counts.gates - localSsh.gates).toBe(0) + expect(counts.registry).toBe(localSsh.registry) + expect(counts.devices).toBe(localSsh.devices) + expect(counts.contexts).toBe(localSsh.contexts) + expect(counts.dependencies).toBe(before.dependencies) + expect(counts.snapshots).toBe(before.snapshots) + const equal = { ...counts } + act(() => publish('host-0', 2, { status: { ...status('host-0'), liveTabCount: 99 } })) + expect(renders).toBe(settledRenders) + expect(result.current).toBe(first) + expect(counts.gates - equal.gates).toBe(0) + expect(counts.deviceEntries - equal.deviceEntries).toBe(fleet) + act(() => + useAppStore + .getState() + .setEnvironmentSshConnectionState('host-0', 'target-0', ssh('disconnected', 'target-0')) + ) + expect(renders).toBe(settledRenders) + expect(result.current).toBe(first) + act(() => + useAppStore.setState({ + sshConnectionStates: new Map([['target-0', ssh('connected', 'target-0')]]) + }) + ) + expect(fetch).toHaveBeenCalledTimes(4 * repoCount) + expect(counts.dependencies).toBe(before.dependencies) + expect(counts.snapshots - before.snapshots).toBe(2 * repoCount) + expect(renders).toBeGreaterThan(settledRenders) + const catalogBuilds = counts.dependencies + act(() => + useAppStore.setState((state) => ({ + repos: state.repos.map((repo, index) => + index === 0 ? { ...repo, path: '/moved/repo' } : repo + ) + })) + ) + expect(counts.dependencies - catalogBuilds).toBe(2 * repoCount) + expect(fetch).toHaveBeenCalledTimes(6 * repoCount) + // Requests remain accepted when their snapshot becomes null; route/force policy is unchanged. + act(() => useAppStore.setState({ projectGroups: [], folderWorkspaces: [] })) + expect(fetch).toHaveBeenCalledTimes(8 * repoCount) + expect( + fetch.mock.calls.every( + ([, options]) => options.force && options.runtimeEnvironmentId === 'host-0' + ) + ).toBe(true) + const healthPlans = counts.plan + const occurrencePlan = result.current.section.occurrencePlan + act(() => publish('host-0', 3, { verification: 'checking', transport: 'connecting' })) + expect( + result.current.section.hostOptions.find((host) => host.id === 'runtime:host-0')?.health + ).toBe('connecting') + expect(counts.plan).toBe(healthPlans) + expect(result.current.section.occurrencePlan).toBe(occurrencePlan) + act(() => publish('host-0', 4, { status: { ...status('host-0'), pairedDeviceId: 'other' } })) + expect(result.current.visible.visibleWorktrees).toHaveLength( + 3 * (repoCount - Math.ceil(repoCount / hostCount)) + ) + } +) + +it('keeps real collapsed same-name header contexts reactive through parent host options and row scope', () => { + const fixture = collapsedSameNameProjectHeaders() + useAppStore.setState({ + runtimeEnvironments: [{ ...environment('home'), name: 'Home' }, environment('B')], + runtimeStatusByEnvironmentId: new Map(), + worktreesByRepo: {} + }) + publish('home', 1) + const args = { + ...sectionArgs(), + repos: [fixture.local, fixture.paired], + visibleReposForRows: [fixture.local, fixture.paired], + worktrees: [], + worktreeMap: new Map(), + repoMap: fixture.repoMap + } + let renders = 0 + const { result, rerender } = renderHook( + (rows) => { + renders++ + const section = useSidebarSectionRows(args) + return useProjectHeaderHostContexts({ + rows, + repoMap: fixture.repoMap, + hosts: section.hostOptions + }) + }, + { initialProps: fixture.rows } + ) + const first = result.current + const before = renders + expect(first.size).toBe(2) + expect(first.get(fixture.rows[1]!.key)).toMatchObject({ hostId: 'runtime:home', label: 'Home' }) + for (let sequence = 0; sequence < 10; sequence++) { + act(() => + useAppStore.setState({ + sshConnectionStates: new Map([ + ['outside', ssh(sequence % 2 ? 'connected' : 'disconnected')] + ]) + }) + ) + } + act(() => + useAppStore.setState({ runtimeEnvironments: [...useAppStore.getState().runtimeEnvironments] }) + ) + expect(renders).toBe(before) + expect(result.current).toBe(first) + act(() => + useAppStore.setState({ + runtimeEnvironments: [{ ...environment('home'), name: 'Renamed home' }, environment('B')] + }) + ) + expect(renders).toBeGreaterThan(before) + expect(result.current).not.toBe(first) + expect(result.current.get(fixture.rows[1]!.key)?.label).toBe('Renamed home') + const renamedRenders = renders + rerender([fixture.rows[0]!]) + expect(renders).toBeGreaterThan(renamedRenders) + expect(result.current.size).toBe(0) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/host-status-test-fixtures.ts b/src/renderer/src/components/sidebar/worktree-list/listing/host-status-test-fixtures.ts new file mode 100644 index 00000000000..8577f8de0a6 --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/listing/host-status-test-fixtures.ts @@ -0,0 +1,194 @@ +import type { useVisibleSidebarWorktrees } from './use-visible-worktrees' +import type { useSidebarSectionRows } from './use-section-rows' +import { makeRepo, makeWorktree } from '../../../worktree-jump-palette-test-fixtures' +import type { Repo } from '../../../../../../shared/repo-types' +import { toRuntimeExecutionHostId } from '../../../../../../shared/execution-host' +import { useAppStore } from '@/store' +import { createCompatibleRuntimeStatusResponse } from '@/runtime/runtime-compatibility-test-fixture' +import type { RuntimeHostStatusSnapshot } from '../../../../../../shared/runtime-host-status' +import type { PublicKnownRuntimeEnvironment } from '../../../../../../shared/runtime-environments' +import type { SshConnectionStatus } from '../../../../../../shared/ssh-types' +import type { RuntimeStatus } from '../../../../../../shared/runtime-types' +export function environment(id: string, pairedDeviceId?: string): PublicKnownRuntimeEnvironment { + return { + id, + name: id, + pairedDeviceId, + createdAt: 1, + updatedAt: 1, + pairingRevision: 1, + lastUsedAt: null, + runtimeId: null, + endpoints: [], + preferredEndpointId: id + } +} +export function status(id: string): RuntimeStatus { + const response = createCompatibleRuntimeStatusResponse(id) + if (!response.ok) { + throw new Error('Expected compatible status') + } + return response.result +} +export function publish( + id: string, + sequence: number, + patch: Partial = {} +) { + useAppStore.getState().applyRuntimeHostStatusSnapshot({ + environmentId: id, + pairingRevision: 1, + sequence, + checkedAt: sequence, + status: status(id), + verification: 'verified', + transport: 'ready', + ...patch + }) +} +export function ssh(status: SshConnectionStatus, targetId = 'target') { + return { targetId, status, error: null, reconnectAttempt: 0 } +} + +export function countArrayEntries(values: readonly T[], visit: () => void): T[] { + const entries = [...values] + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]! + Object.defineProperty(entries, index, { + get: () => { + visit() + return entry + } + }) + } + return entries +} +export function countMapEntries(values: ReadonlyMap, visit: () => void): Map { + const entries = new Map(values) + const keys = entries.keys.bind(entries) + const items = entries.values.bind(entries) + entries.keys = function* () { + for (const key of keys()) { + visit() + yield key + } + return undefined + } + entries.values = function* () { + for (const item of items()) { + visit() + yield item + } + return undefined + } + return entries +} + +export const repo = { ...makeRepo(), executionHostId: 'runtime:A' as const, connectionId: 'target' } +export const repoMap = new Map([[repo.id, repo]]) +export const worktree = makeWorktree('workspace', 'Workspace', { + runtimeOwnerEnvironmentId: 'A', + creatorProvenance: { kind: 'paired-device', deviceId: 'mine' } +}) +export const visibilityArgs: Parameters[0] = { + filterState: { + showSleepingWorkspaces: true, + filterRepoIds: [], + hideDefaultBranchWorkspace: false, + hideAutomationGeneratedWorkspaces: false, + hideCliCreatedWorkspaces: false, + hideDetachedHeadWorkspaces: false, + hideWorkspacesFromOtherDevices: false, + alwaysShowDefaultBranchWorkspace: true, + visibleWorkspaceHostIds: null, + workspaceHostScope: 'all' + }, + sortBy: 'recent', + sortedIds: [worktree.id], + repoMap, + worktreeLineageById: {}, + defaultHostId: 'local', + agentSendTargetWorktreeId: null +} +export function sectionArgs(): Parameters[0] { + return { + groupBy: 'repo', + projectOrderBy: 'manual', + pinnedDisplayPolicy: 'single-location', + defaultHostId: 'local', + worktrees: [worktree], + repos: [repo], + repoMap, + worktreeMap: new Map([[worktree.id, worktree]]), + worktreeLineageById: {}, + prCache: null, + settings: useAppStore.getState().settings, + workspaceStatuses: [], + effectiveCollapsedGroups: new Set(), + projectGrouping: { projects: [], projectHostSetups: [] }, + visibleReposForRows: [repo], + visibleProjectGroupsForRows: [], + visibleFolderWorkspacesForRows: [], + importedWorktreesByRepo: new Map(), + newExternalWorktreesInboxByRepo: new Map(), + filterRepoIds: [], + visibleWorkspaceHostIds: null, + workspaceHostScope: 'all' + } +} + +export function sidebarCorpus(repoCount: number, hostCount: number) { + const environments = Array.from({ length: hostCount }, (_, index) => + environment(`host-${index}`, 'mine') + ) + const repos = Array.from({ length: repoCount }, (_, index): Repo => ({ + ...makeRepo(), + id: `repo-${index}`, + path: `/repos/project-${index}`, + displayName: `Project ${Math.floor(index / 2)}`, + executionHostId: toRuntimeExecutionHostId(environments[index % hostCount]!.id), + connectionId: `target-${index}` + })) + const worktrees = repos.flatMap((repo, index) => + Array.from({ length: 3 }, (_, branch) => + makeWorktree(`workspace-${index}-${branch}`, `Branch ${branch}`, { + repoId: repo.id, + hostId: toRuntimeExecutionHostId(environments[index % hostCount]!.id), + runtimeOwnerEnvironmentId: environments[index % hostCount]!.id, + creatorProvenance: { kind: 'paired-device', deviceId: 'mine' } + }) + ) + ) + const repoMap = new Map(repos.map((repo) => [repo.id, repo])) + useAppStore.setState({ + repos, + runtimeEnvironments: [...environments, environment('B')], + runtimeStatusByEnvironmentId: new Map(), + sshStateByEnvironment: new Map(), + worktreesByRepo: Object.fromEntries( + repos.map((repo) => [repo.id, worktrees.filter((item) => item.repoId === repo.id)]) + ) + }) + for (const owner of [...environments, environment('B')]) { + publish(owner.id, 1) + useAppStore.getState().setEnvironmentSshTargetsMetadata(owner.id, []) + } + for (let index = 0; index < repos.length; index++) { + useAppStore + .getState() + .setEnvironmentSshConnectionState( + environments[index % hostCount]!.id, + `target-${index}`, + ssh('connected', `target-${index}`) + ) + } + return { + ...sectionArgs(), + repos, + repoMap, + worktrees, + worktreeMap: new Map(worktrees.map((item) => [item.id, item])), + visibleReposForRows: repos, + effectiveCollapsedGroups: new Set(repos.map((repo) => `repo:${repo.id}`)) + } +} diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/sidebar-list-model.ts b/src/renderer/src/components/sidebar/worktree-list/listing/sidebar-list-model.ts index 5ab82191c22..4ccb0f0bad9 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/sidebar-list-model.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/sidebar-list-model.ts @@ -6,6 +6,8 @@ import type { ProjectGroupingModel } from '../../../../../../shared/project-sect import type { Repo } from '../../../../../../shared/repo-types' import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types' import type { + ImportedWorktreesCardCandidate, + NewExternalWorktreesInboxCandidate, PinnedWorktreeDisplayPolicy, WorktreeGroupBy } from '../../../../../../shared/sidebar-list/row-types' @@ -46,3 +48,29 @@ export type SidebarListModel = { externalWorktreeCards: ReturnType rowModel: ReturnType } + +export type SectionRowsArgs = { + agentSendTargetWorktreeId?: string | null + groupBy: WorktreeGroupBy + projectOrderBy: ProjectOrderBy + pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy + defaultHostId: ExecutionHostId + worktrees: Worktree[] + repos: readonly Repo[] + repoMap: Map + worktreeMap: Map + worktreeLineageById: Record + prCache: AppState['prCache'] | null + settings: AppState['settings'] + workspaceStatuses: readonly WorkspaceStatusDefinition[] + effectiveCollapsedGroups: Set + projectGrouping: ProjectGroupingModel + visibleReposForRows: readonly Repo[] + visibleProjectGroupsForRows: readonly ProjectGroup[] + visibleFolderWorkspacesForRows: readonly FolderWorkspace[] + importedWorktreesByRepo: ReadonlyMap + newExternalWorktreesInboxByRepo: ReadonlyMap + filterRepoIds: readonly string[] + visibleWorkspaceHostIds: readonly ExecutionHostId[] | null + workspaceHostScope: AppState['workspaceHostScope'] +} diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts index 050b801b2d6..48794a7c51d 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts @@ -1,6 +1,8 @@ import { useCallback, useEffect, useMemo } from 'react' +import { getFolderWorkspaceStatusRequestDependencies } from '@/store/folder-workspaces/folder-path-status' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' +import { selectFolderWorkspaces } from '@/store/workspace-record-selectors' import { useFolderWorkspacePathStatusCacheExpiryTick } from '@/lib/folder-workspace-path-status-cache-expiry' import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types' import type { ProjectGroup } from '../../../../../../shared/project-group-types' @@ -10,15 +12,74 @@ import { getFolderPathStatusRouteOptionsForRows } from './folder-path-status-rou type FolderPathStatusRequest = Parameters[0] +type RoutedRequest = { + request: FolderPathStatusRequest + options: { runtimeEnvironmentId: string | null } +} +const EMPTY_DEPENDENCIES: readonly string[] = [] + +function createFolderPathStatusDependenciesSelector(requests: ReadonlyMap) { + let repos: AppState['repos'] | undefined + let groups: AppState['projectGroups'] | undefined + let folders: AppState['folderWorkspaces'] | undefined + let ssh: AppState['sshConnectionStates'] | undefined + let connectionIds: string[] = [] + let membership = '' + let result = EMPTY_DEPENDENCIES + return (state: AppState) => { + if (requests.size === 0) { + return EMPTY_DEPENDENCIES + } + const nextFolders = selectFolderWorkspaces(state) + if ( + repos === state.repos && + groups === state.projectGroups && + folders === nextFolders && + ssh === state.sshConnectionStates + ) { + return result + } + if (repos !== state.repos || groups !== state.projectGroups || folders !== nextFolders) { + const dependencies = [...requests.values()].map(({ request, options }) => + getFolderWorkspaceStatusRequestDependencies(state, request, options) + ) + membership = JSON.stringify( + dependencies.map((value) => value && [value.scope, value.repoFingerprint]) + ) + connectionIds = [ + ...new Set(dependencies.flatMap((value) => (value ? [...value.relevantConnectionIds] : []))) + ].sort() + } + repos = state.repos + groups = state.projectGroups + folders = nextFolders + ssh = state.sshConnectionStates + let next: string[] | undefined + if (result[0] !== membership || result.length !== connectionIds.length + 1) { + next = [membership] + } + for (let index = 0; index < connectionIds.length; index++) { + const status = state.sshConnectionStates.get(connectionIds[index]!)?.status ?? 'missing' + if (next || result[index + 1] !== status) { + next ??= [...result] + next[index + 1] = status + } + } + if (next) { + result = next + } + return result + } +} + // Refresh rendered folder paths and ignore expired negative results while reprobes run. export function useFolderWorkspacePathStatusRows(args: { allRepoIds: string[] repoMap: Map projectGroups: readonly ProjectGroup[] folderWorkspaces: readonly FolderWorkspace[] - sshConnectionStates: AppState['sshConnectionStates'] }) { - const { allRepoIds, repoMap, projectGroups, folderWorkspaces, sshConnectionStates } = args + const { allRepoIds, repoMap, projectGroups, folderWorkspaces } = args const { folderWorkspacePathStatuses, fetchFolderWorkspacePathStatus, @@ -42,14 +103,6 @@ export function useFolderWorkspacePathStatusRows(args: { .join('\0'), [allRepoIds, repoMap] ) - const folderPathStatusSshConnectionKey = useMemo( - () => - [...sshConnectionStates.entries()] - .map(([connectionId, state]) => `${connectionId}:${state.status}`) - .sort() - .join('\0'), - [sshConnectionStates] - ) const folderPathStatusCacheExpiryTick = useFolderWorkspacePathStatusCacheExpiryTick( folderWorkspacePathStatuses ) @@ -70,14 +123,8 @@ export function useFolderWorkspacePathStatusRows(args: { }), [folderWorkspaceByIdForFolderPathStatus, projectGroupByIdForFolderPathStatus] ) - useEffect(() => { - const requests = new Map< - string, - { - request: FolderPathStatusRequest - options: { runtimeEnvironmentId: string | null } - } - >() + const requests = useMemo(() => { + const requests = new Map() // Why: a request whose group row has not landed has no owner to ask, so it waits. const addRequest = (request: FolderPathStatusRequest): void => { const options = getFolderPathStatusRouteOptions(request) @@ -93,13 +140,27 @@ export function useFolderWorkspacePathStatusRows(args: { for (const workspace of folderWorkspaces) { addRequest({ scope: 'folder-workspace' as const, folderWorkspaceId: workspace.id }) } + return requests + }, [ + folderWorkspaces, + getFolderPathStatusRouteOptions, + getFolderWorkspacePathStatusCacheKey, + projectGroups + ]) + const selectDependencies = useMemo( + () => createFolderPathStatusDependenciesSelector(requests), + [requests] + ) + const dependencies = useAppStore(selectDependencies) + useEffect(() => { for (const { request, options } of requests.values()) { void fetchFolderWorkspacePathStatus(request, { force: true, ...options }) } }, [ fetchFolderWorkspacePathStatus, folderPathStatusRepoMembershipKey, - folderPathStatusSshConnectionKey, + dependencies, + requests, folderWorkspaces, getFolderPathStatusRouteOptions, getFolderWorkspacePathStatusCacheKey, diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts index a4cc7ac16e3..6a222aa2218 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-section-rows.ts @@ -1,13 +1,10 @@ import { useCallback, useMemo, useState } from 'react' +import { shallow } from 'zustand/shallow' +import { persistedUIValuesEqual } from '../../../../../../shared/persisted-ui-equality' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' -import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types' -import type { ProjectGroup } from '../../../../../../shared/project-group-types' -import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types' import type { Repo } from '../../../../../../shared/repo-types' -import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types' -import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types' import { getLocalExecutionHostLabel, type ExecutionHostId @@ -16,14 +13,8 @@ import { getHostDisplayLabelOverrides } from '../../../../../../shared/host-sett import { planSidebarRows } from '../../../../../../shared/sidebar-list/sidebar-row-plan' import { createSidebarPRGroupReader } from '../grouping/pr-group-key' import { useEffectiveCollapsedGroups } from './use-collapsed-groups' -import type { ProjectGroupingModel } from '../../../../../../shared/project-section-grouping' -import type { - ImportedWorktreesCardCandidate, - NewExternalWorktreesInboxCandidate, - PinnedWorktreeDisplayPolicy, - Row, - WorktreeGroupBy -} from '../../../../../../shared/sidebar-list/row-types' +import type { Row } from '../../../../../../shared/sidebar-list/row-types' +import type { SectionRowsArgs } from './sidebar-list-model' import { getLogicalRepoOrderRankById } from '../../../../../../shared/project-section-order' import { getEmptyProjectPlaceholderRepoIds } from '../../../../../../shared/sidebar-list/empty-project-placeholder-repos' import { @@ -37,45 +28,62 @@ import { orderHostSectionOptions } from '../../../../../../shared/sidebar-list/host-section-order' import type { ExecutionHostRegistryEntry } from '../../../../../../shared/execution-host-registry' -import { buildSidebarHostOptions } from '../../sidebar-host-options' +import { + type SidebarHostOption, + buildSidebarHostOptions, + sidebarHostSemantics, + createSidebarHostLabelsSelector +} from '../../sidebar-host-options' import { selectPendingWorktreeCreationKeys } from './pending-worktree-creation-keys' import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' -type SectionRowsArgs = { - agentSendTargetWorktreeId?: string | null - groupBy: WorktreeGroupBy - projectOrderBy: ProjectOrderBy - pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy - defaultHostId: ExecutionHostId - worktrees: Worktree[] - repos: readonly Repo[] - repoMap: Map - worktreeMap: Map - worktreeLineageById: Record - prCache: AppState['prCache'] | null - settings: AppState['settings'] - workspaceStatuses: readonly WorkspaceStatusDefinition[] - effectiveCollapsedGroups: Set - projectGrouping: ProjectGroupingModel - visibleReposForRows: readonly Repo[] - visibleProjectGroupsForRows: readonly ProjectGroup[] - visibleFolderWorkspacesForRows: readonly FolderWorkspace[] - importedWorktreesByRepo: ReadonlyMap - newExternalWorktreesInboxByRepo: ReadonlyMap - filterRepoIds: readonly string[] - visibleWorkspaceHostIds: readonly ExecutionHostId[] | null - workspaceHostScope: AppState['workspaceHostScope'] +function createSidebarHostOptionsSelector() { + let inputs: readonly unknown[] = [] + let result: SidebarHostOption[] = [] + return ( + state: AppState, + repos: readonly Repo[], + settings: AppState['settings'], + hostLabelOverrides: Parameters[0]['hostLabelOverrides'] + ): SidebarHostOption[] => { + const nextInputs = [ + repos, + state.sshTargetLabels, + state.sshConnectionStates, + settings, + state.runtimeStatusByEnvironmentId, + state.runtimeEnvironments, + hostLabelOverrides, + state.wslHostStatuses, + settings?.experimentalWslHosts === true, + state.wslHostServerFiles === 'missing' + ] + if (shallow(inputs, nextInputs)) { + return result + } + inputs = nextInputs + const next = buildSidebarHostOptions({ + repos, + sshTargetLabels: state.sshTargetLabels, + sshConnectionStates: state.sshConnectionStates, + settings, + runtimeEnvironments: state.runtimeEnvironments, + runtimeStatusByEnvironmentId: state.runtimeStatusByEnvironmentId, + hostLabelOverrides, + wslHostStatuses: state.wslHostStatuses, + wslHostsEnabled: settings?.experimentalWslHosts === true, + wslHostServerFilesMissing: state.wslHostServerFiles === 'missing' + }) + if (!persistedUIValuesEqual(result.map(sidebarHostSemantics), next.map(sidebarHostSemantics))) { + result = next + } + return result + } } export function useSidebarSectionRows(args: SectionRowsArgs) { const { repos, worktrees, repoMap, defaultHostId } = args const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s)) - const sshTargetLabels = useAppStore((s) => s.sshTargetLabels) - const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) - const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) - const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) - const wslHostStatuses = useAppStore((s) => s.wslHostStatuses) - const wslHostServerFilesMissing = useAppStore((s) => s.wslHostServerFiles === 'missing') const workspaceHostOrder = useAppStore((s) => s.workspaceHostOrder) const setWorkspaceHostOrder = useAppStore((s) => s.setWorkspaceHostOrder) @@ -116,35 +124,14 @@ export function useSidebarSectionRows(args: SectionRowsArgs) { () => getHostDisplayLabelOverrides(args.settings), [args.settings] ) - const hostOptions = useMemo( - () => - buildSidebarHostOptions({ - repos, - sshTargetLabels, - sshConnectionStates, - settings: args.settings, - runtimeEnvironments, - runtimeStatusByEnvironmentId, - hostLabelOverrides, - wslHostStatuses, - wslHostsEnabled: args.settings?.experimentalWslHosts === true, - wslHostServerFilesMissing - }), - [ - repos, - sshTargetLabels, - sshConnectionStates, - args.settings, - runtimeEnvironments, - runtimeStatusByEnvironmentId, - hostLabelOverrides, - wslHostStatuses, - wslHostServerFilesMissing - ] + const selectHostOptions = useMemo(() => createSidebarHostOptionsSelector(), []) + const selectHostLabels = useMemo(() => createSidebarHostLabelsSelector(), []) + const hostOptions = useAppStore((state) => + selectHostOptions(state, repos, args.settings, hostLabelOverrides) ) const hostLabelById = useMemo( - () => new Map(hostOptions.map((host) => [host.id, host.label])), - [hostOptions] + () => selectHostLabels(hostOptions), + [hostOptions, selectHostLabels] ) const hostScopedGroups = diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts index 85d8a978d86..7a3df326d42 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-visible-worktrees.ts @@ -1,4 +1,6 @@ import { useMemo } from 'react' +import { shallow } from 'zustand/shallow' +import type { AppState } from '@/store/types' import { ORCA_SIDEBAR_INPUTS_IS_MAIN } from '../../../../../../shared/sidebar-inputs-switch' import { useAppStore } from '@/store' import { getAgentStatusEpochNow } from '@/lib/agent-status-epoch-clock' @@ -24,6 +26,29 @@ import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' const EMPTY_WORKTREE_ID_SET: ReadonlySet = new Set() +function createSidebarPairedDeviceIdsSelector(enabled: boolean) { + let inputs: readonly unknown[] = [] + let result = EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT + return (state: AppState): ReadonlyMap => { + if (!enabled) { + return EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT + } + const nextInputs = [state.runtimeEnvironments, state.runtimeStatusByEnvironmentId] + if (shallow(inputs, nextInputs)) { + return result + } + inputs = nextInputs + const next = getPairedDeviceIdsByEnvironment( + state.runtimeEnvironments, + state.runtimeStatusByEnvironmentId + ) + if (!shallow(result, next)) { + result = next + } + return result + } +} + // Share visibility filtering with shortcuts so displayed order and Cmd+1–9 numbering agree. export function useVisibleSidebarWorktrees(args: { filterState: SidebarWorktreeFilters['filterState'] @@ -52,15 +77,11 @@ export function useVisibleSidebarWorktrees(args: { const agentStatusEpoch = useAppStore((s) => (!showSleepingWorkspaces ? s.agentStatusEpoch : 0)) // Opt-out lists must skip the clock so they cannot evict the sample shared by live lists. const agentStatusNow = showSleepingWorkspaces ? 0 : getAgentStatusEpochNow(agentStatusEpoch) - const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) - const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) - const pairedDeviceIdsByEnvironment = useMemo( - () => - hideWorkspacesFromOtherDevices - ? getPairedDeviceIdsByEnvironment(runtimeEnvironments, runtimeStatusByEnvironmentId) - : EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT, - [hideWorkspacesFromOtherDevices, runtimeEnvironments, runtimeStatusByEnvironmentId] + const selectPairedDevices = useMemo( + () => createSidebarPairedDeviceIdsSelector(hideWorkspacesFromOtherDevices), + [hideWorkspacesFromOtherDevices] ) + const pairedDeviceIdsByEnvironment = useAppStore(selectPairedDevices) const needsActivityMaps = !showSleepingWorkspaces || sortBy === 'smart' const tabsByWorktree = useAppStore((s) => diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx index e6297f60d0a..fcf6085d314 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx @@ -4,7 +4,6 @@ import { ProjectHeaderHostLabel, ProjectHeaderHostTooltip } from './ProjectHeade import type { ProjectHeaderHostContext } from './project-header-host-context' import type { VirtualItem } from '@tanstack/react-virtual' import { cn } from '@/lib/utils' -import type { RepoSshGateResolver } from '@/hooks/use-repo-ssh-gate-resolver' import { RepoIconGlyph } from '@/components/repo/repo-icon' import { RepoForkIndicator } from '@/components/repo/repo-fork-indicator' import type { FolderWorkspacePathStatus } from '../../../../../../shared/folder-workspace-path-status' @@ -25,7 +24,6 @@ import { getWorkspaceStatusFromGroupKey } from '../../workspace-status' import { getVirtualRowTransform } from '../viewport/virtual-rows' import { getSidebarHeaderVisual } from './header-visuals' import { resolveProjectGroupHeaderColor } from '../../project-header-color' -import { getRepoHeaderCreateState } from '../../repo-header-create-state' import { ProjectHeaderActions } from '../../ProjectHeaderActions' import { getProjectGroupHeaderPaddingLeft, @@ -55,7 +53,6 @@ export type SectionHeaderRowContext = { collapsedGroups: Set workspaceStatuses: readonly WorkspaceStatusDefinition[] projectGroups: readonly ProjectGroup[] - getRepoSshGate: RepoSshGateResolver highlightedRevealRowKey: string | null dragOverStatus: WorkspaceStatus | null pinDragOver: boolean @@ -160,13 +157,6 @@ export function renderWorktreeSectionHeaderRow(args: { headerKey: row.key, badgeColor: row.repo?.badgeColor }) - const createState = row.repo - ? getRepoHeaderCreateState({ - repo: row.repo, - label, - sshGate: ctx.getRepoSshGate(row.repo) - }) - : null const folderBackedProjectGroup = isProjectGroupHeader && !row.repo && @@ -397,9 +387,9 @@ export function renderWorktreeSectionHeaderRow(args: { {row.repo && ctx.groupBy === 'repo' ? ( ) : null} diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/project-header-host-context.test.ts b/src/renderer/src/components/sidebar/worktree-list/rows/project-header-host-context.test.ts index 19d1d4dc835..e5626bee04e 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/project-header-host-context.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/rows/project-header-host-context.test.ts @@ -7,6 +7,7 @@ import type { SidebarHostOption } from '../../sidebar-host-options' import { planSidebarRows } from '../../../../../../shared/sidebar-list/sidebar-row-plan' import { addHostSectionRows } from '../../../../../../shared/sidebar-list/host-section-rows' import { + collapsedSameNameProjectHeaders, project, projectHostSetups, repo, @@ -16,38 +17,7 @@ import { } from '../../worktree-list-groups-test-fixtures' import { getProjectHeaderHostContexts } from './project-header-host-context' -const local = { ...repo, displayName: 'app' } -const paired: Repo = { ...local, id: 'home-app', executionHostId: 'runtime:home' } -const hosts: SidebarHostOption[] = [ - { - id: 'local', - label: 'Local Mac', - detail: 'This computer', - kind: 'local', - health: 'local', - presence: 'local' - }, - { - id: 'runtime:home', - label: 'Home', - detail: 'home.example', - kind: 'runtime', - health: 'disconnected', - presence: 'configured' - } -] -function header(value: Repo): GroupHeaderRow { - return { - type: 'header', - key: `repo:${value.id}`, - label: value.displayName, - count: 1, - icon: { kind: 'project' }, - repo: value - } -} -const repoMap = new Map([local, paired].map((value) => [value.id, value])) -const rows = [header(local), header(paired)] +const { local, paired, hosts, header, repoMap, rows } = collapsedSameNameProjectHeaders() const folder: ProjectGroup = { id: 'folder', name: 'app', diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/repo-header-project-actions.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/repo-header-project-actions.tsx index f95c459c869..c9c64eb9266 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/repo-header-project-actions.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/repo-header-project-actions.tsx @@ -1,4 +1,5 @@ import React from 'react' +import { useRepoSshGate } from '@/hooks/use-repo-ssh-gate' import { CircleX, Ellipsis, @@ -38,7 +39,7 @@ import { REPO_HEADER_ACTION_BUTTON_CLASS, REPO_HEADER_ACTION_REVEAL_CLASS } from '../../repo-header-action-button-class' -import type { getRepoHeaderCreateState } from '../../repo-header-create-state' +import { getRepoHeaderCreateState } from '../../repo-header-create-state' import { handleRepoHeaderActionPointerDown, stopRepoHeaderKeyboardToggle, @@ -186,14 +187,14 @@ export function RepoHeaderProjectActionsMenu({ export function RepoHeaderCreateWorkspaceButton({ repo, label, - createState, onCreateForRepo }: { repo: Repo label: string - createState: ReturnType | null onCreateForRepo: (projectId: string) => void }): React.JSX.Element { + const sshGate = useRepoSshGate(repo) + const createState = getRepoHeaderCreateState({ repo, label, sshGate }) const fallbackLabel = translate( 'auto.components.sidebar.WorktreeList.bb85cd86ba', 'Create workspace for {{value0}}', diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/repo-header-ssh-isolation.test.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/repo-header-ssh-isolation.test.tsx new file mode 100644 index 00000000000..b5690e37c0d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/rows/repo-header-ssh-isolation.test.tsx @@ -0,0 +1,283 @@ +// @vitest-environment happy-dom +import { Profiler } from 'react' +import { act, cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { TooltipProvider } from '@/components/ui/tooltip' +import { RepoHeaderCreateWorkspaceButton } from './repo-header-project-actions' +import { makeRepo } from '../../../worktree-jump-palette-test-fixtures' +import { environment, publish, ssh } from '../listing/host-status-test-fixtures' +import type * as SshGate from '@/lib/repo-ssh-connection' +import type { Repo } from '../../../../../../shared/repo-types' + +const work = vi.hoisted(() => ({ authorities: 0, connections: 0 })) +vi.mock('@/lib/repo-ssh-connection', async (original) => { + const actual = await original() + return { + ...actual, + getRepoSshConnection: (...args: Parameters) => { + work.connections++ + return actual.getRepoSshConnection(...args) + }, + selectRepoSshGateInput: (...args: Parameters) => { + work.authorities++ + return actual.selectRepoSshGateInput(...args) + } + } +}) +const initial = useAppStore.getInitialState() +beforeEach(() => { + useAppStore.setState(initial, true) + useAppStore.setState({ runtimeEnvironments: [environment('A'), environment('B')] }) + publish('A', 1) + publish('B', 1) + useAppStore.getState().setEnvironmentSshTargetsMetadata('A', []) + useAppStore + .getState() + .setEnvironmentSshConnectionState('A', 'target-0', ssh('connected', 'target-0')) +}) +afterEach(() => { + cleanup() + useAppStore.setState(initial, true) +}) + +it.each([10, 100])('bounds mounted Create work independently of a %i-target catalog', (size) => { + const repos: Repo[] = Array.from({ length: size }, (_, index) => ({ + ...makeRepo(), + id: `repo-${index}`, + executionHostId: 'runtime:A', + connectionId: `target-${index}` + })) + useAppStore.setState({ repos }) + for (const repo of repos) { + useAppStore + .getState() + .setEnvironmentSshConnectionState( + 'A', + repo.connectionId!, + ssh('connected', repo.connectionId!) + ) + } + let reads = 0 + let observing = false + const wrapped = new WeakSet() + function instrument(map: Map) { + if (wrapped.has(map)) { + return + } + wrapped.add(map) + const get = map.get.bind(map) + map.get = (key) => { + if (observing) { + reads++ + } + return get(key) + } + } + const unsubscribe = useAppStore.subscribe((state) => { + instrument(state.runtimeStatusByEnvironmentId) + instrument(state.sshStateByEnvironment) + instrument(state.sshConnectionStates) + for (const bucket of state.sshStateByEnvironment.values()) { + instrument(bucket.connectionStates) + } + observing = true + }) + function publication(write: () => void) { + observing = false + act(write) + observing = false + } + let viewportRenders = 0 + const commits = vi.fn() + const create = vi.fn() + function PaintedHeaders({ painted }: { painted: Repo[] }) { + viewportRenders++ + return ( + + + {painted.map((repo) => ( + + ))} + + + ) + } + const { rerender, getAllByRole } = render() + const settled = { ...work, commits: commits.mock.calls.length, viewportRenders } + reads = 0 + for (let sequence = 2; sequence < 12; sequence++) { + publication(() => publish('B', sequence)) + publication(() => + useAppStore + .getState() + .setEnvironmentSshConnectionState( + 'B', + 'outside', + ssh(sequence % 2 ? 'connected' : 'disconnected', 'outside') + ) + ) + publication(() => + useAppStore.setState({ + sshConnectionStates: new Map([ + ['outside', ssh(sequence % 2 ? 'connected' : 'disconnected', 'outside')] + ]) + }) + ) + publication(() => + useAppStore + .getState() + .setEnvironmentSshConnectionState( + 'A', + `target-${size - 1}`, + ssh(sequence % 2 ? 'connected' : 'disconnected', `target-${size - 1}`) + ) + ) + } + expect(work.authorities).toBe(settled.authorities) + expect(work.connections).toBe(settled.connections) + expect(commits).toHaveBeenCalledTimes(settled.commits) + expect(viewportRenders).toBe(settled.viewportRenders) + // Three selected lookups per mounted consumer; independent of hidden catalog size. + expect(reads).toBe(40 * 2 * 3) + const equal = work.authorities + publication(() => publish('A', 2)) + expect(work.authorities - equal).toBe(2) + expect(commits).toHaveBeenCalledTimes(settled.commits) + const equalOwner = work.authorities + publication(() => + useAppStore.getState().setEnvironmentSshConnectionState('A', 'target-0', { + ...ssh('connected', 'target-0'), + error: 'diagnostic' + }) + ) + expect(work.authorities - equalOwner).toBe(1) + expect(commits).toHaveBeenCalledTimes(settled.commits) + const baseline = work.authorities + publication(() => useAppStore.setState({ workspaceHostOrder: ['local'] })) + expect(work.authorities).toBe(baseline) + publication(() => + useAppStore + .getState() + .setEnvironmentSshConnectionState('A', 'target-0', ssh('disconnected', 'target-0')) + ) + expect(getAllByRole('button')[0]?.hasAttribute('disabled')).toBe(true) + expect(work.authorities - baseline).toBe(1) + expect(viewportRenders).toBe(settled.viewportRenders) + expect(commits.mock.calls.length).toBeGreaterThan(settled.commits) + // A hidden target reads current truth when mounted, including after H=0. + rerender() + const hidden = work.authorities + reads = 0 + publication(() => + useAppStore + .getState() + .setEnvironmentSshConnectionState( + 'A', + `target-${size - 1}`, + ssh('disconnected', `target-${size - 1}`) + ) + ) + expect(work.authorities).toBe(hidden) + expect(reads).toBe(0) + rerender() + expect(getAllByRole('button')[0]?.hasAttribute('disabled')).toBe(true) + rerender() + reads = 0 + publication(() => publish('B', 12)) + expect(reads).toBe(4 * 3) + expect(work.authorities).toBe(hidden + 5) + unsubscribe() +}) + +it('preserves owner, hydration, transport, refusal, removal and local-missing authority in the real control', () => { + const repo: Repo = { ...makeRepo(), executionHostId: 'runtime:A', connectionId: 'target-0' } + const create = vi.fn() + function Control({ repo }: { repo: Repo }) { + return ( + + + + ) + } + const { rerender, getByRole } = render() + const disabled = () => getByRole('button').hasAttribute('disabled') + expect(disabled()).toBe(false) + fireEvent.click(getByRole('button')) + expect(create).toHaveBeenCalledExactlyOnceWith(repo.id) + act(() => + useAppStore + .getState() + .setEnvironmentSshConnectionState('A', 'target-0', ssh('disconnected', 'target-0')) + ) + expect(disabled()).toBe(true) + act(() => useAppStore.getState().markEnvironmentSshStateStale('A')) + expect(disabled()).toBe(false) + act(() => useAppStore.getState().setEnvironmentSshTargetsMetadata('A', [])) + expect(disabled()).toBe(false) + act(() => + useAppStore + .getState() + .setEnvironmentSshConnectionState('A', 'target-0', ssh('disconnected', 'target-0')) + ) + expect(disabled()).toBe(true) + const ownerStatus = useAppStore.getState().runtimeStatusByEnvironmentId.get('A')?.status + if (!ownerStatus) { + throw new Error('Expected owner status') + } + act(() => + publish('A', 2, { + status: ownerStatus, + verification: 'blocked', + blockedReason: 'access-revoked' + }) + ) + expect(disabled()).toBe(false) + act(() => publish('A', 3, { status: ownerStatus })) + expect(disabled()).toBe(true) + act(() => publish('A', 4, { status: null, verification: 'unavailable', transport: 'ready' })) + expect(disabled()).toBe(true) + act(() => + publish('A', 5, { status: ownerStatus, verification: 'checking', transport: 'connecting' }) + ) + expect(disabled()).toBe(false) + act(() => publish('A', 6, { status: ownerStatus })) + expect(disabled()).toBe(true) + act(() => publish('A', 7, { status: ownerStatus, retired: true })) + expect(disabled()).toBe(false) + act(() => publish('A', 8, { status: ownerStatus })) + expect(disabled()).toBe(true) + act(() => useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() })) + expect(disabled()).toBe(false) + act(() => publish('A', 9, { status: ownerStatus })) + expect(disabled()).toBe(true) + act(() => useAppStore.setState({ sshStateByEnvironment: new Map() })) + expect(disabled()).toBe(false) + act(() => useAppStore.getState().setEnvironmentSshTargetsMetadata('A', [])) + expect(disabled()).toBe(false) + act(() => + useAppStore + .getState() + .setEnvironmentSshConnectionState('A', 'target-0', ssh('disconnected', 'target-0')) + ) + expect(disabled()).toBe(true) + rerender() + expect(disabled()).toBe(false) + rerender() + expect(disabled()).toBe(true) + act(() => + useAppStore.setState({ sshConnectionStates: new Map([['absent', ssh('connected', 'absent')]]) }) + ) + expect(disabled()).toBe(false) + act(() => useAppStore.setState({ sshConnectionStates: new Map() })) + expect(disabled()).toBe(true) + rerender() + expect(disabled()).toBe(false) + act(() => useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() })) + expect(disabled()).toBe(false) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx b/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx index 126996c8dd5..d3dfbad8270 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx @@ -1,11 +1,10 @@ import React, { useCallback, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' -import { getProjectHeaderHostContexts } from '../rows/project-header-host-context' +import { useProjectHeaderHostContexts } from './use-project-header-host-contexts' import { translate } from '@/i18n/i18n' import { WorktreeListScrollToTopButton } from '../../WorktreeListScrollToTopButton' import { renderWorktreeSidebarDropIndicators } from './drop-indicators' import { useWorktreeListScrollToTop } from './use-scroll-to-top' -import { useRepoSshGateResolver } from '@/hooks/use-repo-ssh-gate-resolver' import { getActiveDescendantOptionId } from '../navigation/active-descendant-option' import { buildRenderableRows } from '../listing/renderable-rows' import { useFolderWorkspacePathStatusRows } from '../listing/use-folder-path-statuses' @@ -31,6 +30,8 @@ import { useWorktreeDropCommitContext } from '../drag/use-drop-commit-context' import { buildWorktreeVirtualRowContext } from './virtual-row-context' import { renderWorktreeVirtualRow } from '../rows/virtual-row-dispatch' +const EMPTY_HOST_OPTIONS: NonNullable = [] + const WORKTREE_SIDEBAR_SCROLL_STYLE: React.CSSProperties = { // Why: TanStack Virtual owns scroll correction; native overflow anchoring fights it and causes jumps. overflowAnchor: 'none' @@ -59,30 +60,13 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr const [scrollElement, setScrollElement] = useState(null) const settings = useAppStore((s) => s.settings) const worktreeVisibilityDefaultsByHost = useAppStore((s) => s.worktreeVisibilityDefaultsByHost) - const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) - const getRepoSshGate = useRepoSshGateResolver() - const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) - const projectHeaderHostContexts = useMemo( - () => - getProjectHeaderHostContexts({ - rows, - visibleProjectRepoIdsBySection: props.visibleProjectRepoIdsBySection, - hosts: props.hostOptions ?? [], - runtimeEnvironments, - sshConnectionStates, - repoMap, - projectGrouping: props.projectGrouping - }), - [ - rows, - props.visibleProjectRepoIdsBySection, - props.hostOptions, - repoMap, - props.projectGrouping, - runtimeEnvironments, - sshConnectionStates - ] - ) + const projectHeaderHostContexts = useProjectHeaderHostContexts({ + rows, + visibleProjectRepoIdsBySection: props.visibleProjectRepoIdsBySection, + hosts: props.hostOptions ?? EMPTY_HOST_OPTIONS, + repoMap, + projectGrouping: props.projectGrouping + }) const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true const reveal = useSidebarRevealHighlight() @@ -145,8 +129,7 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr allRepoIds: props.allRepoIds, repoMap, projectGroups, - folderWorkspaces: props.folderWorkspaces, - sshConnectionStates + folderWorkspaces: props.folderWorkspaces }) const { showScrollToTop, scrollToTop } = useWorktreeListScrollToTop({ @@ -326,7 +309,6 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr measureVirtualRowElement, settings, worktreeVisibilityDefaultsByHost, - getRepoSshGate, newCardStyle, folderBackedProjectGroupIds, projectGroups, diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/header-folder-composition.test.tsx b/src/renderer/src/components/sidebar/worktree-list/viewport/header-folder-composition.test.tsx new file mode 100644 index 00000000000..ff20023ea8a --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/header-folder-composition.test.tsx @@ -0,0 +1,347 @@ +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render, renderHook } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { TooltipProvider } from '@/components/ui/tooltip' +import { makeRepo, makeFolderWorkspace } from '../../../worktree-jump-palette-test-fixtures' +import { createProjectGroup } from '../../../../../../shared/project-groups' +import type { Repo } from '../../../../../../shared/repo-types' +import type { GroupHeaderRow } from '../../../../../../shared/sidebar-list/row-types' +import { useFolderWorkspacePathStatusRows } from '../listing/use-folder-path-statuses' +import { useProjectHeaderHostContexts } from './use-project-header-host-contexts' +import { + renderWorktreeVirtualRow, + type WorktreeVirtualRowContext +} from '../rows/virtual-row-dispatch' +import type { SectionHeaderRowContext } from '../rows/SectionHeader' +import { INITIAL_REPO_DRAG_STATE } from '../../project-header-drag-contract' +import { INITIAL_PROJECT_GROUP_DRAG_STATE } from '../../project-group-header-drag-contract' +import { environment, publish, ssh } from '../listing/host-status-test-fixtures' + +const initial = useAppStore.getInitialState() +const originalApi = Object.getOwnPropertyDescriptor(window, 'api') +afterEach(() => { + cleanup() + useAppStore.setState(initial, true) + if (originalApi) { + Object.defineProperty(window, 'api', originalApi) + } else { + Reflect.deleteProperty(window, 'api') + } +}) + +it('composes real dispatched headers, current repo gates and folder cache refresh without unrelated viewport work', async () => { + useAppStore.setState(initial, true) + const group = { + ...createProjectGroup({ + name: 'Repo', + parentPath: '/folders', + createdFrom: 'folder-scan', + tabOrder: 0, + now: 1 + }), + id: 'root', + executionHostId: 'local' + } + const nestedGroup = { ...group, id: 'nested', parentGroupId: 'root', parentPath: null } + const repo: Repo = { ...makeRepo(), executionHostId: 'runtime:A', connectionId: 'target-0' } + const nested: Repo = { + ...makeRepo(), + id: 'nested-repo', + path: '/elsewhere/repo', + projectGroupId: 'nested', + connectionId: 'nested-A', + executionHostId: 'local' + } + const folder = makeFolderWorkspace({ + projectGroupId: 'root', + folderPath: '/folders/workspace', + executionHostId: 'local' + }) + const projectGroups = [group, nestedGroup] + const folderWorkspaces = [folder] + const repoMap = new Map([ + [repo.id, repo], + [nested.id, nested] + ]) + useAppStore.setState({ + repos: [repo, nested], + projectGroups, + folderWorkspaces, + runtimeEnvironments: [environment('A'), environment('B')] + }) + publish('A', 1) + publish('B', 1) + useAppStore.getState().setEnvironmentSshTargetsMetadata('A', []) + useAppStore + .getState() + .setEnvironmentSshConnectionState('A', 'target-0', ssh('connected', 'target-0')) + let exists = false + const getPathStatus = vi.fn(async () => ({ + path: '/folders', + exists, + ...(exists ? {} : { reason: 'missing' as const }) + })) + // External filesystem boundary only; request, routing, cache and reader stay real. + Object.defineProperty(window, 'api', { + configurable: true, + value: { ...window.api, folderWorkspaces: { ...window.api?.folderWorkspaces, getPathStatus } } + }) + const fetch = vi.fn(useAppStore.getState().fetchFolderWorkspacePathStatus) + useAppStore.setState({ fetchFolderWorkspacePathStatus: fetch }) + const allRepoIds = [repo.id, nested.id] + const rows: GroupHeaderRow[] = [ + { type: 'header', key: 'repo', label: 'Repo', icon: { kind: 'project' }, count: 1, repo }, + { + type: 'header', + key: 'folder', + label: 'Repo', + icon: { kind: 'project' }, + count: 1, + projectGroup: group + } + ] + const hosts = [ + { + id: 'local' as const, + label: 'Local', + kind: 'local' as const, + detail: '', + health: 'available' as const + }, + { + id: 'runtime:A' as const, + label: 'A', + kind: 'runtime' as const, + detail: '', + health: 'available' as const + } + ] + const create = vi.fn() + const toggle = vi.fn() + const measure = vi.fn() + let viewportRenders = 0 + function Viewport({ rows }: { rows: GroupHeaderRow[] }) { + viewportRenders++ + const getCachedFolderWorkspacePathStatus = useFolderWorkspacePathStatusRows({ + allRepoIds, + repoMap, + projectGroups, + folderWorkspaces + }) + const projectHeaderHostContexts = useProjectHeaderHostContexts({ rows, hosts, repoMap }) + const header: SectionHeaderRowContext = { + groupBy: 'repo', + collapsedGroups: new Set(), + workspaceStatuses: [], + projectGroups, + highlightedRevealRowKey: null, + dragOverStatus: null, + pinDragOver: false, + projectHeaderHostContexts, + getCachedFolderWorkspacePathStatus, + toggleGroupWithScrollAnchor: toggle, + headerDrag: { + canReorderRepoHeaders: false, + canReorderProjectGroupHeaders: false, + orderedHostIds: [], + hostDrag: { + state: { draggingHostId: null, dropIndex: null, dropIndicatorY: null }, + onHandlePointerDown: vi.fn() + }, + repoDrag: { state: INITIAL_REPO_DRAG_STATE, onHandlePointerDown: vi.fn() }, + projectGroupDrag: { state: INITIAL_PROJECT_GROUP_DRAG_STATE, onHandlePointerDown: vi.fn() }, + sidebarRepoHeaderIdsByBucket: new Map(), + sidebarProjectGroupHeaderIdsByBucket: new Map(), + repoHeaderIndexByRepoId: new Map(), + repoHeaderBucketByRepoId: new Map(), + projectGroupHeaderIndexByGroupId: new Map(), + projectGroupHeaderBucketByGroupId: new Map(), + repoHeaderSectionEndByRepoId: new Map(), + projectGroupHeaderSectionEndByGroupId: new Map() + }, + projectActions: { + getWorktreeVisibilityDefaults: () => undefined, + onOpenRepoSettings: vi.fn(), + onOpenWorktreeVisibility: vi.fn(), + onCreateGroupFromRepo: vi.fn(), + onMoveProjectToGroup: vi.fn(), + onRemoveProjectFromGroup: vi.fn(), + onRemoveProject: vi.fn(), + onCreateForRepo: create + }, + onRenameProjectGroup: vi.fn(), + onDeleteProjectGroup: vi.fn(), + onCreateFolderWorkspace: create, + onWorkspaceStatusDragOver: vi.fn(), + onWorkspaceStatusDragLeave: vi.fn(), + onWorkspacePinDragOver: vi.fn(), + onWorkspacePinDragLeave: vi.fn(), + onWorkspaceStatusDrop: vi.fn() + } + const ctx: WorktreeVirtualRowContext = { + renderRows: rows, + firstHeaderIndex: 0, + activeStickyHeaderIndexRef: { current: 0 }, + activeStickyHostIndexRef: { current: null }, + measureVirtualRowElement: measure, + header, + groupBy: header.groupBy, + workspaceStatuses: header.workspaceStatuses, + activeWorktreeId: null, + orderedHostIds: header.headerDrag.orderedHostIds, + hostDrag: header.headerDrag.hostDrag, + toggleGroupWithScrollAnchor: toggle, + importedWorktreeCardActionState: new Map(), + newExternalWorktreeInboxActionState: new Map(), + onShowImportedWorktrees: vi.fn(), + onKeepImportedWorktreesHidden: vi.fn(), + onOpenWorktreeVisibility: header.projectActions.onOpenWorktreeVisibility, + onOpenSuppressExternalWorktreeInbox: vi.fn(), + onWorkspaceStatusDragOver: header.onWorkspaceStatusDragOver, + onWorkspaceStatusDragLeave: header.onWorkspaceStatusDragLeave, + onWorkspaceStatusDrop: header.onWorkspaceStatusDrop, + get worktreeDragState(): WorktreeVirtualRowContext['worktreeDragState'] { + throw new Error('Header fixture cannot read worktree drag state') + }, + get item(): WorktreeVirtualRowContext['item'] { + throw new Error('Header fixture cannot render worktree items') + }, + get folderWorkspace(): WorktreeVirtualRowContext['folderWorkspace'] { + throw new Error('Header fixture cannot render folder workspace rows') + } + } + return ( + + {rows.map((row, index) => + renderWorktreeVirtualRow(ctx, row, { + key: row.key, + index, + start: index * 35, + size: 35, + end: (index + 1) * 35, + lane: 0 + }) + )} + + ) + } + const view = render() + await act(async () => {}) + expect(view.getByRole('button', { name: 'Repo — A' })).toBeTruthy() + expect(view.getByRole('button', { name: 'Repo — Local' })).toBeTruthy() + expect(fetch).toHaveBeenCalledTimes(2) + expect(fetch.mock.calls.map(([request, options]) => [request.scope, options])).toEqual([ + ['project-group', { force: true, runtimeEnvironmentId: null }], + ['folder-workspace', { force: true, runtimeEnvironmentId: null }] + ]) + const folderButton = () => view.getByRole('button', { name: 'Create workspace for Repo' }) + expect(folderButton().getAttribute('aria-disabled')).toBe('true') + const settled = viewportRenders + for (let index = 0; index < 10; index++) { + act(() => + useAppStore.setState({ + sshConnectionStates: new Map([['B', ssh(index % 2 ? 'connected' : 'disconnected', 'B')]]) + }) + ) + act(() => publish('B', index + 2)) + } + expect(viewportRenders).toBe(settled) + expect(fetch).toHaveBeenCalledTimes(2) + act(() => + useAppStore + .getState() + .setEnvironmentSshConnectionState('A', 'target-0', ssh('disconnected', 'target-0')) + ) + expect( + view + .getByRole('button', { name: 'Reconnect SSH target before creating workspaces for Repo' }) + .hasAttribute('disabled') + ).toBe(true) + expect(viewportRenders).toBe(settled) + exists = true + await act(async () => { + useAppStore.setState({ + sshConnectionStates: new Map([['nested-A', ssh('connected', 'nested-A')]]) + }) + }) + expect(fetch).toHaveBeenCalledTimes(4) + expect(getPathStatus).toHaveBeenCalledTimes(4) + expect(folderButton().getAttribute('aria-disabled')).toBe('false') + fireEvent.click(folderButton()) + expect(create).toHaveBeenCalledExactlyOnceWith(group) + expect(toggle).not.toHaveBeenCalled() + expect(measure).toHaveBeenCalled() + expect( + view.container + .querySelector('[data-worktree-virtual-row-key="repo"]') + ?.getAttribute('data-worktree-sticky-header-active') + ).toBe('') + // Reuse the virtual row key for another owner: the mounted JSX control reads its new resource. + view.rerender( + + ) + expect( + view.getByRole('button', { name: 'Create new worktree for Repo' }).hasAttribute('disabled') + ).toBe(false) +}) + +it('keeps empty requests inert, deduplicates owner routes and still fetches null snapshots', () => { + useAppStore.setState(initial, true) + const group = { + ...createProjectGroup({ + name: 'Folder', + parentPath: '/folders', + createdFrom: 'folder-scan', + tabOrder: 0, + now: 1 + }), + id: 'root', + executionHostId: 'local' + } + const fetch = vi.fn(async () => null) + useAppStore.setState({ fetchFolderWorkspacePathStatus: fetch }) + const empty: Parameters[0] = { + allRepoIds: [], + repoMap: new Map(), + projectGroups: [], + folderWorkspaces: [] + } + let renders = 0 + const { result, rerender } = renderHook( + (args) => { + renders++ + return useFolderWorkspacePathStatusRows(args) + }, + { initialProps: empty } + ) + const first = result.current + const settled = renders + for (let index = 0; index < 10; index++) { + act(() => + useAppStore.setState({ + sshConnectionStates: new Map([[`outside-${index}`, ssh('connected')]]) + }) + ) + } + expect(fetch).not.toHaveBeenCalled() + expect(result.current).toBe(first) + expect(renders).toBe(settled) + // Props can route the request before its catalog scope lands; null is not admission. + rerender({ ...empty, projectGroups: [group, group] }) + expect(fetch).toHaveBeenCalledExactlyOnceWith( + { scope: 'project-group', projectGroupId: 'root' }, + { force: true, runtimeEnvironmentId: null } + ) + rerender({ ...empty, projectGroups: [{ ...group, executionHostId: 'runtime:A' }] }) + expect(fetch).toHaveBeenLastCalledWith( + { scope: 'project-group', projectGroupId: 'root' }, + { force: true, runtimeEnvironmentId: 'A' } + ) + expect(fetch).toHaveBeenCalledTimes(2) + act(() => useAppStore.setState({ projectGroups: [{ ...group, executionHostId: 'runtime:A' }] })) + expect(fetch).toHaveBeenCalledTimes(3) + rerender(empty) + act(() => useAppStore.setState({ projectGroups: [], sshConnectionStates: new Map() })) + expect(fetch).toHaveBeenCalledTimes(3) +}) diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-project-header-host-contexts.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-project-header-host-contexts.ts new file mode 100644 index 00000000000..2bb53db250d --- /dev/null +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/use-project-header-host-contexts.ts @@ -0,0 +1,48 @@ +import { useMemo } from 'react' +import { shallow } from 'zustand/shallow' +import { useAppStore } from '@/store' +import type { AppState } from '@/store/types' +import { persistedUIValuesEqual } from '../../../../../../shared/persisted-ui-equality' +import { getProjectHeaderHostContexts } from '../rows/project-header-host-context' + +type ContextArgs = Omit< + Parameters[0], + 'runtimeEnvironments' | 'sshConnectionStates' +> + +function createProjectHeaderHostContextsSelector(args: ContextArgs) { + let inputs: readonly unknown[] = [] + let result: ReturnType = new Map() + return (state: AppState) => { + const nextInputs = [state.runtimeEnvironments, state.sshConnectionStates] + if (shallow(inputs, nextInputs)) { + return result + } + inputs = nextInputs + const next = getProjectHeaderHostContexts({ + ...args, + runtimeEnvironments: state.runtimeEnvironments, + sshConnectionStates: state.sshConnectionStates + }) + if (!persistedUIValuesEqual([...result], [...next])) { + result = next + } + return result + } +} + +export function useProjectHeaderHostContexts(args: ContextArgs) { + const { rows, visibleProjectRepoIdsBySection, hosts, repoMap, projectGrouping } = args + const select = useMemo( + () => + createProjectHeaderHostContextsSelector({ + rows, + visibleProjectRepoIdsBySection, + hosts, + repoMap, + projectGrouping + }), + [rows, visibleProjectRepoIdsBySection, hosts, repoMap, projectGrouping] + ) + return useAppStore(select) +} diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts index 01988d66670..c607cbd8e1a 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts @@ -18,7 +18,6 @@ import type { WorktreeVirtualRowContext } from '../rows/virtual-row-dispatch' import { getRepoOwnerWorktreeVisibilityDefaults } from '../../../../store/worktree-visibility-defaults-by-host' import type { ProjectHeaderHostContext } from '../rows/project-header-host-context' -import type { RepoSshGateResolver } from '@/hooks/use-repo-ssh-gate-resolver' type BuildArgs = { props: VirtualizedWorktreeViewportProps @@ -29,7 +28,6 @@ type BuildArgs = { measureVirtualRowElement: (element: HTMLDivElement | null) => void settings: AppState['settings'] worktreeVisibilityDefaultsByHost: AppState['worktreeVisibilityDefaultsByHost'] - getRepoSshGate: RepoSshGateResolver newCardStyle: boolean folderBackedProjectGroupIds: ReadonlySet session: WorktreeDragSession @@ -80,7 +78,6 @@ export function buildWorktreeVirtualRowContext(args: BuildArgs): WorktreeVirtual collapsedGroups: props.collapsedGroups, workspaceStatuses: props.workspaceStatuses, projectGroups: args.projectGroups, - getRepoSshGate: args.getRepoSshGate, highlightedRevealRowKey: reveal.highlightedRevealRowKey, dragOverStatus: runtime.dragOverStatus, pinDragOver: runtime.pinDragOver, diff --git a/src/renderer/src/hooks/use-repo-ssh-gate-resolver.ts b/src/renderer/src/hooks/use-repo-ssh-gate-resolver.ts deleted file mode 100644 index 02803e63be5..00000000000 --- a/src/renderer/src/hooks/use-repo-ssh-gate-resolver.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { useCallback } from 'react' -import { useAppStore } from '@/store' -import { selectRepoSshGateInput } from '@/lib/repo-ssh-connection' -import type { Repo } from '../../../shared/repo-types' - -export type RepoSshGateResolver = (repo: Repo) => ReturnType - -/** Re-renders when either this client's or a paired server's SSH state changes. */ -export function useRepoSshGateResolver(): RepoSshGateResolver { - const sshConnectionStates = useAppStore((s) => s.sshConnectionStates) - const sshStateByEnvironment = useAppStore((s) => s.sshStateByEnvironment) - const runtimeStatusByEnvironmentId = useAppStore((s) => s.runtimeStatusByEnvironmentId) - return useCallback( - (repo: Repo) => - selectRepoSshGateInput( - { sshConnectionStates, sshStateByEnvironment, runtimeStatusByEnvironmentId }, - repo - ), - [sshConnectionStates, sshStateByEnvironment, runtimeStatusByEnvironmentId] - ) -} diff --git a/src/renderer/src/hooks/use-repo-ssh-gate.ts b/src/renderer/src/hooks/use-repo-ssh-gate.ts new file mode 100644 index 00000000000..2b5085794a1 --- /dev/null +++ b/src/renderer/src/hooks/use-repo-ssh-gate.ts @@ -0,0 +1,56 @@ +import { useMemo } from 'react' +import { useAppStore } from '@/store' +import { getRepoSshConnection, selectRepoSshGateInput } from '@/lib/repo-ssh-connection' +import type { AppState } from '@/store/types' +import type { Repo } from '../../../shared/repo-types' + +type Gate = ReturnType +const NO_GATE: Gate = { connectionId: null, status: null } + +function createRepoSshGateSelector(repo: Pick) { + const connection = getRepoSshConnection(repo) + let initialized = false + let owner: ReturnType + let entry: ReturnType + let hydrated: boolean | undefined + let result = NO_GATE + return (state: AppState): Gate => { + if (!connection) { + return NO_GATE + } + const nextOwner = + connection.environmentId === null + ? undefined + : state.runtimeStatusByEnvironmentId.get(connection.environmentId) + const bucket = + connection.environmentId === null + ? undefined + : state.sshStateByEnvironment.get(connection.environmentId) + const nextHydrated = bucket?.targetsHydrated + const nextEntry = + connection.environmentId === null + ? state.sshConnectionStates.get(connection.targetId) + : bucket?.connectionStates.get(connection.targetId) + if (initialized && owner === nextOwner && hydrated === nextHydrated && entry === nextEntry) { + return result + } + initialized = true + owner = nextOwner + hydrated = nextHydrated + entry = nextEntry + const next = selectRepoSshGateInput(state, repo) + if (result.connectionId !== next.connectionId || result.status !== next.status) { + result = next + } + return result + } +} + +export function useRepoSshGate(repo: Pick): Gate { + const { connectionId, executionHostId } = repo + const select = useMemo( + () => createRepoSshGateSelector({ connectionId, executionHostId }), + [connectionId, executionHostId] + ) + return useAppStore(select) +} diff --git a/src/renderer/src/runtime/structured-agent-session-host-capability-scaling.test.tsx b/src/renderer/src/runtime/structured-agent-session-host-capability-scaling.test.tsx index ae75125406b..a8882a2bd4e 100644 --- a/src/renderer/src/runtime/structured-agent-session-host-capability-scaling.test.tsx +++ b/src/renderer/src/runtime/structured-agent-session-host-capability-scaling.test.tsx @@ -147,52 +147,56 @@ it.each([10, 100])( } ) -it('does not rerender local or other-host consumers when an unrelated host status changes', () => { - const unrelated = createCompatibleRuntimeStatusResponse('other') - const own = createCompatibleRuntimeStatusResponse('host') - if (!unrelated.ok || !own.ok) { - throw new Error('expected successful fixture') - } - const statuses = (otherStatus: typeof unrelated.result | null) => - new Map([ - ['host', { status: own.result, checkedAt: 1 }], - ['other', { status: otherStatus, checkedAt: 1 }] - ]) - useAppStore.setState({ runtimeStatusByEnvironmentId: statuses(null) }) - let renders = 0 - const targets = [{ kind: 'local' }, { kind: 'environment', environmentId: 'host' }] as const - const views = targets.map((target) => - renderHook(() => { - renders += 1 - return useStructuredAgentSessionOwnPermissionState(target) - }) - ) - const before = renders - for (let flip = 0; flip < 10; flip += 1) { - act(() => - useAppStore.setState({ - runtimeStatusByEnvironmentId: statuses(flip % 2 === 0 ? unrelated.result : null) +it.each([10, 100])( + 'does not rerender %s local and other-host consumers for unrelated status changes', + (count) => { + const unrelated = createCompatibleRuntimeStatusResponse('other') + const own = createCompatibleRuntimeStatusResponse('host') + if (!unrelated.ok || !own.ok) { + throw new Error('expected successful fixture') + } + const statuses = (otherStatus: typeof unrelated.result | null) => + new Map([ + ['host', { status: own.result, checkedAt: 1 }], + ['other', { status: otherStatus, checkedAt: 1 }] + ]) + useAppStore.setState({ runtimeStatusByEnvironmentId: statuses(null) }) + let renders = 0 + const targets = [{ kind: 'local' }, { kind: 'environment', environmentId: 'host' }] as const + const views = Array.from({ length: count }, (_, index) => + renderHook(() => { + const target = targets[index % targets.length] + renders += 1 + return useStructuredAgentSessionOwnPermissionState(target) }) ) + const before = renders + for (let flip = 0; flip < 10; flip += 1) { + act(() => + useAppStore.setState({ + runtimeStatusByEnvironmentId: statuses(flip % 2 === 0 ? unrelated.result : null) + }) + ) + } + expect(renders - before).toBe(0) + const hostState = views[1].result.current + act(() => + useAppStore.setState({ + runtimeStatusByEnvironmentId: new Map([ + [ + 'host', + { + status: { + ...own.result, + capabilities: + hostState === 'supported' ? [] : [AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY] + }, + checkedAt: 2 + } + ] + ]) + }) + ) + expect(views[1].result.current).toBe(hostState === 'supported' ? 'unsupported' : 'supported') } - expect(renders - before).toBe(0) - const hostState = views[1].result.current - act(() => - useAppStore.setState({ - runtimeStatusByEnvironmentId: new Map([ - [ - 'host', - { - status: { - ...own.result, - capabilities: - hostState === 'supported' ? [] : [AGENT_SESSION_PERMISSION_OWN_RUNTIME_CAPABILITY] - }, - checkedAt: 2 - } - ] - ]) - }) - ) - expect(views[1].result.current).toBe(hostState === 'supported' ? 'unsupported' : 'supported') -}) +) diff --git a/src/renderer/src/store/folder-workspaces/folder-path-status-ownership.test.ts b/src/renderer/src/store/folder-workspaces/folder-path-status-ownership.test.ts index 2e40c1a9a7b..2ef4b2abfe6 100644 --- a/src/renderer/src/store/folder-workspaces/folder-path-status-ownership.test.ts +++ b/src/renderer/src/store/folder-workspaces/folder-path-status-ownership.test.ts @@ -1,8 +1,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { FolderWorkspacePathStatus } from '../../../../shared/folder-workspace-path-status' +import type { Repo } from '../../../../shared/repo-types' import type { ProjectGroup } from '../../../../shared/project-group-types' import type * as RuntimeRpcClient from '@/runtime/runtime-rpc-client' -import { makeFolderWorkspace } from '@/components/worktree-jump-palette-test-fixtures' +import { + getFolderWorkspaceStatusRequestDependencies, + getFolderWorkspaceStatusRequestSnapshot +} from './folder-path-status' +import { createProjectGroup } from '../../../../shared/project-groups' +import { makeRepo, makeFolderWorkspace } from '@/components/worktree-jump-palette-test-fixtures' import { createTestStore } from '../slices/store-test-helpers' const mocks = vi.hoisted(() => ({ getPathStatus: vi.fn(), callRuntimeRpc: vi.fn() })) @@ -146,3 +152,145 @@ describe('folder path status ownership', () => { } ) }) + +describe('shared folder snapshot candidate extraction', () => { + it('preserves exact owner, descendant, contained-path and explicit-target snapshot bytes', () => { + const store = createTestStore() + const group = { + ...createProjectGroup({ + name: 'Root', + parentPath: '/root', + createdFrom: 'folder-scan', + tabOrder: 0, + now: 1 + }), + id: 'root', + executionHostId: 'local', + connectionId: 'C' + } + const child = { + ...group, + id: 'child', + parentGroupId: 'root', + parentPath: '/elsewhere', + connectionId: null + } + const repos: Repo[] = [ + { + ...makeRepo(), + id: 'nested', + path: '/outside/repo', + projectGroupId: 'child', + connectionId: 'A', + executionHostId: 'local' + }, + { + ...makeRepo(), + id: 'contained', + path: '/root/repo', + connectionId: 'B', + executionHostId: 'local' + }, + { + ...makeRepo(), + id: 'foreign', + path: '/root/repo', + connectionId: 'Z', + executionHostId: 'runtime:builder' + } + ] + const folder = makeFolderWorkspace({ + id: 'folder', + projectGroupId: 'root', + folderPath: '/root', + executionHostId: 'local' + }) + store.setState({ + projectGroups: [ + group, + child, + { ...group, executionHostId: 'runtime:builder', parentPath: '/remote' } + ], + folderWorkspaces: [ + folder, + { ...folder, executionHostId: 'runtime:builder', folderPath: '/remote' } + ], + repos, + sshConnectionStates: new Map([ + ['A', { targetId: 'A', status: 'connected', error: null, reconnectAttempt: 0 }] + ]) + }) + const options = { runtimeEnvironmentId: null } + const groupRequest = { scope: 'project-group', projectGroupId: 'root' } as const + const folderRequest = { scope: 'folder-workspace', folderWorkspaceId: 'folder' } as const + const expected = [ + '/root', + 'root', + 'C', + 'local', + 'A:connected|B:missing|C:missing', + 'contained:/root/repo::B:local|nested:/outside/repo:child:A:local' + ].join('\0') + for (const request of [groupRequest, folderRequest]) { + const dependencies = getFolderWorkspaceStatusRequestDependencies( + store.getState(), + request, + options + ) + expect(dependencies?.relevantConnectionIds).toEqual(new Set(['C', 'A', 'B'])) + expect(getFolderWorkspaceStatusRequestSnapshot(store.getState(), request, options)).toBe( + expected + ) + } + expect( + getFolderWorkspaceStatusRequestSnapshot(store.getState(), groupRequest, { + runtimeEnvironmentId: 'builder' + }) + ).toBe(['/remote', 'root', 'C', 'runtime:builder', 'C:missing', ''].join('\0')) + const pathRequest = { scope: 'path', path: '/root', connectionId: 'C' } as const + expect(getFolderWorkspaceStatusRequestSnapshot(store.getState(), pathRequest, options)).toBe( + ['/root', '', 'C', 'local', 'B:missing|C:missing', 'contained:/root/repo::B:local'].join('\0') + ) + // Reordering cannot change the sorted token or request ownership. + store.setState((state) => ({ + repos: state.repos.toReversed(), + projectGroups: state.projectGroups.toReversed(), + folderWorkspaces: state.folderWorkspaces.toReversed() + })) + expect(getFolderWorkspaceStatusRequestSnapshot(store.getState(), folderRequest, options)).toBe( + expected + ) + store.setState({ projectGroups: [group, { ...child, parentGroupId: null }] }) + expect(getFolderWorkspaceStatusRequestSnapshot(store.getState(), groupRequest, options)).toBe( + ['/root', 'root', 'C', 'local', 'B:missing|C:missing', 'contained:/root/repo::B:local'].join( + '\0' + ) + ) + store.setState({ + repos: repos.map((repo) => ({ ...repo, path: '/moved', projectGroupId: null })) + }) + expect(getFolderWorkspaceStatusRequestSnapshot(store.getState(), groupRequest, options)).toBe( + ['/root', 'root', 'C', 'local', 'C:missing', ''].join('\0') + ) + store.setState({ folderWorkspaces: [] }) + expect( + getFolderWorkspaceStatusRequestDependencies(store.getState(), folderRequest, options) + ).toBeNull() + expect( + getFolderWorkspaceStatusRequestSnapshot(store.getState(), folderRequest, options) + ).toBeNull() + store.setState({ projectGroups: [group, group] }) + expect( + getFolderWorkspaceStatusRequestSnapshot(store.getState(), groupRequest, options) + ).toBeNull() + store.setState({ projectGroups: [] }) + expect( + getFolderWorkspaceStatusRequestSnapshot(store.getState(), groupRequest, options) + ).toBeNull() + store.setState({ projectGroups: [group] }) + expect( + getFolderWorkspaceStatusRequestDependencies(store.getState(), groupRequest, options) + ?.relevantConnectionIds + ).toEqual(new Set(['C'])) + }) +}) diff --git a/src/renderer/src/store/folder-workspaces/folder-path-status.ts b/src/renderer/src/store/folder-workspaces/folder-path-status.ts index ab56eb018a4..78cb1ea94d0 100644 --- a/src/renderer/src/store/folder-workspaces/folder-path-status.ts +++ b/src/renderer/src/store/folder-workspaces/folder-path-status.ts @@ -82,11 +82,11 @@ function getFolderPathStatusScope( } } -export function getFolderWorkspaceStatusRequestSnapshot( +export function getFolderWorkspaceStatusRequestDependencies( state: FolderPathStatusSnapshotState, request: FolderWorkspacePathStatusRequest, options: FolderWorkspacePathStatusRouteOptions -): string | null { +) { const ownerHostId = getRuntimeTargetHostId( runtimeTargetForOwnerEnvironment(options.runtimeEnvironmentId) ) @@ -115,13 +115,6 @@ export function getFolderWorkspaceStatusRequestSnapshot( relevantConnectionIds.add(repo.connectionId) } } - const sshFingerprint = [...relevantConnectionIds] - .map( - (connectionId) => - `${connectionId}:${state.sshConnectionStates.get(connectionId)?.status ?? 'missing'}` - ) - .sort() - .join('|') const repoFingerprint = candidateRepos .map( (repo) => @@ -129,6 +122,26 @@ export function getFolderWorkspaceStatusRequestSnapshot( ) .sort() .join('|') + return { scope, relevantConnectionIds, repoFingerprint } +} + +export function getFolderWorkspaceStatusRequestSnapshot( + state: FolderPathStatusSnapshotState, + request: FolderWorkspacePathStatusRequest, + options: FolderWorkspacePathStatusRouteOptions +): string | null { + const dependencies = getFolderWorkspaceStatusRequestDependencies(state, request, options) + if (!dependencies) { + return null + } + const { scope, relevantConnectionIds, repoFingerprint } = dependencies + const sshFingerprint = [...relevantConnectionIds] + .map( + (connectionId) => + `${connectionId}:${state.sshConnectionStates.get(connectionId)?.status ?? 'missing'}` + ) + .sort() + .join('|') return [ scope.path, scope.projectGroupId ?? '',