feat: improve completions loading and UI handling

Refactor the completions system to support both CLI mode (via
`/__COMPLETIONS.json`) and a client-side fallback scan for dev mode.

- Implement client-side scanning of the file index for dice and headings
- Add `ensureCompletions` to allow components to await data readiness
- Update `JournalInput` to handle "no results" states in the dropdown
- Improve keyboard navigation (Tab to accept, Enter to select)
- Update dice regex to support `:md-dice[...]` syntax
This commit is contained in:
hypercross 2026-07-06 17:13:11 +08:00
parent 4c5add2414
commit e0d61cf91e
3 changed files with 328 additions and 128 deletions

View File

@ -14,7 +14,7 @@ export const diceSource: CompletionSource = {
scan(index) { scan(index) {
const items: DiceCompletion[] = []; const items: DiceCompletion[] = [];
const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/md-dice>/gi; const tagRegex = /:md-dice\[([^[]+)\]/gi;
for (const [path, content] of Object.entries(index)) { for (const [path, content] of Object.entries(index)) {
if (!path.endsWith(".md")) continue; if (!path.endsWith(".md")) continue;

View File

@ -5,17 +5,37 @@
* - Plain text "narrative" type * - Plain text "narrative" type
* - `/roll 3d6kh1` "roll.request" type * - `/roll 3d6kh1` "roll.request" type
* - `/link path#section` "article.reveal" type * - `/link path#section` "article.reveal" type
* - `/` alone opens completions dropdown (populated from /__COMPLETIONS.json) * - `/` alone opens completions dropdown (populated from /__COMPLETIONS.json
* in CLI mode, or client-side scan of the in-memory file index in dev mode)
*/ */
import { Component, createSignal, onMount, onCleanup, Show, For } from "solid-js"; import {
import { sendMessage, revertLatest, canRevert, useJournalStream } from "../stores/journalStream"; Component,
import { useJournalCompletions, type DiceCompletion, type LinkCompletion } from "./completions"; createSignal,
onMount,
onCleanup,
Show,
For,
Switch,
Match,
} from "solid-js";
import {
sendMessage,
revertLatest,
canRevert,
useJournalStream,
} from "../stores/journalStream";
import {
useJournalCompletions,
ensureCompletions,
type CompletionsState,
} from "./completions";
// ---- Helpers ----
interface CompletionItem { interface CompletionItem {
label: string; label: string;
/** "command" | "value" — used for styling and insertion */ kind: "command" | "value" | "no-results";
kind: "command" | "value";
insertText: string; insertText: string;
} }
@ -25,9 +45,42 @@ interface ParsedInput {
error?: string; error?: string;
} }
function parseInput(raw: string): ParsedInput {
if (raw.startsWith("/roll ")) {
const notation = raw.slice("/roll ".length).trim();
if (!notation)
return {
type: "roll.request",
payload: {},
error: "Dice notation required",
};
return { type: "roll.request", payload: { notation, label: notation } };
}
if (raw.startsWith("/link ")) {
const arg = raw.slice("/link ".length).trim();
if (!arg)
return { type: "article.reveal", payload: {}, error: "Path required" };
const hashIdx = arg.indexOf("#");
const path = hashIdx === -1 ? arg : arg.slice(0, hashIdx);
const section =
hashIdx === -1 ? undefined : arg.slice(hashIdx + 1) || undefined;
return { type: "article.reveal", payload: { path, section } };
}
// /roll or /link with no space — need to complete, don't send
if (raw === "/roll" || raw === "/link") {
return { type: "narrative", payload: {}, error: "Complete the command" };
}
return { type: "narrative", payload: { text: raw } };
}
// ---- Component ----
export const JournalInput: Component = () => { export const JournalInput: Component = () => {
const stream = useJournalStream(); const stream = useJournalStream();
const completions = useJournalCompletions(); const comp = useJournalCompletions();
const [text, setText] = createSignal(""); const [text, setText] = createSignal("");
const [error, setError] = createSignal<string | null>(null); const [error, setError] = createSignal<string | null>(null);
@ -38,38 +91,12 @@ export const JournalInput: Component = () => {
let textareaRef!: HTMLTextAreaElement; let textareaRef!: HTMLTextAreaElement;
let completionsRef!: HTMLDivElement; let completionsRef!: HTMLDivElement;
// ---------- Parsing ---------- // Ensure completions are loading on mount
onMount(() => {
void ensureCompletions();
});
function parseInput(raw: string): ParsedInput { // ---- Send ----
if (raw.startsWith("/roll ")) {
const notation = raw.slice("/roll ".length).trim();
if (!notation) return { type: "roll.request", payload: {}, error: "Dice notation required" };
return { type: "roll.request", payload: { notation, label: notation } };
}
if (raw.startsWith("/link ")) {
const arg = raw.slice("/link ".length).trim();
if (!arg) return { type: "article.reveal", payload: {}, error: "Path required" };
const [path, section] = parseLinkArg(arg);
return { type: "article.reveal", payload: { path, section: section ?? undefined } };
}
// /roll or /link with no space — show completions, don't send
if (raw === "/roll" || raw === "/link") {
return { type: "narrative", payload: {}, error: "Complete the command" };
}
return { type: "narrative", payload: { text: raw } };
}
/** Parse "path#section" or just "path" */
function parseLinkArg(arg: string): [string, string | null] {
const hashIdx = arg.indexOf("#");
if (hashIdx === -1) return [arg, null];
return [arg.slice(0, hashIdx), arg.slice(hashIdx + 1) || null];
}
// ---------- Send ----------
function handleSend() { function handleSend() {
const raw = text().trim(); const raw = text().trim();
@ -99,10 +126,11 @@ export const JournalInput: Component = () => {
revertLatest(); revertLatest();
} }
// ---------- Completions ---------- // ---- Completions ----
function buildCompletions(): CompletionItem[] { function buildCompletions(): CompletionItem[] {
const raw = text().trim(); const raw = text().trim();
const data = comp.data;
// Show commands when user types / // Show commands when user types /
if (raw === "" || raw === "/") { if (raw === "" || raw === "/") {
@ -115,38 +143,48 @@ export const JournalInput: Component = () => {
// After /roll — show dice suggestions // After /roll — show dice suggestions
if (raw.startsWith("/roll ")) { if (raw.startsWith("/roll ")) {
const prefix = raw.slice("/roll ".length).toLowerCase(); const prefix = raw.slice("/roll ".length).toLowerCase();
return completions.dice const matches = data.dice
.filter( .filter(
(d) => (d) =>
d.notation.toLowerCase().includes(prefix) || d.notation.toLowerCase().includes(prefix) ||
d.label.toLowerCase().includes(prefix), d.label.toLowerCase().includes(prefix),
) )
.slice(0, 8) .slice(0, 8);
.map((d) => ({ if (matches.length === 0) {
label: d.notation, return [{ label: "No dice found", kind: "no-results", insertText: "" }];
kind: "value" as const, }
insertText: "/roll " + d.notation, return matches.map((d) => ({
})); label: d.notation,
kind: "value" as const,
insertText: "/roll " + d.notation,
}));
} }
// After /link — show article and heading suggestions // After /link — show article and heading suggestions
if (raw.startsWith("/link ")) { if (raw.startsWith("/link ")) {
const prefix = raw.slice("/link ".length).toLowerCase(); const prefix = raw.slice("/link ".length).toLowerCase();
return completions.links const matches = data.links
.filter( .filter(
(l) => (l) =>
l.path.toLowerCase().includes(prefix) || l.path.toLowerCase().includes(prefix) ||
l.label.toLowerCase().includes(prefix), l.label.toLowerCase().includes(prefix),
) )
.slice(0, 8) .slice(0, 8);
.map((l) => { if (matches.length === 0) {
const insert = l.section ? `/link ${l.path}#${l.section}` : `/link ${l.path}`; return [
return { { label: "No links found", kind: "no-results", insertText: "" },
label: l.label, ];
kind: "value" as const, }
insertText: insert, return matches.map((l) => {
}; const insert = l.section
}); ? `/link ${l.path}#${l.section}`
: `/link ${l.path}`;
return {
label: l.label,
kind: "value" as const,
insertText: insert,
};
});
} }
return []; return [];
@ -157,6 +195,7 @@ export const JournalInput: Component = () => {
} }
function acceptCompletion(item: CompletionItem) { function acceptCompletion(item: CompletionItem) {
if (item.kind === "no-results") return;
setText(item.insertText); setText(item.insertText);
setShowCompletions(false); setShowCompletions(false);
textareaRef?.focus(); textareaRef?.focus();
@ -171,22 +210,53 @@ export const JournalInput: Component = () => {
}); });
} }
// ---------- Keyboard ---------- // ---- Keyboard ----
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
const comps = currentCompletions(); const comps = currentCompletions();
const open = showCompletions(); const open = showCompletions();
// Completions: navigate & accept // When opening completions with Tab, if the text matches a command prefix
// and there are options, accept the first. Otherwise just show.
if (e.key === "Tab") {
if (open) {
e.preventDefault();
if (comps.length > 0 && comps[selectedIdx()].kind !== "no-results") {
acceptCompletion(comps[selectedIdx()]);
}
return;
}
// If not open and typing /, open completions
const raw = text();
if (raw === "/" || raw.startsWith("/roll") || raw.startsWith("/link")) {
e.preventDefault();
setShowCompletions(true);
setSelectedIdx(0);
return;
}
return;
}
if (open && comps.length > 0) { if (open && comps.length > 0) {
if (e.key === "ArrowDown") { e.preventDefault(); selectCompletion("down"); return; } if (e.key === "ArrowDown") {
if (e.key === "ArrowUp") { e.preventDefault(); selectCompletion("up"); return; } e.preventDefault();
if (e.key === "Tab" || e.key === "Enter") { selectCompletion("down");
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
selectCompletion("up");
return;
}
if (e.key === "Enter" && comps[selectedIdx()].kind !== "no-results") {
e.preventDefault(); e.preventDefault();
acceptCompletion(comps[selectedIdx()]); acceptCompletion(comps[selectedIdx()]);
return; return;
} }
if (e.key === "Escape") { setShowCompletions(false); return; } if (e.key === "Escape") {
setShowCompletions(false);
return;
}
} }
// Send // Send
@ -196,11 +266,19 @@ export const JournalInput: Component = () => {
} }
} }
function handleInput() { function handleInput(e: InputEvent) {
const raw = text(); const input = e.currentTarget as HTMLTextAreaElement;
const raw = input.value;
setText(raw);
// Show completions when user types / at the start // Completions visibility
if (raw === "/" || raw.startsWith("/roll ") || raw.startsWith("/link ")) { if (
raw === "/" ||
raw.startsWith("/roll ") ||
raw.startsWith("/link ") ||
raw === "/roll" ||
raw === "/link"
) {
setShowCompletions(true); setShowCompletions(true);
setSelectedIdx(0); setSelectedIdx(0);
} else { } else {
@ -212,7 +290,7 @@ export const JournalInput: Component = () => {
textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px"; textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px";
} }
// ---------- Click outside ---------- // ---- Click outside ----
onMount(() => { onMount(() => {
const handler = (e: MouseEvent) => { const handler = (e: MouseEvent) => {
@ -226,24 +304,34 @@ export const JournalInput: Component = () => {
const showRevert = () => canRevert() && stream.myName === "gm"; const showRevert = () => canRevert() && stream.myName === "gm";
return ( // Completions placeholder — shown in the dropdown area when no matches
<div class="border-t border-gray-200 bg-white relative"> function renderCompletionsDropdown() {
{/* Completions dropdown — positioned above the textarea */} const comps = currentCompletions();
<Show when={showCompletions() && currentCompletions().length > 0}> if (comps.length === 0) return null;
<div
ref={completionsRef} return (
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 max-h-40 overflow-y-auto z-50" <div
> ref={completionsRef}
<For each={currentCompletions()}> class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 max-h-40 overflow-y-auto z-50"
{(item, idx) => ( >
<div <For each={comps}>
class={`flex items-center gap-2 px-3 py-1.5 cursor-pointer text-sm ${ {(item, idx) => (
idx() === selectedIdx() <div
class={`flex items-center gap-2 px-3 py-1.5 cursor-pointer text-sm ${
item.kind === "no-results"
? "text-gray-400 italic cursor-default"
: idx() === selectedIdx()
? "bg-blue-50 text-blue-700" ? "bg-blue-50 text-blue-700"
: "hover:bg-gray-100 text-gray-700" : "hover:bg-gray-100 text-gray-700"
}`} }`}
onClick={() => acceptCompletion(item)} onClick={() => acceptCompletion(item)}
onMouseEnter={() => setSelectedIdx(idx())} onMouseEnter={() =>
item.kind !== "no-results" && setSelectedIdx(idx())
}
>
<Show
when={item.kind !== "no-results"}
fallback={<span class="text-xs text-gray-300 shrink-0"></span>}
> >
<span <span
class={`text-xs px-1 rounded shrink-0 ${ class={`text-xs px-1 rounded shrink-0 ${
@ -254,11 +342,44 @@ export const JournalInput: Component = () => {
> >
{item.kind === "command" ? "cmd" : "val"} {item.kind === "command" ? "cmd" : "val"}
</span> </span>
<span class="font-mono truncate flex-1">{item.label}</span> </Show>
</div> <span class="font-mono truncate flex-1">{item.label}</span>
)} </div>
</For> )}
</div> </For>
</div>
);
}
return (
<div class="border-t border-gray-200 bg-white relative">
{/* Completions loading / error / empty teaser */}
<Show when={showCompletions()}>
<Switch>
<Match when={comp.state.status === "loading"}>
<div
ref={completionsRef}
class="absolute bottom-full left-0 right-0 bg-white border border-gray-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-gray-400 italic"
>
Loading completions
</div>
</Match>
<Match when={comp.state.status === "error"}>
<div
ref={completionsRef}
class="absolute bottom-full left-0 right-0 bg-white border border-red-200 rounded-t shadow-lg mb-0.5 z-50 px-3 py-2 text-xs text-red-500"
>
{(comp.state as any).message || "Failed to load completions"}
</div>
</Match>
<Match
when={
comp.state.status === "empty" || comp.state.status === "loaded"
}
>
{renderCompletionsDropdown()}
</Match>
</Switch>
</Show> </Show>
{/* Textarea + actions */} {/* Textarea + actions */}

View File

@ -1,11 +1,19 @@
/** /**
* Journal completions client-side loader for /__COMPLETIONS.json * Journal completions client-side loader for /__COMPLETIONS.json
* *
* Fetches once on first call, caches the result. Call `invalidateCompletions()` * In CLI mode, fetches the pre-computed index. In dev/browser mode, falls
* to force a re-fetch (e.g., when the content source changes). * back to scanning the in-memory file index for dice expressions and headings.
*
* The fetch runs eagerly on module import. Call `useJournalCompletions()`
* from any Solid component to reactively read the state.
*/ */
import { createSignal } from "solid-js"; import { createSignal } from "solid-js";
import { extractHeadings } from "../../data-loader/toc";
import {
getPathsByExtension,
getIndexedData,
} from "../../data-loader/file-index";
// ------------------- Types (mirrors CLI) ------------------- // ------------------- Types (mirrors CLI) -------------------
@ -26,57 +34,128 @@ export interface JournalCompletions {
links: LinkCompletion[]; links: LinkCompletion[];
} }
// ------------------- Cache ------------------- export type CompletionsState =
| { status: "loading" }
| { status: "loaded"; data: JournalCompletions }
| { status: "empty" }
| { status: "error"; message: string };
let _cache: JournalCompletions = { dice: [], links: [] }; // ------------------- Reactive state (module-level signal) -------------------
let _loaded = false;
/** Thawed value signal; invalidate resets it. */ const [completionsState, setCompletionsState] = createSignal<CompletionsState>({
const [completions, setCompletions] = createSignal<JournalCompletions>(_cache); status: "loading",
});
// ------------------- Fetch ------------------- // ------------------- Fetch (CLI mode) -------------------
async function load(): Promise<void> { async function tryServer(): Promise<JournalCompletions | null> {
try { try {
const resp = await fetch("/__COMPLETIONS.json"); const resp = await fetch("/__COMPLETIONS.json");
if (resp.ok) { if (!resp.ok) return null;
const data = await resp.json(); const data = await resp.json();
_cache = { return {
dice: Array.isArray(data.dice) ? data.dice : [], dice: Array.isArray(data.dice) ? data.dice : [],
links: Array.isArray(data.links) ? data.links : [], links: Array.isArray(data.links) ? data.links : [],
}; };
} else {
_cache = { dice: [], links: [] };
}
} catch { } catch {
_cache = { dice: [], links: [] }; return null;
} }
_loaded = true;
setCompletions(_cache);
} }
/** Ensure data is loaded (lazy, idempotent). */ // ------------------- Client-side fallback scan -------------------
let _promise: Promise<void> | null = null;
export function ensureCompletions(): Promise<void> { async function scanClientSide(): Promise<JournalCompletions> {
if (!_promise) _promise = load(); const paths = await getPathsByExtension("md");
return _promise; const dice: DiceCompletion[] = [];
const links: LinkCompletion[] = [];
const tagRegex = /<md-dice[^>]*>\s*([\s\S]*?)\s*<\/md-dice>/gi;
for (const filePath of paths) {
const content = await getIndexedData(filePath);
if (!content) continue;
// Dice scan
let match: RegExpExecArray | null;
tagRegex.lastIndex = 0;
while ((match = tagRegex.exec(content)) !== null) {
const raw = match[1].trim();
if (!raw || raw.length > 80) continue;
if (!/^\d*d\d+/i.test(raw) && !/^[+-]/.test(raw)) continue;
dice.push({ label: raw, notation: raw, source: filePath });
}
// Link scan (headings)
const basePath = filePath.replace(/\.md$/, "");
const fileName = basePath.split("/").filter(Boolean).pop() || basePath;
links.push({ path: basePath, label: fileName, section: null });
for (const heading of extractHeadings(content)) {
links.push({
path: basePath,
label: `${fileName} § ${heading.title}`,
section: heading.id ?? null,
});
}
}
return { dice, links };
} }
/** Force a re-fetch on next use. */ // ------------------- Init (runs eagerly at import time) -------------------
export function invalidateCompletions(): void {
_loaded = false;
_promise = null;
}
// ------------------- Hook ------------------- // Using a top-level IIFE so the promise starts immediately
const _initPromise: Promise<void> = (async () => {
// 1. Try server first (CLI mode)
const serverData = await tryServer();
if (serverData) {
setCompletionsState({ status: "loaded", data: serverData });
return;
}
// 2. Fall back to client-side scan (dev/browser mode)
try {
const data = await scanClientSide();
if (data.dice.length > 0 || data.links.length > 0) {
setCompletionsState({ status: "loaded", data });
} else {
setCompletionsState({ status: "empty" });
}
} catch (e) {
setCompletionsState({
status: "error",
message: e instanceof Error ? e.message : "Failed to scan",
});
}
})();
// ------------------- Public API -------------------
/** /**
* Reactive completions data for the journal input. * Returns a Promise that resolves once completions are loaded (or failed).
* Triggers a lazy fetch on first access; returns empty arrays while loading. * Useful for waiting before showing the completions dropdown.
*/ */
export function useJournalCompletions(): JournalCompletions { export function ensureCompletions(): Promise<void> {
if (!_loaded) { return _initPromise;
void ensureCompletions(); }
}
return completions(); /** Force a re-fetch on the next page load. For runtime, call before invalidate triggers. */
let _invalidated = false;
export function invalidateCompletions(): void {
_invalidated = true;
// On next import (page reload), the module will re-init.
// For a runtime invalidation, you could call init again.
}
/**
* Reactive hooks for the journal input.
* Returns the current completions state + a convenience `data` extractor.
*/
export function useJournalCompletions(): {
state: CompletionsState;
data: JournalCompletions;
} {
const s = completionsState();
if (s.status === "loaded") {
return { state: s, data: s.data };
}
return { state: s, data: { dice: [], links: [] } };
} }