diff --git a/src/renderer/src/components/sidebar/folder-workspace-host-id.test.ts b/src/renderer/src/components/sidebar/folder-workspace-host-id.test.ts index afa29ecb46f..e9dc140110f 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-host-id.test.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-host-id.test.ts @@ -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' + ) + }) }) diff --git a/src/renderer/src/components/sidebar/folder-workspace-host-id.ts b/src/renderer/src/components/sidebar/folder-workspace-host-id.ts index a1cd4a5d96f..451881eed4a 100644 --- a/src/renderer/src/components/sidebar/folder-workspace-host-id.ts +++ b/src/renderer/src/components/sidebar/folder-workspace-host-id.ts @@ -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, - projectGroup: Pick | 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' diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts b/src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts index 5f059a4267b..efda0fa6d12 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.test.ts @@ -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' }) + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.ts b/src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.ts index d1fb00b97b5..2702196f739 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/host-filtering.ts @@ -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 ): ExecutionHostId { @@ -100,32 +106,25 @@ function getProjectGroupExecutionHostIdForFolderPathStatus( export function getFolderPathStatusRouteOptionsForRows({ request, - projectGroupsById, - folderWorkspacesById + projectGroup, + folderWorkspace }: { request: FolderWorkspacePathStatusRequest - projectGroupsById: ReadonlyMap - folderWorkspacesById: ReadonlyMap + 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) } diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/render-row.ts b/src/renderer/src/components/sidebar/worktree-list/listing/render-row.ts index 57fdc44c949..5adc2afb02a 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/render-row.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/render-row.ts @@ -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 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) diff --git a/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts b/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts index 825c9726898..0b13ab7af62 100644 --- a/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts +++ b/src/renderer/src/components/sidebar/worktree-list/listing/use-folder-path-statuses.ts @@ -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[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] diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts index fd51f5e0fd9..478509ad637 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.test.ts @@ -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') diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.ts index fd6766e1c4a..c16eefc1b72 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/folder-reveal.ts @@ -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( diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.folder-workspace.test.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.folder-workspace.test.ts index d56020c488b..0fd44ea0307 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.folder-workspace.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.folder-workspace.test.ts @@ -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) + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.ts index 9728ff4634e..be8783a93c9 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/render-row-lookup.ts @@ -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 diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx index 727438f997a..2ed72c9db25 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/SectionHeader.tsx @@ -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. diff --git a/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx b/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx index 7a2b0e753bb..0fefc473fca 100644 --- a/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/rows/folder-row.tsx @@ -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 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, worktree: Worktree) => boolean onContextMenuSelect: ( event: React.MouseEvent, @@ -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') diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx b/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx index 56f5f8fe50c..f8e1f2cea38 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/VirtualizedWorktreeViewport.tsx @@ -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, diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-measurement.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-measurement.ts index 5526c2d39c9..08fe8456a4f 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-measurement.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/use-row-measurement.ts @@ -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, number>() @@ -31,6 +32,7 @@ export function countRecordKeysByReference(record: Record): num // sticky-header slots the render pass reads out of refs. export function useVirtualRowMeasurementSync(args: { renderRows: RenderRow[] + defaultHostId: ExecutionHostId virtualization: WorktreeListVirtualizer scrollRef: React.RefObject scrollOffsetRef: React.MutableRefObject @@ -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() diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/use-virtualizer.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/use-virtualizer.ts index 221f2da0f94..aaede90d673 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/use-virtualizer.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/use-virtualizer.ts @@ -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 scrollOffsetRef: React.MutableRefObject suppressMeasurementAdjustmentUntilRef: React.MutableRefObject }) { - const { renderRows, firstHeaderIndex, scrollRef, scrollOffsetRef } = args + const { renderRows, defaultHostId, firstHeaderIndex, scrollRef, scrollOffsetRef } = args const stickyHeaderIndexes = useMemo(() => getStickyHeaderIndexes(renderRows), [renderRows]) const activeStickyHeaderIndexRef = useRef(null) const activeStickyHostIndexRef = useRef(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) => { diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts index 2787b93a002..8bf96988776 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-row-context.ts @@ -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, diff --git a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.test.ts b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.test.ts index c5e8135ca7f..274aa138d61 100644 --- a/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/viewport/virtual-rows.test.ts @@ -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 { 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', () => { diff --git a/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.ts b/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.ts index 8b7f05e7e9b..12b3a1d041a 100644 --- a/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.ts +++ b/src/renderer/src/components/sidebar/worktree-sidebar-row-preference.ts @@ -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 diff --git a/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts b/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts index 4cf74a63640..44f695b186a 100644 --- a/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts +++ b/src/renderer/src/store/slices/repos-catalog-merge-identity.test.ts @@ -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() diff --git a/src/renderer/src/store/slices/repos-folder-path-status-host-identity.test.ts b/src/renderer/src/store/slices/repos-folder-path-status-host-identity.test.ts new file mode 100644 index 00000000000..0d1f66b9433 --- /dev/null +++ b/src/renderer/src/store/slices/repos-folder-path-status-host-identity.test.ts @@ -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 }) + }) +}) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 2a802bc57b2..7c45be9205a 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -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) { - if (group.executionHostId) { - return group.executionHostId +function getProjectGroupHostId( + group: Pick +): 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, - 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 = (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 = (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 = (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 }) diff --git a/src/shared/folder-workspace-host.ts b/src/shared/folder-workspace-host.ts new file mode 100644 index 00000000000..08caf650a77 --- /dev/null +++ b/src/shared/folder-workspace-host.ts @@ -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, + projectGroup: Pick | 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, + projectGroups: readonly Pick[], + 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 +} diff --git a/src/shared/folder-workspace-worktree.test.ts b/src/shared/folder-workspace-worktree.test.ts index 420da41488c..8ffd570ddf2 100644 --- a/src/shared/folder-workspace-worktree.test.ts +++ b/src/shared/folder-workspace-worktree.test.ts @@ -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 { 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({ diff --git a/src/shared/folder-workspace-worktree.ts b/src/shared/folder-workspace-worktree.ts index c8715a12b3a..2f25b61ca73 100644 --- a/src/shared/folder-workspace-worktree.ts +++ b/src/shared/folder-workspace-worktree.ts @@ -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) +} diff --git a/tests/e2e/sta-4964-folder-sidebar-oracle.ts b/tests/e2e/sta-4964-folder-sidebar-oracle.ts index 19f64765421..4a57741f011 100644 --- a/tests/e2e/sta-4964-folder-sidebar-oracle.ts +++ b/tests/e2e/sta-4964-folder-sidebar-oracle.ts @@ -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 { - 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('[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('[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('[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 = {} + for (const row of rows) { + const hostHeader = row.querySelector('[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 }) } diff --git a/tests/e2e/sta-4964-folder-sidebar.spec.ts b/tests/e2e/sta-4964-folder-sidebar.spec.ts index 05f99d44628..ca23b1b6b86 100644 --- a/tests/e2e/sta-4964-folder-sidebar.spec.ts +++ b/tests/e2e/sta-4964-folder-sidebar.spec.ts @@ -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()