From 35cbaa1df38b36a110bc658f0b816dc85081aed6 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:21:59 -0700 Subject: [PATCH] fix(sidebar): preserve folder owner routing --- src/main/ipc/filesystem-auth.test.ts | 14 +++++ src/main/ipc/filesystem-auth.ts | 6 +- .../sidebar/WorktreeContextMenu.tsx | 15 ++--- .../src/components/sidebar/WorktreeList.tsx | 49 ++++++++-------- .../sidebar/project-group-header-dom.test.ts | 22 ++++++- .../rendered-sidebar-worktree-order.ts | 58 +++++++++++++++---- .../sidebar/worktree-list-groups.ts | 17 ++++++ .../sidebar/worktree-list-virtual-rows.ts | 2 +- .../src/lib/folder-workspace-connection.ts | 53 +++++++++-------- src/renderer/src/lib/worktree-activation.ts | 6 +- ...-same-id-folder-workspace-identity.test.ts | 20 +++++++ src/renderer/src/store/slices/repos.ts | 2 +- src/renderer/src/store/slices/worktrees.ts | 2 +- src/shared/folder-workspaces.ts | 7 ++- 14 files changed, 199 insertions(+), 74 deletions(-) diff --git a/src/main/ipc/filesystem-auth.test.ts b/src/main/ipc/filesystem-auth.test.ts index f0194c29b50..7633ff9e353 100644 --- a/src/main/ipc/filesystem-auth.test.ts +++ b/src/main/ipc/filesystem-auth.test.ts @@ -155,6 +155,20 @@ describe('filesystem-auth path containment', () => { expect(isPathAllowed(runtimePath, store)).toBe(false) }) + it('does not authorize an SSH repo when stale host metadata says local', () => { + const sshPath = resolve('/ssh-only/repo') + const store = makeStore([ + { + ...repo, + path: sshPath, + connectionId: 'ssh-target', + executionHostId: 'local' + } + ]) + + expect(isPathAllowed(sshPath, store)).toBe(false) + }) + it('authorizes missing nested descendants under an allowed repo', async () => { const tempRoot = await mkdtemp(join(tmpdir(), 'orca-auth-missing-')) try { diff --git a/src/main/ipc/filesystem-auth.ts b/src/main/ipc/filesystem-auth.ts index faff332ed1f..730facc04ad 100644 --- a/src/main/ipc/filesystem-auth.ts +++ b/src/main/ipc/filesystem-auth.ts @@ -68,7 +68,11 @@ export function invalidateAuthorizedRootsCache(): void { function getLocalRepos(store: Store) { // Why: SSH repo paths are remote-host paths; treating them as local roots could authorize unrelated local folders or probe SSH-only paths. - return store.getRepos().filter((repo) => getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID) + return store + .getRepos() + .filter( + (repo) => !repo.connectionId && getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID + ) } type FolderAuthIndex = { diff --git a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx index 21984d1d975..987b8021b61 100644 --- a/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx +++ b/src/renderer/src/components/sidebar/WorktreeContextMenu.tsx @@ -32,12 +32,7 @@ import { } from 'lucide-react' import { useAppStore } from '@/store' import type { AppState } from '@/store/types' -import { - selectRepoByIdForActiveWorkspace, - useAllWorktrees, - useRepoMap, - useWorktreeMap -} from '@/store/selectors' +import { useAllWorktrees, useRepoMap, useWorktreeMap } from '@/store/selectors' import { cn } from '@/lib/utils' import type { ProjectGroup, @@ -103,14 +98,14 @@ const DELETE_POSITION_RESTORE_STABLE_FRAMES = 6 const PARENT_PICKER_EXIT_ANIMATION_MS = 200 export function findContextMenuRepo( - repos: Repo[], + repos: readonly Repo[] | undefined, repoId: string, executionHostId: ExecutionHostId ): Repo | null { - return selectRepoByIdForActiveWorkspace( - { repos, activeRepoId: repoId, activeWorkspaceExecutionHostId: executionHostId }, - repoId + const matches = (repos ?? []).filter( + (repo) => repo.id === repoId && getRepoExecutionHostId(repo) === executionHostId ) + return matches.length === 1 ? matches[0] : null } export function resolveWorktreeContextMenuTargets( diff --git a/src/renderer/src/components/sidebar/WorktreeList.tsx b/src/renderer/src/components/sidebar/WorktreeList.tsx index 400152dffe5..a9670660c5e 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.tsx @@ -99,8 +99,10 @@ import { getSidebarWorktreeSelectionId, getSingleProjectGroupMutationOwner, getGroupKeysForWorktree, + getFolderWorkspaceRenderRowKey, getLineageGroupKey, getPinnedWorktreeDisplayPolicy, + getSidebarWorkspaceActivationId, parseProjectGroupSidebarHeaderKey, type ProjectGroupMutationSelector, type PinnedWorktreeDisplayPolicy @@ -110,7 +112,7 @@ import { estimateRenderRowSize, extractWorktreeVirtualRowIndexes, getActiveStickyIndexesForScroll, - getRenderRowKey, + getRenderRowKey as getVirtualRenderRowKey, getStickyHeaderIndexes, getVirtualRowTransform, HOST_STICKY_GROUP_TOP_PX, @@ -324,7 +326,6 @@ import { getVisibleSidebarHostIdSet } from './worktree-list-host-filtering' import { getFolderWorkspaceCardPrDisplay } from './folder-workspace-card-pr-display' -import { getRenderedWorktreesInSidebarOrder } from './worktree-sidebar-row-preference' import { getCyclableWorktreeIds, resolveCycledWorktreeId } from './worktree-keyboard-cycle' export { getScrollTopToRevealBounds, @@ -1240,10 +1241,6 @@ export function getPinnedWorktreeRevealCollapsedGroupKeys({ return keys } -function uniqueWorktreeIds(ids: readonly string[]): string[] { - return Array.from(new Set(ids)) -} - function getRenderedSidebarSelectionEntries( rows: readonly HostSectionRow[], defaultHostId: ExecutionHostId, @@ -1331,9 +1328,11 @@ function buildRenderableRows(rows: HostSectionRow[]): RenderRow[] { return renderRows } -// Why: getRenderRowKey lives with the other virtual-row helpers now; keep the -// long-standing import path working for callers that reach for it here. -export { getRenderRowKey } +export function getRenderRowKey(row: RenderRow): string { + return row.type === 'folder-workspace' + ? getFolderWorkspaceRenderRowKey(row) + : getVirtualRenderRowKey(row) +} export function getWorktreeDragGroups(rows: HostSectionRow[]): WorktreeDragGroup[] { const groups: WorktreeDragGroup[] = [] @@ -6106,24 +6105,28 @@ const WorktreeList = React.memo(function WorktreeList({ const visibleHostResetKey = visibleWorkspaceHostIds?.join(',') ?? 'all' const viewportResetKey = `group:${groupBy}:host:${visibleHostResetKey}:lineage` - // Why: derive order from the built rows, not the flat worktrees array, so Cmd+1–9 match visual positions when grouping reorders cards. - const renderedWorktrees = useMemo( - () => getRenderedWorktreesInSidebarOrder(sectionRows, pinnedDisplayPolicy), - [pinnedDisplayPolicy, sectionRows] - ) - // Why: order-preserving sectionRows rebuilds must not give this array a new - // identity — updateSelectionForGesture depends on it, and a fresh identity - // there defeats React.memo bail-out for every WorktreeCard on epoch bumps. - const renderedWorktreeIds = useReusedArrayIdentity( - useMemo( - () => uniqueWorktreeIds(renderedWorktrees.map((worktree) => worktree.id)), - [renderedWorktrees] - ) - ) const renderedWorktreeSelectionEntries = useMemo( () => getRenderedSidebarSelectionEntries(sectionRows, defaultHostId, pinnedDisplayPolicy), [defaultHostId, pinnedDisplayPolicy, sectionRows] ) + // Why: shortcut activation needs the same owner-qualified folder identity as selection when same ids collide. + const renderedWorktreeIds = useReusedArrayIdentity( + useMemo( + () => + Array.from( + new Set( + renderedWorktreeSelectionEntries.map(({ worktree }) => + getSidebarWorkspaceActivationId( + worktree, + getWorktreeExecutionHostId(worktree, undefined, defaultHostId), + ownerQualifiedFolderWorkspaceIds + ) + ) + ) + ), + [defaultHostId, ownerQualifiedFolderWorkspaceIds, renderedWorktreeSelectionEntries] + ) + ) const renderedWorktreeSelectionIds = useReusedArrayIdentity( useMemo( () => renderedWorktreeSelectionEntries.map((entry) => entry.selectionId), diff --git a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts index 09a33dc259f..37a68295a58 100644 --- a/src/renderer/src/components/sidebar/project-group-header-dom.test.ts +++ b/src/renderer/src/components/sidebar/project-group-header-dom.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vite import type { ExecutionHostId } from '../../../../shared/execution-host' import { getFolderWorkspaceRowKey } from '../../../../shared/folder-workspaces' import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import { folderWorkspaceKey } from '../../../../shared/workspace-scope' import type { FolderWorkspace, ProjectGroup, Repo, Worktree } from '../../../../shared/types' import { buildRows, @@ -14,16 +15,27 @@ import { findProjectGroupForRepo, findProjectGroupForSidebarOwner, getAmbiguousFolderWorkspaceSidebarIds, + getFolderWorkspaceRenderRowKey, getProjectGroupMutationSelector, getProjectGroupHeaderKey, getProjectGroupSidebarIdentity, + getSidebarWorkspaceActivationId, getSidebarWorktreeSelectionId, getSingleProjectGroupMutationOwner, hasSingleProjectGroupMutationOwner, parseProjectGroupSidebarHeaderKey } from './worktree-list-groups' import { addHostSectionRows } from './host-section-rows' -import { getRenderRowKey } from './worktree-list-virtual-rows' +import { + getRenderRowKey as getVirtualRenderRowKey, + type RenderRow +} from './worktree-list-virtual-rows' + +function getRenderRowKey(row: RenderRow): string { + return row.type === 'folder-workspace' + ? getFolderWorkspaceRenderRowKey(row) + : getVirtualRenderRowKey(row) +} function sidebarIdentityGroup(id: string, executionHostId: string): ProjectGroup { return { @@ -228,6 +240,12 @@ describe('project-group sidebar identity', () => { expect(getSidebarWorktreeSelectionId(local, undefined, 'local')).not.toBe( getSidebarWorktreeSelectionId(runtime, undefined, 'local') ) + expect(getSidebarWorkspaceActivationId(local, 'local', new Set(['same-id']))).toBe( + folderWorkspaceKey('same-id', 'local') + ) + expect(getSidebarWorkspaceActivationId(runtime, 'runtime:env-1', new Set(['same-id']))).toBe( + folderWorkspaceKey('same-id', 'runtime:env-1') + ) }) it('does not fall back to a foreign group for an explicit stale owner', () => { @@ -507,7 +525,7 @@ describe('duplicate project-group header identity (#12532)', () => { const folderRow = rows.find((row) => row.type === 'folder-workspace') expect(folderRow?.key).toBe('folder-workspace:@owner:runtime%3Aenv-1:same-folder') - expect(folderRow && getRenderRowKey(folderRow)).toBe( + expect(folderRow && getFolderWorkspaceRenderRowKey(folderRow)).toBe( 'folder-workspace:@owner:runtime%3Aenv-1:same-folder' ) }) diff --git a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts index fccdc79955c..84ee7fc8958 100644 --- a/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts +++ b/src/renderer/src/components/sidebar/rendered-sidebar-worktree-order.ts @@ -1,18 +1,29 @@ -import type { Worktree } from '../../../../shared/types' +import type { FolderWorkspace, ProjectGroup, Worktree } from '../../../../shared/types' import type { AppState } from '@/store/types' import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' import { ALL_EXECUTION_HOSTS_SCOPE, - getSettingsFocusedExecutionHostId + getSettingsFocusedExecutionHostId, + type ExecutionHostId } from '../../../../shared/execution-host' import { getRepoMapFromState, getWorktreeMapFromState } from '@/store/selectors' import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector' -import { buildRows, getPinnedWorktreeDisplayPolicy } from './worktree-list-groups' +import { + buildProjectGroupSidebarIndex, + buildRows, + getAmbiguousFolderWorkspaceSidebarIds, + getPinnedWorktreeDisplayPolicy, + getProjectGroupMutationSelector, + getSidebarWorkspaceActivationId, + type WorktreeRow +} from './worktree-list-groups' import { addHostSectionRows } from './host-section-rows' import { orderHostSectionOptions } from './host-section-order' import { buildSidebarHostOptions } from './sidebar-host-options' import { getLogicalRepoOrderRankById } from './project-header-drop' -import { getRenderedWorktreesInSidebarOrder } from './worktree-sidebar-row-preference' +import { getPreferredWorktreeRows } from './worktree-sidebar-row-preference' +import { folderWorkspaceToWorktree } from '../../../../shared/folder-workspace-worktree' +import { resolveFolderWorkspaceCatalogOwnerHostId } from '../../../../shared/folder-workspaces' import { selectWorktreeListReviewCacheInputs } from './worktree-list-review-cache-inputs' import { filterFolderWorkspacesForVisibleHosts, @@ -25,6 +36,16 @@ const EMPTY_IMPORTED_BY_REPO = Object.freeze(new Map()) as never const EMPTY_INBOX_BY_REPO = Object.freeze(new Map()) as never const EMPTY_PENDING_CREATIONS = Object.freeze([]) as never +function getFolderOwnerHostId(row: { + folderWorkspace: FolderWorkspace + projectGroup: ProjectGroup +}): ExecutionHostId { + return ( + resolveFolderWorkspaceCatalogOwnerHostId(row.folderWorkspace, [row.projectGroup]) ?? + getProjectGroupMutationSelector(row.projectGroup).ownerHostId + ) +} + /** * Orders already-filtered worktrees the way the sidebar would render them, for * Cmd+1–9 numbering while WorktreeList is unmounted (#9497). @@ -112,11 +133,28 @@ export function computeRenderedSidebarWorktreeOrder( }) : rows - return Array.from( - new Set( - getRenderedWorktreesInSidebarOrder(sectionRows, pinnedDisplayPolicy).map( - (worktree) => worktree.id - ) - ) + const itemRows = sectionRows.filter((row): row is WorktreeRow => row.type === 'item') + const preferredItemRowKeys = new Set( + getPreferredWorktreeRows(itemRows, pinnedDisplayPolicy).map((row) => row.rowKey) ) + const ambiguousFolderWorkspaceIds = getAmbiguousFolderWorkspaceSidebarIds( + buildProjectGroupSidebarIndex(projectGroups), + state.folderWorkspaces + ) + + const workspaceIds: string[] = [] + for (const row of sectionRows) { + if (row.type === 'item' && preferredItemRowKeys.has(row.rowKey)) { + workspaceIds.push(row.worktree.id) + } else if (row.type === 'folder-workspace') { + workspaceIds.push( + getSidebarWorkspaceActivationId( + folderWorkspaceToWorktree(row.folderWorkspace), + getFolderOwnerHostId(row), + ambiguousFolderWorkspaceIds + ) + ) + } + } + return Array.from(new Set(workspaceIds)) } diff --git a/src/renderer/src/components/sidebar/worktree-list-groups.ts b/src/renderer/src/components/sidebar/worktree-list-groups.ts index e4f4ab71f0e..69792106951 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups.ts @@ -32,7 +32,9 @@ import { UNGROUPED_PROJECT_GROUP_KEY } from '../../../../shared/project-groups' import { + folderWorkspaceKey, getProjectGroupSelectorKey, + parseWorkspaceKey, parseProjectGroupSelectorKey } from '../../../../shared/workspace-scope' import { @@ -78,6 +80,17 @@ export function getSidebarWorktreeSelectionId( return JSON.stringify([getWorktreeExecutionHostId(worktree, repo, defaultHostId), worktree.id]) } +export function getSidebarWorkspaceActivationId( + worktree: Worktree, + ownerHostId: ExecutionHostId, + ambiguousFolderWorkspaceIds: ReadonlySet +): string { + const scope = parseWorkspaceKey(worktree.id) + return scope?.type === 'folder' && ambiguousFolderWorkspaceIds.has(scope.folderWorkspaceId) + ? folderWorkspaceKey(scope.folderWorkspaceId, ownerHostId) + : worktree.id +} + export type ProjectGroupSidebarIdentity = string export type ProjectGroupMutationSelector = { @@ -375,6 +388,10 @@ export type FolderWorkspaceRow = { groupDepth: number } +export function getFolderWorkspaceRenderRowKey(row: Pick): string { + return row.key +} + /** Minimal shape buildRows needs for an in-flight create. Deliberately not the * full PendingWorktreeCreation: row identity depends only on which creates * exist and their repo, so callers can subscribe on this stable shape and keep diff --git a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts index fa97a147b58..8b9599766ee 100644 --- a/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts +++ b/src/renderer/src/components/sidebar/worktree-list-virtual-rows.ts @@ -38,7 +38,7 @@ export function getRenderRowKey(row: RenderRow): string { return `pending:${row.creationId}` } if (row.type === 'folder-workspace') { - return row.key + return `folder-workspace:${row.folderWorkspace.id}` } return `wt:${row.rowKey}` } diff --git a/src/renderer/src/lib/folder-workspace-connection.ts b/src/renderer/src/lib/folder-workspace-connection.ts index 29446058443..b21509e71b0 100644 --- a/src/renderer/src/lib/folder-workspace-connection.ts +++ b/src/renderer/src/lib/folder-workspace-connection.ts @@ -4,7 +4,8 @@ import { buildProjectGroupOwnerIndex, getProjectGroupIdentity, getProjectGroupOwnerHostId, - getProjectGroupOwnerSubtreeIdentities + getProjectGroupOwnerSubtreeIdentities, + getProjectGroupSubtreeIds } from '../../../shared/project-groups' import { resolveFolderWorkspaceCatalogOwnerHostId, @@ -29,6 +30,7 @@ function getFolderScopeCandidateRepos(args: { folderPath: string projectGroup: ProjectGroup ownerHostId: ExecutionHostId + inferLegacyOwner: boolean connectionId?: string | null projectGroups: readonly ProjectGroup[] repos: readonly Repo[] @@ -37,22 +39,21 @@ function getFolderScopeCandidateRepos(args: { args.projectGroups, args.projectGroup ) - const groupRepos = args.repos.filter( - (repo) => - typeof repo.projectGroupId === 'string' && - groupIdentities.has( - getProjectGroupIdentity(repo.projectGroupId, getRepoExecutionHostId(repo)) - ) - ) + const legacyGroupIds = args.inferLegacyOwner + ? getProjectGroupSubtreeIds(args.projectGroups, args.projectGroup.id) + : null + const repoIsInGroup = (repo: Repo): boolean => + typeof repo.projectGroupId === 'string' && + (legacyGroupIds + ? legacyGroupIds.has(repo.projectGroupId) + : groupIdentities.has( + getProjectGroupIdentity(repo.projectGroupId, getRepoExecutionHostId(repo)) + )) + const groupRepos = args.repos.filter((repo) => repoIsInGroup(repo)) const pathRepos = args.repos.filter( (repo) => - getRepoExecutionHostId(repo) === args.ownerHostId && - !( - typeof repo.projectGroupId === 'string' && - groupIdentities.has( - getProjectGroupIdentity(repo.projectGroupId, getRepoExecutionHostId(repo)) - ) - ) && + (args.inferLegacyOwner || getRepoExecutionHostId(repo) === args.ownerHostId) && + !repoIsInGroup(repo) && isPathInsideOrEqual(args.folderPath, repo.path) ) if (args.connectionId) { @@ -75,14 +76,20 @@ function findFolderWorkspaceScope( state: FolderWorkspaceConnectionState, folderWorkspaceId: string, ownerHostId?: ExecutionHostId -): { workspace: FolderWorkspace; projectGroup: ProjectGroup; ownerHostId: ExecutionHostId } | null { +): { + workspace: FolderWorkspace + projectGroup: ProjectGroup + ownerHostId: ExecutionHostId + inferLegacyOwner: boolean +} | null { + const candidates = state.folderWorkspaces.filter((entry) => entry.id === folderWorkspaceId) const activeScope = parseWorkspaceKey(state.activeWorktreeId ?? '') const activeOwnerHostId = activeScope?.type === 'folder' && activeScope.folderWorkspaceId === folderWorkspaceId - ? (activeScope.ownerHostId ?? state.activeWorkspaceExecutionHostId) + ? (activeScope.ownerHostId ?? + (candidates.length > 1 ? state.activeWorkspaceExecutionHostId : undefined)) : undefined const requestedOwnerHostId = ownerHostId ?? activeOwnerHostId ?? undefined - const candidates = state.folderWorkspaces.filter((entry) => entry.id === folderWorkspaceId) const workspace = requestedOwnerHostId ? candidates.find( (entry) => @@ -95,17 +102,16 @@ function findFolderWorkspaceScope( if (!workspace) { return null } - const projectGroup = resolveFolderWorkspaceProjectGroupWithLegacySsh( - buildProjectGroupOwnerIndex(state.projectGroups), - workspace - ) + const projectGroupIndex = buildProjectGroupOwnerIndex(state.projectGroups) + const projectGroup = resolveFolderWorkspaceProjectGroupWithLegacySsh(projectGroupIndex, workspace) if (!projectGroup) { return null } return { workspace, projectGroup, - ownerHostId: getProjectGroupOwnerHostId(projectGroup) + ownerHostId: getProjectGroupOwnerHostId(projectGroup), + inferLegacyOwner: projectGroupIndex.byId.get(projectGroup.id)?.length === 1 } } @@ -122,6 +128,7 @@ export function getFolderWorkspaceCandidateRepos( folderPath: scope.workspace.folderPath, projectGroup: scope.projectGroup, ownerHostId: scope.ownerHostId, + inferLegacyOwner: scope.inferLegacyOwner, connectionId: scope.workspace.connectionId ?? scope.projectGroup.connectionId ?? null, projectGroups: state.projectGroups, repos: state.repos diff --git a/src/renderer/src/lib/worktree-activation.ts b/src/renderer/src/lib/worktree-activation.ts index aa4e9050ca3..fb3a82e826c 100644 --- a/src/renderer/src/lib/worktree-activation.ts +++ b/src/renderer/src/lib/worktree-activation.ts @@ -237,12 +237,16 @@ export function activateAndRevealFolderWorkspace( opts && 'runtimeEnvironmentId' in opts ? (opts.runtimeEnvironmentId ?? null) : getRuntimeEnvironmentIdForFolderWorkspace(state, folderWorkspaceId, ownerHostId) + const pathStatusOwnerHostId = + state.folderWorkspaces.filter((workspace) => workspace.id === folderWorkspaceId).length > 1 + ? ownerHostId + : undefined const pathStatus = state.getFreshFolderWorkspacePathStatus( { scope: 'folder-workspace', folderWorkspaceId }, - { runtimeEnvironmentId, ownerHostId } + { runtimeEnvironmentId, ownerHostId: pathStatusOwnerHostId } ) if (folderWorkspaceActivationBlocked(pathStatus)) { const title = diff --git a/src/renderer/src/store/slices/repos-same-id-folder-workspace-identity.test.ts b/src/renderer/src/store/slices/repos-same-id-folder-workspace-identity.test.ts index 3b4b6f6b5b5..6ca92fb2bf1 100644 --- a/src/renderer/src/store/slices/repos-same-id-folder-workspace-identity.test.ts +++ b/src/renderer/src/store/slices/repos-same-id-folder-workspace-identity.test.ts @@ -3,6 +3,7 @@ import type { FolderWorkspace, ProjectGroup, Repo } from '../../../../shared/typ import { getFolderWorkspaceRowKey } from '../../../../shared/folder-workspaces' import { folderWorkspaceKey, parseWorkspaceKey } from '../../../../shared/workspace-scope' import { buildHostIdByWorktreeId } from '../../lib/workspace-session-host-persistence' +import { computeRenderedSidebarWorktreeOrder } from '../../components/sidebar/rendered-sidebar-worktree-order' import { getFolderWorkspaceCandidateRepos, getFolderWorkspaceConnectionId @@ -51,6 +52,25 @@ describe('same-id cross-host folder workspace identity', () => { vi.unstubAllGlobals() }) + it('keeps both owner-qualified folders in closed-sidebar shortcut order', () => { + const store = createTestStore() + 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' }) + ] + }) + + expect(computeRenderedSidebarWorktreeOrder(store.getState(), [])).toEqual([ + folderWorkspaceKey('same-id', 'local'), + folderWorkspaceKey('same-id', 'runtime:env-1') + ]) + }) + it('deletes only the owner-qualified folder row and its session keys', async () => { const local = makeFolder({ name: 'Local', diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 9572b67f9a3..a629b189e4d 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -2932,7 +2932,7 @@ export const createRepoSlice: StateCreator = (set, const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: runtimeEnvironmentId }) const updateIdentity = getFolderWorkspaceUpdateIdentity(ownerHostId, folderWorkspaceId) const includeOwnerHostId = - executionHostId !== undefined || + options?.executionHostId !== undefined || state.folderWorkspaces.filter((workspace) => workspace.id === folderWorkspaceId).length > 1 // Why: same gate as folderWorkspace.create — an older paired runtime would drop the Jira link silently. if ( diff --git a/src/renderer/src/store/slices/worktrees.ts b/src/renderer/src/store/slices/worktrees.ts index 4afd4a000f9..ef5f0991fe5 100644 --- a/src/renderer/src/store/slices/worktrees.ts +++ b/src/renderer/src/store/slices/worktrees.ts @@ -6324,7 +6324,7 @@ export const createWorktreeSlice: StateCreator void get().updateFolderWorkspace( folderWorkspaceId, { isUnread: false }, - { executionHostId: ownerHostId } + folderWorkspaceMutationOptions(get().folderWorkspaces, folderWorkspaceId, ownerHostId) ) } }, diff --git a/src/shared/folder-workspaces.ts b/src/shared/folder-workspaces.ts index 6518247fdc0..c8787a1a6f0 100644 --- a/src/shared/folder-workspaces.ts +++ b/src/shared/folder-workspaces.ts @@ -96,7 +96,12 @@ export function resolveFolderWorkspaceCatalogOwnerHostIdFromIndex( if (group) { return getFolderWorkspaceProjectGroupOwnerHostId(workspace, index) } - return workspace.connectionId ? toSshExecutionHostId(workspace.connectionId) : null + if (workspace.connectionId !== undefined) { + return workspace.connectionId + ? toSshExecutionHostId(workspace.connectionId) + : LOCAL_EXECUTION_HOST_ID + } + return null } export function getFolderWorkspaceOwnerIdentity(