Fix Cmd+J create workspace selection (#2800)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
c7b6476708
commit
8fbc3eecd0
|
|
@ -34,6 +34,7 @@ import {
|
|||
CREATE_WORKTREE_ITEM_ID,
|
||||
createWorktreePaletteRequestGuard,
|
||||
getNextWorktreePaletteSelection,
|
||||
getWorktreePaletteSelectionItemIds,
|
||||
getWorktreePaletteCreateActionState
|
||||
} from '@/lib/worktree-palette-create-action'
|
||||
import { getWorkspacePortsByWorktreeId } from '@/lib/workspace-port-groups'
|
||||
|
|
@ -571,16 +572,13 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
[paletteSections]
|
||||
)
|
||||
|
||||
const selectableItemIds = useMemo(() => selectableItems.map((item) => item.id), [selectableItems])
|
||||
|
||||
const { createWorktreeName, showCreateAction } = useMemo(
|
||||
() =>
|
||||
getWorktreePaletteCreateActionState({
|
||||
canCreateWorktree,
|
||||
query: deferredQuery,
|
||||
selectableItemIds
|
||||
query: deferredQuery
|
||||
}),
|
||||
[canCreateWorktree, deferredQuery, selectableItemIds]
|
||||
[canCreateWorktree, deferredQuery]
|
||||
)
|
||||
|
||||
const listEntries = useMemo<PaletteListEntry[]>(() => {
|
||||
|
|
@ -651,6 +649,11 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
return entries
|
||||
}, [hasQuery, paletteSections, showCreateAction, worktreeItems.length])
|
||||
|
||||
const selectionItemIds = useMemo(
|
||||
() => getWorktreePaletteSelectionItemIds(listEntries),
|
||||
[listEntries]
|
||||
)
|
||||
|
||||
// Why: empty-state / "has any worktrees?" uses the full visible list
|
||||
// (including current) so the palette never claims to be empty just
|
||||
// because the only visible worktree is the currently active one.
|
||||
|
|
@ -721,7 +724,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
const nextSelectedItemId = getNextWorktreePaletteSelection({
|
||||
currentSelectedItemId: selectedItemId,
|
||||
queryChanged,
|
||||
selectableItemIds,
|
||||
selectableItemIds: selectionItemIds,
|
||||
showCreateAction
|
||||
})
|
||||
if (queryChanged) {
|
||||
|
|
@ -733,7 +736,7 @@ export default function WorktreeJumpPalette(): React.JSX.Element | null {
|
|||
if (nextSelectedItemId !== selectedItemId) {
|
||||
setSelectedItemId(nextSelectedItemId)
|
||||
}
|
||||
}, [deferredQuery, selectedItemId, showCreateAction, visible, selectableItemIds])
|
||||
}, [deferredQuery, selectedItemId, showCreateAction, visible, selectionItemIds])
|
||||
|
||||
const focusFallbackSurface = useCallback(() => {
|
||||
requestAnimationFrame(() => {
|
||||
|
|
|
|||
|
|
@ -3,60 +3,71 @@ import {
|
|||
CREATE_WORKTREE_ITEM_ID,
|
||||
createWorktreePaletteRequestGuard,
|
||||
getNextWorktreePaletteSelection,
|
||||
getWorktreePaletteSelectionItemIds,
|
||||
getWorktreePaletteCreateActionState
|
||||
} from './worktree-palette-create-action'
|
||||
|
||||
describe('worktree-palette-create-action', () => {
|
||||
it('shows create for typed queries with workspace matches but selects the first real row', () => {
|
||||
it('shows create for typed queries with workspace matches but selects the first workspace row', () => {
|
||||
const state = getWorktreePaletteCreateActionState({
|
||||
canCreateWorktree: true,
|
||||
query: 'feature',
|
||||
selectableItemIds: ['worktree:one']
|
||||
query: 'feature'
|
||||
})
|
||||
|
||||
expect(state).toEqual({
|
||||
createWorktreeName: 'feature',
|
||||
showCreateAction: true,
|
||||
shouldDefaultToCreate: false
|
||||
showCreateAction: true
|
||||
})
|
||||
expect(
|
||||
getNextWorktreePaletteSelection({
|
||||
currentSelectedItemId: '',
|
||||
queryChanged: true,
|
||||
selectableItemIds: ['worktree:one'],
|
||||
selectableItemIds: ['worktree:one', CREATE_WORKTREE_ITEM_ID, 'settings:provider'],
|
||||
showCreateAction: state.showCreateAction
|
||||
})
|
||||
).toBe('worktree:one')
|
||||
})
|
||||
|
||||
it('shows create for typed queries with browser-only matches but selects the browser row', () => {
|
||||
it('selects create when it appears before actions, settings, and browser rows', () => {
|
||||
const state = getWorktreePaletteCreateActionState({
|
||||
canCreateWorktree: true,
|
||||
query: 'localhost',
|
||||
selectableItemIds: ['browser-page:one']
|
||||
query: 'opencode-issue'
|
||||
})
|
||||
|
||||
expect(state.showCreateAction).toBe(true)
|
||||
expect(state.shouldDefaultToCreate).toBe(false)
|
||||
expect(
|
||||
getNextWorktreePaletteSelection({
|
||||
currentSelectedItemId: '',
|
||||
queryChanged: true,
|
||||
selectableItemIds: ['browser-page:one'],
|
||||
selectableItemIds: [
|
||||
CREATE_WORKTREE_ITEM_ID,
|
||||
'settings:ai-provider-accounts',
|
||||
'quick-action:new-terminal',
|
||||
'browser-page:one'
|
||||
],
|
||||
showCreateAction: state.showCreateAction
|
||||
})
|
||||
).toBe('browser-page:one')
|
||||
).toBe(CREATE_WORKTREE_ITEM_ID)
|
||||
})
|
||||
|
||||
it('selects create when it appears before a browser-only match', () => {
|
||||
expect(
|
||||
getNextWorktreePaletteSelection({
|
||||
currentSelectedItemId: '',
|
||||
queryChanged: true,
|
||||
selectableItemIds: [CREATE_WORKTREE_ITEM_ID, 'browser-page:one'],
|
||||
showCreateAction: true
|
||||
})
|
||||
).toBe(CREATE_WORKTREE_ITEM_ID)
|
||||
})
|
||||
|
||||
it('defaults to create for typed queries with no real matches', () => {
|
||||
const state = getWorktreePaletteCreateActionState({
|
||||
canCreateWorktree: true,
|
||||
query: 'new-workspace',
|
||||
selectableItemIds: []
|
||||
query: 'new-workspace'
|
||||
})
|
||||
|
||||
expect(state.showCreateAction).toBe(true)
|
||||
expect(state.shouldDefaultToCreate).toBe(true)
|
||||
expect(
|
||||
getNextWorktreePaletteSelection({
|
||||
currentSelectedItemId: '',
|
||||
|
|
@ -67,6 +78,28 @@ describe('worktree-palette-create-action', () => {
|
|||
).toBe(CREATE_WORKTREE_ITEM_ID)
|
||||
})
|
||||
|
||||
it('returns empty selection when no create action or rows are available', () => {
|
||||
expect(
|
||||
getNextWorktreePaletteSelection({
|
||||
currentSelectedItemId: '',
|
||||
queryChanged: true,
|
||||
selectableItemIds: [],
|
||||
showCreateAction: false
|
||||
})
|
||||
).toBe('')
|
||||
})
|
||||
|
||||
it('does not keep create selected after the create row disappears', () => {
|
||||
expect(
|
||||
getNextWorktreePaletteSelection({
|
||||
currentSelectedItemId: CREATE_WORKTREE_ITEM_ID,
|
||||
queryChanged: false,
|
||||
selectableItemIds: ['settings:ai-provider-accounts'],
|
||||
showCreateAction: false
|
||||
})
|
||||
).toBe('settings:ai-provider-accounts')
|
||||
})
|
||||
|
||||
it('moves selection back to the first real row when the query changes after manual create selection', () => {
|
||||
expect(
|
||||
getNextWorktreePaletteSelection({
|
||||
|
|
@ -93,8 +126,7 @@ describe('worktree-palette-create-action', () => {
|
|||
expect(
|
||||
getWorktreePaletteCreateActionState({
|
||||
canCreateWorktree: false,
|
||||
query: 'new-workspace',
|
||||
selectableItemIds: []
|
||||
query: 'new-workspace'
|
||||
}).showCreateAction
|
||||
).toBe(false)
|
||||
})
|
||||
|
|
@ -103,12 +135,33 @@ describe('worktree-palette-create-action', () => {
|
|||
expect(
|
||||
getWorktreePaletteCreateActionState({
|
||||
canCreateWorktree: true,
|
||||
query: ' ',
|
||||
selectableItemIds: []
|
||||
query: ' '
|
||||
}).showCreateAction
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('derives selection ids from rendered entries while skipping headers and hints', () => {
|
||||
expect(
|
||||
getWorktreePaletteSelectionItemIds([
|
||||
{ id: '__header_worktrees__', type: 'section-header' },
|
||||
{ id: 'worktree:one', type: 'worktree' },
|
||||
{ id: CREATE_WORKTREE_ITEM_ID, type: 'create-worktree' },
|
||||
{ id: '__hint_worktree_cap__', type: 'hint' },
|
||||
{ id: '__header_actions_settings__', type: 'section-header' },
|
||||
{ id: 'settings:ai-provider-accounts', type: 'settings' },
|
||||
{ id: 'quick-action:new-terminal', type: 'quick-action' },
|
||||
{ id: '__header_browser__', type: 'section-header' },
|
||||
{ id: 'browser-page:one', type: 'browser-page' }
|
||||
])
|
||||
).toEqual([
|
||||
'worktree:one',
|
||||
CREATE_WORKTREE_ITEM_ID,
|
||||
'settings:ai-provider-accounts',
|
||||
'quick-action:new-terminal',
|
||||
'browser-page:one'
|
||||
])
|
||||
})
|
||||
|
||||
it('falls back deterministically when the selected row disappears', () => {
|
||||
expect(
|
||||
getNextWorktreePaletteSelection({
|
||||
|
|
|
|||
|
|
@ -3,27 +3,57 @@ export const CREATE_WORKTREE_ITEM_ID = '__create_worktree__'
|
|||
export type WorktreePaletteCreateActionState = {
|
||||
createWorktreeName: string
|
||||
showCreateAction: boolean
|
||||
shouldDefaultToCreate: boolean
|
||||
}
|
||||
|
||||
export function getWorktreePaletteCreateActionState({
|
||||
canCreateWorktree,
|
||||
query,
|
||||
selectableItemIds
|
||||
query
|
||||
}: {
|
||||
canCreateWorktree: boolean
|
||||
query: string
|
||||
selectableItemIds: readonly string[]
|
||||
}): WorktreePaletteCreateActionState {
|
||||
const createWorktreeName = query.trim()
|
||||
const showCreateAction = canCreateWorktree && createWorktreeName.length > 0
|
||||
return {
|
||||
createWorktreeName,
|
||||
showCreateAction,
|
||||
shouldDefaultToCreate: showCreateAction && selectableItemIds.length === 0
|
||||
showCreateAction
|
||||
}
|
||||
}
|
||||
|
||||
type WorktreePaletteSelectionCandidateEntry = {
|
||||
id: string
|
||||
type: string
|
||||
}
|
||||
|
||||
type WorktreePaletteSelectableEntryType =
|
||||
| 'worktree'
|
||||
| 'create-worktree'
|
||||
| 'settings'
|
||||
| 'quick-action'
|
||||
| 'browser-page'
|
||||
|
||||
export function isSelectableWorktreePaletteEntry(
|
||||
entry: WorktreePaletteSelectionCandidateEntry
|
||||
): entry is WorktreePaletteSelectionCandidateEntry & {
|
||||
type: WorktreePaletteSelectableEntryType
|
||||
} {
|
||||
return (
|
||||
entry.type === 'worktree' ||
|
||||
entry.type === 'create-worktree' ||
|
||||
entry.type === 'settings' ||
|
||||
entry.type === 'quick-action' ||
|
||||
entry.type === 'browser-page'
|
||||
)
|
||||
}
|
||||
|
||||
export function getWorktreePaletteSelectionItemIds<
|
||||
T extends WorktreePaletteSelectionCandidateEntry
|
||||
>(entries: readonly T[]): string[] {
|
||||
// Why: keyboard focus should mirror rendered order, including synthetic
|
||||
// action rows, while skipping headers and explanatory hint rows.
|
||||
return entries.filter(isSelectableWorktreePaletteEntry).map((entry) => entry.id)
|
||||
}
|
||||
|
||||
export function getNextWorktreePaletteSelection({
|
||||
currentSelectedItemId,
|
||||
queryChanged,
|
||||
|
|
|
|||
Loading…
Reference in New Issue