refactor: apply repository naming contract

Apply the accepted pre-release package, service, type, directory, and role renames as one repository-wide change.
This commit is contained in:
Tianyi Cui
2026-08-13 00:36:22 +08:00
parent 101df7cf58
commit a2d0f7f411
3281 changed files with 21730 additions and 21592 deletions

View File

@@ -0,0 +1,57 @@
/**
* Frozen pure-core contract: trigger detection and
* menu reduction, zero React / DOM / cordis. Types only — implementations
* live in sibling modules annotated with these
* aliases; the service shell wires them to ctx.
*/
import type { InputTriggerCandidate, TokenSpan, TriggerChar, TriggerGuard, TriggerPosition } from '../types.ts'
/** A detected trigger token under the caret. */
export interface TriggerHit {
readonly trigger: TriggerChar
/** Text between the trigger char and the caret, live-filtered. */
readonly query: string
/** leading = draft trimmed (whitespace incl. newlines) starts with the token. */
readonly position: TriggerPosition
/** Token span; draftRev injected by the caller. */
readonly span: TokenSpan
}
/**
* Detect a trigger token at the caret under the given guard tier.
* Word-boundary rule: the char before the trigger is start-of-line,
* whitespace, or punctuation; `user@host` and URL '/' do not trigger.
* Returns null when no trigger is live at the caret.
*/
export type DetectTrigger = (draft: string, caret: number, guard: TriggerGuard) => TriggerHit | null
/** Menu state: one group per source; empty ready groups auto-close the menu. */
export interface MenuState {
readonly open: boolean
readonly hit: TriggerHit | null
/** Monotonic per-hit generation; stale source settlements are dropped. */
readonly generation: number
readonly groups: readonly {
readonly source: string
readonly status: 'pending' | 'ready'
readonly items: readonly InputTriggerCandidate[]
}[]
readonly highlight: { readonly source: string; readonly index: number } | null
}
/** Menu reduction events. Source failure = silent group removal (log only; no error UI tier). */
export type MenuEvent =
| { readonly type: 'hit'; readonly hit: TriggerHit | null }
| { readonly type: 'source-settled'; readonly generation: number; readonly source: string; readonly items?: readonly InputTriggerCandidate[] }
| { readonly type: 'source-failed'; readonly generation: number; readonly source: string }
| { readonly type: 'move'; readonly dir: 1 | -1 }
| { readonly type: 'close' }
/** Pure menu reducer; returns the same reference when the event is stale or a no-op. */
export type MenuReduce = (state: MenuState, ev: MenuEvent) => MenuState
/**
* Exact-name lookup in one source's ready group; null when absent or the
* group is not ready.
*/
export type ExactMatch = (groups: MenuState['groups'], source: string, name: string) => InputTriggerCandidate | null

View File

@@ -0,0 +1,63 @@
/**
* Trigger detection pure core. Scans backward from
* the caret for a live trigger char under the guard tier and applies the
* word-boundary rules. Zero React / DOM / cordis.
*/
import type { TriggerChar } from '../types.ts'
import type { DetectTrigger } from './contract.ts'
const WORD_CHAR = /[\p{L}\p{N}_]/u
const WHITESPACE = /\s/u
/**
* Word-boundary rule: a trigger char opens only at start-of-draft, after
* whitespace (newlines included), or after punctuation. Two URL carve-outs
* keep '/' dead inside URLs (both pinned by tests): '/' after a ':' that
* itself follows a non-whitespace char (scheme separator, `https:/…`), and
* '/' directly after another '/' (second slash of `//`).
*/
function boundaryOk(draft: string, index: number, char: TriggerChar): boolean {
if (index === 0) return true
const prev = draft.charAt(index - 1)
if (WHITESPACE.test(prev)) return true
if (WORD_CHAR.test(prev)) return false
if (char === '/') {
if (prev === '/') return false
if (prev === ':' && index >= 2 && !WHITESPACE.test(draft.charAt(index - 2))) return false
}
return true
}
/**
* Detect a trigger token at the caret. Scans left from the caret and stops
* at the first whitespace (the token under edit never spans whitespace);
* trigger chars failing the guard tier or the word boundary are treated as
* ordinary token chars and the scan continues (`user@host`, URL slashes).
* Guard tiers: plain = both chars live; claimed = '/' fully suppressed,
* '@' live; frozen = none.
*
* @param draft - Full draft text.
* @param caret - Caret offset into `draft`.
* @param guard - Availability tier derived from the input phase.
* @returns The hit with `query` = trigger-to-caret slice and `span` =
* `{start: triggerIndex, end: caret}`; `span.draftRev` is a placeholder `0`
* — the calling shell stamps the real revision. Null when no trigger is
* live at the caret.
*/
export const detectTrigger: DetectTrigger = (draft, caret, guard) => {
if (guard.tier === 'frozen') return null
for (let i = caret - 1; i >= 0; i--) {
const ch = draft.charAt(i)
if (WHITESPACE.test(ch)) return null
if (ch !== '/' && ch !== '@') continue
if (guard.tier === 'claimed' && ch === '/') continue
if (!boundaryOk(draft, i, ch)) continue
return {
trigger: ch,
query: draft.slice(i + 1, caret),
position: draft.search(/\S/) === i ? 'leading' : 'inline',
span: { start: i, end: caret, draftRev: 0 },
}
}
return null
}

View File

@@ -0,0 +1,140 @@
/**
* Menu reduction pure core. 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 { InputTriggerCandidate } 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 InputTriggerCandidate[] = 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
}