mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
fix(sidebar): isolate duplicate folder sessions
This commit is contained in:
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user