mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
fix(sidebar): align folder host identities
This commit is contained in:
@@ -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)
|
||||
|
||||
+21
-17
@@ -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(
|
||||
|
||||
+19
@@ -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')
|
||||
|
||||
+2
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user