From eff5032569b942aa227769a6407748c016f7dfc6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:09:28 -0700 Subject: [PATCH] fix(sidebar): isolate duplicate folder sessions --- src/main/persistence.test.ts | 11 + src/main/persistence.ts | 59 +++-- .../components/NewWorkspaceComposerModal.tsx | 3 - .../src/components/sidebar/WorktreeList.tsx | 25 ++- .../components/sidebar/WorktreeMetaDialog.tsx | 15 +- .../sidebar/project-group-header-dom.test.ts | 91 -------- .../sidebar/use-worktree-meta-workspace.ts | 12 +- .../worktree-list-folder-reveal.test.ts | 9 +- .../sidebar/worktree-list-folder-reveal.ts | 17 +- .../sidebar/worktree-list-groups.ts | 26 ++- .../worktree-list-host-filtering.test.ts | 165 +++++++++++++- src/renderer/src/hooks/useComposerState.ts | 8 +- .../src/lib/folder-workspace-connection.ts | 7 +- .../lib/workspace-session-host-persistence.ts | 8 +- src/renderer/src/lib/worktree-activation.ts | 22 +- .../repos-project-groups-delete.test.ts | 10 +- ...-same-id-folder-workspace-identity.test.ts | 172 ++++++++++++++- src/renderer/src/store/slices/repos.ts | 65 ++++-- src/renderer/src/store/slices/worktrees.ts | 205 +++++++++++++++--- src/shared/workspace-scope.ts | 36 ++- 20 files changed, 739 insertions(+), 227 deletions(-) diff --git a/src/main/persistence.test.ts b/src/main/persistence.test.ts index 771ba1c8b1f..92375cdc334 100644 --- a/src/main/persistence.test.ts +++ b/src/main/persistence.test.ts @@ -3798,6 +3798,15 @@ describe('Store', () => { ] }) const store = await createStore() + const localKey = folderWorkspaceKey('same-folder', 'local') + const sshKey = folderWorkspaceKey('same-folder', 'ssh:builder') + store.setWorkspaceSession({ + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [localKey]: [makeTerminalTab({ id: 'local-tab', worktreeId: localKey })], + [sshKey]: [makeTerminalTab({ id: 'ssh-tab', worktreeId: sshKey })] + } + }) expect(store.getFolderWorkspace('same-folder')).toBeUndefined() expect(store.updateFolderWorkspace('same-folder', { name: 'Ambiguous' })).toBeNull() @@ -3807,6 +3816,8 @@ describe('Store', () => { expect(store.removeFolderWorkspace('same-folder')).toBe(false) expect(store.removeFolderWorkspace('same-folder', 'local')).toBe(true) expect(store.getFolderWorkspace('same-folder', 'ssh:builder')?.name).toBe('Remote') + expect(store.getWorkspaceSession().tabsByWorktree[localKey]).toBeUndefined() + expect(store.getWorkspaceSession().tabsByWorktree[sshKey]).toHaveLength(1) }) it('loads a persisted legacy SSH folder under one unstamped group', async () => { diff --git a/src/main/persistence.ts b/src/main/persistence.ts index f6ca234051e..7a6a9ab76ab 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -4509,14 +4509,20 @@ export class Store { ) const removedFolderWorkspaceKeys = new Set() for (const workspace of removedFolderWorkspaces) { - if (!remainingFolderWorkspaceIds.has(workspace.id)) { - removedFolderWorkspaceKeys.add(folderWorkspaceKey(workspace.id)) - this.state.workspaceSession = removeWorkspaceSessionOwner( - this.state.workspaceSession, - folderWorkspaceKey(workspace.id) - )! - this.removeWorkspaceLineageForFolderParent(workspace.id) + const ownerHostId = resolveFolderWorkspaceCatalogOwnerHostId(workspace, projectGroups) + const workspaceKeys: string[] = [] + if (ownerHostId) { + workspaceKeys.push(folderWorkspaceKey(workspace.id, ownerHostId)) } + const removeBareKey = !remainingFolderWorkspaceIds.has(workspace.id) + if (removeBareKey) { + workspaceKeys.push(folderWorkspaceKey(workspace.id)) + } + for (const key of workspaceKeys) { + removedFolderWorkspaceKeys.add(key) + this.removeWorkspaceSessionStateForWorktree(key, ownerHostId) + } + this.removeWorkspaceLineageForFolderParent(workspace.id, ownerHostId, removeBareKey) } this.pruneMobileClientTabSelections((worktreeId) => removedFolderWorkspaceKeys.has(worktreeId)) this.scheduleSave() @@ -4705,14 +4711,23 @@ export class Store { this.state.folderWorkspaces = (this.state.folderWorkspaces ?? []).filter( (candidate) => candidate !== workspace ) - if (!this.state.folderWorkspaces.some((candidate) => candidate.id === id)) { - this.state.workspaceSession = removeWorkspaceSessionOwner( - this.state.workspaceSession, - folderWorkspaceKey(id) - )! - this.removeWorkspaceLineageForFolderParent(id) - this.pruneMobileClientTabSelections((worktreeId) => worktreeId === folderWorkspaceKey(id)) + const resolvedOwnerHostId = + ownerHostId ?? + resolveFolderWorkspaceCatalogOwnerHostId(workspace, this.state.projectGroups ?? []) ?? + undefined + const removeBareKey = !this.state.folderWorkspaces.some((candidate) => candidate.id === id) + const keys = new Set() + if (resolvedOwnerHostId) { + keys.add(folderWorkspaceKey(id, resolvedOwnerHostId)) } + if (removeBareKey) { + keys.add(folderWorkspaceKey(id)) + } + for (const key of keys) { + this.removeWorkspaceSessionStateForWorktree(key, resolvedOwnerHostId) + } + this.removeWorkspaceLineageForFolderParent(id, resolvedOwnerHostId, removeBareKey) + this.pruneMobileClientTabSelections((worktreeId) => keys.has(worktreeId)) this.scheduleSave() return true } @@ -5851,10 +5866,20 @@ export class Store { this.scheduleSave() } - private removeWorkspaceLineageForFolderParent(folderWorkspaceId: string): void { - const parentKey = folderWorkspaceKey(folderWorkspaceId) + private removeWorkspaceLineageForFolderParent( + folderWorkspaceId: string, + ownerHostId?: ExecutionHostId | null, + removeBareKey = true + ): void { + const parentKeys = new Set() + if (ownerHostId) { + parentKeys.add(folderWorkspaceKey(folderWorkspaceId, ownerHostId)) + } + if (removeBareKey) { + parentKeys.add(folderWorkspaceKey(folderWorkspaceId)) + } for (const [childKey, lineage] of Object.entries(this.state.workspaceLineageByChildKey)) { - if (lineage.parentWorkspaceKey === parentKey) { + if (parentKeys.has(lineage.parentWorkspaceKey)) { delete this.state.workspaceLineageByChildKey[childKey as WorkspaceKey] } } diff --git a/src/renderer/src/components/NewWorkspaceComposerModal.tsx b/src/renderer/src/components/NewWorkspaceComposerModal.tsx index 7810c1266e6..aa46a10f7bf 100644 --- a/src/renderer/src/components/NewWorkspaceComposerModal.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerModal.tsx @@ -26,7 +26,6 @@ import type { WorkspaceStatus } from '../../../shared/types' import type { TaskSourceContext } from '../../../shared/task-source-context' -import type { ExecutionHostId } from '../../../shared/execution-host' import { translate } from '@/i18n/i18n' import { getWorkspaceComposerInitialFocusTarget } from '@/lib/workspace-composer-initial-focus' import { getFolderWorkspacePrimaryActionLabel } from '@/components/sidebar/folder-workspace-composer-helpers' @@ -42,7 +41,6 @@ type ComposerModalData = { initialRepoId?: string initialEphemeralVmRecipeId?: string initialProjectGroupId?: string - initialProjectGroupOwnerHostId?: ExecutionHostId linkedWorkItem?: LinkedWorkItemSummary | null initialGitHubWorkItem?: GitHubWorkItem | null taskSourceContext?: TaskSourceContext | null @@ -143,7 +141,6 @@ function QuickTabBody({ initialRepoId: modalData.initialRepoId, initialEphemeralVmRecipeId: modalData.initialEphemeralVmRecipeId, initialProjectGroupId: modalData.initialProjectGroupId, - initialProjectGroupOwnerHostId: modalData.initialProjectGroupOwnerHostId, initialWorkspaceStatus: modalData.initialWorkspaceStatus, ...(modalData.initialBaseBranch ? { initialBaseBranch: modalData.initialBaseBranch } : {}), persistDraft: false, diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 21255c4ddb8..400152dffe5 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -153,6 +153,7 @@ import { type VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor' import { activateAndRevealWorktree } from '@/lib/worktree-activation' +import { getNewWorkspaceProjectGroupOptionId } from '@/lib/new-workspace-project-options' import { useFolderWorkspacePathStatusCacheExpiryTick } from '@/lib/folder-workspace-path-status-cache-expiry' import { getFolderWorkspacePathStatusDescription, @@ -300,7 +301,11 @@ import { buildSidebarHostOptions } from './sidebar-host-options' import { HostSectionHeaderMenu } from './HostSectionHeaderMenu' import { ProjectHeaderActions } from './ProjectHeaderActions' import { translate } from '@/i18n/i18n' -import { folderWorkspaceKey, getActiveSidebarWorkspaceId } from '../../../../shared/workspace-scope' +import { + folderWorkspaceKey, + getActiveSidebarWorkspaceId, + parseWorkspaceKey +} from '../../../../shared/workspace-scope' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' import { isConfirmedStaleFolderPathStatus, @@ -1099,11 +1104,13 @@ export function renderRowContainsWorktree( return false } if (row.type === 'folder-workspace') { + const workspaceScope = parseWorkspaceKey(worktreeId) return ( - folderWorkspaceKey(row.folderWorkspace.id) === worktreeId && - (!ownerHostId || + workspaceScope?.type === 'folder' && + workspaceScope.folderWorkspaceId === row.folderWorkspace.id && + (!(ownerHostId ?? workspaceScope.ownerHostId) || resolveFolderWorkspaceCatalogOwnerHostId(row.folderWorkspace, [row.projectGroup]) === - ownerHostId) + (ownerHostId ?? workspaceScope.ownerHostId)) ) } if (row.type === 'lineage-group') { @@ -5240,6 +5247,12 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp ) ?? getProjectGroupMutationSelector(folderWorkspaceRow.projectGroup).ownerHostId const folderWorktree = { ...folderWorkspaceToWorktree(folderWorkspaceRow.folderWorkspace), + id: folderWorkspaceKey( + folderWorkspaceRow.folderWorkspace.id, + ambiguousFolderWorkspaceIds.has(folderWorkspaceRow.folderWorkspace.id) + ? folderOwnerHostId + : undefined + ), hostId: folderOwnerHostId } const folderSelectionId = getSidebarWorktreeSelectionId( @@ -6589,9 +6602,9 @@ const WorktreeList = React.memo(function WorktreeList({ if (!projectGroup.parentPath) { return } + const ownerHostId = getProjectGroupMutationSelector(projectGroup).ownerHostId openModal('new-workspace-composer', { - initialProjectGroupId: projectGroup.id, - initialProjectGroupOwnerHostId: getProjectGroupMutationSelector(projectGroup).ownerHostId, + initialProjectGroupId: getNewWorkspaceProjectGroupOptionId(projectGroup.id, ownerHostId), telemetrySource: 'sidebar' }) }, diff --git a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx index 19d058bb7fe..0482a644221 100644 --- a/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx +++ b/src/renderer/src/components/sidebar/WorktreeMetaDialog.tsx @@ -20,7 +20,6 @@ import { } from './worktree-meta-updates' import { useWorktreeIssueLink } from './use-worktree-issue-link' import { useWorktreeMetaWorkspace } from './use-worktree-meta-workspace' -import { normalizeExecutionHostId } from '../../../../shared/execution-host' import { WorktreeIssueLinkField } from './WorktreeIssueLinkField' import { getScreenSubmitShortcutLabel, isScreenSubmitShortcut } from '@/lib/screen-submit-shortcut' import { useMountedRef } from '@/hooks/useMountedRef' @@ -70,9 +69,6 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { // Why: the opening row names its repo bucket for workspace IDs the owner index // reads as ambiguous across hosts. const ownerRepoId = typeof modalData.repoId === 'string' ? modalData.repoId : null - const executionHostId = normalizeExecutionHostId( - typeof modalData.executionHostId === 'string' ? modalData.executionHostId : null - ) const { worktree, linkedIssue, @@ -81,9 +77,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { currentProvider, isFolderWorkspace, liveLinks - } = useWorktreeMetaWorkspace({ worktreeId, ownerRepoId, executionHostId }) - const worktreeExecutionHostId = - executionHostId ?? normalizeExecutionHostId(worktree?.hostId) ?? undefined + } = useWorktreeMetaWorkspace({ worktreeId, ownerRepoId }) // Why: ChecksPanel seeds the PR it is looking at, which may not be linked yet. const currentPR = typeof modalData.currentPR === 'number' @@ -230,11 +224,7 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { try { const updates = buildWorktreeMetaUpdates(draft, snapshot, liveLinks) - const result = await updateWorktreeMeta( - worktreeId, - updates, - worktreeExecutionHostId ? { executionHostId: worktreeExecutionHostId } : undefined - ) + const result = await updateWorktreeMeta(worktreeId, updates) // Why: a failed save refetches and reverts the optimistic write. Closing // here would report success for an edit that silently undid itself, and // would discard the name, comment and PR changes in the same payload. @@ -259,7 +249,6 @@ const WorktreeMetaDialog = React.memo(function WorktreeMetaDialog() { } }, [ worktreeId, - worktreeExecutionHostId, canSave, draft, snapshot, 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 d3d9e6ca88d..ef01a86056b 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 @@ -1,7 +1,5 @@ // @vitest-environment happy-dom -import { readFileSync } from 'node:fs' -import { join } from 'node:path' import { act, createElement, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' @@ -26,95 +24,6 @@ import { import { addHostSectionRows } from './host-section-rows' import { getRenderRowKey } from './worktree-list-virtual-rows' -function readWorktreeListSource(): string { - return readFileSync( - join(process.cwd(), 'src', 'renderer', 'src', 'components', 'sidebar', 'WorktreeList.tsx'), - 'utf8' - ) -} - -function readComposerStateSource(): string { - return readFileSync( - join(process.cwd(), 'src', 'renderer', 'src', 'hooks', 'useComposerState.ts'), - 'utf8' - ) -} - -describe('Project Group header drag DOM source', () => { - it('renders concrete Project Group header drag attributes separately from repo headers', () => { - const source = readWorktreeListSource() - - expect(source).toContain('data-project-group-header-id={projectGroupIdForHeader}') - expect(source).toContain('data-project-group-header-index={projectGroupHeaderIndex}') - expect(source).toContain('data-project-group-header-bucket={projectGroupHeaderBucketKey}') - expect(source).toContain('data-project-group-header-drag-handle=') - }) - - it('commits Project Group manual sorting through updateProjectGroup tabOrder', () => { - const source = readWorktreeListSource() - - expect(source).toContain('const updateProjectGroup = useAppStore((s) => s.updateProjectGroup)') - 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('checks Reveal Current visibility with owner-qualified selection identity', () => { - const source = readWorktreeListSource() - const revealStart = source.indexOf('const handleRevealCurrentWorkspaceRequest') - const revealEnd = source.indexOf('useEffect(() => {', revealStart) - const revealSource = source.slice(revealStart, revealEnd) - - expect(revealSource).toContain('getSidebarWorktreeSelectionId(') - expect(revealSource).toContain('renderedWorktreeSelectionIds.includes(') - expect(revealSource).not.toContain('renderedWorktreeIds.includes(') - }) - - it('persists and restores a draft project group through its existing host field', () => { - const source = readComposerStateSource() - const restoreStart = source.indexOf('const initialFolderProjectGroupId') - const restoreEnd = source.indexOf('const isProjectGroupTarget', restoreStart) - const persistStart = source.indexOf('// Persist draft whenever relevant fields change') - const persistEnd = source.indexOf('// Auto-pick the first eligible repo', persistStart) - - expect(source.slice(restoreStart, restoreEnd)).toContain('draftHostId') - expect(source.slice(persistStart, persistEnd)).toContain('selectedProjectGroupOwnerHostId') - }) - - 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. - const source = readWorktreeListSource() - const headerBlockStart = source.indexOf('data-repo-header-id={projectIdForHeader}') - const headerBlockEnd = source.indexOf('', headerBlockStart) - expect(headerBlockStart).toBeGreaterThan(-1) - expect(headerBlockEnd).toBeGreaterThan(headerBlockStart) - const headerBlock = source.slice(headerBlockStart, headerBlockEnd) - - // Dual handle placement: row (indent/padding) + title surface. - expect(headerBlock.match(/data-repo-header-drag-handle=/g)?.length).toBe(2) - expect(headerBlock.match(/data-project-group-header-drag-handle=/g)?.length).toBe(2) - // Surface owns grab; outer row only gets cursor-pointer when not draggable. - expect(headerBlock).toContain( - "!(isDraggableRepoHeader || isDraggableProjectGroupHeader) && 'cursor-pointer'" - ) - expect(headerBlock).toContain("'flex min-w-0 flex-1 items-center gap-1.5 self-stretch'") - expect(headerBlock).toContain("'cursor-grab active:cursor-grabbing'") - // Row-level ternary grab must stay gone. - expect(headerBlock).not.toMatch( - /isDraggableRepoHeader \|\| isDraggableProjectGroupHeader\s*\?\s*'cursor-grab/ - ) - }) -}) - function sidebarIdentityGroup(id: string, executionHostId: string): ProjectGroup { return { id, diff --git a/src/renderer/src/components/sidebar/use-worktree-meta-workspace.ts b/src/renderer/src/components/sidebar/use-worktree-meta-workspace.ts index 1328ba56ee8..6c1fb040a5f 100644 --- a/src/renderer/src/components/sidebar/use-worktree-meta-workspace.ts +++ b/src/renderer/src/components/sidebar/use-worktree-meta-workspace.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react' import { useAppStore } from '@/store' import { findIndexedWorktreeOwner } from '@/lib/worktree-runtime-owner-index' import type { FolderWorkspace, Worktree } from '../../../../shared/types' -import type { ExecutionHostId } from '../../../../shared/execution-host' +import { normalizeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' import { getFolderWorkspaceCatalogOwnerHostId } from '../../../../shared/folder-workspaces' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' @@ -32,7 +32,6 @@ export function useWorktreeMetaWorkspace(args: { worktreeId: string /** The repo bucket the opening row belongs to, when it knows. */ ownerRepoId: string | null - executionHostId: ExecutionHostId | null }): { worktree: Worktree | undefined linkedIssue: number | null @@ -44,8 +43,15 @@ export function useWorktreeMetaWorkspace(args: { /** Link state as it stands now, for save-time displacement decisions. */ liveLinks: WorktreeMetaLiveLinks } { - const { worktreeId, ownerRepoId, executionHostId } = args + const { worktreeId, ownerRepoId } = args const workspaceScope = useMemo(() => parseWorkspaceKey(worktreeId), [worktreeId]) + const executionHostId = useAppStore((s) => + s.activeModal === 'edit-meta' && s.modalData.worktreeId === worktreeId + ? normalizeExecutionHostId( + typeof s.modalData.executionHostId === 'string' ? s.modalData.executionHostId : null + ) + : null + ) const indexedWorktree = useAppStore((s) => { // Why: the same workspace ID can exist under two hosts, which the owner index // reports as ambiguous rather than guessing. The row that opened the dialog 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 7f059717018..9e47cb543c0 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 @@ -151,10 +151,9 @@ describe('worktree list folder reveal', () => { ).toEqual([]) expect( getFolderWorkspaceRevealGroupKeys( - workspaceKey, + folderWorkspaceKey(runtimeFolder.id, 'runtime:env-1'), [localFolder, runtimeFolder], - [localChild, runtimeChild, localRoot, runtimeRoot], - 'runtime:env-1' + [localChild, runtimeChild, localRoot, runtimeRoot] ) ).toEqual([ getProjectGroupHeaderKey(runtimeRoot.id, 'runtime:env-1'), @@ -225,8 +224,8 @@ describe('worktree list folder reveal', () => { it('clears the deleted active folder owner', () => { expect( shouldClearActiveFolderWorkspaceAfterDelete({ - activeWorktreeId: 'folder:same-id', - activeOwnerHostId: 'runtime:env-1', + activeWorktreeId: folderWorkspaceKey('same-id', 'runtime:env-1'), + activeOwnerHostId: null, deletedFolderWorkspaceId: 'same-id', deletedOwnerHostId: 'runtime:env-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 a63bd05ff59..9d366fd3dd6 100644 --- a/src/renderer/src/components/sidebar/worktree-list-folder-reveal.ts +++ b/src/renderer/src/components/sidebar/worktree-list-folder-reveal.ts @@ -25,11 +25,13 @@ function findFolderWorkspaceByKey( const candidates = folderWorkspaces.filter( (workspace) => workspace.id === scope.folderWorkspaceId ) - if (ownerHostId) { + const effectiveOwnerHostId = ownerHostId ?? scope.ownerHostId + if (effectiveOwnerHostId) { return ( candidates.find( (workspace) => - resolveFolderWorkspaceCatalogOwnerHostId(workspace, projectGroups) === ownerHostId + resolveFolderWorkspaceCatalogOwnerHostId(workspace, projectGroups) === + effectiveOwnerHostId ) ?? null ) } @@ -49,14 +51,19 @@ export function shouldClearActiveFolderWorkspaceAfterDelete(args: { deletedOwnerHostId?: ExecutionHostId sameIdWorkspaceStillExists?: boolean }): boolean { - if (args.activeWorktreeId !== folderWorkspaceKey(args.deletedFolderWorkspaceId)) { + const activeScope = parseWorkspaceKey(args.activeWorktreeId ?? '') + if ( + activeScope?.type !== 'folder' || + activeScope.folderWorkspaceId !== args.deletedFolderWorkspaceId + ) { return false } if (!args.deletedOwnerHostId) { return true } - return args.activeOwnerHostId - ? args.activeOwnerHostId === args.deletedOwnerHostId + const activeOwnerHostId = args.activeOwnerHostId ?? activeScope.ownerHostId + return activeOwnerHostId + ? activeOwnerHostId === args.deletedOwnerHostId : !args.sameIdWorkspaceStillExists } diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index c1c5bbdc245..88363263db1 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -1767,16 +1767,22 @@ export function buildRows( for (const child of childGroupsByParentIdentity.get(identity) ?? []) { const childMetrics = getProjectGroupHostMetrics(getProjectGroupSidebarIdentity(child)) metrics.count += childMetrics.count - metrics.worktreeIds.push(...childMetrics.worktreeIds) - metrics.folderWorkspaceIds.push(...childMetrics.folderWorkspaceIds) + for (const worktreeId of childMetrics.worktreeIds) { + metrics.worktreeIds.push(worktreeId) + } + for (const folderWorkspaceId of childMetrics.folderWorkspaceIds) { + metrics.folderWorkspaceIds.push(folderWorkspaceId) + } for (const [hostId, count] of childMetrics.hostCounts) { incrementHostCount(hostId, count) - metrics.hostWorktreeIds - .get(hostId)! - .push(...(childMetrics.hostWorktreeIds.get(hostId) ?? [])) - metrics.hostFolderWorkspaceIds - .get(hostId)! - .push(...(childMetrics.hostFolderWorkspaceIds.get(hostId) ?? [])) + const hostWorktreeIds = metrics.hostWorktreeIds.get(hostId)! + for (const worktreeId of childMetrics.hostWorktreeIds.get(hostId) ?? []) { + hostWorktreeIds.push(worktreeId) + } + const hostFolderWorkspaceIds = metrics.hostFolderWorkspaceIds.get(hostId)! + for (const folderWorkspaceId of childMetrics.hostFolderWorkspaceIds.get(hostId) ?? []) { + hostFolderWorkspaceIds.push(folderWorkspaceId) + } } } if (metrics.hostCounts.size === 0 && projectGroup) { @@ -1857,7 +1863,9 @@ export function buildRows( } // Why: startup can have repos from hosts whose project-group metadata was // not fetched yet; missing metadata must not make those repos disappear. - remainingRepoEntries.push(...entries) + for (const entry of entries) { + remainingRepoEntries.push(entry) + } } appendOrderedGroups( withRepoSectionDisplayLabels(sortRepoEntriesWithinGroup(remainingRepoEntries)), 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 39f91eaa33a..116384c569d 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 @@ -1,7 +1,9 @@ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import type { FolderWorkspace, ProjectGroup } from '../../../../shared/types' +import type { FolderWorkspace, ProjectGroup, Repo, Worktree } from '../../../../shared/types' import { addHostSectionRows } from './host-section-rows' -import type { Row } from './worktree-list-groups' +import { buildRows, type Row } from './worktree-list-groups' import { filterFolderWorkspacesForVisibleHosts, filterProjectGroupsForVisibleHosts, @@ -11,6 +13,94 @@ import { getRuntimeEnvironmentIdForFolderPathStatusHost } from './worktree-list-host-filtering' +function readWorktreeListSource(): string { + return readFileSync( + join(process.cwd(), 'src', 'renderer', 'src', 'components', 'sidebar', 'WorktreeList.tsx'), + 'utf8' + ) +} + +function readComposerStateSource(): string { + return readFileSync( + join(process.cwd(), 'src', 'renderer', 'src', 'hooks', 'useComposerState.ts'), + 'utf8' + ) +} + +describe('Project Group header drag DOM source', () => { + it('renders concrete Project Group header drag attributes separately from repo headers', () => { + const source = readWorktreeListSource() + + expect(source).toContain('data-project-group-header-id={projectGroupIdForHeader}') + expect(source).toContain('data-project-group-header-index={projectGroupHeaderIndex}') + expect(source).toContain('data-project-group-header-bucket={projectGroupHeaderBucketKey}') + expect(source).toContain('data-project-group-header-drag-handle=') + }) + + it('commits Project Group manual sorting through updateProjectGroup tabOrder', () => { + const source = readWorktreeListSource() + + expect(source).toContain('const updateProjectGroup = useAppStore((s) => s.updateProjectGroup)') + 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') + expect(source).toContain('id: folderWorkspaceKey(') + expect(source).toContain( + 'ambiguousFolderWorkspaceIds.has(folderWorkspaceRow.folderWorkspace.id)' + ) + }) + + it('checks Reveal Current visibility with owner-qualified selection identity', () => { + const source = readWorktreeListSource() + const revealStart = source.indexOf('const handleRevealCurrentWorkspaceRequest') + const revealEnd = source.indexOf('useEffect(() => {', revealStart) + const revealSource = source.slice(revealStart, revealEnd) + + expect(revealSource).toContain('getSidebarWorktreeSelectionId(') + expect(revealSource).toContain('renderedWorktreeSelectionIds.includes(') + expect(revealSource).not.toContain('renderedWorktreeIds.includes(') + }) + + it('persists and restores a draft project group through its existing host field', () => { + const source = readComposerStateSource() + const restoreStart = source.indexOf('const initialFolderProjectGroupId') + const restoreEnd = source.indexOf('const isProjectGroupTarget', restoreStart) + const persistStart = source.indexOf('// Persist draft whenever relevant fields change') + const persistEnd = source.indexOf('// Auto-pick the first eligible repo', persistStart) + + expect(source.slice(restoreStart, restoreEnd)).toContain('draftHostId') + expect(source.slice(persistStart, persistEnd)).toContain('selectedProjectGroupOwnerHostId') + }) + + it('keeps grab cursor on the title surface and dual handle attrs on row + surface', () => { + const source = readWorktreeListSource() + const headerBlockStart = source.indexOf('data-repo-header-id={projectIdForHeader}') + const headerBlockEnd = source.indexOf('', headerBlockStart) + expect(headerBlockStart).toBeGreaterThan(-1) + expect(headerBlockEnd).toBeGreaterThan(headerBlockStart) + const headerBlock = source.slice(headerBlockStart, headerBlockEnd) + + expect(headerBlock.match(/data-repo-header-drag-handle=/g)?.length).toBe(2) + expect(headerBlock.match(/data-project-group-header-drag-handle=/g)?.length).toBe(2) + expect(headerBlock).toContain( + "!(isDraggableRepoHeader || isDraggableProjectGroupHeader) && 'cursor-pointer'" + ) + expect(headerBlock).toContain("'flex min-w-0 flex-1 items-center gap-1.5 self-stretch'") + expect(headerBlock).toContain("'cursor-grab active:cursor-grabbing'") + expect(headerBlock).not.toMatch( + /isDraggableRepoHeader \|\| isDraggableProjectGroupHeader\s*\?\s*'cursor-grab/ + ) + }) +}) + function group(overrides: Partial = {}): ProjectGroup { return { id: 'group-1', @@ -310,6 +400,77 @@ describe('runtime folder host section rows', () => { ]) }) + it('aggregates roughly 130000 populated child entries without argument spread overflow', () => { + const root = group({ id: 'root', executionHostId: 'local' }) + const child = group({ + id: 'child', + parentGroupId: root.id, + executionHostId: 'local' + }) + const repo: Repo = { + id: 'large-repo', + path: '/large-repo', + displayName: 'large-repo', + badgeColor: '#000', + addedAt: 1, + projectGroupId: child.id, + projectGroupOrder: 0, + executionHostId: 'local' + } + const worktrees: Worktree[] = Array.from({ length: 130_000 }, (_, index) => ({ + id: `wt-${index}`, + repoId: repo.id, + path: `/large-repo/wt-${index}`, + head: 'abc', + branch: `refs/heads/wt-${index}`, + isBare: false, + isMainWorktree: false, + displayName: `wt-${index}`, + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + linkedGitLabMR: null, + linkedGitLabIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: index, + lastActivityAt: 0 + })) + + const rows = buildRows( + 'repo', + worktrees, + new Map([[repo.id, repo]]), + null, + new Set(), + undefined, + undefined, + 'manual', + undefined, + undefined, + false, + undefined, + [root, child], + new Set(), + new Map(), + new Map(), + [], + undefined, + [], + undefined, + 'local' + ) + + const rootHeader = rows.find( + (row): row is Extract<(typeof rows)[number], { type: 'header' }> => + row.type === 'header' && row.projectGroup?.id === root.id + ) + expect(rootHeader?.worktreeIds).toHaveLength(130_000) + expect(rootHeader?.hostWorktreeIds?.get('local')).toHaveLength(130_000) + }) + it('retains a local-stamped group needed by a visible legacy SSH folder', () => { const localGroup = group({ id: 'legacy-group', diff --git a/src/renderer/src/hooks/useComposerState.ts b/src/renderer/src/hooks/useComposerState.ts index ff35b511f4f..bfe65f6540e 100644 --- a/src/renderer/src/hooks/useComposerState.ts +++ b/src/renderer/src/hooks/useComposerState.ts @@ -747,9 +747,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS ? resolvedInitialWorkspaceTarget.target.projectHostSetupId : null ) - const initialFolderProjectGroupId = initialProjectGroupId ?? draftProjectGroupId + const initialProjectGroupSelector = initialProjectGroupId + ? getProjectGroupSelectorFromNewWorkspaceOptionId(initialProjectGroupId) + : null + const initialFolderProjectGroupId = + initialProjectGroupSelector?.groupId ?? initialProjectGroupId ?? draftProjectGroupId const initialFolderProjectGroupOwnerHostId = initialProjectGroupId - ? initialProjectGroupOwnerHostId + ? (initialProjectGroupSelector?.ownerHostId ?? initialProjectGroupOwnerHostId) : (draftHostId ?? undefined) const initialFolderProjectGroup = findActionableFolderProjectGroup({ projectGroups, diff --git a/src/renderer/src/lib/folder-workspace-connection.ts b/src/renderer/src/lib/folder-workspace-connection.ts index 93372c63216..29446058443 100644 --- a/src/renderer/src/lib/folder-workspace-connection.ts +++ b/src/renderer/src/lib/folder-workspace-connection.ts @@ -15,7 +15,7 @@ import { parseExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' -import { folderWorkspaceKey } from '../../../shared/workspace-scope' +import { parseWorkspaceKey } from '../../../shared/workspace-scope' export type FolderWorkspaceConnectionState = { folderWorkspaces: FolderWorkspace[] @@ -76,9 +76,10 @@ function findFolderWorkspaceScope( folderWorkspaceId: string, ownerHostId?: ExecutionHostId ): { workspace: FolderWorkspace; projectGroup: ProjectGroup; ownerHostId: ExecutionHostId } | null { + const activeScope = parseWorkspaceKey(state.activeWorktreeId ?? '') const activeOwnerHostId = - state.activeWorktreeId === folderWorkspaceKey(folderWorkspaceId) - ? state.activeWorkspaceExecutionHostId + activeScope?.type === 'folder' && activeScope.folderWorkspaceId === folderWorkspaceId + ? (activeScope.ownerHostId ?? state.activeWorkspaceExecutionHostId) : undefined const requestedOwnerHostId = ownerHostId ?? activeOwnerHostId ?? undefined const candidates = state.folderWorkspaces.filter((entry) => entry.id === folderWorkspaceId) diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index 445ef2917e4..4f4aab63d4f 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -145,7 +145,13 @@ function getFolderWorkspaceRuntimeHostId( if (scope?.type !== 'folder') { return LOCAL_EXECUTION_HOST_ID } - const workspace = state.folderWorkspaces?.find((entry) => entry.id === scope.folderWorkspaceId) + const qualifiedOwner = parseExecutionHostId(scope.ownerHostId) + if (qualifiedOwner) { + return qualifiedOwner.kind === 'runtime' ? qualifiedOwner.id : LOCAL_EXECUTION_HOST_ID + } + const matchingWorkspaces = + state.folderWorkspaces?.filter((entry) => entry.id === scope.folderWorkspaceId) ?? [] + const workspace = matchingWorkspaces.length === 1 ? matchingWorkspaces[0] : null const group = workspace ? state.projectGroups?.find((entry) => entry.id === workspace.projectGroupId) : null diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index e5a18a5036b..aa4e9050ca3 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -1,6 +1,5 @@ /* eslint-disable max-lines -- Why: worktree activation is a single ordered flow spanning startup, setup, issue commands, and default tabs; splitting it would obscure sequencing guarantees. */ import type { - FolderWorkspace, GlobalSettings, SetupSplitDirection, Tab, @@ -192,11 +191,10 @@ export type ActivateAndRevealResult = { } function ensureFolderWorkspaceInitialTerminal( - folderWorkspace: FolderWorkspace, + workspaceKey: string, startup?: WorktreeStartupPayload ): string | null { const state = useAppStore.getState() - const workspaceKey = folderWorkspaceKey(folderWorkspace.id) const primaryTabId = ensureWorktreeHasInitialTerminal( state, workspaceKey, @@ -265,13 +263,22 @@ export function activateAndRevealFolderWorkspace( state.setActiveFolderWorkspace(folderWorkspaceId, ownerHostId) - const workspaceKey = folderWorkspaceKey(folderWorkspaceId) + const activatedState = useAppStore.getState() + const activeScope = parseWorkspaceKey(activatedState.activeWorktreeId ?? '') + if ( + activeScope?.type !== 'folder' || + activeScope.folderWorkspaceId !== folderWorkspaceId || + activatedState.activeWorkspaceExecutionHostId !== ownerHostId + ) { + return false + } + const workspaceKey = folderWorkspaceKey(folderWorkspaceId, activeScope.ownerHostId) state.markWorktreeVisited(workspaceKey) if (!state.isNavigatingHistory) { state.recordWorktreeVisit(workspaceKey) } resumeSleepingAgentSessionsForWorktree(workspaceKey) - const primaryTabId = ensureFolderWorkspaceInitialTerminal(folderWorkspace, opts?.startup) + const primaryTabId = ensureFolderWorkspaceInitialTerminal(workspaceKey, opts?.startup) if (opts?.sidebarRevealBehavior) { state.revealWorktreeInSidebar(workspaceKey, { behavior: opts.sidebarRevealBehavior }) @@ -754,7 +761,10 @@ function queueSetupAndIssueCommands( export function activateAndRevealWorkspace(workspaceId: string): ActivateAndRevealResult | false { const workspaceScope = parseWorkspaceKey(workspaceId) if (workspaceScope?.type === 'folder') { - return activateAndRevealFolderWorkspace(workspaceScope.folderWorkspaceId) + return activateAndRevealFolderWorkspace( + workspaceScope.folderWorkspaceId, + workspaceScope.ownerHostId ? { executionHostId: workspaceScope.ownerHostId } : undefined + ) } return activateAndRevealWorktree(workspaceId) } diff --git a/src/renderer/src/store/slices/repos-project-groups-delete.test.ts b/src/renderer/src/store/slices/repos-project-groups-delete.test.ts index eb165ed33c3..9b8c1581da7 100644 --- a/src/renderer/src/store/slices/repos-project-groups-delete.test.ts +++ b/src/renderer/src/store/slices/repos-project-groups-delete.test.ts @@ -7,6 +7,7 @@ import { } from '../../runtime/runtime-compatibility-test-fixture' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' const remoteRepo: Repo = { id: 'remote-repo', @@ -108,7 +109,10 @@ describe('project group deletion store routing', () => { { id: 'nested', projectGroupId: null }, { id: 'sibling', projectGroupId: siblingGroup.id } ]) - expect(purgeWorktreeTerminalState).toHaveBeenCalledWith(['folder:folder-workspace-1']) + expect(purgeWorktreeTerminalState).toHaveBeenCalledWith([ + folderWorkspaceKey('folder-workspace-1', 'local'), + folderWorkspaceKey('folder-workspace-1') + ]) expect(setActiveWorktree).toHaveBeenCalledWith(null) }) @@ -249,7 +253,9 @@ describe('project group deletion store routing', () => { expect(store.getState().projectGroups).toEqual([localGroup]) expect(store.getState().folderWorkspaces).toEqual([localFolder]) expect(store.getState().repos).toEqual([localRepo, { ...runtimeRepo, projectGroupId: null }]) - expect(purgeWorktreeTerminalState).not.toHaveBeenCalled() + expect(purgeWorktreeTerminalState).toHaveBeenCalledWith([ + folderWorkspaceKey('same-folder', 'runtime:env-1') + ]) expect(setActiveWorktree).not.toHaveBeenCalled() expect(projectGroupsDelete).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/store/slices/repos-same-id-folder-workspace-identity.test.ts b/src/renderer/src/store/slices/repos-same-id-folder-workspace-identity.test.ts index d69699410ca..234a8fd3356 100644 --- a/src/renderer/src/store/slices/repos-same-id-folder-workspace-identity.test.ts +++ b/src/renderer/src/store/slices/repos-same-id-folder-workspace-identity.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/types' -import { folderWorkspaceKey } from '../../../../shared/workspace-scope' +import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' +import { buildHostIdByWorktreeId } from '../../lib/workspace-session-host-persistence' import { getFolderWorkspaceCandidateRepos, getFolderWorkspaceConnectionId @@ -105,7 +106,9 @@ describe('same-id cross-host folder workspace identity', () => { folderWorkspaceId: 'same-id', ownerHostId: 'local' }) - expect(purgeWorktreeTerminalState).not.toHaveBeenCalled() + expect(purgeWorktreeTerminalState).toHaveBeenCalledWith([ + folderWorkspaceKey('same-id', 'local') + ]) expect(Object.keys(store.getState().folderWorkspacePathStatuses)).toEqual([ 'local:folder-workspace:["runtime:env-1","same-id"]' ]) @@ -172,9 +175,10 @@ describe('same-id cross-host folder workspace identity', () => { expect(state.activeTabId).toBe('legacy-tab') }) - it('activates an ambiguous folder id only with an exact owner', () => { + it('does not reuse an unowned legacy session when an id becomes ambiguous', () => { const store = createTestStore() const bareKey = folderWorkspaceKey('same-id') + const localKey = folderWorkspaceKey('same-id', 'local') store.setState({ projectGroups: [ makeGroup({ id: 'same-group', executionHostId: 'local', parentPath: '/local' }), @@ -212,10 +216,144 @@ describe('same-id cross-host folder workspace identity', () => { }) store.getState().setActiveFolderWorkspace('same-id', 'local') - expect(store.getState().activeWorkspaceKey).toBe(bareKey) - expect(store.getState().activeWorktreeId).toBe(bareKey) + expect(store.getState().activeWorkspaceKey).toBe(localKey) + expect(store.getState().activeWorktreeId).toBe(localKey) expect(store.getState().activeWorkspaceExecutionHostId).toBe('local') expect(store.getState().tabsByWorktree[bareKey]?.[0]?.id).toBe('legacy-tab') + expect(store.getState().tabsByWorktree[localKey]).toBeUndefined() + }) + + it('migrates a legacy session when its active owner proves the alias', () => { + const store = createTestStore() + const bareKey = folderWorkspaceKey('same-id') + const localKey = folderWorkspaceKey('same-id', 'local') + store.setState({ + projectGroups: [ + makeGroup({ executionHostId: 'local', parentPath: '/local' }), + makeGroup({ executionHostId: 'runtime:env-1', parentPath: '/runtime' }) + ], + folderWorkspaces: [ + makeFolder({ executionHostId: 'local', folderPath: '/local/folder' }), + makeFolder({ executionHostId: 'runtime:env-1', folderPath: '/runtime/folder' }) + ], + activeWorktreeId: bareKey, + activeWorkspaceKey: bareKey, + activeWorkspaceExecutionHostId: 'local', + tabsByWorktree: { [bareKey]: [] }, + activeFileIdByWorktree: { [bareKey]: null }, + browserTabsByWorktree: { [bareKey]: [] }, + groupsByWorktree: { [bareKey]: [] }, + unifiedTabsByWorktree: { [bareKey]: [] } + }) + + store.getState().setActiveFolderWorkspace('same-id', 'local') + + const state = store.getState() + expect(state.activeWorktreeId).toBe(localKey) + expect(state.tabsByWorktree).toHaveProperty(localKey) + expect(state.activeFileIdByWorktree).toHaveProperty(localKey) + expect(state.browserTabsByWorktree).toHaveProperty(localKey) + expect(state.groupsByWorktree).toHaveProperty(localKey) + expect(state.unifiedTabsByWorktree).toHaveProperty(localKey) + expect(state.tabsByWorktree).not.toHaveProperty(bareKey) + }) + + it('does not overwrite a canonical owner session with a proven legacy alias', () => { + const store = createTestStore() + const bareKey = folderWorkspaceKey('same-id') + const localKey = folderWorkspaceKey('same-id', 'local') + store.setState({ + projectGroups: [ + makeGroup({ executionHostId: 'local', parentPath: '/local' }), + makeGroup({ executionHostId: 'runtime:env-1', parentPath: '/runtime' }) + ], + folderWorkspaces: [ + makeFolder({ executionHostId: 'local', folderPath: '/local/folder' }), + makeFolder({ executionHostId: 'runtime:env-1', folderPath: '/runtime/folder' }) + ], + activeWorktreeId: bareKey, + activeWorkspaceKey: bareKey, + activeWorkspaceExecutionHostId: 'local', + tabsByWorktree: { + [bareKey]: [], + [localKey]: [ + { + id: 'canonical-tab', + ptyId: 'pty-canonical', + worktreeId: localKey, + title: 'Canonical', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + ptyIdsByTabId: { 'canonical-tab': ['pty-canonical'] } + }) + + store.getState().setActiveFolderWorkspace('same-id', 'local') + + expect(store.getState().activeWorktreeId).toBe(localKey) + expect(store.getState().tabsByWorktree[localKey]?.[0]?.id).toBe('canonical-tab') + expect(store.getState().tabsByWorktree).toHaveProperty(bareKey) + }) + + it('keeps same-id owner sessions isolated', () => { + const store = createTestStore() + const localKey = folderWorkspaceKey('same-id', 'local') + const runtimeKey = folderWorkspaceKey('same-id', 'runtime:env-1') + store.setState({ + projectGroups: [ + makeGroup({ executionHostId: 'local', parentPath: '/local' }), + makeGroup({ executionHostId: 'runtime:env-1', parentPath: '/runtime' }) + ], + folderWorkspaces: [ + makeFolder({ executionHostId: 'local', folderPath: '/local/folder' }), + makeFolder({ executionHostId: 'runtime:env-1', folderPath: '/runtime/folder' }) + ], + tabsByWorktree: { [localKey]: [], [runtimeKey]: [] } + }) + + store.getState().setActiveFolderWorkspace('same-id', 'local') + expect(store.getState().activeWorktreeId).toBe(localKey) + store.getState().setActiveFolderWorkspace('same-id', 'runtime:env-1') + expect(store.getState().activeWorktreeId).toBe(runtimeKey) + expect(Object.keys(store.getState().tabsByWorktree).sort()).toEqual( + [localKey, runtimeKey].sort() + ) + }) + + it('round-trips owner-qualified keys and persists each runtime owner separately', () => { + const localKey = folderWorkspaceKey('same:id', 'local') + const runtimeKey = folderWorkspaceKey('same:id', 'runtime:env-1') + expect(parseWorkspaceKey(runtimeKey)).toEqual({ + type: 'folder', + folderWorkspaceId: 'same:id', + ownerHostId: 'runtime:env-1' + }) + const getHostId = buildHostIdByWorktreeId({ + repos: [], + projectGroups: [ + { id: 'same-group', executionHostId: 'local' }, + { id: 'same-group', executionHostId: 'runtime:env-1' } + ], + folderWorkspaces: [ + { id: 'same:id', projectGroupId: 'same-group', executionHostId: 'local' }, + { id: 'same:id', projectGroupId: 'same-group', executionHostId: 'runtime:env-1' } + ], + worktreesByRepo: {} + }) + + expect(getHostId(localKey)).toBe('local') + expect(getHostId(runtimeKey)).toBe('runtime:env-1') + }) + + it('keeps legacy bare folder ids containing colons intact', () => { + expect(parseWorkspaceKey('folder:legacy:id')).toEqual({ + type: 'folder', + folderWorkspaceId: 'legacy:id' + }) }) it('routes folder metadata writes through the selected owner', async () => { @@ -353,6 +491,18 @@ describe('same-id cross-host folder workspace identity', () => { expect(store.getState().folderWorkspaces).toEqual([local, ssh]) expect(updateFolderWorkspace).not.toHaveBeenCalled() + + store.getState().markWorktreeUnread(folderWorkspaceKey('same-id', 'ssh:builder')) + + expect(store.getState().folderWorkspaces.map((workspace) => workspace.isUnread)).toEqual([ + true, + true + ]) + expect(updateFolderWorkspace).toHaveBeenCalledWith( + 'same-id', + expect.objectContaining({ isUnread: true }), + { executionHostId: 'ssh:builder' } + ) }) it('clears unread on only the owner selected by generic folder activation', async () => { @@ -456,11 +606,21 @@ describe('same-id cross-host folder workspace identity', () => { getFolderWorkspaceCandidateRepos( { ...state, - activeWorktreeId: 'folder:same-id', + activeWorktreeId: folderWorkspaceKey('same-id', 'ssh:builder'), activeWorkspaceExecutionHostId: 'ssh:builder' }, 'same-id' ) ).toEqual([sshRepo]) + expect( + getFolderWorkspaceConnectionId( + { + ...state, + activeWorktreeId: folderWorkspaceKey('same-id', 'ssh:builder'), + activeWorkspaceExecutionHostId: 'ssh:builder' + }, + 'same-id' + ) + ).toBe('builder') }) }) diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 9309e95092f..87496fcb96e 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -1319,7 +1319,13 @@ function clearRestoredFolderWorkspaceSessionOwners( next[key] = hostId continue } - const matches = state.folderWorkspaces.filter((entry) => entry.id === scope.folderWorkspaceId) + const matches = state.folderWorkspaces.filter( + (entry) => + entry.id === scope.folderWorkspaceId && + (!scope.ownerHostId || + resolveFolderWorkspaceCatalogOwnerHostId(entry, state.projectGroups) === + scope.ownerHostId) + ) const workspace = matches.length === 1 ? matches[0] : undefined if (workspace && !state.projectGroups.some((group) => group.id === workspace.projectGroupId)) { // Why: ownership resolves via the project group; if that catalog is still missing, keep the restored host owner so a session write doesn't move runtime tabs local. @@ -1462,9 +1468,12 @@ async function reconcileFailedFolderWorkspaceUpdate(args: { const remainingSameId = args .get() .folderWorkspaces.some((workspace) => workspace.id === args.folderWorkspaceId) - if (!remainingSameId) { - args.get().purgeWorktreeTerminalState([folderWorkspaceKey(args.folderWorkspaceId)]) - } + args + .get() + .purgeWorktreeTerminalState([ + folderWorkspaceKey(args.folderWorkspaceId, args.ownerHostId), + ...(!remainingSameId ? [folderWorkspaceKey(args.folderWorkspaceId)] : []) + ]) } } catch (err) { console.warn('Failed to reconcile folder workspace after update failure:', err) @@ -2883,9 +2892,11 @@ export const createRepoSlice: StateCreator = (set, updateFolderWorkspace: async (folderWorkspaceId, updates, options) => { const folderWorkspaceUpdates = getFolderWorkspaceUpdateCoordinator(get) const state = get() + const activeWorkspaceScope = parseWorkspaceKey(state.activeWorktreeId ?? '') const executionHostId = options?.executionHostId ?? - (state.activeWorktreeId === folderWorkspaceKey(folderWorkspaceId) + (activeWorkspaceScope?.type === 'folder' && + activeWorkspaceScope.folderWorkspaceId === folderWorkspaceId ? (state.activeWorkspaceExecutionHostId ?? undefined) : undefined) const owner = findFolderWorkspaceOwner(state, folderWorkspaceId, executionHostId) @@ -3054,9 +3065,10 @@ export const createRepoSlice: StateCreator = (set, const remainingSameId = get().folderWorkspaces.some( (workspace) => workspace.id === folderWorkspaceId ) - if (!remainingSameId) { - get().purgeWorktreeTerminalState([folderWorkspaceKey(folderWorkspaceId)]) - } + get().purgeWorktreeTerminalState([ + folderWorkspaceKey(folderWorkspaceId, ownerHostId), + ...(!remainingSameId ? [folderWorkspaceKey(folderWorkspaceId)] : []) + ]) return true } catch (err) { console.error('Failed to delete folder workspace:', err) @@ -3137,14 +3149,23 @@ export const createRepoSlice: StateCreator = (set, } const stateBeforeDelete = get() const removalGroupIndex = buildProjectGroupOwnerIndex(stateBeforeDelete.projectGroups) - const removedFolderWorkspaceIds = new Set( - stateBeforeDelete.folderWorkspaces.flatMap((workspace) => - targets.folderWorkspaceIdentities.has( - getProjectGroupRemovalFolderWorkspaceIdentity(removalGroupIndex, workspace) ?? '' - ) - ? [workspace.id] - : [] + const removedFolderWorkspaces = stateBeforeDelete.folderWorkspaces.flatMap((workspace) => + targets.folderWorkspaceIdentities.has( + getProjectGroupRemovalFolderWorkspaceIdentity(removalGroupIndex, workspace) ?? '' ) + ? [ + { + id: workspace.id, + ownerHostId: resolveFolderWorkspaceCatalogOwnerHostId( + workspace, + stateBeforeDelete.projectGroups + ) + } + ] + : [] + ) + const removedFolderWorkspaceIds = new Set( + removedFolderWorkspaces.map((workspace) => workspace.id) ) set((s) => { const projectTargetIdentities = new Set( @@ -3169,10 +3190,16 @@ export const createRepoSlice: StateCreator = (set, folderWorkspacePathStatuses: {} } }) - for (const folderWorkspaceId of removedFolderWorkspaceIds) { - if (!get().folderWorkspaces.some((workspace) => workspace.id === folderWorkspaceId)) { - get().purgeWorktreeTerminalState([folderWorkspaceKey(folderWorkspaceId)]) - } + for (const removedWorkspace of removedFolderWorkspaces) { + const remainingSameId = get().folderWorkspaces.some( + (workspace) => workspace.id === removedWorkspace.id + ) + get().purgeWorktreeTerminalState([ + ...(removedWorkspace.ownerHostId + ? [folderWorkspaceKey(removedWorkspace.id, removedWorkspace.ownerHostId)] + : []), + ...(!remainingSameId ? [folderWorkspaceKey(removedWorkspace.id)] : []) + ]) } const activeWorkspaceScope = parseWorkspaceKey(stateBeforeDelete.activeWorktreeId ?? '') if (activeWorkspaceScope?.type === 'folder') { diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 819779c16bd..4afd4a000f9 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -104,6 +104,7 @@ import { worktreeWorkspaceKey } from '../../../../shared/workspace-scope' import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import { resolveFolderWorkspaceCatalogOwnerHostId } from '../../../../shared/folder-workspaces' import { CLIENT_WORKTREE_CREATE_MAX_ATTEMPTS, getClientWorktreeCreateCandidate, @@ -916,15 +917,116 @@ function folderWorkspaceMatchesHost( return getFolderWorkspaceExecutionHostId(workspace) === executionHostId } +type FolderWorkspaceSessionTarget = { + folderWorkspace: FolderWorkspace + ownerHostId: ExecutionHostId + workspaceKey: ReturnType + aliasKey: ReturnType + canMigrateAlias: boolean +} + +function resolveFolderWorkspaceSessionTarget( + state: Pick< + AppState, + | 'folderWorkspaces' + | 'projectGroups' + | 'activeWorktreeId' + | 'activeWorkspaceKey' + | 'activeWorkspaceExecutionHostId' + | 'restoredRuntimeHostIdByWorkspaceSessionKey' + >, + folderWorkspaceId: string, + preferredHostId?: ExecutionHostId +): FolderWorkspaceSessionTarget | null { + const matches = state.folderWorkspaces.filter((workspace) => workspace.id === folderWorkspaceId) + const ownerOf = (workspace: FolderWorkspace): ExecutionHostId | null => + resolveFolderWorkspaceCatalogOwnerHostId(workspace, state.projectGroups) + const candidates = preferredHostId + ? matches.filter((workspace) => ownerOf(workspace) === preferredHostId) + : matches + if (candidates.length !== 1) { + return null + } + const folderWorkspace = candidates[0] + const ownerHostId = ownerOf(folderWorkspace) + if (!ownerHostId) { + return null + } + const owners = matches.map(ownerOf) + const ambiguous = + matches.length > 1 && (owners.some((owner) => owner === null) || new Set(owners).size > 1) + const workspaceKey = folderWorkspaceKey(folderWorkspaceId, ambiguous ? ownerHostId : undefined) + const aliasKey = folderWorkspaceKey(folderWorkspaceId, ambiguous ? undefined : ownerHostId) + const aliasOwnerIsProven = + !ambiguous || + ((state.activeWorktreeId === aliasKey || state.activeWorkspaceKey === aliasKey) && + state.activeWorkspaceExecutionHostId === ownerHostId) || + state.restoredRuntimeHostIdByWorkspaceSessionKey[aliasKey] === ownerHostId + return { + folderWorkspace, + ownerHostId, + workspaceKey, + aliasKey, + canMigrateAlias: aliasOwnerIsProven && aliasKey !== workspaceKey + } +} + +function hasWorkspaceSessionIdentity(state: AppState, workspaceKey: string): boolean { + return ( + state.activeWorktreeId === workspaceKey || + state.activeWorkspaceKey === workspaceKey || + workspaceKey in state.tabsByWorktree || + workspaceKey in state.activeFileIdByWorktree || + workspaceKey in state.browserTabsByWorktree || + workspaceKey in state.unifiedTabsByWorktree || + workspaceKey in state.groupsByWorktree || + workspaceKey in state.layoutByWorktree || + workspaceKey in state.restoredRuntimeHostIdByWorkspaceSessionKey || + state.openFiles.some((file) => file.worktreeId === workspaceKey) + ) +} + +function migrateFolderWorkspaceSessionAlias( + set: Parameters>[0], + target: FolderWorkspaceSessionTarget, + state: AppState +): void { + if ( + !target.canMigrateAlias || + !hasWorkspaceSessionIdentity(state, target.aliasKey) || + hasWorkspaceSessionIdentity(state, target.workspaceKey) + ) { + return + } + set((current) => { + const renamed = buildWorktreeRenameState(current, target.aliasKey, target.workspaceKey) + const worktreeNavHistory = current.worktreeNavHistory.map((entry) => + entry === target.aliasKey ? target.workspaceKey : entry + ) + return { + ...renamed, + ...(current.activeWorkspaceKey === target.aliasKey + ? { activeWorkspaceKey: target.workspaceKey } + : {}), + ...(worktreeNavHistory.some((entry, index) => entry !== current.worktreeNavHistory[index]) + ? { worktreeNavHistory } + : {}) + } + }) +} + function findFolderWorkspaceMetadataOwner( state: Pick, - folderWorkspaceId: string + folderWorkspaceId: string, + preferredHostId?: ExecutionHostId ): FolderWorkspace | null { const matches = state.folderWorkspaces.filter((workspace) => workspace.id === folderWorkspaceId) + const activeScope = parseWorkspaceKey(state.activeWorktreeId ?? '') const activeExecutionHostId = - state.activeWorktreeId === folderWorkspaceKey(folderWorkspaceId) - ? state.activeWorkspaceExecutionHostId - : null + preferredHostId ?? + (activeScope?.type === 'folder' && activeScope.folderWorkspaceId === folderWorkspaceId + ? (activeScope.ownerHostId ?? state.activeWorkspaceExecutionHostId) + : null) if (activeExecutionHostId) { return ( matches.find((workspace) => folderWorkspaceMatchesHost(workspace, activeExecutionHostId)) ?? @@ -963,16 +1065,22 @@ function persistFolderWorkspaceMetadata( } function findKnownWorktreeById( - state: Pick, + state: Pick< + AppState, + 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces' | 'projectGroups' + >, worktreeId: string, executionHostId?: ExecutionHostId ): Worktree | DetectedWorktreeListResult['worktrees'][number] | undefined { const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { + const preferredHostId = executionHostId ?? workspaceScope.ownerHostId const matches = state.folderWorkspaces.filter( (workspace) => workspace.id === workspaceScope.folderWorkspaceId && - (!executionHostId || folderWorkspaceMatchesHost(workspace, executionHostId)) + (!preferredHostId || + resolveFolderWorkspaceCatalogOwnerHostId(workspace, state.projectGroups) === + preferredHostId) ) const folderWorkspace = matches.length === 1 ? matches[0] : null if (!folderWorkspace) { @@ -2374,7 +2482,8 @@ const WORKTREE_ID_KEYED_MAP_KEYS = [ 'expandedDirs', 'lastVisitedAtByWorktreeId', 'defaultTerminalTabsAppliedByWorktreeId', - 'recentlyClosedTabKindsByWorktree' + 'recentlyClosedTabKindsByWorktree', + 'restoredRuntimeHostIdByWorkspaceSessionKey' ] as const satisfies readonly (keyof AppState)[] /** @@ -2507,10 +2616,11 @@ function buildWorktreeRenameState( ? { sleepingAgentSessionsByPaneKey } : {}), ...(s.activeWorktreeId === oldWorktreeId ? { activeWorktreeId: newWorktreeId } : {}), - // The active workspace key derives from the worktree id, so keep it in sync when the active worktree is renamed. - ...(s.activeWorkspaceKey === worktreeWorkspaceKey(oldWorktreeId) - ? { activeWorkspaceKey: worktreeWorkspaceKey(newWorktreeId) } - : {}), + ...(s.activeWorkspaceKey === oldWorktreeId + ? { activeWorkspaceKey: newWorktreeId as AppState['activeWorkspaceKey'] } + : s.activeWorkspaceKey === worktreeWorkspaceKey(oldWorktreeId) + ? { activeWorkspaceKey: worktreeWorkspaceKey(newWorktreeId) } + : {}), ...(s.renamingWorktreeId?.worktreeId === oldWorktreeId ? { renamingWorktreeId: { ...s.renamingWorktreeId, worktreeId: newWorktreeId } } : {}) @@ -3762,6 +3872,10 @@ export const createWorktreeSlice: StateCreator // Why: folder workspaces persist tabs under `folder:` keys that authoritative repo scans never return. for (const workspace of get().folderWorkspaces ?? []) { validIds.add(folderWorkspaceKey(workspace.id)) + const ownerHostId = resolveFolderWorkspaceCatalogOwnerHostId(workspace, get().projectGroups) + if (ownerHostId) { + validIds.add(folderWorkspaceKey(workspace.id, ownerHostId)) + } } for (const key of Object.keys(get().restoredRuntimeHostIdByWorkspaceSessionKey ?? {})) { if (parseWorkspaceKey(key)?.type === 'folder') { @@ -4091,7 +4205,7 @@ export const createWorktreeSlice: StateCreator const activeScope = parseWorkspaceKey(get().activeWorkspaceKey ?? '') const parentWorkspace = activeScope?.type === 'folder' - ? folderWorkspaceKey(activeScope.folderWorkspaceId) + ? folderWorkspaceKey(activeScope.folderWorkspaceId, activeScope.ownerHostId) : undefined const createArgs = { repoId, @@ -4896,7 +5010,15 @@ export const createWorktreeSlice: StateCreator updateWorktreeMeta: async (worktreeId, updates, options) => { const shouldApplyUpdate = options?.shouldApply - const existingWorktree = get().getKnownWorktreeById(worktreeId, options?.executionHostId) + const stateBeforeUpdate = get() + const modalExecutionHostId = + stateBeforeUpdate.activeModal === 'edit-meta' && + stateBeforeUpdate.modalData.worktreeId === worktreeId && + typeof stateBeforeUpdate.modalData.executionHostId === 'string' + ? parseExecutionHostId(stateBeforeUpdate.modalData.executionHostId)?.id + : undefined + const executionHostId = options?.executionHostId ?? modalExecutionHostId + const existingWorktree = get().getKnownWorktreeById(worktreeId, executionHostId) if (shouldApplyUpdate && !shouldApplyUpdate(existingWorktree)) { return { ok: true } } @@ -4912,7 +5034,7 @@ export const createWorktreeSlice: StateCreator const updated = await get().updateFolderWorkspace( workspaceScope.folderWorkspaceId, folderUpdates, - options?.executionHostId ? { executionHostId: options.executionHostId } : undefined + executionHostId ? { executionHostId } : undefined ) return updated ? { ok: true } @@ -5268,7 +5390,11 @@ export const createWorktreeSlice: StateCreator const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { const folderWorkspaceId = workspaceScope.folderWorkspaceId - const folderWorkspace = findFolderWorkspaceMetadataOwner(get(), folderWorkspaceId) + const folderWorkspace = findFolderWorkspaceMetadataOwner( + get(), + folderWorkspaceId, + workspaceScope.ownerHostId + ) if (!folderWorkspace || folderWorkspace.isUnread) { return } @@ -5415,7 +5541,11 @@ export const createWorktreeSlice: StateCreator const workspaceScope = parseWorkspaceKey(worktreeId) if (workspaceScope?.type === 'folder') { const folderWorkspaceId = workspaceScope.folderWorkspaceId - const folderWorkspace = findFolderWorkspaceMetadataOwner(get(), folderWorkspaceId) + const folderWorkspace = findFolderWorkspaceMetadataOwner( + get(), + folderWorkspaceId, + workspaceScope.ownerHostId + ) if (!folderWorkspace?.isUnread) { return } @@ -5477,7 +5607,11 @@ export const createWorktreeSlice: StateCreator // Why: folder meta lives on the FolderWorkspace record — persistWorktreeMeta would write a // worktreeMeta['folder:…'] row that folderWorkspaces:list never reads back (#10251). const folderWorkspaceId = workspaceScope.folderWorkspaceId - const folderWorkspace = findFolderWorkspaceMetadataOwner(get(), folderWorkspaceId) + const folderWorkspace = findFolderWorkspaceMetadataOwner( + get(), + folderWorkspaceId, + workspaceScope.ownerHostId + ) if (!folderWorkspace) { return } @@ -5702,6 +5836,21 @@ export const createWorktreeSlice: StateCreator }, setActiveWorktree: (worktreeId, executionHostId, options) => { + const requestedScope = worktreeId ? parseWorkspaceKey(worktreeId) : null + if (requestedScope?.type === 'folder') { + const target = resolveFolderWorkspaceSessionTarget( + get(), + requestedScope.folderWorkspaceId, + executionHostId ?? requestedScope.ownerHostId + ) + if (!target) { + return false + } + migrateFolderWorkspaceSessionAlias(set, target, get()) + if (target.workspaceKey !== worktreeId || executionHostId !== target.ownerHostId) { + return get().setActiveWorktree(target.workspaceKey, target.ownerHostId, options) + } + } const stateTransition = options?.stateTransition?.(get()) if (stateTransition && !stateTransition.activate) { if (Object.keys(stateTransition.patch).length > 0) { @@ -6034,7 +6183,8 @@ export const createWorktreeSlice: StateCreator if (workspaceScope?.type === 'folder') { const folderWorkspace = findFolderWorkspaceMetadataOwner( get(), - workspaceScope.folderWorkspaceId + workspaceScope.folderWorkspaceId, + workspaceScope.ownerHostId ?? executionHostId ) if (folderWorkspace) { persistFolderWorkspaceMetadata( @@ -6057,16 +6207,13 @@ export const createWorktreeSlice: StateCreator }, setActiveFolderWorkspace: (folderWorkspaceId, executionHostId) => { - const matchingWorkspaces = get().folderWorkspaces.filter( - (workspace) => - workspace.id === folderWorkspaceId && - (!executionHostId || folderWorkspaceMatchesHost(workspace, executionHostId)) - ) - if (matchingWorkspaces.length !== 1) { + const target = resolveFolderWorkspaceSessionTarget(get(), folderWorkspaceId, executionHostId) + if (!target) { return } - const workspaceKey = folderWorkspaceKey(folderWorkspaceId) - const workspace = findKnownWorktreeById(get(), workspaceKey, executionHostId) + migrateFolderWorkspaceSessionAlias(set, target, get()) + const { ownerHostId, workspaceKey } = target + const workspace = findKnownWorktreeById(get(), workspaceKey, ownerHostId) if (!workspace) { return } @@ -6152,7 +6299,7 @@ export const createWorktreeSlice: StateCreator activeRepoId: null, activeWorktreeId: workspaceKey, activeWorkspaceKey: workspaceKey, - activeWorkspaceExecutionHostId: executionHostId ?? null, + activeWorkspaceExecutionHostId: ownerHostId, activePendingCreationId: null, activeFileId, activeBrowserTabId, @@ -6166,7 +6313,7 @@ export const createWorktreeSlice: StateCreator folderWorkspaces: workspace.isUnread ? s.folderWorkspaces.map((entry) => entry.id === folderWorkspaceId && - (!executionHostId || folderWorkspaceMatchesHost(entry, executionHostId)) + resolveFolderWorkspaceCatalogOwnerHostId(entry, s.projectGroups) === ownerHostId ? { ...entry, isUnread: false } : entry ) @@ -6177,7 +6324,7 @@ export const createWorktreeSlice: StateCreator void get().updateFolderWorkspace( folderWorkspaceId, { isUnread: false }, - executionHostId ? { executionHostId } : undefined + { executionHostId: ownerHostId } ) } }, diff --git a/src/shared/workspace-scope.ts b/src/shared/workspace-scope.ts index 4993c0599d7..3dbfa4d48a8 100644 --- a/src/shared/workspace-scope.ts +++ b/src/shared/workspace-scope.ts @@ -1,21 +1,47 @@ +import { parseExecutionHostId, type ExecutionHostId } from './execution-host' import type { WorkspaceKey, WorkspaceScope } from './types' +type ParsedWorkspaceScope = + | Extract + | { type: 'folder'; folderWorkspaceId: string; ownerHostId?: ExecutionHostId } + export function worktreeWorkspaceKey(worktreeId: string): WorkspaceKey { return `worktree:${worktreeId}` } -export function folderWorkspaceKey(folderWorkspaceId: string): WorkspaceKey { +export function folderWorkspaceKey( + folderWorkspaceId: string, + ownerHostId?: ExecutionHostId +): WorkspaceKey { + if (ownerHostId) { + return `folder:${encodeURIComponent(ownerHostId)}:${encodeURIComponent(folderWorkspaceId)}` + } return `folder:${folderWorkspaceId}` } -export function parseWorkspaceKey(value: string): WorkspaceScope | null { +export function parseWorkspaceKey(value: string): ParsedWorkspaceScope | null { if (value.startsWith('worktree:')) { const worktreeId = value.slice('worktree:'.length) return worktreeId.length > 0 ? { type: 'worktree', worktreeId } : null } if (value.startsWith('folder:')) { - const folderWorkspaceId = value.slice('folder:'.length) - return folderWorkspaceId.length > 0 ? { type: 'folder', folderWorkspaceId } : null + const rest = value.slice('folder:'.length) + if (rest.length === 0) { + return null + } + const separator = rest.indexOf(':') + if (separator > 0) { + try { + const ownerHostId = parseExecutionHostId(decodeURIComponent(rest.slice(0, separator)))?.id + const folderWorkspaceId = decodeURIComponent(rest.slice(separator + 1)) + if (ownerHostId && folderWorkspaceId.length > 0) { + return { type: 'folder', folderWorkspaceId, ownerHostId } + } + } catch { + // Legacy folder ids remain valid even when percent-decoding fails. + } + } + return { type: 'folder', folderWorkspaceId: rest } } return null } @@ -32,7 +58,7 @@ export function getActiveSidebarWorkspaceId( ): string | null { const scope = activeWorkspaceKey ? parseWorkspaceKey(activeWorkspaceKey) : null if (scope?.type === 'folder') { - return folderWorkspaceKey(scope.folderWorkspaceId) + return folderWorkspaceKey(scope.folderWorkspaceId, scope.ownerHostId) } if (scope?.type === 'worktree') { return scope.worktreeId