mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 16:03:26 +00:00
fix(sidebar): disambiguate owner-scoped folder keys
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user