fix(sidebar): align folder host identities

This commit is contained in:
Jinwoo-H
2026-08-20 20:37:01 -04:00
parent 62c78167e3
commit 910f8b1678
26 changed files with 617 additions and 183 deletions
@@ -2,7 +2,10 @@ import { describe, expect, it } from 'vitest'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../shared/project-group-types'
import { getFolderWorkspaceHostId } from './folder-workspace-host-id'
import {
getFolderWorkspaceHostId,
getFolderWorkspaceHostIdFromGroups
} from './folder-workspace-host-id'
import { addHostSectionRows, type HostSectionOption } from './host-section-rows'
import type { Row } from './worktree-list/grouping/row-types'
@@ -172,4 +175,16 @@ describe('folder workspace host resolution', () => {
expect(folderPlacements(disconnected)).toEqual(expected)
expect(folderPlacements(reconnected)).toEqual(expected)
})
it('does not invent a group owner when same-id groups disagree across hosts', () => {
const workspace = folderWorkspace({ connectionId: 'legacy-target' })
const groups = [
projectGroup({ executionHostId: 'local' }),
projectGroup({ executionHostId: OWNER_HOST_ID })
]
expect(getFolderWorkspaceHostIdFromGroups(workspace, groups, FOCUSED_HOST_ID)).toBe(
'ssh:legacy-target'
)
})
})
@@ -1,23 +1,4 @@
import {
normalizeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../shared/project-group-types'
/** Keeps folder filtering, bucketing, counting, and reveal on one host precedence. */
export function getFolderWorkspaceHostId(
folderWorkspace: Pick<FolderWorkspace, 'connectionId' | 'executionHostId'>,
projectGroup: Pick<ProjectGroup, 'connectionId' | 'executionHostId'> | null | undefined,
defaultHostId: ExecutionHostId
): ExecutionHostId {
const executionHostId =
normalizeExecutionHostId(folderWorkspace.executionHostId) ??
normalizeExecutionHostId(projectGroup?.executionHostId)
if (executionHostId) {
return executionHostId
}
const connectionId = folderWorkspace.connectionId ?? projectGroup?.connectionId
return connectionId ? toSshExecutionHostId(connectionId) : defaultHostId
}
export {
getFolderWorkspaceHostId,
getFolderWorkspaceHostIdFromGroups
} from '../../../../shared/folder-workspace-host'
@@ -87,8 +87,7 @@ describe('WorktreeList host filtering ownership', () => {
expect(
getFolderPathStatusRouteOptionsForRows({
request: { scope: 'project-group', projectGroupId: runtimeGroup.id },
projectGroupsById: new Map([[runtimeGroup.id, runtimeGroup]]),
folderWorkspacesById: new Map()
projectGroup: runtimeGroup
})
).toEqual({ runtimeEnvironmentId: 'env-1' })
})
@@ -99,8 +98,8 @@ describe('WorktreeList host filtering ownership', () => {
expect(
getFolderPathStatusRouteOptionsForRows({
request: { scope: 'folder-workspace', folderWorkspaceId: workspace.id },
projectGroupsById: new Map([[runtimeGroup.id, runtimeGroup]]),
folderWorkspacesById: new Map([[workspace.id, workspace]])
projectGroup: runtimeGroup,
folderWorkspace: workspace
})
).toEqual({ runtimeEnvironmentId: 'env-1' })
})
@@ -110,8 +109,7 @@ describe('WorktreeList host filtering ownership', () => {
expect(
getFolderPathStatusRouteOptionsForRows({
request: { scope: 'project-group', projectGroupId: localGroup.id },
projectGroupsById: new Map([[localGroup.id, localGroup]]),
folderWorkspacesById: new Map()
projectGroup: localGroup
})
).toEqual({ runtimeEnvironmentId: null })
})
@@ -121,9 +119,29 @@ describe('WorktreeList host filtering ownership', () => {
expect(
getFolderPathStatusRouteOptionsForRows({
request: { scope: 'project-group', projectGroupId: sshGroup.id },
projectGroupsById: new Map([[sshGroup.id, sshGroup]]),
folderWorkspacesById: new Map()
projectGroup: sshGroup
})
).toEqual({ runtimeEnvironmentId: null })
})
it('routes identical folder ids from each concrete host row', () => {
const request = { scope: 'folder-workspace' as const, folderWorkspaceId: 'folder-1' }
const localGroup = group({ executionHostId: 'local' })
const runtimeGroup = group({ executionHostId: 'runtime:env-1' })
expect(
getFolderPathStatusRouteOptionsForRows({
request,
projectGroup: localGroup,
folderWorkspace: folderWorkspace({ executionHostId: 'local' })
})
).toEqual({ runtimeEnvironmentId: null })
expect(
getFolderPathStatusRouteOptionsForRows({
request,
projectGroup: runtimeGroup,
folderWorkspace: folderWorkspace({ executionHostId: 'runtime:env-1' })
})
).toEqual({ runtimeEnvironmentId: 'env-1' })
})
})
@@ -88,6 +88,12 @@ export function getRuntimeEnvironmentIdForFolderPathStatusHost(
return parsed?.kind === 'runtime' ? parsed.environmentId : null
}
export function getFolderPathStatusRouteOptionsForHost(hostId: ExecutionHostId): {
runtimeEnvironmentId: string | null
} {
return { runtimeEnvironmentId: getRuntimeEnvironmentIdForFolderPathStatusHost(hostId) }
}
function getProjectGroupExecutionHostIdForFolderPathStatus(
group: Pick<ProjectGroup, 'connectionId' | 'executionHostId'>
): ExecutionHostId {
@@ -100,32 +106,25 @@ function getProjectGroupExecutionHostIdForFolderPathStatus(
export function getFolderPathStatusRouteOptionsForRows({
request,
projectGroupsById,
folderWorkspacesById
projectGroup,
folderWorkspace
}: {
request: FolderWorkspacePathStatusRequest
projectGroupsById: ReadonlyMap<string, ProjectGroup>
folderWorkspacesById: ReadonlyMap<string, FolderWorkspace>
projectGroup: ProjectGroup | null | undefined
folderWorkspace?: FolderWorkspace
}): { runtimeEnvironmentId: string | null } | undefined {
const folderWorkspace =
request.scope === 'folder-workspace'
? folderWorkspacesById.get(request.folderWorkspaceId)
: undefined
const group =
request.scope === 'project-group'
? projectGroupsById.get(request.projectGroupId)
: projectGroupsById.get(folderWorkspace?.projectGroupId ?? '')
if (!group) {
if (request.scope === 'project-group' && !projectGroup) {
return undefined
}
const hostId =
request.scope === 'project-group'
? getProjectGroupExecutionHostIdForFolderPathStatus(group)
? getProjectGroupExecutionHostIdForFolderPathStatus(projectGroup!)
: getFolderWorkspaceExecutionHostIdForRows({
folderWorkspace: folderWorkspace ?? { connectionId: null, executionHostId: null },
projectGroup: group,
defaultHostId: getProjectGroupExecutionHostIdForFolderPathStatus(group)
projectGroup: projectGroup ?? undefined,
defaultHostId: projectGroup
? getProjectGroupExecutionHostIdForFolderPathStatus(projectGroup)
: 'local'
})
const runtimeEnvironmentId = getRuntimeEnvironmentIdForFolderPathStatusHost(hostId)
return { runtimeEnvironmentId }
return getFolderPathStatusRouteOptionsForHost(hostId)
}
@@ -2,13 +2,18 @@ import type { HostSectionRow } from '../../host-section-rows'
import { getFolderWorkspaceHostId } from '../../folder-workspace-host-id'
import { composeWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope'
import { LOCAL_EXECUTION_HOST_ID } from '../../../../../../shared/execution-host'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
type WorktreeItemRow = Extract<HostSectionRow, { type: 'item' }>
export type RenderRow =
| HostSectionRow
| { type: 'lineage-group'; key: string; rows: WorktreeItemRow[] }
export function getRenderRowKey(row: RenderRow): string {
export function getRenderRowKey(
row: RenderRow,
defaultHostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID
): string {
if (row.type === 'host-header') {
return `host:${row.hostId}`
}
@@ -28,7 +33,7 @@ export function getRenderRowKey(row: RenderRow): string {
return `pending:${row.creationId}`
}
if (row.type === 'folder-workspace') {
const hostId = getFolderWorkspaceHostId(row.folderWorkspace, row.projectGroup, 'local')
const hostId = getFolderWorkspaceHostId(row.folderWorkspace, row.projectGroup, defaultHostId)
return `folder-workspace:${composeWorktreeHostIdentity(
hostId,
folderWorkspaceKey(row.folderWorkspace.id)
@@ -6,7 +6,11 @@ import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { AppState } from '@/store/types'
import { getFolderPathStatusRouteOptionsForRows } from './host-filtering'
import {
getFolderPathStatusRouteOptionsForHost,
getFolderPathStatusRouteOptionsForRows
} from './host-filtering'
import { getFolderWorkspaceHostIdFromGroups } from '../../../../../../shared/folder-workspace-host'
type FolderPathStatusRequest = Parameters<AppState['fetchFolderWorkspacePathStatus']>[0]
@@ -56,22 +60,17 @@ export function useFolderWorkspacePathStatusRows(args: {
const folderPathStatusCacheExpiryTick = useFolderWorkspacePathStatusCacheExpiryTick(
folderWorkspacePathStatuses
)
const projectGroupByIdForFolderPathStatus = useMemo(
() => new Map(projectGroups.map((group) => [group.id, group])),
[projectGroups]
)
const folderWorkspaceByIdForFolderPathStatus = useMemo(
() => new Map(folderWorkspaces.map((workspace) => [workspace.id, workspace])),
[folderWorkspaces]
)
const getFolderPathStatusRouteOptions = useCallback(
(request: FolderPathStatusRequest) =>
(
request: FolderPathStatusRequest,
scope: { projectGroup?: ProjectGroup; folderWorkspace?: FolderWorkspace } = {}
) =>
getFolderPathStatusRouteOptionsForRows({
request,
projectGroupsById: projectGroupByIdForFolderPathStatus,
folderWorkspacesById: folderWorkspaceByIdForFolderPathStatus
projectGroup: scope.projectGroup,
folderWorkspace: scope.folderWorkspace
}),
[folderWorkspaceByIdForFolderPathStatus, projectGroupByIdForFolderPathStatus]
[]
)
useEffect(() => {
const requests = new Map<
@@ -84,13 +83,15 @@ export function useFolderWorkspacePathStatusRows(args: {
for (const group of projectGroups) {
if (group.parentPath) {
const request = { scope: 'project-group' as const, projectGroupId: group.id }
const options = getFolderPathStatusRouteOptions(request)
const options = getFolderPathStatusRouteOptions(request, { projectGroup: group })
requests.set(getFolderWorkspacePathStatusCacheKey(request, options), { request, options })
}
}
for (const workspace of folderWorkspaces) {
const request = { scope: 'folder-workspace' as const, folderWorkspaceId: workspace.id }
const options = getFolderPathStatusRouteOptions(request)
const options = getFolderPathStatusRouteOptionsForHost(
getFolderWorkspaceHostIdFromGroups(workspace, projectGroups, 'local')
)
requests.set(getFolderWorkspacePathStatusCacheKey(request, options), { request, options })
}
for (const { request, options } of requests.values()) {
@@ -107,8 +108,11 @@ export function useFolderWorkspacePathStatusRows(args: {
projectGroups
])
const getCachedFolderWorkspacePathStatus = useCallback(
(request: FolderPathStatusRequest) => {
const options = getFolderPathStatusRouteOptions(request)
(
request: FolderPathStatusRequest,
scope?: { projectGroup?: ProjectGroup; folderWorkspace?: FolderWorkspace }
) => {
const options = getFolderPathStatusRouteOptions(request, scope)
const cacheKey = getFolderWorkspacePathStatusCacheKey(request, options)
// Why: don't let an expired negative status keep folder workspaces disabled while a refresh is in flight.
void folderWorkspacePathStatuses[cacheKey]
@@ -85,6 +85,24 @@ describe('worktree list folder reveal', () => {
})
})
it('resolves identical folder ids to the requested execution host', () => {
const local = makeFolderWorkspace({ executionHostId: 'local', name: 'Local folder' })
const runtime = makeFolderWorkspace({
executionHostId: 'runtime:env-1',
name: 'Runtime folder'
})
expect(
getKnownSidebarWorktreeById(
folderWorkspaceKey(runtime.id),
new Map(),
[local, runtime],
[],
'runtime:env-1'
)
).toMatchObject({ hostId: 'runtime:env-1', displayName: 'Runtime folder' })
})
it('keeps pending reveals alive for folder workspaces missing from raw git worktrees', () => {
const folderWorkspace = makeFolderWorkspace()
const gitWorktree = makeWorktree('git-worktree-1')
@@ -1,7 +1,10 @@
import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { WorkspaceStatusDefinition, Worktree } from '../../../../../../shared/worktree/types'
import { folderWorkspaceToWorktree } from '../../../../../../shared/folder-workspace-worktree'
import {
folderWorkspaceToWorktree,
folderWorkspaceToWorktreeForHost
} from '../../../../../../shared/folder-workspace-worktree'
import { parseWorkspaceKey } from '../../../../../../shared/workspace-scope'
import { getProjectGroupHeaderKey } from '../grouping/group-keys'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
@@ -11,13 +14,21 @@ import { getFolderWorkspaceHostId } from '../../folder-workspace-host-id'
function findFolderWorkspaceByKey(
worktreeId: string,
folderWorkspaces: readonly FolderWorkspace[]
folderWorkspaces: readonly FolderWorkspace[],
executionHostId?: ExecutionHostId | null
): FolderWorkspace | null {
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type !== 'folder') {
return null
}
return folderWorkspaces.find((workspace) => workspace.id === scope.folderWorkspaceId) ?? null
return (
folderWorkspaces.find(
(workspace) =>
workspace.id === scope.folderWorkspaceId &&
(!executionHostId ||
getFolderWorkspaceHostId(workspace, undefined, executionHostId) === executionHostId)
) ?? null
)
}
export function getKnownSidebarWorktreeById(
@@ -35,8 +46,13 @@ export function getKnownSidebarWorktreeById(
if (worktree) {
return worktree
}
const folderWorkspace = findFolderWorkspaceByKey(worktreeId, folderWorkspaces)
return folderWorkspace ? folderWorkspaceToWorktree(folderWorkspace) : null
const folderWorkspace = findFolderWorkspaceByKey(worktreeId, folderWorkspaces, executionHostId)
if (!folderWorkspace) {
return null
}
return executionHostId
? folderWorkspaceToWorktreeForHost(folderWorkspace, executionHostId)
: folderWorkspaceToWorktree(folderWorkspace)
}
export function sidebarWorkspaceStillExists(
@@ -54,7 +70,7 @@ export function sidebarWorkspaceStillExists(
) {
return true
}
return findFolderWorkspaceByKey(worktreeId, folderWorkspaces) !== null
return findFolderWorkspaceByKey(worktreeId, folderWorkspaces, executionHostId) !== null
}
export function getFolderWorkspaceRevealGroupKeys(
@@ -79,4 +79,23 @@ describe('host-qualified reveal lookup finds folder workspaces', () => {
)
).toBe(-1)
})
it('selects the requested host when folder ids collide', () => {
const rows: RenderRow[] = ['local', 'runtime:env-1'].map((executionHostId) => ({
type: 'folder-workspace',
key: `folder-workspace:${executionHostId}`,
folderWorkspace: { ...FOLDER_WORKSPACE, executionHostId },
projectGroup: { ...PROJECT_GROUP, executionHostId },
depth: 0,
groupDepth: 0
})) as RenderRow[]
expect(
findPreferredRenderRowIndexForWorktreeIdentity(
rows,
{ id: folderWorkspaceKey(FOLDER_WORKSPACE.id), hostId: 'runtime:env-1' },
'single-location'
)
).toBe(1)
})
})
@@ -6,6 +6,7 @@ import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-
import type { RenderRow } from '../listing/render-row'
import type { PinnedWorktreeDisplayPolicy } from '../grouping/row-types'
import { isPinnedWorktreeRow, type WorktreeItemRow } from '../listing/renderable-rows'
import { getFolderWorkspaceHostId } from '../../folder-workspace-host-id'
export function getRenderRowSidebarKey(row: RenderRow): string | null {
if (row.type === 'header') {
@@ -58,7 +59,12 @@ export function renderRowContainsWorktree(
return false
}
if (row.type === 'folder-workspace') {
return folderWorkspaceKey(row.folderWorkspace.id) === worktreeId
return (
folderWorkspaceKey(row.folderWorkspace.id) === worktreeId &&
(executionHostId === undefined ||
getFolderWorkspaceHostId(row.folderWorkspace, row.projectGroup, executionHostId) ===
executionHostId)
)
}
if (row.type === 'lineage-group') {
return row.rows.some((item) => itemMatchesWorktree(item, worktreeId, executionHostId))
@@ -112,7 +118,12 @@ export function findPreferredRenderRowIndexForWorktreeIdentity(
// Why: host-qualified reveals are emitted for folder workspaces too, and a
// walker that only knows item rows returns -1 so the reveal never lands.
if (row.type === 'folder-workspace') {
if (folderWorkspaceKey(row.folderWorkspace.id) === worktree.id) {
if (
folderWorkspaceKey(row.folderWorkspace.id) === worktree.id &&
(!worktree.hostId ||
getFolderWorkspaceHostId(row.folderWorkspace, row.projectGroup, worktree.hostId) ===
worktree.hostId)
) {
return index
}
continue
@@ -50,10 +50,13 @@ export type SectionHeaderRowContext = {
dragOverStatus: WorkspaceStatus | null
pinDragOver: boolean
headerDrag: WorktreeSidebarHeaderDrag
getCachedFolderWorkspacePathStatus: (request: {
scope: 'project-group'
projectGroupId: string
}) => FolderWorkspacePathStatus | null
getCachedFolderWorkspacePathStatus: (
request: {
scope: 'project-group'
projectGroupId: string
},
scope: { projectGroup: ProjectGroup }
) => FolderWorkspacePathStatus | null
toggleGroupWithScrollAnchor: (groupKey: string) => void
projectActions: RepoHeaderProjectActions
onRenameProjectGroup: (groupId: string, currentName: string) => void
@@ -158,10 +161,13 @@ export function renderWorktreeSectionHeaderRow(args: {
? row.projectGroup
: null
const projectGroupPathStatus = folderBackedProjectGroup
? ctx.getCachedFolderWorkspacePathStatus({
scope: 'project-group',
projectGroupId: folderBackedProjectGroup.id
})
? ctx.getCachedFolderWorkspacePathStatus(
{
scope: 'project-group',
projectGroupId: folderBackedProjectGroup.id
},
{ projectGroup: folderBackedProjectGroup }
)
: null
const isHeaderCollapsed = ctx.collapsedGroups.has(row.key)
// Why: repo/project/status/pinned share compact section chrome; flat "All" stays a simple label.
@@ -10,7 +10,7 @@ import type { Worktree } from '../../../../../../shared/worktree/types'
import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity'
import type { FolderWorkspacePathStatus } from '../../../../../../shared/folder-workspace-path-status'
import { isConfirmedStaleFolderPathStatus } from '../../../../../../shared/folder-workspace-path-status'
import { folderWorkspaceToWorktree } from '../../../../../../shared/folder-workspace-worktree'
import { folderWorkspaceToWorktreeForHost } from '../../../../../../shared/folder-workspace-worktree'
import WorktreeCard from '../../WorktreeCard'
import type { WorktreeGroupBy } from '../grouping/row-types'
import { getVirtualRowTransform } from '../viewport/virtual-rows'
@@ -19,8 +19,11 @@ import { getFolderWorkspaceCardPrDisplay } from '../../folder-workspace-card-pr-
import { FolderPathStatusIndicator } from './FolderPathStatusIndicator'
import type { FolderWorkspaceItemRow } from '../listing/renderable-rows'
import { getWorktreeOptionId } from './option-dom'
import { getFolderWorkspaceHostId } from '../../folder-workspace-host-id'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
export type FolderWorkspaceRowContext = {
defaultHostId: ExecutionHostId
groupBy: WorktreeGroupBy
newCardStyle: boolean
settings: AppState['settings']
@@ -33,10 +36,16 @@ export type FolderWorkspaceRowContext = {
workspaceLineageByChildKey: Record<string, WorkspaceLineage>
prCache: AppState['prCache'] | null
hostedReviewCache: AppState['hostedReviewCache'] | null
getCachedFolderWorkspacePathStatus: (request: {
scope: 'folder-workspace'
folderWorkspaceId: string
}) => FolderWorkspacePathStatus | null
getCachedFolderWorkspacePathStatus: (
request: {
scope: 'folder-workspace'
folderWorkspaceId: string
},
scope: {
projectGroup: FolderWorkspaceItemRow['projectGroup']
folderWorkspace: FolderWorkspaceItemRow['folderWorkspace']
}
) => FolderWorkspacePathStatus | null
onSelectionGesture: (event: React.MouseEvent<HTMLElement>, worktree: Worktree) => boolean
onContextMenuSelect: (
event: React.MouseEvent<HTMLElement>,
@@ -58,12 +67,16 @@ export function renderFolderWorkspaceVirtualRow(args: {
measureVirtualRowElement: (element: HTMLDivElement | null) => void
}): React.JSX.Element {
const { ctx, row, vItem } = args
const folderWorktree = folderWorkspaceToWorktree(row.folderWorkspace)
const hostId = getFolderWorkspaceHostId(row.folderWorkspace, row.projectGroup, ctx.defaultHostId)
const folderWorktree = folderWorkspaceToWorktreeForHost(row.folderWorkspace, hostId)
const folderWorktreeIdentity = getWorktreeHostIdentity(folderWorktree)
const pathStatus = ctx.getCachedFolderWorkspacePathStatus({
scope: 'folder-workspace',
folderWorkspaceId: row.folderWorkspace.id
})
const pathStatus = ctx.getCachedFolderWorkspacePathStatus(
{
scope: 'folder-workspace',
folderWorkspaceId: row.folderWorkspace.id
},
{ projectGroup: row.projectGroup, folderWorkspace: row.folderWorkspace }
)
const activationDisabled =
pathStatus?.exists === false &&
(isConfirmedStaleFolderPathStatus(pathStatus) || pathStatus.reason === 'ambiguous-connection')
@@ -131,6 +131,7 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr
const virtualization = useWorktreeListVirtualizer({
renderRows,
defaultHostId: props.defaultHostId,
firstHeaderIndex,
scrollRef,
scrollOffsetRef,
@@ -169,6 +170,7 @@ export const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktr
const { virtualItems, measureVirtualRowElement } = useVirtualRowMeasurementSync({
renderRows,
defaultHostId: props.defaultHostId,
virtualization,
scrollRef,
scrollOffsetRef,
@@ -14,6 +14,7 @@ import {
import { getRenderRowKey } from '../listing/render-row'
import type { RenderRow } from '../listing/render-row'
import type { WorktreeListVirtualizer } from './use-virtualizer'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
const recordKeyCountCache = new WeakMap<Record<string, unknown>, number>()
@@ -31,6 +32,7 @@ export function countRecordKeysByReference(record: Record<string, unknown>): num
// sticky-header slots the render pass reads out of refs.
export function useVirtualRowMeasurementSync(args: {
renderRows: RenderRow[]
defaultHostId: ExecutionHostId
virtualization: WorktreeListVirtualizer
scrollRef: React.RefObject<HTMLDivElement | null>
scrollOffsetRef: React.MutableRefObject<number>
@@ -40,6 +42,7 @@ export function useVirtualRowMeasurementSync(args: {
}) {
const {
renderRows,
defaultHostId,
virtualization,
scrollRef,
scrollOffsetRef,
@@ -51,10 +54,13 @@ export function useVirtualRowMeasurementSync(args: {
const prCacheLen = useAppStore((s) => countRecordKeysByReference(s.prCache))
const issueCacheLen = useAppStore((s) => countRecordKeysByReference(s.issueCache))
const renderRowKeySignature = useMemo(
() => renderRows.map(getRenderRowKey).join('\n'),
[renderRows]
() => renderRows.map((row) => getRenderRowKey(row, defaultHostId)).join('\n'),
[defaultHostId, renderRows]
)
const activeRenderRowKeys = useMemo(
() => new Set(renderRows.map((row) => getRenderRowKey(row, defaultHostId))),
[defaultHostId, renderRows]
)
const activeRenderRowKeys = useMemo(() => new Set(renderRows.map(getRenderRowKey)), [renderRows])
const lineageRowRekeys = useMemo(() => buildLineageRowRekeyMap(renderRows), [renderRows])
const totalSize = virtualizer.getTotalSize()
const virtualItems = virtualizer.getVirtualItems()
@@ -14,6 +14,7 @@ import {
} from './virtual-rows'
import { getRenderRowKey } from '../listing/render-row'
import type { RenderRow } from '../listing/render-row'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import { WORKTREE_SIDEBAR_REVEAL_TOP_INSET } from '../../worktree-sidebar-reveal'
import {
shouldAdjustWorktreeSidebarMeasuredRowScroll,
@@ -26,12 +27,13 @@ export type WorktreeListVirtualizer = ReturnType<typeof useWorktreeListVirtualiz
// from writing a stale height into the wrong slot.
export function useWorktreeListVirtualizer(args: {
renderRows: RenderRow[]
defaultHostId: ExecutionHostId
firstHeaderIndex: number
scrollRef: React.RefObject<HTMLDivElement | null>
scrollOffsetRef: React.MutableRefObject<number>
suppressMeasurementAdjustmentUntilRef: React.MutableRefObject<number>
}) {
const { renderRows, firstHeaderIndex, scrollRef, scrollOffsetRef } = args
const { renderRows, defaultHostId, firstHeaderIndex, scrollRef, scrollOffsetRef } = args
const stickyHeaderIndexes = useMemo(() => getStickyHeaderIndexes(renderRows), [renderRows])
const activeStickyHeaderIndexRef = useRef<number | null>(null)
const activeStickyHostIndexRef = useRef<number | null>(null)
@@ -43,17 +45,17 @@ export function useWorktreeListVirtualizer(args: {
if (!row) {
return `__stale_${index}`
}
return getRenderRowKey(row)
return getRenderRowKey(row, defaultHostId)
},
[renderRows]
[defaultHostId, renderRows]
)
const getExpectedVirtualRowKey = useCallback(
(element: Element) => {
const index = getVirtualRowIndex(element)
const row = index === null ? undefined : renderRows[index]
return row ? getRenderRowKey(row) : null
return row ? getRenderRowKey(row, defaultHostId) : null
},
[renderRows]
[defaultHostId, renderRows]
)
const isCurrentVirtualRowElement = useCallback(
(element: Element) => {
@@ -136,6 +136,7 @@ export function buildWorktreeVirtualRowContext(args: BuildArgs): WorktreeVirtual
onCardDragEnd: runtime.clearWorktreeDrag
},
folderWorkspace: {
defaultHostId: props.defaultHostId,
groupBy: props.groupBy,
newCardStyle: args.newCardStyle,
settings: args.settings,
@@ -43,7 +43,9 @@ function itemStub(id: string): RenderRow {
return { type: 'item', key: id } as unknown as RenderRow
}
function folderRow(executionHostId: ExecutionHostId): RenderRow {
function folderRow(
executionHostId: ExecutionHostId
): Extract<RenderRow, { type: 'folder-workspace' }> {
return {
type: 'folder-workspace',
key: 'folder-workspace:same-id',
@@ -98,6 +100,15 @@ describe('getRenderRowKey', () => {
'folder-workspace:runtime:owner|folder:same-id'
)
})
it('uses the focused host for a legacy folder with no owner fields', () => {
const row = folderRow('local')
delete row.folderWorkspace.executionHostId
delete row.projectGroup.executionHostId
expect(getRenderRowKey(row, 'runtime:focused')).toBe(
'folder-workspace:runtime:focused|folder:same-id'
)
})
})
describe('getActiveStickyIndexesForScroll', () => {
@@ -1,7 +1,11 @@
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
import {
folderWorkspaceToWorktree,
folderWorkspaceToWorktreeForHost
} from '../../../../shared/folder-workspace-worktree'
import type { Worktree } from '../../../../shared/worktree/types'
import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
import type { HostSectionRow } from './host-section-rows'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { PINNED_GROUP_KEY } from './worktree-list/grouping/group-keys'
import type { PinnedWorktreeDisplayPolicy, WorktreeRow } from './worktree-list/grouping/row-types'
@@ -53,12 +57,19 @@ export function getRenderedWorktreesInSidebarOrder(
getPreferredWorktreeRows(itemRows, pinnedDisplayPolicy).map((row) => row.rowKey)
)
const renderedWorktrees: Worktree[] = []
let currentHostId: ExecutionHostId | undefined
for (const row of rows) {
if (row.type === 'item' && preferredRowKeys.has(row.rowKey)) {
if (row.type === 'host-header') {
currentHostId = row.hostId
} else if (row.type === 'item' && preferredRowKeys.has(row.rowKey)) {
renderedWorktrees.push(row.worktree)
} else if (row.type === 'folder-workspace') {
renderedWorktrees.push(folderWorkspaceToWorktree(row.folderWorkspace))
renderedWorktrees.push(
currentHostId
? folderWorkspaceToWorktreeForHost(row.folderWorkspace, currentHostId)
: folderWorkspaceToWorktree(row.folderWorkspace)
)
}
}
return renderedWorktrees
@@ -136,6 +136,26 @@ describe('catalog merge referential stability', () => {
expect(store.getState().folderWorkspaces[0]).toBe(firstEntry)
})
it('stamps a legacy folder from its runtime-owned project group', async () => {
const store = createTestStore()
store.setState({
projectGroups: [
{
...projectGroup,
connectionId: 'legacy-ssh',
executionHostId: 'runtime:env-1'
}
]
})
folderWorkspacesList.mockResolvedValue([
clone({ ...folderWorkspace, connectionId: 'legacy-ssh', executionHostId: undefined })
])
await store.getState().fetchFolderWorkspaces()
expect(store.getState().folderWorkspaces[0]?.executionHostId).toBe('runtime:env-1')
})
it('appends new entries and replaces changed ones while keeping order', async () => {
const store = createTestStore()
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../shared/project-group-types'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
type RuntimeEnvironmentCallRequest
} from '../../runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
import { createTestStore } from './store-test-helpers'
const folderWorkspacesGetPathStatus = vi.fn()
const runtimeEnvironmentCall = vi.fn()
function projectGroup(executionHostId: 'local' | 'runtime:env-1'): ProjectGroup {
return {
id: 'same-group',
name: 'Same group',
parentPath: executionHostId === 'local' ? '/local/group' : '/runtime/group',
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1,
executionHostId
}
}
function folderWorkspace(executionHostId: 'local' | 'runtime:env-1'): FolderWorkspace {
return {
id: 'same-folder',
projectGroupId: 'same-group',
name: 'Same folder',
folderPath: executionHostId === 'local' ? '/local/folder' : '/runtime/folder',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 0,
createdAt: 1,
updatedAt: 1,
executionHostId
}
}
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
folderWorkspacesGetPathStatus.mockReset()
folderWorkspacesGetPathStatus.mockResolvedValue({ path: '/local/folder', exists: true })
runtimeEnvironmentCall.mockReset()
runtimeEnvironmentCall.mockResolvedValue({
id: 'rpc-runtime-folder-status',
ok: true,
result: { status: { path: '/runtime/folder', exists: true } },
_meta: { runtimeId: 'runtime-remote' }
})
vi.stubGlobal('window', {
api: {
folderWorkspaces: { getPathStatus: folderWorkspacesGetPathStatus },
runtimeEnvironments: {
call: (args: RuntimeEnvironmentCallRequest) =>
createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
}
}
})
})
describe('folder path status host identity', () => {
it('keeps same-id local and runtime snapshots separate', async () => {
const request = { scope: 'folder-workspace' as const, folderWorkspaceId: 'same-folder' }
const store = createTestStore()
store.setState({
projectGroups: [projectGroup('local'), projectGroup('runtime:env-1')],
folderWorkspaces: [folderWorkspace('local'), folderWorkspace('runtime:env-1')]
})
await store
.getState()
.fetchFolderWorkspacePathStatus(request, { force: true, runtimeEnvironmentId: null })
await store
.getState()
.fetchFolderWorkspacePathStatus(request, { force: true, runtimeEnvironmentId: 'env-1' })
expect(
store.getState().getFreshFolderWorkspacePathStatus(request, { runtimeEnvironmentId: null })
).toEqual({ path: '/local/folder', exists: true })
expect(
store.getState().getFreshFolderWorkspacePathStatus(request, { runtimeEnvironmentId: 'env-1' })
).toEqual({ path: '/runtime/folder', exists: true })
})
})
+54 -39
View File
@@ -88,6 +88,7 @@ import {
getRepoExecutionHostId,
isRuntimeOwnedSshTargetId,
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostId,
parseExecutionHostId,
toRuntimeExecutionHostId,
toSshExecutionHostId,
@@ -100,6 +101,7 @@ import {
} from '../../../../shared/worktree/visibility-sources'
import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { getFolderWorkspaceHostIdFromGroups } from '../../../../shared/folder-workspace-host'
import { formatFolderWorkspaceCreateError } from '../../lib/folder-workspace-path-status'
import { getEnvironmentSshStateGeneration } from './runtime-environment-ssh'
import { getRuntimeEnvironmentConnectionGeneration } from './runtime-status'
@@ -1120,9 +1122,12 @@ function mergeProjectCompatibilityForHostRepoChange({
})
}
function getProjectGroupHostId(group: Pick<ProjectGroup, 'connectionId' | 'executionHostId'>) {
if (group.executionHostId) {
return group.executionHostId
function getProjectGroupHostId(
group: Pick<ProjectGroup, 'connectionId' | 'executionHostId'>
): ExecutionHostId {
const executionHostId = normalizeExecutionHostId(group.executionHostId)
if (executionHostId) {
return executionHostId
}
return group.connectionId ? toSshExecutionHostId(group.connectionId) : LOCAL_EXECUTION_HOST_ID
}
@@ -1158,32 +1163,18 @@ function mergeFetchedProjectGroupsForHost(
)
}
function getFolderWorkspaceHostId(
function getCatalogFolderWorkspaceHostId(
workspace: FolderWorkspace,
projectGroups: readonly ProjectGroup[]
): ExecutionHostId {
const explicitHostId = parseExecutionHostId(workspace.executionHostId)?.id
if (explicitHostId) {
return explicitHostId
}
if (workspace.connectionId) {
return toSshExecutionHostId(workspace.connectionId)
}
const matchingHosts = new Set(
projectGroups
.filter((group) => group.id === workspace.projectGroupId)
.map(getProjectGroupHostId)
)
return matchingHosts.size === 1
? ([...matchingHosts][0] as ExecutionHostId)
: LOCAL_EXECUTION_HOST_ID
return getFolderWorkspaceHostIdFromGroups(workspace, projectGroups, LOCAL_EXECUTION_HOST_ID)
}
function getFolderWorkspaceHostIdentity(
workspace: FolderWorkspace,
projectGroups: readonly ProjectGroup[]
): string {
return JSON.stringify([getFolderWorkspaceHostId(workspace, projectGroups), workspace.id])
return JSON.stringify([getCatalogFolderWorkspaceHostId(workspace, projectGroups), workspace.id])
}
function getFolderWorkspaceUpdateIdentity(
@@ -1208,7 +1199,7 @@ function mergeFetchedFolderWorkspacesForHost({
fetched.map((workspace) => getFolderWorkspaceHostIdentity(workspace, projectGroups))
)
const preserved = previous.filter((workspace) => {
const existingHostId = getFolderWorkspaceHostId(workspace, projectGroups)
const existingHostId = getCatalogFolderWorkspaceHostId(workspace, projectGroups)
return (
!catalogOwnsHost(hostId, existingHostId) ||
fetchedIdentities.has(getFolderWorkspaceHostIdentity(workspace, projectGroups))
@@ -1247,13 +1238,13 @@ function getFolderWorkspaceCatalogReplacementIdentities(
const replacedIdentities = new Set(
catalog.folderWorkspaces.map((workspace) =>
getFolderWorkspaceUpdateIdentity(
getFolderWorkspaceHostId(workspace, projectGroups),
getCatalogFolderWorkspaceHostId(workspace, projectGroups),
workspace.id
)
)
)
for (const workspace of currentFolderWorkspaces) {
const hostId = getFolderWorkspaceHostId(workspace, projectGroups)
const hostId = getCatalogFolderWorkspaceHostId(workspace, projectGroups)
if (catalogOwnsHost(catalog.hostId, hostId)) {
replacedIdentities.add(getFolderWorkspaceUpdateIdentity(hostId, workspace.id))
}
@@ -1461,7 +1452,7 @@ function folderWorkspaceWithFetchedOwner(
executionHostId:
target.kind === 'environment'
? getRuntimeTargetHostId(target)
: getFolderWorkspaceHostId(workspace, projectGroups)
: getCatalogFolderWorkspaceHostId(workspace, projectGroups)
}
}
@@ -1510,14 +1501,14 @@ async function reconcileFailedFolderWorkspaceUpdate(args: {
folderWorkspaces: refreshed
? state.folderWorkspaces.map((workspace) =>
workspace.id === args.folderWorkspaceId &&
getFolderWorkspaceHostId(workspace, state.projectGroups) === args.ownerHostId
getCatalogFolderWorkspaceHostId(workspace, state.projectGroups) === args.ownerHostId
? mergeFolderWorkspaceUpdateResponse(workspace, refreshed, latestFields)
: workspace
)
: state.folderWorkspaces.filter(
(workspace) =>
workspace.id !== args.folderWorkspaceId ||
getFolderWorkspaceHostId(workspace, state.projectGroups) !== args.ownerHostId
getCatalogFolderWorkspaceHostId(workspace, state.projectGroups) !== args.ownerHostId
),
...(folderWorkspaceUpdateInvalidatesPathStatus(latestFields) || !refreshed
? { folderWorkspacePathStatuses: {} }
@@ -1671,7 +1662,8 @@ async function fetchRuntimeAddProjectPathStatus(args: {
function getFolderWorkspaceStatusRequestSnapshot(
state: Pick<AppState, 'projectGroups' | 'folderWorkspaces' | 'repos' | 'sshConnectionStates'>,
request: FolderWorkspacePathStatusRequest
request: FolderWorkspacePathStatusRequest,
options?: FolderWorkspacePathStatusRouteOptions
): string | null {
if (request.scope === 'path') {
const candidateRepos = state.repos.filter((repo) =>
@@ -1704,18 +1696,36 @@ function getFolderWorkspaceStatusRequestSnapshot(
)
}
const scope =
const runtimeHostId = options?.runtimeEnvironmentId
? toRuntimeExecutionHostId(options.runtimeEnvironmentId)
: null
const excludeRuntimeHosts = options?.runtimeEnvironmentId === null
const matchesRouteHost = (hostId: ExecutionHostId): boolean =>
runtimeHostId
? hostId === runtimeHostId
: !excludeRuntimeHosts || parseExecutionHostId(hostId)?.kind !== 'runtime'
const scopeCandidates =
request.scope === 'project-group'
? state.projectGroups.find((group) => group.id === request.projectGroupId)
: state.folderWorkspaces.find((workspace) => workspace.id === request.folderWorkspaceId)
? state.projectGroups.filter((group) => group.id === request.projectGroupId)
: state.folderWorkspaces.filter((workspace) => workspace.id === request.folderWorkspaceId)
const scope =
scopeCandidates.find((candidate) =>
'parentPath' in candidate
? matchesRouteHost(getProjectGroupHostId(candidate))
: matchesRouteHost(getCatalogFolderWorkspaceHostId(candidate, state.projectGroups))
) ?? scopeCandidates[0]
const matchingProjectGroups =
scope && 'projectGroupId' in scope
? state.projectGroups.filter((group) => group.id === scope.projectGroupId)
: []
const projectGroup =
request.scope === 'project-group'
? scope && 'parentPath' in scope
? scope
: null
: scope && 'projectGroupId' in scope
? state.projectGroups.find((group) => group.id === scope.projectGroupId)
: null
: (matchingProjectGroups.find((group) => matchesRouteHost(getProjectGroupHostId(group))) ??
matchingProjectGroups[0] ??
null)
const folderPath =
request.scope === 'project-group'
? scope && 'parentPath' in scope
@@ -1791,9 +1801,10 @@ function getFreshFolderWorkspacePathStatusFromCache(args: {
function getFolderWorkspacePathStatusRequestSnapshotForRead(
state: AppState,
request: FolderWorkspacePathStatusRequest
request: FolderWorkspacePathStatusRequest,
options?: FolderWorkspacePathStatusRouteOptions
): string | null {
return getFolderWorkspaceStatusRequestSnapshot(state, request)
return getFolderWorkspaceStatusRequestSnapshot(state, request, options)
}
export type RepoSlice = {
@@ -2626,13 +2637,17 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
const state = get()
const cacheKey = get().getFolderWorkspacePathStatusCacheKey(request, options)
const cached = state.folderWorkspacePathStatuses[cacheKey]
const requestSnapshot = getFolderWorkspacePathStatusRequestSnapshotForRead(state, request)
const requestSnapshot = getFolderWorkspacePathStatusRequestSnapshotForRead(
state,
request,
options
)
return getFreshFolderWorkspacePathStatusFromCache({ entry: cached, requestSnapshot })
},
fetchFolderWorkspacePathStatus: async (request, options) => {
const cacheKey = get().getFolderWorkspacePathStatusCacheKey(request, options)
const requestSnapshot = getFolderWorkspaceStatusRequestSnapshot(get(), request)
const requestSnapshot = getFolderWorkspaceStatusRequestSnapshot(get(), request, options)
const cached = get().folderWorkspacePathStatuses[cacheKey]
const freshCachedStatus = getFreshFolderWorkspacePathStatusFromCache({
entry: cached,
@@ -2659,7 +2674,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
set((state) => ({
folderWorkspacePathStatuses:
requestSnapshot !== null &&
getFolderWorkspaceStatusRequestSnapshot(state, request) === requestSnapshot
getFolderWorkspaceStatusRequestSnapshot(state, request, options) === requestSnapshot
? {
...state.folderWorkspacePathStatuses,
[cacheKey]: { status, checkedAt: Date.now(), requestSnapshot }
@@ -2922,7 +2937,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
set((s) => ({
folderWorkspaces: s.folderWorkspaces.map((workspace) =>
workspace.id === folderWorkspaceId &&
getFolderWorkspaceHostId(workspace, s.projectGroups) === ownerHostId
getCatalogFolderWorkspaceHostId(workspace, s.projectGroups) === ownerHostId
? mergeFolderWorkspaceUpdateResponse(workspace, updated, latestFields, {
rejectOlderResponse: catalogChanged
})
+57
View File
@@ -0,0 +1,57 @@
import {
normalizeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from './execution-host'
import type { FolderWorkspace } from './folder-workspace-types'
import type { ProjectGroup } from './project-group-types'
export function getFolderWorkspaceHostId(
folderWorkspace: Pick<FolderWorkspace, 'connectionId' | 'executionHostId'>,
projectGroup: Pick<ProjectGroup, 'connectionId' | 'executionHostId'> | null | undefined,
defaultHostId: ExecutionHostId
): ExecutionHostId {
const executionHostId =
normalizeExecutionHostId(folderWorkspace.executionHostId) ??
normalizeExecutionHostId(projectGroup?.executionHostId)
if (executionHostId) {
return executionHostId
}
const connectionId = folderWorkspace.connectionId ?? projectGroup?.connectionId
return connectionId ? toSshExecutionHostId(connectionId) : defaultHostId
}
export function getFolderWorkspaceHostIdFromGroups(
folderWorkspace: Pick<FolderWorkspace, 'connectionId' | 'executionHostId' | 'projectGroupId'>,
projectGroups: readonly Pick<ProjectGroup, 'id' | 'connectionId' | 'executionHostId'>[],
defaultHostId: ExecutionHostId
): ExecutionHostId {
const matchingGroups = projectGroups.filter(
(group) => group.id === folderWorkspace.projectGroupId
)
if (matchingGroups.length <= 1) {
return getFolderWorkspaceHostId(folderWorkspace, matchingGroups[0], defaultHostId)
}
const folderHostId = normalizeExecutionHostId(folderWorkspace.executionHostId)
if (folderHostId) {
return folderHostId
}
const groupExecutionHostIds = new Set(
matchingGroups
.map((group) => normalizeExecutionHostId(group.executionHostId))
.filter((hostId): hostId is ExecutionHostId => hostId !== null)
)
if (groupExecutionHostIds.size === 1) {
return [...groupExecutionHostIds][0]!
}
if (folderWorkspace.connectionId) {
return toSshExecutionHostId(folderWorkspace.connectionId)
}
const groupConnectionHostIds = new Set(
matchingGroups
.map((group) => group.connectionId)
.filter((connectionId): connectionId is string => Boolean(connectionId))
.map(toSshExecutionHostId)
)
return groupConnectionHostIds.size === 1 ? [...groupConnectionHostIds][0]! : defaultHostId
}
+16 -1
View File
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
import type { FolderWorkspace } from './folder-workspace-types'
import { folderWorkspaceToWorktree } from './folder-workspace-worktree'
import {
folderWorkspaceToWorktree,
folderWorkspaceToWorktreeForHost
} from './folder-workspace-worktree'
function makeFolderWorkspace(overrides: Partial<FolderWorkspace> = {}): FolderWorkspace {
return {
@@ -143,6 +146,18 @@ describe('folderWorkspaceToWorktree', () => {
})
})
it('uses a resolved group owner over legacy folder identity', () => {
const worktree = folderWorkspaceToWorktreeForHost(
makeFolderWorkspace({ connectionId: 'legacy-ssh' }),
'runtime:env-1'
)
expect(worktree).toMatchObject({
hostId: 'runtime:env-1',
runtimeOwnerEnvironmentId: 'env-1'
})
})
it('keeps review-style tasks attached only to the folder workspace record', () => {
const githubPr = folderWorkspaceToWorktree(
makeFolderWorkspace({
+17 -1
View File
@@ -3,11 +3,16 @@ import type { Worktree } from './worktree/types'
import { folderWorkspaceKey } from './workspace-scope'
import { parseExecutionHostId, toSshExecutionHostId } from './execution-host'
import { normalizeWorkspaceCreatorProvenance } from './workspace-creator-provenance'
import type { ExecutionHostId } from './execution-host'
export function folderWorkspaceToWorktree(folderWorkspace: FolderWorkspace): Worktree {
function projectFolderWorkspaceToWorktree(
folderWorkspace: FolderWorkspace,
resolvedHostId?: ExecutionHostId
): Worktree {
const linkedTask = folderWorkspace.linkedTask
const creatorProvenance = normalizeWorkspaceCreatorProvenance(folderWorkspace.creatorProvenance)
const hostId =
resolvedHostId ??
folderWorkspace.executionHostId ??
(folderWorkspace.connectionId ? toSshExecutionHostId(folderWorkspace.connectionId) : 'local')
const parsedHost = parseExecutionHostId(hostId)
@@ -54,3 +59,14 @@ export function folderWorkspaceToWorktree(folderWorkspace: FolderWorkspace): Wor
: {})
}
}
export function folderWorkspaceToWorktree(folderWorkspace: FolderWorkspace): Worktree {
return projectFolderWorkspaceToWorktree(folderWorkspace)
}
export function folderWorkspaceToWorktreeForHost(
folderWorkspace: FolderWorkspace,
hostId: ExecutionHostId
): Worktree {
return projectFolderWorkspaceToWorktree(folderWorkspace, hostId)
}
+59 -34
View File
@@ -4,25 +4,37 @@ import { expect } from './helpers/orca-app'
export async function expectFolderWorkspaceSidebarGrouping(
page: Page,
testInfo: TestInfo,
args: { folderPath: string; hostId: `runtime:${string}` }
args: {
folderPath: string
hostId: `runtime:${string}`
localFolderPath: string
screenshotPrefix: string
}
): Promise<void> {
const target = await page.evaluate(({ folderPath, hostId }) => {
const targets = await page.evaluate(({ folderPath, hostId, localFolderPath }) => {
const store = window.__store
if (!store) {
throw new Error('Renderer store unavailable')
}
const workspace = store
const runtimeWorkspace = store
.getState()
.folderWorkspaces.find(
(candidate) => candidate.folderPath === folderPath && candidate.executionHostId === hostId
)
if (!workspace) {
const localWorkspace = store
.getState()
.folderWorkspaces.find(
(candidate) =>
candidate.folderPath === localFolderPath && candidate.executionHostId === 'local'
)
if (!runtimeWorkspace || !localWorkspace) {
throw new Error('Runtime folder unavailable for sidebar grouping')
}
store.getState().setVisibleWorkspaceHostIds(['local', hostId])
return { id: workspace.id, name: workspace.name, executionHostId: workspace.executionHostId }
return { runtimeWorkspace, localWorkspace }
}, args)
expect(target.executionHostId).toBe(args.hostId)
expect(targets.runtimeWorkspace.executionHostId).toBe(args.hostId)
expect(targets.localWorkspace.executionHostId).toBe('local')
const cases = [
{ id: 'repo', label: 'Project' },
@@ -30,8 +42,9 @@ export async function expectFolderWorkspaceSidebarGrouping(
{ id: 'pr-status', label: 'PR' },
{ id: 'none', label: 'None' }
] as const
const identity = `${args.hostId}|folder:${target.id}`
const placements: { groupBy: string; hostId: string; identity: string }[] = []
const runtimeIdentity = `${args.hostId}|folder:${targets.runtimeWorkspace.id}`
const localIdentity = `local|folder:${targets.localWorkspace.id}`
const placements: { groupBy: string; runtimeHostId: string; localHostId: string }[] = []
for (const groupBy of cases) {
await page.evaluate((groupById) => window.__store?.getState().setGroupBy(groupById), groupBy.id)
@@ -59,36 +72,48 @@ export async function expectFolderWorkspaceSidebarGrouping(
executionHostId: hostId
})
},
{ folderWorkspaceId: target.id, hostId: args.hostId }
{ folderWorkspaceId: targets.runtimeWorkspace.id, hostId: args.hostId }
)
const folderRow = page.locator(`[data-worktree-host-identity="${identity}"]`)
await expect(folderRow).toBeVisible()
await expect(folderRow).toContainText(target.name)
const placement = await page.evaluate((targetIdentity) => {
const rows = [
...document.querySelectorAll<HTMLElement>('[data-worktree-sidebar] [data-index]')
].sort(
(left, right) =>
Number(left.dataset.index ?? Number.MAX_SAFE_INTEGER) -
Number(right.dataset.index ?? Number.MAX_SAFE_INTEGER)
)
let hostId: string | null = null
for (const row of rows) {
const hostHeader = row.querySelector<HTMLElement>('[data-host-header-drag-id]')
if (hostHeader) {
hostId = hostHeader.dataset.hostHeaderDragId ?? null
const runtimeRow = page.locator(`[data-worktree-host-identity="${runtimeIdentity}"]`)
const localRow = page.locator(`[data-worktree-host-identity="${localIdentity}"]`)
await expect(runtimeRow).toHaveCount(1)
await expect(runtimeRow).toContainText(targets.runtimeWorkspace.name)
await expect(localRow).toHaveCount(1)
await expect(localRow).toContainText(targets.localWorkspace.name)
const placement = await page.evaluate(
(targetIdentities) => {
const rows = [
...document.querySelectorAll<HTMLElement>('[data-worktree-sidebar] [data-index]')
].sort(
(left, right) =>
Number(left.dataset.index ?? Number.MAX_SAFE_INTEGER) -
Number(right.dataset.index ?? Number.MAX_SAFE_INTEGER)
)
let hostId: string | null = null
const result: Record<string, string | null> = {}
for (const row of rows) {
const hostHeader = row.querySelector<HTMLElement>('[data-host-header-drag-id]')
if (hostHeader) {
hostId = hostHeader.dataset.hostHeaderDragId ?? null
}
const identity = row.dataset.worktreeHostIdentity
if (identity && targetIdentities.includes(identity)) {
result[identity] = hostId
}
}
if (row.dataset.worktreeHostIdentity === targetIdentity) {
return { hostId, identity: row.dataset.worktreeHostIdentity ?? '' }
}
}
return null
}, identity)
expect(placement).toEqual({ hostId: args.hostId, identity })
placements.push({ groupBy: groupBy.id, ...placement! })
return result
},
[runtimeIdentity, localIdentity]
)
expect(placement).toEqual({ [runtimeIdentity]: args.hostId, [localIdentity]: 'local' })
placements.push({
groupBy: groupBy.id,
runtimeHostId: placement[runtimeIdentity]!,
localHostId: placement[localIdentity]!
})
await page.screenshot({
path: testInfo.outputPath(`sta-4964-${groupBy.id}.png`),
path: testInfo.outputPath(`sta-4964-${args.screenshotPrefix}-${groupBy.id}.png`),
fullPage: true
})
}
+55 -1
View File
@@ -123,9 +123,63 @@ test('keeps a runtime-owned folder under its paired host in every grouping mode
expect(new Set(seeded.map((workspace) => workspace.id)).size).toBe(1)
expect(seeded.map((workspace) => workspace.executionHostId)).toEqual(['local', runtimeHostId])
await client.page.evaluate(async (environmentId) => {
const store = window.__store
if (!store) {
throw new Error('Renderer store unavailable')
}
await window.api.runtimeEnvironments.disconnect({ selector: environmentId })
store.getState().setRuntimeEnvironmentStatus(environmentId, {
status: null,
checkedAt: Date.now()
})
}, client.environmentId)
await expectFolderWorkspaceSidebarGrouping(client.page, testInfo, {
folderPath: runtimeFolderPath,
hostId: runtimeHostId
hostId: runtimeHostId,
localFolderPath,
screenshotPrefix: 'disconnected'
})
const reconnected = await client.page.evaluate(
async (args) => {
const store = window.__store
if (!store) {
throw new Error('Renderer store unavailable')
}
const response = await window.api.runtimeEnvironments.connect({
selector: args.environmentId,
timeoutMs: 15_000
})
if (!response.ok) {
throw new Error(response.error.message)
}
store.getState().setRuntimeEnvironmentStatus(args.environmentId, {
status: response.result,
checkedAt: Date.now()
})
await Promise.all([
store.getState().fetchProjectGroups({ runtimeEnvironmentId: args.environmentId }),
store.getState().fetchFolderWorkspaces({ runtimeEnvironmentId: args.environmentId })
])
return store
.getState()
.folderWorkspaces.filter(
(workspace) =>
workspace.folderPath === args.runtimeFolderPath ||
workspace.folderPath === args.localFolderPath
)
.map((workspace) => workspace.executionHostId)
.sort()
},
{ environmentId: client.environmentId, localFolderPath, runtimeFolderPath }
)
expect(reconnected).toEqual(['local', runtimeHostId])
await expectFolderWorkspaceSidebarGrouping(client.page, testInfo, {
folderPath: runtimeFolderPath,
hostId: runtimeHostId,
localFolderPath,
screenshotPrefix: 'reconnected'
})
} finally {
await client.dispose()