fix(sidebar): preserve folder owner routing

This commit is contained in:
Brennan Benson
2026-08-10 23:21:59 -07:00
parent 82afbb3071
commit 35cbaa1df3
14 changed files with 199 additions and 74 deletions
+14
View File
@@ -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 {
+5 -1
View File
@@ -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 = {
@@ -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(
@@ -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),
@@ -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'
)
})
@@ -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))
}
@@ -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>
): 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<FolderWorkspaceRow, 'key'>): 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
@@ -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}`
}
@@ -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
+5 -1
View File
@@ -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 =
@@ -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',
+1 -1
View File
@@ -2932,7 +2932,7 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (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 (
+1 -1
View File
@@ -6324,7 +6324,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
void get().updateFolderWorkspace(
folderWorkspaceId,
{ isUnread: false },
{ executionHostId: ownerHostId }
folderWorkspaceMutationOptions(get().folderWorkspaces, folderWorkspaceId, ownerHostId)
)
}
},
+6 -1
View File
@@ -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(