mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 16:03:53 +00:00
fix(sidebar): narrow owner-qualify same-id group identity
Disambiguate multi-host project groups and folder workspaces only at catalog normalize, store CRUD, sidebar row keys, activation dual-read, IPC, persistence, and filesystem-auth. Keep bare IDs when unambiguous (including folder:folder-workspace-1); fail closed on collision. Leave #13611 sticky geometry untouched and drop broad WorktreeCard/dashboard session-routing propagation from the prior 66-file tip.
This commit is contained in:
@@ -264,6 +264,90 @@ describe('filesystem-auth path containment', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('authorizes only the local same-id folder owner', async () => {
|
||||
const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-same-id-folders-'))
|
||||
try {
|
||||
const localPath = join(tempRoot, 'local')
|
||||
const sshPath = join(tempRoot, 'ssh')
|
||||
await mkdir(localPath)
|
||||
await mkdir(sshPath)
|
||||
const localGroup = makeProjectGroup({ id: 'same-id', parentPath: localPath })
|
||||
const sshGroup = makeProjectGroup({
|
||||
id: 'same-id',
|
||||
parentPath: sshPath,
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
const store = makeStore([], {
|
||||
projectGroups: [localGroup, sshGroup],
|
||||
folderWorkspaces: [
|
||||
makeFolderWorkspace({
|
||||
id: 'local-folder',
|
||||
projectGroupId: 'same-id',
|
||||
folderPath: localPath,
|
||||
connectionId: null
|
||||
}),
|
||||
makeFolderWorkspace({
|
||||
id: 'ssh-folder',
|
||||
projectGroupId: 'same-id',
|
||||
folderPath: sshPath,
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
]
|
||||
})
|
||||
|
||||
await expect(resolveAuthorizedPath(localPath, store)).resolves.toBe(await realpath(localPath))
|
||||
await expect(resolveAuthorizedPath(sshPath, store)).rejects.toThrow('Access denied')
|
||||
} finally {
|
||||
await rm(tempRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps large multi-owner folder authorization linear in catalog size', () => {
|
||||
const groupCount = 2_048
|
||||
const repoCount = 2_048
|
||||
let pathReads = 0
|
||||
const projectGroups = Array.from({ length: groupCount }, (_, index) =>
|
||||
makeProjectGroup({
|
||||
id: index < 16 ? 'same-id' : `group-${index}`,
|
||||
parentPath: index % 16 === 0 ? `/local/group-${index}` : null,
|
||||
executionHostId: index % 16 === 0 ? 'local' : `runtime:env-${index % 16}`
|
||||
})
|
||||
)
|
||||
const folderWorkspaces = Array.from({ length: groupCount }, (_, index) =>
|
||||
makeFolderWorkspace({
|
||||
id: index < 16 ? 'same-folder' : `folder-${index}`,
|
||||
projectGroupId: index < 16 ? 'same-id' : `group-${index}`,
|
||||
folderPath: `/folder-${index}`,
|
||||
executionHostId: index % 16 === 0 ? 'local' : `runtime:env-${index % 16}`,
|
||||
connectionId: index % 16 === 0 ? null : undefined
|
||||
})
|
||||
)
|
||||
const repos = Array.from({ length: repoCount }, (_, index) => {
|
||||
const ownerIndex = index % 16
|
||||
return {
|
||||
...repo,
|
||||
id: `repo-${index}`,
|
||||
get path() {
|
||||
pathReads += 1
|
||||
return `/repo-${index}`
|
||||
},
|
||||
projectGroupId: ownerIndex === 0 ? 'same-id' : `group-${index}`,
|
||||
executionHostId: ownerIndex === 0 ? 'local' : `runtime:env-${ownerIndex}`,
|
||||
connectionId: ownerIndex === 0 ? null : undefined
|
||||
} as Repo
|
||||
})
|
||||
const store = makeStore(repos, { projectGroups, folderWorkspaces })
|
||||
|
||||
const started = performance.now()
|
||||
const allowed = isPathAllowed('/local/group-0', store)
|
||||
const elapsedMs = performance.now() - started
|
||||
|
||||
expect(allowed).toBe(true)
|
||||
// Why: indexed auth must not re-scan every path for every group/folder on each authorize.
|
||||
expect(pathReads).toBeLessThan(repoCount * 4)
|
||||
expect(elapsedMs).toBeLessThan(250)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'rejects missing descendants under a symlinked ancestor outside the repo',
|
||||
async () => {
|
||||
|
||||
+205
-35
@@ -6,8 +6,15 @@ import type { Store } from '../persistence'
|
||||
import { isRepoRoot, listRepoWorktrees } from '../repo-worktrees'
|
||||
import { computeWorkspaceRoot, getWorktreePathSettings } from './worktree-logic'
|
||||
import { isPathInsideOrEqual } from '../../shared/cross-platform-path'
|
||||
import { getProjectGroupSubtreeIds } from '../../shared/project-groups'
|
||||
import type { FolderWorkspace, ProjectGroup, Repo } from '../../shared/types'
|
||||
import {
|
||||
buildProjectGroupOwnerIndex,
|
||||
getProjectGroupOwnerHostId,
|
||||
getProjectGroupSubtreeIds,
|
||||
resolveFolderWorkspaceProjectGroup
|
||||
} from '../../shared/project-groups'
|
||||
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
|
||||
import type { ProjectGroup, Repo } from '../../shared/types'
|
||||
import { getFolderWorkspaceCatalogOwnerHostId } from '../../shared/folder-workspaces'
|
||||
|
||||
export const PATH_ACCESS_DENIED_MESSAGE =
|
||||
'Access denied: path resolves outside allowed directories. If this blocks a legitimate workflow, please file a GitHub issue.'
|
||||
@@ -58,43 +65,189 @@ function getLocalRepos(store: Store) {
|
||||
return store.getRepos().filter((repo) => !repo.connectionId)
|
||||
}
|
||||
|
||||
function getFolderScopeCandidateRepos(
|
||||
folderPath: string,
|
||||
projectGroupId: string,
|
||||
projectGroups: readonly ProjectGroup[],
|
||||
type FolderAuthIndex = {
|
||||
projectGroupIndex: ReturnType<typeof buildProjectGroupOwnerIndex>
|
||||
ownerGroupIdsByOwner: Map<string, ProjectGroup[]>
|
||||
reposByOwner: Map<string, Repo[]>
|
||||
reposByOwnerAndGroupId: Map<string, Repo[]>
|
||||
ownerSubtreeIds: Map<string, Map<string, Set<string>>>
|
||||
unscopedSubtreeIds: Map<string, Set<string>>
|
||||
repos: readonly Repo[]
|
||||
): Repo[] {
|
||||
const groupIds = getProjectGroupSubtreeIds(projectGroups, projectGroupId)
|
||||
return repos.filter(
|
||||
(repo) =>
|
||||
(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) ||
|
||||
isPathInsideOrEqual(folderPath, repo.path)
|
||||
)
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
}
|
||||
|
||||
function isRemoteOnlyFolderScope(
|
||||
folderPath: string,
|
||||
projectGroupId: string,
|
||||
connectionId: string | null | undefined,
|
||||
function ownerGroupKey(ownerHostId: string, groupId: string): string {
|
||||
return `${ownerHostId}\0${groupId}`
|
||||
}
|
||||
|
||||
function buildFolderAuthIndex(
|
||||
projectGroups: readonly ProjectGroup[],
|
||||
repos: readonly Repo[]
|
||||
): FolderAuthIndex {
|
||||
const projectGroupIndex = buildProjectGroupOwnerIndex(projectGroups)
|
||||
const ownerGroupIdsByOwner = new Map<string, ProjectGroup[]>()
|
||||
const childrenByOwnerParent = new Map<string, string[]>()
|
||||
for (const group of projectGroups) {
|
||||
const ownerHostId = getProjectGroupOwnerHostId(group)
|
||||
const ownerGroups = ownerGroupIdsByOwner.get(ownerHostId) ?? []
|
||||
ownerGroups.push(group)
|
||||
ownerGroupIdsByOwner.set(ownerHostId, ownerGroups)
|
||||
if (!group.parentGroupId) {
|
||||
continue
|
||||
}
|
||||
const parentKey = ownerGroupKey(ownerHostId, group.parentGroupId)
|
||||
const children = childrenByOwnerParent.get(parentKey) ?? []
|
||||
children.push(group.id)
|
||||
childrenByOwnerParent.set(parentKey, children)
|
||||
}
|
||||
|
||||
const ownerSubtreeIds = new Map<string, Map<string, Set<string>>>()
|
||||
for (const [ownerHostId, groups] of ownerGroupIdsByOwner) {
|
||||
const subtreeByRoot = new Map<string, Set<string>>()
|
||||
for (const group of groups) {
|
||||
if (subtreeByRoot.has(group.id)) {
|
||||
continue
|
||||
}
|
||||
const subtree = new Set<string>()
|
||||
const pending = [group.id]
|
||||
while (pending.length > 0) {
|
||||
const groupId = pending.pop()!
|
||||
if (subtree.has(groupId)) {
|
||||
continue
|
||||
}
|
||||
subtree.add(groupId)
|
||||
for (const childId of childrenByOwnerParent.get(ownerGroupKey(ownerHostId, groupId)) ??
|
||||
[]) {
|
||||
pending.push(childId)
|
||||
}
|
||||
}
|
||||
subtreeByRoot.set(group.id, subtree)
|
||||
}
|
||||
ownerSubtreeIds.set(ownerHostId, subtreeByRoot)
|
||||
}
|
||||
|
||||
const unscopedSubtreeIds = new Map<string, Set<string>>()
|
||||
for (const group of projectGroups) {
|
||||
if (!unscopedSubtreeIds.has(group.id)) {
|
||||
unscopedSubtreeIds.set(group.id, getProjectGroupSubtreeIds(projectGroups, group.id))
|
||||
}
|
||||
}
|
||||
|
||||
const reposByOwner = new Map<string, Repo[]>()
|
||||
const reposByOwnerAndGroupId = new Map<string, Repo[]>()
|
||||
for (const repo of repos) {
|
||||
const ownerHostId = getRepoExecutionHostId(repo)
|
||||
const ownerRepos = reposByOwner.get(ownerHostId) ?? []
|
||||
ownerRepos.push(repo)
|
||||
reposByOwner.set(ownerHostId, ownerRepos)
|
||||
if (typeof repo.projectGroupId === 'string') {
|
||||
const key = ownerGroupKey(ownerHostId, repo.projectGroupId)
|
||||
const groupRepos = reposByOwnerAndGroupId.get(key) ?? []
|
||||
groupRepos.push(repo)
|
||||
reposByOwnerAndGroupId.set(key, groupRepos)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
projectGroupIndex,
|
||||
ownerGroupIdsByOwner,
|
||||
reposByOwner,
|
||||
reposByOwnerAndGroupId,
|
||||
ownerSubtreeIds,
|
||||
unscopedSubtreeIds,
|
||||
repos,
|
||||
projectGroups
|
||||
}
|
||||
}
|
||||
|
||||
function collectOwnerGroupMembershipRepos(
|
||||
index: FolderAuthIndex,
|
||||
projectGroupId: string,
|
||||
ownerHostId: string
|
||||
): Repo[] {
|
||||
const groupIds =
|
||||
index.ownerSubtreeIds.get(ownerHostId)?.get(projectGroupId) ?? new Set([projectGroupId])
|
||||
const seen = new Set<Repo>()
|
||||
const candidates: Repo[] = []
|
||||
for (const groupId of groupIds) {
|
||||
for (const repo of index.reposByOwnerAndGroupId.get(ownerGroupKey(ownerHostId, groupId)) ??
|
||||
[]) {
|
||||
if (!seen.has(repo)) {
|
||||
seen.add(repo)
|
||||
candidates.push(repo)
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
function collectOwnerPathMembershipRepos(
|
||||
index: FolderAuthIndex,
|
||||
folderPath: string,
|
||||
ownerHostId: string,
|
||||
exclude: ReadonlySet<Repo>
|
||||
): Repo[] {
|
||||
const candidates: Repo[] = []
|
||||
for (const repo of index.reposByOwner.get(ownerHostId) ?? []) {
|
||||
if (exclude.has(repo)) {
|
||||
continue
|
||||
}
|
||||
if (isPathInsideOrEqual(folderPath, repo.path)) {
|
||||
candidates.push(repo)
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
function isRemoteOnlyFolderScopeWithIndex(
|
||||
index: FolderAuthIndex,
|
||||
folderPath: string,
|
||||
projectGroupId: string,
|
||||
ownerHostId: string,
|
||||
inferLegacyOwner: boolean
|
||||
): boolean {
|
||||
if (connectionId) {
|
||||
if (ownerHostId !== LOCAL_EXECUTION_HOST_ID) {
|
||||
return true
|
||||
}
|
||||
const candidates = getFolderScopeCandidateRepos(folderPath, projectGroupId, projectGroups, repos)
|
||||
return candidates.length > 0 && candidates.every((repo) => Boolean(repo.connectionId))
|
||||
}
|
||||
|
||||
function getFolderWorkspaceConnectionId(
|
||||
workspace: FolderWorkspace,
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
): string | null {
|
||||
return (
|
||||
workspace.connectionId ??
|
||||
projectGroups.find((group) => group.id === workspace.projectGroupId)?.connectionId ??
|
||||
null
|
||||
if (inferLegacyOwner) {
|
||||
const groupIds = index.unscopedSubtreeIds.get(projectGroupId) ?? new Set([projectGroupId])
|
||||
let sawLegacyCandidate = false
|
||||
let sawLocalLegacyCandidate = false
|
||||
for (const repo of index.repos) {
|
||||
const inGroup = typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)
|
||||
// Why: only pay path reads when group membership alone cannot classify the scope.
|
||||
if (!inGroup && !isPathInsideOrEqual(folderPath, repo.path)) {
|
||||
continue
|
||||
}
|
||||
sawLegacyCandidate = true
|
||||
if (getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID) {
|
||||
sawLocalLegacyCandidate = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if (sawLegacyCandidate && !sawLocalLegacyCandidate) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
const groupCandidates = collectOwnerGroupMembershipRepos(index, projectGroupId, ownerHostId)
|
||||
if (groupCandidates.length === 0) {
|
||||
// Why: empty membership means no remote-linked evidence; authorize local folder scopes without path scans.
|
||||
return false
|
||||
}
|
||||
if (groupCandidates.some((repo) => !repo.connectionId)) {
|
||||
return false
|
||||
}
|
||||
// Why: path containment only matters when every group-linked repo is remote-linked.
|
||||
const pathCandidates = collectOwnerPathMembershipRepos(
|
||||
index,
|
||||
folderPath,
|
||||
ownerHostId,
|
||||
new Set(groupCandidates)
|
||||
)
|
||||
if (pathCandidates.some((repo) => !repo.connectionId)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function getLocalFolderScopeRoots(store: Store): string[] {
|
||||
@@ -102,23 +255,40 @@ function getLocalFolderScopeRoots(store: Store): string[] {
|
||||
const repos = scopeStore.getRepos()
|
||||
// Why: many filesystem tests use narrow Store doubles; folder scopes are additive.
|
||||
const projectGroups = scopeStore.getProjectGroups?.() ?? []
|
||||
// Why: one owner-indexed pass avoids O((groups+folders)*(groups+repos)) remote-only checks.
|
||||
const index = buildFolderAuthIndex(projectGroups, repos)
|
||||
const roots: string[] = []
|
||||
for (const group of projectGroups) {
|
||||
if (
|
||||
group.parentPath &&
|
||||
!isRemoteOnlyFolderScope(group.parentPath, group.id, group.connectionId, projectGroups, repos)
|
||||
!isRemoteOnlyFolderScopeWithIndex(
|
||||
index,
|
||||
group.parentPath,
|
||||
group.id,
|
||||
getProjectGroupOwnerHostId(group),
|
||||
group.connectionId === undefined &&
|
||||
group.executionHostId === undefined &&
|
||||
index.projectGroupIndex.byId.get(group.id)?.length === 1
|
||||
)
|
||||
) {
|
||||
roots.push(resolve(group.parentPath))
|
||||
}
|
||||
}
|
||||
for (const workspace of scopeStore.getFolderWorkspaces?.() ?? []) {
|
||||
const group = resolveFolderWorkspaceProjectGroup(index.projectGroupIndex, workspace)
|
||||
// Why: narrow Store doubles may omit projectGroups; folder roots stay additive from the workspace catalog.
|
||||
const ownerHostId = group
|
||||
? getProjectGroupOwnerHostId(group)
|
||||
: getFolderWorkspaceCatalogOwnerHostId(workspace, projectGroups)
|
||||
if (
|
||||
!isRemoteOnlyFolderScope(
|
||||
!isRemoteOnlyFolderScopeWithIndex(
|
||||
index,
|
||||
workspace.folderPath,
|
||||
workspace.projectGroupId,
|
||||
getFolderWorkspaceConnectionId(workspace, projectGroups),
|
||||
projectGroups,
|
||||
repos
|
||||
ownerHostId,
|
||||
workspace.connectionId === undefined &&
|
||||
workspace.executionHostId === undefined &&
|
||||
index.projectGroupIndex.byId.get(workspace.projectGroupId)?.length === 1
|
||||
)
|
||||
) {
|
||||
roots.push(resolve(workspace.folderPath))
|
||||
|
||||
@@ -350,6 +350,68 @@ describe('projectGroups IPC validation', () => {
|
||||
expect(mockStore.updateProjectGroup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('forwards optional project group mutation ownership to persistence', () => {
|
||||
mockStore.updateProjectGroup.mockReturnValue({ id: 'group-1' })
|
||||
mockStore.deleteProjectGroup.mockReturnValue(true)
|
||||
mockStore.moveProjectToGroup.mockReturnValue({ id: 'repo-1' })
|
||||
|
||||
handlers.get('projectGroups:update')!(null, {
|
||||
groupId: 'group-1',
|
||||
ownerHostId: 'ssh:builder',
|
||||
updates: { name: 'Remote' }
|
||||
})
|
||||
handlers.get('projectGroups:delete')!(null, {
|
||||
groupId: 'group-1',
|
||||
ownerHostId: 'local'
|
||||
})
|
||||
handlers.get('projectGroups:moveProject')!(null, {
|
||||
projectId: 'repo-1',
|
||||
groupId: 'group-1',
|
||||
order: 2,
|
||||
ownerHostId: 'runtime:env-1'
|
||||
})
|
||||
|
||||
expect(mockStore.updateProjectGroup).toHaveBeenCalledWith(
|
||||
'group-1',
|
||||
{ name: 'Remote' },
|
||||
'ssh:builder'
|
||||
)
|
||||
expect(mockStore.deleteProjectGroup).toHaveBeenCalledWith('group-1', 'local')
|
||||
expect(mockStore.moveProjectToGroup).toHaveBeenCalledWith(
|
||||
'repo-1',
|
||||
'group-1',
|
||||
2,
|
||||
'runtime:env-1'
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects invalid project group mutation owners before persistence', () => {
|
||||
expect(() =>
|
||||
handlers.get('projectGroups:update')!(null, {
|
||||
groupId: 'group-1',
|
||||
ownerHostId: 'ssh:',
|
||||
updates: { name: 'Remote' }
|
||||
})
|
||||
).toThrow('invalid_project_group_update_args')
|
||||
expect(() =>
|
||||
handlers.get('projectGroups:delete')!(null, {
|
||||
groupId: 'group-1',
|
||||
ownerHostId: 'builder'
|
||||
})
|
||||
).toThrow('invalid_project_group_delete_args')
|
||||
expect(() =>
|
||||
handlers.get('projectGroups:moveProject')!(null, {
|
||||
projectId: 'repo-1',
|
||||
groupId: 'group-1',
|
||||
ownerHostId: 'runtime:'
|
||||
})
|
||||
).toThrow('invalid_project_group_move_repo_args')
|
||||
|
||||
expect(mockStore.updateProjectGroup).not.toHaveBeenCalled()
|
||||
expect(mockStore.deleteProjectGroup).not.toHaveBeenCalled()
|
||||
expect(mockStore.moveProjectToGroup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('scans nested repositories over a connected SSH filesystem', async () => {
|
||||
mockGitProvider.isGitRepoAsync.mockImplementation(async (path: string) => ({
|
||||
isRepo: path === '/srv/platform/api',
|
||||
|
||||
+62
-15
@@ -102,8 +102,16 @@ import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostId,
|
||||
parseExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../shared/execution-host'
|
||||
import {
|
||||
buildProjectGroupOwnerIndex,
|
||||
getFolderWorkspaceProjectGroupOwnerHostId,
|
||||
getProjectGroupOwnerHostId,
|
||||
resolveFolderWorkspaceProjectGroup,
|
||||
resolveProjectGroupOwner
|
||||
} from '../../shared/project-groups'
|
||||
import { joinRemotePath } from '../ssh/ssh-remote-platform'
|
||||
import {
|
||||
assertFolderWorkspacePathUsable,
|
||||
@@ -798,8 +806,21 @@ const ProjectGroupCreateArgs = z.object({
|
||||
createdFrom: z.enum(['manual', 'folder-scan', 'migration']).optional()
|
||||
})
|
||||
|
||||
const ProjectGroupOwnerHostId = z
|
||||
.string()
|
||||
.min(1)
|
||||
.transform((value, ctx) => {
|
||||
const hostId = normalizeExecutionHostId(value)
|
||||
if (!hostId) {
|
||||
ctx.addIssue({ code: 'custom', message: 'Invalid owner host ID' })
|
||||
return z.NEVER
|
||||
}
|
||||
return hostId
|
||||
})
|
||||
|
||||
const ProjectGroupUpdateArgs = z.object({
|
||||
groupId: z.string().min(1),
|
||||
ownerHostId: ProjectGroupOwnerHostId.optional(),
|
||||
updates: z.object({
|
||||
name: z.string().optional(),
|
||||
isCollapsed: z.boolean().optional(),
|
||||
@@ -809,13 +830,15 @@ const ProjectGroupUpdateArgs = z.object({
|
||||
})
|
||||
|
||||
const ProjectGroupSelectorArgs = z.object({
|
||||
groupId: z.string().min(1)
|
||||
groupId: z.string().min(1),
|
||||
ownerHostId: ProjectGroupOwnerHostId.optional()
|
||||
})
|
||||
|
||||
const ProjectGroupMoveProjectArgs = z.object({
|
||||
projectId: z.string().min(1),
|
||||
groupId: z.string().nullable(),
|
||||
order: z.number().finite().optional()
|
||||
order: z.number().finite().optional(),
|
||||
ownerHostId: ProjectGroupOwnerHostId.optional()
|
||||
})
|
||||
|
||||
const ProjectHostSetupExistingFolderIpcArgs = z.object({
|
||||
@@ -958,7 +981,8 @@ const FolderWorkspacePathStatusArgs = z.discriminatedUnion('scope', [
|
||||
}),
|
||||
z.object({
|
||||
scope: z.literal('project-group'),
|
||||
projectGroupId: z.string().min(1)
|
||||
projectGroupId: z.string().min(1),
|
||||
ownerHostId: ProjectGroupOwnerHostId.optional()
|
||||
}),
|
||||
z.object({
|
||||
scope: z.literal('path'),
|
||||
@@ -1488,7 +1512,15 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
'invalid_folder_workspace_create_args'
|
||||
)
|
||||
const projectGroups = store.getProjectGroups()
|
||||
const group = projectGroups.find((entry) => entry.id === args.projectGroupId)
|
||||
const group = resolveProjectGroupOwner(
|
||||
buildProjectGroupOwnerIndex(projectGroups),
|
||||
args.projectGroupId,
|
||||
args.connectionId !== undefined
|
||||
? args.connectionId
|
||||
? toSshExecutionHostId(args.connectionId)
|
||||
: LOCAL_EXECUTION_HOST_ID
|
||||
: undefined
|
||||
)
|
||||
const folderPath =
|
||||
typeof args.folderPath === 'string' && args.folderPath.trim().length > 0
|
||||
? args.folderPath
|
||||
@@ -1501,8 +1533,13 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
folderPath,
|
||||
projectGroupId: group.id,
|
||||
connectionId: args.connectionId ?? group.connectionId ?? null,
|
||||
projectGroups,
|
||||
repos: store.getRepos()
|
||||
projectGroups: projectGroups.filter(
|
||||
(candidate) =>
|
||||
getProjectGroupOwnerHostId(candidate) === getProjectGroupOwnerHostId(group)
|
||||
),
|
||||
repos: store
|
||||
.getRepos()
|
||||
.filter((repo) => getRepoExecutionHostId(repo) === getProjectGroupOwnerHostId(group))
|
||||
},
|
||||
{ getSshFilesystemProvider }
|
||||
)
|
||||
@@ -1530,16 +1567,21 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
return null
|
||||
}
|
||||
const projectGroups = store.getProjectGroups()
|
||||
const projectGroupIndex = buildProjectGroupOwnerIndex(projectGroups)
|
||||
const group = resolveFolderWorkspaceProjectGroup(projectGroupIndex, workspace)
|
||||
if (!group) {
|
||||
return null
|
||||
}
|
||||
const ownerHostId = getFolderWorkspaceProjectGroupOwnerHostId(workspace, projectGroupIndex)
|
||||
const status = await getFolderWorkspacePathStatusForPath(
|
||||
{
|
||||
folderPath: args.updates.folderPath,
|
||||
projectGroupId: workspace.projectGroupId,
|
||||
connectionId:
|
||||
workspace.connectionId ??
|
||||
projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId ??
|
||||
null,
|
||||
projectGroups,
|
||||
repos: store.getRepos()
|
||||
connectionId: workspace.connectionId ?? group.connectionId ?? null,
|
||||
projectGroups: projectGroups.filter(
|
||||
(candidate) => getProjectGroupOwnerHostId(candidate) === ownerHostId
|
||||
),
|
||||
repos: store.getRepos().filter((repo) => getRepoExecutionHostId(repo) === ownerHostId)
|
||||
},
|
||||
{ getSshFilesystemProvider }
|
||||
)
|
||||
@@ -1589,7 +1631,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
rawArgs,
|
||||
'invalid_project_group_update_args'
|
||||
)
|
||||
const updated = store.updateProjectGroup(args.groupId, args.updates)
|
||||
const updated = store.updateProjectGroup(args.groupId, args.updates, args.ownerHostId)
|
||||
if (updated) {
|
||||
notifyReposChanged(mainWindow)
|
||||
}
|
||||
@@ -1602,7 +1644,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
rawArgs,
|
||||
'invalid_project_group_delete_args'
|
||||
)
|
||||
const deleted = store.deleteProjectGroup(args.groupId)
|
||||
const deleted = store.deleteProjectGroup(args.groupId, args.ownerHostId)
|
||||
if (deleted) {
|
||||
notifyReposChanged(mainWindow)
|
||||
}
|
||||
@@ -1615,7 +1657,12 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
|
||||
rawArgs,
|
||||
'invalid_project_group_move_repo_args'
|
||||
)
|
||||
const moved = store.moveProjectToGroup(args.projectId, args.groupId, args.order)
|
||||
const moved = store.moveProjectToGroup(
|
||||
args.projectId,
|
||||
args.groupId,
|
||||
args.order,
|
||||
args.ownerHostId
|
||||
)
|
||||
if (moved) {
|
||||
notifyReposChanged(mainWindow)
|
||||
}
|
||||
|
||||
@@ -3524,6 +3524,218 @@ describe('Store', () => {
|
||||
expect(store.getRepo('sibling')?.projectGroupId).toBe(sibling.id)
|
||||
})
|
||||
|
||||
it('moves duplicate repo ids only within the exact project group owner', async () => {
|
||||
const makeOwnedGroup = (name: string, connectionId: string | null): ProjectGroup => ({
|
||||
id: 'same-group',
|
||||
name,
|
||||
parentPath: null,
|
||||
connectionId,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
})
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [
|
||||
makeRepo({ id: 'same-repo', path: '/local', projectGroupOrder: 900 }),
|
||||
makeRepo({
|
||||
id: 'same-repo',
|
||||
path: '/ssh',
|
||||
connectionId: 'builder',
|
||||
projectGroupOrder: 1
|
||||
}),
|
||||
makeRepo({
|
||||
id: 'ssh-sibling',
|
||||
path: '/ssh/sibling',
|
||||
connectionId: 'builder',
|
||||
projectGroupId: 'same-group',
|
||||
projectGroupOrder: 5
|
||||
})
|
||||
],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
projectGroups: [
|
||||
makeOwnedGroup('Local', null),
|
||||
makeOwnedGroup('SSH', 'builder'),
|
||||
{ ...makeOwnedGroup('Local only', null), id: 'local-only' }
|
||||
]
|
||||
})
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.moveProjectToGroup('same-repo', null)).toBeNull()
|
||||
expect(store.moveProjectToGroup('same-repo', null, undefined, 'ssh:missing')).toBeNull()
|
||||
expect(store.moveProjectToGroup('same-repo', 'local-only', undefined, 'ssh:builder')).toBeNull()
|
||||
expect(
|
||||
store.moveProjectToGroup('same-repo', 'same-group', undefined, 'ssh:builder')
|
||||
).toMatchObject({
|
||||
path: '/ssh',
|
||||
projectGroupId: 'same-group',
|
||||
projectGroupOrder: 6
|
||||
})
|
||||
|
||||
const repos = store.getRepos()
|
||||
const localRepo = repos.find((entry) => entry.path === '/local')
|
||||
expect(localRepo).toMatchObject({ projectGroupOrder: 900 })
|
||||
expect(localRepo?.projectGroupId).toBeUndefined()
|
||||
expect(repos.find((entry) => entry.path === '/ssh')).toMatchObject({
|
||||
projectGroupId: 'same-group',
|
||||
projectGroupOrder: 6
|
||||
})
|
||||
})
|
||||
|
||||
it('updates and deletes same-id project groups only within the selected owner', async () => {
|
||||
const projectGroups = [
|
||||
{
|
||||
id: 'root',
|
||||
name: 'Local root',
|
||||
parentPath: '/local',
|
||||
connectionId: null,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan' as const,
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'child',
|
||||
name: 'Local child',
|
||||
parentPath: '/local/child',
|
||||
connectionId: null,
|
||||
parentGroupId: 'root',
|
||||
createdFrom: 'folder-scan' as const,
|
||||
tabOrder: 1,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'root',
|
||||
name: 'SSH root',
|
||||
parentPath: '/remote',
|
||||
connectionId: 'builder',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan' as const,
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'child',
|
||||
name: 'SSH child',
|
||||
parentPath: '/remote/child',
|
||||
connectionId: 'builder',
|
||||
parentGroupId: 'root',
|
||||
createdFrom: 'folder-scan' as const,
|
||||
tabOrder: 1,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [
|
||||
makeRepo({ id: 'local-repo', path: '/local/repo', projectGroupId: 'child' }),
|
||||
makeRepo({
|
||||
id: 'ssh-repo',
|
||||
path: '/remote/repo',
|
||||
projectGroupId: 'child',
|
||||
connectionId: 'builder'
|
||||
})
|
||||
],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
projectGroups
|
||||
})
|
||||
const store = await createStore()
|
||||
const localWorkspace = store.createFolderWorkspace({
|
||||
projectGroupId: 'child',
|
||||
connectionId: null
|
||||
})
|
||||
const sshWorkspace = store.createFolderWorkspace({
|
||||
projectGroupId: 'child',
|
||||
connectionId: 'builder'
|
||||
})
|
||||
|
||||
expect(store.updateProjectGroup('root', { name: 'Ambiguous' })).toBeNull()
|
||||
expect(store.updateProjectGroup('root', { name: 'Missing' }, 'ssh:missing')).toBeNull()
|
||||
expect(store.updateProjectGroup('root', { name: 'Remote' }, 'ssh:builder')?.name).toBe('Remote')
|
||||
expect(store.deleteProjectGroup('root', 'local')).toBe(true)
|
||||
|
||||
expect(store.getProjectGroups().map((group) => [group.name, group.connectionId])).toEqual([
|
||||
['Remote', 'builder'],
|
||||
['SSH child', 'builder']
|
||||
])
|
||||
expect(store.getRepo('local-repo')?.projectGroupId).toBeNull()
|
||||
expect(store.getRepo('ssh-repo')?.projectGroupId).toBe('child')
|
||||
expect(store.getFolderWorkspace(localWorkspace.id)).toBeUndefined()
|
||||
expect(store.getFolderWorkspace(sshWorkspace.id)).toBeDefined()
|
||||
})
|
||||
|
||||
it('resolves same-id folder workspace creation from explicit connection ownership', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
projectGroups: [
|
||||
{
|
||||
id: 'same-id',
|
||||
name: 'Local',
|
||||
parentPath: '/local',
|
||||
connectionId: null,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'same-id',
|
||||
name: 'SSH',
|
||||
parentPath: '/remote',
|
||||
connectionId: 'builder',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 1,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
]
|
||||
})
|
||||
const store = await createStore()
|
||||
|
||||
expect(
|
||||
store.createFolderWorkspace({ projectGroupId: 'same-id', connectionId: null }).folderPath
|
||||
).toBe('/local')
|
||||
expect(
|
||||
store.createFolderWorkspace({ projectGroupId: 'same-id', connectionId: 'builder' }).folderPath
|
||||
).toBe('/remote')
|
||||
expect(() => store.createFolderWorkspace({ projectGroupId: 'same-id' })).toThrow(
|
||||
'Folder-backed project group not found.'
|
||||
)
|
||||
})
|
||||
|
||||
it('adapts flat folder-scan groups into sparse nested folder scopes on load', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
@@ -5304,6 +5516,137 @@ describe('Store', () => {
|
||||
expect(store.getFolderWorkspaces()[0]).toMatchObject({ id: 'fw-1', connectionId: 'ssh-1' })
|
||||
})
|
||||
|
||||
it('does not backfill folder scope provenance across same-id owners', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [
|
||||
makeRepo({
|
||||
id: 'remote-repo',
|
||||
path: '/remote/repo',
|
||||
projectGroupId: 'same-id',
|
||||
connectionId: 'ssh-1'
|
||||
})
|
||||
],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
projectGroups: [
|
||||
{
|
||||
id: 'same-id',
|
||||
name: 'Local',
|
||||
parentPath: '/local',
|
||||
connectionId: null,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'same-id',
|
||||
name: 'SSH',
|
||||
parentPath: '/remote',
|
||||
connectionId: 'ssh-1',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 1,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
folderWorkspaces: [
|
||||
{
|
||||
id: 'local-folder',
|
||||
projectGroupId: 'same-id',
|
||||
name: 'Local folder',
|
||||
folderPath: '/local',
|
||||
connectionId: null
|
||||
},
|
||||
{
|
||||
id: 'ssh-folder',
|
||||
projectGroupId: 'same-id',
|
||||
name: 'SSH folder',
|
||||
folderPath: '/remote',
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getProjectGroups().map((group) => [group.name, group.connectionId])).toEqual([
|
||||
['Local', null],
|
||||
['SSH', 'ssh-1']
|
||||
])
|
||||
expect(
|
||||
store.getFolderWorkspaces().map((workspace) => [workspace.name, workspace.connectionId])
|
||||
).toEqual([
|
||||
['Local folder', null],
|
||||
['SSH folder', 'ssh-1']
|
||||
])
|
||||
})
|
||||
|
||||
it('normalizes same-id runtime folder workspaces against their explicit owner', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: {},
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
projectGroups: [
|
||||
{
|
||||
id: 'same-id',
|
||||
name: 'Runtime',
|
||||
parentPath: '/runtime',
|
||||
executionHostId: 'runtime:hub-1',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
},
|
||||
{
|
||||
id: 'same-id',
|
||||
name: 'Local',
|
||||
parentPath: '/local',
|
||||
parentGroupId: null,
|
||||
createdFrom: 'folder-scan',
|
||||
tabOrder: 1,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
folderWorkspaces: [
|
||||
{
|
||||
id: 'runtime-folder',
|
||||
projectGroupId: 'same-id',
|
||||
name: 'Runtime folder',
|
||||
executionHostId: 'runtime:hub-1'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
|
||||
expect(store.getFolderWorkspaces()).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'runtime-folder',
|
||||
folderPath: '/runtime',
|
||||
executionHostId: 'runtime:hub-1'
|
||||
})
|
||||
])
|
||||
})
|
||||
|
||||
it('removes folder workspace metadata and its scoped session state only', async () => {
|
||||
const store = await createStore()
|
||||
const group = store.createProjectGroup({
|
||||
|
||||
+134
-52
@@ -219,12 +219,18 @@ import { isLegacyRepoForExternalWorktreeVisibility } from '../shared/worktree-ow
|
||||
import { sanitizeRepoIcon } from '../shared/repo-icon'
|
||||
import { normalizeRepoBadgeColor } from '../shared/repo-badge-color'
|
||||
import {
|
||||
buildProjectGroupOwnerIndex,
|
||||
clearMissingProjectGroupMemberships,
|
||||
createProjectGroup,
|
||||
getNextProjectGroupOrder,
|
||||
getProjectGroupSubtreeIds,
|
||||
getProjectGroupOwnerHostId,
|
||||
getProjectGroupOwnerIdentity,
|
||||
getProjectGroupOwnerSubtreeIdentities,
|
||||
normalizeProjectGroupName,
|
||||
normalizeProjectGroups
|
||||
normalizeProjectGroups,
|
||||
resolveProjectGroupMembership,
|
||||
resolveProjectGroupOwner,
|
||||
resolveFolderWorkspaceProjectGroup
|
||||
} from '../shared/project-groups'
|
||||
import { createNestedProjectGroupResolver } from './project-groups/nested-repo-import'
|
||||
import {
|
||||
@@ -2684,13 +2690,31 @@ function removeWorkspaceSessionOwners(
|
||||
|
||||
function inferFolderScopeConnectionIdForMigration(args: {
|
||||
folderPath: string
|
||||
projectGroupId: string
|
||||
projectGroup: ProjectGroup
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
projectGroupIndex: ReturnType<typeof buildProjectGroupOwnerIndex>
|
||||
repos: readonly Repo[]
|
||||
}): string | null {
|
||||
const groupIds = getProjectGroupSubtreeIds(args.projectGroups, args.projectGroupId)
|
||||
if (args.projectGroupIndex.byId.get(args.projectGroup.id)?.length !== 1) {
|
||||
return null
|
||||
}
|
||||
const groupIdentities = getProjectGroupOwnerSubtreeIdentities(
|
||||
args.projectGroups,
|
||||
args.projectGroup
|
||||
)
|
||||
const groupRepos = args.repos.filter(
|
||||
(repo) => typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)
|
||||
(repo) =>
|
||||
typeof repo.projectGroupId === 'string' &&
|
||||
(() => {
|
||||
const exactGroup = resolveProjectGroupMembership(
|
||||
args.projectGroupIndex,
|
||||
repo.projectGroupId,
|
||||
getRepoExecutionHostId(repo)
|
||||
)
|
||||
const group =
|
||||
exactGroup ?? resolveProjectGroupOwner(args.projectGroupIndex, repo.projectGroupId)
|
||||
return group !== null && groupIdentities.has(getProjectGroupOwnerIdentity(group))
|
||||
})()
|
||||
)
|
||||
const candidateRepos =
|
||||
groupRepos.length > 0
|
||||
@@ -2720,15 +2744,17 @@ function backfillFolderScopeConnectionIds(state: PersistedState): {
|
||||
} {
|
||||
const groups = state.projectGroups ?? []
|
||||
const repos = state.repos ?? []
|
||||
const originalProjectGroupIndex = buildProjectGroupOwnerIndex(groups)
|
||||
let changed = false
|
||||
const projectGroups = groups.map((group) => {
|
||||
if (group.connectionId || !group.parentPath) {
|
||||
if (getProjectGroupOwnerHostId(group) !== LOCAL_EXECUTION_HOST_ID || !group.parentPath) {
|
||||
return group
|
||||
}
|
||||
const connectionId = inferFolderScopeConnectionIdForMigration({
|
||||
folderPath: group.parentPath,
|
||||
projectGroupId: group.id,
|
||||
projectGroup: group,
|
||||
projectGroups: groups,
|
||||
projectGroupIndex: originalProjectGroupIndex,
|
||||
repos
|
||||
})
|
||||
if (!connectionId) {
|
||||
@@ -2737,20 +2763,24 @@ function backfillFolderScopeConnectionIds(state: PersistedState): {
|
||||
changed = true
|
||||
return { ...group, connectionId }
|
||||
})
|
||||
const groupsById = new Map(projectGroups.map((group) => [group.id, group]))
|
||||
const projectGroupIndex = buildProjectGroupOwnerIndex(projectGroups)
|
||||
const folderWorkspaces = (state.folderWorkspaces ?? []).map((workspace) => {
|
||||
if (workspace.connectionId) {
|
||||
if (workspace.connectionId || normalizeExecutionHostId(workspace.executionHostId)) {
|
||||
return workspace
|
||||
}
|
||||
const groupConnectionId = groupsById.get(workspace.projectGroupId)?.connectionId ?? null
|
||||
const group = resolveProjectGroupOwner(projectGroupIndex, workspace.projectGroupId)
|
||||
const groupConnectionId = group?.connectionId ?? null
|
||||
const connectionId =
|
||||
groupConnectionId ??
|
||||
inferFolderScopeConnectionIdForMigration({
|
||||
folderPath: workspace.folderPath,
|
||||
projectGroupId: workspace.projectGroupId,
|
||||
projectGroups,
|
||||
repos
|
||||
})
|
||||
(group
|
||||
? inferFolderScopeConnectionIdForMigration({
|
||||
folderPath: workspace.folderPath,
|
||||
projectGroup: group,
|
||||
projectGroups,
|
||||
projectGroupIndex,
|
||||
repos
|
||||
})
|
||||
: null)
|
||||
if (!connectionId) {
|
||||
return workspace
|
||||
}
|
||||
@@ -3769,6 +3799,16 @@ export class Store {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
|
||||
// Why: legacy folder groups derive SSH ownership from their assigned repos; stamp that owner before exact-owner membership validation.
|
||||
const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({
|
||||
...result,
|
||||
workspaceSession: migratedScrollback.session
|
||||
})
|
||||
if (folderScopeConnectionMigration.changed) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
result = folderScopeConnectionMigration.state
|
||||
|
||||
const repos = clearMissingProjectGroupMemberships(result.repos, result.projectGroups ?? [])
|
||||
const projectHostSetupCompatibility = mergeProjectHostSetupCompatibilityState(result, repos)
|
||||
if (!projectHostSetupCompatibilityStateEqual(result, projectHostSetupCompatibility)) {
|
||||
@@ -3785,21 +3825,12 @@ export class Store {
|
||||
}
|
||||
result = {
|
||||
...result,
|
||||
repos,
|
||||
...projectHostSetupCompatibility,
|
||||
automations: automationContextMigration.state.automations,
|
||||
automationRuns: automationContextMigration.state.automationRuns
|
||||
}
|
||||
|
||||
const folderScopeConnectionMigration = backfillFolderScopeConnectionIds({
|
||||
...result,
|
||||
repos,
|
||||
...projectHostSetupCompatibility,
|
||||
workspaceSession: migratedScrollback.session
|
||||
})
|
||||
if (folderScopeConnectionMigration.changed) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
result = folderScopeConnectionMigration.state
|
||||
|
||||
if (normalizeWorktreeLinkedItemMetadata(result)) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
@@ -4399,9 +4430,15 @@ export class Store {
|
||||
|
||||
updateProjectGroup(
|
||||
groupId: string,
|
||||
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>
|
||||
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>,
|
||||
ownerHostId?: ExecutionHostId
|
||||
): ProjectGroup | null {
|
||||
const group = (this.state.projectGroups ?? []).find((entry) => entry.id === groupId)
|
||||
const projectGroups = this.state.projectGroups ?? []
|
||||
const group = resolveProjectGroupOwner(
|
||||
buildProjectGroupOwnerIndex(projectGroups),
|
||||
groupId,
|
||||
ownerHostId
|
||||
)
|
||||
if (!group) {
|
||||
return null
|
||||
}
|
||||
@@ -4422,24 +4459,39 @@ export class Store {
|
||||
return group
|
||||
}
|
||||
|
||||
deleteProjectGroup(groupId: string): boolean {
|
||||
deleteProjectGroup(groupId: string, ownerHostId?: ExecutionHostId): boolean {
|
||||
const projectGroups = this.state.projectGroups ?? []
|
||||
const projectGroupIndex = buildProjectGroupOwnerIndex(projectGroups)
|
||||
const rootGroup = resolveProjectGroupOwner(projectGroupIndex, groupId, ownerHostId)
|
||||
if (!rootGroup) {
|
||||
return false
|
||||
}
|
||||
const before = this.state.projectGroups?.length ?? 0
|
||||
const deletedGroupIds = getProjectGroupSubtreeIds(this.state.projectGroups ?? [], groupId)
|
||||
this.state.projectGroups = (this.state.projectGroups ?? []).filter(
|
||||
(group) => !deletedGroupIds.has(group.id)
|
||||
const deletedGroupIdentities = getProjectGroupOwnerSubtreeIdentities(projectGroups, rootGroup)
|
||||
this.state.projectGroups = projectGroups.filter(
|
||||
(group) => !deletedGroupIdentities.has(getProjectGroupOwnerIdentity(group))
|
||||
)
|
||||
if ((this.state.projectGroups?.length ?? 0) === before) {
|
||||
return false
|
||||
}
|
||||
// Why: groups are sidebar organization only, so deleting one ungroups its repos rather than deleting them.
|
||||
this.state.repos = this.state.repos.map((repo) =>
|
||||
repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId)
|
||||
this.state.repos = this.state.repos.map((repo) => {
|
||||
if (!repo.projectGroupId) {
|
||||
return repo
|
||||
}
|
||||
const group = resolveProjectGroupMembership(
|
||||
projectGroupIndex,
|
||||
repo.projectGroupId,
|
||||
getRepoExecutionHostId(repo)
|
||||
)
|
||||
return group && deletedGroupIdentities.has(getProjectGroupOwnerIdentity(group))
|
||||
? { ...repo, projectGroupId: null }
|
||||
: repo
|
||||
)
|
||||
})
|
||||
const removedFolderWorkspaceKeys = new Set<string>()
|
||||
for (const workspace of this.state.folderWorkspaces ?? []) {
|
||||
if (deletedGroupIds.has(workspace.projectGroupId)) {
|
||||
const group = resolveFolderWorkspaceProjectGroup(projectGroupIndex, workspace)
|
||||
if (group && deletedGroupIdentities.has(getProjectGroupOwnerIdentity(group))) {
|
||||
removedFolderWorkspaceKeys.add(folderWorkspaceKey(workspace.id))
|
||||
this.state.workspaceSession = removeWorkspaceSessionOwner(
|
||||
this.state.workspaceSession,
|
||||
@@ -4448,9 +4500,10 @@ export class Store {
|
||||
this.removeWorkspaceLineageForFolderParent(workspace.id)
|
||||
}
|
||||
}
|
||||
this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter(
|
||||
(workspace) => !deletedGroupIds.has(workspace.projectGroupId)
|
||||
)
|
||||
this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter((workspace) => {
|
||||
const group = resolveFolderWorkspaceProjectGroup(projectGroupIndex, workspace)
|
||||
return !group || !deletedGroupIdentities.has(getProjectGroupOwnerIdentity(group))
|
||||
})
|
||||
this.pruneMobileClientTabSelections((worktreeId) => removedFolderWorkspaceKeys.has(worktreeId))
|
||||
this.scheduleSave()
|
||||
return true
|
||||
@@ -4476,8 +4529,15 @@ export class Store {
|
||||
createdWithAgent?: FolderWorkspace['createdWithAgent']
|
||||
pendingFirstAgentMessageRename?: boolean
|
||||
}): FolderWorkspace {
|
||||
const group = (this.state.projectGroups ?? []).find(
|
||||
(entry) => entry.id === input.projectGroupId
|
||||
const projectGroupIndex = buildProjectGroupOwnerIndex(this.state.projectGroups ?? [])
|
||||
const group = resolveProjectGroupOwner(
|
||||
projectGroupIndex,
|
||||
input.projectGroupId,
|
||||
input.connectionId !== undefined
|
||||
? input.connectionId
|
||||
? toSshExecutionHostId(input.connectionId)
|
||||
: LOCAL_EXECUTION_HOST_ID
|
||||
: undefined
|
||||
)
|
||||
const folderPath =
|
||||
typeof input.folderPath === 'string' && input.folderPath.trim().length > 0
|
||||
@@ -4633,21 +4693,43 @@ export class Store {
|
||||
return true
|
||||
}
|
||||
|
||||
moveProjectToGroup(repoId: string, groupId: string | null, order?: number): Repo | null {
|
||||
const repo = this.state.repos.find((entry) => entry.id === repoId)
|
||||
if (!repo) {
|
||||
moveProjectToGroup(
|
||||
repoId: string,
|
||||
groupId: string | null,
|
||||
order?: number,
|
||||
ownerHostId?: ExecutionHostId
|
||||
): Repo | null {
|
||||
const hasExplicitOwner = ownerHostId !== undefined
|
||||
if (hasExplicitOwner && !parseExecutionHostId(ownerHostId)) {
|
||||
return null
|
||||
}
|
||||
const normalizedGroupId =
|
||||
groupId && (this.state.projectGroups ?? []).some((group) => group.id === groupId)
|
||||
? groupId
|
||||
: null
|
||||
const siblingRepos = this.state.repos.filter((entry) => entry.id !== repoId)
|
||||
repo.projectGroupId = normalizedGroupId
|
||||
const matchingRepos = this.state.repos.filter(
|
||||
(entry) =>
|
||||
entry.id === repoId && (!hasExplicitOwner || getRepoExecutionHostId(entry) === ownerHostId)
|
||||
)
|
||||
if (matchingRepos.length !== 1) {
|
||||
return null
|
||||
}
|
||||
const repo = matchingRepos[0]
|
||||
const repoOwnerHostId = getRepoExecutionHostId(repo)
|
||||
if (
|
||||
groupId !== null &&
|
||||
!resolveProjectGroupOwner(
|
||||
buildProjectGroupOwnerIndex(this.state.projectGroups ?? []),
|
||||
groupId,
|
||||
repoOwnerHostId
|
||||
)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const siblingRepos = this.state.repos.filter(
|
||||
(entry) => entry !== repo && getRepoExecutionHostId(entry) === repoOwnerHostId
|
||||
)
|
||||
repo.projectGroupId = groupId
|
||||
repo.projectGroupOrder =
|
||||
typeof order === 'number' && Number.isFinite(order)
|
||||
? order
|
||||
: getNextProjectGroupOrder(siblingRepos, normalizedGroupId)
|
||||
: getNextProjectGroupOrder(siblingRepos, groupId)
|
||||
this.scheduleSave()
|
||||
return this.hydrateRepo(repo)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
getFolderWorkspacePathStatus,
|
||||
getFolderWorkspacePathStatusForPath,
|
||||
inferFolderWorkspacePathConnection
|
||||
inferFolderWorkspacePathConnection,
|
||||
resolveFolderWorkspaceStatusPath
|
||||
} from './folder-workspace-path-status'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
import type { ProjectGroup, Repo } from '../../shared/types'
|
||||
@@ -39,6 +40,33 @@ function makeRepo(overrides: Partial<Repo> = {}): Repo {
|
||||
}
|
||||
}
|
||||
|
||||
it('resolves same-id project-group paths by exact owner', () => {
|
||||
const local = makeGroup({ id: 'same-id', parentPath: '/local' })
|
||||
const ssh = makeGroup({ id: 'same-id', parentPath: '/remote', connectionId: 'builder' })
|
||||
const store = {
|
||||
getRepos: () => [],
|
||||
getProjectGroups: () => [local, ssh]
|
||||
}
|
||||
|
||||
expect(
|
||||
resolveFolderWorkspaceStatusPath({
|
||||
store,
|
||||
request: { scope: 'project-group', projectGroupId: 'same-id', ownerHostId: 'ssh:builder' }
|
||||
})
|
||||
).toEqual({
|
||||
folderPath: '/remote',
|
||||
projectGroupId: 'same-id',
|
||||
connectionId: 'builder',
|
||||
ownerHostId: 'ssh:builder'
|
||||
})
|
||||
expect(() =>
|
||||
resolveFolderWorkspaceStatusPath({
|
||||
store,
|
||||
request: { scope: 'project-group', projectGroupId: 'same-id' }
|
||||
})
|
||||
).toThrow('folder_workspace_path_scope_not_found')
|
||||
})
|
||||
|
||||
describe('folder workspace path status', () => {
|
||||
it('reports existing local directories and local files', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-folder-status-'))
|
||||
|
||||
@@ -4,7 +4,15 @@ import type {
|
||||
FolderWorkspacePathStatus,
|
||||
FolderWorkspacePathStatusRequest
|
||||
} from '../../shared/folder-workspace-path-status'
|
||||
import { getProjectGroupSubtreeIds } from '../../shared/project-groups'
|
||||
import {
|
||||
buildProjectGroupOwnerIndex,
|
||||
getFolderWorkspaceProjectGroupOwnerHostId,
|
||||
getProjectGroupOwnerHostId,
|
||||
getProjectGroupSubtreeIds,
|
||||
resolveProjectGroupOwner,
|
||||
resolveFolderWorkspaceProjectGroup
|
||||
} from '../../shared/project-groups'
|
||||
import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
|
||||
import type { FolderWorkspace, ProjectGroup, Repo } from '../../shared/types'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
|
||||
@@ -152,19 +160,29 @@ export async function getFolderWorkspacePathStatusForPath(
|
||||
export function resolveFolderWorkspaceStatusPath(args: {
|
||||
store: FolderWorkspacePathStatusStore
|
||||
request: FolderWorkspacePathStatusRequest
|
||||
}): { folderPath: string; projectGroupId: string | null; connectionId?: string | null } {
|
||||
}): {
|
||||
folderPath: string
|
||||
projectGroupId: string | null
|
||||
connectionId?: string | null
|
||||
ownerHostId?: ExecutionHostId
|
||||
} {
|
||||
const { request } = args
|
||||
const projectGroups = args.store.getProjectGroups?.() ?? []
|
||||
const projectGroupIndex = buildProjectGroupOwnerIndex(projectGroups)
|
||||
if (request.scope === 'project-group') {
|
||||
const group = args.store
|
||||
.getProjectGroups?.()
|
||||
.find((entry) => entry.id === request.projectGroupId)
|
||||
const group = resolveProjectGroupOwner(
|
||||
projectGroupIndex,
|
||||
request.projectGroupId,
|
||||
request.ownerHostId
|
||||
)
|
||||
if (!group?.parentPath) {
|
||||
throw new Error('folder_workspace_path_scope_not_found')
|
||||
}
|
||||
return {
|
||||
folderPath: group.parentPath,
|
||||
projectGroupId: group.id,
|
||||
connectionId: group.connectionId ?? null
|
||||
connectionId: group.connectionId ?? null,
|
||||
ownerHostId: getProjectGroupOwnerHostId(group)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,13 +200,14 @@ export function resolveFolderWorkspaceStatusPath(args: {
|
||||
if (!workspace) {
|
||||
throw new Error('folder_workspace_path_scope_not_found')
|
||||
}
|
||||
const group = args.store
|
||||
.getProjectGroups?.()
|
||||
.find((entry) => entry.id === workspace.projectGroupId)
|
||||
const group = resolveFolderWorkspaceProjectGroup(projectGroupIndex, workspace)
|
||||
// Why: fail closed only when the workspace row is missing; group may lag hydrate on Store doubles.
|
||||
const ownerHostId = getFolderWorkspaceProjectGroupOwnerHostId(workspace, projectGroupIndex)
|
||||
return {
|
||||
folderPath: workspace.folderPath,
|
||||
projectGroupId: workspace.projectGroupId,
|
||||
connectionId: workspace.connectionId ?? group?.connectionId ?? null
|
||||
connectionId: workspace.connectionId ?? group?.connectionId ?? null,
|
||||
ownerHostId
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,13 +217,18 @@ export async function getFolderWorkspacePathStatus(
|
||||
deps: FolderWorkspacePathStatusDeps
|
||||
): Promise<FolderWorkspacePathStatus> {
|
||||
const scope = resolveFolderWorkspaceStatusPath({ store, request })
|
||||
const projectGroups = store.getProjectGroups?.() ?? []
|
||||
return getFolderWorkspacePathStatusForPath(
|
||||
{
|
||||
folderPath: scope.folderPath,
|
||||
projectGroupId: scope.projectGroupId,
|
||||
connectionId: scope.connectionId,
|
||||
projectGroups: store.getProjectGroups?.() ?? [],
|
||||
repos: store.getRepos()
|
||||
projectGroups: scope.ownerHostId
|
||||
? projectGroups.filter((group) => getProjectGroupOwnerHostId(group) === scope.ownerHostId)
|
||||
: projectGroups,
|
||||
repos: scope.ownerHostId
|
||||
? store.getRepos().filter((repo) => getRepoExecutionHostId(repo) === scope.ownerHostId)
|
||||
: store.getRepos()
|
||||
},
|
||||
deps
|
||||
)
|
||||
|
||||
@@ -1345,13 +1345,15 @@ export type PreloadApi = {
|
||||
}) => Promise<ProjectGroup>
|
||||
update: (args: {
|
||||
groupId: string
|
||||
ownerHostId?: ExecutionHostId
|
||||
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>
|
||||
}) => Promise<ProjectGroup | null>
|
||||
delete: (args: { groupId: string }) => Promise<boolean>
|
||||
delete: (args: { groupId: string; ownerHostId?: ExecutionHostId }) => Promise<boolean>
|
||||
moveProject: (args: {
|
||||
projectId: string
|
||||
groupId: string | null
|
||||
order?: number
|
||||
ownerHostId?: ExecutionHostId
|
||||
}) => Promise<Repo | null>
|
||||
scanNested: (args: {
|
||||
path: string
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ProjectGroup } from '../../../../shared/types'
|
||||
import {
|
||||
buildProjectGroupSidebarIndex,
|
||||
findProjectGroupForSidebarOwner,
|
||||
getProjectGroupSidebarIdentity,
|
||||
hasSingleProjectGroupMutationOwner
|
||||
} from './project-group-sidebar-identity'
|
||||
|
||||
function group(id: string, executionHostId: string): ProjectGroup {
|
||||
return {
|
||||
id,
|
||||
name: `${executionHostId}:${id}`,
|
||||
parentPath: null,
|
||||
executionHostId,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('project-group sidebar identity', () => {
|
||||
it('resolves duplicate ids only with their catalog owner', () => {
|
||||
const local = group('same-id', 'local')
|
||||
const runtime = group('same-id', 'runtime:env-1')
|
||||
const index = buildProjectGroupSidebarIndex([local, runtime])
|
||||
|
||||
expect(findProjectGroupForSidebarOwner(index, 'same-id')).toBeUndefined()
|
||||
expect(findProjectGroupForSidebarOwner(index, 'same-id', 'local')).toBe(local)
|
||||
expect(findProjectGroupForSidebarOwner(index, 'same-id', 'runtime:env-1')).toBe(runtime)
|
||||
expect(getProjectGroupSidebarIdentity(local)).not.toBe(getProjectGroupSidebarIdentity(runtime))
|
||||
})
|
||||
|
||||
it('disables focused-host mutations for mixed-owner and mismatched lists', () => {
|
||||
const localA = group('a', 'local')
|
||||
const localB = group('b', 'local')
|
||||
const runtime = group('c', 'runtime:env-1')
|
||||
|
||||
expect(hasSingleProjectGroupMutationOwner([localA, localB], 'local')).toBe(true)
|
||||
expect(hasSingleProjectGroupMutationOwner([localA, localB], 'runtime:env-1')).toBe(false)
|
||||
expect(hasSingleProjectGroupMutationOwner([localA, runtime], 'local')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
normalizeExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import {
|
||||
getProjectGroupOwnerHostId as getSharedProjectGroupOwnerHostId,
|
||||
getProjectGroupOwnerIdentity
|
||||
} from '../../../../shared/project-groups'
|
||||
|
||||
export type ProjectGroupSidebarIdentity = string
|
||||
|
||||
export function getProjectGroupOwnerHostId(
|
||||
group: Pick<ProjectGroup, 'connectionId' | 'executionHostId'>
|
||||
): ExecutionHostId {
|
||||
return getSharedProjectGroupOwnerHostId(group)
|
||||
}
|
||||
|
||||
export function getProjectGroupSidebarIdentity(
|
||||
group: Pick<ProjectGroup, 'id' | 'connectionId' | 'executionHostId'>
|
||||
): ProjectGroupSidebarIdentity {
|
||||
return getProjectGroupOwnerIdentity(group)
|
||||
}
|
||||
|
||||
export function hasSingleProjectGroupMutationOwner(
|
||||
groups: readonly ProjectGroup[],
|
||||
routedHostId: ExecutionHostId
|
||||
): boolean {
|
||||
if (groups.length === 0) {
|
||||
return false
|
||||
}
|
||||
return groups.every((group) => getProjectGroupOwnerHostId(group) === routedHostId)
|
||||
}
|
||||
|
||||
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 }
|
||||
}
|
||||
let ownerHostId: ExecutionHostId | null
|
||||
let groupId: string
|
||||
try {
|
||||
ownerHostId = normalizeExecutionHostId(decodeURIComponent(value.slice(0, separator)))
|
||||
groupId = decodeURIComponent(value.slice(separator + 1))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (!ownerHostId) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
ownerHostId,
|
||||
groupId
|
||||
}
|
||||
}
|
||||
|
||||
export type ProjectGroupSidebarIndex = {
|
||||
byIdentity: ReadonlyMap<ProjectGroupSidebarIdentity, ProjectGroup>
|
||||
byUnambiguousId: ReadonlyMap<string, ProjectGroup>
|
||||
}
|
||||
|
||||
export function buildProjectGroupSidebarIndex(
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
): ProjectGroupSidebarIndex {
|
||||
const byIdentity = new Map<ProjectGroupSidebarIdentity, ProjectGroup>()
|
||||
const byUnambiguousId = new Map<string, ProjectGroup>()
|
||||
const ambiguousIds = new Set<string>()
|
||||
for (const group of projectGroups) {
|
||||
byIdentity.set(getProjectGroupSidebarIdentity(group), group)
|
||||
if (ambiguousIds.has(group.id)) {
|
||||
continue
|
||||
}
|
||||
const existing = byUnambiguousId.get(group.id)
|
||||
if (
|
||||
existing &&
|
||||
getProjectGroupSidebarIdentity(existing) !== getProjectGroupSidebarIdentity(group)
|
||||
) {
|
||||
byUnambiguousId.delete(group.id)
|
||||
ambiguousIds.add(group.id)
|
||||
} else {
|
||||
byUnambiguousId.set(group.id, group)
|
||||
}
|
||||
}
|
||||
return { byIdentity, byUnambiguousId }
|
||||
}
|
||||
|
||||
export function findProjectGroupForSidebarOwner(
|
||||
index: ProjectGroupSidebarIndex,
|
||||
groupId: string | null | undefined,
|
||||
ownerHostId?: ExecutionHostId | null
|
||||
): ProjectGroup | undefined {
|
||||
if (!groupId) {
|
||||
return undefined
|
||||
}
|
||||
if (ownerHostId) {
|
||||
const exact = index.byIdentity.get(JSON.stringify([ownerHostId, groupId]))
|
||||
if (exact) {
|
||||
return exact
|
||||
}
|
||||
}
|
||||
return index.byUnambiguousId.get(groupId)
|
||||
}
|
||||
|
||||
export function findProjectGroupForRepo(
|
||||
index: ProjectGroupSidebarIndex,
|
||||
repo: Pick<Repo, 'connectionId' | 'executionHostId' | 'projectGroupId'> | undefined,
|
||||
defaultHostId: ExecutionHostId
|
||||
): ProjectGroup | undefined {
|
||||
if (!repo?.projectGroupId) {
|
||||
return undefined
|
||||
}
|
||||
const ownerHostId =
|
||||
repo.connectionId || repo.executionHostId ? getRepoExecutionHostId(repo) : defaultHostId
|
||||
return findProjectGroupForSidebarOwner(index, repo.projectGroupId, ownerHostId)
|
||||
}
|
||||
|
||||
export function findProjectGroupForFolderWorkspace(
|
||||
index: ProjectGroupSidebarIndex,
|
||||
workspace: Pick<FolderWorkspace, 'connectionId' | 'executionHostId' | 'projectGroupId'>
|
||||
): ProjectGroup | undefined {
|
||||
const explicitHostId = normalizeExecutionHostId(workspace.executionHostId)
|
||||
// Why: connectionId null is an explicit local stamp; leave undefined only when owner is unknown.
|
||||
const ownerHostId =
|
||||
explicitHostId ??
|
||||
(workspace.connectionId
|
||||
? toSshExecutionHostId(workspace.connectionId)
|
||||
: workspace.connectionId === null
|
||||
? ('local' as ExecutionHostId)
|
||||
: undefined)
|
||||
return findProjectGroupForSidebarOwner(index, workspace.projectGroupId, ownerHostId)
|
||||
}
|
||||
|
||||
export function findProjectGroupParentForSidebar(
|
||||
index: ProjectGroupSidebarIndex,
|
||||
group: ProjectGroup
|
||||
): ProjectGroup | undefined {
|
||||
return findProjectGroupForSidebarOwner(
|
||||
index,
|
||||
group.parentGroupId,
|
||||
getProjectGroupOwnerHostId(group)
|
||||
)
|
||||
}
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
getEffectiveProjectGroupManualRank,
|
||||
UNGROUPED_PROJECT_GROUP_KEY
|
||||
} from '../../../../shared/project-groups'
|
||||
import { getFolderWorkspaceRowKey } from '../../../../shared/folder-workspaces'
|
||||
import { cloneDefaultWorkspaceStatuses } from '../../../../shared/workspace-statuses'
|
||||
import type { AppState } from '../../store/types'
|
||||
import { getGitHubPRCacheKey, getLegacyGitHubPRCacheKey } from '../../store/slices/github-cache-key'
|
||||
@@ -51,6 +52,18 @@ import {
|
||||
getCyclicProjectedWorktreeLineageIds,
|
||||
getLineageRenderInfo
|
||||
} from './worktree-lineage-projection'
|
||||
import {
|
||||
getFolderWorkspaceExecutionHostIdForRows,
|
||||
getProjectGroupExecutionHostIdForRows
|
||||
} from './worktree-list-host-filtering'
|
||||
import {
|
||||
buildProjectGroupSidebarIndex,
|
||||
findProjectGroupForFolderWorkspace,
|
||||
findProjectGroupForRepo,
|
||||
findProjectGroupParentForSidebar,
|
||||
getProjectGroupOwnerHostId,
|
||||
getProjectGroupSidebarIdentity
|
||||
} from './project-group-sidebar-identity'
|
||||
|
||||
export { getLineageRenderInfo } from './worktree-lineage-projection'
|
||||
|
||||
@@ -74,11 +87,15 @@ export type GroupHeaderRow = {
|
||||
icon?: React.ComponentType<{ className?: string }>
|
||||
repo?: Repo
|
||||
projectGroup?: ProjectGroup | { id: null; name: 'Ungrouped'; tabOrder: number }
|
||||
projectGroupOwnerHostId?: ExecutionHostId
|
||||
collapseKey?: string
|
||||
projectGroupDepth?: number
|
||||
hostId?: ExecutionHostId
|
||||
hostWorktreeCounts?: ReadonlyMap<ExecutionHostId, number>
|
||||
hostWorktreeIds?: ReadonlyMap<ExecutionHostId, readonly string[]>
|
||||
hostFolderWorkspaceIds?: ReadonlyMap<ExecutionHostId, readonly string[]>
|
||||
worktreeIds?: readonly string[]
|
||||
folderWorkspaceIds?: readonly string[]
|
||||
}
|
||||
|
||||
export type WorktreeRow = {
|
||||
@@ -178,6 +195,15 @@ type WorktreeGroupEntry = {
|
||||
repoIds: Set<string>
|
||||
}
|
||||
|
||||
type ProjectGroupHostMetrics = {
|
||||
count: number
|
||||
hostCounts: Map<ExecutionHostId, number>
|
||||
hostWorktreeIds: Map<ExecutionHostId, string[]>
|
||||
hostFolderWorkspaceIds: Map<ExecutionHostId, string[]>
|
||||
worktreeIds: string[]
|
||||
folderWorkspaceIds: string[]
|
||||
}
|
||||
|
||||
type ProjectGroupingIndex = {
|
||||
projectById: Map<string, Project>
|
||||
setupByRepoId: Map<string, ProjectHostSetup>
|
||||
@@ -365,8 +391,16 @@ export const PROJECT_GROUP_META = {
|
||||
icon: FolderTree
|
||||
} as const
|
||||
|
||||
export function getProjectGroupHeaderKey(groupId: string | null): string {
|
||||
return groupId ? `project-group:${groupId}` : UNGROUPED_PROJECT_GROUP_KEY
|
||||
export function getProjectGroupHeaderKey(
|
||||
groupId: string | null,
|
||||
ownerHostId?: ExecutionHostId
|
||||
): string {
|
||||
if (!groupId) {
|
||||
return UNGROUPED_PROJECT_GROUP_KEY
|
||||
}
|
||||
return ownerHostId
|
||||
? `project-group:${encodeURIComponent(ownerHostId)}:${encodeURIComponent(groupId)}`
|
||||
: `project-group:${groupId}`
|
||||
}
|
||||
|
||||
export const PINNED_GROUP_KEY = 'pinned'
|
||||
@@ -1260,7 +1294,10 @@ export function buildRows(
|
||||
tone: PROJECT_GROUP_META.tone,
|
||||
icon: PROJECT_GROUP_META.icon,
|
||||
repo,
|
||||
projectGroupDepth
|
||||
projectGroupDepth,
|
||||
hostWorktreeCounts: getHostWorktreeCounts(group.items, repoMap, defaultHostId),
|
||||
hostWorktreeIds: getHostWorktreeIds(group.items, repoMap, defaultHostId),
|
||||
worktreeIds: group.items.map((worktree) => worktree.id)
|
||||
}
|
||||
: groupBy === 'workspace-status'
|
||||
? (() => {
|
||||
@@ -1369,13 +1406,15 @@ export function buildRows(
|
||||
return result
|
||||
}
|
||||
|
||||
const groupByProjectGroupId = new Map<string | null, OrderedGroupEntry[]>()
|
||||
const projectGroupIndex = buildProjectGroupSidebarIndex(projectGroups)
|
||||
const groupByProjectGroupIdentity = new Map<string | null, OrderedGroupEntry[]>()
|
||||
for (const entry of orderedGroups) {
|
||||
const repo = entry[1].repo
|
||||
const projectGroupId = repo?.projectGroupId ?? null
|
||||
const list = groupByProjectGroupId.get(projectGroupId) ?? []
|
||||
const projectGroup = findProjectGroupForRepo(projectGroupIndex, repo, defaultHostId)
|
||||
const projectGroupIdentity = projectGroup ? getProjectGroupSidebarIdentity(projectGroup) : null
|
||||
const list = groupByProjectGroupIdentity.get(projectGroupIdentity) ?? []
|
||||
list.push(entry)
|
||||
groupByProjectGroupId.set(projectGroupId, list)
|
||||
groupByProjectGroupIdentity.set(projectGroupIdentity, list)
|
||||
}
|
||||
|
||||
const sortRepoEntriesWithinGroup = (entries: OrderedGroupEntry[]): OrderedGroupEntry[] => {
|
||||
@@ -1394,67 +1433,162 @@ export function buildRows(
|
||||
})
|
||||
}
|
||||
|
||||
const projectGroupsById = new Map(projectGroups.map((group) => [group.id, group]))
|
||||
const folderWorkspacesByProjectGroupId = new Map<string, FolderWorkspace[]>()
|
||||
const folderWorkspacesByProjectGroupIdentity = new Map<string, FolderWorkspace[]>()
|
||||
for (const workspace of folderWorkspaces) {
|
||||
const group = projectGroupsById.get(workspace.projectGroupId)
|
||||
const group = findProjectGroupForFolderWorkspace(projectGroupIndex, workspace)
|
||||
if (!group?.parentPath) {
|
||||
continue
|
||||
}
|
||||
const list = folderWorkspacesByProjectGroupId.get(workspace.projectGroupId) ?? []
|
||||
const identity = getProjectGroupSidebarIdentity(group)
|
||||
const list = folderWorkspacesByProjectGroupIdentity.get(identity) ?? []
|
||||
list.push(workspace)
|
||||
folderWorkspacesByProjectGroupId.set(workspace.projectGroupId, list)
|
||||
folderWorkspacesByProjectGroupIdentity.set(identity, list)
|
||||
}
|
||||
for (const list of folderWorkspacesByProjectGroupId.values()) {
|
||||
for (const list of folderWorkspacesByProjectGroupIdentity.values()) {
|
||||
list.sort((left, right) => {
|
||||
const leftOrder = left.manualOrder ?? left.sortOrder
|
||||
const rightOrder = right.manualOrder ?? right.sortOrder
|
||||
return rightOrder - leftOrder || left.name.localeCompare(right.name)
|
||||
})
|
||||
}
|
||||
const childGroupsByParentId = new Map<string | null, ProjectGroup[]>()
|
||||
const childGroupsByParentIdentity = new Map<string | null, ProjectGroup[]>()
|
||||
for (const group of projectGroups) {
|
||||
const parentId =
|
||||
group.parentGroupId && projectGroupsById.has(group.parentGroupId) ? group.parentGroupId : null
|
||||
const children = childGroupsByParentId.get(parentId) ?? []
|
||||
const parent = findProjectGroupParentForSidebar(projectGroupIndex, group)
|
||||
const parentIdentity = parent ? getProjectGroupSidebarIdentity(parent) : null
|
||||
const children = childGroupsByParentIdentity.get(parentIdentity) ?? []
|
||||
children.push(group)
|
||||
childGroupsByParentId.set(parentId, children)
|
||||
childGroupsByParentIdentity.set(parentIdentity, children)
|
||||
}
|
||||
for (const groups of childGroupsByParentId.values()) {
|
||||
for (const groups of childGroupsByParentIdentity.values()) {
|
||||
groups.sort(
|
||||
(left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name)
|
||||
)
|
||||
}
|
||||
|
||||
const getProjectGroupSubtreeCount = (groupId: string): number => {
|
||||
const directCount = groupByProjectGroupId.get(groupId)?.length ?? 0
|
||||
const folderWorkspaceCount = folderWorkspacesByProjectGroupId.get(groupId)?.length ?? 0
|
||||
const children = childGroupsByParentId.get(groupId) ?? []
|
||||
return children.reduce(
|
||||
(count, child) => count + getProjectGroupSubtreeCount(child.id),
|
||||
directCount + folderWorkspaceCount
|
||||
)
|
||||
const projectGroupHostMetricsByIdentity = new Map<string, ProjectGroupHostMetrics>()
|
||||
const getProjectGroupHostMetrics = (identity: string): ProjectGroupHostMetrics => {
|
||||
const cached = projectGroupHostMetricsByIdentity.get(identity)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
const metrics: ProjectGroupHostMetrics = {
|
||||
count: 0,
|
||||
hostCounts: new Map(),
|
||||
hostWorktreeIds: new Map(),
|
||||
hostFolderWorkspaceIds: new Map(),
|
||||
worktreeIds: [],
|
||||
folderWorkspaceIds: []
|
||||
}
|
||||
const incrementHostCount = (hostId: ExecutionHostId, amount: number): void => {
|
||||
metrics.hostCounts.set(hostId, (metrics.hostCounts.get(hostId) ?? 0) + amount)
|
||||
if (!metrics.hostWorktreeIds.has(hostId)) {
|
||||
metrics.hostWorktreeIds.set(hostId, [])
|
||||
}
|
||||
if (!metrics.hostFolderWorkspaceIds.has(hostId)) {
|
||||
metrics.hostFolderWorkspaceIds.set(hostId, [])
|
||||
}
|
||||
}
|
||||
for (const [, entry] of groupByProjectGroupIdentity.get(identity) ?? []) {
|
||||
metrics.count += 1
|
||||
const entryHostIds = new Set<ExecutionHostId>()
|
||||
for (const worktree of entry.items) {
|
||||
const hostId = getWorktreeExecutionHostId(
|
||||
worktree,
|
||||
repoMap.get(worktree.repoId),
|
||||
defaultHostId
|
||||
)
|
||||
entryHostIds.add(hostId)
|
||||
const hostWorktreeIds = metrics.hostWorktreeIds.get(hostId) ?? []
|
||||
hostWorktreeIds.push(worktree.id)
|
||||
metrics.hostWorktreeIds.set(hostId, hostWorktreeIds)
|
||||
metrics.worktreeIds.push(worktree.id)
|
||||
}
|
||||
for (const repoId of entry.repoIds) {
|
||||
const entryRepo = repoMap.get(repoId)
|
||||
entryHostIds.add(
|
||||
entryRepo?.connectionId || entryRepo?.executionHostId
|
||||
? getRepoExecutionHostId(entryRepo)
|
||||
: defaultHostId
|
||||
)
|
||||
}
|
||||
for (const hostId of entryHostIds) {
|
||||
incrementHostCount(hostId, 1)
|
||||
}
|
||||
}
|
||||
const projectGroup = projectGroupIndex.byIdentity.get(identity)
|
||||
for (const folderWorkspace of folderWorkspacesByProjectGroupIdentity.get(identity) ?? []) {
|
||||
metrics.count += 1
|
||||
const hostId = getFolderWorkspaceExecutionHostIdForRows({
|
||||
folderWorkspace,
|
||||
projectGroup,
|
||||
defaultHostId
|
||||
})
|
||||
incrementHostCount(hostId, 1)
|
||||
metrics.hostFolderWorkspaceIds.get(hostId)!.push(folderWorkspace.id)
|
||||
metrics.folderWorkspaceIds.push(folderWorkspace.id)
|
||||
}
|
||||
for (const child of childGroupsByParentIdentity.get(identity) ?? []) {
|
||||
const childMetrics = getProjectGroupHostMetrics(getProjectGroupSidebarIdentity(child))
|
||||
metrics.count += childMetrics.count
|
||||
metrics.worktreeIds.push(...childMetrics.worktreeIds)
|
||||
metrics.folderWorkspaceIds.push(...childMetrics.folderWorkspaceIds)
|
||||
for (const [hostId, count] of childMetrics.hostCounts) {
|
||||
incrementHostCount(hostId, count)
|
||||
metrics.hostWorktreeIds
|
||||
.get(hostId)!
|
||||
.push(...(childMetrics.hostWorktreeIds.get(hostId) ?? []))
|
||||
metrics.hostFolderWorkspaceIds
|
||||
.get(hostId)!
|
||||
.push(...(childMetrics.hostFolderWorkspaceIds.get(hostId) ?? []))
|
||||
}
|
||||
}
|
||||
if (metrics.hostCounts.size === 0 && projectGroup) {
|
||||
incrementHostCount(getProjectGroupExecutionHostIdForRows(projectGroup, defaultHostId), 0)
|
||||
}
|
||||
projectGroupHostMetricsByIdentity.set(identity, metrics)
|
||||
return metrics
|
||||
}
|
||||
|
||||
const appendProjectGroup = (projectGroup: ProjectGroup, depth: number): void => {
|
||||
const repoEntries = sortRepoEntriesWithinGroup(groupByProjectGroupId.get(projectGroup.id) ?? [])
|
||||
const childGroups = childGroupsByParentId.get(projectGroup.id) ?? []
|
||||
const key = getProjectGroupHeaderKey(projectGroup.id)
|
||||
const identity = getProjectGroupSidebarIdentity(projectGroup)
|
||||
const ownerHostId = getProjectGroupOwnerHostId(projectGroup)
|
||||
const repoEntries = sortRepoEntriesWithinGroup(groupByProjectGroupIdentity.get(identity) ?? [])
|
||||
const childGroups = childGroupsByParentIdentity.get(identity) ?? []
|
||||
// Why: bare project-group:<id> stays when the bare id is unique; only same-id multi-owner rows need owner stamps.
|
||||
const keyOwner =
|
||||
projectGroups.filter((group) => group.id === projectGroup.id).length > 1
|
||||
? ownerHostId
|
||||
: undefined
|
||||
const key = getProjectGroupHeaderKey(projectGroup.id, keyOwner)
|
||||
// Why: same-id groups on distinct owners collapse independently in persisted sidebar state.
|
||||
const collapseKey = getProjectGroupHeaderKey(projectGroup.id, keyOwner)
|
||||
const hostMetrics = getProjectGroupHostMetrics(identity)
|
||||
result.push({
|
||||
type: 'header',
|
||||
key,
|
||||
label: projectGroup.name,
|
||||
count: getProjectGroupSubtreeCount(projectGroup.id),
|
||||
count: hostMetrics.count,
|
||||
tone: PROJECT_GROUP_META.tone,
|
||||
icon: PROJECT_GROUP_META.icon,
|
||||
projectGroup,
|
||||
projectGroupDepth: depth
|
||||
projectGroupOwnerHostId: ownerHostId,
|
||||
collapseKey,
|
||||
projectGroupDepth: depth,
|
||||
hostWorktreeCounts: hostMetrics.hostCounts.size > 0 ? hostMetrics.hostCounts : undefined,
|
||||
hostWorktreeIds:
|
||||
hostMetrics.hostWorktreeIds.size > 0 ? hostMetrics.hostWorktreeIds : undefined,
|
||||
hostFolderWorkspaceIds:
|
||||
hostMetrics.hostFolderWorkspaceIds.size > 0
|
||||
? hostMetrics.hostFolderWorkspaceIds
|
||||
: undefined,
|
||||
worktreeIds: hostMetrics.worktreeIds,
|
||||
folderWorkspaceIds: hostMetrics.folderWorkspaceIds
|
||||
})
|
||||
if (!collapsedGroups.has(key)) {
|
||||
for (const folderWorkspace of folderWorkspacesByProjectGroupId.get(projectGroup.id) ?? []) {
|
||||
if (!collapsedGroups.has(collapseKey)) {
|
||||
for (const folderWorkspace of folderWorkspacesByProjectGroupIdentity.get(identity) ?? []) {
|
||||
result.push({
|
||||
type: 'folder-workspace',
|
||||
key: `folder-workspace:${folderWorkspace.id}`,
|
||||
key: getFolderWorkspaceRowKey(folderWorkspace, [projectGroup]),
|
||||
folderWorkspace,
|
||||
projectGroup,
|
||||
depth: 0,
|
||||
@@ -1466,16 +1600,16 @@ export function buildRows(
|
||||
appendProjectGroup(childGroup, depth + 1)
|
||||
}
|
||||
}
|
||||
groupByProjectGroupId.delete(projectGroup.id)
|
||||
groupByProjectGroupIdentity.delete(identity)
|
||||
}
|
||||
|
||||
for (const projectGroup of childGroupsByParentId.get(null) ?? []) {
|
||||
for (const projectGroup of childGroupsByParentIdentity.get(null) ?? []) {
|
||||
appendProjectGroup(projectGroup, 0)
|
||||
}
|
||||
|
||||
const remainingRepoEntries = [...(groupByProjectGroupId.get(null) ?? [])]
|
||||
for (const [projectGroupId, entries] of groupByProjectGroupId) {
|
||||
if (projectGroupId === null || projectGroupsById.has(projectGroupId)) {
|
||||
const remainingRepoEntries = [...(groupByProjectGroupIdentity.get(null) ?? [])]
|
||||
for (const [projectGroupIdentity, entries] of groupByProjectGroupIdentity) {
|
||||
if (projectGroupIdentity === null || projectGroupIndex.byIdentity.has(projectGroupIdentity)) {
|
||||
continue
|
||||
}
|
||||
// Why: startup can have repos from hosts whose project-group metadata was
|
||||
@@ -1541,21 +1675,22 @@ export function getGroupKeysForWorktree(
|
||||
return [groupKey]
|
||||
}
|
||||
const repo = repoMap.get(worktree.repoId)
|
||||
const groupIds: string[] = []
|
||||
const groupsById = new Map(projectGroups.map((group) => [group.id, group]))
|
||||
const groupKeys: string[] = []
|
||||
const projectGroupIndex = buildProjectGroupSidebarIndex(projectGroups)
|
||||
const visited = new Set<string>()
|
||||
let currentGroupId = repo?.projectGroupId ?? null
|
||||
while (currentGroupId && !visited.has(currentGroupId)) {
|
||||
const group = groupsById.get(currentGroupId)
|
||||
if (!group) {
|
||||
// Why: repos can arrive before their remote Project Group metadata; reveal
|
||||
// keys must match the top-level fallback rows buildRows actually renders.
|
||||
let group = findProjectGroupForRepo(projectGroupIndex, repo, getRepoExecutionHostId(repo ?? {}))
|
||||
while (group) {
|
||||
const identity = getProjectGroupSidebarIdentity(group)
|
||||
if (visited.has(identity)) {
|
||||
break
|
||||
}
|
||||
visited.add(currentGroupId)
|
||||
groupIds.unshift(currentGroupId)
|
||||
const parentId = group.parentGroupId ?? null
|
||||
currentGroupId = parentId && groupsById.has(parentId) ? parentId : null
|
||||
visited.add(identity)
|
||||
const revealOwner =
|
||||
projectGroups.filter((entry) => entry.id === group!.id).length > 1
|
||||
? getProjectGroupOwnerHostId(group)
|
||||
: undefined
|
||||
groupKeys.unshift(getProjectGroupHeaderKey(group.id, revealOwner))
|
||||
group = findProjectGroupParentForSidebar(projectGroupIndex, group)
|
||||
}
|
||||
return [...groupIds.map((id) => getProjectGroupHeaderKey(id)), groupKey]
|
||||
return [...groupKeys, groupKey]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ProjectGroup, Repo } from '../../../../shared/types'
|
||||
import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types'
|
||||
import { selectProjectGroupRemovalTargets } from './project-group-removal-targets'
|
||||
|
||||
const rootGroup: ProjectGroup = {
|
||||
@@ -41,6 +41,33 @@ function makeRepo(id: string, projectGroupId: string | null): Repo {
|
||||
}
|
||||
}
|
||||
|
||||
function withOwner(group: ProjectGroup, executionHostId: string): ProjectGroup {
|
||||
return { ...group, executionHostId }
|
||||
}
|
||||
|
||||
function makeFolderWorkspace(
|
||||
id: string,
|
||||
projectGroupId: string,
|
||||
executionHostId: 'local' | `runtime:${string}`
|
||||
): FolderWorkspace {
|
||||
return {
|
||||
id,
|
||||
projectGroupId,
|
||||
executionHostId,
|
||||
name: id,
|
||||
folderPath: `/${id}`,
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('selectProjectGroupRemovalTargets', () => {
|
||||
it('selects direct and nested child projects in repo order', () => {
|
||||
const result = selectProjectGroupRemovalTargets(
|
||||
@@ -78,4 +105,65 @@ describe('selectProjectGroupRemovalTargets', () => {
|
||||
expect([...result.deletedGroupIds]).toEqual([])
|
||||
expect(result.projectIds).toEqual([])
|
||||
})
|
||||
|
||||
it('requires an owner when group ids are ambiguous', () => {
|
||||
const result = selectProjectGroupRemovalTargets(
|
||||
[withOwner(rootGroup, 'local'), withOwner(rootGroup, 'runtime:env-1')],
|
||||
[],
|
||||
rootGroup.id
|
||||
)
|
||||
|
||||
expect(result.groupExists).toBe(false)
|
||||
expect(result.ownerHostId).toBeNull()
|
||||
})
|
||||
|
||||
it('does not fall back to a foreign owner when the requested owner is stale', () => {
|
||||
const result = selectProjectGroupRemovalTargets(
|
||||
[withOwner(rootGroup, 'local')],
|
||||
[],
|
||||
rootGroup.id,
|
||||
'runtime:env-1'
|
||||
)
|
||||
|
||||
expect(result.groupExists).toBe(false)
|
||||
expect(result.ownerHostId).toBeNull()
|
||||
})
|
||||
|
||||
it('scopes descendants, repos, and folders to the selected owner', () => {
|
||||
const localRoot = withOwner(rootGroup, 'local')
|
||||
const localChild = withOwner(childGroup, 'local')
|
||||
const runtimeRoot = withOwner(rootGroup, 'runtime:env-1')
|
||||
const runtimeChild = withOwner(childGroup, 'runtime:env-1')
|
||||
const localRepo: Repo = {
|
||||
...makeRepo('same-repo', rootGroup.id),
|
||||
executionHostId: 'local'
|
||||
}
|
||||
const runtimeRepo: Repo = {
|
||||
...makeRepo('same-repo', childGroup.id),
|
||||
executionHostId: 'runtime:env-1'
|
||||
}
|
||||
const localFolder = makeFolderWorkspace('same-folder', childGroup.id, 'local')
|
||||
const runtimeFolder = makeFolderWorkspace('same-folder', childGroup.id, 'runtime:env-1')
|
||||
|
||||
const result = selectProjectGroupRemovalTargets(
|
||||
[localRoot, localChild, runtimeRoot, runtimeChild],
|
||||
[localRepo, runtimeRepo],
|
||||
rootGroup.id,
|
||||
'runtime:env-1',
|
||||
[localFolder, runtimeFolder]
|
||||
)
|
||||
|
||||
expect(result.ownerHostId).toBe('runtime:env-1')
|
||||
expect(result.deletedGroupIdentities).toEqual(
|
||||
new Set(['["runtime:env-1","root"]', '["runtime:env-1","child"]'])
|
||||
)
|
||||
expect(result.projectTargets).toEqual([
|
||||
{
|
||||
projectId: 'same-repo',
|
||||
ownerHostId: 'runtime:env-1',
|
||||
identity: 'runtime:env-1\0same-repo'
|
||||
}
|
||||
])
|
||||
expect(result.folderWorkspaceIdentities).toEqual(new Set(['["runtime:env-1","same-folder"]']))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,37 +1,131 @@
|
||||
import type { ProjectGroup, Repo } from '../../../../shared/types'
|
||||
import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups'
|
||||
import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
normalizeExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../../shared/execution-host'
|
||||
import {
|
||||
buildProjectGroupOwnerIndex,
|
||||
getProjectGroupIdentity,
|
||||
getProjectGroupOwnerHostId,
|
||||
getProjectGroupOwnerIdentity,
|
||||
getProjectGroupOwnerSubtreeIdentities,
|
||||
resolveProjectGroupOwner,
|
||||
type ProjectGroupOwnerIndex
|
||||
} from '../../../../shared/project-groups'
|
||||
import { getRepoHostIdentity } from './repo-host-identity'
|
||||
|
||||
export type ProjectGroupRemovalProjectTarget = {
|
||||
projectId: string
|
||||
ownerHostId: ExecutionHostId
|
||||
identity: string
|
||||
}
|
||||
|
||||
export type ProjectGroupRemovalTargets = {
|
||||
groupExists: boolean
|
||||
ownerHostId: ExecutionHostId | null
|
||||
deletedGroupIds: Set<string>
|
||||
deletedGroupIdentities: Set<string>
|
||||
projectIds: string[]
|
||||
projectTargets: ProjectGroupRemovalProjectTarget[]
|
||||
folderWorkspaceIdentities: Set<string>
|
||||
}
|
||||
|
||||
function getFolderWorkspacePreferredOwnerHostId(
|
||||
workspace: Pick<FolderWorkspace, 'connectionId' | 'executionHostId'>
|
||||
): ExecutionHostId | undefined {
|
||||
return (
|
||||
normalizeExecutionHostId(workspace.executionHostId) ??
|
||||
(workspace.connectionId ? toSshExecutionHostId(workspace.connectionId) : undefined)
|
||||
)
|
||||
}
|
||||
|
||||
function resolveFolderWorkspaceProjectGroup(
|
||||
index: ProjectGroupOwnerIndex,
|
||||
workspace: Pick<FolderWorkspace, 'connectionId' | 'executionHostId' | 'projectGroupId'>
|
||||
): ProjectGroup | null {
|
||||
return resolveProjectGroupOwner(
|
||||
index,
|
||||
workspace.projectGroupId,
|
||||
getFolderWorkspacePreferredOwnerHostId(workspace)
|
||||
)
|
||||
}
|
||||
|
||||
export function getProjectGroupRemovalFolderWorkspaceIdentity(
|
||||
index: ProjectGroupOwnerIndex,
|
||||
workspace: Pick<FolderWorkspace, 'id' | 'projectGroupId' | 'connectionId' | 'executionHostId'>
|
||||
): string | null {
|
||||
const group = resolveFolderWorkspaceProjectGroup(index, workspace)
|
||||
return group ? JSON.stringify([getProjectGroupOwnerHostId(group), workspace.id]) : null
|
||||
}
|
||||
|
||||
function emptyRemovalTargets(): ProjectGroupRemovalTargets {
|
||||
return {
|
||||
groupExists: false,
|
||||
ownerHostId: null,
|
||||
deletedGroupIds: new Set(),
|
||||
deletedGroupIdentities: new Set(),
|
||||
projectIds: [],
|
||||
projectTargets: [],
|
||||
folderWorkspaceIdentities: new Set()
|
||||
}
|
||||
}
|
||||
|
||||
export function selectProjectGroupRemovalTargets(
|
||||
projectGroups: readonly ProjectGroup[],
|
||||
repos: readonly Repo[],
|
||||
groupId: string
|
||||
groupId: string,
|
||||
ownerHostId?: ExecutionHostId,
|
||||
folderWorkspaces: readonly FolderWorkspace[] = []
|
||||
): ProjectGroupRemovalTargets {
|
||||
const groupExists = projectGroups.some((group) => group.id === groupId)
|
||||
if (!groupExists) {
|
||||
return {
|
||||
groupExists: false,
|
||||
deletedGroupIds: new Set(),
|
||||
projectIds: []
|
||||
}
|
||||
const index = buildProjectGroupOwnerIndex(projectGroups)
|
||||
const rootGroup = resolveProjectGroupOwner(index, groupId, ownerHostId)
|
||||
if (!rootGroup) {
|
||||
return emptyRemovalTargets()
|
||||
}
|
||||
|
||||
const deletedGroupIds = getProjectGroupSubtreeIds(projectGroups, groupId)
|
||||
const projectIds: string[] = []
|
||||
const selectedOwnerHostId = getProjectGroupOwnerHostId(rootGroup)
|
||||
const deletedGroupIdentities = getProjectGroupOwnerSubtreeIdentities(projectGroups, rootGroup)
|
||||
const deletedGroupIds = new Set(
|
||||
projectGroups.flatMap((group) =>
|
||||
deletedGroupIdentities.has(
|
||||
getProjectGroupIdentity(group.id, getProjectGroupOwnerHostId(group))
|
||||
)
|
||||
? [group.id]
|
||||
: []
|
||||
)
|
||||
)
|
||||
const projectTargets: ProjectGroupRemovalProjectTarget[] = []
|
||||
const seenProjectIdentities = new Set<string>()
|
||||
for (const repo of repos) {
|
||||
if (repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId)) {
|
||||
projectIds.push(repo.id)
|
||||
if (!repo.projectGroupId) {
|
||||
continue
|
||||
}
|
||||
const repoOwnerHostId = getRepoExecutionHostId(repo)
|
||||
const groupIdentity = getProjectGroupIdentity(repo.projectGroupId, repoOwnerHostId)
|
||||
const identity = getRepoHostIdentity(repo)
|
||||
if (deletedGroupIdentities.has(groupIdentity) && !seenProjectIdentities.has(identity)) {
|
||||
seenProjectIdentities.add(identity)
|
||||
projectTargets.push({ projectId: repo.id, ownerHostId: repoOwnerHostId, identity })
|
||||
}
|
||||
}
|
||||
const folderWorkspaceIdentities = new Set<string>()
|
||||
for (const workspace of folderWorkspaces) {
|
||||
const group = resolveFolderWorkspaceProjectGroup(index, workspace)
|
||||
if (!group || !deletedGroupIdentities.has(getProjectGroupOwnerIdentity(group))) {
|
||||
continue
|
||||
}
|
||||
folderWorkspaceIdentities.add(JSON.stringify([getProjectGroupOwnerHostId(group), workspace.id]))
|
||||
}
|
||||
|
||||
return {
|
||||
groupExists: true,
|
||||
ownerHostId: selectedOwnerHostId,
|
||||
deletedGroupIds,
|
||||
projectIds
|
||||
deletedGroupIdentities,
|
||||
projectIds: projectTargets.map((target) => target.projectId),
|
||||
projectTargets,
|
||||
folderWorkspaceIdentities
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProjectGroup } from '../../../../shared/types'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
import {
|
||||
createCompatibleRuntimeStatusResponseIfNeeded,
|
||||
type RuntimeEnvironmentCallRequest
|
||||
} from '../../runtime/runtime-compatibility-test-fixture'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
|
||||
|
||||
function group(executionHostId: 'local' | `runtime:${string}`): ProjectGroup {
|
||||
return {
|
||||
id: 'same-id',
|
||||
name: executionHostId,
|
||||
parentPath: null,
|
||||
executionHostId,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('project-group owner-qualified updates', () => {
|
||||
beforeEach(() => {
|
||||
clearRuntimeCompatibilityCacheForTests()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('updates only the routed owner when ids overlap', async () => {
|
||||
const local = group('local')
|
||||
const runtime = group('runtime:env-1')
|
||||
const runtimeCall = vi.fn((request: RuntimeEnvironmentCallRequest) => {
|
||||
const compatibility = createCompatibleRuntimeStatusResponseIfNeeded(request)
|
||||
if (compatibility) {
|
||||
return compatibility
|
||||
}
|
||||
return {
|
||||
id: 'rpc-project-group-update',
|
||||
ok: true,
|
||||
result: { group: { ...runtime, tabOrder: 4 } },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
}
|
||||
})
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
runtimeEnvironments: { call: runtimeCall }
|
||||
}
|
||||
})
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
settings: { activeRuntimeEnvironmentId: null } as never,
|
||||
projectGroups: [local, runtime]
|
||||
})
|
||||
|
||||
await store
|
||||
.getState()
|
||||
.updateProjectGroup('same-id', { tabOrder: 4 }, { ownerHostId: 'runtime:env-1' })
|
||||
|
||||
expect(store.getState().projectGroups).toEqual([local, { ...runtime, tabOrder: 4 }])
|
||||
expect(runtimeCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'projectGroup.update',
|
||||
params: { groupId: 'same-id', updates: { tabOrder: 4 } },
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
})
|
||||
|
||||
it('routes local updates by owner while a runtime is focused', async () => {
|
||||
const local = group('local')
|
||||
const runtime = group('runtime:env-1')
|
||||
const update = vi.fn().mockResolvedValue({ ...local, name: 'renamed' })
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
projectGroups: { update },
|
||||
runtimeEnvironments: { call: vi.fn() }
|
||||
}
|
||||
})
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
|
||||
projectGroups: [local, runtime]
|
||||
})
|
||||
|
||||
await store
|
||||
.getState()
|
||||
.updateProjectGroup('same-id', { name: 'renamed' }, { ownerHostId: 'local' })
|
||||
|
||||
expect(update).toHaveBeenCalledWith({
|
||||
groupId: 'same-id',
|
||||
updates: { name: 'renamed' },
|
||||
ownerHostId: 'local'
|
||||
})
|
||||
expect(store.getState().projectGroups).toEqual([{ ...local, name: 'renamed' }, runtime])
|
||||
})
|
||||
|
||||
it('does not update when an omitted owner is ambiguous', async () => {
|
||||
const local = group('local')
|
||||
const runtime = group('runtime:env-1')
|
||||
const runtimeCall = vi.fn()
|
||||
const update = vi.fn()
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
projectGroups: { update },
|
||||
runtimeEnvironments: { call: runtimeCall }
|
||||
}
|
||||
})
|
||||
const store = createTestStore()
|
||||
store.setState({ projectGroups: [local, runtime] })
|
||||
|
||||
await expect(store.getState().updateProjectGroup('same-id', { tabOrder: 4 })).resolves.toBe(
|
||||
false
|
||||
)
|
||||
|
||||
expect(update).not.toHaveBeenCalled()
|
||||
expect(runtimeCall).not.toHaveBeenCalled()
|
||||
expect(store.getState().projectGroups).toEqual([local, runtime])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,58 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { ProjectGroup, Repo } from '../../../../shared/types'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
|
||||
function group(index: number): ProjectGroup {
|
||||
const ownerIndex = index % 16
|
||||
return {
|
||||
id: index < 16 ? 'same-id' : `group-${index}`,
|
||||
name: `Group ${index}`,
|
||||
parentPath: ownerIndex === 0 && index === 0 ? '/workspace' : null,
|
||||
executionHostId: ownerIndex === 0 ? 'local' : `runtime:env-${ownerIndex}`,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: index,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
}
|
||||
|
||||
describe('project-group path-status catalog cache', () => {
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
|
||||
it('reuses the owner-scoped snapshot across 12,208 groups and 16 owners', async () => {
|
||||
let pathReads = 0
|
||||
const projectGroups = Array.from({ length: 12_208 }, (_, index) => group(index))
|
||||
const repos = Array.from({ length: 12_208 }, (_, index) => {
|
||||
const ownerIndex = index % 16
|
||||
return {
|
||||
id: `repo-${index}`,
|
||||
get path() {
|
||||
pathReads += 1
|
||||
return `/workspace/repo-${index}`
|
||||
},
|
||||
displayName: `Repo ${index}`,
|
||||
badgeColor: '#000',
|
||||
addedAt: 1,
|
||||
projectGroupId: ownerIndex === 0 ? 'same-id' : null,
|
||||
executionHostId: ownerIndex === 0 ? 'local' : `runtime:env-${ownerIndex}`
|
||||
} as Repo
|
||||
})
|
||||
const getPathStatus = vi.fn().mockResolvedValue({ path: '/workspace', exists: true })
|
||||
vi.stubGlobal('window', { api: { folderWorkspaces: { getPathStatus } } })
|
||||
const store = createTestStore()
|
||||
store.setState({ projectGroups, repos })
|
||||
const request = { scope: 'project-group' as const, projectGroupId: 'same-id' }
|
||||
const options = { force: true, runtimeEnvironmentId: null, ownerHostId: 'local' as const }
|
||||
|
||||
await store.getState().fetchFolderWorkspacePathStatus(request, options)
|
||||
const readsAfterFirstSnapshot = pathReads
|
||||
await store.getState().fetchFolderWorkspacePathStatus(request, options)
|
||||
|
||||
expect(readsAfterFirstSnapshot).toBeGreaterThan(0)
|
||||
expect(pathReads).toBe(readsAfterFirstSnapshot)
|
||||
expect(getPathStatus).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
type RuntimeEnvironmentCallRequest
|
||||
} from '../../runtime/runtime-compatibility-test-fixture'
|
||||
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
|
||||
import { getRepoExecutionHostId } from '../../../../shared/execution-host'
|
||||
|
||||
const remoteRepo: Repo = {
|
||||
id: 'remote-repo',
|
||||
@@ -110,17 +111,22 @@ describe('project group deletion store routing', () => {
|
||||
result: { deleted: false },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
const groupedRepo = { ...remoteRepo, projectGroupId: projectGroup.id }
|
||||
const runtimeGroup = { ...projectGroup, executionHostId: 'runtime:env-1' }
|
||||
const groupedRepo: Repo = {
|
||||
...remoteRepo,
|
||||
projectGroupId: projectGroup.id,
|
||||
executionHostId: 'runtime:env-1'
|
||||
}
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
|
||||
projectGroups: [projectGroup],
|
||||
projectGroups: [runtimeGroup],
|
||||
repos: [groupedRepo]
|
||||
})
|
||||
|
||||
await expect(store.getState().deleteProjectGroup(projectGroup.id)).resolves.toBe(false)
|
||||
|
||||
expect(store.getState().projectGroups).toEqual([projectGroup])
|
||||
expect(store.getState().projectGroups).toEqual([runtimeGroup])
|
||||
expect(store.getState().repos).toEqual([groupedRepo])
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
@@ -131,6 +137,85 @@ describe('project group deletion store routing', () => {
|
||||
expect(projectGroupsDelete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('deletes and cleans up only the selected owner when ids overlap', async () => {
|
||||
const localGroup = { ...projectGroup, executionHostId: 'local' }
|
||||
const runtimeGroup = { ...projectGroup, executionHostId: 'runtime:env-1' }
|
||||
const localFolder: FolderWorkspace = {
|
||||
id: 'same-folder',
|
||||
projectGroupId: projectGroup.id,
|
||||
executionHostId: 'local',
|
||||
name: 'Local folder',
|
||||
folderPath: '/local',
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
createdAt: 1,
|
||||
updatedAt: 1
|
||||
}
|
||||
const runtimeFolder = {
|
||||
...localFolder,
|
||||
name: 'Runtime folder',
|
||||
folderPath: '/runtime',
|
||||
executionHostId: 'runtime:env-1' as const
|
||||
}
|
||||
const localRepo: Repo = {
|
||||
...remoteRepo,
|
||||
id: 'same-repo',
|
||||
projectGroupId: projectGroup.id,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
const runtimeRepo: Repo = {
|
||||
...localRepo,
|
||||
path: '/runtime-repo',
|
||||
executionHostId: 'runtime:env-1'
|
||||
}
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-delete-group',
|
||||
ok: true,
|
||||
result: { deleted: true },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
const store = createTestStore()
|
||||
store.setState({
|
||||
settings: { activeRuntimeEnvironmentId: null } as never,
|
||||
projectGroups: [localGroup, runtimeGroup],
|
||||
folderWorkspaces: [localFolder, runtimeFolder],
|
||||
repos: [localRepo, runtimeRepo]
|
||||
})
|
||||
|
||||
await expect(
|
||||
store.getState().deleteProjectGroup(projectGroup.id, { ownerHostId: 'runtime:env-1' })
|
||||
).resolves.toBe(true)
|
||||
|
||||
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
|
||||
selector: 'env-1',
|
||||
method: 'projectGroup.delete',
|
||||
params: { groupId: projectGroup.id },
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
expect(store.getState().projectGroups).toEqual([localGroup])
|
||||
expect(store.getState().folderWorkspaces).toEqual([localFolder])
|
||||
expect(store.getState().repos).toEqual([localRepo, { ...runtimeRepo, projectGroupId: null }])
|
||||
expect(projectGroupsDelete).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not delete when an omitted owner is ambiguous', async () => {
|
||||
const localGroup = { ...projectGroup, executionHostId: 'local' }
|
||||
const runtimeGroup = { ...projectGroup, executionHostId: 'runtime:env-1' }
|
||||
const store = createTestStore()
|
||||
store.setState({ projectGroups: [localGroup, runtimeGroup] })
|
||||
|
||||
await expect(store.getState().deleteProjectGroup(projectGroup.id)).resolves.toBe(false)
|
||||
|
||||
expect(projectGroupsDelete).not.toHaveBeenCalled()
|
||||
expect(runtimeEnvironmentTransportCall).not.toHaveBeenCalled()
|
||||
expect(store.getState().projectGroups).toEqual([localGroup, runtimeGroup])
|
||||
})
|
||||
|
||||
it('deletes only the group when contained project removal is not requested', async () => {
|
||||
projectGroupsDelete.mockResolvedValue(true)
|
||||
const groupedRepo = { ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id }
|
||||
@@ -191,6 +276,59 @@ describe('project group deletion store routing', () => {
|
||||
expect(store.getState().repos).toEqual([siblingRepo])
|
||||
})
|
||||
|
||||
it('removes contained projects through the selected repo owner', async () => {
|
||||
const localGroup = { ...projectGroup, executionHostId: 'local' }
|
||||
const runtimeGroup = { ...projectGroup, executionHostId: 'runtime:env-1' }
|
||||
const localRepo: Repo = {
|
||||
...remoteRepo,
|
||||
id: 'same-repo',
|
||||
projectGroupId: projectGroup.id,
|
||||
executionHostId: 'local'
|
||||
}
|
||||
const runtimeRepo: Repo = {
|
||||
...localRepo,
|
||||
path: '/runtime-repo',
|
||||
executionHostId: 'runtime:env-1'
|
||||
}
|
||||
runtimeEnvironmentCall.mockResolvedValue({
|
||||
id: 'rpc-delete-group',
|
||||
ok: true,
|
||||
result: { deleted: true },
|
||||
_meta: { runtimeId: 'runtime-remote' }
|
||||
})
|
||||
const store = createTestStore()
|
||||
const removeProject = vi.fn(
|
||||
async (projectId: string, options?: { hostId?: string }): Promise<void> => {
|
||||
store.setState((state) => ({
|
||||
repos: state.repos.filter(
|
||||
(repo) => repo.id !== projectId || getRepoExecutionHostId(repo) !== options?.hostId
|
||||
)
|
||||
}))
|
||||
}
|
||||
)
|
||||
store.setState({
|
||||
projectGroups: [localGroup, runtimeGroup],
|
||||
repos: [localRepo, runtimeRepo],
|
||||
removeProject
|
||||
})
|
||||
|
||||
await expect(
|
||||
store.getState().deleteProjectGroupWithContainedProjects(projectGroup.id, {
|
||||
removeContainedProjects: true,
|
||||
ownerHostId: 'runtime:env-1'
|
||||
})
|
||||
).resolves.toEqual({
|
||||
status: 'deleted-group',
|
||||
groupId: projectGroup.id,
|
||||
requestedProjectIds: ['same-repo'],
|
||||
removedProjectIds: ['same-repo'],
|
||||
failedProjectRemovals: []
|
||||
})
|
||||
|
||||
expect(removeProject).toHaveBeenCalledWith('same-repo', { hostId: 'runtime:env-1' })
|
||||
expect(store.getState().repos).toEqual([localRepo])
|
||||
})
|
||||
|
||||
it('does not remove contained projects when group deletion fails', async () => {
|
||||
projectGroupsDelete.mockResolvedValue(false)
|
||||
const groupedRepo = { ...remoteRepo, id: 'direct', projectGroupId: projectGroup.id }
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { FolderWorkspace, ProjectGroup } from '../../../../shared/types'
|
||||
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import { createTestStore } from './store-test-helpers'
|
||||
|
||||
function makeGroup(overrides: Partial<ProjectGroup> = {}): ProjectGroup {
|
||||
return {
|
||||
id: overrides.id ?? 'same-group',
|
||||
name: overrides.name ?? 'Same Group',
|
||||
parentPath: overrides.parentPath ?? '/workspace',
|
||||
connectionId: overrides.connectionId ?? null,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...(overrides.executionHostId ? { executionHostId: overrides.executionHostId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
function makeFolder(overrides: Partial<FolderWorkspace> = {}): FolderWorkspace {
|
||||
return {
|
||||
id: overrides.id ?? 'same-id',
|
||||
projectGroupId: overrides.projectGroupId ?? 'same-group',
|
||||
name: overrides.name ?? 'Workspace',
|
||||
folderPath: overrides.folderPath ?? '/workspace/folder',
|
||||
connectionId: overrides.connectionId ?? null,
|
||||
linkedTask: null,
|
||||
comment: '',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 1,
|
||||
lastActivityAt: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...(overrides.executionHostId ? { executionHostId: overrides.executionHostId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
describe('same-id cross-host folder workspace identity', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('deletes only the owner-qualified folder row and its session keys', async () => {
|
||||
const local = makeFolder({
|
||||
name: 'Local',
|
||||
folderPath: '/local/folder',
|
||||
connectionId: null,
|
||||
executionHostId: 'local'
|
||||
})
|
||||
const remote = makeFolder({
|
||||
name: 'Remote',
|
||||
folderPath: '/remote/folder',
|
||||
connectionId: null,
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
const store = createTestStore()
|
||||
const purgeWorktreeTerminalState = vi.fn()
|
||||
store.setState({
|
||||
projectGroups: [
|
||||
makeGroup({ executionHostId: 'local', parentPath: '/local' }),
|
||||
makeGroup({ executionHostId: 'runtime:env-1', parentPath: '/remote' })
|
||||
],
|
||||
folderWorkspaces: [local, remote],
|
||||
folderWorkspacePathStatuses: {
|
||||
'local:folder-workspace:local:same-id': {
|
||||
status: { path: '/local/folder', exists: true },
|
||||
checkedAt: 1,
|
||||
requestSnapshot: 'local'
|
||||
},
|
||||
'local:folder-workspace:runtime:env-1:same-id': {
|
||||
status: { path: '/remote/folder', exists: true },
|
||||
checkedAt: 1,
|
||||
requestSnapshot: 'remote'
|
||||
}
|
||||
},
|
||||
purgeWorktreeTerminalState
|
||||
})
|
||||
|
||||
vi.stubGlobal('window', {
|
||||
api: {
|
||||
folderWorkspaces: {
|
||||
delete: vi.fn().mockResolvedValue(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await expect(
|
||||
store.getState().deleteFolderWorkspace('same-id', { ownerHostId: 'local' })
|
||||
).resolves.toBe(true)
|
||||
|
||||
const remaining = store.getState().folderWorkspaces
|
||||
expect(remaining).toHaveLength(1)
|
||||
expect(remaining[0]?.executionHostId).toBe('runtime:env-1')
|
||||
expect(purgeWorktreeTerminalState).toHaveBeenCalledWith([
|
||||
folderWorkspaceKey('same-id', 'local')
|
||||
])
|
||||
expect(Object.keys(store.getState().folderWorkspacePathStatuses)).toEqual([
|
||||
'local:folder-workspace:runtime:env-1:same-id'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps path-status cache keys independent per owner for same folder id', () => {
|
||||
const store = createTestStore()
|
||||
const localKey = store
|
||||
.getState()
|
||||
.getFolderWorkspacePathStatusCacheKey(
|
||||
{ scope: 'folder-workspace', folderWorkspaceId: 'same-id' },
|
||||
{ ownerHostId: 'local' }
|
||||
)
|
||||
const remoteKey = store
|
||||
.getState()
|
||||
.getFolderWorkspacePathStatusCacheKey(
|
||||
{ scope: 'folder-workspace', folderWorkspaceId: 'same-id' },
|
||||
{ ownerHostId: 'runtime:env-1' }
|
||||
)
|
||||
expect(localKey).not.toBe(remoteKey)
|
||||
expect(localKey).toContain('local:')
|
||||
expect(localKey).toContain('folder-workspace:same-id')
|
||||
expect(remoteKey).toContain('runtime:env-1:')
|
||||
expect(remoteKey).toContain('folder-workspace:same-id')
|
||||
})
|
||||
|
||||
it('migrates legacy bare folder session tabs for the default owner on activation', () => {
|
||||
const store = createTestStore()
|
||||
const bareKey = folderWorkspaceKey('folder-workspace-1')
|
||||
const qualifiedKey = folderWorkspaceKey('folder-workspace-1', 'local')
|
||||
const folder = makeFolder({
|
||||
id: 'folder-workspace-1',
|
||||
projectGroupId: 'group-a',
|
||||
connectionId: null,
|
||||
executionHostId: 'local',
|
||||
folderPath: '/local/folder'
|
||||
})
|
||||
store.setState({
|
||||
projectGroups: [makeGroup({ id: 'group-a', executionHostId: 'local', parentPath: '/local' })],
|
||||
folderWorkspaces: [folder],
|
||||
// Why: reconnectable pty keeps the tab out of orphan cleanup during activation reconcile.
|
||||
tabsByWorktree: {
|
||||
[bareKey]: [
|
||||
{
|
||||
id: 'legacy-tab',
|
||||
ptyId: 'pty-legacy',
|
||||
worktreeId: bareKey,
|
||||
title: 'Legacy',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: { 'legacy-tab': ['pty-legacy'] },
|
||||
activeTabIdByWorktree: { [bareKey]: 'legacy-tab' }
|
||||
})
|
||||
|
||||
store.getState().setActiveFolderWorkspace('folder-workspace-1', 'local')
|
||||
|
||||
const state = store.getState()
|
||||
expect(state.activeWorkspaceKey).toBe(qualifiedKey)
|
||||
expect(state.activeWorktreeId).toBe(qualifiedKey)
|
||||
expect(state.tabsByWorktree[qualifiedKey]?.[0]?.id).toBe('legacy-tab')
|
||||
expect(state.tabsByWorktree[bareKey]).toBeUndefined()
|
||||
expect(state.activeTabIdByWorktree[qualifiedKey]).toBe('legacy-tab')
|
||||
expect(state.activeTabId).toBe('legacy-tab')
|
||||
})
|
||||
|
||||
it('keeps owner-distinct same-id session tabs isolated across hosts', () => {
|
||||
const store = createTestStore()
|
||||
const localKey = folderWorkspaceKey('same-id', 'local')
|
||||
const remoteKey = folderWorkspaceKey('same-id', 'runtime:env-1')
|
||||
store.setState({
|
||||
projectGroups: [
|
||||
makeGroup({ id: 'same-group', executionHostId: 'local', parentPath: '/local' }),
|
||||
makeGroup({ id: 'same-group', executionHostId: 'runtime:env-1', parentPath: '/remote' })
|
||||
],
|
||||
folderWorkspaces: [
|
||||
makeFolder({
|
||||
id: 'same-id',
|
||||
name: 'Local',
|
||||
executionHostId: 'local',
|
||||
folderPath: '/local/folder'
|
||||
}),
|
||||
makeFolder({
|
||||
id: 'same-id',
|
||||
name: 'Remote',
|
||||
executionHostId: 'runtime:env-1',
|
||||
folderPath: '/remote/folder'
|
||||
})
|
||||
],
|
||||
tabsByWorktree: {
|
||||
[localKey]: [
|
||||
{
|
||||
id: 'local-tab',
|
||||
ptyId: 'pty-local',
|
||||
worktreeId: localKey,
|
||||
title: 'Local',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
],
|
||||
[remoteKey]: [
|
||||
{
|
||||
id: 'remote-tab',
|
||||
ptyId: 'pty-remote',
|
||||
worktreeId: remoteKey,
|
||||
title: 'Remote',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
]
|
||||
},
|
||||
ptyIdsByTabId: {
|
||||
'local-tab': ['pty-local'],
|
||||
'remote-tab': ['pty-remote']
|
||||
}
|
||||
})
|
||||
|
||||
store.getState().setActiveFolderWorkspace('same-id', 'local')
|
||||
expect(store.getState().activeWorkspaceKey).toBe(localKey)
|
||||
expect(store.getState().tabsByWorktree[localKey]?.[0]?.id).toBe('local-tab')
|
||||
expect(store.getState().tabsByWorktree[remoteKey]?.[0]?.id).toBe('remote-tab')
|
||||
|
||||
store.getState().setActiveFolderWorkspace('same-id', 'runtime:env-1')
|
||||
expect(store.getState().activeWorkspaceKey).toBe(remoteKey)
|
||||
expect(store.getState().tabsByWorktree[localKey]?.[0]?.id).toBe('local-tab')
|
||||
expect(store.getState().tabsByWorktree[remoteKey]?.[0]?.id).toBe('remote-tab')
|
||||
})
|
||||
})
|
||||
@@ -43,10 +43,21 @@ import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import { sanitizeRepoIcon } from '../../../../shared/repo-icon'
|
||||
import { normalizeRepoBadgeColor } from '../../../../shared/repo-badge-color'
|
||||
import { applyManualRepoOrder, getManualRepoOrder } from '../../../../shared/manual-repo-order'
|
||||
import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups'
|
||||
import {
|
||||
buildProjectGroupOwnerIndex,
|
||||
getFolderWorkspaceProjectGroupOwnerHostId,
|
||||
getProjectGroupOwnerHostId,
|
||||
getProjectGroupOwnerIdentity,
|
||||
getProjectGroupSubtreeIds,
|
||||
resolveFolderWorkspaceProjectGroup,
|
||||
resolveProjectGroupOwner
|
||||
} from '../../../../shared/project-groups'
|
||||
import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
|
||||
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree-id'
|
||||
import { selectProjectGroupRemovalTargets } from './project-group-removal-targets'
|
||||
import {
|
||||
getProjectGroupRemovalFolderWorkspaceIdentity,
|
||||
selectProjectGroupRemovalTargets
|
||||
} from './project-group-removal-targets'
|
||||
import { reconcileFetchedRepos } from './repo-identity-reconcile'
|
||||
import {
|
||||
mergeSshRepoReadoptions,
|
||||
@@ -87,7 +98,16 @@ import {
|
||||
} from '../../../../shared/execution-host'
|
||||
import { isRemovedRuntimeHostId } from './stale-runtime-host-rows'
|
||||
import { cleanupEphemeralVmRuntimesForDeleted } from '@/lib/ephemeral-vm-runtime-cleanup'
|
||||
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
|
||||
import {
|
||||
folderWorkspaceKey,
|
||||
folderWorkspaceSessionKeys,
|
||||
parseWorkspaceKey
|
||||
} from '../../../../shared/workspace-scope'
|
||||
import {
|
||||
getFolderWorkspaceCatalogOwnerHostId,
|
||||
getFolderWorkspaceIdentity,
|
||||
getFolderWorkspaceOwnerIdentity
|
||||
} from '../../../../shared/folder-workspaces'
|
||||
import { formatFolderWorkspaceCreateError } from '../../lib/folder-workspace-path-status'
|
||||
import { getEnvironmentSshStateGeneration } from './runtime-environment-ssh'
|
||||
import { getRuntimeEnvironmentConnectionGeneration } from './runtime-status'
|
||||
@@ -202,6 +222,7 @@ export type FolderWorkspacePathStatusCacheEntry = {
|
||||
|
||||
export type DeleteProjectGroupWithContainedProjectsOptions = {
|
||||
removeContainedProjects: boolean
|
||||
ownerHostId?: ExecutionHostId
|
||||
}
|
||||
|
||||
type AllHostCatalogFetchOptions = {
|
||||
@@ -319,6 +340,15 @@ function getProjectSetupRuntimeTarget(
|
||||
: { kind: 'local' }
|
||||
}
|
||||
|
||||
function getProjectGroupRuntimeTarget(
|
||||
ownerHostId: ExecutionHostId
|
||||
): ReturnType<typeof getActiveRuntimeTarget> {
|
||||
const parsedHost = parseExecutionHostId(ownerHostId)
|
||||
return parsedHost?.kind === 'runtime'
|
||||
? { kind: 'environment', environmentId: parsedHost.environmentId }
|
||||
: { kind: 'local' }
|
||||
}
|
||||
|
||||
function getProjectUpdateRuntimeTarget(
|
||||
state: AppState,
|
||||
projectId: string
|
||||
@@ -953,15 +983,8 @@ function mergeProjectCompatibilityForHostRepoChange({
|
||||
})
|
||||
}
|
||||
|
||||
function getProjectGroupHostId(group: Pick<ProjectGroup, 'connectionId' | 'executionHostId'>) {
|
||||
if (group.executionHostId) {
|
||||
return group.executionHostId
|
||||
}
|
||||
return group.connectionId ? toSshExecutionHostId(group.connectionId) : LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
|
||||
function getProjectGroupHostIdentity(group: ProjectGroup): string {
|
||||
return JSON.stringify([getProjectGroupHostId(group), group.id])
|
||||
return getProjectGroupOwnerIdentity(group)
|
||||
}
|
||||
|
||||
function catalogOwnsHost(catalogHostId: string, rowHostId: string): boolean {
|
||||
@@ -978,7 +1001,7 @@ function mergeFetchedProjectGroupsForHost(
|
||||
): ProjectGroup[] {
|
||||
const fetchedIdentities = new Set(fetched.map(getProjectGroupHostIdentity))
|
||||
const preserved = previous.filter((group) => {
|
||||
const existingHostId = getProjectGroupHostId(group)
|
||||
const existingHostId = getProjectGroupOwnerHostId(group)
|
||||
return (
|
||||
!catalogOwnsHost(hostId, existingHostId) ||
|
||||
fetchedIdentities.has(getProjectGroupHostIdentity(group))
|
||||
@@ -1001,7 +1024,7 @@ function getFolderWorkspaceHostId(
|
||||
const matchingHosts = new Set(
|
||||
projectGroups
|
||||
.filter((group) => group.id === workspace.projectGroupId)
|
||||
.map(getProjectGroupHostId)
|
||||
.map(getProjectGroupOwnerHostId)
|
||||
)
|
||||
return matchingHosts.size === 1
|
||||
? ([...matchingHosts][0] as ExecutionHostId)
|
||||
@@ -1209,7 +1232,15 @@ function clearRestoredFolderWorkspaceSessionOwners(
|
||||
next[key] = hostId
|
||||
continue
|
||||
}
|
||||
const workspace = state.folderWorkspaces.find((entry) => entry.id === scope.folderWorkspaceId)
|
||||
const workspace = state.folderWorkspaces.find((entry) => {
|
||||
if (entry.id !== scope.folderWorkspaceId) {
|
||||
return false
|
||||
}
|
||||
if (!scope.ownerHostId) {
|
||||
return true
|
||||
}
|
||||
return getFolderWorkspaceCatalogOwnerHostId(entry, state.projectGroups) === scope.ownerHostId
|
||||
})
|
||||
if (workspace && !state.projectGroups.some((group) => group.id === workspace.projectGroupId)) {
|
||||
// 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
|
||||
@@ -1346,7 +1377,17 @@ async function reconcileFailedFolderWorkspaceUpdate(args: {
|
||||
: {})
|
||||
}))
|
||||
if (!refreshed) {
|
||||
args.get().purgeWorktreeTerminalState([folderWorkspaceKey(args.folderWorkspaceId)])
|
||||
const remainingSameId = args
|
||||
.get()
|
||||
.folderWorkspaces.some((workspace) => workspace.id === args.folderWorkspaceId)
|
||||
args.get().purgeWorktreeTerminalState(
|
||||
remainingSameId
|
||||
? [folderWorkspaceKey(args.folderWorkspaceId, args.ownerHostId)]
|
||||
: folderWorkspaceSessionKeys({
|
||||
folderWorkspaceId: args.folderWorkspaceId,
|
||||
ownerHostId: args.ownerHostId
|
||||
})
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Failed to reconcile folder workspace after update failure:', err)
|
||||
@@ -1391,12 +1432,16 @@ function settingsForRepoOwner(
|
||||
|
||||
function getFolderWorkspacePathStatusScopeKey(request: FolderWorkspacePathStatusRequest): string {
|
||||
if (request.scope === 'project-group') {
|
||||
return `project-group:${request.projectGroupId}`
|
||||
return request.ownerHostId
|
||||
? `project-group:${request.ownerHostId}:${request.projectGroupId}`
|
||||
: `project-group:${request.projectGroupId}`
|
||||
}
|
||||
if (request.scope === 'path') {
|
||||
return `path:${request.connectionId ?? ''}:${request.path}`
|
||||
}
|
||||
return `folder-workspace:${request.folderWorkspaceId}`
|
||||
return request.ownerHostId
|
||||
? `folder-workspace:${request.ownerHostId}:${request.folderWorkspaceId}`
|
||||
: `folder-workspace:${request.folderWorkspaceId}`
|
||||
}
|
||||
|
||||
function getRuntimeTargetCachePrefix(
|
||||
@@ -1406,7 +1451,10 @@ function getRuntimeTargetCachePrefix(
|
||||
return target.kind === 'local' ? 'local' : `environment:${target.environmentId}`
|
||||
}
|
||||
|
||||
type FolderWorkspacePathStatusRouteOptions = { runtimeEnvironmentId?: string | null }
|
||||
type FolderWorkspacePathStatusRouteOptions = {
|
||||
runtimeEnvironmentId?: string | null
|
||||
ownerHostId?: ExecutionHostId
|
||||
}
|
||||
type AddRepoPathRouteOptions = { runtimeEnvironmentId?: string | null }
|
||||
type RuntimeCatalogFetchOptions = { runtimeEnvironmentId?: string | null }
|
||||
|
||||
@@ -1526,17 +1574,30 @@ function getFolderWorkspaceStatusRequestSnapshot(
|
||||
)
|
||||
}
|
||||
|
||||
const catalog = getFolderPathStatusCatalog(state.projectGroups, state.repos)
|
||||
const projectGroupIndex = catalog.projectGroupIndex
|
||||
const scope =
|
||||
request.scope === 'project-group'
|
||||
? state.projectGroups.find((group) => group.id === request.projectGroupId)
|
||||
: state.folderWorkspaces.find((workspace) => workspace.id === request.folderWorkspaceId)
|
||||
? resolveProjectGroupOwner(projectGroupIndex, request.projectGroupId, request.ownerHostId)
|
||||
: state.folderWorkspaces.find((workspace) => {
|
||||
if (workspace.id !== request.folderWorkspaceId) {
|
||||
return false
|
||||
}
|
||||
if (!request.ownerHostId) {
|
||||
return true
|
||||
}
|
||||
return (
|
||||
getFolderWorkspaceCatalogOwnerHostId(workspace, state.projectGroups) ===
|
||||
request.ownerHostId
|
||||
)
|
||||
})
|
||||
const projectGroup =
|
||||
request.scope === 'project-group'
|
||||
? scope && 'parentPath' in scope
|
||||
? scope
|
||||
: null
|
||||
: scope && 'projectGroupId' in scope
|
||||
? state.projectGroups.find((group) => group.id === scope.projectGroupId)
|
||||
? resolveFolderWorkspaceProjectGroup(projectGroupIndex, scope)
|
||||
: null
|
||||
const folderPath =
|
||||
request.scope === 'project-group'
|
||||
@@ -1563,11 +1624,29 @@ function getFolderWorkspaceStatusRequestSnapshot(
|
||||
if (!folderPath || !projectGroupId) {
|
||||
return null
|
||||
}
|
||||
const groupIds = getProjectGroupSubtreeIds(state.projectGroups, projectGroupId)
|
||||
const cachedBase =
|
||||
request.scope === 'project-group' && projectGroup
|
||||
? getProjectGroupPathStatusSnapshotBase(catalog, projectGroup)
|
||||
: scope && 'folderPath' in scope && projectGroup
|
||||
? getFolderWorkspacePathStatusSnapshotBase(catalog, scope, projectGroup)
|
||||
: null
|
||||
if (cachedBase) {
|
||||
return getFolderPathStatusSnapshotWithSshState(cachedBase, state.sshConnectionStates)
|
||||
}
|
||||
const ownerHostId = projectGroup
|
||||
? request.scope === 'folder-workspace' && scope && 'projectGroupId' in scope
|
||||
? getFolderWorkspaceProjectGroupOwnerHostId(scope, projectGroupIndex)
|
||||
: getProjectGroupOwnerHostId(projectGroup)
|
||||
: null
|
||||
const ownerGroups = ownerHostId
|
||||
? state.projectGroups.filter((group) => getProjectGroupOwnerHostId(group) === ownerHostId)
|
||||
: state.projectGroups
|
||||
const groupIds = getProjectGroupSubtreeIds(ownerGroups, projectGroupId)
|
||||
const candidateRepos = state.repos.filter(
|
||||
(repo) =>
|
||||
(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) ||
|
||||
isPathInsideOrEqual(folderPath, repo.path)
|
||||
(!ownerHostId || getRepoExecutionHostId(repo) === ownerHostId) &&
|
||||
((typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) ||
|
||||
isPathInsideOrEqual(folderPath, repo.path))
|
||||
)
|
||||
const relevantConnectionIds = new Set<string>()
|
||||
if (scopeConnectionId) {
|
||||
@@ -1600,6 +1679,164 @@ function getFolderWorkspaceStatusRequestSnapshot(
|
||||
].join('\0')
|
||||
}
|
||||
|
||||
type FolderPathStatusSnapshotBase = {
|
||||
folderPath: string
|
||||
projectGroupId: string
|
||||
scopeConnectionId: string
|
||||
relevantConnectionIds: readonly string[]
|
||||
repoFingerprint: string
|
||||
}
|
||||
|
||||
type FolderPathStatusCatalog = {
|
||||
projectGroupIndex: ReturnType<typeof buildProjectGroupOwnerIndex>
|
||||
groupsByOwner: ReadonlyMap<ExecutionHostId, readonly ProjectGroup[]>
|
||||
reposByOwner: ReadonlyMap<ExecutionHostId, readonly Repo[]>
|
||||
groupSnapshots: Map<string, FolderPathStatusSnapshotBase>
|
||||
workspaceSnapshots: WeakMap<FolderWorkspace, FolderPathStatusSnapshotBase>
|
||||
}
|
||||
|
||||
const folderPathStatusCatalogs = new WeakMap<
|
||||
readonly ProjectGroup[],
|
||||
WeakMap<readonly Repo[], FolderPathStatusCatalog>
|
||||
>()
|
||||
|
||||
function getFolderPathStatusCatalog(
|
||||
projectGroups: readonly ProjectGroup[],
|
||||
repos: readonly Repo[]
|
||||
): FolderPathStatusCatalog {
|
||||
let byRepos = folderPathStatusCatalogs.get(projectGroups)
|
||||
if (!byRepos) {
|
||||
byRepos = new WeakMap()
|
||||
folderPathStatusCatalogs.set(projectGroups, byRepos)
|
||||
}
|
||||
const existing = byRepos.get(repos)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const groupsByOwner = new Map<ExecutionHostId, ProjectGroup[]>()
|
||||
for (const group of projectGroups) {
|
||||
const ownerHostId = getProjectGroupOwnerHostId(group)
|
||||
const ownerGroups = groupsByOwner.get(ownerHostId) ?? []
|
||||
ownerGroups.push(group)
|
||||
groupsByOwner.set(ownerHostId, ownerGroups)
|
||||
}
|
||||
const reposByOwner = new Map<ExecutionHostId, Repo[]>()
|
||||
for (const repo of repos) {
|
||||
const ownerHostId = getRepoExecutionHostId(repo)
|
||||
const ownerRepos = reposByOwner.get(ownerHostId) ?? []
|
||||
ownerRepos.push(repo)
|
||||
reposByOwner.set(ownerHostId, ownerRepos)
|
||||
}
|
||||
const catalog: FolderPathStatusCatalog = {
|
||||
projectGroupIndex: buildProjectGroupOwnerIndex(projectGroups),
|
||||
groupsByOwner,
|
||||
reposByOwner,
|
||||
groupSnapshots: new Map(),
|
||||
workspaceSnapshots: new WeakMap()
|
||||
}
|
||||
byRepos.set(repos, catalog)
|
||||
return catalog
|
||||
}
|
||||
|
||||
function buildFolderPathStatusSnapshotBase(
|
||||
catalog: FolderPathStatusCatalog,
|
||||
projectGroup: ProjectGroup,
|
||||
folderPath: string,
|
||||
scopeConnectionId: string | null | undefined
|
||||
): FolderPathStatusSnapshotBase {
|
||||
const ownerHostId = getProjectGroupOwnerHostId(projectGroup)
|
||||
const groupIds = getProjectGroupSubtreeIds(
|
||||
catalog.groupsByOwner.get(ownerHostId) ?? [],
|
||||
projectGroup.id
|
||||
)
|
||||
const candidateRepos = (catalog.reposByOwner.get(ownerHostId) ?? []).filter(
|
||||
(repo) =>
|
||||
(typeof repo.projectGroupId === 'string' && groupIds.has(repo.projectGroupId)) ||
|
||||
isPathInsideOrEqual(folderPath, repo.path)
|
||||
)
|
||||
const relevantConnectionIds = new Set<string>()
|
||||
if (scopeConnectionId) {
|
||||
relevantConnectionIds.add(scopeConnectionId)
|
||||
}
|
||||
for (const repo of candidateRepos) {
|
||||
if (repo.connectionId) {
|
||||
relevantConnectionIds.add(repo.connectionId)
|
||||
}
|
||||
}
|
||||
return {
|
||||
folderPath,
|
||||
projectGroupId: projectGroup.id,
|
||||
scopeConnectionId: scopeConnectionId ?? '',
|
||||
relevantConnectionIds: [...relevantConnectionIds].sort(),
|
||||
repoFingerprint: candidateRepos
|
||||
.map(
|
||||
(repo) => `${repo.id}:${repo.path}:${repo.projectGroupId ?? ''}:${repo.connectionId ?? ''}`
|
||||
)
|
||||
.sort()
|
||||
.join('|')
|
||||
}
|
||||
}
|
||||
|
||||
function getProjectGroupPathStatusSnapshotBase(
|
||||
catalog: FolderPathStatusCatalog,
|
||||
projectGroup: ProjectGroup
|
||||
): FolderPathStatusSnapshotBase | null {
|
||||
if (!projectGroup.parentPath) {
|
||||
return null
|
||||
}
|
||||
const identity = getProjectGroupOwnerIdentity(projectGroup)
|
||||
const existing = catalog.groupSnapshots.get(identity)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const snapshot = buildFolderPathStatusSnapshotBase(
|
||||
catalog,
|
||||
projectGroup,
|
||||
projectGroup.parentPath,
|
||||
projectGroup.connectionId
|
||||
)
|
||||
catalog.groupSnapshots.set(identity, snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function getFolderWorkspacePathStatusSnapshotBase(
|
||||
catalog: FolderPathStatusCatalog,
|
||||
workspace: FolderWorkspace,
|
||||
projectGroup: ProjectGroup
|
||||
): FolderPathStatusSnapshotBase {
|
||||
const existing = catalog.workspaceSnapshots.get(workspace)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
const snapshot = buildFolderPathStatusSnapshotBase(
|
||||
catalog,
|
||||
projectGroup,
|
||||
workspace.folderPath,
|
||||
workspace.connectionId ?? projectGroup.connectionId
|
||||
)
|
||||
catalog.workspaceSnapshots.set(workspace, snapshot)
|
||||
return snapshot
|
||||
}
|
||||
|
||||
function getFolderPathStatusSnapshotWithSshState(
|
||||
base: FolderPathStatusSnapshotBase,
|
||||
sshConnectionStates: AppState['sshConnectionStates']
|
||||
): string {
|
||||
const sshFingerprint = base.relevantConnectionIds
|
||||
.map(
|
||||
(connectionId) =>
|
||||
`${connectionId}:${sshConnectionStates.get(connectionId)?.status ?? 'missing'}`
|
||||
)
|
||||
.join('|')
|
||||
return [
|
||||
base.folderPath,
|
||||
base.projectGroupId,
|
||||
base.scopeConnectionId,
|
||||
sshFingerprint,
|
||||
base.repoFingerprint
|
||||
].join('\0')
|
||||
}
|
||||
|
||||
function getFreshFolderWorkspacePathStatusFromCache(args: {
|
||||
entry: FolderWorkspacePathStatusCacheEntry | undefined
|
||||
requestSnapshot: string | null
|
||||
@@ -1611,6 +1848,19 @@ function getFreshFolderWorkspacePathStatusFromCache(args: {
|
||||
return Date.now() - entry.checkedAt < FOLDER_WORKSPACE_PATH_STATUS_TTL_MS ? entry.status : null
|
||||
}
|
||||
|
||||
function getOwnerQualifiedFolderWorkspacePathStatusRequest(
|
||||
request: FolderWorkspacePathStatusRequest,
|
||||
options: FolderWorkspacePathStatusRouteOptions | undefined
|
||||
): FolderWorkspacePathStatusRequest {
|
||||
if (
|
||||
(request.scope === 'project-group' || request.scope === 'folder-workspace') &&
|
||||
options?.ownerHostId
|
||||
) {
|
||||
return { ...request, ownerHostId: options.ownerHostId }
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
function getFolderWorkspacePathStatusRequestSnapshotForRead(
|
||||
state: AppState,
|
||||
request: FolderWorkspacePathStatusRequest
|
||||
@@ -1673,7 +1923,10 @@ export type RepoSlice = {
|
||||
runtimeEnvironmentId?: string | null
|
||||
mode: 'group' | 'separate'
|
||||
}) => Promise<ProjectGroupImportResult | null>
|
||||
createProjectGroup: (name: string) => Promise<ProjectGroup | null>
|
||||
createProjectGroup: (
|
||||
name: string,
|
||||
options?: { ownerHostId?: ExecutionHostId }
|
||||
) => Promise<ProjectGroup | null>
|
||||
createFolderWorkspace: (
|
||||
args: {
|
||||
projectGroupId: string
|
||||
@@ -1704,12 +1957,19 @@ export type RepoSlice = {
|
||||
updates: FolderWorkspaceUpdates,
|
||||
options?: { executionHostId?: ExecutionHostId }
|
||||
) => Promise<boolean>
|
||||
deleteFolderWorkspace: (folderWorkspaceId: string) => Promise<boolean>
|
||||
deleteFolderWorkspace: (
|
||||
folderWorkspaceId: string,
|
||||
options?: { ownerHostId?: ExecutionHostId }
|
||||
) => Promise<boolean>
|
||||
updateProjectGroup: (
|
||||
groupId: string,
|
||||
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>
|
||||
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>,
|
||||
options?: { ownerHostId?: ExecutionHostId }
|
||||
) => Promise<boolean>
|
||||
deleteProjectGroup: (
|
||||
groupId: string,
|
||||
options?: { ownerHostId?: ExecutionHostId }
|
||||
) => Promise<boolean>
|
||||
deleteProjectGroup: (groupId: string) => Promise<boolean>
|
||||
deleteProjectGroupWithContainedProjects: (
|
||||
groupId: string,
|
||||
options: DeleteProjectGroupWithContainedProjectsOptions
|
||||
@@ -1717,7 +1977,8 @@ export type RepoSlice = {
|
||||
moveProjectToGroup: (
|
||||
projectId: string,
|
||||
groupId: string | null,
|
||||
order?: number
|
||||
order?: number,
|
||||
options?: { ownerHostId?: ExecutionHostId }
|
||||
) => Promise<boolean>
|
||||
// options.hostId disambiguates which host's row to remove when the id exists on multiple hosts; else the focused host is assumed.
|
||||
// options.errorFeedback defaults to 'silent' so bulk/background callers keep their own aggregate reporting.
|
||||
@@ -2335,19 +2596,21 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
getFolderWorkspacePathStatusCacheKey: (request, options) =>
|
||||
`${getRuntimeTargetCachePrefix(
|
||||
getFolderWorkspacePathStatusRouteSettings(options, get().settings)
|
||||
)}:${getFolderWorkspacePathStatusScopeKey(request)}`,
|
||||
)}:${options?.ownerHostId ? `${options.ownerHostId}:` : ''}${getFolderWorkspacePathStatusScopeKey(request)}`,
|
||||
|
||||
getFreshFolderWorkspacePathStatus: (request, options) => {
|
||||
const state = get()
|
||||
const cacheKey = get().getFolderWorkspacePathStatusCacheKey(request, options)
|
||||
const cached = state.folderWorkspacePathStatuses[cacheKey]
|
||||
const requestSnapshot = getFolderWorkspacePathStatusRequestSnapshotForRead(state, request)
|
||||
const ownedRequest = getOwnerQualifiedFolderWorkspacePathStatusRequest(request, options)
|
||||
const requestSnapshot = getFolderWorkspacePathStatusRequestSnapshotForRead(state, ownedRequest)
|
||||
return getFreshFolderWorkspacePathStatusFromCache({ entry: cached, requestSnapshot })
|
||||
},
|
||||
|
||||
fetchFolderWorkspacePathStatus: async (request, options) => {
|
||||
const cacheKey = get().getFolderWorkspacePathStatusCacheKey(request, options)
|
||||
const requestSnapshot = getFolderWorkspaceStatusRequestSnapshot(get(), request)
|
||||
const ownedRequest = getOwnerQualifiedFolderWorkspacePathStatusRequest(request, options)
|
||||
const requestSnapshot = getFolderWorkspaceStatusRequestSnapshot(get(), ownedRequest)
|
||||
const cached = get().folderWorkspacePathStatuses[cacheKey]
|
||||
const freshCachedStatus = getFreshFolderWorkspacePathStatusFromCache({
|
||||
entry: cached,
|
||||
@@ -2362,7 +2625,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
)
|
||||
const status =
|
||||
target.kind === 'local'
|
||||
? await window.api.folderWorkspaces.getPathStatus(request)
|
||||
? await window.api.folderWorkspaces.getPathStatus(ownedRequest)
|
||||
: (
|
||||
await callRuntimeRpc<{ status: FolderWorkspacePathStatus }>(
|
||||
target,
|
||||
@@ -2374,7 +2637,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
set((state) => ({
|
||||
folderWorkspacePathStatuses:
|
||||
requestSnapshot !== null &&
|
||||
getFolderWorkspaceStatusRequestSnapshot(state, request) === requestSnapshot
|
||||
getFolderWorkspaceStatusRequestSnapshot(state, ownedRequest) === requestSnapshot
|
||||
? {
|
||||
...state.folderWorkspacePathStatuses,
|
||||
[cacheKey]: { status, checkedAt: Date.now(), requestSnapshot }
|
||||
@@ -2487,14 +2750,25 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
}
|
||||
},
|
||||
|
||||
createProjectGroup: async (name) => {
|
||||
createProjectGroup: async (name, options) => {
|
||||
try {
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const hasExplicitOwner = options?.ownerHostId !== undefined
|
||||
const parsedOwner = hasExplicitOwner ? parseExecutionHostId(options.ownerHostId!) : null
|
||||
if (hasExplicitOwner && !parsedOwner) {
|
||||
return null
|
||||
}
|
||||
const target = parsedOwner
|
||||
? parsedOwner.kind === 'runtime'
|
||||
? { kind: 'environment' as const, environmentId: parsedOwner.environmentId }
|
||||
: { kind: 'local' as const }
|
||||
: getActiveRuntimeTarget(get().settings)
|
||||
const connectionId = parsedOwner?.kind === 'ssh' ? parsedOwner.targetId : undefined
|
||||
const group =
|
||||
target.kind === 'local'
|
||||
? await window.api.projectGroups.create({
|
||||
name,
|
||||
createdFrom: 'manual'
|
||||
createdFrom: 'manual',
|
||||
...(connectionId ? { connectionId } : {})
|
||||
})
|
||||
: (
|
||||
await callRuntimeRpc<{ group: ProjectGroup }>(
|
||||
@@ -2504,7 +2778,9 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
{ timeoutMs: 15_000 }
|
||||
)
|
||||
).group
|
||||
const ownedGroup = projectGroupWithFetchedOwner(group, target)
|
||||
const ownedGroup = hasExplicitOwner
|
||||
? { ...group, executionHostId: options.ownerHostId }
|
||||
: projectGroupWithFetchedOwner(group, target)
|
||||
set((s) => ({
|
||||
projectGroups: [...s.projectGroups, ownedGroup],
|
||||
folderWorkspacePathStatuses: {}
|
||||
@@ -2653,12 +2929,20 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
}
|
||||
},
|
||||
|
||||
deleteFolderWorkspace: async (folderWorkspaceId) => {
|
||||
deleteFolderWorkspace: async (folderWorkspaceId, options) => {
|
||||
const state = get()
|
||||
if (!findFolderWorkspaceOwner(state, folderWorkspaceId)) {
|
||||
const owner = findFolderWorkspaceOwner(state, folderWorkspaceId, options?.ownerHostId)
|
||||
if (!owner) {
|
||||
return false
|
||||
}
|
||||
const runtimeEnvironmentId = getRuntimeEnvironmentIdForFolderWorkspace(state, folderWorkspaceId)
|
||||
const ownerHostId =
|
||||
options?.ownerHostId ?? getFolderWorkspaceCatalogOwnerHostId(owner, state.projectGroups)
|
||||
// Why: catalog owner can come from the project group even when the folder row itself is still unstamped.
|
||||
const ownerRuntime = parseExecutionHostId(ownerHostId)
|
||||
const runtimeEnvironmentId =
|
||||
ownerRuntime?.kind === 'runtime'
|
||||
? ownerRuntime.environmentId
|
||||
: getRuntimeEnvironmentIdForFolderWorkspace(state, folderWorkspaceId, options?.ownerHostId)
|
||||
try {
|
||||
// Why: deletion targets the folder's owner; focus may be on a different host.
|
||||
const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId })
|
||||
@@ -2676,14 +2960,29 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
if (!deleted) {
|
||||
return false
|
||||
}
|
||||
const workspaceKey = folderWorkspaceKey(folderWorkspaceId)
|
||||
const ownerIdentity = getFolderWorkspaceIdentity(folderWorkspaceId, ownerHostId)
|
||||
set((s) => ({
|
||||
folderWorkspaces: s.folderWorkspaces.filter(
|
||||
(workspace) => workspace.id !== folderWorkspaceId
|
||||
(workspace) =>
|
||||
getFolderWorkspaceOwnerIdentity(workspace, s.projectGroups) !== ownerIdentity
|
||||
),
|
||||
folderWorkspacePathStatuses: {}
|
||||
folderWorkspacePathStatuses: Object.fromEntries(
|
||||
Object.entries(s.folderWorkspacePathStatuses).filter(([cacheKey]) => {
|
||||
const ownerScoped = `folder-workspace:${ownerHostId}:${folderWorkspaceId}`
|
||||
const bareScoped = `folder-workspace:${folderWorkspaceId}`
|
||||
return !cacheKey.includes(ownerScoped) && !cacheKey.endsWith(bareScoped)
|
||||
})
|
||||
)
|
||||
}))
|
||||
get().purgeWorktreeTerminalState([workspaceKey])
|
||||
const remainingSameId = get().folderWorkspaces.some(
|
||||
(workspace) => workspace.id === folderWorkspaceId
|
||||
)
|
||||
// Why: same-id cross-host rows share the legacy bare key; only drop it when no sibling remains.
|
||||
get().purgeWorktreeTerminalState(
|
||||
remainingSameId
|
||||
? [folderWorkspaceKey(folderWorkspaceId, ownerHostId)]
|
||||
: folderWorkspaceSessionKeys({ folderWorkspaceId, ownerHostId })
|
||||
)
|
||||
return true
|
||||
} catch (err) {
|
||||
console.error('Failed to delete folder workspace:', err)
|
||||
@@ -2691,13 +2990,21 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
}
|
||||
},
|
||||
|
||||
updateProjectGroup: async (groupId, updates) => {
|
||||
updateProjectGroup: async (groupId, updates, options) => {
|
||||
try {
|
||||
// Why: project groups are focused-host-scoped by design; all CRUD routes by the focused host and the list is replaced, not merged.
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const group = resolveProjectGroupOwner(
|
||||
buildProjectGroupOwnerIndex(get().projectGroups),
|
||||
groupId,
|
||||
options?.ownerHostId
|
||||
)
|
||||
if (!group) {
|
||||
return false
|
||||
}
|
||||
const ownerHostId = getProjectGroupOwnerHostId(group)
|
||||
const target = getProjectGroupRuntimeTarget(ownerHostId)
|
||||
const updated =
|
||||
target.kind === 'local'
|
||||
? await window.api.projectGroups.update({ groupId, updates })
|
||||
? await window.api.projectGroups.update({ groupId, updates, ownerHostId })
|
||||
: (
|
||||
await callRuntimeRpc<{ group: ProjectGroup | null }>(
|
||||
target,
|
||||
@@ -2710,8 +3017,11 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
return false
|
||||
}
|
||||
const ownedGroup = projectGroupWithFetchedOwner(updated, target)
|
||||
const ownedIdentity = getProjectGroupHostIdentity(ownedGroup)
|
||||
set((s) => ({
|
||||
projectGroups: s.projectGroups.map((group) => (group.id === groupId ? ownedGroup : group)),
|
||||
projectGroups: s.projectGroups.map((group) =>
|
||||
getProjectGroupHostIdentity(group) === ownedIdentity ? ownedGroup : group
|
||||
),
|
||||
folderWorkspacePathStatuses: {}
|
||||
}))
|
||||
return true
|
||||
@@ -2721,13 +3031,25 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
}
|
||||
},
|
||||
|
||||
deleteProjectGroup: async (groupId) => {
|
||||
deleteProjectGroup: async (groupId, options) => {
|
||||
try {
|
||||
// Why: project groups are focused-host-scoped by design (see updateProjectGroup).
|
||||
const target = getActiveRuntimeTarget(get().settings)
|
||||
const targets = selectProjectGroupRemovalTargets(
|
||||
get().projectGroups,
|
||||
get().repos,
|
||||
groupId,
|
||||
options?.ownerHostId,
|
||||
get().folderWorkspaces
|
||||
)
|
||||
if (!targets.ownerHostId) {
|
||||
return false
|
||||
}
|
||||
const target = getProjectGroupRuntimeTarget(targets.ownerHostId)
|
||||
const deleted =
|
||||
target.kind === 'local'
|
||||
? await window.api.projectGroups.delete({ groupId })
|
||||
? await window.api.projectGroups.delete({
|
||||
groupId,
|
||||
ownerHostId: targets.ownerHostId
|
||||
})
|
||||
: (
|
||||
await callRuntimeRpc<{ deleted: boolean }>(
|
||||
target,
|
||||
@@ -2740,14 +3062,22 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
return false
|
||||
}
|
||||
set((s) => {
|
||||
const deletedGroupIds = getProjectGroupSubtreeIds(s.projectGroups, groupId)
|
||||
const projectTargetIdentities = new Set(
|
||||
targets.projectTargets.map((projectTarget) => projectTarget.identity)
|
||||
)
|
||||
const projectGroupIndex = buildProjectGroupOwnerIndex(s.projectGroups)
|
||||
return {
|
||||
projectGroups: s.projectGroups.filter((group) => !deletedGroupIds.has(group.id)),
|
||||
projectGroups: s.projectGroups.filter(
|
||||
(group) => !targets.deletedGroupIdentities.has(getProjectGroupHostIdentity(group))
|
||||
),
|
||||
folderWorkspaces: s.folderWorkspaces.filter(
|
||||
(workspace) => !deletedGroupIds.has(workspace.projectGroupId)
|
||||
(workspace) =>
|
||||
!targets.folderWorkspaceIdentities.has(
|
||||
getProjectGroupRemovalFolderWorkspaceIdentity(projectGroupIndex, workspace) ?? ''
|
||||
)
|
||||
),
|
||||
repos: s.repos.map((repo) =>
|
||||
repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId)
|
||||
repo.projectGroupId && projectTargetIdentities.has(getRepoHostIdentity(repo))
|
||||
? { ...repo, projectGroupId: null }
|
||||
: repo
|
||||
),
|
||||
@@ -2762,7 +3092,13 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
},
|
||||
|
||||
deleteProjectGroupWithContainedProjects: async (groupId, options) => {
|
||||
const targets = selectProjectGroupRemovalTargets(get().projectGroups, get().repos, groupId)
|
||||
const targets = selectProjectGroupRemovalTargets(
|
||||
get().projectGroups,
|
||||
get().repos,
|
||||
groupId,
|
||||
options.ownerHostId,
|
||||
get().folderWorkspaces
|
||||
)
|
||||
const requestedProjectIds = options.removeContainedProjects ? targets.projectIds : []
|
||||
if (!targets.groupExists) {
|
||||
return {
|
||||
@@ -2774,7 +3110,9 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
}
|
||||
}
|
||||
|
||||
const deleted = await get().deleteProjectGroup(groupId)
|
||||
const deleted = await get().deleteProjectGroup(groupId, {
|
||||
ownerHostId: targets.ownerHostId ?? undefined
|
||||
})
|
||||
if (!deleted) {
|
||||
return {
|
||||
status: 'group-delete-failed',
|
||||
@@ -2797,16 +3135,19 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
|
||||
const removedProjectIds: string[] = []
|
||||
const failedProjectRemovals: ProjectRemovalFailure[] = []
|
||||
for (const projectId of targets.projectIds) {
|
||||
const existedBeforeRemoval = get().repos.some((repo) => repo.id === projectId)
|
||||
for (const projectTarget of targets.projectTargets) {
|
||||
const { projectId, ownerHostId, identity } = projectTarget
|
||||
const existedBeforeRemoval = get().repos.some(
|
||||
(repo) => getRepoHostIdentity(repo) === identity
|
||||
)
|
||||
try {
|
||||
if (existedBeforeRemoval) {
|
||||
await get().removeProject(projectId)
|
||||
await get().removeProject(projectId, { hostId: ownerHostId })
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to remove contained project:', err)
|
||||
}
|
||||
const stillExists = get().repos.some((repo) => repo.id === projectId)
|
||||
const stillExists = get().repos.some((repo) => getRepoHostIdentity(repo) === identity)
|
||||
if (stillExists) {
|
||||
failedProjectRemovals.push({
|
||||
projectId,
|
||||
@@ -2826,18 +3167,42 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
}
|
||||
},
|
||||
|
||||
moveProjectToGroup: async (projectId, groupId, order) => {
|
||||
moveProjectToGroup: async (projectId, groupId, order, options) => {
|
||||
try {
|
||||
if (!findRepoForHost(get().repos, projectId, { settings: get().settings })) {
|
||||
const hasExplicitOwner = options?.ownerHostId !== undefined
|
||||
if (hasExplicitOwner && !parseExecutionHostId(options.ownerHostId!)) {
|
||||
return false
|
||||
}
|
||||
const target = getActiveRuntimeTarget(settingsForRepoOwner(get(), projectId))
|
||||
const matchingRepos = get().repos.filter(
|
||||
(repo) =>
|
||||
repo.id === projectId &&
|
||||
(!hasExplicitOwner || getRepoExecutionHostId(repo) === options.ownerHostId)
|
||||
)
|
||||
if (matchingRepos.length !== 1) {
|
||||
return false
|
||||
}
|
||||
const ownerHostId = getRepoExecutionHostId(matchingRepos[0])
|
||||
if (
|
||||
groupId !== null &&
|
||||
!resolveProjectGroupOwner(
|
||||
buildProjectGroupOwnerIndex(get().projectGroups),
|
||||
groupId,
|
||||
ownerHostId
|
||||
)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const target = getProjectGroupRuntimeTarget(ownerHostId)
|
||||
// Why: omit ownerHostId when the bare group id is unique so legacy preload callers keep their call shape.
|
||||
const groupIdIsAmbiguous =
|
||||
groupId !== null && get().projectGroups.filter((group) => group.id === groupId).length > 1
|
||||
const moved =
|
||||
target.kind === 'local'
|
||||
? await window.api.projectGroups.moveProject({
|
||||
projectId,
|
||||
groupId,
|
||||
order
|
||||
order,
|
||||
...(groupIdIsAmbiguous || hasExplicitOwner ? { ownerHostId } : {})
|
||||
})
|
||||
: (
|
||||
await callRuntimeRpc<{ repo: Repo | null }>(
|
||||
@@ -2850,18 +3215,17 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
|
||||
if (!moved) {
|
||||
return false
|
||||
}
|
||||
const ownedMoved = repoWithFetchedOwner(moved, target)
|
||||
const movedHostId = getRepoExecutionHostId(ownedMoved)
|
||||
const ownedMoved = { ...repoWithFetchedOwner(moved, target), executionHostId: ownerHostId }
|
||||
set((s) => {
|
||||
const nextRepos = s.repos.map((repo) =>
|
||||
repoMatchesHostIdentity(repo, projectId, movedHostId) ? ownedMoved : repo
|
||||
repoMatchesHostIdentity(repo, projectId, ownerHostId) ? ownedMoved : repo
|
||||
)
|
||||
return {
|
||||
repos: nextRepos,
|
||||
...mergeProjectCompatibilityForHostRepoChange({
|
||||
previous: { projects: s.projects, projectHostSetups: s.projectHostSetups },
|
||||
nextRepos,
|
||||
hostId: movedHostId
|
||||
hostId: ownerHostId
|
||||
}),
|
||||
folderWorkspacePathStatuses: {}
|
||||
}
|
||||
|
||||
@@ -97,13 +97,16 @@ import { captureWorktreeOperationGenerationGuard } from '@/lib/worktree-operatio
|
||||
import { getEnvironmentSshStateGeneration } from './runtime-environment-ssh'
|
||||
import { getRuntimeEnvironmentConnectionGeneration } from './runtime-status'
|
||||
import {
|
||||
dualReadFolderWorkspaceKeyedValue,
|
||||
folderWorkspaceKey,
|
||||
getActiveSidebarWorkspaceId,
|
||||
isWorkspaceKey,
|
||||
migrateFolderWorkspaceKeyedRecord,
|
||||
parseWorkspaceKey,
|
||||
worktreeWorkspaceKey
|
||||
} from '../../../../shared/workspace-scope'
|
||||
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
|
||||
import { getFolderWorkspaceCatalogOwnerHostId } from '../../../../shared/folder-workspaces'
|
||||
import {
|
||||
CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS,
|
||||
getClientWorktreeCreateCandidate,
|
||||
@@ -875,6 +878,110 @@ function folderWorkspaceMatchesHost(
|
||||
)
|
||||
}
|
||||
|
||||
/** Promote legacy bare `folder:<id>` session maps to owner-qualified keys for one unambiguous owner. */
|
||||
function migrateLegacyFolderWorkspaceSessionMaps(
|
||||
s: AppState,
|
||||
folderWorkspaceId: string,
|
||||
ownerHostId: ExecutionHostId
|
||||
): Partial<AppState> | null {
|
||||
const resolveOwner = (id: string): ExecutionHostId | null =>
|
||||
id === folderWorkspaceId ? ownerHostId : null
|
||||
const tabs = migrateFolderWorkspaceKeyedRecord(s.tabsByWorktree, resolveOwner)
|
||||
const activeFileIdByWorktree = migrateFolderWorkspaceKeyedRecord(
|
||||
s.activeFileIdByWorktree,
|
||||
resolveOwner
|
||||
)
|
||||
const activeBrowserTabIdByWorktree = migrateFolderWorkspaceKeyedRecord(
|
||||
s.activeBrowserTabIdByWorktree,
|
||||
resolveOwner
|
||||
)
|
||||
const activeTabTypeByWorktree = migrateFolderWorkspaceKeyedRecord(
|
||||
s.activeTabTypeByWorktree,
|
||||
resolveOwner
|
||||
)
|
||||
const activeTabIdByWorktree = migrateFolderWorkspaceKeyedRecord(
|
||||
s.activeTabIdByWorktree,
|
||||
resolveOwner
|
||||
)
|
||||
const browserTabsByWorktree = migrateFolderWorkspaceKeyedRecord(
|
||||
s.browserTabsByWorktree,
|
||||
resolveOwner
|
||||
)
|
||||
const groupsByWorktree = migrateFolderWorkspaceKeyedRecord(s.groupsByWorktree, resolveOwner)
|
||||
const unifiedTabsByWorktree = migrateFolderWorkspaceKeyedRecord(
|
||||
s.unifiedTabsByWorktree,
|
||||
resolveOwner
|
||||
)
|
||||
const activeGroupIdByWorktree = migrateFolderWorkspaceKeyedRecord(
|
||||
s.activeGroupIdByWorktree,
|
||||
resolveOwner
|
||||
)
|
||||
// Why: openFiles rows also stamp worktreeId; rewrite legacy bare folder stamps for this owner.
|
||||
let openFilesChanged = false
|
||||
const nextOpenFiles = s.openFiles.map((file) => {
|
||||
const scope = parseWorkspaceKey(file.worktreeId)
|
||||
if (
|
||||
scope?.type === 'folder' &&
|
||||
!scope.ownerHostId &&
|
||||
scope.folderWorkspaceId === folderWorkspaceId
|
||||
) {
|
||||
openFilesChanged = true
|
||||
return { ...file, worktreeId: folderWorkspaceKey(folderWorkspaceId, ownerHostId) }
|
||||
}
|
||||
return file
|
||||
})
|
||||
const bareKey = folderWorkspaceKey(folderWorkspaceId)
|
||||
const qualifiedKey = folderWorkspaceKey(folderWorkspaceId, ownerHostId)
|
||||
const activeMigrated =
|
||||
s.activeWorkspaceKey === bareKey || s.activeWorktreeId === bareKey
|
||||
? { activeWorkspaceKey: qualifiedKey, activeWorktreeId: qualifiedKey }
|
||||
: null
|
||||
const changed =
|
||||
tabs.migratedKeys.length > 0 ||
|
||||
activeFileIdByWorktree.migratedKeys.length > 0 ||
|
||||
activeBrowserTabIdByWorktree.migratedKeys.length > 0 ||
|
||||
activeTabTypeByWorktree.migratedKeys.length > 0 ||
|
||||
activeTabIdByWorktree.migratedKeys.length > 0 ||
|
||||
browserTabsByWorktree.migratedKeys.length > 0 ||
|
||||
groupsByWorktree.migratedKeys.length > 0 ||
|
||||
unifiedTabsByWorktree.migratedKeys.length > 0 ||
|
||||
activeGroupIdByWorktree.migratedKeys.length > 0 ||
|
||||
openFilesChanged ||
|
||||
activeMigrated != null
|
||||
if (!changed) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
...(tabs.migratedKeys.length > 0 ? { tabsByWorktree: tabs.record } : {}),
|
||||
...(activeFileIdByWorktree.migratedKeys.length > 0
|
||||
? { activeFileIdByWorktree: activeFileIdByWorktree.record }
|
||||
: {}),
|
||||
...(activeBrowserTabIdByWorktree.migratedKeys.length > 0
|
||||
? { activeBrowserTabIdByWorktree: activeBrowserTabIdByWorktree.record }
|
||||
: {}),
|
||||
...(activeTabTypeByWorktree.migratedKeys.length > 0
|
||||
? { activeTabTypeByWorktree: activeTabTypeByWorktree.record }
|
||||
: {}),
|
||||
...(activeTabIdByWorktree.migratedKeys.length > 0
|
||||
? { activeTabIdByWorktree: activeTabIdByWorktree.record }
|
||||
: {}),
|
||||
...(browserTabsByWorktree.migratedKeys.length > 0
|
||||
? { browserTabsByWorktree: browserTabsByWorktree.record }
|
||||
: {}),
|
||||
...(groupsByWorktree.migratedKeys.length > 0
|
||||
? { groupsByWorktree: groupsByWorktree.record }
|
||||
: {}),
|
||||
...(unifiedTabsByWorktree.migratedKeys.length > 0
|
||||
? { unifiedTabsByWorktree: unifiedTabsByWorktree.record }
|
||||
: {}),
|
||||
...(activeGroupIdByWorktree.migratedKeys.length > 0
|
||||
? { activeGroupIdByWorktree: activeGroupIdByWorktree.record }
|
||||
: {}),
|
||||
...(openFilesChanged ? { openFiles: nextOpenFiles } : {}),
|
||||
...(activeMigrated != null ? activeMigrated : {})
|
||||
}
|
||||
}
|
||||
|
||||
function findKnownWorktreeById(
|
||||
state: Pick<AppState, 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces'>,
|
||||
worktreeId: string,
|
||||
@@ -882,11 +989,18 @@ function findKnownWorktreeById(
|
||||
): Worktree | DetectedWorktreeListResult['worktrees'][number] | undefined {
|
||||
const workspaceScope = parseWorkspaceKey(worktreeId)
|
||||
if (workspaceScope?.type === 'folder') {
|
||||
const folderWorkspace = state.folderWorkspaces.find(
|
||||
const preferredHostId = executionHostId ?? workspaceScope.ownerHostId
|
||||
const matches = state.folderWorkspaces.filter(
|
||||
(workspace) =>
|
||||
workspace.id === workspaceScope.folderWorkspaceId &&
|
||||
(!executionHostId || folderWorkspaceMatchesHost(workspace, executionHostId))
|
||||
(!preferredHostId || folderWorkspaceMatchesHost(workspace, preferredHostId))
|
||||
)
|
||||
// Why: bare folder keys dual-read only when the owner is unambiguous; multi-owner same-id must use owner-qualified keys.
|
||||
const folderWorkspace = preferredHostId
|
||||
? (matches[0] ?? null)
|
||||
: matches.length === 1
|
||||
? matches[0]
|
||||
: null
|
||||
if (!folderWorkspace) {
|
||||
return undefined
|
||||
}
|
||||
@@ -3673,6 +3787,9 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
validIds.add(FLOATING_TERMINAL_WORKTREE_ID)
|
||||
// Why: folder workspaces persist tabs under `folder:<id>` keys that authoritative repo scans never return.
|
||||
for (const workspace of get().folderWorkspaces ?? []) {
|
||||
const ownerHostId = getFolderWorkspaceCatalogOwnerHostId(workspace, get().projectGroups)
|
||||
validIds.add(folderWorkspaceKey(workspace.id, ownerHostId))
|
||||
// Why: pre-owner session rows still use bare folder ids.
|
||||
validIds.add(folderWorkspaceKey(workspace.id))
|
||||
}
|
||||
for (const key of Object.keys(get().restoredRuntimeHostIdByWorkspaceSessionKey ?? {})) {
|
||||
@@ -4003,7 +4120,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
const activeScope = parseWorkspaceKey(get().activeWorkspaceKey ?? '')
|
||||
const parentWorkspace =
|
||||
activeScope?.type === 'folder'
|
||||
? folderWorkspaceKey(activeScope.folderWorkspaceId)
|
||||
? folderWorkspaceKey(activeScope.folderWorkspaceId, activeScope.ownerHostId)
|
||||
: undefined
|
||||
const createArgs = {
|
||||
repoId,
|
||||
@@ -5924,11 +6041,26 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
},
|
||||
|
||||
setActiveFolderWorkspace: (folderWorkspaceId, executionHostId) => {
|
||||
const workspaceKey = folderWorkspaceKey(folderWorkspaceId)
|
||||
const workspace = findKnownWorktreeById(get(), workspaceKey, executionHostId)
|
||||
const workspace = findKnownWorktreeById(
|
||||
get(),
|
||||
folderWorkspaceKey(folderWorkspaceId, executionHostId),
|
||||
executionHostId
|
||||
)
|
||||
if (!workspace) {
|
||||
return
|
||||
}
|
||||
// Why: synthetic folder worktrees stamp hostId from catalog ownership; prefer the caller host when provided.
|
||||
const ownerHostId = executionHostId ?? workspace.hostId ?? LOCAL_EXECUTION_HOST_ID
|
||||
const workspaceKey = folderWorkspaceKey(folderWorkspaceId, ownerHostId)
|
||||
// Why: promote legacy bare `folder:<id>` session maps before activation so dual-read becomes a one-time migration.
|
||||
const migratedSession = migrateLegacyFolderWorkspaceSessionMaps(
|
||||
get(),
|
||||
folderWorkspaceId,
|
||||
ownerHostId
|
||||
)
|
||||
if (migratedSession) {
|
||||
set(migratedSession)
|
||||
}
|
||||
if (shouldDeferActivationTerminalPrep()) {
|
||||
markInputQuietSchedulerInput()
|
||||
}
|
||||
@@ -5938,32 +6070,71 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
const reconciledActiveTabId =
|
||||
get().reconcileWorktreeTabModel(workspaceKey).activeRenderableTabId
|
||||
set((s) => {
|
||||
const restoredFileId = s.activeFileIdByWorktree[workspaceKey] ?? null
|
||||
const restoredBrowserTabId = s.activeBrowserTabIdByWorktree[workspaceKey] ?? null
|
||||
const restoredTabType = s.activeTabTypeByWorktree[workspaceKey] ?? 'terminal'
|
||||
const activeGroupId =
|
||||
s.activeGroupIdByWorktree[workspaceKey] ?? s.groupsByWorktree[workspaceKey]?.[0]?.id ?? null
|
||||
const dualTabs = dualReadFolderWorkspaceKeyedValue((key) => s.tabsByWorktree[key], {
|
||||
folderWorkspaceId,
|
||||
ownerHostId
|
||||
})
|
||||
const dualActiveFile = dualReadFolderWorkspaceKeyedValue(
|
||||
(key) => s.activeFileIdByWorktree[key],
|
||||
{ folderWorkspaceId, ownerHostId }
|
||||
)
|
||||
const dualActiveBrowser = dualReadFolderWorkspaceKeyedValue(
|
||||
(key) => s.activeBrowserTabIdByWorktree[key],
|
||||
{ folderWorkspaceId, ownerHostId }
|
||||
)
|
||||
const dualActiveTabType = dualReadFolderWorkspaceKeyedValue(
|
||||
(key) => s.activeTabTypeByWorktree[key],
|
||||
{ folderWorkspaceId, ownerHostId }
|
||||
)
|
||||
const dualActiveTab = dualReadFolderWorkspaceKeyedValue(
|
||||
(key) => s.activeTabIdByWorktree[key],
|
||||
{ folderWorkspaceId, ownerHostId }
|
||||
)
|
||||
const dualBrowserTabs = dualReadFolderWorkspaceKeyedValue(
|
||||
(key) => s.browserTabsByWorktree[key],
|
||||
{ folderWorkspaceId, ownerHostId }
|
||||
)
|
||||
const dualGroups = dualReadFolderWorkspaceKeyedValue((key) => s.groupsByWorktree[key], {
|
||||
folderWorkspaceId,
|
||||
ownerHostId
|
||||
})
|
||||
const dualUnified = dualReadFolderWorkspaceKeyedValue((key) => s.unifiedTabsByWorktree[key], {
|
||||
folderWorkspaceId,
|
||||
ownerHostId
|
||||
})
|
||||
const dualActiveGroup = dualReadFolderWorkspaceKeyedValue(
|
||||
(key) => s.activeGroupIdByWorktree[key],
|
||||
{ folderWorkspaceId, ownerHostId }
|
||||
)
|
||||
const restoredFileId = dualActiveFile?.value ?? null
|
||||
const restoredBrowserTabId = dualActiveBrowser?.value ?? null
|
||||
const restoredTabType = dualActiveTabType?.value ?? 'terminal'
|
||||
const activeGroupId = dualActiveGroup?.value ?? dualGroups?.value?.[0]?.id ?? null
|
||||
const activeGroup = activeGroupId
|
||||
? ((s.groupsByWorktree[workspaceKey] ?? []).find((group) => group.id === activeGroupId) ??
|
||||
null)
|
||||
? ((dualGroups?.value ?? []).find((group) => group.id === activeGroupId) ?? null)
|
||||
: null
|
||||
const activeUnifiedTabId = reconciledActiveTabId ?? activeGroup?.activeTabId ?? null
|
||||
const activeUnifiedTab =
|
||||
activeUnifiedTabId != null
|
||||
? ((s.unifiedTabsByWorktree[workspaceKey] ?? []).find(
|
||||
? ((dualUnified?.value ?? []).find(
|
||||
(tab) =>
|
||||
tab.id === activeUnifiedTabId && (!activeGroup || tab.groupId === activeGroup.id)
|
||||
) ?? null)
|
||||
: null
|
||||
const fileStillOpen = restoredFileId
|
||||
? s.openFiles.some((file) => file.id === restoredFileId && file.worktreeId === workspaceKey)
|
||||
? s.openFiles.some(
|
||||
(file) =>
|
||||
file.id === restoredFileId &&
|
||||
(file.worktreeId === workspaceKey ||
|
||||
file.worktreeId === folderWorkspaceKey(folderWorkspaceId))
|
||||
)
|
||||
: false
|
||||
const browserTabs = s.browserTabsByWorktree[workspaceKey] ?? []
|
||||
const browserTabs = dualBrowserTabs?.value ?? []
|
||||
const browserTabStillOpen = restoredBrowserTabId
|
||||
? browserTabs.some((tab) => tab.id === restoredBrowserTabId)
|
||||
: false
|
||||
const worktreeTabs = s.tabsByWorktree[workspaceKey] ?? []
|
||||
const restoredTabId = s.activeTabIdByWorktree[workspaceKey] ?? null
|
||||
const worktreeTabs = dualTabs?.value ?? []
|
||||
const restoredTabId = dualActiveTab?.value ?? null
|
||||
const tabStillExists = restoredTabId
|
||||
? worktreeTabs.some((tab) => tab.id === restoredTabId)
|
||||
: false
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ExecutionHostId } from './execution-host'
|
||||
|
||||
export type FolderWorkspacePathStatusReason =
|
||||
| 'missing'
|
||||
| 'not-directory'
|
||||
@@ -7,8 +9,8 @@ export type FolderWorkspacePathStatusReason =
|
||||
export const FOLDER_WORKSPACE_PATH_STATUS_TTL_MS = 10_000
|
||||
|
||||
export type FolderWorkspacePathStatusRequest =
|
||||
| { scope: 'folder-workspace'; folderWorkspaceId: string }
|
||||
| { scope: 'project-group'; projectGroupId: string }
|
||||
| { scope: 'folder-workspace'; folderWorkspaceId: string; ownerHostId?: ExecutionHostId }
|
||||
| { scope: 'project-group'; projectGroupId: string; ownerHostId?: ExecutionHostId }
|
||||
| { scope: 'path'; path: string; connectionId?: string | null }
|
||||
|
||||
export type FolderWorkspacePathStatus = {
|
||||
|
||||
@@ -1,9 +1,69 @@
|
||||
import {
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from './execution-host'
|
||||
import {
|
||||
buildProjectGroupOwnerIndex,
|
||||
getFolderWorkspaceProjectGroupOwnerHostId,
|
||||
resolveFolderWorkspaceProjectGroup,
|
||||
resolveProjectGroupOwner
|
||||
} from './project-groups'
|
||||
import type { FolderWorkspace, ProjectGroup } from './types'
|
||||
import { isTuiAgent } from './tui-agent-config'
|
||||
import { normalizeStoredTaskSourceContext } from './task-source-context'
|
||||
import { normalizeWorkspaceLinkedItem } from './workspace-linked-item'
|
||||
import { isWorkspaceLinkedItemSourceContextMatch } from './workspace-linked-item-source-context'
|
||||
|
||||
export function getFolderWorkspaceCatalogOwnerHostId(
|
||||
workspace: Pick<FolderWorkspace, 'connectionId' | 'executionHostId' | 'projectGroupId'>,
|
||||
projectGroups: readonly ProjectGroup[] = []
|
||||
): ExecutionHostId {
|
||||
const executionHostId = normalizeExecutionHostId(workspace.executionHostId)
|
||||
if (executionHostId) {
|
||||
return executionHostId
|
||||
}
|
||||
if (workspace.connectionId) {
|
||||
return toSshExecutionHostId(workspace.connectionId)
|
||||
}
|
||||
if (workspace.connectionId === null) {
|
||||
return LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
if (projectGroups.length === 0) {
|
||||
return LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
return getFolderWorkspaceProjectGroupOwnerHostId(
|
||||
workspace,
|
||||
buildProjectGroupOwnerIndex(projectGroups)
|
||||
)
|
||||
}
|
||||
|
||||
export function getFolderWorkspaceOwnerIdentity(
|
||||
workspace: Pick<FolderWorkspace, 'id' | 'connectionId' | 'executionHostId' | 'projectGroupId'>,
|
||||
projectGroups: readonly ProjectGroup[] = []
|
||||
): string {
|
||||
return getFolderWorkspaceIdentity(
|
||||
workspace.id,
|
||||
getFolderWorkspaceCatalogOwnerHostId(workspace, projectGroups)
|
||||
)
|
||||
}
|
||||
|
||||
export function getFolderWorkspaceIdentity(
|
||||
folderWorkspaceId: string,
|
||||
ownerHostId: ExecutionHostId
|
||||
): string {
|
||||
return JSON.stringify([ownerHostId, folderWorkspaceId])
|
||||
}
|
||||
|
||||
export function getFolderWorkspaceRowKey(
|
||||
workspace: Pick<FolderWorkspace, 'id' | 'connectionId' | 'executionHostId' | 'projectGroupId'>,
|
||||
projectGroups: readonly ProjectGroup[] = []
|
||||
): string {
|
||||
const ownerHostId = getFolderWorkspaceCatalogOwnerHostId(workspace, projectGroups)
|
||||
return `folder-workspace:${encodeURIComponent(ownerHostId)}:${encodeURIComponent(workspace.id)}`
|
||||
}
|
||||
|
||||
export function normalizeFolderWorkspaceName(
|
||||
name: string | null | undefined,
|
||||
fallback = 'Untitled workspace'
|
||||
@@ -19,12 +79,7 @@ export function normalizeFolderWorkspaces(
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
const folderGroups = new Map<string, ProjectGroup>()
|
||||
for (const group of projectGroups) {
|
||||
if (group.parentPath) {
|
||||
folderGroups.set(group.id, group)
|
||||
}
|
||||
}
|
||||
const projectGroupIndex = buildProjectGroupOwnerIndex(projectGroups)
|
||||
|
||||
const workspaces: FolderWorkspace[] = []
|
||||
const seen = new Set<string>()
|
||||
@@ -36,13 +91,20 @@ export function normalizeFolderWorkspaces(
|
||||
if (
|
||||
typeof raw.id !== 'string' ||
|
||||
raw.id.trim().length === 0 ||
|
||||
seen.has(raw.id) ||
|
||||
typeof raw.projectGroupId !== 'string' ||
|
||||
!folderGroups.has(raw.projectGroupId)
|
||||
typeof raw.projectGroupId !== 'string'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const group = folderGroups.get(raw.projectGroupId)
|
||||
const executionHostId = normalizeExecutionHostId(raw.executionHostId)
|
||||
const group =
|
||||
resolveFolderWorkspaceProjectGroup(projectGroupIndex, {
|
||||
projectGroupId: raw.projectGroupId,
|
||||
connectionId: raw.connectionId,
|
||||
executionHostId
|
||||
}) ?? resolveProjectGroupOwner(projectGroupIndex, raw.projectGroupId)
|
||||
if (!group?.parentPath) {
|
||||
continue
|
||||
}
|
||||
const folderPath =
|
||||
typeof raw.folderPath === 'string' && raw.folderPath.trim().length > 0
|
||||
? raw.folderPath
|
||||
@@ -50,21 +112,36 @@ export function normalizeFolderWorkspaces(
|
||||
if (!folderPath) {
|
||||
continue
|
||||
}
|
||||
const connectionId =
|
||||
typeof raw.connectionId === 'string'
|
||||
? raw.connectionId
|
||||
: raw.connectionId === null
|
||||
? null
|
||||
: (group?.connectionId ?? null)
|
||||
// Why: multi-host catalogs keep same-id folder rows; identity is owner+id, not bare id.
|
||||
const identity = getFolderWorkspaceOwnerIdentity(
|
||||
{
|
||||
id: raw.id,
|
||||
projectGroupId: raw.projectGroupId,
|
||||
connectionId,
|
||||
...(executionHostId ? { executionHostId } : {})
|
||||
},
|
||||
projectGroups
|
||||
)
|
||||
if (seen.has(identity)) {
|
||||
continue
|
||||
}
|
||||
const now = Date.now()
|
||||
const linkedTask = normalizeWorkspaceLinkedItem(raw.linkedTask)
|
||||
const linkedTaskSourceContext = normalizeStoredTaskSourceContext(raw.linkedTaskSourceContext)
|
||||
seen.add(raw.id)
|
||||
seen.add(identity)
|
||||
workspaces.push({
|
||||
id: raw.id,
|
||||
projectGroupId: raw.projectGroupId,
|
||||
name: normalizeFolderWorkspaceName(raw.name),
|
||||
folderPath,
|
||||
connectionId:
|
||||
typeof raw.connectionId === 'string'
|
||||
? raw.connectionId
|
||||
: raw.connectionId === null
|
||||
? null
|
||||
: (group?.connectionId ?? null),
|
||||
connectionId,
|
||||
...(executionHostId ? { executionHostId } : {}),
|
||||
linkedTask,
|
||||
linkedTaskSourceContext: isWorkspaceLinkedItemSourceContextMatch(
|
||||
linkedTask,
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildProjectGroupOwnerIndex,
|
||||
clearMissingProjectGroupMemberships,
|
||||
createProjectGroup,
|
||||
getEffectiveProjectGroupManualRank,
|
||||
getNextProjectGroupOrder,
|
||||
getProjectGroupOwnerIdentity,
|
||||
getProjectGroupOwnerSubtreeIdentities,
|
||||
getProjectGroupSubtreeIds,
|
||||
normalizeProjectGroupName,
|
||||
normalizeProjectGroups
|
||||
normalizeProjectGroups,
|
||||
resolveFolderWorkspaceProjectGroup,
|
||||
resolveProjectGroupOwner
|
||||
} from './project-groups'
|
||||
import type { Repo } from './types'
|
||||
import type { ProjectGroup, Repo } from './types'
|
||||
|
||||
function repo(overrides: Partial<Repo>): Repo {
|
||||
return {
|
||||
@@ -22,6 +27,22 @@ function repo(overrides: Partial<Repo>): Repo {
|
||||
}
|
||||
}
|
||||
|
||||
function projectGroup(overrides: Partial<ProjectGroup>): ProjectGroup {
|
||||
return {
|
||||
id: overrides.id ?? 'group-1',
|
||||
name: overrides.name ?? 'Group',
|
||||
parentPath: null,
|
||||
parentGroupId: null,
|
||||
createdFrom: 'manual',
|
||||
tabOrder: 0,
|
||||
isCollapsed: false,
|
||||
color: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('project-groups', () => {
|
||||
it('creates a durable project group with normalized defaults', () => {
|
||||
const group = createProjectGroup({
|
||||
@@ -84,6 +105,80 @@ describe('project-groups', () => {
|
||||
expect(groups.find((group) => group.id === 'invalid')?.executionHostId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('preserves same-id groups and parentage independently across owners', () => {
|
||||
const groups = normalizeProjectGroups([
|
||||
projectGroup({ id: 'root', name: 'Local root' }),
|
||||
projectGroup({ id: 'child', name: 'Local child', parentGroupId: 'root' }),
|
||||
projectGroup({ id: 'root', name: 'SSH root', connectionId: 'builder' }),
|
||||
projectGroup({
|
||||
id: 'child',
|
||||
name: 'SSH child',
|
||||
connectionId: 'builder',
|
||||
parentGroupId: 'root'
|
||||
}),
|
||||
projectGroup({ id: 'root', name: 'Duplicate SSH root', connectionId: 'builder' })
|
||||
])
|
||||
|
||||
expect(groups.map((group) => [group.name, group.parentGroupId])).toEqual([
|
||||
['Local child', 'root'],
|
||||
['Local root', null],
|
||||
['SSH child', 'root'],
|
||||
['SSH root', null]
|
||||
])
|
||||
})
|
||||
|
||||
it('resolves mutations exactly by owner and rejects ambiguous legacy selectors', () => {
|
||||
const local = projectGroup({ id: 'same-id', name: 'Local' })
|
||||
const ssh = projectGroup({ id: 'same-id', name: 'SSH', connectionId: 'builder' })
|
||||
const index = buildProjectGroupOwnerIndex([local, ssh])
|
||||
|
||||
expect(resolveProjectGroupOwner(index, 'same-id', 'local')).toBe(local)
|
||||
expect(resolveProjectGroupOwner(index, 'same-id', 'ssh:builder')).toBe(ssh)
|
||||
expect(resolveProjectGroupOwner(index, 'same-id')).toBeNull()
|
||||
expect(resolveProjectGroupOwner(index, 'same-id', 'ssh:missing')).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves folder membership by owner and rejects unstamped same-id ambiguity', () => {
|
||||
const local = projectGroup({ id: 'same-id', name: 'Local' })
|
||||
const runtime = projectGroup({
|
||||
id: 'same-id',
|
||||
name: 'Runtime',
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
const index = buildProjectGroupOwnerIndex([local, runtime])
|
||||
|
||||
expect(
|
||||
resolveFolderWorkspaceProjectGroup(index, {
|
||||
projectGroupId: 'same-id',
|
||||
connectionId: null
|
||||
})
|
||||
).toBe(local)
|
||||
expect(
|
||||
resolveFolderWorkspaceProjectGroup(index, {
|
||||
projectGroupId: 'same-id',
|
||||
executionHostId: 'runtime:env-1'
|
||||
})
|
||||
).toBe(runtime)
|
||||
expect(resolveFolderWorkspaceProjectGroup(index, { projectGroupId: 'same-id' })).toBeNull()
|
||||
})
|
||||
|
||||
it('collects descendants only from the selected owner hierarchy', () => {
|
||||
const localRoot = projectGroup({ id: 'root', name: 'Local root' })
|
||||
const localChild = projectGroup({ id: 'child', parentGroupId: 'root' })
|
||||
const sshRoot = projectGroup({ id: 'root', name: 'SSH root', connectionId: 'builder' })
|
||||
const sshChild = projectGroup({
|
||||
id: 'child',
|
||||
parentGroupId: 'root',
|
||||
connectionId: 'builder'
|
||||
})
|
||||
|
||||
expect(
|
||||
getProjectGroupOwnerSubtreeIdentities([localRoot, localChild, sshRoot, sshChild], localRoot)
|
||||
).toEqual(
|
||||
new Set([getProjectGroupOwnerIdentity(localRoot), getProjectGroupOwnerIdentity(localChild)])
|
||||
)
|
||||
})
|
||||
|
||||
it('clears repo memberships whose group no longer exists', () => {
|
||||
const groups = [createProjectGroup({ name: 'Known', createdFrom: 'manual', tabOrder: 0 })]
|
||||
const repos = clearMissingProjectGroupMemberships(
|
||||
@@ -98,6 +193,25 @@ describe('project-groups', () => {
|
||||
expect(repos.find((entry) => entry.id === 'missing')?.projectGroupId).toBeNull()
|
||||
})
|
||||
|
||||
it('does not retain repo membership from another owner with the same group id', () => {
|
||||
const localGroup = projectGroup({ id: 'same-id', name: 'Local' })
|
||||
const sshGroup = projectGroup({ id: 'same-id', name: 'SSH', connectionId: 'builder' })
|
||||
const repos = clearMissingProjectGroupMemberships(
|
||||
[
|
||||
repo({ id: 'local', projectGroupId: 'same-id' }),
|
||||
repo({ id: 'ssh', projectGroupId: 'same-id', connectionId: 'builder' }),
|
||||
repo({ id: 'wrong-local', projectGroupId: 'ssh-only' })
|
||||
],
|
||||
[localGroup, sshGroup, projectGroup({ id: 'ssh-only', connectionId: 'builder' })]
|
||||
)
|
||||
|
||||
expect(repos.map((entry) => [entry.id, entry.projectGroupId])).toEqual([
|
||||
['local', 'same-id'],
|
||||
['ssh', 'same-id'],
|
||||
['wrong-local', null]
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back to global repo order when projectGroupOrder is unset', () => {
|
||||
const repoOrder = new Map([
|
||||
['a', 0],
|
||||
|
||||
+158
-13
@@ -1,8 +1,143 @@
|
||||
import { normalizeExecutionHostId } from './execution-host'
|
||||
import type { Repo, ProjectGroup, ProjectGroupCreatedFrom } from './types'
|
||||
import {
|
||||
getRepoExecutionHostId,
|
||||
LOCAL_EXECUTION_HOST_ID,
|
||||
normalizeExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from './execution-host'
|
||||
import type { FolderWorkspace, Repo, ProjectGroup, ProjectGroupCreatedFrom } from './types'
|
||||
|
||||
export const UNGROUPED_PROJECT_GROUP_KEY = 'project-group:ungrouped'
|
||||
|
||||
export type ProjectGroupOwnerIndex = {
|
||||
byIdentity: ReadonlyMap<string, ProjectGroup>
|
||||
byId: ReadonlyMap<string, readonly ProjectGroup[]>
|
||||
}
|
||||
|
||||
export function getProjectGroupOwnerHostId(
|
||||
group: Pick<ProjectGroup, 'connectionId' | 'executionHostId'>
|
||||
): ExecutionHostId {
|
||||
const executionHostId = normalizeExecutionHostId(group.executionHostId)
|
||||
if (executionHostId) {
|
||||
return executionHostId
|
||||
}
|
||||
return group.connectionId ? toSshExecutionHostId(group.connectionId) : LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
|
||||
export function getProjectGroupOwnerIdentity(
|
||||
group: Pick<ProjectGroup, 'id' | 'connectionId' | 'executionHostId'>
|
||||
): string {
|
||||
return getProjectGroupIdentity(group.id, getProjectGroupOwnerHostId(group))
|
||||
}
|
||||
|
||||
export function getProjectGroupIdentity(groupId: string, ownerHostId: ExecutionHostId): string {
|
||||
return JSON.stringify([ownerHostId, groupId])
|
||||
}
|
||||
|
||||
export function buildProjectGroupOwnerIndex(
|
||||
projectGroups: readonly ProjectGroup[]
|
||||
): ProjectGroupOwnerIndex {
|
||||
const byIdentity = new Map<string, ProjectGroup>()
|
||||
const byId = new Map<string, ProjectGroup[]>()
|
||||
for (const group of projectGroups) {
|
||||
byIdentity.set(getProjectGroupOwnerIdentity(group), group)
|
||||
const matches = byId.get(group.id) ?? []
|
||||
matches.push(group)
|
||||
byId.set(group.id, matches)
|
||||
}
|
||||
return { byIdentity, byId }
|
||||
}
|
||||
|
||||
export function resolveProjectGroupOwner(
|
||||
index: ProjectGroupOwnerIndex,
|
||||
groupId: string,
|
||||
ownerHostId?: ExecutionHostId
|
||||
): ProjectGroup | null {
|
||||
if (ownerHostId) {
|
||||
return index.byIdentity.get(getProjectGroupIdentity(groupId, ownerHostId)) ?? null
|
||||
}
|
||||
const matches = index.byId.get(groupId) ?? []
|
||||
return matches.length === 1 ? (matches[0] ?? null) : null
|
||||
}
|
||||
|
||||
export function resolveProjectGroupMembership(
|
||||
index: ProjectGroupOwnerIndex,
|
||||
groupId: string,
|
||||
ownerHostId: ExecutionHostId
|
||||
): ProjectGroup | null {
|
||||
return index.byIdentity.get(getProjectGroupIdentity(groupId, ownerHostId)) ?? null
|
||||
}
|
||||
|
||||
export function getFolderWorkspaceProjectGroupOwnerHostId(
|
||||
workspace: Pick<FolderWorkspace, 'connectionId' | 'executionHostId' | 'projectGroupId'>,
|
||||
index: ProjectGroupOwnerIndex
|
||||
): ExecutionHostId {
|
||||
const executionHostId = normalizeExecutionHostId(workspace.executionHostId)
|
||||
if (executionHostId) {
|
||||
return executionHostId
|
||||
}
|
||||
const group = resolveFolderWorkspaceProjectGroup(index, workspace)
|
||||
if (group) {
|
||||
return getProjectGroupOwnerHostId(group)
|
||||
}
|
||||
if (workspace.connectionId) {
|
||||
return toSshExecutionHostId(workspace.connectionId)
|
||||
}
|
||||
return LOCAL_EXECUTION_HOST_ID
|
||||
}
|
||||
|
||||
export function resolveFolderWorkspaceProjectGroup(
|
||||
index: ProjectGroupOwnerIndex,
|
||||
workspace: Pick<FolderWorkspace, 'connectionId' | 'executionHostId' | 'projectGroupId'>
|
||||
): ProjectGroup | null {
|
||||
const executionHostId = normalizeExecutionHostId(workspace.executionHostId)
|
||||
if (executionHostId) {
|
||||
return resolveProjectGroupMembership(index, workspace.projectGroupId, executionHostId)
|
||||
}
|
||||
if (workspace.connectionId !== undefined) {
|
||||
const ownerHostId = workspace.connectionId
|
||||
? toSshExecutionHostId(workspace.connectionId)
|
||||
: LOCAL_EXECUTION_HOST_ID
|
||||
const group = resolveProjectGroupMembership(index, workspace.projectGroupId, ownerHostId)
|
||||
return group ?? resolveProjectGroupOwner(index, workspace.projectGroupId)
|
||||
}
|
||||
return resolveProjectGroupOwner(index, workspace.projectGroupId)
|
||||
}
|
||||
|
||||
export function getProjectGroupOwnerSubtreeIdentities(
|
||||
projectGroups: readonly ProjectGroup[],
|
||||
rootGroup: ProjectGroup
|
||||
): Set<string> {
|
||||
const childrenByParentIdentity = new Map<string, ProjectGroup[]>()
|
||||
for (const group of projectGroups) {
|
||||
if (!group.parentGroupId) {
|
||||
continue
|
||||
}
|
||||
const parentIdentity = getProjectGroupIdentity(
|
||||
group.parentGroupId,
|
||||
getProjectGroupOwnerHostId(group)
|
||||
)
|
||||
const children = childrenByParentIdentity.get(parentIdentity) ?? []
|
||||
children.push(group)
|
||||
childrenByParentIdentity.set(parentIdentity, children)
|
||||
}
|
||||
|
||||
const subtreeIdentities = new Set<string>()
|
||||
const pending = [rootGroup]
|
||||
while (pending.length > 0) {
|
||||
const group = pending.pop()!
|
||||
const identity = getProjectGroupOwnerIdentity(group)
|
||||
if (subtreeIdentities.has(identity)) {
|
||||
continue
|
||||
}
|
||||
subtreeIdentities.add(identity)
|
||||
for (const child of childrenByParentIdentity.get(identity) ?? []) {
|
||||
pending.push(child)
|
||||
}
|
||||
}
|
||||
return subtreeIdentities
|
||||
}
|
||||
|
||||
function createProjectGroupId(): string {
|
||||
const randomUUID = globalThis.crypto?.randomUUID
|
||||
if (randomUUID) {
|
||||
@@ -52,13 +187,12 @@ export function normalizeProjectGroups(value: unknown): ProjectGroup[] {
|
||||
continue
|
||||
}
|
||||
const raw = candidate as Partial<ProjectGroup>
|
||||
if (typeof raw.id !== 'string' || seen.has(raw.id)) {
|
||||
if (typeof raw.id !== 'string') {
|
||||
continue
|
||||
}
|
||||
seen.add(raw.id)
|
||||
const now = Date.now()
|
||||
const executionHostId = normalizeExecutionHostId(raw.executionHostId)
|
||||
groups.push({
|
||||
const group: ProjectGroup = {
|
||||
id: raw.id,
|
||||
name: normalizeProjectGroupName(typeof raw.name === 'string' ? raw.name : ''),
|
||||
parentPath: typeof raw.parentPath === 'string' ? raw.parentPath : null,
|
||||
@@ -83,16 +217,27 @@ export function normalizeProjectGroups(value: unknown): ProjectGroup[] {
|
||||
typeof raw.createdAt === 'number' && Number.isFinite(raw.createdAt) ? raw.createdAt : now,
|
||||
updatedAt:
|
||||
typeof raw.updatedAt === 'number' && Number.isFinite(raw.updatedAt) ? raw.updatedAt : now,
|
||||
// Why: runtime-owned groups otherwise look local after persistence reload.
|
||||
...(executionHostId ? { executionHostId } : {})
|
||||
})
|
||||
}
|
||||
const identity = getProjectGroupOwnerIdentity(group)
|
||||
if (seen.has(identity)) {
|
||||
continue
|
||||
}
|
||||
seen.add(identity)
|
||||
groups.push(group)
|
||||
}
|
||||
groups.sort(
|
||||
(left, right) => left.tabOrder - right.tabOrder || left.name.localeCompare(right.name)
|
||||
)
|
||||
const groupIds = new Set(groups.map((group) => group.id))
|
||||
const groupIdentities = new Set(groups.map(getProjectGroupOwnerIdentity))
|
||||
for (const group of groups) {
|
||||
if (group.parentGroupId === group.id || !groupIds.has(group.parentGroupId ?? '')) {
|
||||
const parentIdentity = group.parentGroupId
|
||||
? getProjectGroupIdentity(group.parentGroupId, getProjectGroupOwnerHostId(group))
|
||||
: null
|
||||
if (
|
||||
group.parentGroupId === group.id ||
|
||||
(parentIdentity !== null && !groupIdentities.has(parentIdentity))
|
||||
) {
|
||||
group.parentGroupId = null
|
||||
}
|
||||
}
|
||||
@@ -100,9 +245,10 @@ export function normalizeProjectGroups(value: unknown): ProjectGroup[] {
|
||||
}
|
||||
|
||||
export function clearMissingProjectGroupMemberships(repos: Repo[], groups: ProjectGroup[]): Repo[] {
|
||||
const groupIds = new Set(groups.map((group) => group.id))
|
||||
const index = buildProjectGroupOwnerIndex(groups)
|
||||
return repos.map((repo) =>
|
||||
repo.projectGroupId && !groupIds.has(repo.projectGroupId)
|
||||
repo.projectGroupId &&
|
||||
!resolveProjectGroupMembership(index, repo.projectGroupId, getRepoExecutionHostId(repo))
|
||||
? { ...repo, projectGroupId: null }
|
||||
: repo
|
||||
)
|
||||
@@ -130,8 +276,7 @@ export function getProjectGroupSubtreeIds(
|
||||
continue
|
||||
}
|
||||
subtreeIds.add(groupId)
|
||||
// Why: imported project-group trees can be very wide; `push(...children)`
|
||||
// can exceed V8's argument limit while collecting descendants.
|
||||
// Why: avoid V8 argument limits in wide imported project-group trees.
|
||||
for (const childGroupId of childGroupsByParentId.get(groupId) ?? []) {
|
||||
pending.push(childGroupId)
|
||||
}
|
||||
|
||||
+1
-1
@@ -326,7 +326,7 @@ export type ProjectGroup = {
|
||||
|
||||
export type WorkspaceScope =
|
||||
| { type: 'worktree'; worktreeId: string }
|
||||
| { type: 'folder'; folderWorkspaceId: string }
|
||||
| { type: 'folder'; folderWorkspaceId: string; ownerHostId?: ExecutionHostId }
|
||||
|
||||
export type WorkspaceKey = `worktree:${string}` | `folder:${string}`
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
dualReadFolderWorkspaceKeyedValue,
|
||||
folderWorkspaceKey,
|
||||
folderWorkspaceSessionKeys,
|
||||
migrateFolderWorkspaceKeyedRecord,
|
||||
parseWorkspaceKey
|
||||
} from './workspace-scope'
|
||||
|
||||
describe('owner-qualified folder workspace keys', () => {
|
||||
it('round-trips owner-qualified keys for same-id multi-host rows', () => {
|
||||
const localKey = folderWorkspaceKey('same-id', 'local')
|
||||
const sshKey = folderWorkspaceKey('same-id', 'ssh:builder-1')
|
||||
|
||||
expect(localKey).not.toBe(sshKey)
|
||||
expect(parseWorkspaceKey(localKey)).toEqual({
|
||||
type: 'folder',
|
||||
folderWorkspaceId: 'same-id',
|
||||
ownerHostId: 'local'
|
||||
})
|
||||
expect(parseWorkspaceKey(sshKey)).toEqual({
|
||||
type: 'folder',
|
||||
folderWorkspaceId: 'same-id',
|
||||
ownerHostId: 'ssh:builder-1'
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps legacy bare folder keys parseable', () => {
|
||||
expect(parseWorkspaceKey(folderWorkspaceKey('legacy-folder'))).toEqual({
|
||||
type: 'folder',
|
||||
folderWorkspaceId: 'legacy-folder'
|
||||
})
|
||||
})
|
||||
|
||||
it('lists both owner-qualified and legacy session keys for cleanup', () => {
|
||||
expect(
|
||||
folderWorkspaceSessionKeys({ folderWorkspaceId: 'same-id', ownerHostId: 'local' })
|
||||
).toEqual([folderWorkspaceKey('same-id', 'local'), folderWorkspaceKey('same-id')])
|
||||
})
|
||||
|
||||
it('dual-reads owner-qualified first, then legacy bare alias', () => {
|
||||
const bare = folderWorkspaceKey('folder-workspace-1')
|
||||
const qualified = folderWorkspaceKey('folder-workspace-1', 'local')
|
||||
const record: Record<string, string> = { [bare]: 'legacy-tabs' }
|
||||
|
||||
expect(
|
||||
dualReadFolderWorkspaceKeyedValue((key) => record[key], {
|
||||
folderWorkspaceId: 'folder-workspace-1',
|
||||
ownerHostId: 'local'
|
||||
})
|
||||
).toEqual({ value: 'legacy-tabs', key: bare, viaLegacyAlias: true })
|
||||
|
||||
record[qualified] = 'canonical-tabs'
|
||||
expect(
|
||||
dualReadFolderWorkspaceKeyedValue((key) => record[key], {
|
||||
folderWorkspaceId: 'folder-workspace-1',
|
||||
ownerHostId: 'local'
|
||||
})
|
||||
).toEqual({ value: 'canonical-tabs', key: qualified, viaLegacyAlias: false })
|
||||
})
|
||||
|
||||
it('migrates unambiguous legacy bare folder keys for the default owner', () => {
|
||||
const bare = folderWorkspaceKey('folder-workspace-1')
|
||||
const qualified = folderWorkspaceKey('folder-workspace-1', 'local')
|
||||
const { record, migratedKeys } = migrateFolderWorkspaceKeyedRecord(
|
||||
{ [bare]: [{ id: 'tab-1' }], [folderWorkspaceKey('ambiguous')]: [{ id: 'tab-2' }] },
|
||||
(folderWorkspaceId) => (folderWorkspaceId === 'folder-workspace-1' ? 'local' : null)
|
||||
)
|
||||
|
||||
expect(migratedKeys).toEqual([bare])
|
||||
expect(record[qualified]).toEqual([{ id: 'tab-1' }])
|
||||
expect(record[bare]).toBeUndefined()
|
||||
// Multi-owner / unresolved bare keys stay put so callers fail closed.
|
||||
expect(record[folderWorkspaceKey('ambiguous')]).toEqual([{ id: 'tab-2' }])
|
||||
})
|
||||
|
||||
it('does not overwrite an existing owner-qualified row when migrating bare aliases', () => {
|
||||
const bare = folderWorkspaceKey('folder-workspace-1')
|
||||
const qualified = folderWorkspaceKey('folder-workspace-1', 'local')
|
||||
const { record } = migrateFolderWorkspaceKeyedRecord(
|
||||
{ [bare]: 'legacy', [qualified]: 'canonical' },
|
||||
() => 'local'
|
||||
)
|
||||
expect(record[qualified]).toBe('canonical')
|
||||
expect(record[bare]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { ExecutionHostId } from './execution-host'
|
||||
import type { WorkspaceKey, WorkspaceScope } from './types'
|
||||
|
||||
export function worktreeWorkspaceKey(worktreeId: string): WorkspaceKey {
|
||||
return `worktree:${worktreeId}`
|
||||
}
|
||||
|
||||
export function folderWorkspaceKey(folderWorkspaceId: string): WorkspaceKey {
|
||||
/** Owner-qualified folder session keys keep same-id multi-host rows independent. */
|
||||
export function folderWorkspaceKey(
|
||||
folderWorkspaceId: string,
|
||||
ownerHostId?: ExecutionHostId
|
||||
): WorkspaceKey {
|
||||
if (ownerHostId) {
|
||||
return `folder:${encodeURIComponent(ownerHostId)}:${encodeURIComponent(folderWorkspaceId)}`
|
||||
}
|
||||
return `folder:${folderWorkspaceId}`
|
||||
}
|
||||
|
||||
@@ -14,8 +22,24 @@ export function parseWorkspaceKey(value: string): WorkspaceScope | null {
|
||||
return worktreeId.length > 0 ? { type: 'worktree', worktreeId } : null
|
||||
}
|
||||
if (value.startsWith('folder:')) {
|
||||
const folderWorkspaceId = value.slice('folder:'.length)
|
||||
return folderWorkspaceId.length > 0 ? { type: 'folder', folderWorkspaceId } : null
|
||||
const rest = value.slice('folder:'.length)
|
||||
if (rest.length === 0) {
|
||||
return null
|
||||
}
|
||||
// Why: owner-qualified keys encode both segments so host ids with colons stay unambiguous.
|
||||
const separator = rest.indexOf(':')
|
||||
if (separator > 0) {
|
||||
try {
|
||||
const ownerHostId = decodeURIComponent(rest.slice(0, separator)) as ExecutionHostId
|
||||
const folderWorkspaceId = decodeURIComponent(rest.slice(separator + 1))
|
||||
if (ownerHostId.length > 0 && folderWorkspaceId.length > 0) {
|
||||
return { type: 'folder', folderWorkspaceId, ownerHostId }
|
||||
}
|
||||
} catch {
|
||||
// Fall through to legacy id-only form when decoding fails.
|
||||
}
|
||||
}
|
||||
return { type: 'folder', folderWorkspaceId: rest }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -32,10 +56,80 @@ export function getActiveSidebarWorkspaceId(
|
||||
): string | null {
|
||||
const scope = activeWorkspaceKey ? parseWorkspaceKey(activeWorkspaceKey) : null
|
||||
if (scope?.type === 'folder') {
|
||||
return folderWorkspaceKey(scope.folderWorkspaceId)
|
||||
return folderWorkspaceKey(scope.folderWorkspaceId, scope.ownerHostId)
|
||||
}
|
||||
if (scope?.type === 'worktree') {
|
||||
return scope.worktreeId
|
||||
}
|
||||
return activeWorktreeId
|
||||
}
|
||||
|
||||
export function folderWorkspaceSessionKeys(args: {
|
||||
folderWorkspaceId: string
|
||||
ownerHostId: ExecutionHostId
|
||||
}): string[] {
|
||||
return [
|
||||
folderWorkspaceKey(args.folderWorkspaceId, args.ownerHostId),
|
||||
// Why: older sessions still key by bare folder id; purge both so one owner cleanup cannot leave the legacy alias behind for the deleted row only when it was sole occupant is handled by callers.
|
||||
folderWorkspaceKey(args.folderWorkspaceId)
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Dual-read contract for owner-qualified folder session keys:
|
||||
* prefer the canonical owner-qualified key, then fall back to the legacy bare
|
||||
* `folder:<id>` alias so pre-migration tabs/state keep resolving for that owner.
|
||||
*/
|
||||
export function dualReadFolderWorkspaceKeyedValue<T>(
|
||||
get: (key: string) => T | undefined,
|
||||
args: { folderWorkspaceId: string; ownerHostId: ExecutionHostId }
|
||||
): { value: T; key: string; viaLegacyAlias: boolean } | null {
|
||||
const qualified = folderWorkspaceKey(args.folderWorkspaceId, args.ownerHostId)
|
||||
const fromQualified = get(qualified)
|
||||
if (fromQualified !== undefined) {
|
||||
return { value: fromQualified, key: qualified, viaLegacyAlias: false }
|
||||
}
|
||||
const bare = folderWorkspaceKey(args.folderWorkspaceId)
|
||||
const fromBare = get(bare)
|
||||
if (fromBare !== undefined) {
|
||||
return { value: fromBare, key: bare, viaLegacyAlias: true }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrate legacy bare `folder:<id>` map keys to owner-qualified form when the
|
||||
* catalog can resolve an unambiguous owner. Multi-owner same-id bare keys stay
|
||||
* untouched so callers can fail closed instead of guessing.
|
||||
*/
|
||||
export function migrateFolderWorkspaceKeyedRecord<T>(
|
||||
record: Record<string, T>,
|
||||
resolveUnambiguousOwner: (folderWorkspaceId: string) => ExecutionHostId | null
|
||||
): { record: Record<string, T>; migratedKeys: string[] } {
|
||||
const migratedKeys: string[] = []
|
||||
let next: Record<string, T> | null = null
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
const scope = parseWorkspaceKey(key)
|
||||
if (scope?.type !== 'folder' || scope.ownerHostId) {
|
||||
continue
|
||||
}
|
||||
const ownerHostId = resolveUnambiguousOwner(scope.folderWorkspaceId)
|
||||
if (!ownerHostId) {
|
||||
continue
|
||||
}
|
||||
const qualified = folderWorkspaceKey(scope.folderWorkspaceId, ownerHostId)
|
||||
if (qualified === key) {
|
||||
continue
|
||||
}
|
||||
if (!next) {
|
||||
next = { ...record }
|
||||
}
|
||||
// Why: if both forms already exist, keep the canonical owner-qualified row.
|
||||
if (!(qualified in next)) {
|
||||
next[qualified] = value
|
||||
}
|
||||
delete next[key]
|
||||
migratedKeys.push(key)
|
||||
}
|
||||
return { record: next ?? record, migratedKeys }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user