fix(sidebar): disambiguate owner-scoped folder keys

This commit is contained in:
Brennan Benson
2026-08-10 22:30:47 -07:00
parent eff5032569
commit 82afbb3071
10 changed files with 233 additions and 78 deletions
@@ -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'
)
})
})
@@ -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', () => {
@@ -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'
@@ -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]] : []))
@@ -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', () => {
@@ -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 {
@@ -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({
+14 -1
View File
@@ -1313,6 +1313,7 @@ function clearRestoredFolderWorkspaceSessionOwners(
state: Pick<AppState, 'folderWorkspaces' | 'projectGroups'>
): 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
}
+3 -2
View File
@@ -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(
+82 -9
View File
@@ -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<WorkspaceScope, { type: 'worktree' }>
| { 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
}