diff --git a/src/renderer/src/components/sidebar/host-section-rows.ts b/src/renderer/src/components/sidebar/host-section-rows.ts index e53789f7d4d..149a582072b 100644 --- a/src/renderer/src/components/sidebar/host-section-rows.ts +++ b/src/renderer/src/components/sidebar/host-section-rows.ts @@ -13,6 +13,7 @@ import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat' import type { SshConnectionStatus } from '../../../../shared/ssh-types' import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types' import type { Row } from './worktree-list-groups' +import { getFolderWorkspaceExecutionHostIdForRows } from './worktree-list-host-filtering' export type HostHeaderRow = { type: 'host-header' @@ -54,17 +55,16 @@ function getRepoHostId( return defaultHostId } -function getSshHostId(connectionId: string): ExecutionHostId { - return `ssh:${encodeURIComponent(connectionId)}` as ExecutionHostId -} - function getFolderWorkspaceHostId( - folderWorkspace: Pick, - projectGroup: Pick, + folderWorkspace: Pick, + projectGroup: Pick, defaultHostId: ExecutionHostId ): ExecutionHostId { - const connectionId = folderWorkspace.connectionId ?? projectGroup.connectionId - return connectionId ? getSshHostId(connectionId) : defaultHostId + return getFolderWorkspaceExecutionHostIdForRows({ + folderWorkspace, + projectGroup, + defaultHostId + }) } function getRowHostId(row: Row, defaultHostId: ExecutionHostId): ExecutionHostId | null { diff --git a/src/renderer/src/components/sidebar/host-section-runtime-folder-rows.test.ts b/src/renderer/src/components/sidebar/host-section-runtime-folder-rows.test.ts new file mode 100644 index 00000000000..9793e4119bd --- /dev/null +++ b/src/renderer/src/components/sidebar/host-section-runtime-folder-rows.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' +import type { FolderWorkspace, ProjectGroup } from '../../../../shared/types' +import { addHostSectionRows, type HostSectionRow } from './host-section-rows' +import type { Row } from './worktree-list-groups' + +function rowKey(row: HostSectionRow): string { + return row.type === 'item' ? row.rowKey : row.key +} + +describe('runtime folder host section rows', () => { + it('groups a runtime folder row under its stamped owner', () => { + const projectGroup: ProjectGroup = { + id: 'group-1', + name: 'Runtime folder', + parentPath: '/srv/project', + connectionId: null, + executionHostId: 'runtime:env-1', + parentGroupId: null, + createdFrom: 'manual', + tabOrder: 0, + isCollapsed: false, + color: null, + createdAt: 1, + updatedAt: 1 + } + const folderWorkspace: FolderWorkspace = { + id: 'folder-1', + projectGroupId: projectGroup.id, + name: 'Folder workspace', + folderPath: '/srv/project', + connectionId: null, + executionHostId: 'runtime:env-1', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + const localProjectGroup: ProjectGroup = { + ...projectGroup, + id: 'local-group', + name: 'Local folder', + executionHostId: 'local' + } + const localFolderWorkspace: FolderWorkspace = { + ...folderWorkspace, + id: 'local-folder', + projectGroupId: localProjectGroup.id, + name: 'Local workspace', + executionHostId: 'local' + } + const rows: Row[] = [ + { + type: 'header', + key: 'project-group:local', + label: 'Local folder', + count: 1, + tone: 'text-foreground', + hostWorktreeCounts: new Map([['local', 1]]), + folderWorkspaceIds: [localFolderWorkspace.id] + }, + { + type: 'folder-workspace', + key: 'folder-workspace:local-folder', + folderWorkspace: localFolderWorkspace, + projectGroup: localProjectGroup, + depth: 0, + groupDepth: 1 + }, + { + type: 'header', + key: 'project-group:runtime', + label: 'Runtime folder', + count: 1, + tone: 'text-foreground', + hostWorktreeCounts: new Map([['runtime:env-1', 1]]), + folderWorkspaceIds: [folderWorkspace.id] + }, + { + type: 'folder-workspace', + key: 'folder-workspace:folder-1', + folderWorkspace, + projectGroup, + depth: 0, + groupDepth: 1 + } + ] + + const sectioned = addHostSectionRows({ + rows, + hostOptions: [ + { + id: 'local', + kind: 'local', + label: 'Local Mac', + detail: 'This computer', + health: 'local' + }, + { + id: 'runtime:env-1', + kind: 'runtime', + label: 'Runtime', + detail: 'Orca server', + health: 'available' + } + ], + workspaceHostScope: 'all', + defaultHostId: 'local' + }) + + expect(sectioned.map(rowKey)).toEqual([ + 'host:local', + 'project-group:local', + 'folder-workspace:local-folder', + 'host:runtime:env-1', + 'project-group:runtime', + 'folder-workspace:folder-1' + ]) + }) +}) diff --git a/src/renderer/src/components/sidebar/project-group-sidebar-identity.test.ts b/src/renderer/src/components/sidebar/project-group-sidebar-identity.test.ts index bb9e5d5e276..d2a11b61ca1 100644 --- a/src/renderer/src/components/sidebar/project-group-sidebar-identity.test.ts +++ b/src/renderer/src/components/sidebar/project-group-sidebar-identity.test.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest' -import type { ProjectGroup } from '../../../../shared/types' +import { getFolderWorkspaceRowKey } from '../../../../shared/folder-workspaces' +import type { FolderWorkspace, ProjectGroup } from '../../../../shared/types' import { buildProjectGroupSidebarIndex, findProjectGroupForSidebarOwner, + getAmbiguousFolderWorkspaceSidebarIds, getProjectGroupSidebarIdentity, hasSingleProjectGroupMutationOwner } from './project-group-sidebar-identity' @@ -24,6 +26,30 @@ function group(id: string, executionHostId: string): ProjectGroup { } describe('project-group sidebar identity', () => { + it('keeps bare folder row keys until owner qualification is required', () => { + const workspace: FolderWorkspace = { + id: 'same-id', + projectGroupId: 'group-1', + name: 'Runtime folder', + folderPath: '/workspace', + executionHostId: 'runtime:env-1', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + createdAt: 1, + updatedAt: 1 + } + + expect(getFolderWorkspaceRowKey(workspace)).toBe('folder-workspace:same-id') + expect(getFolderWorkspaceRowKey(workspace, [], true)).toBe( + 'folder-workspace:runtime%3Aenv-1:same-id' + ) + }) + it('resolves duplicate ids only with their catalog owner', () => { const local = group('same-id', 'local') const runtime = group('same-id', 'runtime:env-1') @@ -35,6 +61,20 @@ describe('project-group sidebar identity', () => { expect(getProjectGroupSidebarIdentity(local)).not.toBe(getProjectGroupSidebarIdentity(runtime)) }) + it('marks folder row ids ambiguous only across owners', () => { + const local = group('local-group', 'local') + const runtime = group('runtime-group', 'runtime:env-1') + const index = buildProjectGroupSidebarIndex([local, runtime]) + + expect( + getAmbiguousFolderWorkspaceSidebarIds(index, [ + { id: 'same-id', projectGroupId: local.id, executionHostId: 'local' }, + { id: 'same-id', projectGroupId: runtime.id, executionHostId: 'runtime:env-1' }, + { id: 'unique-id', projectGroupId: runtime.id, executionHostId: 'runtime:env-1' } + ]) + ).toEqual(new Set(['same-id'])) + }) + it('does not fall back to a foreign group for an explicit stale owner', () => { const local = group('same-id', 'local') const index = buildProjectGroupSidebarIndex([local]) diff --git a/src/renderer/src/components/sidebar/project-group-sidebar-identity.ts b/src/renderer/src/components/sidebar/project-group-sidebar-identity.ts index 938ed9a0625..f01f4c0a5cc 100644 --- a/src/renderer/src/components/sidebar/project-group-sidebar-identity.ts +++ b/src/renderer/src/components/sidebar/project-group-sidebar-identity.ts @@ -137,6 +137,32 @@ export function findProjectGroupForFolderWorkspace( return findProjectGroupForSidebarOwner(index, workspace.projectGroupId, ownerHostId) } +export function getAmbiguousFolderWorkspaceSidebarIds( + index: ProjectGroupSidebarIndex, + workspaces: readonly Pick< + FolderWorkspace, + 'id' | 'connectionId' | 'executionHostId' | 'projectGroupId' + >[] +): ReadonlySet { + const ownerById = new Map() + const ambiguousIds = new Set() + for (const workspace of workspaces) { + const group = findProjectGroupForFolderWorkspace(index, workspace) + if (!group || ambiguousIds.has(workspace.id)) { + continue + } + const ownerHostId = getProjectGroupOwnerHostId(group) + const existingOwnerHostId = ownerById.get(workspace.id) + if (existingOwnerHostId && existingOwnerHostId !== ownerHostId) { + ownerById.delete(workspace.id) + ambiguousIds.add(workspace.id) + } else { + ownerById.set(workspace.id, ownerHostId) + } + } + return ambiguousIds +} + export function findProjectGroupParentForSidebar( index: ProjectGroupSidebarIndex, group: ProjectGroup diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 26fe90abdeb..7ea040cbcae 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -61,6 +61,7 @@ import { findProjectGroupForFolderWorkspace, findProjectGroupForRepo, findProjectGroupParentForSidebar, + getAmbiguousFolderWorkspaceSidebarIds, getProjectGroupOwnerHostId, getProjectGroupSidebarIdentity } from './project-group-sidebar-identity' @@ -1407,6 +1408,10 @@ export function buildRows( } const projectGroupIndex = buildProjectGroupSidebarIndex(projectGroups) + const ambiguousFolderWorkspaceIds = getAmbiguousFolderWorkspaceSidebarIds( + projectGroupIndex, + folderWorkspaces + ) const groupByProjectGroupIdentity = new Map() for (const entry of orderedGroups) { const repo = entry[1].repo @@ -1585,7 +1590,11 @@ export function buildRows( for (const folderWorkspace of folderWorkspacesByProjectGroupIdentity.get(identity) ?? []) { result.push({ type: 'folder-workspace', - key: getFolderWorkspaceRowKey(folderWorkspace, [projectGroup]), + key: getFolderWorkspaceRowKey( + folderWorkspace, + [projectGroup], + ambiguousFolderWorkspaceIds.has(folderWorkspace.id) + ), folderWorkspace, projectGroup, depth: 0, diff --git a/src/shared/folder-workspaces.ts b/src/shared/folder-workspaces.ts index 8c595f0a4cf..15d69eb3ba7 100644 --- a/src/shared/folder-workspaces.ts +++ b/src/shared/folder-workspaces.ts @@ -68,8 +68,12 @@ export function getFolderWorkspaceIdentity( export function getFolderWorkspaceRowKey( workspace: Pick, - projectGroups: readonly ProjectGroup[] = [] + projectGroups: readonly ProjectGroup[] = [], + qualifyOwner = false ): string { + if (!qualifyOwner) { + return `folder-workspace:${workspace.id}` + } const ownerHostId = getFolderWorkspaceCatalogOwnerHostId(workspace, projectGroups) return `folder-workspace:${encodeURIComponent(ownerHostId)}:${encodeURIComponent(workspace.id)}` }