diff --git a/src/cli/completions/sources/dice.ts b/src/cli/completions/sources/dice.ts index 079849c..42d1e2d 100644 --- a/src/cli/completions/sources/dice.ts +++ b/src/cli/completions/sources/dice.ts @@ -14,7 +14,7 @@ export const diceSource: CompletionSource = { scan(index) { const items: DiceCompletion[] = []; - const tagRegex = /]*>\s*([\s\S]*?)\s*<\/md-dice>/gi; + const tagRegex = /:md-dice\[([^[]+)\]/gi; for (const [path, content] of Object.entries(index)) { if (!path.endsWith(".md")) continue; diff --git a/src/components/journal/JournalInput.tsx b/src/components/journal/JournalInput.tsx index 8d06409..08bfee5 100644 --- a/src/components/journal/JournalInput.tsx +++ b/src/components/journal/JournalInput.tsx @@ -5,17 +5,37 @@ * - Plain text → "narrative" type * - `/roll 3d6kh1` → "roll.request" 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 { sendMessage, revertLatest, canRevert, useJournalStream } from "../stores/journalStream"; -import { useJournalCompletions, type DiceCompletion, type LinkCompletion } from "./completions"; +import { + Component, + 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 { label: string; - /** "command" | "value" — used for styling and insertion */ - kind: "command" | "value"; + kind: "command" | "value" | "no-results"; insertText: string; } @@ -25,9 +45,42 @@ interface ParsedInput { 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 = () => { const stream = useJournalStream(); - const completions = useJournalCompletions(); + const comp = useJournalCompletions(); const [text, setText] = createSignal(""); const [error, setError] = createSignal(null); @@ -38,38 +91,12 @@ export const JournalInput: Component = () => { let textareaRef!: HTMLTextAreaElement; let completionsRef!: HTMLDivElement; - // ---------- Parsing ---------- + // Ensure completions are loading on mount + onMount(() => { + void ensureCompletions(); + }); - 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 [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 ---------- + // ---- Send ---- function handleSend() { const raw = text().trim(); @@ -99,10 +126,11 @@ export const JournalInput: Component = () => { revertLatest(); } - // ---------- Completions ---------- + // ---- Completions ---- function buildCompletions(): CompletionItem[] { const raw = text().trim(); + const data = comp.data; // Show commands when user types / if (raw === "" || raw === "/") { @@ -115,38 +143,48 @@ export const JournalInput: Component = () => { // After /roll — show dice suggestions if (raw.startsWith("/roll ")) { const prefix = raw.slice("/roll ".length).toLowerCase(); - return completions.dice + const matches = data.dice .filter( (d) => d.notation.toLowerCase().includes(prefix) || d.label.toLowerCase().includes(prefix), ) - .slice(0, 8) - .map((d) => ({ - label: d.notation, - kind: "value" as const, - insertText: "/roll " + d.notation, - })); + .slice(0, 8); + if (matches.length === 0) { + return [{ label: "No dice found", kind: "no-results", insertText: "" }]; + } + return matches.map((d) => ({ + label: d.notation, + kind: "value" as const, + insertText: "/roll " + d.notation, + })); } // After /link — show article and heading suggestions if (raw.startsWith("/link ")) { const prefix = raw.slice("/link ".length).toLowerCase(); - return completions.links + const matches = data.links .filter( (l) => l.path.toLowerCase().includes(prefix) || l.label.toLowerCase().includes(prefix), ) - .slice(0, 8) - .map((l) => { - const insert = l.section ? `/link ${l.path}#${l.section}` : `/link ${l.path}`; - return { - label: l.label, - kind: "value" as const, - insertText: insert, - }; - }); + .slice(0, 8); + if (matches.length === 0) { + return [ + { label: "No links found", kind: "no-results", insertText: "" }, + ]; + } + 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 []; @@ -157,6 +195,7 @@ export const JournalInput: Component = () => { } function acceptCompletion(item: CompletionItem) { + if (item.kind === "no-results") return; setText(item.insertText); setShowCompletions(false); textareaRef?.focus(); @@ -171,22 +210,53 @@ export const JournalInput: Component = () => { }); } - // ---------- Keyboard ---------- + // ---- Keyboard ---- function handleKeyDown(e: KeyboardEvent) { const comps = currentCompletions(); 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 (e.key === "ArrowDown") { e.preventDefault(); selectCompletion("down"); return; } - if (e.key === "ArrowUp") { e.preventDefault(); selectCompletion("up"); return; } - if (e.key === "Tab" || e.key === "Enter") { + if (e.key === "ArrowDown") { + e.preventDefault(); + selectCompletion("down"); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + selectCompletion("up"); + return; + } + if (e.key === "Enter" && comps[selectedIdx()].kind !== "no-results") { e.preventDefault(); acceptCompletion(comps[selectedIdx()]); return; } - if (e.key === "Escape") { setShowCompletions(false); return; } + if (e.key === "Escape") { + setShowCompletions(false); + return; + } } // Send @@ -196,11 +266,19 @@ export const JournalInput: Component = () => { } } - function handleInput() { - const raw = text(); + function handleInput(e: InputEvent) { + const input = e.currentTarget as HTMLTextAreaElement; + const raw = input.value; + setText(raw); - // Show completions when user types / at the start - if (raw === "/" || raw.startsWith("/roll ") || raw.startsWith("/link ")) { + // Completions visibility + if ( + raw === "/" || + raw.startsWith("/roll ") || + raw.startsWith("/link ") || + raw === "/roll" || + raw === "/link" + ) { setShowCompletions(true); setSelectedIdx(0); } else { @@ -212,7 +290,7 @@ export const JournalInput: Component = () => { textareaRef.style.height = Math.min(textareaRef.scrollHeight, 150) + "px"; } - // ---------- Click outside ---------- + // ---- Click outside ---- onMount(() => { const handler = (e: MouseEvent) => { @@ -226,24 +304,34 @@ export const JournalInput: Component = () => { const showRevert = () => canRevert() && stream.myName === "gm"; - return ( -
- {/* Completions dropdown — positioned above the textarea */} - 0}> -
- - {(item, idx) => ( -
+ + {(item, idx) => ( +
acceptCompletion(item)} - onMouseEnter={() => setSelectedIdx(idx())} + }`} + onClick={() => acceptCompletion(item)} + onMouseEnter={() => + item.kind !== "no-results" && setSelectedIdx(idx()) + } + > + —} > { > {item.kind === "command" ? "cmd" : "val"} - {item.label} -
- )} -
-
+ + {item.label} +
+ )} + +
+ ); + } + + return ( +
+ {/* Completions loading / error / empty teaser */} + + + +
+ Loading completions… +
+
+ +
+ {(comp.state as any).message || "Failed to load completions"} +
+
+ + {renderCompletionsDropdown()} + +
{/* Textarea + actions */} diff --git a/src/components/journal/completions.ts b/src/components/journal/completions.ts index df1ba3a..f98749c 100644 --- a/src/components/journal/completions.ts +++ b/src/components/journal/completions.ts @@ -1,11 +1,19 @@ /** * Journal completions — client-side loader for /__COMPLETIONS.json * - * Fetches once on first call, caches the result. Call `invalidateCompletions()` - * to force a re-fetch (e.g., when the content source changes). + * In CLI mode, fetches the pre-computed index. In dev/browser mode, falls + * 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 { extractHeadings } from "../../data-loader/toc"; +import { + getPathsByExtension, + getIndexedData, +} from "../../data-loader/file-index"; // ------------------- Types (mirrors CLI) ------------------- @@ -26,57 +34,128 @@ export interface JournalCompletions { links: LinkCompletion[]; } -// ------------------- Cache ------------------- +export type CompletionsState = + | { status: "loading" } + | { status: "loaded"; data: JournalCompletions } + | { status: "empty" } + | { status: "error"; message: string }; -let _cache: JournalCompletions = { dice: [], links: [] }; -let _loaded = false; +// ------------------- Reactive state (module-level signal) ------------------- -/** Thawed value signal; invalidate resets it. */ -const [completions, setCompletions] = createSignal(_cache); +const [completionsState, setCompletionsState] = createSignal({ + status: "loading", +}); -// ------------------- Fetch ------------------- +// ------------------- Fetch (CLI mode) ------------------- -async function load(): Promise { +async function tryServer(): Promise { try { const resp = await fetch("/__COMPLETIONS.json"); - if (resp.ok) { - const data = await resp.json(); - _cache = { - dice: Array.isArray(data.dice) ? data.dice : [], - links: Array.isArray(data.links) ? data.links : [], - }; - } else { - _cache = { dice: [], links: [] }; - } + if (!resp.ok) return null; + const data = await resp.json(); + return { + dice: Array.isArray(data.dice) ? data.dice : [], + links: Array.isArray(data.links) ? data.links : [], + }; } catch { - _cache = { dice: [], links: [] }; + return null; } - _loaded = true; - setCompletions(_cache); } -/** Ensure data is loaded (lazy, idempotent). */ -let _promise: Promise | null = null; -export function ensureCompletions(): Promise { - if (!_promise) _promise = load(); - return _promise; +// ------------------- Client-side fallback scan ------------------- + +async function scanClientSide(): Promise { + const paths = await getPathsByExtension("md"); + const dice: DiceCompletion[] = []; + const links: LinkCompletion[] = []; + const tagRegex = /]*>\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. */ -export function invalidateCompletions(): void { - _loaded = false; - _promise = null; -} +// ------------------- Init (runs eagerly at import time) ------------------- -// ------------------- Hook ------------------- +// Using a top-level IIFE so the promise starts immediately +const _initPromise: Promise = (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. - * Triggers a lazy fetch on first access; returns empty arrays while loading. + * Returns a Promise that resolves once completions are loaded (or failed). + * Useful for waiting before showing the completions dropdown. */ -export function useJournalCompletions(): JournalCompletions { - if (!_loaded) { - void ensureCompletions(); - } - return completions(); +export function ensureCompletions(): Promise { + return _initPromise; +} + +/** 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: [] } }; }