252 lines
11 KiB
TypeScript
252 lines
11 KiB
TypeScript
/**
|
|
* Headless popupSelect shell state (design §10): one controller per client
|
|
* session, owned by CommandService's per-session map and torn down by the
|
|
* session scope disposer. The shell is a transient layer (never in the input
|
|
* state machine): it loads options once, filters them locally against the
|
|
* shell's own search text, and settles a selection through the context
|
|
* captured at open time. Draft consumption and composer focus are injected
|
|
* callbacks — the session wiring dispatches the consume-token event (the
|
|
* Input side owns the span/bare-token CAS guard) and focuses the composer;
|
|
* the controller never touches the input machine.
|
|
*/
|
|
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
|
import type { TokenSpan } from '@deepseek-ai/dsh-client-ui-slash/client'
|
|
import type { SelectOption } from './contract.ts'
|
|
|
|
/**
|
|
* The command token segment snapshotted at shell-open time, replayed to the
|
|
* injected {@link PopupSelectDeps.consume} callback after a successful
|
|
* selection. The Input side guards it: a menu-path span consumes iff draftRev
|
|
* is unchanged, an enter-path line iff the trimmed draft still equals the
|
|
* bare token.
|
|
*/
|
|
export type TokenSegment =
|
|
| { readonly via: 'menu'; readonly span: TokenSpan }
|
|
| { readonly via: 'enter'; readonly token: string }
|
|
|
|
/**
|
|
* Structural business spec the shell settles against — the popupSelect half
|
|
* of CommandUiSpec, generic in the context value the opener captures (the
|
|
* session wiring passes its session projection; the controller only carries
|
|
* it from open() to the callbacks).
|
|
*/
|
|
export interface PopupSpec<TCtx> {
|
|
/** Load the option rows once per open (retry after failure reuses the same signal). */
|
|
options(context: TCtx, signal: AbortSignal): Promise<readonly SelectOption[]>
|
|
/** Settle the picked option against the open-time context. */
|
|
onSelect(option: SelectOption, context: TCtx): void | Promise<void>
|
|
}
|
|
|
|
/** Injected session-wiring callbacks of one controller (tests pass fakes). */
|
|
export interface PopupSelectDeps {
|
|
/**
|
|
* Consume the open-time token segment after a successful onSelect (the
|
|
* wiring dispatches the consume-token event to the opening session).
|
|
* @param segment - the open-time token segment snapshot.
|
|
* @returns whether the token was consumed; false (CAS miss) is benign and
|
|
* never retried.
|
|
*/
|
|
consume(segment: TokenSegment): boolean
|
|
/** Return focus to the session composer (successful settle and Escape close paths). */
|
|
focusComposer(): void
|
|
}
|
|
|
|
/** Popup shell state (the shell component renders from here; closed = render null). */
|
|
export interface PopupState {
|
|
readonly open: boolean
|
|
/** Command name the shell is open for (null while closed). */
|
|
readonly command: string | null
|
|
/** Options-load lifecycle; 'failed' keeps the shell open for retry(). */
|
|
readonly status: 'pending' | 'ready' | 'failed'
|
|
/** Options as loaded — never re-fetched per keystroke; views render {@link filterOptions} over them. */
|
|
readonly options: readonly SelectOption[]
|
|
/** Local filter text over the loaded options. */
|
|
readonly search: string
|
|
/** Highlight index into the filtered row list (0 when empty/pending). */
|
|
readonly active: number
|
|
/** A select() settlement is in flight: further select/search/highlight no-op until it settles. */
|
|
readonly submitting: boolean
|
|
/** Surfaced settlement failure (options load or onSelect); null when none. */
|
|
readonly error: string | null
|
|
}
|
|
|
|
const CLOSED: PopupState = {
|
|
open: false, command: null, status: 'pending', options: [], search: '', active: 0, submitting: false, error: null,
|
|
}
|
|
|
|
/**
|
|
* Filter option rows against the shell's local search text (case-insensitive
|
|
* substring over label and detail; blank search keeps every row).
|
|
* @param options - the loaded rows.
|
|
* @param search - the shell's search text.
|
|
* @returns the rows the shell shows and highlights over.
|
|
*/
|
|
export function filterOptions(options: readonly SelectOption[], search: string): readonly SelectOption[] {
|
|
const query = search.trim().toLowerCase()
|
|
if (query === '') return options
|
|
return options.filter(o => o.label.toLowerCase().includes(query) || (o.detail?.toLowerCase().includes(query) ?? false))
|
|
}
|
|
|
|
/** One open shell's bindings (spec + open-time context + segment snapshot + options-fetch abort). */
|
|
interface OpenBinding<TCtx> {
|
|
readonly command: string
|
|
readonly spec: PopupSpec<TCtx>
|
|
readonly context: TCtx
|
|
readonly segment: TokenSegment
|
|
readonly abort: AbortController
|
|
}
|
|
|
|
/** The shell's error-strip line for a settlement failure. */
|
|
function errorText(error: unknown): string {
|
|
return error instanceof Error ? error.message : String(error)
|
|
}
|
|
|
|
/**
|
|
* Headless controller of one session's popupSelect shell. Late settlements
|
|
* lose their write rights through binding identity: dismiss/dispose/reopen
|
|
* swap the binding, so a settling options fetch or onSelect that no longer
|
|
* matches writes nothing and consumes nothing.
|
|
*/
|
|
export class PopupSelectController<TCtx = unknown> {
|
|
/** Shell state store (the overlay component subscribes here). */
|
|
readonly state: SnapshotStore<PopupState> = createSnapshotStore<PopupState>(CLOSED)
|
|
private binding: OpenBinding<TCtx> | null = null
|
|
|
|
/**
|
|
* @param deps - session-wiring callbacks (token consumption + composer focus).
|
|
*/
|
|
constructor(private readonly deps: PopupSelectDeps) {}
|
|
|
|
/**
|
|
* Open the shell for one command: publish pending state and fetch options
|
|
* once through the business spec. A reopen supersedes the previous shell
|
|
* (its options fetch is aborted, its late settlements are dropped).
|
|
* @param command - command name the shell serves.
|
|
* @param spec - the registered popupSelect spec.
|
|
* @param context - open-time context snapshot, handed verbatim to options/onSelect.
|
|
* @param segment - open-time token segment snapshot for post-select consumption.
|
|
*/
|
|
open(command: string, spec: PopupSpec<TCtx>, context: TCtx, segment: TokenSegment): void {
|
|
this.binding?.abort.abort()
|
|
const binding: OpenBinding<TCtx> = { command, spec, context, segment, abort: new AbortController() }
|
|
this.binding = binding
|
|
this.state.set({ ...CLOSED, open: true, command })
|
|
this.load(binding)
|
|
}
|
|
|
|
/** Run the one options fetch of a binding; settlement rights die with the binding. */
|
|
private load(binding: OpenBinding<TCtx>): void {
|
|
binding.spec.options(binding.context, binding.abort.signal).then(
|
|
(options) => {
|
|
if (this.binding !== binding) return
|
|
this.state.set({ ...this.state.getSnapshot(), status: 'ready', options, active: 0, error: null })
|
|
},
|
|
(error: unknown) => {
|
|
if (this.binding !== binding) return
|
|
console.error(`[ui-command] popupSelect options failed for /${binding.command}:`, error)
|
|
this.state.set({ ...this.state.getSnapshot(), status: 'failed', options: [], active: 0, error: errorText(error) })
|
|
},
|
|
)
|
|
}
|
|
|
|
/** Re-run a failed options fetch (search survives; no-op unless status is 'failed'). */
|
|
retry(): void {
|
|
const binding = this.binding
|
|
const s = this.state.getSnapshot()
|
|
if (binding === null || !s.open || s.status !== 'failed') return
|
|
this.state.set({ ...s, status: 'pending', error: null })
|
|
this.load(binding)
|
|
}
|
|
|
|
/**
|
|
* Replace the local search text (pure local filter — the provider is never
|
|
* re-queried) and rebase the highlight onto the new filtered list.
|
|
* @param search - the shell search input's text.
|
|
*/
|
|
setSearch(search: string): void {
|
|
const s = this.state.getSnapshot()
|
|
if (!s.open || s.submitting || search === s.search) return
|
|
this.state.set({ ...s, search, active: 0 })
|
|
}
|
|
|
|
/**
|
|
* Move the highlight across the filtered rows (wraps around; no-op unless
|
|
* options are ready and no selection is in flight).
|
|
* @param dir - +1 down, -1 up.
|
|
*/
|
|
move(dir: 1 | -1): void {
|
|
const s = this.state.getSnapshot()
|
|
if (!s.open || s.status !== 'ready' || s.submitting) return
|
|
const rows = filterOptions(s.options, s.search)
|
|
if (rows.length === 0) return
|
|
const active = (s.active + dir + rows.length) % rows.length
|
|
this.state.set({ ...s, active })
|
|
}
|
|
|
|
/**
|
|
* Set the highlight directly (pointer hover; no-op unless ready, idle, and
|
|
* in filtered range).
|
|
* @param index - filtered-row index.
|
|
*/
|
|
highlight(index: number): void {
|
|
const s = this.state.getSnapshot()
|
|
if (!s.open || s.status !== 'ready' || s.submitting) return
|
|
if (index < 0 || index >= filterOptions(s.options, s.search).length || index === s.active) return
|
|
this.state.set({ ...s, active: index })
|
|
}
|
|
|
|
/**
|
|
* Select one filtered row: single-flight — the first call enters
|
|
* `submitting` and later calls no-op until it settles. Success consumes the
|
|
* open-time token segment (a false CAS answer is benign), closes, and
|
|
* returns focus to the composer. Failure keeps the shell open with search,
|
|
* highlight, and token intact, surfaces the error, and re-arms select as
|
|
* the retry.
|
|
* @param index - filtered-row index (callers pass the highlight or the clicked row).
|
|
* @returns settled when the attempt has closed the shell or surfaced its failure.
|
|
*/
|
|
async select(index: number): Promise<void> {
|
|
const binding = this.binding
|
|
const s = this.state.getSnapshot()
|
|
if (binding === null || !s.open || s.status !== 'ready' || s.submitting) return
|
|
const option = filterOptions(s.options, s.search)[index]
|
|
if (option === undefined) return
|
|
this.state.set({ ...s, submitting: true, error: null })
|
|
try {
|
|
await binding.spec.onSelect(option, binding.context)
|
|
} catch (error) {
|
|
console.error(`[ui-command] popupSelect onSelect failed for /${binding.command}:`, error)
|
|
if (this.binding !== binding) return // dismissed/reopened/disposed while onSelect flew
|
|
this.state.set({ ...this.state.getSnapshot(), submitting: false, error: errorText(error) })
|
|
return
|
|
}
|
|
if (this.binding !== binding) return // late success: no state write, no consumption
|
|
this.deps.consume(binding.segment)
|
|
this.binding = null
|
|
this.state.set(CLOSED)
|
|
this.deps.focusComposer()
|
|
}
|
|
|
|
/**
|
|
* Close the shell; aborts a flying options fetch and revokes settlement
|
|
* rights. An outside pointer interaction dismisses plainly (the click's own
|
|
* target takes focus); Escape passes focusComposer to return focus explicitly.
|
|
* @param opts - focusComposer: also restore composer focus (Escape path).
|
|
*/
|
|
dismiss(opts?: { readonly focusComposer?: boolean }): void {
|
|
if (this.binding === null) return
|
|
this.binding.abort.abort()
|
|
this.binding = null
|
|
this.state.set(CLOSED)
|
|
if (opts?.focusComposer === true) this.deps.focusComposer()
|
|
}
|
|
|
|
/** Scope-teardown disposer: abort in-flight work and clear state (no focus side effect). */
|
|
dispose(): void {
|
|
this.binding?.abort.abort()
|
|
this.binding = null
|
|
this.state.set(CLOSED)
|
|
}
|
|
}
|