mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<ExecutionHostId, string>()
|
||||
return (hosts: readonly SidebarHostOption[]): ReadonlyMap<ExecutionHostId, string> => {
|
||||
const next = new Map(hosts.map((host) => [host.id, host.label]))
|
||||
if (!shallow(result, next)) {
|
||||
result = next
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<DetectedWorktree> = {}):
|
||||
...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 }
|
||||
}
|
||||
|
||||
+690
@@ -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<typeof RuntimeRpcClient>()),
|
||||
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<typeof FolderStatus>()
|
||||
return {
|
||||
...actual,
|
||||
getFolderWorkspaceStatusRequestDependencies: (
|
||||
...args: Parameters<typeof actual.getFolderWorkspaceStatusRequestDependencies>
|
||||
) => {
|
||||
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<typeof actual.getFolderWorkspaceStatusRequestSnapshot>
|
||||
) => {
|
||||
counts.snapshots++
|
||||
return actual.getFolderWorkspaceStatusRequestSnapshot(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.mock('@/lib/repo-ssh-connection', async (original) => {
|
||||
const actual = await original<typeof SshGate>()
|
||||
return {
|
||||
...actual,
|
||||
selectRepoSshGateInput: (...args: Parameters<typeof actual.selectRepoSshGateInput>) => {
|
||||
counts.gates++
|
||||
return actual.selectRepoSshGateInput(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.mock('../../../../../../shared/workspace-creator-visibility', async (original) => {
|
||||
const actual = await original<typeof DeviceVisibility>()
|
||||
return {
|
||||
...actual,
|
||||
getPairedDeviceIdsByEnvironment: (
|
||||
...args: Parameters<typeof actual.getPairedDeviceIdsByEnvironment>
|
||||
) => {
|
||||
counts.devices++
|
||||
return actual.getPairedDeviceIdsByEnvironment(
|
||||
countArrayEntries(args[0], () => counts.deviceEntries++),
|
||||
args[1]
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.mock('../../../../../../shared/execution-host-registry', async (original) => {
|
||||
const actual = await original<typeof Registry>()
|
||||
return {
|
||||
...actual,
|
||||
buildExecutionHostRegistry: (...args: Parameters<typeof actual.buildExecutionHostRegistry>) => {
|
||||
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<typeof RowPlan>()
|
||||
return {
|
||||
...actual,
|
||||
planSidebarRows: (...args: Parameters<typeof actual.planSidebarRows>) => {
|
||||
counts.plan++
|
||||
return actual.planSidebarRows(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
vi.mock('../../visible-worktrees', async (original) => {
|
||||
const actual = await original<typeof Visibility>()
|
||||
return {
|
||||
...actual,
|
||||
computeVisibleWorktrees: (...args: Parameters<typeof actual.computeVisibleWorktrees>) => {
|
||||
counts.visibility++
|
||||
return actual.computeVisibleWorktrees(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('../rows/project-header-host-context', async (original) => {
|
||||
const actual = await original<typeof HeaderContext>()
|
||||
return {
|
||||
...actual,
|
||||
getProjectHeaderHostContexts: (
|
||||
args: Parameters<typeof actual.getProjectHeaderHostContexts>[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<never>(() => {}))
|
||||
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)
|
||||
})
|
||||
+194
@@ -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<RuntimeHostStatusSnapshot> = {}
|
||||
) {
|
||||
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<T>(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<K, V>(values: ReadonlyMap<K, V>, visit: () => void): Map<K, V> {
|
||||
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<string, Repo>([[repo.id, repo]])
|
||||
export const worktree = makeWorktree('workspace', 'Workspace', {
|
||||
runtimeOwnerEnvironmentId: 'A',
|
||||
creatorProvenance: { kind: 'paired-device', deviceId: 'mine' }
|
||||
})
|
||||
export const visibilityArgs: Parameters<typeof useVisibleSidebarWorktrees>[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<typeof useSidebarSectionRows>[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}`))
|
||||
}
|
||||
}
|
||||
@@ -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<typeof useSidebarExternalWorktreeCards>
|
||||
rowModel: ReturnType<typeof useSidebarSectionRows>
|
||||
}
|
||||
|
||||
export type SectionRowsArgs = {
|
||||
agentSendTargetWorktreeId?: string | null
|
||||
groupBy: WorktreeGroupBy
|
||||
projectOrderBy: ProjectOrderBy
|
||||
pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy
|
||||
defaultHostId: ExecutionHostId
|
||||
worktrees: Worktree[]
|
||||
repos: readonly Repo[]
|
||||
repoMap: Map<string, Repo>
|
||||
worktreeMap: Map<string, Worktree>
|
||||
worktreeLineageById: Record<string, WorktreeLineage>
|
||||
prCache: AppState['prCache'] | null
|
||||
settings: AppState['settings']
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[]
|
||||
effectiveCollapsedGroups: Set<string>
|
||||
projectGrouping: ProjectGroupingModel
|
||||
visibleReposForRows: readonly Repo[]
|
||||
visibleProjectGroupsForRows: readonly ProjectGroup[]
|
||||
visibleFolderWorkspacesForRows: readonly FolderWorkspace[]
|
||||
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate>
|
||||
newExternalWorktreesInboxByRepo: ReadonlyMap<string, NewExternalWorktreesInboxCandidate>
|
||||
filterRepoIds: readonly string[]
|
||||
visibleWorkspaceHostIds: readonly ExecutionHostId[] | null
|
||||
workspaceHostScope: AppState['workspaceHostScope']
|
||||
}
|
||||
|
||||
+80
-19
@@ -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<AppState['fetchFolderWorkspacePathStatus']>[0]
|
||||
|
||||
type RoutedRequest = {
|
||||
request: FolderPathStatusRequest
|
||||
options: { runtimeEnvironmentId: string | null }
|
||||
}
|
||||
const EMPTY_DEPENDENCIES: readonly string[] = []
|
||||
|
||||
function createFolderPathStatusDependenciesSelector(requests: ReadonlyMap<string, RoutedRequest>) {
|
||||
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<string, Repo>
|
||||
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<string, RoutedRequest>()
|
||||
// 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,
|
||||
|
||||
@@ -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<string, Repo>
|
||||
worktreeMap: Map<string, Worktree>
|
||||
worktreeLineageById: Record<string, WorktreeLineage>
|
||||
prCache: AppState['prCache'] | null
|
||||
settings: AppState['settings']
|
||||
workspaceStatuses: readonly WorkspaceStatusDefinition[]
|
||||
effectiveCollapsedGroups: Set<string>
|
||||
projectGrouping: ProjectGroupingModel
|
||||
visibleReposForRows: readonly Repo[]
|
||||
visibleProjectGroupsForRows: readonly ProjectGroup[]
|
||||
visibleFolderWorkspacesForRows: readonly FolderWorkspace[]
|
||||
importedWorktreesByRepo: ReadonlyMap<string, ImportedWorktreesCardCandidate>
|
||||
newExternalWorktreesInboxByRepo: ReadonlyMap<string, NewExternalWorktreesInboxCandidate>
|
||||
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<typeof buildSidebarHostOptions>[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 =
|
||||
|
||||
@@ -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<string> = new Set()
|
||||
|
||||
function createSidebarPairedDeviceIdsSelector(enabled: boolean) {
|
||||
let inputs: readonly unknown[] = []
|
||||
let result = EMPTY_PAIRED_DEVICE_IDS_BY_ENVIRONMENT
|
||||
return (state: AppState): ReadonlyMap<string, string> => {
|
||||
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) =>
|
||||
|
||||
@@ -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<string>
|
||||
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' ? (
|
||||
<RepoHeaderCreateWorkspaceButton
|
||||
key={`${row.repo.executionHostId ?? 'local'}:${row.repo.connectionId ?? ''}`}
|
||||
repo={row.repo}
|
||||
label={label}
|
||||
createState={createState}
|
||||
onCreateForRepo={ctx.projectActions.onCreateForRepo}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
+2
-32
@@ -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',
|
||||
|
||||
+4
-3
@@ -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<typeof getRepoHeaderCreateState> | 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}}',
|
||||
|
||||
+283
@@ -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<typeof SshGate>()
|
||||
return {
|
||||
...actual,
|
||||
getRepoSshConnection: (...args: Parameters<typeof actual.getRepoSshConnection>) => {
|
||||
work.connections++
|
||||
return actual.getRepoSshConnection(...args)
|
||||
},
|
||||
selectRepoSshGateInput: (...args: Parameters<typeof actual.selectRepoSshGateInput>) => {
|
||||
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<object>()
|
||||
function instrument<K, V>(map: Map<K, V>) {
|
||||
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 (
|
||||
<TooltipProvider>
|
||||
<Profiler id="painted" onRender={commits}>
|
||||
{painted.map((repo) => (
|
||||
<RepoHeaderCreateWorkspaceButton
|
||||
key={repo.id}
|
||||
repo={repo}
|
||||
label={repo.id}
|
||||
onCreateForRepo={create}
|
||||
/>
|
||||
))}
|
||||
</Profiler>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
const { rerender, getAllByRole } = render(<PaintedHeaders painted={repos.slice(0, 2)} />)
|
||||
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(<PaintedHeaders painted={[]} />)
|
||||
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(<PaintedHeaders painted={[repos[size - 1]!]} />)
|
||||
expect(getAllByRole('button')[0]?.hasAttribute('disabled')).toBe(true)
|
||||
rerender(<PaintedHeaders painted={repos.slice(0, 4)} />)
|
||||
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 (
|
||||
<TooltipProvider>
|
||||
<RepoHeaderCreateWorkspaceButton repo={repo} label="Project" onCreateForRepo={create} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
const { rerender, getByRole } = render(<Control repo={repo} />)
|
||||
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(<Control repo={{ ...repo, executionHostId: 'runtime:B' }} />)
|
||||
expect(disabled()).toBe(false)
|
||||
rerender(<Control repo={{ ...repo, executionHostId: 'local', connectionId: 'absent' }} />)
|
||||
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(<Control repo={{ ...repo, executionHostId: 'local', connectionId: undefined }} />)
|
||||
expect(disabled()).toBe(false)
|
||||
act(() => useAppStore.setState({ runtimeStatusByEnvironmentId: new Map() }))
|
||||
expect(disabled()).toBe(false)
|
||||
})
|
||||
+11
-29
@@ -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<VirtualizedWorktreeViewportProps['hostOptions']> = []
|
||||
|
||||
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<HTMLDivElement | null>(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,
|
||||
|
||||
+347
@@ -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<string, Repo>([
|
||||
[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 (
|
||||
<TooltipProvider>
|
||||
{rows.map((row, index) =>
|
||||
renderWorktreeVirtualRow(ctx, row, {
|
||||
key: row.key,
|
||||
index,
|
||||
start: index * 35,
|
||||
size: 35,
|
||||
end: (index + 1) * 35,
|
||||
lane: 0
|
||||
})
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
const view = render(<Viewport rows={rows} />)
|
||||
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(
|
||||
<Viewport rows={[{ ...rows[0]!, repo: { ...repo, executionHostId: 'runtime:B' } }, rows[1]!]} />
|
||||
)
|
||||
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<typeof useFolderWorkspacePathStatusRows>[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)
|
||||
})
|
||||
+48
@@ -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<typeof getProjectHeaderHostContexts>[0],
|
||||
'runtimeEnvironments' | 'sshConnectionStates'
|
||||
>
|
||||
|
||||
function createProjectHeaderHostContextsSelector(args: ContextArgs) {
|
||||
let inputs: readonly unknown[] = []
|
||||
let result: ReturnType<typeof getProjectHeaderHostContexts> = 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)
|
||||
}
|
||||
@@ -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<string>
|
||||
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,
|
||||
|
||||
@@ -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<typeof selectRepoSshGateInput>
|
||||
|
||||
/** 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]
|
||||
)
|
||||
}
|
||||
@@ -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<typeof selectRepoSshGateInput>
|
||||
const NO_GATE: Gate = { connectionId: null, status: null }
|
||||
|
||||
function createRepoSshGateSelector(repo: Pick<Repo, 'connectionId' | 'executionHostId'>) {
|
||||
const connection = getRepoSshConnection(repo)
|
||||
let initialized = false
|
||||
let owner: ReturnType<AppState['runtimeStatusByEnvironmentId']['get']>
|
||||
let entry: ReturnType<AppState['sshConnectionStates']['get']>
|
||||
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<Repo, 'connectionId' | 'executionHostId'>): Gate {
|
||||
const { connectionId, executionHostId } = repo
|
||||
const select = useMemo(
|
||||
() => createRepoSshGateSelector({ connectionId, executionHostId }),
|
||||
[connectionId, executionHostId]
|
||||
)
|
||||
return useAppStore(select)
|
||||
}
|
||||
+50
-46
@@ -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<string, RuntimeEnvironmentStatus>([
|
||||
['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<string, RuntimeEnvironmentStatus>([
|
||||
['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<string, RuntimeEnvironmentStatus>([
|
||||
[
|
||||
'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<string, RuntimeEnvironmentStatus>([
|
||||
[
|
||||
'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')
|
||||
})
|
||||
)
|
||||
|
||||
@@ -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']))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
Reference in New Issue
Block a user