fix(sidebar): isolate duplicate folder sessions

This commit is contained in:
Brennan Benson
2026-08-10 22:09:28 -07:00
parent ade4e9d3e0
commit eff5032569
20 changed files with 739 additions and 227 deletions
+11
View File
@@ -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 () => {
+42 -17
View File
@@ -4509,14 +4509,20 @@ export class Store {
)
const removedFolderWorkspaceKeys = new Set<string>()
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<string>()
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<string>()
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]
}
}
@@ -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,
@@ -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'
})
},
@@ -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,
@@ -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('<ProjectHeaderActions>', 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,
@@ -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
@@ -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'
})
@@ -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
}
@@ -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)),
@@ -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('<ProjectHeaderActions>', 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> = {}): 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',
+6 -2
View File
@@ -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,
@@ -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)
@@ -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
+16 -6
View File
@@ -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)
}
@@ -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()
})
@@ -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')
})
})
+46 -19
View File
@@ -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<AppState, [], [], RepoSlice> = (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<AppState, [], [], RepoSlice> = (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<AppState, [], [], RepoSlice> = (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<AppState, [], [], RepoSlice> = (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') {
+176 -29
View File
@@ -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<typeof folderWorkspaceKey>
aliasKey: ReturnType<typeof folderWorkspaceKey>
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<StateCreator<AppState>>[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<AppState, 'folderWorkspaces' | 'activeWorktreeId' | 'activeWorkspaceExecutionHostId'>,
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<AppState, 'worktreesByRepo' | 'detectedWorktreesByRepo' | 'folderWorkspaces'>,
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<AppState, [], [], WorktreeSlice>
// Why: folder workspaces persist tabs under `folder:<id>` 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<AppState, [], [], WorktreeSlice>
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<AppState, [], [], WorktreeSlice>
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<AppState, [], [], WorktreeSlice>
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<AppState, [], [], WorktreeSlice>
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<AppState, [], [], WorktreeSlice>
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<AppState, [], [], WorktreeSlice>
// 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<AppState, [], [], WorktreeSlice>
},
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<AppState, [], [], WorktreeSlice>
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<AppState, [], [], WorktreeSlice>
},
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<AppState, [], [], WorktreeSlice>
activeRepoId: null,
activeWorktreeId: workspaceKey,
activeWorkspaceKey: workspaceKey,
activeWorkspaceExecutionHostId: executionHostId ?? null,
activeWorkspaceExecutionHostId: ownerHostId,
activePendingCreationId: null,
activeFileId,
activeBrowserTabId,
@@ -6166,7 +6313,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
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<AppState, [], [], WorktreeSlice>
void get().updateFolderWorkspace(
folderWorkspaceId,
{ isUnread: false },
executionHostId ? { executionHostId } : undefined
{ executionHostId: ownerHostId }
)
}
},
+31 -5
View File
@@ -1,21 +1,47 @@
import { parseExecutionHostId, type ExecutionHostId } from './execution-host'
import type { WorkspaceKey, WorkspaceScope } from './types'
type ParsedWorkspaceScope =
| Extract<WorkspaceScope, { type: 'worktree' }>
| { 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