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