feat(sidebar): include folder workspaces in keyboard navigation

Use the rendered sidebar row order and host identity when cycling through folder and Git workspaces.

Related: https://github.com/stablyai/orca/pull/10555

Co-authored-by: Neil <neil@stably.ai>
Co-authored-by: JeongUk Park <jeongph.dev@gmail.com>
This commit is contained in:
Neil
2026-10-03 00:51:30 -07:00
committed by GitHub
co-authored by Neil JeongUk Park
parent c1d403a47b
commit 7b8498b406
9 changed files with 307 additions and 42 deletions
@@ -1,11 +1,49 @@
import { describe, expect, it } from 'vitest'
import type { HostSectionRow } from './host-section-rows'
import type { FolderWorkspaceRow } from './worktree-list/grouping/row-types'
import {
getCyclableRowIdentity,
getCyclableWorktreeRows,
getCyclableWorktreeIds,
getCyclableWorktrees,
resolveActiveCycleIdentity,
resolveCycledWorktreeId
} from './worktree-keyboard-cycle'
const folderRow: FolderWorkspaceRow = {
type: 'folder-workspace',
key: 'folder-workspace:folder-1',
folderWorkspace: {
id: 'folder-1',
projectGroupId: 'group-1',
name: 'Folder 1',
folderPath: '/group-1/folder-1',
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 1,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
},
projectGroup: {
id: 'group-1',
name: 'Group 1',
parentPath: '/group-1',
parentGroupId: null,
createdFrom: 'folder-scan',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
},
depth: 0,
groupDepth: 0
}
describe('resolveCycledWorktreeId', () => {
const worktreeIds = ['a', 'b', 'c']
@@ -110,22 +148,62 @@ describe('getCyclableWorktreeIds', () => {
])
})
it('leaves folder workspaces out of the rotation', () => {
// Why: their synthetic `folder:` id is not activatable through
// activateAndRevealWorktree, so arrowing onto one would be a dead keypress.
const rows: HostSectionRow[] = [
{
type: 'folder-workspace',
key: 'folder-workspace:folder-1',
folderWorkspace: { id: 'folder-1', projectGroupId: 'group-1' } as never,
projectGroup: { id: 'group-1' } as never,
depth: 0,
groupDepth: 0
},
worktree('plain-b')
it('includes folder workspaces between git worktrees in visible order', () => {
const rows = [worktree('a'), folderRow, worktree('b')]
expect(getCyclableWorktreeIds(rows, 'single-location')).toEqual(['a', 'folder:folder-1', 'b'])
})
it('anchors both directions on the active folder workspace', () => {
const rows = getCyclableWorktreeRows(
[worktree('a'), folderRow, worktree('b')],
'single-location'
)
const activeWorktreeId = resolveActiveCycleIdentity({
rows,
activeWorktreeId: 'folder:folder-1',
activeWorkspaceExecutionHostId: 'local'
})
const worktreeIds = rows.map(getCyclableRowIdentity)
expect(resolveCycledWorktreeId({ worktreeIds, activeWorktreeId, direction: 'up' })).toBe(
getCyclableRowIdentity(rows[0])
)
expect(resolveCycledWorktreeId({ worktreeIds, activeWorktreeId, direction: 'down' })).toBe(
getCyclableRowIdentity(rows[2])
)
})
it('keeps folder placement while preferring a pinned worktree natural row', () => {
const rows = [
worktree('dup', true),
folderRow,
{ ...worktree('dup'), rowKey: 'row:dup-natural' },
worktree('b')
]
expect(getCyclableWorktreeIds(rows, 'single-location')).toEqual(['plain-b'])
expect(getCyclableWorktreeIds(rows, 'duplicate-in-groups')).toEqual([
'folder:folder-1',
'dup',
'b'
])
})
it('keeps folder keys distinct from git ids and preserves same-id host ownership', () => {
const otherHostFolder = {
...folderRow,
folderWorkspace: { ...folderRow.folderWorkspace, executionHostId: 'ssh:host-b' as const }
}
const rows = getCyclableWorktreeRows(
[worktree('folder-1'), folderRow, otherHostFolder],
'single-location'
)
expect(rows.map(getCyclableRowIdentity)).toEqual([
'local|folder-1',
'local|folder:folder-1',
'ssh:host-b|folder:folder-1'
])
})
it('drops worktrees the sidebar elided inside a collapsed host section', () => {
@@ -2,8 +2,11 @@ import type { HostSectionRow } from './host-section-rows'
import type { Worktree } from '../../../../shared/worktree/types'
import { composeWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity'
import { getWorktreeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host'
import type { PinnedWorktreeDisplayPolicy, WorktreeRow } from './worktree-list/grouping/row-types'
import { getPreferredWorktreeRows } from './worktree-sidebar-row-preference'
import type { PinnedWorktreeDisplayPolicy } from './worktree-list/grouping/row-types'
import {
getRenderedWorkspaceRowsInSidebarOrder,
type RenderedWorkspaceRow
} from './worktree-sidebar-row-preference'
/** Host-resolved identity for a cyclable row.
*
@@ -11,7 +14,7 @@ import { getPreferredWorktreeRows } from './worktree-sidebar-row-preference'
* `hostId` (`withRepoHostOwnership` leaves it unqualified), but every activation
* path stores the host it resolved to, so raw and resolved identities never match.
*/
export function getCyclableRowIdentity(row: Pick<WorktreeRow, 'worktree' | 'repo'>): string {
export function getCyclableRowIdentity(row: RenderedWorkspaceRow): string {
return composeWorktreeHostIdentity(
getWorktreeExecutionHostId(row.worktree, row.repo),
row.worktree.id
@@ -21,14 +24,13 @@ export function getCyclableRowIdentity(row: Pick<WorktreeRow, 'worktree' | 'repo
export function getCyclableWorktreeRows(
rows: readonly HostSectionRow[],
pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy
): WorktreeRow[] {
const itemRows = rows.filter((row): row is WorktreeRow => row.type === 'item')
return getPreferredWorktreeRows(itemRows, pinnedDisplayPolicy)
): RenderedWorkspaceRow[] {
return getRenderedWorkspaceRowsInSidebarOrder(rows, pinnedDisplayPolicy)
}
/** Identity that locates the active workspace among the cyclable rows. */
export function resolveActiveCycleIdentity(args: {
rows: readonly WorktreeRow[]
rows: readonly RenderedWorkspaceRow[]
activeWorktreeId: string | null
activeWorkspaceExecutionHostId: ExecutionHostId | null
}): string | null {
@@ -44,14 +46,12 @@ export function resolveActiveCycleIdentity(args: {
return row ? getCyclableRowIdentity(row) : null
}
/** Worktree ids in sidebar order, taken from the rows the sidebar actually
/** Workspace ids in sidebar order, taken from the rows the sidebar actually
* rendered, so collapsed groups and collapsed host sections drop out on their own. */
export function getCyclableWorktreeIds(
rows: readonly HostSectionRow[],
pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy
): string[] {
// Why item-only: folder workspaces render as their own row type and are not
// activatable through activateAndRevealWorktree, so cycling has never included them.
const ids: string[] = []
const seen = new Set<string>()
for (const row of getCyclableWorktreeRows(rows, pinnedDisplayPolicy)) {
@@ -47,7 +47,7 @@ describe('host-qualified reveal lookup finds folder workspaces', () => {
projectGroup: PROJECT_GROUP,
depth: 0,
groupDepth: 0
} as RenderRow
}
]
const index = findPreferredRenderRowIndexForWorktreeIdentity(
@@ -68,7 +68,7 @@ describe('host-qualified reveal lookup finds folder workspaces', () => {
projectGroup: PROJECT_GROUP,
depth: 0,
groupDepth: 0
} as RenderRow
}
]
expect(
@@ -79,4 +79,28 @@ describe('host-qualified reveal lookup finds folder workspaces', () => {
)
).toBe(-1)
})
it.each([
['local', 0],
['runtime:env', 1],
[undefined, 0],
['runtime:missing', -1]
] as const)('matches the folder row for host %s', (hostId, expectedIndex) => {
const rows: RenderRow[] = (['local', 'runtime:env'] as const).map((executionHostId) => ({
type: 'folder-workspace',
key: `folder-workspace:${executionHostId}:${FOLDER_WORKSPACE.id}`,
folderWorkspace: { ...FOLDER_WORKSPACE, executionHostId },
projectGroup: PROJECT_GROUP,
depth: 0,
groupDepth: 0
}))
expect(
findPreferredRenderRowIndexForWorktreeIdentity(
rows,
{ id: folderWorkspaceKey(FOLDER_WORKSPACE.id), hostId },
'single-location'
)
).toBe(expectedIndex)
})
})
@@ -1,4 +1,5 @@
import { folderWorkspaceKey } from '../../../../../../shared/workspace-scope'
import { folderWorkspaceToWorktree } from '../../../../../../shared/folder-workspace-worktree'
import { getWorktreeExecutionHostId } from '../../../../../../shared/execution-host'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
import type { Worktree } from '../../../../../../shared/worktree/types'
@@ -112,7 +113,11 @@ export function findPreferredRenderRowIndexForWorktreeIdentity(
// Why: host-qualified reveals are emitted for folder workspaces too, and a
// walker that only knows item rows returns -1 so the reveal never lands.
if (row.type === 'folder-workspace') {
if (folderWorkspaceKey(row.folderWorkspace.id) === worktree.id) {
if (
folderWorkspaceKey(row.folderWorkspace.id) === worktree.id &&
(!worktree.hostId ||
getWorktreeHostIdentity(folderWorkspaceToWorktree(row.folderWorkspace)) === identity)
) {
return index
}
continue
@@ -148,6 +148,7 @@ describe('workspace list focus ownership', () => {
expect(activate.mock.calls.map(([id]) => id)).toEqual(['b', 'c', 'b'])
expect(activate).toHaveBeenLastCalledWith('b', {
navigationIntent: 'user-open',
revealInSidebar: false,
executionHostId: 'ssh:fixture'
})
expect(terminal.focus).not.toHaveBeenCalled()
@@ -101,7 +101,10 @@ describe('worktree keyboard cycling with a resolved active host', () => {
press('down')
expect(activateAndRevealWorktree).toHaveBeenCalledWith('c', { navigationIntent: 'user-open' })
expect(activateAndRevealWorktree).toHaveBeenCalledWith('c', {
navigationIntent: 'user-open',
revealInSidebar: false
})
})
it('steps to the previous row when the active host resolved to local', () => {
@@ -109,7 +112,10 @@ describe('worktree keyboard cycling with a resolved active host', () => {
press('up')
expect(activateAndRevealWorktree).toHaveBeenCalledWith('a', { navigationIntent: 'user-open' })
expect(activateAndRevealWorktree).toHaveBeenCalledWith('a', {
navigationIntent: 'user-open',
revealInSidebar: false
})
})
it('still steps normally when the active host is unqualified', () => {
@@ -117,6 +123,9 @@ describe('worktree keyboard cycling with a resolved active host', () => {
press('down')
expect(activateAndRevealWorktree).toHaveBeenCalledWith('c', { navigationIntent: 'user-open' })
expect(activateAndRevealWorktree).toHaveBeenCalledWith('c', {
navigationIntent: 'user-open',
revealInSidebar: false
})
})
})
@@ -2,7 +2,7 @@ import { useCallback, useEffect } from 'react'
import type React from 'react'
import type { Virtualizer } from '@tanstack/react-virtual'
import { useAppStore } from '@/store'
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
import { activateWorktreeFromSidebar } from '@/lib/sidebar-worktree-activation'
import { focusRuntimeTerminalSurface } from '@/runtime/sync-runtime-graph'
import { hasVisibleOverlay } from '@/lib/visible-overlay'
import type { ExecutionHostId } from '../../../../../../shared/execution-host'
@@ -88,11 +88,7 @@ export function useWorktreeListKeyboardNavigation(args: {
return
}
// Why: keyboard cycling is real navigation; route through the activation helper that records history.
activateAndRevealWorktree(nextWorktree.id, {
navigationIntent: 'user-open',
...(nextWorktree.hostId ? { executionHostId: nextWorktree.hostId } : {})
})
void activateWorktreeFromSidebar(nextWorktree.id, nextWorktree.hostId)
const rowIndex = findPreferredRenderRowIndexForWorktreeIdentity(
renderRows,
@@ -44,22 +44,36 @@ export function getPreferredWorktreeRows(
return preferredRows
}
export function getRenderedWorktreesInSidebarOrder(
export type RenderedWorkspaceRow = Pick<WorktreeRow, 'worktree' | 'repo'>
export function getRenderedWorkspaceRowsInSidebarOrder(
rows: readonly HostSectionRow[],
pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy
): Worktree[] {
): RenderedWorkspaceRow[] {
const itemRows = rows.filter((row): row is WorktreeRow => row.type === 'item')
const preferredRowKeys = new Set(
getPreferredWorktreeRows(itemRows, pinnedDisplayPolicy).map((row) => row.rowKey)
)
const renderedWorktrees: Worktree[] = []
const renderedRows: RenderedWorkspaceRow[] = []
for (const row of rows) {
if (row.type === 'item' && preferredRowKeys.has(row.rowKey)) {
renderedWorktrees.push(row.worktree)
renderedRows.push({ worktree: row.worktree, repo: row.repo })
} else if (row.type === 'folder-workspace') {
renderedWorktrees.push(folderWorkspaceToWorktree(row.folderWorkspace))
renderedRows.push({
worktree: folderWorkspaceToWorktree(row.folderWorkspace),
repo: undefined
})
}
}
return renderedWorktrees
return renderedRows
}
export function getRenderedWorktreesInSidebarOrder(
rows: readonly HostSectionRow[],
pinnedDisplayPolicy: PinnedWorktreeDisplayPolicy
): Worktree[] {
return getRenderedWorkspaceRowsInSidebarOrder(rows, pinnedDisplayPolicy).map(
(row) => row.worktree
)
}