eslint --fix autofixes plus manual repairs: max-len line splits (fake-api handlers, notifier/slots JSDoc, spec signatures), charAt over non-null-asserted indexing in slash detect/menu cores, Array.from for code-point capping, typeof assertions for unbound-method in specs, generic getByRole for the send-button cast, effect disposer void-wrap in command register, and dropped unused type imports.
141 lines
6.0 KiB
TypeScript
141 lines
6.0 KiB
TypeScript
/**
|
|
* Menu reduction pure core (design §5.1, plan §1.2). One group per source;
|
|
* generation-gated settlement; empty ready groups auto-close. Zero React /
|
|
* DOM / cordis. Stale or no-op events return the same state reference so
|
|
* store subscribers skip re-renders.
|
|
*
|
|
* Roster protocol: the frozen `hit` event carries no source roster, so the
|
|
* reducer cannot invent groups. Opening from a closed state, the shell seeds
|
|
* the roster with {@link seedGroups} and then dispatches `hit`; a `hit`
|
|
* while open (query refinement) resets the existing groups to pending under
|
|
* a new generation. Auto-close and explicit close drop the groups.
|
|
*/
|
|
import type { SlashCandidate } from '../types.ts'
|
|
import type { ExactMatch, MenuReduce, MenuState } from './contract.ts'
|
|
|
|
/** Closed rest state with generation 0; store initializer and test seed. */
|
|
export const MENU_CLOSED: MenuState = { open: false, hit: null, generation: 0, groups: [], highlight: null }
|
|
|
|
/**
|
|
* Replace the group roster with pending groups for `sources`, in order.
|
|
* Shell-side step before dispatching `hit` on a fresh menu open.
|
|
*
|
|
* @param state - Current menu state.
|
|
* @param sources - Source names registered for the hit trigger, menu order.
|
|
* @returns State carrying the new pending roster; highlight cleared.
|
|
*/
|
|
export function seedGroups(state: MenuState, sources: readonly string[]): MenuState {
|
|
return { ...state, groups: sources.map(source => ({ source, status: 'pending', items: [] })), highlight: null }
|
|
}
|
|
|
|
/** Close, preserving the generation so in-flight settlements stay droppable. */
|
|
const closed = (state: MenuState): MenuState =>
|
|
state.open || state.hit !== null || state.groups.length > 0 || state.highlight !== null
|
|
? { open: false, hit: null, generation: state.generation, groups: [], highlight: null }
|
|
: state
|
|
|
|
/** First item of the first non-empty ready group, or null. */
|
|
function firstHighlight(groups: MenuState['groups']): MenuState['highlight'] {
|
|
for (const g of groups) {
|
|
if (g.status === 'ready' && g.items.length > 0) return { source: g.source, index: 0 }
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** The highlight itself when it still points at a ready item, else null. */
|
|
function validHighlight(highlight: MenuState['highlight'], groups: MenuState['groups']): MenuState['highlight'] {
|
|
if (!highlight) return null
|
|
const g = groups.find(x => x.source === highlight.source)
|
|
return g && g.status === 'ready' && highlight.index < g.items.length ? highlight : null
|
|
}
|
|
|
|
/** Flatten ready items into (source, index) positions in group order. */
|
|
function positions(groups: MenuState['groups']): { source: string; index: number }[] {
|
|
const out: { source: string; index: number }[] = []
|
|
for (const g of groups) {
|
|
if (g.status !== 'ready') continue
|
|
for (let i = 0; i < g.items.length; i++) out.push({ source: g.source, index: i })
|
|
}
|
|
return out
|
|
}
|
|
|
|
/** True when every group is ready with zero items (the auto-close condition). */
|
|
const allReadyEmpty = (groups: MenuState['groups']): boolean =>
|
|
groups.every(g => g.status === 'ready' && g.items.length === 0)
|
|
|
|
/**
|
|
* Pure menu reducer. `hit` opens a new generation over the seeded roster
|
|
* (null hit closes); `source-settled` outside the current generation, the
|
|
* open menu, or the roster is dropped; a settlement or failure leaving every
|
|
* group ready-and-empty (or no groups) auto-closes; `source-failed` silently
|
|
* removes the group (the shell logs); `move` cycles the highlight across
|
|
* ready items.
|
|
*
|
|
* @param state - Current menu state.
|
|
* @param ev - Menu event.
|
|
* @returns Next state; the same reference when stale or a no-op.
|
|
*/
|
|
export const menuReduce: MenuReduce = (state, ev) => {
|
|
switch (ev.type) {
|
|
case 'hit': {
|
|
if (ev.hit === null) return closed(state)
|
|
return {
|
|
open: true,
|
|
hit: ev.hit,
|
|
generation: state.generation + 1,
|
|
groups: state.groups.map(g => ({ source: g.source, status: 'pending', items: [] })),
|
|
highlight: null,
|
|
}
|
|
}
|
|
case 'source-settled': {
|
|
if (!state.open || ev.generation !== state.generation) return state
|
|
const idx = state.groups.findIndex(g => g.source === ev.source)
|
|
if (idx < 0) return state
|
|
const items: readonly SlashCandidate[] = ev.items ?? []
|
|
const groups = state.groups.map((g, i) =>
|
|
i === idx ? { source: g.source, status: 'ready' as const, items } : g)
|
|
if (allReadyEmpty(groups)) return closed(state)
|
|
const highlight = validHighlight(state.highlight, groups) ?? firstHighlight(groups)
|
|
return { ...state, groups, highlight }
|
|
}
|
|
case 'source-failed': {
|
|
if (!state.open || ev.generation !== state.generation) return state
|
|
if (!state.groups.some(g => g.source === ev.source)) return state
|
|
const groups = state.groups.filter(g => g.source !== ev.source)
|
|
if (groups.length === 0 || allReadyEmpty(groups)) return closed(state)
|
|
const highlight = validHighlight(state.highlight, groups) ?? firstHighlight(groups)
|
|
return { ...state, groups, highlight }
|
|
}
|
|
case 'move': {
|
|
if (!state.open) return state
|
|
const pos = positions(state.groups)
|
|
if (pos.length === 0) return state
|
|
const hl = state.highlight
|
|
const at = hl ? pos.findIndex(p => p.source === hl.source && p.index === hl.index) : -1
|
|
const next = pos[at < 0
|
|
? (ev.dir === 1 ? 0 : pos.length - 1)
|
|
: (at + ev.dir + pos.length) % pos.length]
|
|
if (next === undefined) return state
|
|
if (hl && next.source === hl.source && next.index === hl.index) return state
|
|
return { ...state, highlight: next }
|
|
}
|
|
case 'close':
|
|
return closed(state)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Exact-name lookup in one source's ready group.
|
|
*
|
|
* @param groups - Menu groups.
|
|
* @param source - Source (group) name.
|
|
* @param name - Candidate name to match exactly.
|
|
* @returns The candidate, or null when the group is absent, not ready, or
|
|
* has no candidate of that name.
|
|
*/
|
|
export const exactMatch: ExactMatch = (groups, source, name) => {
|
|
const group = groups.find(g => g.source === source)
|
|
if (!group || group.status !== 'ready') return null
|
|
return group.items.find(c => c.name === name) ?? null
|
|
}
|