fix(sidebar): preserve folder workspace owner identity

This commit is contained in:
Brennan Benson
2026-08-10 16:22:43 -07:00
parent 0c9cbcb678
commit de06dbb4f2
27 changed files with 663 additions and 129 deletions
+2 -2
View File
@@ -109,9 +109,9 @@ import {
buildProjectGroupOwnerIndex,
getFolderWorkspaceProjectGroupOwnerHostId,
getProjectGroupOwnerHostId,
resolveFolderWorkspaceProjectGroup,
resolveProjectGroupOwner
} from '../../shared/project-groups'
import { resolveFolderWorkspaceProjectGroupWithLegacySsh } from '../../shared/folder-workspace-project-group-resolution'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import {
assertFolderWorkspacePathUsable,
@@ -1571,7 +1571,7 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v
}
const projectGroups = store.getProjectGroups()
const projectGroupIndex = buildProjectGroupOwnerIndex(projectGroups)
const group = resolveFolderWorkspaceProjectGroup(projectGroupIndex, workspace)
const group = resolveFolderWorkspaceProjectGroupWithLegacySsh(projectGroupIndex, workspace)
if (!group) {
return null
}
+50
View File
@@ -3809,6 +3809,56 @@ describe('Store', () => {
expect(store.getFolderWorkspace('same-folder', 'ssh:builder')?.name).toBe('Remote')
})
it('loads a persisted legacy SSH folder under one unstamped group', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {},
ui: {},
githubCache: { pr: {}, issue: {} },
projectGroups: [
{
id: 'legacy-group',
name: 'Legacy',
parentPath: '/remote',
parentGroupId: null,
createdFrom: 'migration',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
],
folderWorkspaces: [
{
id: 'legacy-folder',
projectGroupId: 'legacy-group',
name: 'Legacy folder',
folderPath: '/remote/folder',
connectionId: 'builder',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}
]
})
const store = await createStore()
expect(store.getProjectGroups()[0]?.connectionId).toBeUndefined()
expect(store.getFolderWorkspace('legacy-folder', 'ssh:builder')).toBeDefined()
expect(store.deleteProjectGroup('legacy-group', 'local')).toBe(true)
expect(store.getFolderWorkspace('legacy-folder', 'ssh:builder')).toBeUndefined()
})
it('adapts flat folder-scan groups into sparse nested folder scopes on load', async () => {
writeDataFile({
schemaVersion: 1,
+3 -3
View File
@@ -229,9 +229,9 @@ import {
normalizeProjectGroupName,
normalizeProjectGroups,
resolveProjectGroupMembership,
resolveProjectGroupOwner,
resolveFolderWorkspaceProjectGroup
resolveProjectGroupOwner
} from '../shared/project-groups'
import { resolveFolderWorkspaceProjectGroupWithLegacySsh } from '../shared/folder-workspace-project-group-resolution'
import { createNestedProjectGroupResolver } from './project-groups/nested-repo-import'
import {
mergeLegacyCommitMessageAiIntoSourceControlAi,
@@ -4491,7 +4491,7 @@ export class Store {
})
const folderWorkspaces = this.state.folderWorkspaces ?? []
const removedFolderWorkspaces = folderWorkspaces.filter((workspace) => {
const group = resolveFolderWorkspaceProjectGroup(projectGroupIndex, workspace)
const group = resolveFolderWorkspaceProjectGroupWithLegacySsh(projectGroupIndex, workspace)
return group && deletedGroupIdentities.has(getProjectGroupOwnerIdentity(group))
})
this.state.folderWorkspaces = folderWorkspaces.filter(
@@ -15,7 +15,7 @@ import {
import { getRepoExecutionHostId, type ExecutionHostId } from '../../shared/execution-host'
import type { FolderWorkspace, ProjectGroup, Repo } from '../../shared/types'
import {
resolveFolderWorkspaceCatalogOwnerHostId,
resolveFolderWorkspaceCatalogOwnerHostIdFromIndex,
resolveLegacySshFolderWorkspaceProjectGroup
} from '../../shared/folder-workspaces'
import type { IFilesystemProvider } from '../providers/types'
@@ -202,7 +202,8 @@ export function resolveFolderWorkspaceStatusPath(args: {
(entry) =>
entry.id === request.folderWorkspaceId &&
(!request.ownerHostId ||
resolveFolderWorkspaceCatalogOwnerHostId(entry, projectGroups) === request.ownerHostId)
resolveFolderWorkspaceCatalogOwnerHostIdFromIndex(entry, projectGroupIndex) ===
request.ownerHostId)
)
const workspace = matches.length === 1 ? matches[0] : null
if (!workspace) {
@@ -260,6 +260,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
const projectGroups = useAppStore((s) => s.projectGroups)
const newCardStyle = settings?.experimentalNewWorktreeCardStyle === true
const compactCards = !newCardStyle && settings?.compactWorktreeCards === true
const worktreeExecutionHostId = parseExecutionHostId(worktree.hostId)?.id
const handleEditIssue = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
@@ -268,6 +269,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
// Why: the same workspace ID can exist under two hosts. Naming the owner
// keeps the dialog on the clicked row instead of the ambiguous lookup.
repoId: worktree.repoId,
executionHostId: worktreeExecutionHostId,
currentDisplayName: worktree.displayName,
currentIssue: worktree.linkedIssue,
currentPR: worktree.linkedPR,
@@ -275,7 +277,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
focus: 'issue'
})
},
[worktree, openModal]
[worktree, openModal, worktreeExecutionHostId]
)
const handleEditComment = useCallback(
@@ -284,6 +286,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
openModal('edit-meta', {
worktreeId: worktree.id,
repoId: worktree.repoId,
executionHostId: worktreeExecutionHostId,
currentDisplayName: worktree.displayName,
currentIssue: worktree.linkedIssue,
currentPR: worktree.linkedPR,
@@ -291,7 +294,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
focus: 'comment'
})
},
[worktree, openModal]
[worktree, openModal, worktreeExecutionHostId]
)
const handleOpenAutomation = useCallback(
@@ -906,9 +909,13 @@ const WorktreeCard = React.memo(function WorktreeCard({
// Inline rename has no surface for the failure; the store already logs and
// refetches, which reverts the optimistic title in place.
async (displayName: string): Promise<void> => {
await updateWorktreeMeta(worktree.id, { displayName })
await updateWorktreeMeta(
worktree.id,
{ displayName },
{ executionHostId: worktreeExecutionHostId }
)
},
[updateWorktreeMeta, worktree.id]
[updateWorktreeMeta, worktree.id, worktreeExecutionHostId]
)
const handleDoubleClick = useCallback(
@@ -922,6 +929,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
openModal('edit-meta', {
worktreeId: worktree.id,
repoId: worktree.repoId,
executionHostId: worktreeExecutionHostId,
currentDisplayName: worktree.displayName,
currentIssue: worktree.linkedIssue,
currentPR: worktree.linkedPR,
@@ -936,7 +944,8 @@ const WorktreeCard = React.memo(function WorktreeCard({
worktree.id,
worktree.linkedIssue,
worktree.linkedPR,
worktree.repoId
worktree.repoId,
worktreeExecutionHostId
]
)
@@ -944,9 +953,13 @@ const WorktreeCard = React.memo(function WorktreeCard({
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
event.stopPropagation()
updateWorktreeMeta(worktree.id, { isUnread: !worktree.isUnread })
updateWorktreeMeta(
worktree.id,
{ isUnread: !worktree.isUnread },
{ executionHostId: worktreeExecutionHostId }
)
},
[worktree.id, worktree.isUnread, updateWorktreeMeta]
[worktree.id, worktree.isUnread, updateWorktreeMeta, worktreeExecutionHostId]
)
// Why: delete is destructive, so it only appears while holding Option/Alt, not in the ordinary hover chrome.
const showDeleteQuickAction =
@@ -962,7 +975,9 @@ const WorktreeCard = React.memo(function WorktreeCard({
event.stopPropagation()
if (showDeleteQuickAction) {
if (folderWorkspaceId) {
void deleteFolderWorkspace(folderWorkspaceId).then((deleted) => {
void deleteFolderWorkspace(folderWorkspaceId, {
ownerHostId: worktreeExecutionHostId
}).then((deleted) => {
if (
deleted &&
useAppStore.getState().activeWorktreeId === folderWorkspaceKey(folderWorkspaceId)
@@ -978,6 +993,7 @@ const WorktreeCard = React.memo(function WorktreeCard({
[
deleteFolderWorkspace,
folderWorkspaceId,
worktreeExecutionHostId,
setActiveWorktree,
showDeleteQuickAction,
worktree.id
@@ -1144,25 +1160,45 @@ const WorktreeCard = React.memo(function WorktreeCard({
const handleUnlinkReview = useCallback(() => {
switch (hoverReviewProvider) {
case 'github':
void updateWorktreeMeta(worktree.id, { linkedPR: null })
void updateWorktreeMeta(
worktree.id,
{ linkedPR: null },
{ executionHostId: worktreeExecutionHostId }
)
return
case 'gitlab':
void updateWorktreeMeta(worktree.id, { linkedGitLabMR: null })
void updateWorktreeMeta(
worktree.id,
{ linkedGitLabMR: null },
{ executionHostId: worktreeExecutionHostId }
)
return
case 'bitbucket':
void updateWorktreeMeta(worktree.id, { linkedBitbucketPR: null })
void updateWorktreeMeta(
worktree.id,
{ linkedBitbucketPR: null },
{ executionHostId: worktreeExecutionHostId }
)
return
case 'azure-devops':
void updateWorktreeMeta(worktree.id, { linkedAzureDevOpsPR: null })
void updateWorktreeMeta(
worktree.id,
{ linkedAzureDevOpsPR: null },
{ executionHostId: worktreeExecutionHostId }
)
return
case 'gitea':
void updateWorktreeMeta(worktree.id, { linkedGiteaPR: null })
void updateWorktreeMeta(
worktree.id,
{ linkedGiteaPR: null },
{ executionHostId: worktreeExecutionHostId }
)
break
case 'unsupported':
case undefined:
break
}
}, [hoverReviewProvider, updateWorktreeMeta, worktree.id])
}, [hoverReviewProvider, updateWorktreeMeta, worktree.id, worktreeExecutionHostId])
const handleOpenLinearIssueInOrca = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
@@ -371,7 +371,7 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
const moveProjectToGroup = useAppStore((s) => s.moveProjectToGroup)
const deleteFolderWorkspace = useAppStore((s) => s.deleteFolderWorkspace)
const setActiveWorktree = useAppStore((s) => s.setActiveWorktree)
const repo = useRepoById(worktree.repoId)
const repo = useRepoById(worktree.repoId, normalizeExecutionHostId(worktree.hostId) ?? undefined)
const projectGroupTargets = useMemo(
() => (repo ? getContextMenuProjectGroupTargets(repo, projectGroups) : null),
[projectGroups, repo]
@@ -603,8 +603,16 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
}, [worktree, updateWorktreeMeta])
const handleTogglePin = useCallback(() => {
if (parseWorkspaceKey(worktree.id)?.type === 'folder') {
void updateWorktreeMeta(
worktree.id,
{ isPinned: !worktree.isPinned },
getContextMenuWorktreeExecutionHost(worktree)
)
return
}
setWorktreesPinnedAndReveal([worktree.id], !worktree.isPinned)
}, [worktree.id, worktree.isPinned, setWorktreesPinnedAndReveal])
}, [setWorktreesPinnedAndReveal, updateWorktreeMeta, worktree])
const handleCreateGroupFromRepo = useCallback(() => {
if (!repo) {
@@ -299,6 +299,7 @@ import {
} from '../../../../shared/folder-workspace-path-status'
import {
getFolderWorkspaceRevealGroupKeys,
getFolderWorkspaceSidebarRowKey,
getKnownSidebarWorktreeById,
sidebarWorkspaceStillExists
} from './worktree-list-folder-reveal'
@@ -313,6 +314,7 @@ import { getRenderedWorktreesInSidebarOrder } from './worktree-sidebar-row-prefe
import { getCyclableWorktreeIds, resolveCycledWorktreeId } from './worktree-keyboard-cycle'
import {
buildProjectGroupSidebarIndex,
getAmbiguousFolderWorkspaceSidebarIds,
getProjectGroupMutationSelector,
getProjectGroupSidebarIdentity,
getSingleProjectGroupMutationOwner,
@@ -455,7 +457,10 @@ function getMountedWorktreeOptions(worktreeId: string, root?: ParentNode | null)
function markSidebarWorktreeActiveImmediately(worktreeId: string, primaryRowKey?: string): void {
const sidebar = document.querySelector<HTMLElement>('[data-worktree-sidebar]')
const nextOptions = getMountedWorktreeOptions(worktreeId, sidebar)
const matchingOptions = getMountedWorktreeOptions(worktreeId, sidebar)
const nextOptions = primaryRowKey?.startsWith('folder-workspace:')
? matchingOptions.filter((option) => option.dataset.worktreeRowKey === primaryRowKey)
: matchingOptions
const nextOption = nextOptions[0]
if (!nextOption) {
return
@@ -526,7 +531,7 @@ function getRenderRowSidebarKey(row: RenderRow): string | null {
return row.rowKey
}
if (row.type === 'folder-workspace') {
return folderWorkspaceKey(row.folderWorkspace.id)
return getFolderWorkspaceSidebarRowKey(row.folderWorkspace.id, row.key)
}
if (row.type === 'pending-creation') {
return `pending:${row.creationId}`
@@ -1085,12 +1090,20 @@ function isWorktreeItemRow(row: HostSectionRow): row is WorktreeItemRow {
return row.type === 'item'
}
export function renderRowContainsWorktree(row: RenderRow, worktreeId: string | null): boolean {
export function renderRowContainsWorktree(
row: RenderRow,
worktreeId: string | null,
ownerHostId?: ExecutionHostId
): boolean {
if (worktreeId === null) {
return false
}
if (row.type === 'folder-workspace') {
return folderWorkspaceKey(row.folderWorkspace.id) === worktreeId
return (
folderWorkspaceKey(row.folderWorkspace.id) === worktreeId &&
(!ownerHostId ||
getProjectGroupMutationSelector(row.projectGroup).ownerHostId === ownerHostId)
)
}
if (row.type === 'lineage-group') {
return row.rows.some((item) => item.worktree.id === worktreeId)
@@ -1124,13 +1137,14 @@ function getRenderRowOptionId(
return getWorktreeOptionId(row.rowKey)
}
if (row.type === 'folder-workspace') {
return getWorktreeOptionId(folderWorkspaceKey(row.folderWorkspace.id))
return getWorktreeOptionId(getFolderWorkspaceSidebarRowKey(row.folderWorkspace.id, row.key))
}
return undefined
}
function getActiveDescendantOptionId(args: {
activeWorktreeId: string | null
activeWorkspaceExecutionHostId?: ExecutionHostId | null
primaryActiveRowKey?: string
pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy
renderRows: readonly RenderRow[]
@@ -1151,7 +1165,14 @@ function getActiveDescendantOptionId(args: {
let fallbackOptionId: string | undefined
for (const item of args.virtualItems) {
const row = args.renderRows[item.index]
if (row && renderRowContainsWorktree(row, args.activeWorktreeId)) {
if (
row &&
renderRowContainsWorktree(
row,
args.activeWorktreeId,
args.activeWorkspaceExecutionHostId ?? undefined
)
) {
const optionId = getRenderRowOptionId(row, args.activeWorktreeId)
if (!optionId) {
continue
@@ -1173,12 +1194,13 @@ function getActiveDescendantOptionId(args: {
function findPreferredRenderRowIndexForWorktree(
renderRows: readonly RenderRow[],
worktreeId: string,
pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy
pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy,
ownerHostId?: ExecutionHostId
): number {
let fallbackIndex = -1
for (let index = 0; index < renderRows.length; index++) {
const row = renderRows[index]
if (!renderRowContainsWorktree(row, worktreeId)) {
if (!renderRowContainsWorktree(row, worktreeId, ownerHostId)) {
continue
}
if (fallbackIndex === -1) {
@@ -1405,6 +1427,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
scrollOffsetRef,
scrollAnchorRef
}: VirtualizedWorktreeViewportProps) {
const activeWorkspaceExecutionHostId = useAppStore((s) => s.activeWorkspaceExecutionHostId)
const ambiguousFolderWorkspaceIds = useMemo(
() =>
getAmbiguousFolderWorkspaceSidebarIds(
buildProjectGroupSidebarIndex(projectGroups),
folderWorkspaces
),
[folderWorkspaces, projectGroups]
)
const scrollRef = useRef<HTMLDivElement>(null)
const suppressMeasurementAdjustmentUntilRef = useRef(0)
const directScrollInputUntilRef = useRef(0)
@@ -2187,7 +2218,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const folderGroupKeys = getFolderWorkspaceRevealGroupKeys(
pendingRevealWorktree.worktreeId,
folderWorkspaces,
projectGroups
projectGroups,
pendingRevealWorktree.ownerHostId
)
if (folderGroupKeys.length > 0) {
for (const groupKey of folderGroupKeys) {
@@ -2249,12 +2281,15 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const targetWorktreeStillExists = sidebarWorkspaceStillExists(
pendingRevealWorktree.worktreeId,
worktrees,
folderWorkspaces
folderWorkspaces,
projectGroups,
pendingRevealWorktree.ownerHostId
)
const targetIndex = findPreferredRenderRowIndexForWorktree(
renderRows,
pendingRevealWorktree.worktreeId,
pinnedDisplayPolicy
pinnedDisplayPolicy,
pendingRevealWorktree.ownerHostId
)
const outcome = resolvePendingSidebarReveal({ targetIndex, targetWorktreeStillExists })
if (outcome === 'scroll-and-clear') {
@@ -3857,6 +3892,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
const activeDescendantId = getActiveDescendantOptionId({
activeWorktreeId,
activeWorkspaceExecutionHostId,
primaryActiveRowKey:
primaryActiveWorktreeRow?.worktreeId === activeWorktreeId
? primaryActiveWorktreeRow.rowKey
@@ -5128,12 +5164,26 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
if (row.type === 'folder-workspace') {
const folderWorkspaceRow = row as FolderWorkspaceItemRow
const folderWorktree = folderWorkspaceToWorktree(folderWorkspaceRow.folderWorkspace)
const folderOwnerHostId = getProjectGroupMutationSelector(
folderWorkspaceRow.projectGroup
).ownerHostId
const folderWorktree = {
...folderWorkspaceToWorktree(folderWorkspaceRow.folderWorkspace),
hostId: folderOwnerHostId
}
const folderSidebarRowKey = getFolderWorkspaceSidebarRowKey(
folderWorkspaceRow.folderWorkspace.id,
folderWorkspaceRow.key
)
const isFolderWorkspaceActive =
activeWorktreeId === folderWorktree.id &&
(activeWorkspaceExecutionHostId
? activeWorkspaceExecutionHostId === folderOwnerHostId
: !ambiguousFolderWorkspaceIds.has(folderWorkspaceRow.folderWorkspace.id))
const folderWorkspacePathStatus = getCachedFolderWorkspacePathStatus({
scope: 'folder-workspace',
folderWorkspaceId: folderWorkspaceRow.folderWorkspace.id,
ownerHostId: getProjectGroupMutationSelector(folderWorkspaceRow.projectGroup)
.ownerHostId
ownerHostId: folderOwnerHostId
})
const folderWorkspaceActivationDisabled =
folderWorkspacePathStatus?.exists === false &&
@@ -5161,12 +5211,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
return (
<div
key={vItem.key}
id={getWorktreeOptionId(folderWorktree.id)}
id={getWorktreeOptionId(folderSidebarRowKey)}
role="option"
aria-selected={selectedWorktreeIds.has(folderWorktree.id)}
aria-current={activeWorktreeId === folderWorktree.id ? 'page' : undefined}
aria-current={isFolderWorkspaceActive ? 'page' : undefined}
data-worktree-id={folderWorktree.id}
data-worktree-row-key={folderWorktree.id}
data-worktree-row-key={folderSidebarRowKey}
data-worktree-virtual-row
data-worktree-virtual-row-key={String(vItem.key)}
data-worktree-virtual-row-start={vItem.start}
@@ -5176,7 +5226,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
style={{ transform: getVirtualRowTransform(vItem.start) }}
onClickCapture={handleWorktreeRowClickCapture}
onPointerDown={(event) =>
handleWorktreeRowPointerDown(event, folderWorktree.id, folderWorktree.id)
handleWorktreeRowPointerDown(event, folderWorktree.id, folderSidebarRowKey)
}
>
<div
@@ -5186,8 +5236,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
<WorktreeCard
worktree={folderWorktree}
repo={undefined}
isActive={activeWorktreeId === folderWorktree.id}
isCurrentWorktree={currentWorktreeId === folderWorktree.id}
isActive={isFolderWorkspaceActive}
isCurrentWorktree={
currentWorktreeId === folderWorktree.id && isFolderWorkspaceActive
}
contentIndent={cardContentIndent}
flushSurface
nativeDragEnabled={false}
@@ -5196,7 +5248,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
? undefined
: handleImmediateWorktreeRowActivate
}
activationRowKey={folderWorktree.id}
activationRowKey={folderSidebarRowKey}
onSelectionGesture={onSelectionGesture}
onContextMenuSelect={onContextMenuSelect}
statusPrDisplay={folderPrDisplay}
@@ -5289,6 +5341,7 @@ const WorktreeList = React.memo(function WorktreeList({
const detectedWorktreesByRepo = useAppStore((s) => s.detectedWorktreesByRepo)
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
const activeWorkspaceKey = useAppStore((s) => s.activeWorkspaceKey)
const activeWorkspaceExecutionHostId = useAppStore((s) => s.activeWorkspaceExecutionHostId)
const currentSidebarWorktreeId = useMemo(
() => getActiveSidebarWorkspaceId(activeWorkspaceKey, activeWorktreeId),
[activeWorkspaceKey, activeWorktreeId]
@@ -5719,6 +5772,14 @@ const WorktreeList = React.memo(function WorktreeList({
() => buildProjectGroupSidebarIndex(projectGroups).ambiguousIds,
[projectGroups]
)
const ownerQualifiedFolderWorkspaceIds = useMemo(
() =>
getAmbiguousFolderWorkspaceSidebarIds(
buildProjectGroupSidebarIndex(projectGroups),
folderWorkspaces
),
[folderWorkspaces, projectGroups]
)
const visibleFolderWorkspacesForRows = useMemo(
() =>
filterFolderWorkspacesForVisibleHosts(
@@ -5843,7 +5904,8 @@ const WorktreeList = React.memo(function WorktreeList({
hostLabelById,
defaultHostId,
pinnedDisplayPolicy,
ownerQualifiedProjectGroupIds
ownerQualifiedProjectGroupIds,
ownerQualifiedFolderWorkspaceIds
),
[
groupBy,
@@ -5867,7 +5929,8 @@ const WorktreeList = React.memo(function WorktreeList({
pendingCreations,
hostLabelById,
pinnedDisplayPolicy,
ownerQualifiedProjectGroupIds
ownerQualifiedProjectGroupIds,
ownerQualifiedFolderWorkspaceIds
]
)
const orderedHostOptions = useMemo(
@@ -5925,7 +5988,7 @@ const WorktreeList = React.memo(function WorktreeList({
} else if (row.type === 'item') {
keys.add(row.rowKey)
} else if (row.type === 'folder-workspace') {
keys.add(folderWorkspaceKey(row.folderWorkspace.id))
keys.add(getFolderWorkspaceSidebarRowKey(row.folderWorkspace.id, row.key))
} else if (row.type === 'pending-creation') {
keys.add(`pending:${row.creationId}`)
} else if (row.type === 'imported-worktrees-card') {
@@ -6728,7 +6791,9 @@ const WorktreeList = React.memo(function WorktreeList({
const activeWorktree = getKnownSidebarWorktreeById(
currentSidebarWorktreeId,
worktreeMap,
folderWorkspaces
folderWorkspaces,
projectGroups,
activeWorkspaceExecutionHostId ?? undefined
)
if (!activeWorktree || activeWorktree.isArchived) {
return
@@ -6739,14 +6804,17 @@ const WorktreeList = React.memo(function WorktreeList({
}
revealWorktreeInSidebar(currentSidebarWorktreeId, {
behavior: 'smooth',
...(activeWorkspaceExecutionHostId ? { ownerHostId: activeWorkspaceExecutionHostId } : {}),
highlight: true,
beginRename: (detail as { beginRename?: boolean } | undefined)?.beginRename === true
})
},
[
clearFilters,
activeWorkspaceExecutionHostId,
currentSidebarWorktreeId,
folderWorkspaces,
projectGroups,
revealSidebarRow,
renderedWorktreeIds,
revealWorktreeInSidebar,
@@ -38,6 +38,16 @@ describe('Project Group header drag DOM source', () => {
expect(source).toContain('ownerHostId: getProjectGroupMutationSelector(group).ownerHostId')
})
it('routes folder DOM identity and active styling through the exact owner row', () => {
const source = readWorktreeListSource()
expect(source).toContain('id={getWorktreeOptionId(folderSidebarRowKey)}')
expect(source).toContain('data-worktree-row-key={folderSidebarRowKey}')
expect(source).toContain('activationRowKey={folderSidebarRowKey}')
expect(source).toContain('aria-current={isFolderWorkspaceActive')
expect(source).toContain('activeWorkspaceExecutionHostId === folderOwnerHostId')
})
it('keeps grab cursor on the title surface and dual handle attrs on row + surface', () => {
// Why: lock the cursor/hit-test split so a cleanup does not put grab back on the whole row
// or drop row-level handle attrs that arm drag from indent/padding.
@@ -173,7 +183,7 @@ function ownerHeaderGroup(ownerHostId: ExecutionHostId): ProjectGroup {
return {
id: 'same-id',
name: `${ownerHostId} group`,
parentPath: null,
parentPath: `/${ownerHostId}`,
executionHostId: ownerHostId,
parentGroupId: null,
createdFrom: 'manual',
@@ -225,7 +235,9 @@ function ownerHeaderWorktree(id: string, repoId: string): Worktree {
function buildOwnerRows(
owners: readonly ExecutionHostId[],
collapsed = new Set<string>(),
ownerQualifiedProjectGroupIds = new Set(['same-id'])
ownerQualifiedProjectGroupIds = new Set(['same-id']),
folderWorkspaces: readonly FolderWorkspace[] = [],
ownerQualifiedFolderWorkspaceIds = new Set<string>()
) {
const groups = owners.map(ownerHeaderGroup)
const repos = owners.map((owner, index) => ownerHeaderRepo(`repo-${index}`, owner))
@@ -248,11 +260,12 @@ function buildOwnerRows(
new Map(),
[],
undefined,
[],
folderWorkspaces,
undefined,
'local',
undefined,
ownerQualifiedProjectGroupIds
ownerQualifiedProjectGroupIds,
ownerQualifiedFolderWorkspaceIds
)
}
@@ -294,4 +307,34 @@ describe('duplicate project-group header identity (#12532)', () => {
const renderKeys = rows.map(getRenderRowKey)
expect(new Set(renderKeys).size).toBe(renderKeys.length)
})
it('keeps a collision-qualified folder key after filtering to one owner', () => {
const runtimeFolder: FolderWorkspace = {
id: 'same-folder',
projectGroupId: 'same-id',
name: 'Runtime folder',
folderPath: '/runtime/folder',
executionHostId: 'runtime:env-1',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
createdAt: 1,
updatedAt: 1
}
const rows = buildOwnerRows(
['runtime:env-1'],
new Set(),
new Set(['same-id']),
[runtimeFolder],
new Set(['same-folder'])
)
expect(rows.find((row) => row.type === 'folder-workspace')?.key).toBe(
'folder-workspace:runtime%3Aenv-1:same-folder'
)
})
})
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
import type { FolderWorkspace, ProjectGroup, Worktree } from '../../../../shared/types'
import { folderWorkspaceKey } from '../../../../shared/workspace-scope'
import {
getFolderWorkspaceSidebarRowKey,
getFolderWorkspaceRevealGroupKeys,
getKnownSidebarWorktreeById,
sidebarWorkspaceStillExists
@@ -116,4 +117,59 @@ describe('worktree list folder reveal', () => {
)
).toEqual([getProjectGroupHeaderKey(root.id), getProjectGroupHeaderKey(child.id)])
})
it('reveals only the exact owner when folder and group ids collide', () => {
const localRoot = makeProjectGroup({
id: 'group-root',
executionHostId: 'local'
})
const runtimeRoot = makeProjectGroup({
id: 'group-root',
executionHostId: 'runtime:env-1'
})
const localChild = makeProjectGroup({
id: 'group-child',
parentGroupId: localRoot.id,
executionHostId: 'local'
})
const runtimeChild = makeProjectGroup({
id: 'group-child',
parentGroupId: runtimeRoot.id,
executionHostId: 'runtime:env-1'
})
const localFolder = makeFolderWorkspace({ executionHostId: 'local' })
const runtimeFolder = makeFolderWorkspace({ executionHostId: 'runtime:env-1' })
const workspaceKey = folderWorkspaceKey(localFolder.id)
expect(
getFolderWorkspaceRevealGroupKeys(
workspaceKey,
[localFolder, runtimeFolder],
[localChild, runtimeChild, localRoot, runtimeRoot]
)
).toEqual([])
expect(
getFolderWorkspaceRevealGroupKeys(
workspaceKey,
[localFolder, runtimeFolder],
[localChild, runtimeChild, localRoot, runtimeRoot],
'runtime:env-1'
)
).toEqual([
getProjectGroupHeaderKey(runtimeRoot.id, 'runtime:env-1'),
getProjectGroupHeaderKey(runtimeChild.id, 'runtime:env-1')
])
})
it('uses qualified DOM row keys only for collision-qualified folder rows', () => {
expect(
getFolderWorkspaceSidebarRowKey('folder-workspace-1', 'folder-workspace:folder-workspace-1')
).toBe(folderWorkspaceKey('folder-workspace-1'))
expect(
getFolderWorkspaceSidebarRowKey(
'folder-workspace-1',
'folder-workspace:runtime%3Aenv-1:folder-workspace-1'
)
).toBe('folder-workspace:runtime%3Aenv-1:folder-workspace-1')
})
})
@@ -1,65 +1,115 @@
import type { FolderWorkspace, ProjectGroup, Worktree } from '../../../../shared/types'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree'
import { parseWorkspaceKey } from '../../../../shared/workspace-scope'
import { resolveFolderWorkspaceCatalogOwnerHostId } from '../../../../shared/folder-workspaces'
import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope'
import {
buildProjectGroupSidebarIndex,
findProjectGroupForSidebarOwner,
findProjectGroupParentForSidebar
} from './project-group-sidebar-identity'
import { getProjectGroupHeaderKey } from './worktree-list-groups'
function findFolderWorkspaceByKey(
worktreeId: string,
folderWorkspaces: readonly FolderWorkspace[]
folderWorkspaces: readonly FolderWorkspace[],
projectGroups: readonly ProjectGroup[] = [],
ownerHostId?: ExecutionHostId
): FolderWorkspace | null {
const scope = parseWorkspaceKey(worktreeId)
if (scope?.type !== 'folder') {
return null
}
return folderWorkspaces.find((workspace) => workspace.id === scope.folderWorkspaceId) ?? null
const candidates = folderWorkspaces.filter(
(workspace) => workspace.id === scope.folderWorkspaceId
)
if (ownerHostId) {
return (
candidates.find(
(workspace) =>
resolveFolderWorkspaceCatalogOwnerHostId(workspace, projectGroups) === ownerHostId
) ?? null
)
}
return candidates.length === 1 ? candidates[0] : null
}
export function getFolderWorkspaceSidebarRowKey(folderWorkspaceId: string, rowKey: string): string {
return rowKey === `folder-workspace:${folderWorkspaceId}`
? folderWorkspaceKey(folderWorkspaceId)
: rowKey
}
export function getKnownSidebarWorktreeById(
worktreeId: string,
worktreeMap: ReadonlyMap<string, Worktree>,
folderWorkspaces: readonly FolderWorkspace[]
folderWorkspaces: readonly FolderWorkspace[],
projectGroups: readonly ProjectGroup[] = [],
ownerHostId?: ExecutionHostId
): Worktree | null {
const worktree = worktreeMap.get(worktreeId)
if (worktree) {
return worktree
}
const folderWorkspace = findFolderWorkspaceByKey(worktreeId, folderWorkspaces)
const folderWorkspace = findFolderWorkspaceByKey(
worktreeId,
folderWorkspaces,
projectGroups,
ownerHostId
)
return folderWorkspace ? folderWorkspaceToWorktree(folderWorkspace) : null
}
export function sidebarWorkspaceStillExists(
worktreeId: string,
worktrees: readonly Worktree[],
folderWorkspaces: readonly FolderWorkspace[]
folderWorkspaces: readonly FolderWorkspace[],
projectGroups: readonly ProjectGroup[] = [],
ownerHostId?: ExecutionHostId
): boolean {
if (worktrees.some((worktree) => worktree.id === worktreeId)) {
return true
}
return findFolderWorkspaceByKey(worktreeId, folderWorkspaces) !== null
return findFolderWorkspaceByKey(worktreeId, folderWorkspaces, projectGroups, ownerHostId) !== null
}
export function getFolderWorkspaceRevealGroupKeys(
worktreeId: string,
folderWorkspaces: readonly FolderWorkspace[],
projectGroups: readonly ProjectGroup[]
projectGroups: readonly ProjectGroup[],
ownerHostId?: ExecutionHostId
): string[] {
const folderWorkspace = findFolderWorkspaceByKey(worktreeId, folderWorkspaces)
const folderWorkspace = findFolderWorkspaceByKey(
worktreeId,
folderWorkspaces,
projectGroups,
ownerHostId
)
if (!folderWorkspace) {
return []
}
const groupsById = new Map(projectGroups.map((group) => [group.id, group]))
const projectGroupIndex = buildProjectGroupSidebarIndex(projectGroups)
let group = findProjectGroupForSidebarOwner(
projectGroupIndex,
folderWorkspace.projectGroupId,
ownerHostId
)
const keys: string[] = []
const seen = new Set<string>()
let groupId: string | null = folderWorkspace.projectGroupId
while (groupId && !seen.has(groupId)) {
seen.add(groupId)
const group = groupsById.get(groupId)
if (!group) {
while (group) {
const identity = JSON.stringify([ownerHostId, group.id])
if (seen.has(identity)) {
break
}
keys.unshift(getProjectGroupHeaderKey(group.id))
groupId = group.parentGroupId
seen.add(identity)
keys.unshift(
getProjectGroupHeaderKey(
group.id,
projectGroupIndex.ambiguousIds.has(group.id) ? ownerHostId : undefined
)
)
group = findProjectGroupParentForSidebar(projectGroupIndex, group)
}
return keys
}
@@ -1059,7 +1059,8 @@ export function buildRows(
hostLabelById?: ReadonlyMap<string, string>,
defaultHostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID,
pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings),
ownerQualifiedProjectGroupIds: ReadonlySet<string> = new Set()
ownerQualifiedProjectGroupIds: ReadonlySet<string> = new Set(),
ownerQualifiedFolderWorkspaceIds: ReadonlySet<string> = new Set()
): Row[] {
const result: Row[] = []
const projectIndex = buildProjectGroupingIndex(projectGrouping)
@@ -1598,7 +1599,8 @@ export function buildRows(
key: getFolderWorkspaceRowKey(
folderWorkspace,
[projectGroup],
ambiguousFolderWorkspaceIds.has(folderWorkspace.id)
ambiguousFolderWorkspaceIds.has(folderWorkspace.id) ||
ownerQualifiedFolderWorkspaceIds.has(folderWorkspace.id)
),
folderWorkspace,
projectGroup,
@@ -3,6 +3,7 @@ import type { FolderWorkspace, ProjectGroup } from '../../../../shared/types'
import { addHostSectionRows } from './host-section-rows'
import type { Row } from './worktree-list-groups'
import {
filterFolderWorkspacesForVisibleHosts,
getFolderPathStatusRouteOptionsForRows,
getFolderWorkspaceExecutionHostIdForRows,
getProjectGroupExecutionHostIdForRows,
@@ -83,6 +84,30 @@ describe('WorktreeList host filtering ownership', () => {
expect(getRuntimeEnvironmentIdForFolderPathStatusHost('local')).toBeNull()
})
it('filters same-id legacy folders through their exact project-group owner', () => {
const localGroup = group({ id: 'same-group', executionHostId: 'local' })
const runtimeGroup = group({ id: 'same-group', executionHostId: 'runtime:env-1' })
const localFolder = folderWorkspace({
id: 'same-folder',
projectGroupId: localGroup.id,
connectionId: null
})
const runtimeFolder = folderWorkspace({
id: 'same-folder',
projectGroupId: runtimeGroup.id,
executionHostId: 'runtime:env-1'
})
expect(
filterFolderWorkspacesForVisibleHosts(
[localFolder, runtimeFolder],
[localGroup, runtimeGroup],
new Set(['local']),
'local'
)
).toEqual([localFolder])
})
it('routes project-group path status through the owning runtime', () => {
const runtimeGroup = group({ executionHostId: 'runtime:env-1' })
expect(
@@ -8,6 +8,10 @@ import {
} from '../../../../shared/execution-host'
import type { FolderWorkspacePathStatusRequest } from '../../../../shared/folder-workspace-path-status'
import type { FolderWorkspace, ProjectGroup } from '../../../../shared/types'
import {
buildProjectGroupSidebarIndex,
findProjectGroupForFolderWorkspace
} from './project-group-sidebar-identity'
/** null means "no host filter" — every host is visible. */
export function getVisibleSidebarHostIdSet(
@@ -44,12 +48,12 @@ export function filterFolderWorkspacesForVisibleHosts(
if (!visibleHostIdSet) {
return folderWorkspaces
}
const projectGroupById = new Map(projectGroups.map((group) => [group.id, group]))
const projectGroupIndex = buildProjectGroupSidebarIndex(projectGroups)
return folderWorkspaces.filter((folderWorkspace) =>
visibleHostIdSet.has(
getFolderWorkspaceExecutionHostIdForRows({
folderWorkspace,
projectGroup: projectGroupById.get(folderWorkspace.projectGroupId),
projectGroup: findProjectGroupForFolderWorkspace(projectGroupIndex, folderWorkspace),
defaultHostId
})
)
@@ -2,7 +2,11 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SetupScriptLaunchMode } from '../../../shared/types'
import { SETUP_AGENT_SEQUENCE_STARTUP_SCRIPT_ENV } from '../../../shared/setup-agent-sequencing'
import { activateAndRevealWorktree, ensureWorktreeHasInitialTerminal } from './worktree-activation'
import {
activateAndRevealFolderWorkspace,
activateAndRevealWorktree,
ensureWorktreeHasInitialTerminal
} from './worktree-activation'
import { resetHookCommandDelayedDeliveryForTests } from './hook-command-delayed-delivery'
import { useAppStore } from '@/store'
@@ -25,6 +29,7 @@ function setSetupScriptLaunchMode(mode: SetupScriptLaunchMode | null): void {
}
afterEach(() => {
vi.restoreAllMocks()
delete (globalThis as { __ORCA_WEB_CLIENT__?: boolean }).__ORCA_WEB_CLIENT__
useAppStore.setState((state) => ({
settings: state.settings
@@ -41,6 +46,49 @@ afterEach(() => {
} as Partial<AppStoreState>)
})
describe('activateAndRevealFolderWorkspace owner routing', () => {
it('reads path status from the exact folder owner cache', () => {
const getFreshFolderWorkspacePathStatus = vi.fn(() => ({
path: '/runtime/folder',
exists: false,
reason: 'missing' as const
}))
vi.spyOn(useAppStore, 'getState').mockReturnValue({
folderWorkspaces: [
{
id: 'same-id',
projectGroupId: 'same-group',
name: 'Local',
folderPath: '/local/folder',
executionHostId: 'local'
},
{
id: 'same-id',
projectGroupId: 'same-group',
name: 'Runtime',
folderPath: '/runtime/folder',
executionHostId: 'runtime:env-1'
}
],
projectGroups: [
{ id: 'same-group', executionHostId: 'local' },
{ id: 'same-group', executionHostId: 'runtime:env-1' }
],
getFreshFolderWorkspacePathStatus
} as never)
expect(
activateAndRevealFolderWorkspace('same-id', {
executionHostId: 'runtime:env-1'
})
).toBe(false)
expect(getFreshFolderWorkspacePathStatus).toHaveBeenCalledWith(
{ scope: 'folder-workspace', folderWorkspaceId: 'same-id' },
{ runtimeEnvironmentId: 'env-1', ownerHostId: 'runtime:env-1' }
)
})
})
function createMockStore(overrides: Record<string, unknown> = {}) {
return {
tabsByWorktree: {} as Record<string, { id: string }[]>,
+19 -6
View File
@@ -58,7 +58,11 @@ import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcrip
import { seedNativeChatAppliedSessionOptions } from '@/components/native-chat/native-chat-session-option-cache'
import type { SessionOptionValue } from '../../../shared/native-chat-session-options'
import type { ExecutionHostId } from '../../../shared/execution-host'
import { findFolderWorkspaceOwner } from './folder-workspace-runtime-owner'
import { resolveFolderWorkspaceCatalogOwnerHostId } from '../../../shared/folder-workspaces'
import {
findFolderWorkspaceOwner,
getRuntimeEnvironmentIdForFolderWorkspace
} from './folder-workspace-runtime-owner'
/** Telemetry threaded from the launch site to `pty:spawn`; main fires `agent_started`
* only after the spawn succeeds. See telemetry-plan.md§Agent launch semantics. */
@@ -225,16 +229,22 @@ export function activateAndRevealFolderWorkspace(
if (!folderWorkspace) {
return false
}
const ownerHostId =
opts?.executionHostId ??
resolveFolderWorkspaceCatalogOwnerHostId(folderWorkspace, state.projectGroups)
if (!ownerHostId) {
return false
}
const runtimeEnvironmentId =
opts && 'runtimeEnvironmentId' in opts
? (opts.runtimeEnvironmentId ?? null)
: getRuntimeEnvironmentIdForWorktree(state, folderWorkspaceKey(folderWorkspaceId))
: getRuntimeEnvironmentIdForFolderWorkspace(state, folderWorkspaceId, ownerHostId)
const pathStatus = state.getFreshFolderWorkspacePathStatus(
{
scope: 'folder-workspace',
folderWorkspaceId
},
{ runtimeEnvironmentId }
{ runtimeEnvironmentId, ownerHostId }
)
if (folderWorkspaceActivationBlocked(pathStatus)) {
const title =
@@ -253,7 +263,7 @@ export function activateAndRevealFolderWorkspace(
state.setActiveView('terminal')
}
state.setActiveFolderWorkspace(folderWorkspaceId, opts?.executionHostId)
state.setActiveFolderWorkspace(folderWorkspaceId, ownerHostId)
const workspaceKey = folderWorkspaceKey(folderWorkspaceId)
state.markWorktreeVisited(workspaceKey)
@@ -264,9 +274,12 @@ export function activateAndRevealFolderWorkspace(
const primaryTabId = ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup)
if (opts?.sidebarRevealBehavior) {
state.revealWorktreeInSidebar(workspaceKey, { behavior: opts.sidebarRevealBehavior })
state.revealWorktreeInSidebar(workspaceKey, {
behavior: opts.sidebarRevealBehavior,
ownerHostId
})
} else {
state.revealWorktreeInSidebar(workspaceKey)
state.revealWorktreeInSidebar(workspaceKey, { ownerHostId })
}
return { primaryTabId }
+24
View File
@@ -10,6 +10,7 @@ import {
getWorktreeMapFromState,
resetFloatingVisibleTabCountSelectorCacheForTest,
selectRepoByIdForActiveWorkspace,
selectRepoByIdForExecutionHost,
selectFloatingVisibleTabCount,
selectFloatingWorkspaceHasUnread
} from './selectors'
@@ -319,6 +320,29 @@ describe('store selectors', () => {
expect(activeRepo ? isGitRepoKind(activeRepo) : false).toBe(true)
})
it('resolves an inactive duplicate repo from the clicked row host', () => {
const local = makeRepo({
id: 'same-repo',
path: '/local/repo',
displayName: 'local',
executionHostId: 'local'
})
const runtime = makeRepo({
id: 'same-repo',
path: '/runtime/repo',
displayName: 'runtime',
executionHostId: toRuntimeExecutionHostId('env-1')
})
expect(
selectRepoByIdForExecutionHost(
{ repos: [local, runtime] },
'same-repo',
toRuntimeExecutionHostId('env-1')
)
).toBe(runtime)
})
it('fails a paired-hub repo fallback closed when rival HUB rows share the repo ID', () => {
const repoForHub = (environmentId: string) =>
makeRepo({
+29 -18
View File
@@ -187,28 +187,39 @@ export function selectRepoByIdForActiveWorkspace(
}
const repo = getCachedRepoMap(state.repos).get(repoId) ?? null
if (repoId === state.activeRepoId && state.activeWorkspaceExecutionHostId) {
const repoCandidates = state.repos.filter((candidate) => candidate.id === repoId)
const hostMatch = repoCandidates.find(
(candidate) => getRepoExecutionHostId(candidate) === state.activeWorkspaceExecutionHostId
)
if (hostMatch) {
return hostMatch
}
// Why: withRepoHostOwnership keeps a paired-hub worktree on its own SSH host while the repo
// stays hub-owned, so that one mismatch still names the right repo; every other stays closed.
if (parseExecutionHostId(state.activeWorkspaceExecutionHostId)?.kind !== 'ssh') {
return null
}
const pairedHubRepos = repoCandidates.filter(
(candidate) => parseExecutionHostId(getRepoExecutionHostId(candidate))?.kind === 'runtime'
)
return pairedHubRepos.length === 1 ? pairedHubRepos[0] : null
return selectRepoByIdForExecutionHost(state, repoId, state.activeWorkspaceExecutionHostId)
}
return repo
}
export const useRepoById = (repoId: string | null) =>
useAppStore((s) => selectRepoByIdForActiveWorkspace(s, repoId))
export function selectRepoByIdForExecutionHost(
state: Pick<AppState, 'repos'>,
repoId: string,
executionHostId: ExecutionHostId
): Repo | null {
const repoCandidates = state.repos.filter((candidate) => candidate.id === repoId)
const hostMatch = repoCandidates.find(
(candidate) => getRepoExecutionHostId(candidate) === executionHostId
)
if (hostMatch) {
return hostMatch
}
// Why: paired-hub worktrees can name a private SSH host while their repo remains hub-owned.
if (parseExecutionHostId(executionHostId)?.kind !== 'ssh') {
return null
}
const pairedHubRepos = repoCandidates.filter(
(candidate) => parseExecutionHostId(getRepoExecutionHostId(candidate))?.kind === 'runtime'
)
return pairedHubRepos.length === 1 ? pairedHubRepos[0] : null
}
export const useRepoById = (repoId: string | null, executionHostId?: ExecutionHostId) =>
useAppStore((s) =>
repoId && executionHostId
? selectRepoByIdForExecutionHost(s, repoId, executionHostId)
: selectRepoByIdForActiveWorkspace(s, repoId)
)
export const useProjectHostSetupProjection = () =>
useAppStore((s) => getProjectHostSetupProjectionFromState(s))
@@ -166,4 +166,19 @@ describe('selectProjectGroupRemovalTargets', () => {
])
expect(result.folderWorkspaceIdentities).toEqual(new Set(['["runtime:env-1","same-folder"]']))
})
it('removes a legacy SSH folder associated with one unstamped group', () => {
const legacyGroup = { ...rootGroup, connectionId: undefined }
const legacyFolder = {
...makeFolderWorkspace('legacy-folder', legacyGroup.id, 'local'),
executionHostId: undefined,
connectionId: 'builder'
}
const result = selectProjectGroupRemovalTargets([legacyGroup], [], legacyGroup.id, 'local', [
legacyFolder
])
expect(result.folderWorkspaceIdentities).toEqual(new Set(['["local","legacy-folder"]']))
})
})
@@ -6,10 +6,10 @@ import {
getProjectGroupOwnerHostId,
getProjectGroupOwnerIdentity,
getProjectGroupOwnerSubtreeIdentities,
resolveFolderWorkspaceProjectGroup,
resolveProjectGroupOwner,
type ProjectGroupOwnerIndex
} from '../../../../shared/project-groups'
import { resolveFolderWorkspaceProjectGroupWithLegacySsh } from '../../../../shared/folder-workspace-project-group-resolution'
import { getRepoHostIdentity } from './repo-host-identity'
export type ProjectGroupRemovalProjectTarget = {
@@ -32,7 +32,7 @@ export function getProjectGroupRemovalFolderWorkspaceIdentity(
index: ProjectGroupOwnerIndex,
workspace: Pick<FolderWorkspace, 'id' | 'projectGroupId' | 'connectionId' | 'executionHostId'>
): string | null {
const group = resolveFolderWorkspaceProjectGroup(index, workspace)
const group = resolveFolderWorkspaceProjectGroupWithLegacySsh(index, workspace)
return group ? JSON.stringify([getProjectGroupOwnerHostId(group), workspace.id]) : null
}
@@ -88,7 +88,7 @@ export function selectProjectGroupRemovalTargets(
}
const folderWorkspaceIdentities = new Set<string>()
for (const workspace of folderWorkspaces) {
const group = resolveFolderWorkspaceProjectGroup(index, workspace)
const group = resolveFolderWorkspaceProjectGroupWithLegacySsh(index, workspace)
if (!group || !deletedGroupIdentities.has(getProjectGroupOwnerIdentity(group))) {
continue
}
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ProjectGroup, Repo } from '../../../../shared/types'
import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types'
import { normalizeExecutionHostId } from '../../../../shared/execution-host'
import { createTestStore } from './store-test-helpers'
function group(index: number): ProjectGroup {
@@ -55,4 +56,48 @@ describe('project-group path-status catalog cache', () => {
expect(pathReads).toBe(readsAfterFirstSnapshot)
expect(getPathStatus).toHaveBeenCalledTimes(2)
})
it('indexes a large same-id folder catalog once per status read', () => {
let groupIdReads = 0
const projectGroups: ProjectGroup[] = Array.from(
{ length: 512 },
(_, index): ProjectGroup => ({
...group(index),
get id() {
groupIdReads += 1
return `group-${index}`
}
})
)
const folderWorkspaces = projectGroups.map(
(projectGroup, index) =>
({
id: 'same-folder',
projectGroupId: projectGroup.id,
name: `Folder ${index}`,
folderPath: `/workspace/${index}`,
executionHostId: normalizeExecutionHostId(projectGroup.executionHostId),
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: index,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}) satisfies FolderWorkspace
)
groupIdReads = 0
const store = createTestStore()
store.setState({ projectGroups, folderWorkspaces })
store.getState().getFreshFolderWorkspacePathStatus({
scope: 'folder-workspace',
folderWorkspaceId: 'same-folder',
ownerHostId: 'runtime:env-1'
})
expect(groupIdReads).toBeLessThan(4_096)
})
})
+3 -2
View File
@@ -103,7 +103,8 @@ import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/worksp
import {
getFolderWorkspaceIdentity,
getFolderWorkspaceOwnerIdentity,
resolveFolderWorkspaceCatalogOwnerHostId
resolveFolderWorkspaceCatalogOwnerHostId,
resolveFolderWorkspaceCatalogOwnerHostIdFromIndex
} from '../../../../shared/folder-workspaces'
import { formatFolderWorkspaceCreateError } from '../../lib/folder-workspace-path-status'
import { getEnvironmentSshStateGeneration } from './runtime-environment-ssh'
@@ -1569,7 +1570,7 @@ function getFolderWorkspaceStatusRequestSnapshot(
(workspace) =>
workspace.id === request.folderWorkspaceId &&
(!request.ownerHostId ||
resolveFolderWorkspaceCatalogOwnerHostId(workspace, state.projectGroups) ===
resolveFolderWorkspaceCatalogOwnerHostIdFromIndex(workspace, projectGroupIndex) ===
request.ownerHostId)
)
: []
+2
View File
@@ -1298,6 +1298,7 @@ describe('createUISlice hydratePersistedUI', () => {
store.getState().revealWorktreeInSidebar('repo1::/feature', {
behavior: 'smooth',
ownerHostId: 'runtime:env-1',
highlight: true,
beginRename: true
})
@@ -1305,6 +1306,7 @@ describe('createUISlice hydratePersistedUI', () => {
expect(store.getState().pendingRevealWorktree).toEqual({
worktreeId: 'repo1::/feature',
behavior: 'smooth',
ownerHostId: 'runtime:env-1',
highlight: true,
beginRename: true
})
+3
View File
@@ -137,6 +137,7 @@ import { getRepoHostIdentity } from './repo-host-identity'
export type PendingSidebarWorktreeReveal = {
worktreeId: string
behavior: 'auto' | 'smooth'
ownerHostId?: ExecutionHostId
highlight?: boolean
beginRename?: boolean
}
@@ -962,6 +963,7 @@ export type UISlice = {
worktreeId: string,
options?: {
behavior?: PendingSidebarWorktreeReveal['behavior']
ownerHostId?: ExecutionHostId
highlight?: boolean
beginRename?: boolean
}
@@ -2372,6 +2374,7 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
pendingRevealWorktree: {
worktreeId,
behavior: options?.behavior ?? 'smooth',
...(options?.ownerHostId ? { ownerHostId: options.ownerHostId } : {}),
...(options?.highlight ? { highlight: true } : {}),
...(options?.beginRename ? { beginRename: true } : {})
}
@@ -0,0 +1,23 @@
import { normalizeExecutionHostId } from './execution-host'
import {
resolveFolderWorkspaceProjectGroup,
resolveProjectGroupOwner,
type ProjectGroupOwnerIndex
} from './project-groups'
import type { FolderWorkspace, ProjectGroup } from './types'
export function resolveFolderWorkspaceProjectGroupWithLegacySsh(
index: ProjectGroupOwnerIndex,
workspace: Pick<FolderWorkspace, 'connectionId' | 'executionHostId' | 'projectGroupId'>
): ProjectGroup | null {
const strict = resolveFolderWorkspaceProjectGroup(index, workspace)
if (strict || !workspace.connectionId) {
return strict
}
const group = resolveProjectGroupOwner(index, workspace.projectGroupId)
return group &&
group.connectionId === undefined &&
!normalizeExecutionHostId(group.executionHostId)
? group
: null
}
+18 -18
View File
@@ -8,9 +8,9 @@ import {
buildProjectGroupOwnerIndex,
getFolderWorkspaceProjectGroupOwnerHostId,
resolveFolderWorkspaceProjectGroup,
resolveProjectGroupOwner,
type ProjectGroupOwnerIndex
} from './project-groups'
import { resolveFolderWorkspaceProjectGroupWithLegacySsh } from './folder-workspace-project-group-resolution'
import type { FolderWorkspace, ProjectGroup } from './types'
import { isTuiAgent } from './tui-agent-config'
import { normalizeStoredTaskSourceContext } from './task-source-context'
@@ -21,15 +21,7 @@ export function resolveLegacySshFolderWorkspaceProjectGroup(
index: ProjectGroupOwnerIndex,
workspace: Pick<FolderWorkspace, 'connectionId' | 'projectGroupId'>
): ProjectGroup | null {
if (!workspace.connectionId) {
return null
}
const group = resolveProjectGroupOwner(index, workspace.projectGroupId)
return group &&
group.connectionId === undefined &&
!normalizeExecutionHostId(group.executionHostId)
? group
: null
return resolveFolderWorkspaceProjectGroupWithLegacySsh(index, workspace)
}
export function getFolderWorkspaceCatalogOwnerHostId(
@@ -51,14 +43,7 @@ export function resolveFolderWorkspaceCatalogOwnerHostId(
}
if (projectGroups.length > 0) {
const index = buildProjectGroupOwnerIndex(projectGroups)
const group = resolveFolderWorkspaceProjectGroup(index, workspace)
if (group) {
return getFolderWorkspaceProjectGroupOwnerHostId(workspace, index)
}
if (workspace.connectionId) {
return toSshExecutionHostId(workspace.connectionId)
}
return null
return resolveFolderWorkspaceCatalogOwnerHostIdFromIndex(workspace, index)
}
if (workspace.connectionId) {
return toSshExecutionHostId(workspace.connectionId)
@@ -69,6 +54,21 @@ export function resolveFolderWorkspaceCatalogOwnerHostId(
return LOCAL_EXECUTION_HOST_ID
}
export function resolveFolderWorkspaceCatalogOwnerHostIdFromIndex(
workspace: Pick<FolderWorkspace, 'connectionId' | 'executionHostId' | 'projectGroupId'>,
index: ProjectGroupOwnerIndex
): ExecutionHostId | null {
const executionHostId = normalizeExecutionHostId(workspace.executionHostId)
if (executionHostId) {
return executionHostId
}
const group = resolveFolderWorkspaceProjectGroupWithLegacySsh(index, workspace)
if (group) {
return getFolderWorkspaceProjectGroupOwnerHostId(workspace, index)
}
return workspace.connectionId ? toSshExecutionHostId(workspace.connectionId) : null
}
export function getFolderWorkspaceOwnerIdentity(
workspace: Pick<FolderWorkspace, 'id' | 'connectionId' | 'executionHostId' | 'projectGroupId'>,
projectGroups: readonly ProjectGroup[] = []
+10 -1
View File
@@ -165,7 +165,16 @@ describe('project-groups', () => {
})
it('retains a legacy SSH folder owner with one unstamped group', () => {
const legacyGroup = projectGroup({ id: 'legacy', name: 'Legacy', parentPath: '/legacy' })
const legacyGroup = normalizeProjectGroups([
{
id: 'legacy',
name: 'Legacy',
parentPath: '/legacy',
createdFrom: 'migration',
tabOrder: 0
}
])[0]!
expect(legacyGroup.connectionId).toBeUndefined()
const index = buildProjectGroupOwnerIndex([legacyGroup])
const workspace = { projectGroupId: 'legacy', connectionId: 'builder' }
+3 -6
View File
@@ -198,12 +198,9 @@ export function normalizeProjectGroups(value: unknown): ProjectGroup[] {
id: raw.id,
name: normalizeProjectGroupName(typeof raw.name === 'string' ? raw.name : ''),
parentPath: typeof raw.parentPath === 'string' ? raw.parentPath : null,
connectionId:
typeof raw.connectionId === 'string'
? raw.connectionId
: raw.connectionId === null
? null
: null,
...(typeof raw.connectionId === 'string' || raw.connectionId === null
? { connectionId: raw.connectionId }
: {}),
parentGroupId: typeof raw.parentGroupId === 'string' ? raw.parentGroupId : null,
createdFrom:
raw.createdFrom === 'manual' ||