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 f3434a695cc..57fdc44c949 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 @@ -1,4 +1,7 @@ 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' type WorktreeItemRow = Extract export type RenderRow = @@ -25,7 +28,11 @@ export function getRenderRowKey(row: RenderRow): string { return `pending:${row.creationId}` } if (row.type === 'folder-workspace') { - return `folder-workspace:${row.folderWorkspace.id}` + const hostId = getFolderWorkspaceHostId(row.folderWorkspace, row.projectGroup, 'local') + return `folder-workspace:${composeWorktreeHostIdentity( + hostId, + folderWorkspaceKey(row.folderWorkspace.id) + )}` } return `wt:${row.rowKey}` } 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 82ce28019c8..c5e8135ca7f 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 @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import type { VirtualItem } from '@tanstack/react-virtual' import type { ExecutionHostId } from '../../../../../../shared/execution-host' +import type { FolderWorkspace } from '../../../../../../shared/folder-workspace-types' +import type { ProjectGroup } from '../../../../../../shared/project-group-types' import { HOST_STICKY_PINNED_HEIGHT, buildLineageRowRekeyMap, @@ -41,6 +43,21 @@ function itemStub(id: string): RenderRow { return { type: 'item', key: id } as unknown as RenderRow } +function folderRow(executionHostId: ExecutionHostId): RenderRow { + return { + type: 'folder-workspace', + key: 'folder-workspace:same-id', + folderWorkspace: { + id: 'same-id', + executionHostId, + projectGroupId: 'group-id' + } as FolderWorkspace, + projectGroup: { id: 'group-id', executionHostId } as ProjectGroup, + depth: 0, + groupDepth: 0 + } +} + function virtualItem(index: number, start: number): VirtualItem { return { index, start } as VirtualItem } @@ -74,6 +91,13 @@ describe('getRenderRowKey', () => { 'hdr:workspace-status:in-progress' ) }) + + it('keeps identical folder workspace ids distinct across hosts', () => { + expect(getRenderRowKey(folderRow('local'))).toBe('folder-workspace:local|folder:same-id') + expect(getRenderRowKey(folderRow('runtime:owner'))).toBe( + 'folder-workspace:runtime:owner|folder:same-id' + ) + }) }) describe('getActiveStickyIndexesForScroll', () => { diff --git a/tests/e2e/sta-4964-folder-sidebar-oracle.ts b/tests/e2e/sta-4964-folder-sidebar-oracle.ts new file mode 100644 index 00000000000..19f64765421 --- /dev/null +++ b/tests/e2e/sta-4964-folder-sidebar-oracle.ts @@ -0,0 +1,97 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect } from './helpers/orca-app' + +export async function expectFolderWorkspaceSidebarGrouping( + page: Page, + testInfo: TestInfo, + args: { folderPath: string; hostId: `runtime:${string}` } +): Promise { + const target = await page.evaluate(({ folderPath, hostId }) => { + const store = window.__store + if (!store) { + throw new Error('Renderer store unavailable') + } + const workspace = store + .getState() + .folderWorkspaces.find( + (candidate) => candidate.folderPath === folderPath && candidate.executionHostId === hostId + ) + if (!workspace) { + throw new Error('Runtime folder unavailable for sidebar grouping') + } + store.getState().setVisibleWorkspaceHostIds(['local', hostId]) + return { id: workspace.id, name: workspace.name, executionHostId: workspace.executionHostId } + }, args) + expect(target.executionHostId).toBe(args.hostId) + + const cases = [ + { id: 'repo', label: 'Project' }, + { id: 'workspace-status', label: 'Status' }, + { 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 }[] = [] + + for (const groupBy of cases) { + await page.evaluate((groupById) => window.__store?.getState().setGroupBy(groupById), groupBy.id) + await expect + .poll(() => page.evaluate(() => window.__store?.getState().groupBy ?? null)) + .toBe(groupBy.id) + await expect + .poll(() => + page.evaluate( + ({ folderPath, hostId }) => + window.__store + ?.getState() + .folderWorkspaces.some( + (workspace) => + workspace.folderPath === folderPath && workspace.executionHostId === hostId + ) ?? false, + args + ) + ) + .toBe(true) + await page.evaluate( + ({ folderWorkspaceId, hostId }) => { + window.__store?.getState().revealWorktreeInSidebar(`folder:${folderWorkspaceId}`, { + behavior: 'auto', + executionHostId: hostId + }) + }, + { folderWorkspaceId: target.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 + } + 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! }) + await page.screenshot({ + path: testInfo.outputPath(`sta-4964-${groupBy.id}.png`), + fullPage: true + }) + } + + console.info(`[sta-4964-sidebar] ${JSON.stringify(placements)}`) +} diff --git a/tests/e2e/sta-4964-folder-sidebar.spec.ts b/tests/e2e/sta-4964-folder-sidebar.spec.ts new file mode 100644 index 00000000000..05f99d44628 --- /dev/null +++ b/tests/e2e/sta-4964-folder-sidebar.spec.ts @@ -0,0 +1,133 @@ +import { mkdirSync } from 'node:fs' +import type { FolderWorkspace } from '../../src/shared/folder-workspace-types' +import type { ProjectGroup } from '../../src/shared/project-group-types' +import { expect, test } from './helpers/orca-app' +import { + createRuntimeDesktopPairingOffer, + launchPairedElectronClient +} from './helpers/paired-electron-client' +import { waitForSessionReady } from './helpers/store' +import { expectFolderWorkspaceSidebarGrouping } from './sta-4964-folder-sidebar-oracle' + +test('keeps a runtime-owned folder under its paired host in every grouping mode @headful', async ({ + orcaPage +}, testInfo) => { + test.setTimeout(120_000) + await waitForSessionReady(orcaPage) + const offer = await createRuntimeDesktopPairingOffer(orcaPage) + const client = await launchPairedElectronClient(offer, testInfo, 'STA-4964 runtime') + const runtimeHostId = `runtime:${client.environmentId}` as const + const runtimeFolderPath = testInfo.outputPath('runtime-folder') + const localFolderPath = testInfo.outputPath('local-collision-folder') + mkdirSync(runtimeFolderPath, { recursive: true }) + mkdirSync(localFolderPath, { recursive: true }) + + try { + await client.app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0]?.show()) + await expect.poll(() => client.page.evaluate(() => window.__store != null)).toBe(true) + const seeded = await client.page.evaluate( + async (args) => { + const store = window.__store + if (!store) { + throw new Error('Renderer store unavailable') + } + await store.getState().setActiveRuntimeEnvironmentPreference(null) + const groupResponse = await window.api.runtimeEnvironments.call({ + selector: args.environmentId, + method: 'projectGroup.create', + params: { + name: 'Runtime-owned group', + parentPath: args.runtimeFolderPath, + createdFrom: 'manual' + }, + timeoutMs: 15_000 + }) + if (!groupResponse.ok) { + throw new Error(groupResponse.error.message) + } + const createdGroup = (groupResponse.result as { group: ProjectGroup }).group + const folderResponse = await window.api.runtimeEnvironments.call({ + selector: args.environmentId, + method: 'folderWorkspace.create', + params: { + folderPath: args.runtimeFolderPath, + name: 'Runtime-owned folder', + projectGroupId: createdGroup.id + }, + timeoutMs: 15_000 + }) + if (!folderResponse.ok) { + throw new Error(folderResponse.error.message) + } + const createdFolder = (folderResponse.result as { folderWorkspace: FolderWorkspace }) + .folderWorkspace + await Promise.all([ + store.getState().fetchProjectGroups({ runtimeEnvironmentId: args.environmentId }), + store.getState().fetchFolderWorkspaces({ runtimeEnvironmentId: args.environmentId }) + ]) + const state = store.getState() + const runtimeFolder = state.folderWorkspaces.find( + (workspace) => + workspace.id === createdFolder.id && workspace.executionHostId === args.runtimeHostId + ) + const runtimeGroup = state.projectGroups.find( + (group) => group.id === createdGroup.id && group.executionHostId === args.runtimeHostId + ) + if (!runtimeFolder || !runtimeGroup) { + throw new Error('Runtime-owned folder catalog unavailable') + } + const localGroupId = `${runtimeGroup.id}-local-collision` + store.setState({ + folderWorkspaces: [ + { + ...runtimeFolder, + name: 'Local same-ID collision', + folderPath: args.localFolderPath, + projectGroupId: localGroupId, + executionHostId: 'local' + }, + ...state.folderWorkspaces + ], + projectGroups: [ + { + ...runtimeGroup, + id: localGroupId, + name: 'Local same-ID collision', + parentPath: args.localFolderPath, + executionHostId: 'local' + }, + ...state.projectGroups + ], + workspaceHostScope: 'all', + activeWorkspaceExecutionHostId: 'local' + }) + await Promise.all([ + store.getState().fetchProjectGroups({ runtimeEnvironmentId: args.environmentId }), + store.getState().fetchFolderWorkspaces({ runtimeEnvironmentId: args.environmentId }) + ]) + return store + .getState() + .folderWorkspaces.filter((workspace) => workspace.id === createdFolder.id) + .map((workspace) => ({ + id: workspace.id, + executionHostId: workspace.executionHostId + })) + }, + { + environmentId: client.environmentId, + localFolderPath, + runtimeFolderPath, + runtimeHostId + } + ) + expect(new Set(seeded.map((workspace) => workspace.id)).size).toBe(1) + expect(seeded.map((workspace) => workspace.executionHostId)).toEqual(['local', runtimeHostId]) + + await expectFolderWorkspaceSidebarGrouping(client.page, testInfo, { + folderPath: runtimeFolderPath, + hostId: runtimeHostId + }) + } finally { + await client.dispose() + } +})