fix(project-groups): route rename and delete to the group's owning host

The sidebar lists project groups from every host, but updateProjectGroup and
deleteProjectGroup routed by the focused runtime, so a group owned by another
host (folder-scan groups imported onto a selected host, e.g. an SSH/remote
`orca serve`) hit a host that does not hold it: rename silently no-ops and
delete reports groupDeleteFailed.

Resolve the owner from the row's executionHostId/connectionId via the existing
indexed owner lookup, carry the owner host from the sidebar row through the
rename/delete dialogs, and scope the post-mutation state cascades (groups,
folder workspaces, contained projects) to that host so a colliding id on
another host is left alone. Rename failures now surface a toast.

No RPC method, param, or response change.
This commit is contained in:
Neil
2026-08-21 23:20:52 -07:00
parent 2b1254d681
commit bdc1ca23fb
12 changed files with 717 additions and 69 deletions
@@ -30,7 +30,10 @@ describe('Project Group header drag DOM source', () => {
const source = readHeaderDragSource()
expect(source).toContain('const updateProjectGroup = useAppStore((s) => s.updateProjectGroup)')
expect(source).toContain('void updateProjectGroup(groupId, { tabOrder })')
// Why: the commit must carry the group's owner host so a non-focused host still persists the order.
expect(source).toContain(
'void updateProjectGroup(groupId, { tabOrder }, { hostId: ownerHostId ?? undefined })'
)
})
it('keeps grab cursor on the title surface and dual handle attrs on row + surface', () => {
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo } from 'react'
import type React from 'react'
import { useAppStore } from '@/store'
import { getProjectGroupHostId } from '@/store/slices/project-group-owner-routing'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { ProjectOrderBy } from '../../../../../../shared/ui-chrome-types'
import type { Repo } from '../../../../../../shared/repo-types'
@@ -83,6 +84,16 @@ export function useWorktreeSidebarHeaderDrag(args: {
() => new Map(projectGroups.map((group) => [group.id, group])),
[projectGroups]
)
// Why: an id shared by two hosts has no single owner, so leave it unrouted rather than guess.
const projectGroupOwnerHostIdByGroupId = useMemo(() => {
const byGroupId = new Map<string, ExecutionHostId | null>()
for (const group of projectGroups) {
const hostId = getProjectGroupHostId(group)
const existing = byGroupId.get(group.id)
byGroupId.set(group.id, existing === undefined || existing === hostId ? hostId : null)
}
return byGroupId
}, [projectGroups])
// Why: reorder keeps scrollTop stable; flag direct scroll input so anchor-restore won't chase the moved row (jumpy drop).
const suppressScrollCorrectionForHeaderCommit = useCallback(() => {
@@ -157,9 +168,11 @@ export function useWorktreeSidebarHeaderDrag(args: {
return
}
suppressScrollCorrectionForHeaderCommit()
void updateProjectGroup(groupId, { tabOrder })
// Why: manual order persists on the group's own host; the focused host may not hold this row.
const ownerHostId = projectGroupOwnerHostIdByGroupId.get(groupId)
void updateProjectGroup(groupId, { tabOrder }, { hostId: ownerHostId ?? undefined })
},
[suppressScrollCorrectionForHeaderCommit, updateProjectGroup]
[projectGroupOwnerHostIdByGroupId, suppressScrollCorrectionForHeaderCommit, updateProjectGroup]
)
// Drag applies only in manual order; still construct the controller inert for stable hook order.
const repoDrag = useRepoHeaderDrag({
@@ -8,6 +8,8 @@ import { RepoForkIndicator } from '@/components/repo/repo-fork-indicator'
import type { FolderWorkspacePathStatus } from '../../../../../../shared/folder-workspace-path-status'
import { isConfirmedStaleFolderPathStatus } from '../../../../../../shared/folder-workspace-path-status'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import { getProjectGroupHostId } from '@/store/slices/project-group-owner-routing'
import type {
WorkspaceStatus,
WorkspaceStatusDefinition
@@ -56,8 +58,8 @@ export type SectionHeaderRowContext = {
}) => FolderWorkspacePathStatus | null
toggleGroupWithScrollAnchor: (groupKey: string) => void
projectActions: RepoHeaderProjectActions
onRenameProjectGroup: (groupId: string, currentName: string) => void
onDeleteProjectGroup: (groupId: string, groupName: string) => void
onRenameProjectGroup: (groupId: string, currentName: string, hostId?: ExecutionHostId) => void
onDeleteProjectGroup: (groupId: string, groupName: string, hostId?: ExecutionHostId) => void
onCreateFolderWorkspace: (projectGroup: ProjectGroup) => void
onWorkspaceStatusDragOver: (event: React.DragEvent, status: WorkspaceStatus) => void
onWorkspaceStatusDragLeave: (event: React.DragEvent) => void
@@ -92,6 +94,11 @@ export function renderWorktreeSectionHeaderRow(args: {
isProjectGroupHeader && !row.repo && typeof row.projectGroup?.id === 'string'
? row.projectGroup.id
: undefined
// Why: rename/delete must route to the host that owns this row, not to whichever host has focus.
const projectGroupHostIdForHeader =
row.projectGroup && 'createdFrom' in row.projectGroup
? getProjectGroupHostId(row.projectGroup)
: undefined
const repoHeaderIndex =
projectIdForHeader !== undefined
? headerDrag.repoHeaderIndexByRepoId.get(projectIdForHeader)
@@ -353,6 +360,7 @@ export function renderWorktreeSectionHeaderRow(args: {
{isProjectGroupHeader && !row.repo && projectGroupIdForHeader ? (
<ProjectGroupHeaderMenu
groupId={projectGroupIdForHeader}
hostId={projectGroupHostIdForHeader}
label={row.label}
onRename={ctx.onRenameProjectGroup}
onDelete={ctx.onDeleteProjectGroup}
@@ -13,6 +13,7 @@ import { translate } from '@/i18n/i18n'
import { getFolderWorkspacePathStatusDescription } from '@/lib/folder-workspace-path-status'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { FolderWorkspacePathStatus } from '../../../../../../shared/folder-workspace-path-status'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import { REPO_HEADER_ACTION_BUTTON_CLASS } from '../../repo-header-action-button-class'
import {
handleRepoHeaderActionPointerDown,
@@ -22,14 +23,17 @@ import {
export function ProjectGroupHeaderMenu({
groupId,
hostId,
label,
onRename,
onDelete
}: {
groupId: string
/** Owner host of the group row, so rename/delete route to the host that holds it. */
hostId?: ExecutionHostId
label: string
onRename: (groupId: string, currentName: string) => void
onDelete: (groupId: string, groupName: string) => void
onRename: (groupId: string, currentName: string, hostId?: ExecutionHostId) => void
onDelete: (groupId: string, groupName: string, hostId?: ExecutionHostId) => void
}): React.JSX.Element {
return (
<DropdownMenu modal={false}>
@@ -64,10 +68,10 @@ export function ProjectGroupHeaderMenu({
onClick={stopRepoHeaderMenuEvent}
onKeyDown={stopRepoHeaderMenuEvent}
>
<DropdownMenuItem onSelect={() => onRename(groupId, label)}>
<DropdownMenuItem onSelect={() => onRename(groupId, label, hostId)}>
{translate('auto.components.sidebar.WorktreeList.4d7b73658c', 'Rename group')}
</DropdownMenuItem>
<DropdownMenuItem variant="destructive" onSelect={() => onDelete(groupId, label)}>
<DropdownMenuItem variant="destructive" onSelect={() => onDelete(groupId, label, hostId)}>
{translate('auto.components.sidebar.WorktreeList.902115cdbe', 'Delete group')}
</DropdownMenuItem>
</DropdownMenuContent>
@@ -0,0 +1,135 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useProjectGroupDialogs, type ProjectGroupDialogs } from './use-project-group-dialogs'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
const mocks = vi.hoisted(() => ({
moveProjectToGroup: vi.fn(),
createProjectGroup: vi.fn(),
updateProjectGroup: vi.fn(),
deleteProjectGroupWithContainedProjects: vi.fn(),
toastError: vi.fn()
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
moveProjectToGroup: mocks.moveProjectToGroup,
createProjectGroup: mocks.createProjectGroup,
updateProjectGroup: mocks.updateProjectGroup,
deleteProjectGroupWithContainedProjects: mocks.deleteProjectGroupWithContainedProjects
})
}))
vi.mock('sonner', () => ({
toast: { error: mocks.toastError }
}))
const remoteGroup: ProjectGroup = {
id: 'group-1',
name: 'Projects',
parentPath: '/srv/projects',
parentGroupId: null,
createdFrom: 'folder-scan',
executionHostId: 'runtime:env-1',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
let latest: ProjectGroupDialogs | null = null
const roots: Root[] = []
function HookProbe(): null {
latest = useProjectGroupDialogs({
repos: [],
repoMap: new Map(),
projectGroups: [remoteGroup]
})
return null
}
async function renderHookProbe(): Promise<void> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(<HookProbe />)
})
}
beforeEach(() => {
vi.clearAllMocks()
mocks.updateProjectGroup.mockResolvedValue(true)
mocks.deleteProjectGroupWithContainedProjects.mockResolvedValue({
status: 'deleted-group',
groupId: remoteGroup.id,
requestedProjectIds: [],
removedProjectIds: [],
failedProjectRemovals: []
})
})
afterEach(async () => {
await act(async () => {
for (const root of roots.splice(0)) {
root.unmount()
}
})
latest = null
})
describe('project group dialogs carry the owner host', () => {
it('renames through the host that owns the group row', async () => {
await renderHookProbe()
await act(async () => {
latest!.handleRenameProjectGroup(remoteGroup.id, remoteGroup.name, 'runtime:env-1')
})
await act(async () => {
await latest!.handleSubmitProjectGroupName('Renamed')
})
expect(mocks.updateProjectGroup).toHaveBeenCalledWith(
remoteGroup.id,
{ name: 'Renamed' },
{ hostId: 'runtime:env-1' }
)
expect(mocks.toastError).not.toHaveBeenCalled()
})
it('surfaces a toast when the owner host does not apply the rename', async () => {
mocks.updateProjectGroup.mockResolvedValue(false)
await renderHookProbe()
await act(async () => {
latest!.handleRenameProjectGroup(remoteGroup.id, remoteGroup.name, 'runtime:env-1')
})
await act(async () => {
await latest!.handleSubmitProjectGroupName('Renamed')
})
expect(mocks.toastError).toHaveBeenCalledWith('Failed to rename group', {
description: "The group's host did not apply the new name."
})
})
it('deletes through the host that owns the group row', async () => {
await renderHookProbe()
await act(async () => {
latest!.handleDeleteProjectGroup(remoteGroup.id, remoteGroup.name, 'runtime:env-1')
})
await act(async () => {
await latest!.handleConfirmDeleteProjectGroup()
})
expect(mocks.deleteProjectGroupWithContainedProjects).toHaveBeenCalledWith(remoteGroup.id, {
removeContainedProjects: false,
hostId: 'runtime:env-1'
})
})
})
@@ -5,15 +5,18 @@ import { translate } from '@/i18n/i18n'
import { selectProjectGroupRemovalTargets } from '@/store/slices/project-group-removal-targets'
import type { ProjectGroup } from '../../../../../../shared/project-group-types'
import type { Repo } from '../../../../../../shared/repo-types'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
export type ProjectGroupNameDialogState =
| { type: 'create-from-repo'; repo: Repo }
| { type: 'rename'; groupId: string; currentName: string }
// hostId is the group row's owner host, so the mutation is not routed to whichever host has focus.
| { type: 'rename'; groupId: string; currentName: string; hostId?: ExecutionHostId }
export type ProjectGroupDeleteDialogState = {
groupId: string
groupName: string
removeContainedProjects: boolean
hostId?: ExecutionHostId
}
export type ProjectGroupDialogs = ReturnType<typeof useProjectGroupDialogs>
@@ -95,9 +98,12 @@ export function useProjectGroupDialogs(args: {
[moveProjectToGroup]
)
const handleRenameProjectGroup = useCallback((groupId: string, currentName: string) => {
setNameDialog({ type: 'rename', groupId, currentName })
}, [])
const handleRenameProjectGroup = useCallback(
(groupId: string, currentName: string, hostId?: ExecutionHostId) => {
setNameDialog({ type: 'rename', groupId, currentName, hostId })
},
[]
)
const handleSubmitProjectGroupName = useCallback(
async (name: string) => {
@@ -111,7 +117,25 @@ export function useProjectGroupDialogs(args: {
}
return
}
await updateProjectGroup(nameDialog.groupId, { name })
const renamed = await updateProjectGroup(
nameDialog.groupId,
{ name },
{ hostId: nameDialog.hostId }
)
if (!renamed) {
toast.error(
translate(
'auto.components.sidebar.WorktreeList.groupRenameFailed',
'Failed to rename group'
),
{
description: translate(
'auto.components.sidebar.WorktreeList.groupRenameFailedDesc',
"The group's host did not apply the new name."
)
}
)
}
},
[createProjectGroup, moveProjectToGroup, nameDialog, updateProjectGroup]
)
@@ -120,7 +144,12 @@ export function useProjectGroupDialogs(args: {
if (!deleteDialog) {
return null
}
return selectProjectGroupRemovalTargets(projectGroups, repos, deleteDialog.groupId)
return selectProjectGroupRemovalTargets(
projectGroups,
repos,
deleteDialog.groupId,
deleteDialog.hostId
)
}, [deleteDialog, projectGroups, repos])
const deleteProjectCount = deleteTargets?.projectIds.length ?? 0
const deleteProjectNames = useMemo(
@@ -133,9 +162,12 @@ export function useProjectGroupDialogs(args: {
const removeContainedProjects =
deleteProjectCount > 0 && deleteDialog?.removeContainedProjects === true
const handleDeleteProjectGroup = useCallback((groupId: string, groupName: string) => {
setDeleteDialog({ groupId, groupName, removeContainedProjects: false })
}, [])
const handleDeleteProjectGroup = useCallback(
(groupId: string, groupName: string, hostId?: ExecutionHostId) => {
setDeleteDialog({ groupId, groupName, removeContainedProjects: false, hostId })
},
[]
)
const handleConfirmDeleteProjectGroup = useCallback(async () => {
if (!deleteDialog) {
@@ -144,7 +176,8 @@ export function useProjectGroupDialogs(args: {
try {
reportProjectGroupDeleteFailures(
await deleteProjectGroupWithContainedProjects(deleteDialog.groupId, {
removeContainedProjects
removeContainedProjects,
hostId: deleteDialog.hostId
})
)
} finally {
@@ -47,8 +47,8 @@ export type VirtualizedWorktreeViewportProps = {
handleCreateGroupFromRepo: (repo: Repo) => void
handleMoveProjectToGroup: (repo: Repo, groupId: string) => void
handleRemoveProjectFromGroup: (repo: Repo) => void
handleRenameProjectGroup: (groupId: string, currentName: string) => void
handleDeleteProjectGroup: (groupId: string, groupName: string) => void
handleRenameProjectGroup: (groupId: string, currentName: string, hostId?: ExecutionHostId) => void
handleDeleteProjectGroup: (groupId: string, groupName: string, hostId?: ExecutionHostId) => void
handleCreateFolderWorkspace: (projectGroup: ProjectGroup) => void
activeModal: string
pendingRevealWorktree: PendingSidebarWorktreeReveal | null
+2
View File
@@ -5333,6 +5333,8 @@
"f94466bc39": "{{value0}} of {{value1}} contained project{{value2}} remained after deleting the group.",
"groupDeleteFailed": "Failed to delete group",
"groupDeleteFailedDesc": "Something went wrong while deleting the group. No projects were removed.",
"groupRenameFailed": "Failed to rename group",
"groupRenameFailedDesc": "The group's host did not apply the new name.",
"failedNestWorkspace": "Failed to nest workspace",
"sidebarRowMissing": "Target no longer exists",
"failedUnnestWorkspace": "Failed to unnest workspace"
@@ -0,0 +1,94 @@
import type { GlobalSettings } from '../../../../shared/global-settings-types'
import type { ProjectGroup } from '../../../../shared/project-group-types'
import {
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostId,
parseExecutionHostId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../../../shared/execution-host'
import { findIndexedProjectGroupOwner } from '@/lib/worktree-runtime-owner-index'
type ProjectGroupHostParts = Pick<ProjectGroup, 'connectionId' | 'executionHostId'>
type ProjectGroupOwnerRecord = Pick<ProjectGroup, 'id' | 'connectionId' | 'executionHostId'>
type RoutingSettings = Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null | undefined
type ProjectGroupOwnerRoutingState = {
projectGroups: readonly ProjectGroupOwnerRecord[]
settings: RoutingSettings
}
// Why: persisted rows predate host stamping and may carry padded/unparseable ids; normalize so
// routing and catalog identity agree on the same owner host.
export function getProjectGroupHostId(group: ProjectGroupHostParts): ExecutionHostId {
const executionHostId = normalizeExecutionHostId(group.executionHostId)
if (executionHostId) {
return executionHostId
}
const connectionId = group.connectionId?.trim()
return connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
}
export function catalogOwnsHost(catalogHostId: string, rowHostId: string): boolean {
if (catalogHostId !== LOCAL_EXECUTION_HOST_ID) {
return catalogHostId === rowHostId
}
return parseExecutionHostId(rowHostId)?.kind !== 'runtime'
}
export function projectGroupMatchesOwnerHost(
group: ProjectGroupOwnerRecord,
groupId: string,
ownerHostId: ExecutionHostId | null
): boolean {
if (group.id !== groupId) {
return false
}
return ownerHostId ? catalogOwnsHost(ownerHostId, getProjectGroupHostId(group)) : true
}
export function resolveProjectGroupOwnerHostId(
state: ProjectGroupOwnerRoutingState,
groupId: string,
hostId?: ExecutionHostId
): ExecutionHostId | null {
const owner = findIndexedProjectGroupOwner(state.projectGroups, groupId, hostId)
if (!owner) {
return null
}
if (hostId) {
return hostId
}
// Why: an unstamped row carries no owner, so keep the focused-host behavior instead of assuming local.
if (!owner.executionHostId && !owner.connectionId) {
return null
}
return getProjectGroupHostId(owner)
}
// Why: the sidebar lists groups from every host, so mutations must route to the row's owner
// instead of whichever host currently has focus. Mirrors settingsForRepoOwner.
export function settingsForProjectGroupOwner(
state: ProjectGroupOwnerRoutingState,
groupId: string,
hostId?: ExecutionHostId
): RoutingSettings {
const ownerHostId = resolveProjectGroupOwnerHostId(state, groupId, hostId)
if (!ownerHostId) {
return state.settings
}
const parsed = parseExecutionHostId(ownerHostId)
if (parsed?.kind === 'runtime') {
return state.settings
? { ...state.settings, activeRuntimeEnvironmentId: parsed.environmentId }
: { activeRuntimeEnvironmentId: parsed.environmentId }
}
// Why: direct-SSH groups live in the local main process catalog, so they route through window.api.
if (
(parsed?.kind === 'local' || parsed?.kind === 'ssh') &&
state.settings?.activeRuntimeEnvironmentId
) {
return { ...state.settings, activeRuntimeEnvironmentId: null }
}
return state.settings
}
@@ -1,6 +1,8 @@
import type { ProjectGroup } from '../../../../shared/project-group-types'
import type { Repo } from '../../../../shared/repo-types'
import { getProjectGroupSubtreeIds } from '../../../../shared/project-groups'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
import { catalogOwnsHost, getProjectGroupHostId } from './project-group-owner-routing'
export type ProjectGroupRemovalTargets = {
groupExists: boolean
@@ -11,9 +13,16 @@ export type ProjectGroupRemovalTargets = {
export function selectProjectGroupRemovalTargets(
projectGroups: readonly ProjectGroup[],
repos: readonly Repo[],
groupId: string
groupId: string,
// Why: ids repeat across hosts; without an owner host the caller would target another host's rows.
hostId?: ExecutionHostId | null
): ProjectGroupRemovalTargets {
const groupExists = projectGroups.some((group) => group.id === groupId)
const ownsRowHost = (rowHostId: string): boolean =>
hostId ? catalogOwnsHost(hostId, rowHostId) : true
const ownerGroups = hostId
? projectGroups.filter((group) => ownsRowHost(getProjectGroupHostId(group)))
: projectGroups
const groupExists = ownerGroups.some((group) => group.id === groupId)
if (!groupExists) {
return {
groupExists: false,
@@ -22,10 +31,14 @@ export function selectProjectGroupRemovalTargets(
}
}
const deletedGroupIds = getProjectGroupSubtreeIds(projectGroups, groupId)
const deletedGroupIds = getProjectGroupSubtreeIds(ownerGroups, groupId)
const projectIds: string[] = []
for (const repo of repos) {
if (repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId)) {
if (
repo.projectGroupId &&
deletedGroupIds.has(repo.projectGroupId) &&
ownsRowHost(getRepoExecutionHostId(repo))
) {
projectIds.push(repo.id)
}
}
@@ -0,0 +1,303 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../shared/project-group-types'
import type { Repo } from '../../../../shared/repo-types'
import {
createCompatibleRuntimeStatusResponseIfNeeded,
type RuntimeEnvironmentCallRequest
} from '../../runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
import { createTestStore } from './store-test-helpers'
const folderScanGroup: ProjectGroup = {
id: 'group-1',
name: 'Projects',
parentPath: '/srv/projects',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const baseRepo: Repo = {
id: 'repo',
path: '/repo',
displayName: 'Repo',
badgeColor: '#111',
addedAt: 1
}
const baseFolderWorkspace: FolderWorkspace = {
id: 'folder-workspace-1',
projectGroupId: folderScanGroup.id,
name: 'Notes',
folderPath: '/srv/projects/notes',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 0,
createdAt: 1,
updatedAt: 1
}
const projectGroupsUpdate = vi.fn()
const projectGroupsDelete = vi.fn()
const runtimeEnvironmentCall = vi.fn()
const runtimeEnvironmentTransportCall = vi.fn()
beforeEach(() => {
clearRuntimeCompatibilityCacheForTests()
vi.clearAllMocks()
projectGroupsUpdate.mockResolvedValue(null)
projectGroupsDelete.mockResolvedValue(false)
runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => {
return createCompatibleRuntimeStatusResponseIfNeeded(args) ?? runtimeEnvironmentCall(args)
})
vi.stubGlobal('window', {
api: {
repos: { remove: vi.fn() },
projectGroups: { update: projectGroupsUpdate, delete: projectGroupsDelete },
runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
}
})
})
function runtimeRpcResponse(result: unknown) {
return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-remote' } }
}
describe('project group mutations route to the owning host', () => {
it('renames a runtime-owned folder-scan group while the local host is focused', async () => {
const runtimeGroup: ProjectGroup = { ...folderScanGroup, executionHostId: 'runtime:env-1' }
runtimeEnvironmentCall.mockResolvedValue(
runtimeRpcResponse({ group: { ...runtimeGroup, name: 'Renamed' } })
)
const store = createTestStore()
store.setState({
settings: { activeRuntimeEnvironmentId: null } as never,
projectGroups: [runtimeGroup]
})
await expect(
store.getState().updateProjectGroup(runtimeGroup.id, { name: 'Renamed' })
).resolves.toBe(true)
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'projectGroup.update',
params: { groupId: runtimeGroup.id, updates: { name: 'Renamed' } },
timeoutMs: 15_000
})
expect(projectGroupsUpdate).not.toHaveBeenCalled()
expect(store.getState().projectGroups[0]).toMatchObject({
name: 'Renamed',
executionHostId: 'runtime:env-1'
})
})
it('deletes a local folder-scan group while a runtime host is focused', async () => {
projectGroupsDelete.mockResolvedValue(true)
const store = createTestStore()
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
projectGroups: [{ ...folderScanGroup, executionHostId: 'local' }]
})
await expect(store.getState().deleteProjectGroup(folderScanGroup.id)).resolves.toBe(true)
expect(projectGroupsDelete).toHaveBeenCalledWith({ groupId: folderScanGroup.id })
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
expect(store.getState().projectGroups).toEqual([])
})
it('routes a direct-SSH group through the local catalog, not the focused runtime', async () => {
projectGroupsDelete.mockResolvedValue(true)
const store = createTestStore()
store.setState({
settings: { activeRuntimeEnvironmentId: 'env-1' } as never,
projectGroups: [{ ...folderScanGroup, connectionId: 'conn-1' }]
})
await expect(store.getState().deleteProjectGroup(folderScanGroup.id)).resolves.toBe(true)
expect(projectGroupsDelete).toHaveBeenCalledWith({ groupId: folderScanGroup.id })
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
it('prefers an explicit hostId over both the focused host and a colliding row', async () => {
runtimeEnvironmentCall.mockResolvedValue(runtimeRpcResponse({ deleted: true }))
const store = createTestStore()
store.setState({
settings: { activeRuntimeEnvironmentId: null } as never,
projectGroups: [
{ ...folderScanGroup, executionHostId: 'local' },
{ ...folderScanGroup, executionHostId: 'runtime:env-1' }
]
})
await expect(
store.getState().deleteProjectGroup(folderScanGroup.id, { hostId: 'runtime:env-1' })
).resolves.toBe(true)
expect(runtimeEnvironmentCall).toHaveBeenCalledWith({
selector: 'env-1',
method: 'projectGroup.delete',
params: { groupId: folderScanGroup.id },
timeoutMs: 15_000
})
expect(projectGroupsDelete).not.toHaveBeenCalled()
expect(store.getState().projectGroups).toMatchObject([{ executionHostId: 'local' }])
})
it('keeps the focused host when a colliding id has no unambiguous owner', async () => {
projectGroupsDelete.mockResolvedValue(true)
const store = createTestStore()
store.setState({
settings: { activeRuntimeEnvironmentId: null } as never,
projectGroups: [
{ ...folderScanGroup, executionHostId: 'local' },
{ ...folderScanGroup, executionHostId: 'runtime:env-1' }
]
})
await expect(store.getState().deleteProjectGroup(folderScanGroup.id)).resolves.toBe(true)
expect(projectGroupsDelete).toHaveBeenCalledWith({ groupId: folderScanGroup.id })
expect(runtimeEnvironmentCall).not.toHaveBeenCalled()
})
})
describe('project group state cascades stay scoped to the owner host', () => {
it('leaves another host rows intact when deleting a colliding local group', async () => {
projectGroupsDelete.mockResolvedValue(true)
const runtimeChild: ProjectGroup = {
...folderScanGroup,
id: 'child',
parentGroupId: folderScanGroup.id,
executionHostId: 'runtime:env-1'
}
const store = createTestStore()
store.setState({
settings: { activeRuntimeEnvironmentId: null } as never,
projectGroups: [
{ ...folderScanGroup, executionHostId: 'local' },
{ ...folderScanGroup, name: 'Remote projects', executionHostId: 'runtime:env-1' },
runtimeChild
],
folderWorkspaces: [
{ ...baseFolderWorkspace, executionHostId: 'local' },
{
...baseFolderWorkspace,
id: 'folder-workspace-remote',
executionHostId: 'runtime:env-1'
}
],
repos: [
{ ...baseRepo, id: 'local-repo', projectGroupId: folderScanGroup.id },
{
...baseRepo,
id: 'remote-repo',
projectGroupId: folderScanGroup.id,
executionHostId: 'runtime:env-1'
}
]
})
await expect(
store.getState().deleteProjectGroup(folderScanGroup.id, { hostId: 'local' })
).resolves.toBe(true)
expect(store.getState().projectGroups).toMatchObject([
{ id: folderScanGroup.id, executionHostId: 'runtime:env-1' },
{ id: 'child', executionHostId: 'runtime:env-1' }
])
expect(store.getState().folderWorkspaces.map((workspace) => workspace.id)).toEqual([
'folder-workspace-remote'
])
expect(store.getState().repos).toMatchObject([
{ id: 'local-repo', projectGroupId: null },
{ id: 'remote-repo', projectGroupId: folderScanGroup.id }
])
})
it('renames only the owner host row when the id exists on two hosts', async () => {
projectGroupsUpdate.mockResolvedValue({
...folderScanGroup,
executionHostId: 'local',
name: 'Renamed'
})
const store = createTestStore()
store.setState({
settings: { activeRuntimeEnvironmentId: null } as never,
projectGroups: [
{ ...folderScanGroup, executionHostId: 'local' },
{ ...folderScanGroup, name: 'Remote projects', executionHostId: 'runtime:env-1' }
]
})
await expect(
store.getState().updateProjectGroup(
folderScanGroup.id,
{ name: 'Renamed' },
{
hostId: 'local'
}
)
).resolves.toBe(true)
expect(store.getState().projectGroups).toMatchObject([
{ name: 'Renamed', executionHostId: 'local' },
{ name: 'Remote projects', executionHostId: 'runtime:env-1' }
])
})
it('removes contained projects only from the owner host', async () => {
projectGroupsDelete.mockResolvedValue(true)
const reposRemove = vi.fn().mockResolvedValue(undefined)
const reposRemoveForHost = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('window', {
api: {
repos: { remove: reposRemove, removeForHost: reposRemoveForHost },
projectGroups: { update: projectGroupsUpdate, delete: projectGroupsDelete },
runtimeEnvironments: { call: runtimeEnvironmentTransportCall }
}
})
const store = createTestStore()
store.setState({
settings: { activeRuntimeEnvironmentId: null } as never,
projectGroups: [
{ ...folderScanGroup, executionHostId: 'local' },
{ ...folderScanGroup, executionHostId: 'runtime:env-1' }
],
repos: [
{ ...baseRepo, id: 'local-repo', projectGroupId: folderScanGroup.id },
{
...baseRepo,
id: 'remote-repo',
projectGroupId: folderScanGroup.id,
executionHostId: 'runtime:env-1'
}
]
})
const result = await store
.getState()
.deleteProjectGroupWithContainedProjects(folderScanGroup.id, {
removeContainedProjects: true,
hostId: 'local'
})
expect(result).toMatchObject({
status: 'deleted-group',
requestedProjectIds: ['local-repo'],
removedProjectIds: ['local-repo']
})
expect(store.getState().repos.map((repo) => repo.id)).toEqual(['remote-repo'])
})
})
+84 -44
View File
@@ -52,6 +52,13 @@ import { isPathInsideOrEqual } from '../../../../shared/cross-platform-path'
import { getRepoIdFromWorktreeId } from '../../../../shared/worktree/id'
import { structuralValuesEqual } from '../../../../shared/structural-value-equality'
import { selectProjectGroupRemovalTargets } from './project-group-removal-targets'
import {
catalogOwnsHost,
getProjectGroupHostId,
projectGroupMatchesOwnerHost,
resolveProjectGroupOwnerHostId,
settingsForProjectGroupOwner
} from './project-group-owner-routing'
import { reconcileCatalogRows, reconcileFetchedRepos } from './repo-identity-reconcile'
import { retainValidFilterRepoIds } from './repo-filter-selection'
import { readRuntimeWorktreeVisibilitySnapshot } from './worktree-visibility-owner-settings'
@@ -218,6 +225,8 @@ export type FolderWorkspacePathStatusCacheEntry = {
export type DeleteProjectGroupWithContainedProjectsOptions = {
removeContainedProjects: boolean
// hostId disambiguates which host's group row to delete when the id exists on multiple hosts.
hostId?: ExecutionHostId
}
type AllHostCatalogFetchOptions = {
@@ -1120,24 +1129,10 @@ 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])
}
function catalogOwnsHost(catalogHostId: string, rowHostId: string): boolean {
if (catalogHostId !== LOCAL_EXECUTION_HOST_ID) {
return catalogHostId === rowHostId
}
return parseExecutionHostId(rowHostId)?.kind !== 'runtime'
}
function mergeFetchedProjectGroupsForHost(
previous: readonly ProjectGroup[],
fetched: ProjectGroup[],
@@ -1186,6 +1181,37 @@ function getFolderWorkspaceHostIdentity(
return JSON.stringify([getFolderWorkspaceHostId(workspace, projectGroups), workspace.id])
}
// Why: a group id can exist on several hosts; only the deleted owner's rows may be cascaded away.
function applyProjectGroupDeleteCascade(
s: Pick<RepoSlice, 'projectGroups' | 'folderWorkspaces' | 'repos'>,
groupId: string,
ownerHostId: ExecutionHostId | null
): Pick<RepoSlice, 'projectGroups' | 'folderWorkspaces' | 'repos' | 'folderWorkspacePathStatuses'> {
const ownsRowHost = (rowHostId: string): boolean =>
ownerHostId ? catalogOwnsHost(ownerHostId, rowHostId) : true
const ownerGroups = s.projectGroups.filter((group) => ownsRowHost(getProjectGroupHostId(group)))
const deletedGroupIds = getProjectGroupSubtreeIds(ownerGroups, groupId)
const isDeletedGroup = (group: ProjectGroup): boolean =>
deletedGroupIds.has(group.id) && ownsRowHost(getProjectGroupHostId(group))
return {
projectGroups: s.projectGroups.filter((group) => !isDeletedGroup(group)),
folderWorkspaces: s.folderWorkspaces.filter(
(workspace) =>
!deletedGroupIds.has(workspace.projectGroupId) ||
// Why: resolve the workspace's host against the pre-delete group list, which still holds its owner row.
!ownsRowHost(getFolderWorkspaceHostId(workspace, s.projectGroups))
),
repos: s.repos.map((repo) =>
repo.projectGroupId &&
deletedGroupIds.has(repo.projectGroupId) &&
ownsRowHost(getRepoExecutionHostId(repo))
? { ...repo, projectGroupId: null }
: repo
),
folderWorkspacePathStatuses: {}
}
}
function getFolderWorkspaceUpdateIdentity(
hostId: ExecutionHostId,
folderWorkspaceId: string
@@ -1883,11 +1909,13 @@ export type RepoSlice = {
options?: { executionHostId?: ExecutionHostId }
) => Promise<boolean>
deleteFolderWorkspace: (folderWorkspaceId: string) => Promise<boolean>
// options.hostId targets a specific host's row + RPC target when the id exists on multiple hosts; else the group's own host owns the call.
updateProjectGroup: (
groupId: string,
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>
updates: Partial<Pick<ProjectGroup, 'name' | 'isCollapsed' | 'tabOrder' | 'color'>>,
options?: { hostId?: ExecutionHostId }
) => Promise<boolean>
deleteProjectGroup: (groupId: string) => Promise<boolean>
deleteProjectGroup: (groupId: string, options?: { hostId?: ExecutionHostId }) => Promise<boolean>
deleteProjectGroupWithContainedProjects: (
groupId: string,
options: DeleteProjectGroupWithContainedProjectsOptions
@@ -2990,10 +3018,13 @@ 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)
// Why: the sidebar lists groups from every host, so the mutation follows the group's owner, not the focused host.
const ownerHostId = resolveProjectGroupOwnerHostId(get(), groupId, options?.hostId)
const target = getActiveRuntimeTarget(
settingsForProjectGroupOwner(get(), groupId, options?.hostId)
)
const updated =
target.kind === 'local'
? await window.api.projectGroups.update({ groupId, updates })
@@ -3010,7 +3041,9 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}
const ownedGroup = projectGroupWithFetchedOwner(updated, target)
set((s) => ({
projectGroups: s.projectGroups.map((group) => (group.id === groupId ? ownedGroup : group)),
projectGroups: s.projectGroups.map((group) =>
projectGroupMatchesOwnerHost(group, groupId, ownerHostId) ? ownedGroup : group
),
folderWorkspacePathStatuses: {}
}))
return true
@@ -3020,10 +3053,13 @@ 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)
// Why: deletion targets the group's owner host (see updateProjectGroup); focus may be elsewhere.
const ownerHostId = resolveProjectGroupOwnerHostId(get(), groupId, options?.hostId)
const target = getActiveRuntimeTarget(
settingsForProjectGroupOwner(get(), groupId, options?.hostId)
)
const deleted =
target.kind === 'local'
? await window.api.projectGroups.delete({ groupId })
@@ -3038,21 +3074,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
if (!deleted) {
return false
}
set((s) => {
const deletedGroupIds = getProjectGroupSubtreeIds(s.projectGroups, groupId)
return {
projectGroups: s.projectGroups.filter((group) => !deletedGroupIds.has(group.id)),
folderWorkspaces: s.folderWorkspaces.filter(
(workspace) => !deletedGroupIds.has(workspace.projectGroupId)
),
repos: s.repos.map((repo) =>
repo.projectGroupId && deletedGroupIds.has(repo.projectGroupId)
? { ...repo, projectGroupId: null }
: repo
),
folderWorkspacePathStatuses: {}
}
})
set((s) => applyProjectGroupDeleteCascade(s, groupId, ownerHostId))
return true
} catch (err) {
console.error('Failed to delete project group:', err)
@@ -3061,7 +3083,13 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
},
deleteProjectGroupWithContainedProjects: async (groupId, options) => {
const targets = selectProjectGroupRemovalTargets(get().projectGroups, get().repos, groupId)
const ownerHostId = resolveProjectGroupOwnerHostId(get(), groupId, options.hostId)
const targets = selectProjectGroupRemovalTargets(
get().projectGroups,
get().repos,
groupId,
ownerHostId
)
const requestedProjectIds = options.removeContainedProjects ? targets.projectIds : []
if (!targets.groupExists) {
return {
@@ -3073,7 +3101,9 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
}
}
const deleted = await get().deleteProjectGroup(groupId)
const deleted = await get().deleteProjectGroup(groupId, {
hostId: ownerHostId ?? undefined
})
if (!deleted) {
return {
status: 'group-delete-failed',
@@ -3096,16 +3126,26 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
const removedProjectIds: string[] = []
const failedProjectRemovals: ProjectRemovalFailure[] = []
// Why: the group's catalog can hold rows from several hosts (a local catalog also owns SSH rows),
// so each project is removed on its own host rather than on the group's.
const findOwnedProjects = (projectId: string): Repo[] =>
get().repos.filter(
(repo) =>
repo.id === projectId &&
(!ownerHostId || catalogOwnsHost(ownerHostId, getRepoExecutionHostId(repo)))
)
for (const projectId of targets.projectIds) {
const existedBeforeRemoval = get().repos.some((repo) => repo.id === projectId)
const ownedProjects = findOwnedProjects(projectId)
const projectHostId =
ownedProjects.length === 1 ? getRepoExecutionHostId(ownedProjects[0]) : undefined
try {
if (existedBeforeRemoval) {
await get().removeProject(projectId)
if (ownedProjects.length > 0) {
await get().removeProject(projectId, { hostId: projectHostId })
}
} catch (err) {
console.error('Failed to remove contained project:', err)
}
const stillExists = get().repos.some((repo) => repo.id === projectId)
const stillExists = findOwnedProjects(projectId).length > 0
if (stillExists) {
failedProjectRemovals.push({
projectId,