From 82afbb307111ff8722d5e7f3114095081a339c72 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:30:47 -0700 Subject: [PATCH] fix(sidebar): disambiguate owner-scoped folder keys --- .../sidebar/project-group-header-dom.test.ts | 48 +++++++--- .../worktree-list-folder-reveal.test.ts | 4 +- .../sidebar/worktree-list-groups.ts | 25 ++--- .../worktree-list-host-filtering.test.ts | 8 +- .../lib/new-workspace-project-options.test.ts | 31 +++++-- .../src/lib/new-workspace-project-options.ts | 27 ++---- ...-same-id-folder-workspace-identity.test.ts | 57 ++++++++++++ src/renderer/src/store/slices/repos.ts | 15 ++- src/shared/folder-workspaces.ts | 5 +- src/shared/workspace-scope.ts | 91 +++++++++++++++++-- 10 files changed, 233 insertions(+), 78 deletions(-) diff --git a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts index ef01a86056b..09a33dc259f 100644 --- a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts +++ b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts @@ -15,6 +15,7 @@ import { findProjectGroupForSidebarOwner, getAmbiguousFolderWorkspaceSidebarIds, getProjectGroupMutationSelector, + getProjectGroupHeaderKey, getProjectGroupSidebarIdentity, getSidebarWorktreeSelectionId, getSingleProjectGroupMutationOwner, @@ -61,7 +62,7 @@ describe('project-group sidebar identity', () => { expect(getFolderWorkspaceRowKey(workspace)).toBe('folder-workspace:same-id') expect(getFolderWorkspaceRowKey(workspace, [], true)).toBe( - 'folder-workspace:runtime%3Aenv-1:same-id' + 'folder-workspace:@owner:runtime%3Aenv-1:same-id' ) }) @@ -164,12 +165,33 @@ describe('project-group sidebar identity', () => { groupId: 'same-id', ownerHostId: 'runtime:env-1' }) - expect(parseProjectGroupSidebarHeaderKey('project-group:runtime%3Aenv-1:same-id')).toEqual({ + expect( + parseProjectGroupSidebarHeaderKey(getProjectGroupHeaderKey('same-id', 'runtime:env-1')) + ).toEqual({ groupId: 'same-id', ownerHostId: 'runtime:env-1' }) }) + it('keeps host-shaped group ids distinct from owner-qualified header keys', () => { + const legacyKey = getProjectGroupHeaderKey('local:same-id') + const qualifiedKey = getProjectGroupHeaderKey('same-id', 'local') + + expect(legacyKey).not.toBe(qualifiedKey) + expect(parseProjectGroupSidebarHeaderKey(legacyKey)).toEqual({ + groupId: 'local:same-id' + }) + expect(parseProjectGroupSidebarHeaderKey(qualifiedKey)).toEqual({ + groupId: 'same-id', + ownerHostId: 'local' + }) + for (const reservedId of ['@owner:raw', '@id:raw']) { + expect(parseProjectGroupSidebarHeaderKey(getProjectGroupHeaderKey(reservedId))).toEqual({ + groupId: reservedId + }) + } + }) + it('marks folder row ids ambiguous only across owners', () => { const local = sidebarIdentityGroup('local-group', 'local') const runtime = sidebarIdentityGroup('runtime-group', 'runtime:env-1') @@ -378,7 +400,7 @@ describe('duplicate project-group header identity (#12532)', () => { }, { type: 'header', - key: 'project-group:runtime%3Aenv-1:same-id', + key: getProjectGroupHeaderKey('same-id', 'runtime:env-1'), label: emptyGroup.name, count: 0, tone: 'text-foreground', @@ -414,7 +436,7 @@ describe('duplicate project-group header identity (#12532)', () => { expect(sectioned).toContainEqual( expect.objectContaining({ type: 'header', - key: 'project-group:runtime%3Aenv-1:same-id', + key: getProjectGroupHeaderKey('same-id', 'runtime:env-1'), count: 0, hostId: 'runtime:env-1' }) @@ -428,20 +450,20 @@ describe('duplicate project-group header identity (#12532)', () => { const rows = buildOwnerRows(['local', 'runtime:env-1']) expect(projectGroupHeaderKeys(rows)).toEqual([ - 'project-group:local:same-id', - 'project-group:runtime%3Aenv-1:same-id' + getProjectGroupHeaderKey('same-id', 'local'), + getProjectGroupHeaderKey('same-id', 'runtime:env-1') ]) const renderKeys = rows.map(getRenderRowKey) expect(new Set(renderKeys).size).toBe(renderKeys.length) }) it('collapses only the selected owner and stays unique after rebuild', () => { - const collapsed = new Set(['project-group:runtime%3Aenv-1:same-id']) + const collapsed = new Set([getProjectGroupHeaderKey('same-id', 'runtime:env-1')]) const rows = buildOwnerRows(['local', 'runtime:env-1'], collapsed) expect(projectGroupHeaderKeys(rows)).toEqual([ - 'project-group:local:same-id', - 'project-group:runtime%3Aenv-1:same-id' + getProjectGroupHeaderKey('same-id', 'local'), + getProjectGroupHeaderKey('same-id', 'runtime:env-1') ]) expect(rows.filter((row) => row.type === 'item')).toHaveLength(1) const renderKeys = rows.map(getRenderRowKey) @@ -451,7 +473,9 @@ describe('duplicate project-group header identity (#12532)', () => { it('keeps a host-filter rebuild owner-qualified without disabling the visible owner', () => { const rows = buildOwnerRows(['runtime:env-1']) - expect(projectGroupHeaderKeys(rows)).toEqual(['project-group:runtime%3Aenv-1:same-id']) + expect(projectGroupHeaderKeys(rows)).toEqual([ + getProjectGroupHeaderKey('same-id', 'runtime:env-1') + ]) const renderKeys = rows.map(getRenderRowKey) expect(new Set(renderKeys).size).toBe(renderKeys.length) }) @@ -482,9 +506,9 @@ describe('duplicate project-group header identity (#12532)', () => { ) const folderRow = rows.find((row) => row.type === 'folder-workspace') - expect(folderRow?.key).toBe('folder-workspace:runtime%3Aenv-1:same-folder') + expect(folderRow?.key).toBe('folder-workspace:@owner:runtime%3Aenv-1:same-folder') expect(folderRow && getRenderRowKey(folderRow)).toBe( - 'folder-workspace:runtime%3Aenv-1:same-folder' + 'folder-workspace:@owner:runtime%3Aenv-1:same-folder' ) }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list-folder-reveal.test.ts b/src/renderer/src/components/sidebar/worktree-list-folder-reveal.test.ts index 9e47cb543c0..ec6539fcc09 100644 --- a/src/renderer/src/components/sidebar/worktree-list-folder-reveal.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-folder-reveal.test.ts @@ -205,9 +205,9 @@ describe('worktree list folder reveal', () => { expect( getFolderWorkspaceSidebarRowKey( 'folder-workspace-1', - 'folder-workspace:runtime%3Aenv-1:folder-workspace-1' + 'folder-workspace:@owner:runtime%3Aenv-1:folder-workspace-1' ) - ).toBe('folder-workspace:runtime%3Aenv-1:folder-workspace-1') + ).toBe('folder-workspace:@owner:runtime%3Aenv-1:folder-workspace-1') }) it('keeps the surviving same-id owner active after deleting its sibling', () => { diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 88363263db1..e4f4ab71f0e 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -31,6 +31,10 @@ import { getProjectGroupOwnerIdentity, UNGROUPED_PROJECT_GROUP_KEY } from '../../../../shared/project-groups' +import { + getProjectGroupSelectorKey, + parseProjectGroupSelectorKey +} from '../../../../shared/workspace-scope' import { getFolderWorkspaceCatalogOwnerHostId, getFolderWorkspaceRowKey @@ -121,22 +125,7 @@ export function getSingleProjectGroupMutationOwner( export function parseProjectGroupSidebarHeaderKey( key: string ): { groupId: string; ownerHostId?: ExecutionHostId } | null { - const prefix = 'project-group:' - if (!key.startsWith(prefix)) { - return null - } - const value = key.slice(prefix.length) - const separator = value.indexOf(':') - if (separator === -1) { - return { groupId: value } - } - try { - const ownerHostId = normalizeExecutionHostId(decodeURIComponent(value.slice(0, separator))) - const groupId = decodeURIComponent(value.slice(separator + 1)) - return ownerHostId ? { ownerHostId, groupId } : null - } catch { - return null - } + return parseProjectGroupSelectorKey(key) } export type ProjectGroupSidebarIndex = { @@ -629,9 +618,7 @@ export function getProjectGroupHeaderKey( if (!groupId) { return UNGROUPED_PROJECT_GROUP_KEY } - return ownerHostId - ? `project-group:${encodeURIComponent(ownerHostId)}:${encodeURIComponent(groupId)}` - : `project-group:${groupId}` + return getProjectGroupSelectorKey(groupId, ownerHostId) } export const PINNED_GROUP_KEY = 'pinned' diff --git a/src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts b/src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts index 116384c569d..069649539e7 100644 --- a/src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts @@ -300,7 +300,7 @@ describe('runtime folder host section rows', () => { const rows: Row[] = [ { type: 'folder-workspace', - key: 'folder-workspace:local:local-folder', + key: 'folder-workspace:@owner:local:local-folder', folderWorkspace: localFolder, projectGroup: localGroup, depth: 0, @@ -308,7 +308,7 @@ describe('runtime folder host section rows', () => { }, { type: 'folder-workspace', - key: 'folder-workspace:runtime%3Aenv-2:folder-1', + key: 'folder-workspace:@owner:runtime%3Aenv-2:folder-1', folderWorkspace: runtimeFolder, projectGroup: runtimeGroup, depth: 0, @@ -341,9 +341,9 @@ describe('runtime folder host section rows', () => { expect(sectioned.map((row) => (row.type === 'item' ? row.rowKey : row.key))).toEqual([ 'host:local', - 'folder-workspace:local:local-folder', + 'folder-workspace:@owner:local:local-folder', 'host:runtime:env-2', - 'folder-workspace:runtime%3Aenv-2:folder-1' + 'folder-workspace:@owner:runtime%3Aenv-2:folder-1' ]) expect( sectioned.flatMap((row) => (row.type === 'host-header' ? [[row.hostId, row.count]] : [])) diff --git a/src/renderer/src/lib/new-workspace-project-options.test.ts b/src/renderer/src/lib/new-workspace-project-options.test.ts index 8dce8429b67..664c4ed2d67 100644 --- a/src/renderer/src/lib/new-workspace-project-options.test.ts +++ b/src/renderer/src/lib/new-workspace-project-options.test.ts @@ -4,7 +4,9 @@ import { buildNewWorkspaceFolderSourceOptions, buildNewWorkspaceProjectOptions, findActionableFolderProjectGroup, + getNewWorkspaceProjectGroupOptionId, getNewWorkspaceProjectGroupsForOwner, + getProjectGroupSelectorFromNewWorkspaceOptionId, getRepoIdFromNewWorkspaceFolderSourceOptionId, isNewWorkspaceProjectOptionQueryTooLarge, searchNewWorkspaceProjectOptions, @@ -478,10 +480,12 @@ describe('buildNewWorkspaceCreateTargetOptions', () => { expect(options.map((option) => option.id).sort()).toEqual([ 'github:stablyai/orca', - 'project-group:local:folder-group' + getNewWorkspaceProjectGroupOptionId('folder-group', 'local') ]) expect( - options.find((option) => option.id === 'project-group:local:folder-group') + options.find( + (option) => option.id === getNewWorkspaceProjectGroupOptionId('folder-group', 'local') + ) ).toMatchObject({ kind: 'project-group', projectGroupId: 'folder-group', @@ -491,10 +495,7 @@ describe('buildNewWorkspaceCreateTargetOptions', () => { }) it('gives duplicate group ids distinct owner-qualified option values', async () => { - const { - buildNewWorkspaceCreateTargetOptions, - getProjectGroupSelectorFromNewWorkspaceOptionId - } = await import('./new-workspace-project-options') + const { buildNewWorkspaceCreateTargetOptions } = await import('./new-workspace-project-options') const options = buildNewWorkspaceCreateTargetOptions({ projects: [], projectHostSetups: [], @@ -510,14 +511,28 @@ describe('buildNewWorkspaceCreateTargetOptions', () => { }) expect(options.map((option) => option.id)).toEqual([ - 'project-group:local:same-id', - 'project-group:runtime%3Aenv-1:same-id' + getNewWorkspaceProjectGroupOptionId('same-id', 'local'), + getNewWorkspaceProjectGroupOptionId('same-id', 'runtime:env-1') ]) expect(getProjectGroupSelectorFromNewWorkspaceOptionId(options[1].id)).toEqual({ groupId: 'same-id', ownerHostId: 'runtime:env-1' }) }) + + it('keeps host-shaped legacy group ids distinct from owner-qualified options', () => { + const legacyOptionId = 'project-group:local:same-id' + const qualifiedOptionId = getNewWorkspaceProjectGroupOptionId('same-id', 'local') + + expect(legacyOptionId).not.toBe(qualifiedOptionId) + expect(getProjectGroupSelectorFromNewWorkspaceOptionId(legacyOptionId)).toEqual({ + groupId: 'local:same-id' + }) + expect(getProjectGroupSelectorFromNewWorkspaceOptionId(qualifiedOptionId)).toEqual({ + groupId: 'same-id', + ownerHostId: 'local' + }) + }) }) describe('findActionableFolderProjectGroup', () => { diff --git a/src/renderer/src/lib/new-workspace-project-options.ts b/src/renderer/src/lib/new-workspace-project-options.ts index 27577438ef1..4091b6e6abc 100644 --- a/src/renderer/src/lib/new-workspace-project-options.ts +++ b/src/renderer/src/lib/new-workspace-project-options.ts @@ -8,6 +8,10 @@ import { toSshExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import { + getProjectGroupSelectorKey, + parseProjectGroupSelectorKey +} from '../../../shared/workspace-scope' import { getDuplicateProjectDetailsById, type ProjectSetupDirectory @@ -149,7 +153,7 @@ export function getNewWorkspaceProjectGroupOptionId( projectGroupId: string, ownerHostId: ExecutionHostId ): string { - return `${NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX}${encodeURIComponent(ownerHostId)}:${encodeURIComponent(projectGroupId)}` + return getProjectGroupSelectorKey(projectGroupId, ownerHostId) } function getFolderSourceOptionId(repoId: string): string { @@ -169,26 +173,7 @@ export function getProjectGroupIdFromNewWorkspaceOptionId(optionId: string): str export function getProjectGroupSelectorFromNewWorkspaceOptionId( optionId: string ): { groupId: string; ownerHostId?: ExecutionHostId } | null { - if (!optionId.startsWith(NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX)) { - return null - } - const value = optionId.slice(NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX.length) - const separator = value.indexOf(':') - if (separator === -1) { - return value ? { groupId: value } : null - } - try { - const parsedOwner = parseExecutionHostId(decodeURIComponent(value.slice(0, separator))) - if (!parsedOwner) { - return null - } - return { - ownerHostId: parsedOwner.id, - groupId: decodeURIComponent(value.slice(separator + 1)) - } - } catch { - return null - } + return parseProjectGroupSelectorKey(optionId) } function getProjectGroupDetail(group: ProjectGroup): string { diff --git a/src/renderer/src/store/slices/repos-same-id-folder-workspace-identity.test.ts b/src/renderer/src/store/slices/repos-same-id-folder-workspace-identity.test.ts index 234a8fd3356..3b4b6f6b5b5 100644 --- a/src/renderer/src/store/slices/repos-same-id-folder-workspace-identity.test.ts +++ b/src/renderer/src/store/slices/repos-same-id-folder-workspace-identity.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types' +import { getFolderWorkspaceRowKey } from '../../../../shared/folder-workspaces' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' import { buildHostIdByWorktreeId } from '../../lib/workspace-session-host-persistence' import { @@ -133,6 +134,33 @@ describe('same-id cross-host folder workspace identity', () => { expect(remoteKey).toContain('["runtime:env-1","same-id"]') }) + it('keeps restored ownership until the matching same-id group catalog arrives', async () => { + const remoteKey = folderWorkspaceKey('same-id', 'runtime:env-1') + const store = createTestStore() + store.setState({ + projectGroups: [makeGroup({ executionHostId: 'local' })], + folderWorkspaces: [ + makeFolder({ + executionHostId: 'runtime:env-1', + folderPath: '/remote/folder' + }) + ], + restoredRuntimeHostIdByWorkspaceSessionKey: { [remoteKey]: 'runtime:env-1' } + }) + vi.stubGlobal('window', { + api: { + folderWorkspaces: { list: vi.fn().mockResolvedValue([]) }, + runtimeEnvironments: { list: vi.fn().mockResolvedValue([]) } + } + }) + + await store.getState().fetchFolderWorkspacesForAllHosts() + + expect(store.getState().restoredRuntimeHostIdByWorkspaceSessionKey).toEqual({ + [remoteKey]: 'runtime:env-1' + }) + }) + it('keeps the legacy bare folder session identity on unambiguous activation', () => { const store = createTestStore() const bareKey = folderWorkspaceKey('folder-workspace-1') @@ -356,6 +384,35 @@ describe('same-id cross-host folder workspace identity', () => { }) }) + it('keeps host-shaped legacy ids distinct from owner-qualified keys', () => { + const legacyKey = folderWorkspaceKey('local:same-id') + const qualifiedKey = folderWorkspaceKey('same-id', 'local') + const legacyRowKey = getFolderWorkspaceRowKey(makeFolder({ id: 'local:same-id' })) + const qualifiedRowKey = getFolderWorkspaceRowKey( + makeFolder({ executionHostId: 'local' }), + [], + true + ) + + expect(legacyKey).not.toBe(qualifiedKey) + expect(legacyRowKey).not.toBe(qualifiedRowKey) + expect(parseWorkspaceKey(legacyKey)).toEqual({ + type: 'folder', + folderWorkspaceId: 'local:same-id' + }) + expect(parseWorkspaceKey(qualifiedKey)).toEqual({ + type: 'folder', + folderWorkspaceId: 'same-id', + ownerHostId: 'local' + }) + for (const reservedId of ['@owner:raw', '@id:raw']) { + expect(parseWorkspaceKey(folderWorkspaceKey(reservedId))).toEqual({ + type: 'folder', + folderWorkspaceId: reservedId + }) + } + }) + it('routes folder metadata writes through the selected owner', async () => { const local = makeFolder({ executionHostId: 'local' }) const ssh = makeFolder({ diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 87496fcb96e..9572b67f9a3 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -1313,6 +1313,7 @@ function clearRestoredFolderWorkspaceSessionOwners( state: Pick ): AppState['restoredRuntimeHostIdByWorkspaceSessionKey'] { const next: AppState['restoredRuntimeHostIdByWorkspaceSessionKey'] = {} + const projectGroupIndex = buildProjectGroupOwnerIndex(state.projectGroups) for (const [key, hostId] of Object.entries(owners ?? {})) { const scope = parseWorkspaceKey(key) if (scope?.type !== 'folder') { @@ -1327,7 +1328,19 @@ function clearRestoredFolderWorkspaceSessionOwners( scope.ownerHostId) ) const workspace = matches.length === 1 ? matches[0] : undefined - if (workspace && !state.projectGroups.some((group) => group.id === workspace.projectGroupId)) { + const workspaceOwnerHostId = + workspace && + (scope.ownerHostId ?? + resolveFolderWorkspaceCatalogOwnerHostIdFromIndex(workspace, projectGroupIndex)) + if ( + workspace && + (!workspaceOwnerHostId || + !resolveProjectGroupOwner( + projectGroupIndex, + workspace.projectGroupId, + workspaceOwnerHostId + )) + ) { // Why: ownership resolves via the project group; if that catalog is still missing, keep the restored host owner so a session write doesn't move runtime tabs local. next[key] = hostId } diff --git a/src/shared/folder-workspaces.ts b/src/shared/folder-workspaces.ts index 486dc49b0e3..6518247fdc0 100644 --- a/src/shared/folder-workspaces.ts +++ b/src/shared/folder-workspaces.ts @@ -13,6 +13,7 @@ import { type ProjectGroupOwnerIndex } from './project-groups' import type { FolderWorkspace, ProjectGroup } from './types' +import { folderWorkspaceKey } from './workspace-scope' import { isTuiAgent } from './tui-agent-config' import { normalizeStoredTaskSourceContext } from './task-source-context' import { normalizeWorkspaceLinkedItem } from './workspace-linked-item' @@ -121,10 +122,10 @@ export function getFolderWorkspaceRowKey( qualifyOwner = false ): string { if (!qualifyOwner) { - return `folder-workspace:${workspace.id}` + return folderWorkspaceKey(workspace.id).replace(/^folder:/, 'folder-workspace:') } const ownerHostId = getFolderWorkspaceCatalogOwnerHostId(workspace, projectGroups) - return `folder-workspace:${encodeURIComponent(ownerHostId)}:${encodeURIComponent(workspace.id)}` + return folderWorkspaceKey(workspace.id, ownerHostId).replace(/^folder:/, 'folder-workspace:') } export function normalizeFolderWorkspaceName( diff --git a/src/shared/workspace-scope.ts b/src/shared/workspace-scope.ts index 3dbfa4d48a8..20379b857fa 100644 --- a/src/shared/workspace-scope.ts +++ b/src/shared/workspace-scope.ts @@ -1,6 +1,10 @@ import { parseExecutionHostId, type ExecutionHostId } from './execution-host' import type { WorkspaceKey, WorkspaceScope } from './types' +const FOLDER_WORKSPACE_OWNER_KEY_PREFIX = '@owner:' +const FOLDER_WORKSPACE_ESCAPED_ID_PREFIX = '@id:' +const PROJECT_GROUP_KEY_PREFIX = 'project-group:' + type ParsedWorkspaceScope = | Extract | { type: 'folder'; folderWorkspaceId: string; ownerHostId?: ExecutionHostId } @@ -14,7 +18,13 @@ export function folderWorkspaceKey( ownerHostId?: ExecutionHostId ): WorkspaceKey { if (ownerHostId) { - return `folder:${encodeURIComponent(ownerHostId)}:${encodeURIComponent(folderWorkspaceId)}` + return `folder:${FOLDER_WORKSPACE_OWNER_KEY_PREFIX}${encodeURIComponent(ownerHostId)}:${encodeURIComponent(folderWorkspaceId)}` + } + if ( + folderWorkspaceId.startsWith(FOLDER_WORKSPACE_OWNER_KEY_PREFIX) || + folderWorkspaceId.startsWith(FOLDER_WORKSPACE_ESCAPED_ID_PREFIX) + ) { + return `folder:${FOLDER_WORKSPACE_ESCAPED_ID_PREFIX}${encodeURIComponent(folderWorkspaceId)}` } return `folder:${folderWorkspaceId}` } @@ -29,16 +39,32 @@ export function parseWorkspaceKey(value: string): ParsedWorkspaceScope | null { if (rest.length === 0) { return null } - const separator = rest.indexOf(':') - if (separator > 0) { + if (rest.startsWith(FOLDER_WORKSPACE_ESCAPED_ID_PREFIX)) { try { - const ownerHostId = parseExecutionHostId(decodeURIComponent(rest.slice(0, separator)))?.id - const folderWorkspaceId = decodeURIComponent(rest.slice(separator + 1)) - if (ownerHostId && folderWorkspaceId.length > 0) { - return { type: 'folder', folderWorkspaceId, ownerHostId } - } + const folderWorkspaceId = decodeURIComponent( + rest.slice(FOLDER_WORKSPACE_ESCAPED_ID_PREFIX.length) + ) + return folderWorkspaceId ? { type: 'folder', folderWorkspaceId } : null } catch { - // Legacy folder ids remain valid even when percent-decoding fails. + return null + } + } + if (rest.startsWith(FOLDER_WORKSPACE_OWNER_KEY_PREFIX)) { + const ownerAndWorkspace = rest.slice(FOLDER_WORKSPACE_OWNER_KEY_PREFIX.length) + const separator = ownerAndWorkspace.indexOf(':') + if (separator < 1) { + return null + } + try { + const ownerHostId = parseExecutionHostId( + decodeURIComponent(ownerAndWorkspace.slice(0, separator)) + )?.id + const folderWorkspaceId = decodeURIComponent(ownerAndWorkspace.slice(separator + 1)) + return ownerHostId && folderWorkspaceId + ? { type: 'folder', folderWorkspaceId, ownerHostId } + : null + } catch { + return null } } return { type: 'folder', folderWorkspaceId: rest } @@ -46,6 +72,53 @@ export function parseWorkspaceKey(value: string): ParsedWorkspaceScope | null { return null } +export function getProjectGroupSelectorKey(groupId: string, ownerHostId?: ExecutionHostId): string { + if (ownerHostId) { + return `${PROJECT_GROUP_KEY_PREFIX}${FOLDER_WORKSPACE_OWNER_KEY_PREFIX}${encodeURIComponent(ownerHostId)}:${encodeURIComponent(groupId)}` + } + if ( + groupId.startsWith(FOLDER_WORKSPACE_OWNER_KEY_PREFIX) || + groupId.startsWith(FOLDER_WORKSPACE_ESCAPED_ID_PREFIX) + ) { + return `${PROJECT_GROUP_KEY_PREFIX}${FOLDER_WORKSPACE_ESCAPED_ID_PREFIX}${encodeURIComponent(groupId)}` + } + return `${PROJECT_GROUP_KEY_PREFIX}${groupId}` +} + +export function parseProjectGroupSelectorKey( + key: string +): { groupId: string; ownerHostId?: ExecutionHostId } | null { + if (!key.startsWith(PROJECT_GROUP_KEY_PREFIX)) { + return null + } + const value = key.slice(PROJECT_GROUP_KEY_PREFIX.length) + if (value.startsWith(FOLDER_WORKSPACE_ESCAPED_ID_PREFIX)) { + try { + const groupId = decodeURIComponent(value.slice(FOLDER_WORKSPACE_ESCAPED_ID_PREFIX.length)) + return groupId ? { groupId } : null + } catch { + return null + } + } + if (!value.startsWith(FOLDER_WORKSPACE_OWNER_KEY_PREFIX)) { + return value ? { groupId: value } : null + } + const ownerAndGroup = value.slice(FOLDER_WORKSPACE_OWNER_KEY_PREFIX.length) + const separator = ownerAndGroup.indexOf(':') + if (separator < 1) { + return null + } + try { + const ownerHostId = parseExecutionHostId( + decodeURIComponent(ownerAndGroup.slice(0, separator)) + )?.id + const groupId = decodeURIComponent(ownerAndGroup.slice(separator + 1)) + return ownerHostId && groupId ? { groupId, ownerHostId } : null + } catch { + return null + } +} + export function isWorkspaceKey(value: string): value is WorkspaceKey { return parseWorkspaceKey(value) !== null }