mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
Add interactive resume session picker
This commit is contained in:
@@ -7,6 +7,7 @@ const {
|
||||
listResumableSessionsMock,
|
||||
getLocalResumeTargetMock,
|
||||
handoffSessionToLocalMock,
|
||||
renderMock,
|
||||
runCodexMock,
|
||||
runClaudeMock,
|
||||
assertCodexLocalSupportedMock,
|
||||
@@ -18,6 +19,7 @@ const {
|
||||
listResumableSessionsMock: vi.fn(),
|
||||
getLocalResumeTargetMock: vi.fn(),
|
||||
handoffSessionToLocalMock: vi.fn(async () => {}),
|
||||
renderMock: vi.fn(),
|
||||
runCodexMock: vi.fn(async () => {}),
|
||||
runClaudeMock: vi.fn(async () => {}),
|
||||
assertCodexLocalSupportedMock: vi.fn(),
|
||||
@@ -36,6 +38,10 @@ vi.mock('@/api/api', () => ({
|
||||
})
|
||||
}
|
||||
}))
|
||||
vi.mock('ink', () => ({ render: renderMock }))
|
||||
vi.mock('@/ui/ink/ResumeSessionPicker', () => ({
|
||||
ResumeSessionPicker: 'ResumeSessionPicker'
|
||||
}))
|
||||
vi.mock('@/codex/runCodex', () => ({ runCodex: runCodexMock }))
|
||||
vi.mock('@/claude/runClaude', () => ({ runClaude: runClaudeMock }))
|
||||
vi.mock('@/codex/utils/codexVersion', () => ({ assertCodexLocalSupported: assertCodexLocalSupportedMock }))
|
||||
@@ -59,6 +65,11 @@ describe('resumeCommand', () => {
|
||||
listResumableSessionsMock.mockReset()
|
||||
getLocalResumeTargetMock.mockReset()
|
||||
handoffSessionToLocalMock.mockClear()
|
||||
renderMock.mockReset()
|
||||
renderMock.mockImplementation((element: { props?: { onSelect?: (sessionId: string) => void } }) => {
|
||||
queueMicrotask(() => element.props?.onSelect?.('picked-session'))
|
||||
return { unmount: vi.fn() }
|
||||
})
|
||||
runCodexMock.mockClear()
|
||||
runClaudeMock.mockClear()
|
||||
assertCodexLocalSupportedMock.mockClear()
|
||||
@@ -187,4 +198,93 @@ describe('resumeCommand', () => {
|
||||
exitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('uses the interactive picker when no session id is provided on a TTY', async () => {
|
||||
const originalIsTTY = process.stdin.isTTY
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
configurable: true,
|
||||
value: true
|
||||
})
|
||||
listResumableSessionsMock.mockResolvedValue([
|
||||
{
|
||||
sessionId: 'picked-session',
|
||||
flavor: 'codex',
|
||||
directory: '/tmp/project',
|
||||
machineId: 'machine-1',
|
||||
active: false,
|
||||
thinking: false,
|
||||
controlledByUser: false,
|
||||
agentSessionId: 'codex-thread-1',
|
||||
updatedAt: 2,
|
||||
name: 'Picked'
|
||||
}
|
||||
])
|
||||
getLocalResumeTargetMock.mockResolvedValue({
|
||||
sessionId: 'picked-session',
|
||||
flavor: 'codex',
|
||||
directory: '/tmp/project',
|
||||
machineId: 'machine-1',
|
||||
active: false,
|
||||
thinking: false,
|
||||
controlledByUser: false,
|
||||
agentSessionId: 'codex-thread-1'
|
||||
})
|
||||
|
||||
try {
|
||||
await resumeCommand.run(createContext([]))
|
||||
|
||||
expect(renderMock).toHaveBeenCalledOnce()
|
||||
expect(getLocalResumeTargetMock).toHaveBeenCalledWith('picked-session')
|
||||
expect(runCodexMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
existingSessionId: 'picked-session',
|
||||
resumeSessionId: 'codex-thread-1'
|
||||
}))
|
||||
} finally {
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
configurable: true,
|
||||
value: originalIsTTY
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the non-TTY fallback and asks for an explicit session id', async () => {
|
||||
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||
throw new Error(`process.exit:${code ?? 'undefined'}`)
|
||||
}) as never)
|
||||
const originalIsTTY = process.stdin.isTTY
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
configurable: true,
|
||||
value: false
|
||||
})
|
||||
listResumableSessionsMock.mockResolvedValue([
|
||||
{
|
||||
sessionId: 'hapi-session-1',
|
||||
flavor: 'claude',
|
||||
directory: '/tmp/project',
|
||||
machineId: 'machine-1',
|
||||
active: false,
|
||||
thinking: false,
|
||||
controlledByUser: false,
|
||||
agentSessionId: 'claude-session-1',
|
||||
updatedAt: 1
|
||||
}
|
||||
])
|
||||
|
||||
try {
|
||||
await expect(resumeCommand.run(createContext([]))).rejects.toThrow('process.exit:1')
|
||||
expect(renderMock).not.toHaveBeenCalled()
|
||||
expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('hapi-session-1'))
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(String), 'Run: hapi resume <session-id>')
|
||||
} finally {
|
||||
Object.defineProperty(process.stdin, 'isTTY', {
|
||||
configurable: true,
|
||||
value: originalIsTTY
|
||||
})
|
||||
consoleLogSpy.mockRestore()
|
||||
consoleErrorSpy.mockRestore()
|
||||
exitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+19
-19
@@ -1,7 +1,7 @@
|
||||
import chalk from 'chalk'
|
||||
import React from 'react'
|
||||
import { render } from 'ink'
|
||||
import { existsSync } from 'node:fs'
|
||||
import * as readline from 'node:readline/promises'
|
||||
import { stdin as input, stdout as output } from 'node:process'
|
||||
import type { LocalResumeTarget, ResumableSession } from '@hapi/protocol'
|
||||
import type {
|
||||
ClaudePermissionMode,
|
||||
@@ -16,6 +16,7 @@ import { authAndSetupMachineIfNeeded } from '@/ui/auth'
|
||||
import { initializeToken } from '@/ui/tokenInit'
|
||||
import { maybeAutoStartServer } from '@/utils/autoStartServer'
|
||||
import { assertCodexLocalSupported } from '@/codex/utils/codexVersion'
|
||||
import { ResumeSessionPicker } from '@/ui/ink/ResumeSessionPicker'
|
||||
import type { CommandDefinition } from './types'
|
||||
|
||||
function formatSessionLine(session: ResumableSession, index: number): string {
|
||||
@@ -27,24 +28,23 @@ function formatSessionLine(session: ResumableSession, index: number): string {
|
||||
}
|
||||
|
||||
async function selectSession(sessions: ResumableSession[]): Promise<string> {
|
||||
console.log(chalk.bold('Resumable sessions'))
|
||||
console.log('')
|
||||
sessions.forEach((session, index) => {
|
||||
console.log(formatSessionLine(session, index))
|
||||
})
|
||||
console.log('')
|
||||
|
||||
const rl = readline.createInterface({ input, output })
|
||||
try {
|
||||
const answer = await rl.question(chalk.cyan('Select session: '))
|
||||
const index = Number(answer.trim()) - 1
|
||||
if (!Number.isInteger(index) || index < 0 || index >= sessions.length) {
|
||||
throw new Error('Invalid selection')
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
let settled = false
|
||||
const complete = (callback: () => void) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
instance.unmount()
|
||||
callback()
|
||||
}
|
||||
return sessions[index].sessionId
|
||||
} finally {
|
||||
rl.close()
|
||||
}
|
||||
const instance = render(React.createElement(ResumeSessionPicker, {
|
||||
sessions,
|
||||
onSelect: (sessionId: string) => complete(() => resolve(sessionId)),
|
||||
onCancel: () => complete(() => reject(new Error('Selection cancelled')))
|
||||
}), {
|
||||
patchConsole: false,
|
||||
exitOnCtrlC: false
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function assertTargetMachine(target: LocalResumeTarget, machineId: string): void {
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import { Box, Text, useInput, useStdout } from 'ink'
|
||||
import type { ResumableSession } from '@hapi/protocol'
|
||||
import {
|
||||
filterResumeSessions,
|
||||
getResumeSessionName,
|
||||
getResumeSessionState,
|
||||
normalizeScrollOffset,
|
||||
reducePickerState,
|
||||
type PickerState
|
||||
} from './resumeSessionPickerState'
|
||||
|
||||
type ExtendedKey = {
|
||||
upArrow?: boolean
|
||||
downArrow?: boolean
|
||||
return?: boolean
|
||||
escape?: boolean
|
||||
backspace?: boolean
|
||||
delete?: boolean
|
||||
ctrl?: boolean
|
||||
pageUp?: boolean
|
||||
pageDown?: boolean
|
||||
home?: boolean
|
||||
end?: boolean
|
||||
name?: string
|
||||
sequence?: string
|
||||
}
|
||||
|
||||
export type ResumeSessionPickerProps = {
|
||||
sessions: ResumableSession[]
|
||||
onSelect: (sessionId: string) => void
|
||||
onCancel: () => void
|
||||
}
|
||||
|
||||
function truncateText(value: string, maxLength: number): string {
|
||||
if (maxLength <= 0) return ''
|
||||
if (value.length <= maxLength) return value
|
||||
if (maxLength <= 3) return '.'.repeat(maxLength)
|
||||
return `${value.slice(0, maxLength - 3)}...`
|
||||
}
|
||||
|
||||
function formatSessionLine(session: ResumableSession, width: number): string {
|
||||
const state = getResumeSessionState(session)
|
||||
const prefix = `${session.flavor.padEnd(8)} ${state.padEnd(8)} `
|
||||
const directoryBudget = Math.max(16, Math.floor(width * 0.35))
|
||||
const nameBudget = Math.max(12, width - prefix.length - directoryBudget - 2)
|
||||
const name = truncateText(getResumeSessionName(session), nameBudget)
|
||||
const directory = truncateText(session.directory, directoryBudget)
|
||||
return `${prefix}${name.padEnd(nameBudget)} ${directory}`
|
||||
}
|
||||
|
||||
function isPrintableInput(input: string, key: ExtendedKey): boolean {
|
||||
if (key.ctrl || key.return || key.escape || key.backspace || key.delete) return false
|
||||
if (key.upArrow || key.downArrow || key.pageUp || key.pageDown || key.home || key.end) return false
|
||||
if (input.length !== 1) return false
|
||||
return input >= ' ' && input !== '\u007f'
|
||||
}
|
||||
|
||||
export const ResumeSessionPicker: React.FC<ResumeSessionPickerProps> = ({
|
||||
sessions,
|
||||
onSelect,
|
||||
onCancel
|
||||
}) => {
|
||||
const { stdout } = useStdout()
|
||||
const terminalWidth = stdout.columns || 80
|
||||
const terminalHeight = stdout.rows || 24
|
||||
const visibleCount = Math.max(5, terminalHeight - 8)
|
||||
const [state, setState] = useState<PickerState>({
|
||||
query: '',
|
||||
selectedIndex: 0,
|
||||
scrollOffset: 0
|
||||
})
|
||||
|
||||
const filteredSessions = useMemo(
|
||||
() => filterResumeSessions(sessions, state.query),
|
||||
[sessions, state.query]
|
||||
)
|
||||
const selectedIndex = filteredSessions.length === 0
|
||||
? 0
|
||||
: Math.min(state.selectedIndex, filteredSessions.length - 1)
|
||||
const scrollOffset = normalizeScrollOffset(
|
||||
selectedIndex,
|
||||
state.scrollOffset,
|
||||
visibleCount,
|
||||
filteredSessions.length
|
||||
)
|
||||
const visibleSessions = filteredSessions.slice(scrollOffset, scrollOffset + visibleCount)
|
||||
|
||||
useInput((input, key: ExtendedKey) => {
|
||||
if (key.ctrl && input === 'c') {
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
|
||||
if (key.return) {
|
||||
const selected = filteredSessions[selectedIndex]
|
||||
if (selected) {
|
||||
onSelect(selected.sessionId)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (key.escape) {
|
||||
if (state.query.length === 0) {
|
||||
onCancel()
|
||||
return
|
||||
}
|
||||
setState((current) => reducePickerState(current, {
|
||||
type: 'key',
|
||||
key: 'escape'
|
||||
}, {
|
||||
itemCount: filteredSessions.length,
|
||||
visibleCount
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
const keyName = key.name
|
||||
const mappedKey =
|
||||
key.upArrow || keyName === 'up' ? 'up'
|
||||
: key.downArrow || keyName === 'down' ? 'down'
|
||||
: key.pageUp || keyName === 'pageup' ? 'pageUp'
|
||||
: key.pageDown || keyName === 'pagedown' ? 'pageDown'
|
||||
: key.home || keyName === 'home' ? 'home'
|
||||
: key.end || keyName === 'end' ? 'end'
|
||||
: key.backspace || key.delete || keyName === 'backspace' || keyName === 'delete' ? 'backspace'
|
||||
: null
|
||||
|
||||
if (mappedKey) {
|
||||
setState((current) => reducePickerState(current, {
|
||||
type: 'key',
|
||||
key: mappedKey
|
||||
}, {
|
||||
itemCount: filteredSessions.length,
|
||||
visibleCount
|
||||
}))
|
||||
return
|
||||
}
|
||||
|
||||
if (isPrintableInput(input, key)) {
|
||||
setState((current) => reducePickerState(current, {
|
||||
type: 'char',
|
||||
value: input
|
||||
}, {
|
||||
itemCount: filteredSessions.length,
|
||||
visibleCount
|
||||
}))
|
||||
}
|
||||
})
|
||||
|
||||
const width = Math.max(40, terminalWidth - 4)
|
||||
const shownStart = filteredSessions.length === 0 ? 0 : scrollOffset + 1
|
||||
const shownEnd = Math.min(filteredSessions.length, scrollOffset + visibleSessions.length)
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" width={terminalWidth}>
|
||||
<Text bold>Resumable sessions</Text>
|
||||
<Text color="gray">
|
||||
Search: <Text color={state.query ? 'cyan' : 'gray'}>{state.query || 'type to filter'}</Text>
|
||||
</Text>
|
||||
<Text color="gray">
|
||||
{filteredSessions.length === 0
|
||||
? 'No matching sessions'
|
||||
: `${shownStart}-${shownEnd} of ${filteredSessions.length}`}
|
||||
</Text>
|
||||
<Box flexDirection="column" marginTop={1}>
|
||||
{visibleSessions.map((session, index) => {
|
||||
const absoluteIndex = scrollOffset + index
|
||||
const selected = absoluteIndex === selectedIndex
|
||||
return (
|
||||
<Text
|
||||
key={session.sessionId}
|
||||
color={selected ? 'cyan' : undefined}
|
||||
inverse={selected}
|
||||
>
|
||||
{selected ? '> ' : ' '}
|
||||
{formatSessionLine(session, width - 2)}
|
||||
</Text>
|
||||
)
|
||||
})}
|
||||
</Box>
|
||||
<Box marginTop={1}>
|
||||
<Text color="gray">Up/Down move | PageUp/PageDown scroll | type search | Enter resume | Esc clear/cancel | Ctrl-C cancel</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { ResumableSession } from '@hapi/protocol'
|
||||
import {
|
||||
filterResumeSessions,
|
||||
reducePickerState,
|
||||
type PickerState
|
||||
} from './resumeSessionPickerState'
|
||||
|
||||
function session(overrides: Partial<ResumableSession>): ResumableSession {
|
||||
return {
|
||||
sessionId: 'session-1',
|
||||
flavor: 'codex',
|
||||
directory: '/tmp/project',
|
||||
machineId: 'machine-1',
|
||||
active: false,
|
||||
thinking: false,
|
||||
controlledByUser: false,
|
||||
agentSessionId: 'agent-1',
|
||||
updatedAt: 1,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('resumeSessionPickerState', () => {
|
||||
it('filters sessions by searchable fields case-insensitively', () => {
|
||||
const sessions = [
|
||||
session({
|
||||
sessionId: 'alpha',
|
||||
name: 'Payment Refactor',
|
||||
directory: '/repo/api',
|
||||
agentSessionId: 'thread-a'
|
||||
}),
|
||||
session({
|
||||
sessionId: 'beta',
|
||||
flavor: 'claude',
|
||||
directory: '/repo/mobile',
|
||||
summary: 'Fix login screen',
|
||||
agentSessionId: 'thread-b'
|
||||
}),
|
||||
session({
|
||||
sessionId: 'gamma',
|
||||
active: true,
|
||||
controlledByUser: false,
|
||||
directory: '/repo/web',
|
||||
agentSessionId: 'thread-c'
|
||||
})
|
||||
]
|
||||
|
||||
expect(filterResumeSessions(sessions, 'payment').map((item) => item.sessionId)).toEqual(['alpha'])
|
||||
expect(filterResumeSessions(sessions, 'MOBILE').map((item) => item.sessionId)).toEqual(['beta'])
|
||||
expect(filterResumeSessions(sessions, 'thread-c').map((item) => item.sessionId)).toEqual(['gamma'])
|
||||
expect(filterResumeSessions(sessions, 'remote').map((item) => item.sessionId)).toEqual(['gamma'])
|
||||
})
|
||||
|
||||
it('resets selection and scroll when query changes', () => {
|
||||
const initial: PickerState = {
|
||||
query: 'abc',
|
||||
selectedIndex: 5,
|
||||
scrollOffset: 3
|
||||
}
|
||||
|
||||
expect(reducePickerState(initial, {
|
||||
type: 'char',
|
||||
value: 'd'
|
||||
}, {
|
||||
itemCount: 10,
|
||||
visibleCount: 5
|
||||
})).toEqual({
|
||||
query: 'abcd',
|
||||
selectedIndex: 0,
|
||||
scrollOffset: 0
|
||||
})
|
||||
|
||||
expect(reducePickerState(initial, {
|
||||
type: 'key',
|
||||
key: 'backspace'
|
||||
}, {
|
||||
itemCount: 10,
|
||||
visibleCount: 5
|
||||
})).toEqual({
|
||||
query: 'ab',
|
||||
selectedIndex: 0,
|
||||
scrollOffset: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps keyboard navigation inside list bounds and visible window', () => {
|
||||
let state: PickerState = {
|
||||
query: '',
|
||||
selectedIndex: 0,
|
||||
scrollOffset: 0
|
||||
}
|
||||
|
||||
state = reducePickerState(state, { type: 'key', key: 'up' }, {
|
||||
itemCount: 20,
|
||||
visibleCount: 5
|
||||
})
|
||||
expect(state.selectedIndex).toBe(0)
|
||||
expect(state.scrollOffset).toBe(0)
|
||||
|
||||
state = reducePickerState(state, { type: 'key', key: 'pageDown' }, {
|
||||
itemCount: 20,
|
||||
visibleCount: 5
|
||||
})
|
||||
expect(state.selectedIndex).toBe(5)
|
||||
expect(state.scrollOffset).toBe(1)
|
||||
|
||||
state = reducePickerState(state, { type: 'key', key: 'end' }, {
|
||||
itemCount: 20,
|
||||
visibleCount: 5
|
||||
})
|
||||
expect(state.selectedIndex).toBe(19)
|
||||
expect(state.scrollOffset).toBe(15)
|
||||
|
||||
state = reducePickerState(state, { type: 'key', key: 'down' }, {
|
||||
itemCount: 20,
|
||||
visibleCount: 5
|
||||
})
|
||||
expect(state.selectedIndex).toBe(19)
|
||||
expect(state.scrollOffset).toBe(15)
|
||||
})
|
||||
|
||||
it('uses null-equivalent selection when there are no items', () => {
|
||||
const state = reducePickerState({
|
||||
query: '',
|
||||
selectedIndex: 0,
|
||||
scrollOffset: 0
|
||||
}, {
|
||||
type: 'key',
|
||||
key: 'down'
|
||||
}, {
|
||||
itemCount: 0,
|
||||
visibleCount: 5
|
||||
})
|
||||
|
||||
expect(state.selectedIndex).toBe(0)
|
||||
expect(state.scrollOffset).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { ResumableSession } from '@hapi/protocol'
|
||||
|
||||
export type PickerState = {
|
||||
query: string
|
||||
selectedIndex: number
|
||||
scrollOffset: number
|
||||
}
|
||||
|
||||
export type PickerKey =
|
||||
| 'up'
|
||||
| 'down'
|
||||
| 'pageUp'
|
||||
| 'pageDown'
|
||||
| 'home'
|
||||
| 'end'
|
||||
| 'backspace'
|
||||
| 'escape'
|
||||
|
||||
export function getResumeSessionName(session: ResumableSession): string {
|
||||
return session.name ?? session.summary ?? session.sessionId
|
||||
}
|
||||
|
||||
export function getResumeSessionState(session: ResumableSession): string {
|
||||
if (!session.active) return 'inactive'
|
||||
return session.controlledByUser ? 'local' : 'remote'
|
||||
}
|
||||
|
||||
export function filterResumeSessions(
|
||||
sessions: ResumableSession[],
|
||||
query: string
|
||||
): ResumableSession[] {
|
||||
const normalized = query.trim().toLowerCase()
|
||||
if (normalized.length === 0) return sessions
|
||||
|
||||
return sessions.filter((session) => {
|
||||
const fields = [
|
||||
session.name,
|
||||
session.summary,
|
||||
session.sessionId,
|
||||
session.agentSessionId,
|
||||
session.directory,
|
||||
session.flavor,
|
||||
getResumeSessionState(session)
|
||||
]
|
||||
return fields.some((field) => field?.toLowerCase().includes(normalized))
|
||||
})
|
||||
}
|
||||
|
||||
export function clampSelectedIndex(index: number, itemCount: number): number {
|
||||
if (itemCount <= 0) return 0
|
||||
return Math.max(0, Math.min(index, itemCount - 1))
|
||||
}
|
||||
|
||||
export function normalizeScrollOffset(
|
||||
selectedIndex: number,
|
||||
scrollOffset: number,
|
||||
visibleCount: number,
|
||||
itemCount: number
|
||||
): number {
|
||||
if (itemCount <= 0) return 0
|
||||
|
||||
const safeVisibleCount = Math.max(1, visibleCount)
|
||||
const maxOffset = Math.max(0, itemCount - safeVisibleCount)
|
||||
let nextOffset = Math.max(0, Math.min(scrollOffset, maxOffset))
|
||||
|
||||
if (selectedIndex < nextOffset) {
|
||||
nextOffset = selectedIndex
|
||||
} else if (selectedIndex >= nextOffset + safeVisibleCount) {
|
||||
nextOffset = selectedIndex - safeVisibleCount + 1
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(nextOffset, maxOffset))
|
||||
}
|
||||
|
||||
export function reducePickerState(
|
||||
state: PickerState,
|
||||
event: { type: 'char'; value: string } | { type: 'key'; key: PickerKey },
|
||||
opts: {
|
||||
itemCount: number
|
||||
visibleCount: number
|
||||
}
|
||||
): PickerState {
|
||||
const { itemCount, visibleCount } = opts
|
||||
|
||||
if (event.type === 'char') {
|
||||
return {
|
||||
query: state.query + event.value,
|
||||
selectedIndex: 0,
|
||||
scrollOffset: 0
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'backspace') {
|
||||
if (state.query.length === 0) return state
|
||||
return {
|
||||
query: state.query.slice(0, -1),
|
||||
selectedIndex: 0,
|
||||
scrollOffset: 0
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'escape') {
|
||||
if (state.query.length === 0) return state
|
||||
return {
|
||||
query: '',
|
||||
selectedIndex: 0,
|
||||
scrollOffset: 0
|
||||
}
|
||||
}
|
||||
|
||||
const currentIndex = clampSelectedIndex(state.selectedIndex, itemCount)
|
||||
const pageSize = Math.max(1, visibleCount)
|
||||
const nextSelectedIndex = (() => {
|
||||
switch (event.key) {
|
||||
case 'up':
|
||||
return currentIndex - 1
|
||||
case 'down':
|
||||
return currentIndex + 1
|
||||
case 'pageUp':
|
||||
return currentIndex - pageSize
|
||||
case 'pageDown':
|
||||
return currentIndex + pageSize
|
||||
case 'home':
|
||||
return 0
|
||||
case 'end':
|
||||
return itemCount - 1
|
||||
default:
|
||||
return currentIndex
|
||||
}
|
||||
})()
|
||||
|
||||
const selectedIndex = clampSelectedIndex(nextSelectedIndex, itemCount)
|
||||
return {
|
||||
...state,
|
||||
selectedIndex,
|
||||
scrollOffset: normalizeScrollOffset(
|
||||
selectedIndex,
|
||||
state.scrollOffset,
|
||||
visibleCount,
|
||||
itemCount
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user