From de06dbb4f2e2e27da7fa21592432439b985649db Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:22:43 -0700 Subject: [PATCH] fix(sidebar): preserve folder workspace owner identity --- src/main/ipc/repos.ts | 4 +- src/main/persistence.test.ts | 50 ++++++++ src/main/persistence.ts | 6 +- .../folder-workspace-path-status.ts | 5 +- .../src/components/sidebar/WorktreeCard.tsx | 64 +++++++--- .../sidebar/WorktreeContextMenu.tsx | 12 +- .../src/components/sidebar/WorktreeList.tsx | 118 ++++++++++++++---- .../sidebar/project-group-header-dom.test.ts | 51 +++++++- .../worktree-list-folder-reveal.test.ts | 56 +++++++++ .../sidebar/worktree-list-folder-reveal.ts | 84 ++++++++++--- .../sidebar/worktree-list-groups.ts | 6 +- .../worktree-list-host-filtering.test.ts | 25 ++++ .../sidebar/worktree-list-host-filtering.ts | 8 +- .../src/lib/worktree-activation.test.ts | 50 +++++++- src/renderer/src/lib/worktree-activation.ts | 25 +++- src/renderer/src/store/selectors.test.ts | 24 ++++ src/renderer/src/store/selectors.ts | 47 ++++--- .../project-group-removal-targets.test.ts | 15 +++ .../slices/project-group-removal-targets.ts | 6 +- ...pos-project-group-path-status-perf.test.ts | 47 ++++++- src/renderer/src/store/slices/repos.ts | 5 +- src/renderer/src/store/slices/ui.test.ts | 2 + src/renderer/src/store/slices/ui.ts | 3 + ...lder-workspace-project-group-resolution.ts | 23 ++++ src/shared/folder-workspaces.ts | 36 +++--- src/shared/project-groups.test.ts | 11 +- src/shared/project-groups.ts | 9 +- 27 files changed, 663 insertions(+), 129 deletions(-) create mode 100644 src/shared/folder-workspace-project-group-resolution.ts diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index 444d3f69d3c..3150bfe8e57 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -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 } diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index acff6cb7128..a470aac4aa1 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -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, diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 864a3e3ea3a..a8583238c38 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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( diff --git a/src/main/project-groups/folder-workspace-path-status.ts b/src/main/project-groups/folder-workspace-path-status.ts index b1a09381d01..d966fb7fef2 100644 --- a/src/main/project-groups/folder-workspace-path-status.ts +++ b/src/main/project-groups/folder-workspace-path-status.ts @@ -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) { diff --git a/src/renderer/src/components/sidebar/WorktreeCard.tsx b/src/renderer/src/components/sidebar/WorktreeCard.tsx index b53a03ec833..02bb0227c46 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.tsx @@ -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 => { - 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) => { 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() diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index fc840aaf6be..9590136e989 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -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) { diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 5cfe972e04a..f6cead0c028 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -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('[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(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 (
- handleWorktreeRowPointerDown(event, folderWorktree.id, folderWorktree.id) + handleWorktreeRowPointerDown(event, folderWorktree.id, folderSidebarRowKey) } >
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, diff --git a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts index 3198e058cb2..ee329bbb642 100644 --- a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts +++ b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts @@ -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(), - ownerQualifiedProjectGroupIds = new Set(['same-id']) + ownerQualifiedProjectGroupIds = new Set(['same-id']), + folderWorkspaces: readonly FolderWorkspace[] = [], + ownerQualifiedFolderWorkspaceIds = new Set() ) { 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' + ) + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list-folder-reveal.test.ts b/src/renderer/src/components/sidebar/worktree-list-folder-reveal.test.ts index 9b472ab7be7..3f449a4542e 100644 --- a/src/renderer/src/components/sidebar/worktree-list-folder-reveal.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-folder-reveal.test.ts @@ -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') + }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list-folder-reveal.ts b/src/renderer/src/components/sidebar/worktree-list-folder-reveal.ts index 21c70db9384..1ccd2079deb 100644 --- a/src/renderer/src/components/sidebar/worktree-list-folder-reveal.ts +++ b/src/renderer/src/components/sidebar/worktree-list-folder-reveal.ts @@ -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, - 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() - 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 } diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index 684bed8222c..6b7b2a6b588 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -1059,7 +1059,8 @@ export function buildRows( hostLabelById?: ReadonlyMap, defaultHostId: ExecutionHostId = LOCAL_EXECUTION_HOST_ID, pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy = getPinnedWorktreeDisplayPolicy(settings), - ownerQualifiedProjectGroupIds: ReadonlySet = new Set() + ownerQualifiedProjectGroupIds: ReadonlySet = new Set(), + ownerQualifiedFolderWorkspaceIds: ReadonlySet = 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, diff --git a/src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts b/src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts index 152fc6176aa..3890f60e11b 100644 --- a/src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-host-filtering.test.ts @@ -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( diff --git a/src/renderer/src/components/sidebar/worktree-list-host-filtering.ts b/src/renderer/src/components/sidebar/worktree-list-host-filtering.ts index 62f788269d0..3690bed120b 100644 --- a/src/renderer/src/components/sidebar/worktree-list-host-filtering.ts +++ b/src/renderer/src/components/sidebar/worktree-list-host-filtering.ts @@ -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 }) ) diff --git a/src/renderer/src/lib/worktree-activation.test.ts b/src/renderer/src/lib/worktree-activation.test.ts index 923cecd5aed..8480c6350e4 100644 --- a/src/renderer/src/lib/worktree-activation.test.ts +++ b/src/renderer/src/lib/worktree-activation.test.ts @@ -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) }) +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 = {}) { return { tabsByWorktree: {} as Record, diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index d27a18565ef..d483c3dd0b2 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -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 } diff --git a/src/renderer/src/store/selectors.test.ts b/src/renderer/src/store/selectors.test.ts index 345a25c0eec..68e2986a4f9 100644 --- a/src/renderer/src/store/selectors.test.ts +++ b/src/renderer/src/store/selectors.test.ts @@ -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({ diff --git a/src/renderer/src/store/selectors.ts b/src/renderer/src/store/selectors.ts index b955666760f..e41b2133859 100644 --- a/src/renderer/src/store/selectors.ts +++ b/src/renderer/src/store/selectors.ts @@ -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, + 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)) diff --git a/src/renderer/src/store/slices/project-group-removal-targets.test.ts b/src/renderer/src/store/slices/project-group-removal-targets.test.ts index 1bff1649342..9ed8c86b058 100644 --- a/src/renderer/src/store/slices/project-group-removal-targets.test.ts +++ b/src/renderer/src/store/slices/project-group-removal-targets.test.ts @@ -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"]'])) + }) }) diff --git a/src/renderer/src/store/slices/project-group-removal-targets.ts b/src/renderer/src/store/slices/project-group-removal-targets.ts index 6fac496b239..b3835d57152 100644 --- a/src/renderer/src/store/slices/project-group-removal-targets.ts +++ b/src/renderer/src/store/slices/project-group-removal-targets.ts @@ -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 ): 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() for (const workspace of folderWorkspaces) { - const group = resolveFolderWorkspaceProjectGroup(index, workspace) + const group = resolveFolderWorkspaceProjectGroupWithLegacySsh(index, workspace) if (!group || !deletedGroupIdentities.has(getProjectGroupOwnerIdentity(group))) { continue } diff --git a/src/renderer/src/store/slices/repos-project-group-path-status-perf.test.ts b/src/renderer/src/store/slices/repos-project-group-path-status-perf.test.ts index bfc22e2ed70..18181ccb601 100644 --- a/src/renderer/src/store/slices/repos-project-group-path-status-perf.test.ts +++ b/src/renderer/src/store/slices/repos-project-group-path-status-perf.test.ts @@ -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) + }) }) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 74f739808bb..53ef4a761db 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -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) ) : [] diff --git a/src/renderer/src/store/slices/ui.test.ts b/src/renderer/src/store/slices/ui.test.ts index b8bccc235a4..7ffae75ef9d 100644 --- a/src/renderer/src/store/slices/ui.test.ts +++ b/src/renderer/src/store/slices/ui.test.ts @@ -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 }) diff --git a/src/renderer/src/store/slices/ui.ts b/src/renderer/src/store/slices/ui.ts index e1b9c89e6ab..07a82c7b0de 100644 --- a/src/renderer/src/store/slices/ui.ts +++ b/src/renderer/src/store/slices/ui.ts @@ -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 = (set, get) pendingRevealWorktree: { worktreeId, behavior: options?.behavior ?? 'smooth', + ...(options?.ownerHostId ? { ownerHostId: options.ownerHostId } : {}), ...(options?.highlight ? { highlight: true } : {}), ...(options?.beginRename ? { beginRename: true } : {}) } diff --git a/src/shared/folder-workspace-project-group-resolution.ts b/src/shared/folder-workspace-project-group-resolution.ts new file mode 100644 index 00000000000..1ca2e6754b6 --- /dev/null +++ b/src/shared/folder-workspace-project-group-resolution.ts @@ -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 +): 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 +} diff --git a/src/shared/folder-workspaces.ts b/src/shared/folder-workspaces.ts index acbc6dcb376..51f794b9ad4 100644 --- a/src/shared/folder-workspaces.ts +++ b/src/shared/folder-workspaces.ts @@ -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 ): 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, + 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, projectGroups: readonly ProjectGroup[] = [] diff --git a/src/shared/project-groups.test.ts b/src/shared/project-groups.test.ts index 7e0d494d284..bb18f0ab98b 100644 --- a/src/shared/project-groups.test.ts +++ b/src/shared/project-groups.test.ts @@ -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' } diff --git a/src/shared/project-groups.ts b/src/shared/project-groups.ts index 4826a8b8b9c..720c529695f 100644 --- a/src/shared/project-groups.ts +++ b/src/shared/project-groups.ts @@ -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' ||