diff --git a/packages/api/src/namespaces.ts b/packages/api/src/namespaces.ts index 0bc03e0..17a39e6 100644 --- a/packages/api/src/namespaces.ts +++ b/packages/api/src/namespaces.ts @@ -155,6 +155,15 @@ export interface InputBoxOptions { placeHolder?: string; /** Mask the input, for secrets. */ password?: boolean; + /** + * Validate the current value on every keystroke (Task 3.1, design.md + * §12). A returned string is shown as a validation message and blocks + * `showInputBox`'s promise from resolving on accept (Enter); `undefined` + * means the current value is valid. Called once with the initial `value` + * (or `""`) when the input box opens, so a required-field validator can + * report immediately rather than only after the first keystroke. + */ + validateInput?: (value: string) => string | undefined; } /** Which side of the status bar an item renders on, and its sort diff --git a/packages/cli/src/keymapState.test.ts b/packages/cli/src/keymapState.test.ts index fe27bae..d4664cf 100644 --- a/packages/cli/src/keymapState.test.ts +++ b/packages/cli/src/keymapState.test.ts @@ -48,6 +48,25 @@ test("a malformed raw user entry is skipped rather than thrown", () => { expect(log.entries().some((e) => e.level === "warning")).toBe(true); }); +test("a defaults layer, when given, is present from the very first getTable() call and outranks nothing (lowest precedence)", () => { + const log = createHostLog(); + const state = createKeymapState(log, [{ key: "escape", command: "modal.close", when: "quickPickFocus" }]); + + const resolved = state.getTable().lookup("escape", (key) => key === "quickPickFocus"); + expect(resolved?.command).toBe("modal.close"); + expect(resolved?.layer).toBe("defaults"); +}); + +test("user entries outrank a defaults-layer binding on the same key", () => { + const log = createHostLog(); + const state = createKeymapState(log, [{ key: "ctrl+s", command: "modal.accept" }]); + state.setUserEntries([{ key: "ctrl+s", command: "user.save" }]); + + const resolved = state.getTable().lookup("ctrl+s", () => undefined); + expect(resolved?.command).toBe("user.save"); + expect(resolved?.layer).toBe("user"); +}); + test("later setUserEntries calls fully replace the previous user layer", () => { const log = createHostLog(); const state = createKeymapState(log); diff --git a/packages/cli/src/keymapState.ts b/packages/cli/src/keymapState.ts index a3cf711..b060fb2 100644 --- a/packages/cli/src/keymapState.ts +++ b/packages/cli/src/keymapState.ts @@ -1,30 +1,30 @@ /** * Keeps the layered `BindingTable` up to date across the CLI's startup * phases (Req 4.1-4.3; design.md §6.2; CodeRabbit's Phase 2 plan): the - * sync phase builds one with whatever is known synchronously (nothing — - * `defaults`/`fallback` have no source yet, see below), `ConfigService`'s + * sync phase builds one with whatever is known synchronously (`defaults` — + * see below — and an empty `fallback`), `ConfigService`'s * `onKeybindingsChange` hook rebuilds it with the `user` layer once the * user's `keybindings.json` has loaded (and again on every live reload), * and the deferred phase rebuilds it again once `loadExtensions`'s * `extensionKeybindings` are known. * - * **`defaults`/`fallback` are `[]` today, deliberately.** `KeymapLayers` - * (`@tecode/core`'s `bindingTable.ts`) requires all four layers regardless - * of which are populated yet: - * - `defaults` — core commands' own default bindings. No core command - * contributes one yet (editor-core's movement/editing commands are - * Phase 2 tasks, command-palette's `ctrl+shift+p`/`ctrl+p` are Phase 3) — - * there is nothing to seed this layer with until those land. - * - `fallback` — the terminal-capability fallback overlay (Req 4.7). - * `terminalCapabilities.ts`'s stub result feeds this once Task 4.2 wires - * real detection; until then it stays empty, exactly like - * `bindingTable.ts`'s own TSDoc says it may. + * **`defaults` — core commands' own default bindings** (Task 3.1): fixed + * at construction via {@link createKeymapState}'s second parameter, never + * mutated afterward (unlike `user`/`extension`, which change over the + * app's lifetime) — core commands' own bindings are static data known at + * startup, not something that reloads. `main.ts`'s composition root passes + * `@tecode/core`'s `MODAL_DEFAULT_KEYBINDINGS` (`modal.selectNext`/ + * `selectPrevious`/`accept`/`close`, Req 10.1) as this task's first real + * occupant of a layer `bindingTable.ts` has reserved since Task 1.5. + * Defaults to `[]` for a caller with nothing to seed it with (every test + * that predates Task 3.1) — `KeymapLayers` (`@tecode/core`'s + * `bindingTable.ts`) requires all four layers regardless of which are + * populated. * - * `@tecode/core` has no OpenTUI key-event pipeline consuming this table - * yet (routing key input into editing is tasks.md's Task 2.2) — this - * module's job for Task 1.15 is only to keep the table itself correctly - * assembled and rebuildable end to end, the same way `ui/slotRegistry.ts` - * is kept live before any view consumes it. + * **`fallback` is `[]` today, deliberately** — the terminal-capability + * fallback overlay (Req 4.7). `terminalCapabilities.ts`'s stub result + * feeds this once Task 4.2 wires real detection; until then it stays + * empty, exactly like `bindingTable.ts`'s own TSDoc says it may. */ import { createBindingTable, type BindingTable, type HostLog } from "@tecode/core"; @@ -50,17 +50,23 @@ export interface KeymapState { setExtensionEntries(entries: readonly KeybindingContribution[]): void; } -/** Build a {@link KeymapState} (Req 4.1-4.3). Starts with every layer - * empty; `getTable()` is always safe to call, even before either setter - * has ever run. */ -export function createKeymapState(log: HostLog): KeymapState { +/** Build a {@link KeymapState} (Req 4.1-4.3). `defaults` seeds the + * `defaults` layer once and for all (this module's TSDoc) — omit it (or + * pass `[]`) for the pre-Task-3.1 behavior of an empty defaults layer. + * `user`/`extension` start empty regardless; `getTable()` is always safe to + * call, even before either setter has ever run. */ +export function createKeymapState( + log: HostLog, + defaults: readonly KeybindingContribution[] = [], +): KeymapState { + const defaultEntries = defaults.slice(); let userEntries: KeybindingContribution[] = []; let extensionEntries: KeybindingContribution[] = []; let table = build(); function build(): BindingTable { return createBindingTable( - { defaults: [], fallback: [], extension: extensionEntries, user: userEntries }, + { defaults: defaultEntries, fallback: [], extension: extensionEntries, user: userEntries }, { log }, ); } diff --git a/packages/cli/src/main.test.ts b/packages/cli/src/main.test.ts index 2f906ab..ed8b694 100644 --- a/packages/cli/src/main.test.ts +++ b/packages/cli/src/main.test.ts @@ -88,7 +88,16 @@ test("buildAssemblyRoot wires every core service and registers the 'tecode' modu expect(root.slotRegistry).toBeDefined(); expect(root.layoutState).toBeDefined(); expect(root.theme.colors).toBeDefined(); - expect(root.keymap.getTable().entries().size).toBe(0); + // Task 3.1: the `defaults` layer is no longer empty — `modal.*`'s 4 + // keybindings (`down`/`up`/`return`/`escape`) are seeded synchronously + // by `createKeymapState(log, MODAL_DEFAULT_KEYBINDINGS)`, ahead of any + // extension/user layer. + expect(root.keymap.getTable().entries().size).toBe(4); + const resolvedModalClose = root.keymap + .getTable() + .lookup("escape", (key) => key === "quickPickFocus" || key === "inputBoxFocus"); + expect(resolvedModalClose?.command).toBe("modal.close"); + expect(resolvedModalClose?.layer).toBe("defaults"); expect(root.hostRef.current).toBeUndefined(); // Task 2.6's theme wiring: the registry always has the built-in base diff --git a/packages/cli/src/main.ts b/packages/cli/src/main.ts index 2433f83..b72cae5 100644 --- a/packages/cli/src/main.ts +++ b/packages/cli/src/main.ts @@ -18,6 +18,7 @@ import { createHostLog, createLanguageRegistry, createLayoutStateService, + createModalService, createNoopStatusSink, createSlotRegistry, createTecodeApi, @@ -25,9 +26,12 @@ import { createThemeService, createThemeSettingsWriter, createWebTreeSitterParserBackend, + createWindowMessageService, loadExtensions, + MODAL_DEFAULT_KEYBINDINGS, pathToUri, registerCoreConfiguration, + registerModalCommands, registerTecodeAlias, registerThemeSelectCommand, wireEditorLangIdContext, @@ -48,11 +52,13 @@ import { type LanguageRegistry, type LayoutStateService, type LoadExtensionsResult, + type ModalService, type PendingThemeContribution, type SlotRegistry, type StatusSink, type ThemeRegistry, type ThemeService, + type WindowMessageService, } from "@tecode/core"; import { builtinLanguageGrammarAssets, @@ -259,6 +265,21 @@ export interface AssemblyRoot { * Disposed alongside every other startup-owned subscription in * {@link wireProcessExit}. */ editorLangIdSync: Disposable; + /** The core-owned modal overlay's state/logic (Task 3.1, Req 10.1, + * design.md §12) — backs the real `tecode.window.showQuickPick`/ + * `showInputBox` (via `api` above) and the rendered `ModalOverlay` + * sibling (`renderShell.tsx`'s `ShellRenderDeps.modalService`). */ + modalService: ModalService; + /** The `modal.*` commands' registration (Task 3.1, `ui/modalCommands. + * ts`) — disposed alongside every other startup-owned subscription in + * {@link wireProcessExit}. */ + modalCommands: Disposable; + /** Backs the real `tecode.window.showMessage`/`setStatusBarItem` (Task + * 3.1, Req 10.1, `ui/windowMessageService.ts`) against the SAME + * `slotRegistry` above. Disposed alongside every other startup-owned + * subscription in {@link wireProcessExit} so a pending `showMessage` + * notice doesn't linger past shutdown. */ + windowMessageService: WindowMessageService; /** * Forward-reference box for the extension host (this module's TSDoc, * "Forward-referenced host wiring"): `undefined` until the deferred @@ -379,7 +400,11 @@ export function buildAssemblyRoot( }); const fs = createFileSystem({ log }); - const keymap = createKeymapState(log); + // `MODAL_DEFAULT_KEYBINDINGS` (Task 3.1, `ui/modalCommands.ts`) is this + // codebase's first real occupant of the `defaults` layer + // (`keymapState.ts`'s TSDoc) — core-owned bindings, not an extension + // manifest's. + const keymap = createKeymapState(log, MODAL_DEFAULT_KEYBINDINGS); const config = createConfigService({ log, sink, @@ -473,6 +498,15 @@ export function buildAssemblyRoot( // `editorSession` itself is shared above. const findService = createFindService({ editorSession }); + // Task 3.1's core-owned modal overlay layer (Req 10.1, design.md §12): + // built before `createTecodeApi` so the REAL `tecode.window. + // showQuickPick`/`showInputBox` can be wired against it below, exactly + // like `editorSession`/`findService` above. `windowMessageService` backs + // `showMessage`/`setStatusBarItem` against the SAME `slotRegistry` + // instance the rendered `Shell`'s `StatusBar` reads from. + const modalService = createModalService(); + const windowMessageService = createWindowMessageService({ slotRegistry }); + const api = createTecodeApi({ commands, documents, @@ -487,18 +521,29 @@ export function buildAssemblyRoot( themeRegistry, themeService, languageRegistry, + modalService, + windowMessageService, }); // Must run before any extension module is imported (see this function's // TSDoc). registerTecodeAlias(api); + // The `modal.*` commands + their default keybindings (Task 3.1, `ui/ + // modalCommands.ts`'s TSDoc): registered directly on `commands`, NOT + // through an extension manifest — the modal overlay is core-owned + // infrastructure `theme.select`/`editor-core`'s find widget already + // depend on existing. The default keybindings themselves were already + // fed into `keymap`'s `defaults` layer above, ahead of `config`'s own + // construction. + const modalCommands = registerModalCommands(commands, modalService); + // `theme.select` (Req 7.5, `ui/themeSelectCommand.ts`'s TSDoc): a // PRIVILEGED registration straight on `commands`, closing over // `themeService`'s preview/commit/revert directly — no equivalent exists // on `tecode.themes` (extensions never get this). `showQuickPick` comes - // from `api.window` — still `createWindowStub`'s inert stub until Task - // 3.1's real quick-pick UI lands (that module's TSDoc). + // from `api.window`, now genuinely backed by `modalService` above (Task + // 3.1) — live, real quick-pick UI, not a stub. const themeSelectCommand = registerThemeSelectCommand(commands, { themeRegistry, themeService, @@ -557,6 +602,9 @@ export function buildAssemblyRoot( highlightService, editorInputRouter, editorLangIdSync, + modalService, + modalCommands, + windowMessageService, hostRef, }; } @@ -722,6 +770,9 @@ function wireProcessExit(root: AssemblyRoot): void { root.editorLangIdSync.dispose(); root.themeConfigSync.dispose(); root.themeSelectCommand.dispose(); + root.modalCommands.dispose(); + root.modalService.dispose(); + root.windowMessageService.dispose(); root.highlightService.dispose(); root.languageRegistry.dispose(); await root.hostRef.current?.disposeAll(); @@ -821,6 +872,7 @@ export async function runTecode( highlightService: root.highlightService, chordMachine: root.chordMachine, editorInputRouter: root.editorInputRouter, + modalService: root.modalService, }); const firstFrameMs = performance.now() - startedAt; @@ -861,6 +913,9 @@ export async function runTecode( root.editorLangIdSync.dispose(); root.themeConfigSync.dispose(); root.themeSelectCommand.dispose(); + root.modalCommands.dispose(); + root.modalService.dispose(); + root.windowMessageService.dispose(); root.highlightService.dispose(); root.languageRegistry.dispose(); await deferred.extensionHost.disposeAll(); diff --git a/packages/cli/src/renderShell.tsx b/packages/cli/src/renderShell.tsx index fc5447f..4c32beb 100644 --- a/packages/cli/src/renderShell.tsx +++ b/packages/cli/src/renderShell.tsx @@ -14,6 +14,7 @@ import { createRoot } from "@opentui/react"; import type { ResolvedTheme } from "@tecode/api"; import { ContextFocusTracker, + ModalOverlay, Shell, ThemeProvider, type ChordStateMachine, @@ -26,6 +27,7 @@ import { type FindService, type HighlightService, type LayoutStateService, + type ModalService, type SlotRegistry, type ThemeService, } from "@tecode/core"; @@ -96,6 +98,14 @@ export interface ShellRenderDeps { * See {@link chordMachine}'s TSDoc for when the listener is actually * wired. */ editorInputRouter?: Pick; + /** The core-owned modal overlay's state/logic (Task 3.1, Req 10.1, + * design.md §12) — when given, rendered as the LAST sibling of ``, + * inside the same ``/``, via + * `ModalOverlay` (`ui/modalOverlay.tsx`). Optional, matching every other + * service dependency above: a caller/test that omits it renders `` + * alone, with no modal overlay at all (not even an inert one) — exactly + * the pre-Task-3.1 behavior. */ + modalService?: Pick; } /** The render seam's shape: resolves once "first frame" has happened (see @@ -135,6 +145,11 @@ export const renderShellToTerminal: RenderShell = async (deps) => { findService={deps.findService} highlightService={deps.highlightService} /> + {/* LAST sibling of (Task 3.1, `ui/modalOverlay.tsx`'s + * TSDoc's "Mount point") — omitted entirely (not even an inert + * render) when no `modalService` is given, matching every other + * optional-dependency fallback in this module. */} + {deps.modalService ? : null} , ); diff --git a/packages/core/src/api/create.ts b/packages/core/src/api/create.ts index 509c447..f1fef17 100644 --- a/packages/core/src/api/create.ts +++ b/packages/core/src/api/create.ts @@ -53,9 +53,11 @@ import type { StatusSink } from "../host/errors"; import type { EditorSessionService } from "../ui/editorSession"; import type { FindService } from "../ui/findService"; import { Input, List, Tabs, Tree } from "../ui/components"; +import type { ModalService } from "../ui/modalService"; import { createSlotRegistry, type SlotRegistry } from "../ui/slotRegistry"; import type { ThemeRegistry } from "../ui/themeRegistry"; import type { ThemeService } from "../ui/themeService"; +import type { WindowMessageService } from "../ui/windowMessageService"; import type { LanguageRegistry } from "../languages/languageRegistry"; import { cloneSelection, createEditorNamespace } from "./editorNamespace"; import { @@ -176,6 +178,37 @@ export interface CreateTecodeApiDeps { * `stubs.ts`'s `createLanguagesStub` exactly as before. */ languageRegistry?: Pick; + /** + * Backs the REAL `tecode.window.showQuickPick`/`showInputBox` (Task 3.1, + * Req 10.1, design.md §12's "implemented on the shell's modal layer"). + * `ModalService.openQuickPick`/`openInputBox` already match + * `WindowNamespace.showQuickPick`/`showInputBox`'s exact signatures, so + * they are wired straight through with no wrapper closures (this module's + * TSDoc's "narrowing, not re-implementing" — same "same function + * references" freezing as `commandsNamespace`). Optional: a caller that + * omits this (every test that predates Task 3.1) keeps `stubs.ts`'s + * `createWindowStub()` pickers — both always resolve `undefined` + * immediately, exactly as before. + */ + modalService?: Pick; + /** + * Backs the REAL `tecode.window.showMessage`/`setStatusBarItem` (Task + * 3.1, Req 10.1) — a real, disposable `statusBar.item` registration + * against the SAME `slotRegistry` the rendered `Shell`'s `StatusBar` + * reads from (`windowMessageService.ts`'s TSDoc), rather than + * `stubs.ts`'s `createWindowStub()`'s own internal, never-rendered `Set`. + * Optional, same fallback shape as every other real-backing dependency + * above: a caller that omits this keeps the stub's inert `showMessage` + * and disposable-but-unrendered `setStatusBarItem`. + * + * Guarded by identity, exactly like `findService.session` vs + * `editorSession` below: the real backing is used only when + * `windowMessageService.registry` IS this deps object's `slotRegistry` — + * a service registered against a different registry than the one the + * rendered `Shell`'s `StatusBar` reads would accept `showMessage` calls + * that never render anywhere, so a mismatch falls back to the stub. + */ + windowMessageService?: Pick; } /** @@ -249,6 +282,15 @@ export function createTecodeApi(deps: CreateTecodeApiDeps): Tecode { }); const windowStub = createWindowStub(); + // Identity gate (`CreateTecodeApiDeps.windowMessageService`'s TSDoc): + // the real message backing applies only when the service's OWN registry + // is the exact `slotRegistry` supplied here — the one the rendered + // `Shell`'s `StatusBar` reads. Mirrors `findNamespace`'s + // `findService.session === editorSession` triple gate below. + const windowMessages = + deps.windowMessageService && deps.slotRegistry && deps.windowMessageService.registry === deps.slotRegistry + ? deps.windowMessageService + : undefined; const windowNamespace: WindowNamespace = Object.freeze({ get activeEditor() { // Real backing (Task 2.3) when an `editorSession` was supplied; @@ -270,10 +312,14 @@ export function createTecodeApi(deps: CreateTecodeApiDeps): Tecode { selections: deps.editorSession.getState(document.uri).selections.map(cloneSelection), }; }, - showMessage: windowStub.showMessage, - showQuickPick: windowStub.showQuickPick, - showInputBox: windowStub.showInputBox, - setStatusBarItem: windowStub.setStatusBarItem, + // Task 3.1: real backing when the corresponding dep is supplied, else + // the exact pre-Task-3.1 stub (`CreateTecodeApiDeps.modalService`/ + // `windowMessageService`'s own TSDoc) — same real function references, + // no wrapper closures, matching every other delegated namespace here. + showMessage: windowMessages ? windowMessages.showMessage : windowStub.showMessage, + showQuickPick: deps.modalService ? deps.modalService.openQuickPick : windowStub.showQuickPick, + showInputBox: deps.modalService ? deps.modalService.openInputBox : windowStub.showInputBox, + setStatusBarItem: windowMessages ? windowMessages.setStatusBarItem : windowStub.setStatusBarItem, }); // `tecode.editor.find` (Req 11.1, design.md §13): a ready-made diff --git a/packages/core/src/api/create.window.test.ts b/packages/core/src/api/create.window.test.ts new file mode 100644 index 0000000..d4ca370 --- /dev/null +++ b/packages/core/src/api/create.window.test.ts @@ -0,0 +1,139 @@ +/** + * Tests for `createTecodeApi`'s real `tecode.window` wiring (Task 3.1, Req + * 10.1): `showQuickPick`/`showInputBox` delegate to an injected + * `ModalService`, and `showMessage`/`setStatusBarItem` delegate to an + * injected `WindowMessageService`, falling back to `stubs.ts`'s + * `createWindowStub` exactly as before when either dep is omitted + * (`CreateTecodeApiDeps.modalService`/`windowMessageService`'s TSDoc). + */ + +import { describe, expect, test } from "bun:test"; +import { createCommandRegistry } from "../commands/registry"; +import { createDocumentManager } from "../buffer/documentManager"; +import { createFileSystem } from "../buffer/fileSystem"; +import type { ConfigServiceFs } from "../config/service"; +import { createConfigService } from "../config/service"; +import { createContextService } from "../keymap/context"; +import { createHostLog } from "../host/errors"; +import { createModalService } from "../ui/modalService"; +import { createSlotRegistry } from "../ui/slotRegistry"; +import { createWindowMessageService, WINDOW_MESSAGE_STATUS_BAR_ITEM_ID } from "../ui/windowMessageService"; +import { createTecodeApi } from "./create"; + +function createEmptyConfigFs(): ConfigServiceFs { + return { + readFile: () => Promise.reject(Object.assign(new Error("ENOENT"), { code: "ENOENT" })), + watch: () => ({ close() {} }), + }; +} + +async function buildBaseDeps() { + const log = createHostLog(); + const sink = { error() {} }; + const commands = createCommandRegistry({ log, sink }); + const documents = createDocumentManager({ log, sink }); + const fs = createFileSystem({ log }); + const config = createConfigService({ log, sink, fs: createEmptyConfigFs() }); + await config.ready; + const context = createContextService(); + return { commands, documents, fs, config, context, sink }; +} + +describe("createTecodeApi's tecode.window (Task 3.1)", () => { + test("falls back to the stub when neither modalService nor windowMessageService is supplied", async () => { + const deps = await buildBaseDeps(); + const api = createTecodeApi(deps); + + await expect(api.window.showQuickPick([{ label: "A" }])).resolves.toBeUndefined(); + await expect(api.window.showInputBox()).resolves.toBeUndefined(); + expect(() => api.window.showMessage("hi")).not.toThrow(); + // setStatusBarItem still returns a real, disposable registration — just + // not one anything renders (`stubs.ts`'s `createWindowStub` TSDoc). + const disposable = api.window.setStatusBarItem({ id: "x", text: "t", side: "left", priority: 0 }); + expect(() => disposable.dispose()).not.toThrow(); + }); + + test("showQuickPick/showInputBox delegate to the real ModalService when supplied", async () => { + const deps = await buildBaseDeps(); + const modalService = createModalService(); + const api = createTecodeApi({ ...deps, modalService }); + + const pending = api.window.showQuickPick([{ label: "Only" }], { placeHolder: "pick one" }); + // The SAME service instance backs it — driving it directly resolves the + // API-facing promise (host + extension share state, matching + // `create.languages.test.ts`'s equivalent assertion). + modalService.accept(); + expect(await pending).toEqual({ label: "Only" }); + + const pendingInput = api.window.showInputBox({ value: "seed" }); + modalService.accept(); + expect(await pendingInput).toBe("seed"); + }); + + test("showQuickPick/showInputBox are the exact same function references as ModalService's own methods (no wrapper closures)", async () => { + const deps = await buildBaseDeps(); + const modalService = createModalService(); + const api = createTecodeApi({ ...deps, modalService }); + expect(api.window.showQuickPick).toBe(modalService.openQuickPick); + expect(api.window.showInputBox).toBe(modalService.openInputBox); + }); + + test("showMessage/setStatusBarItem delegate to the real WindowMessageService when supplied, rendering into the live slot registry", async () => { + const deps = await buildBaseDeps(); + const slotRegistry = createSlotRegistry(); + const windowMessageService = createWindowMessageService({ slotRegistry, setTimeout: () => 0, clearTimeout: () => {} }); + const api = createTecodeApi({ ...deps, slotRegistry, windowMessageService }); + + api.window.showMessage("Saved.", "info"); + expect(slotRegistry.getView("statusBar.item", WINDOW_MESSAGE_STATUS_BAR_ITEM_ID)?.title).toContain("Saved."); + + const disposable = api.window.setStatusBarItem({ id: "ext.item", text: "hello", side: "right", priority: 3 }); + expect(slotRegistry.getView("statusBar.item", "ext.item")?.title).toBe("hello"); + disposable.dispose(); + expect(slotRegistry.getView("statusBar.item", "ext.item")).toBeUndefined(); + }); + + test("activeEditor/showQuickPick/showInputBox stay stubbed when only windowMessageService is supplied (independent gating)", async () => { + const deps = await buildBaseDeps(); + const slotRegistry = createSlotRegistry(); + const windowMessageService = createWindowMessageService({ slotRegistry, setTimeout: () => 0, clearTimeout: () => {} }); + const api = createTecodeApi({ ...deps, slotRegistry, windowMessageService }); + + await expect(api.window.showQuickPick([{ label: "A" }])).resolves.toBeUndefined(); + await expect(api.window.showInputBox()).resolves.toBeUndefined(); + }); + + test("showMessage/setStatusBarItem fall back to the stub when windowMessageService was built on a DIFFERENT slot registry (identity gate)", async () => { + const deps = await buildBaseDeps(); + // Registry A backs the API (what the rendered Shell's StatusBar would + // read); registry B backs the message service — the cross-instance + // wiring bug `WindowMessageService.registry`'s TSDoc guards against. + const registryA = createSlotRegistry(); + const registryB = createSlotRegistry(); + const windowMessageService = createWindowMessageService({ + slotRegistry: registryB, + setTimeout: () => 0, + clearTimeout: () => {}, + }); + const api = createTecodeApi({ ...deps, slotRegistry: registryA, windowMessageService }); + + api.window.showMessage("lost?", "info"); + const disposable = api.window.setStatusBarItem({ id: "ext.item", text: "hello", side: "right", priority: 3 }); + disposable.dispose(); + // The stub handled both calls — NEITHER registry saw a registration. + expect(registryA.getViews("statusBar.item").length).toBe(0); + expect(registryB.getViews("statusBar.item").length).toBe(0); + }); + + test("showMessage/setStatusBarItem stay stubbed when only modalService is supplied (independent gating)", async () => { + const deps = await buildBaseDeps(); + const slotRegistry = createSlotRegistry(); + const modalService = createModalService(); + const api = createTecodeApi({ ...deps, slotRegistry, modalService }); + + api.window.showMessage("hi"); + // No WindowMessageService means the stub handled it — nothing landed in + // the real slot registry. + expect(slotRegistry.getViews("statusBar.item").length).toBe(0); + }); +}); diff --git a/packages/core/src/api/stubs.ts b/packages/core/src/api/stubs.ts index 794ec8b..a12fca7 100644 --- a/packages/core/src/api/stubs.ts +++ b/packages/core/src/api/stubs.ts @@ -8,10 +8,14 @@ * tracking (a later editor task) — design.md §12 says as much for * `window.showQuickPick`/`showInputBox` ("implemented on the shell's * modal layer... since the palette and pickers must exist before any - * extension UI"). Until then, every read reports "nothing is active" and - * every action reports through the injected {@link StatusSink} rather - * than silently doing nothing (Req 10.1's contract stays observable even - * before there is a UI to observe). + * extension UI"). Until a `ModalService`/`WindowMessageService` dep is + * supplied (Task 3.1, `create.ts`'s `CreateTecodeApiDeps.modalService`/ + * `windowMessageService`), every read here reports "nothing is + * active/no picker" and every action reports through the injected + * {@link StatusSink} rather than silently doing nothing (Req 10.1's + * contract stays observable even before there is a UI to observe) — this + * is `create.ts`'s pre-Task-3.1 fallback path, still exercised by any + * caller/test that omits those deps. * - `languages`/`themes` registration is real (a `register` call returns a * working, disposable registration extensions can rely on immediately), * but nothing yet *consumes* the registry — grammar/theme resolution @@ -205,10 +209,15 @@ export interface WindowStub extends WindowNamespace { } /** - * Build the `tecode.window` stub (Req 10.1). No UI shell exists yet (Task - * 1.14) so every read reports "nothing active/no picker" and every action - * is inert; `setStatusBarItem` is a real, disposable registration with no - * renderer behind it yet. + * Build the `tecode.window` stub (Req 10.1) — `create.ts`'s fallback for + * whichever of `showQuickPick`/`showInputBox` (no `modalService` dep) or + * `showMessage`/`setStatusBarItem` (no `windowMessageService` dep) has no + * real backing supplied (Task 3.1's TSDoc on both). Every read reports + * "nothing active/no picker" and every action is inert; + * `setStatusBarItem` here is a disposable registration into this stub's + * OWN internal `Set` — NOT the real, rendered `SlotRegistry` + * (`windowMessageService.ts`'s TSDoc explains why that distinction + * matters). */ export function createWindowStub(): WindowStub { const statusBarItems = createRegistrySet(); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5f1e9f7..ddfd496 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -118,26 +118,39 @@ export { createInitialEditorState, createInitialFindState, createLayoutStateService, + createModalService, createSlotRegistry, createThemeRegistry, createThemeSelectHandler, createThemeService, createThemeSettingsWriter, + createWindowMessageService, cursorCellColumn, DEFAULT_LAYOUT_STATE, + DEFAULT_MESSAGE_TIMEOUT_MS, EditorArea, EditorView, + filterQuickPickItems, FindWidget, gutterDigitWidth, + INPUT_BOX_FOCUS_CONTEXT_KEY, Input, List, loadThemeFallbackForReadError, loadThemeFromJsonText, + MODAL_ACCEPT_COMMAND, + MODAL_CLOSE_COMMAND, + MODAL_DEFAULT_KEYBINDINGS, + MODAL_SELECT_NEXT_COMMAND, + MODAL_SELECT_PREVIOUS_COMMAND, + ModalOverlay, Panel, parseHexColor, quantizeTheme, quantizeToXterm256, + QUICK_PICK_FOCUS_CONTEXT_KEY, RegisteredView, + registerModalCommands, registerThemeSelectCommand, resolveCaptureStyle, revealLine, @@ -155,6 +168,7 @@ export { useTheme, wireEditorLangIdContext, wireThemeConfigSync, + WINDOW_MESSAGE_STATUS_BAR_ITEM_ID, type ActivityBarProps, type ColorDepth, type ContextFocusTrackerProps, @@ -179,6 +193,10 @@ export { type ListItem, type ListProps, type LoadThemeOptions, + type ModalCommandsRegistrar, + type ModalOverlayProps, + type ModalService, + type ModalState, type PanelProps, type RegisterViewMeta, type ShellProps, @@ -208,6 +226,8 @@ export { type TreeNode, type TreeProps, type VisibleLineRange, + type WindowMessageService, + type WindowMessageServiceDeps, type WireEditorLangIdContextDeps, type WireThemeConfigSyncDeps, } from "./ui/index"; diff --git a/packages/core/src/ui/index.ts b/packages/core/src/ui/index.ts index 21cd75b..c99669c 100644 --- a/packages/core/src/ui/index.ts +++ b/packages/core/src/ui/index.ts @@ -162,3 +162,32 @@ export { wireEditorLangIdContext, type WireEditorLangIdContextDeps, } from "./editorLangId"; + +export { + createModalService, + filterQuickPickItems, + type ModalService, + type ModalState, +} from "./modalService"; + +export { + INPUT_BOX_FOCUS_CONTEXT_KEY, + MODAL_ACCEPT_COMMAND, + MODAL_CLOSE_COMMAND, + MODAL_DEFAULT_KEYBINDINGS, + MODAL_SELECT_NEXT_COMMAND, + MODAL_SELECT_PREVIOUS_COMMAND, + QUICK_PICK_FOCUS_CONTEXT_KEY, + registerModalCommands, + type ModalCommandsRegistrar, +} from "./modalCommands"; + +export { ModalOverlay, type ModalOverlayProps } from "./modalOverlay"; + +export { + createWindowMessageService, + DEFAULT_MESSAGE_TIMEOUT_MS, + WINDOW_MESSAGE_STATUS_BAR_ITEM_ID, + type WindowMessageService, + type WindowMessageServiceDeps, +} from "./windowMessageService"; diff --git a/packages/core/src/ui/modalCommands.test.ts b/packages/core/src/ui/modalCommands.test.ts new file mode 100644 index 0000000..2d57ccf --- /dev/null +++ b/packages/core/src/ui/modalCommands.test.ts @@ -0,0 +1,90 @@ +/** + * `registerModalCommands`/`MODAL_DEFAULT_KEYBINDINGS` tests (Task 3.1, Req + * 10.1, `modalCommands.ts`'s TSDoc). + */ + +import { describe, expect, test } from "bun:test"; +import { createCommandRegistry } from "../commands/registry"; +import { createHostLog, createNoopStatusSink } from "../host/index"; +import { createModalService } from "./modalService"; +import { + INPUT_BOX_FOCUS_CONTEXT_KEY, + MODAL_ACCEPT_COMMAND, + MODAL_CLOSE_COMMAND, + MODAL_DEFAULT_KEYBINDINGS, + MODAL_SELECT_NEXT_COMMAND, + MODAL_SELECT_PREVIOUS_COMMAND, + QUICK_PICK_FOCUS_CONTEXT_KEY, + registerModalCommands, +} from "./modalCommands"; + +function realCommands() { + return createCommandRegistry({ log: createHostLog(), sink: createNoopStatusSink() }); +} + +describe("registerModalCommands", () => { + test("each command delegates one-to-one to the matching ModalService method", async () => { + const commands = realCommands(); + const modalService = createModalService(); + void modalService.openQuickPick([{ label: "A" }, { label: "B" }]); + registerModalCommands(commands, modalService); + + await commands.execute(MODAL_SELECT_NEXT_COMMAND); + let state = modalService.getState(); + if (state.mode !== "quickPick") throw new Error("unreachable"); + expect(state.activeIndex).toBe(1); + + await commands.execute(MODAL_SELECT_PREVIOUS_COMMAND); + state = modalService.getState(); + if (state.mode !== "quickPick") throw new Error("unreachable"); + expect(state.activeIndex).toBe(0); + + await commands.execute(MODAL_CLOSE_COMMAND); + expect(modalService.getState().mode).toBeNull(); + }); + + test("modal.accept resolves the open quick pick's active item", async () => { + const commands = realCommands(); + const modalService = createModalService(); + const pending = modalService.openQuickPick([{ label: "Only" }]); + registerModalCommands(commands, modalService); + + await commands.execute(MODAL_ACCEPT_COMMAND); + expect(await pending).toEqual({ label: "Only" }); + }); + + test("the returned Disposable unregisters all 4 commands, idempotently", async () => { + const commands = realCommands(); + const modalService = createModalService(); + const disposable = registerModalCommands(commands, modalService); + + const ids = commands.list().map((c) => c.id); + expect(ids).toContain(MODAL_SELECT_NEXT_COMMAND); + expect(ids).toContain(MODAL_ACCEPT_COMMAND); + + disposable.dispose(); + const idsAfter = commands.list().map((c) => c.id); + expect(idsAfter).not.toContain(MODAL_SELECT_NEXT_COMMAND); + expect(idsAfter).not.toContain(MODAL_ACCEPT_COMMAND); + expect(() => disposable.dispose()).not.toThrow(); + }); +}); + +describe("MODAL_DEFAULT_KEYBINDINGS", () => { + test("up/down are gated on quickPickFocus only", () => { + const upDown = MODAL_DEFAULT_KEYBINDINGS.filter((b) => b.key === "up" || b.key === "down"); + expect(upDown.length).toBe(2); + for (const binding of upDown) { + expect(binding.when).toBe(QUICK_PICK_FOCUS_CONTEXT_KEY); + } + }); + + test("return/escape are gated on quickPickFocus || inputBoxFocus", () => { + const returnEscape = MODAL_DEFAULT_KEYBINDINGS.filter((b) => b.key === "return" || b.key === "escape"); + expect(returnEscape.length).toBe(2); + for (const binding of returnEscape) { + expect(binding.when).toContain(QUICK_PICK_FOCUS_CONTEXT_KEY); + expect(binding.when).toContain(INPUT_BOX_FOCUS_CONTEXT_KEY); + } + }); +}); diff --git a/packages/core/src/ui/modalCommands.ts b/packages/core/src/ui/modalCommands.ts new file mode 100644 index 0000000..5e6ad2f --- /dev/null +++ b/packages/core/src/ui/modalCommands.ts @@ -0,0 +1,102 @@ +/** + * Core-owned `modal.*` commands and their default keybindings (Task 3.1, + * Req 10.1, design.md §12). Thin one-line delegations from a command (and, + * via {@link MODAL_DEFAULT_KEYBINDINGS}, a keystroke) straight to + * {@link ModalService} — the exact same "pure command handlers... delegate + * to `ctx.api.editor.find.*`" shape `editor-core`'s find/replace commands + * use over `ui/findService.ts` (`findService.ts`'s TSDoc), applied here to + * the modal overlay instead. + * + * **Registered directly on the core `CommandRegistry`, NOT through an + * extension manifest** — same privilege/ordering reasoning as `theme. + * select` (`ui/themeSelectCommand.ts`'s TSDoc): the modal overlay is + * core-owned infrastructure (design.md §12's "the palette and pickers must + * exist before any extension UI") that `editor-core`'s own find/replace + * commands, and later the command-palette/quick-open built-ins (tasks.md's + * Task 3.2), all depend on already existing — it cannot wait for extension + * discovery/registration to contribute its own keybindings the way + * `editor-core`'s manifest does for `findWidgetFocus`. `main.ts`'s + * composition root registers these commands AND feeds + * {@link MODAL_DEFAULT_KEYBINDINGS} into `keymapState.ts`'s `defaults` + * layer (`createKeymapState`'s second parameter) — the layer `bindingTable. + * ts` has always reserved for exactly this ("core commands' own default + * bindings", `keymapState.ts`'s pre-Task-3.1 TSDoc, now the first real + * occupant). + * + * **`when` gating mirrors `editor-core`'s `findWidgetFocus` precedent** + * (`editor-core/manifest.ts`'s TSDoc): `up`/`down` are scoped to + * `quickPickFocus` only (an input box has nothing to navigate — Enter/ + * Escape are its only two actions), while `return`/`escape` are gated on + * `quickPickFocus || inputBoxFocus` so the SAME two keys drive whichever + * modal happens to be open, exactly like `editor-core`'s `return` binding + * safely appears twice in one table disambiguated purely by `when` + * (`bindingTable.ts`'s documented multi-binding-per-key contract) — here, + * `quickPickFocus`/`inputBoxFocus`/`findWidgetFocus`/`editorTextFocus` are + * never more than one truthy at a time (`modalOverlay.tsx`'s conditionally + * mounted, single-active-modal Input reports whichever ONE of the first two + * applies; `focus.tsx`'s single-focus-pointer bookkeeping — Escape/Enter + * consumed here `preventDefault()`s before OpenTUI's own focused-input + * handling ever sees the stroke, `keyRouting.ts`'s "consumed" branch). + */ + +import type { CommandHandler, Disposable, KeybindingContribution } from "@tecode/api"; +import type { ModalService } from "./modalService"; + +/** The `up`/`down`-gated context key a quick pick's filter `Input` reports + * via `useFocusTracking` (`modalOverlay.tsx`) — mirrors `editor-core/ + * manifest.ts`'s `WHEN_FIND_WIDGET_FOCUS` naming. */ +export const QUICK_PICK_FOCUS_CONTEXT_KEY = "quickPickFocus"; +/** The context key an input box's `Input` reports (this module's TSDoc). */ +export const INPUT_BOX_FOCUS_CONTEXT_KEY = "inputBoxFocus"; + +export const MODAL_SELECT_NEXT_COMMAND = "modal.selectNext"; +export const MODAL_SELECT_PREVIOUS_COMMAND = "modal.selectPrevious"; +export const MODAL_ACCEPT_COMMAND = "modal.accept"; +export const MODAL_CLOSE_COMMAND = "modal.close"; + +const WHEN_ANY_MODAL_FOCUS = `${QUICK_PICK_FOCUS_CONTEXT_KEY} || ${INPUT_BOX_FOCUS_CONTEXT_KEY}`; + +/** The modal overlay's default keybindings (this module's TSDoc) — fed + * into `keymapState.ts`'s `defaults` layer directly by `main.ts`, never + * through an extension manifest. Key names already in `keymap/normalize. + * ts`'s canonical form (`editor-core/manifest.ts`'s own verified names: + * `"return"` for Enter, `"escape"` for Escape). */ +export const MODAL_DEFAULT_KEYBINDINGS: KeybindingContribution[] = [ + { key: "down", command: MODAL_SELECT_NEXT_COMMAND, when: QUICK_PICK_FOCUS_CONTEXT_KEY }, + { key: "up", command: MODAL_SELECT_PREVIOUS_COMMAND, when: QUICK_PICK_FOCUS_CONTEXT_KEY }, + { key: "return", command: MODAL_ACCEPT_COMMAND, when: WHEN_ANY_MODAL_FOCUS }, + { key: "escape", command: MODAL_CLOSE_COMMAND, when: WHEN_ANY_MODAL_FOCUS }, +]; + +/** Narrow surface {@link registerModalCommands} needs from the core command + * registry — matches `themeSelectCommand.ts`'s own `commands` parameter + * shape. */ +export interface ModalCommandsRegistrar { + register(id: string, handler: CommandHandler): Disposable; +} + +/** + * Register the 4 `modal.*` commands against `modalService` (this module's + * TSDoc) directly on the core `CommandRegistry`. Returns one + * {@link Disposable} that unregisters all 4 together, idempotent like every + * other `Disposable` in this codebase. + */ +export function registerModalCommands( + commands: ModalCommandsRegistrar, + modalService: Pick, +): Disposable { + const disposables: Disposable[] = [ + commands.register(MODAL_SELECT_NEXT_COMMAND, () => modalService.selectNext()), + commands.register(MODAL_SELECT_PREVIOUS_COMMAND, () => modalService.selectPrevious()), + commands.register(MODAL_ACCEPT_COMMAND, () => modalService.accept()), + commands.register(MODAL_CLOSE_COMMAND, () => modalService.cancel()), + ]; + let disposed = false; + return { + dispose() { + if (disposed) return; + disposed = true; + for (const disposable of disposables) disposable.dispose(); + }, + }; +} diff --git a/packages/core/src/ui/modalOverlay.test.tsx b/packages/core/src/ui/modalOverlay.test.tsx new file mode 100644 index 0000000..d5a9b5a --- /dev/null +++ b/packages/core/src/ui/modalOverlay.test.tsx @@ -0,0 +1,276 @@ +/** + * `ModalOverlay` tests (Task 3.1, Req 10.1, `modalOverlay.tsx`'s TSDoc): + * rendering both modes, filter-as-you-type narrowing the rendered list, + * end-to-end keyboard accept/cancel through the real `modal.*` commands + + * binding table, and focus save/restore across open/close. + */ + +import { describe, expect, test } from "bun:test"; +import { act } from "react"; +import type { BoxRenderable } from "@opentui/core"; +import { testRender } from "@opentui/react/test-utils"; +import { createBindingTable } from "../keymap/bindingTable"; +import { createContextService } from "../keymap/context"; +import { createCommandRegistry } from "../commands/registry"; +import { createHostLog } from "../host/errors"; +import { ContextFocusTracker } from "./focus"; +import { MODAL_DEFAULT_KEYBINDINGS, registerModalCommands } from "./modalCommands"; +import { ModalOverlay } from "./modalOverlay"; +import { createModalService } from "./modalService"; +import { ThemeProvider } from "./theme"; + +/** Depth-first search for an OpenTUI `` renderable by its + * `placeholder` text — matches `findWidget.test.tsx`'s identical helper + * (this module has no test-only refs on `ModalOverlay`'s public props + * either). */ +function findInputByPlaceholder( + node: unknown, + placeholder: string, +): { insertText(text: string): void; submit(): boolean; value: string } | undefined { + const candidate = node as { + placeholder?: string; + insertText?: (text: string) => void; + submit?: () => boolean; + value?: string; + getChildren?: () => unknown[]; + }; + if ( + candidate?.placeholder === placeholder && + candidate.insertText && + candidate.submit + ) { + return candidate as { insertText(text: string): void; submit(): boolean; value: string }; + } + for (const child of candidate?.getChildren?.() ?? []) { + const found = findInputByPlaceholder(child, placeholder); + if (found) return found; + } + return undefined; +} + +describe("ModalOverlay — rendering", () => { + test("renders nothing while no modal is open", async () => { + const modalService = createModalService(); + const { renderOnce, captureCharFrame } = await testRender( + + + , + { width: 80, height: 20 }, + ); + await act(async () => { + await renderOnce(); + }); + expect(captureCharFrame().trim()).toBe(""); + }); + + test("quick pick: renders every item's label and the filter placeholder", async () => { + const modalService = createModalService(); + void modalService.openQuickPick( + [{ label: "Alpha" }, { label: "Beta" }, { label: "Gamma" }], + { placeHolder: "Type to filter" }, + ); + const { renderOnce, captureCharFrame } = await testRender( + + + , + { width: 80, height: 20 }, + ); + await act(async () => { + await renderOnce(); + }); + const frame = captureCharFrame(); + expect(frame).toContain("Alpha"); + expect(frame).toContain("Beta"); + expect(frame).toContain("Gamma"); + }); + + test("input box: renders the prompt, current value, and a validation message", async () => { + const modalService = createModalService(); + void modalService.openInputBox({ + prompt: "Enter a name", + value: "abc", + validateInput: () => "Name is required", + }); + const { renderOnce, captureCharFrame } = await testRender( + + + , + { width: 80, height: 20 }, + ); + await act(async () => { + await renderOnce(); + }); + const frame = captureCharFrame(); + expect(frame).toContain("Enter a name"); + expect(frame).toContain("abc"); + expect(frame).toContain("Name is required"); + }); +}); + +describe("ModalOverlay — filter-as-you-type (Req 10.1)", () => { + test("typing into the filter Input narrows the rendered list", async () => { + const modalService = createModalService(); + void modalService.openQuickPick([{ label: "Alpha" }, { label: "Beta" }, { label: "Gamma" }]); + const { renderOnce, captureCharFrame, renderer } = await testRender( + + + , + { width: 80, height: 20 }, + ); + await act(async () => { + await renderOnce(); + }); + expect(captureCharFrame()).toContain("Alpha"); + + const filterInput = findInputByPlaceholder(renderer.root, ""); + expect(filterInput).toBeDefined(); + act(() => { + filterInput!.insertText("beta"); + }); + await act(async () => { + await renderOnce(); + }); + + const frame = captureCharFrame(); + expect(frame).toContain("Beta"); + expect(frame).not.toContain("Alpha"); + expect(frame).not.toContain("Gamma"); + }); +}); + +describe("ModalOverlay — end-to-end keyboard accept/cancel through the real modal.* commands", () => { + function buildPipeline() { + const log = createHostLog(); + const context = createContextService(); + const commands = createCommandRegistry({ log, sink: { error() {} } }); + const modalService = createModalService(); + registerModalCommands(commands, modalService); + const table = createBindingTable( + { defaults: MODAL_DEFAULT_KEYBINDINGS, fallback: [], extension: [], user: [] }, + { log }, + ); + return { context, commands, modalService, table }; + } + + test("pressing Enter (return) while quickPickFocus is true resolves the active item", async () => { + const { context, commands, modalService, table } = buildPipeline(); + const pending = modalService.openQuickPick([{ label: "Only" }]); + + const { renderOnce } = await testRender( + + + + + , + { width: 80, height: 20 }, + ); + await act(async () => { + await renderOnce(); + }); + + // The filter Input's mount effect already focused itself (mirrors + // findWidget.tsx's "Ctrl+F opens focused"). + expect(context.get("quickPickFocus")).toBe(true); + + const resolved = table.lookup("return", (key) => Boolean(context.get(key))); + expect(resolved?.command).toBe("modal.accept"); + await act(async () => { + await commands.execute(resolved!.command); + }); + + expect(await pending).toEqual({ label: "Only" }); + }); + + test("pressing Escape while inputBoxFocus is true resolves undefined and closes the modal", async () => { + const { context, commands, modalService, table } = buildPipeline(); + const pending = modalService.openInputBox(); + + const { renderOnce } = await testRender( + + + + + , + { width: 80, height: 20 }, + ); + await act(async () => { + await renderOnce(); + }); + + expect(context.get("inputBoxFocus")).toBe(true); + + const resolved = table.lookup("escape", (key) => Boolean(context.get(key))); + expect(resolved?.command).toBe("modal.close"); + await act(async () => { + await commands.execute(resolved!.command); + }); + + expect(await pending).toBeUndefined(); + expect(modalService.getState().mode).toBeNull(); + }); + + test("up/down are NOT bound while inputBoxFocus is true (quickPickFocus-only gating)", () => { + const { context, table } = buildPipeline(); + context.set("inputBoxFocus", true); + expect(table.lookup("down", (key) => Boolean(context.get(key)))).toBeUndefined(); + expect(table.lookup("up", (key) => Boolean(context.get(key)))).toBeUndefined(); + }); +}); + +describe("ModalOverlay — focus save/restore (Req 10.1)", () => { + test("opening a modal moves focus to it; closing restores focus to whatever was focused before", async () => { + const context = createContextService(); + const modalService = createModalService(); + let priorNode: BoxRenderable | null = null; + + function Harness() { + return ( + + { + priorNode = node; + }} + /> + + + ); + } + + const { renderOnce, renderer } = await testRender( + + + + + , + { width: 80, height: 20 }, + ); + await act(async () => { + await renderOnce(); + }); + + expect(priorNode).not.toBeNull(); + priorNode!.focus(); + expect(renderer.currentFocusedRenderable).toBe(priorNode as unknown as BoxRenderable); + + // Opening the quick pick steals focus onto its own filter Input. + act(() => { + void modalService.openQuickPick([{ label: "A" }]); + }); + await act(async () => { + await renderOnce(); + }); + expect(context.get("quickPickFocus")).toBe(true); + expect(renderer.currentFocusedRenderable).not.toBe(priorNode as unknown as BoxRenderable); + + // Closing it (Escape/cancel) restores focus to the prior node. + act(() => { + modalService.cancel(); + }); + await act(async () => { + await renderOnce(); + }); + expect(context.get("quickPickFocus")).toBe(false); + expect(renderer.currentFocusedRenderable).toBe(priorNode as unknown as BoxRenderable); + }); +}); diff --git a/packages/core/src/ui/modalOverlay.tsx b/packages/core/src/ui/modalOverlay.tsx new file mode 100644 index 0000000..d7d3ef0 --- /dev/null +++ b/packages/core/src/ui/modalOverlay.tsx @@ -0,0 +1,242 @@ +/** + * `ModalOverlay` — the centered overlay component design.md §12 refers to + * ("`tecode.window.showQuickPick/showInputBox` are implemented on the + * shell's modal layer — a centered overlay component owned by core"). + * Renders {@link ModalService}'s current state: nothing while + * `getState().mode` is `null`, a filter `Input` + `List` while + * `"quickPick"`, or a prompt + `Input` + validation message while + * `"inputBox"`. + * + * **Mount point** (Task 3.1's plan): `renderShell.tsx` mounts this as the + * LAST sibling of ``, inside the same ``/ + * `` — see that module's own comment. `ModalOverlay` + * itself is ALWAYS mounted for the app's whole lifetime (never conditionally, + * unlike `findWidget.tsx`); only the `QuickPickBody`/`InputBoxBody` + * sub-components it renders internally mount/unmount as `getState().mode` + * toggles — this split is what lets `ModalOverlay` play `EditorArea`'s own + * "always-mounted parent restores focus on close" role (`shell.tsx`'s + * `EditorArea`'s TSDoc) for a modal that, unlike the find widget, can be + * opened from literally any previously-focused node in the shell. + * + * **Positioning** (verified against this repo's vendored `@opentui/core` + * `Renderable.d.ts`/`lib/yoga.options.d.ts`): OpenTUI's Yoga-backed layout + * supports genuine CSS-style `position: "absolute"` with percentage `top`/ + * `left`/`right`/`bottom` (resolved against the nearest sized ancestor — + * here, the full-terminal root box `renderShell.tsx` wraps `` and + * this component in), so centering needs no terminal-dimension hook or + * negative-margin arithmetic: `top: "15%", left: "15%", right: "15%"` + * removes the overlay from the normal flex flow and centers it + * horizontally with a 15%-of-width margin on each side, sized vertically by + * its own content. `zIndex` (a top-level `Renderable` option, confirmed in + * `Renderable.d.ts`) lifts it above `Shell`'s own content. + * + * **Focus save/restore, and the ordering hazard this module works around**: + * {@link ModalOverlay} needs to remember whatever OpenTUI node was focused + * immediately BEFORE a modal opens (it could be anything — the sidebar, the + * editor, another extension's view) and refocus it once the modal closes. + * `useRenderer()` (`@opentui/react`) exposes the live `CliRenderer`, whose + * `currentFocusedRenderable` getter and `focusRenderable()` method are + * exactly the "read/set the single global focus pointer" primitives needed + * (`renderer.d.ts`) — but capturing "whatever was focused before" CANNOT be + * done in a `useEffect`: React runs child effects before parent effects in + * the same commit, and `QuickPickBody`/`InputBoxBody`'s OWN mount effect + * (mirroring `findWidget.tsx`'s "Ctrl+F opens focused" imperative `.focus()` + * on mount) would already have claimed the focus pointer by the time an + * effect ON THIS component ran — capturing `renderer.currentFocusedRenderable` + * there would read back the modal's OWN just-focused input, not whatever was + * focused beforehand. Capturing it DURING RENDER instead — guarded by an + * edge-triggered ref comparison, `wasOpenRef` — runs strictly before ANY + * effect in this commit (including the child's mount effect), so it always + * observes the pre-modal focus target. This is a deliberate, narrow + * exception to "don't mutate refs during render": it never affects what + * this render PRODUCES (the read result isn't used until the close-side + * effect fires, possibly commits later), so it cannot desync this + * component's output from React's own reconciliation. + */ + +import { useCallback, useEffect, useReducer, useRef, type ReactNode } from "react"; +import { useRenderer } from "@opentui/react"; +import { Input, List, type ListItem } from "./components"; +import type { FocusableNode } from "./focus"; +import { useFocusTracking } from "./focus"; +import { INPUT_BOX_FOCUS_CONTEXT_KEY, QUICK_PICK_FOCUS_CONTEXT_KEY } from "./modalCommands"; +import type { ModalService, ModalState } from "./modalService"; +import { toColorInput, useTheme } from "./theme"; + +type QuickPickModalState = Extract; +type InputBoxModalState = Extract; + +/** Props for {@link ModalOverlay}. */ +export interface ModalOverlayProps { + /** Narrowed to exactly what rendering + input handling needs — matches + * `findWidget.tsx`'s `FindWidgetProps.findService` narrowing convention. */ + modalService: Pick; +} + +/** Renders the quick pick's filter `Input` + `List` (this module's TSDoc). + * A fresh mount/unmount every time `ModalOverlay`'s parent toggles between + * `mode !== "quickPick"` and `"quickPick"` — its own `useEffect(() => {...}, + * [])` mount effect focuses the filter input exactly once per open, the + * same "conditionally mounted, self-focusing" shape as `findWidget.tsx`. */ +function QuickPickBody(props: { + state: QuickPickModalState; + setFilter: (query: string) => void; +}): ReactNode { + const theme = useTheme(); + const focusRef = useFocusTracking(QUICK_PICK_FOCUS_CONTEXT_KEY); + const nodeRef = useRef(null); + // A stable identity across re-renders (the same "focus-identity-churn" + // lesson `findWidget.tsx`'s `queryInputRef`/`shell.tsx`'s `EditorArea. + // handleTextPlaneNode` document): `ModalOverlay`'s own mount effect calls + // an unconditional `forceRender()` right after subscribing to + // `modalService.onDidChange` (closing the same "subscribe-after-render + // race" every other `onDidChange` consumer in this codebase closes, + // `shell.tsx`'s `useSlotViews`'s TSDoc) — that extra render happens + // WHILE this component is still mounted, and a freshly-allocated inline + // ref callback on that render would make React detach-then-reattach this + // Input's ref (calling it with `null`, then the SAME node again) in + // between this component's own mount effect calling `.focus()` and the + // event actually being observed, silently dropping the very `FOCUSED` + // event `useFocusTracking` needs. `useCallback` keyed only on `focusRef` + // (itself stable — `useFocusTracking`'s own `useCallback`, keyed on + // `context`/`key`, neither of which changes here) keeps this ref's + // identity stable across that extra render. + const inputRef = useCallback( + (node: FocusableNode | null) => { + focusRef(node); + nodeRef.current = node; + }, + [focusRef], + ); + useEffect(() => { + // Runs once, after every ref in this render has attached — mirrors + // `findWidget.tsx`'s own mount-focus effect exactly (its TSDoc explains + // why a `useEffect` is required here rather than the declarative + // `focused` prop). + nodeRef.current?.focus(); + }, []); + + const listItems: ListItem[] = props.state.items.map((item, index) => ({ + id: String(index), + label: item.label, + description: item.description, + })); + const activeId = props.state.activeIndex >= 0 ? String(props.state.activeIndex) : undefined; + + return ( + + + {listItems.length > 0 ? ( + + ) : ( + {" No matching results "} + )} + + ); +} + +/** Renders the input box's prompt + `Input` + validation message (this + * module's TSDoc) — same conditionally-mounted, self-focusing shape as + * {@link QuickPickBody}. */ +function InputBoxBody(props: { + state: InputBoxModalState; + setInputValue: (value: string) => void; +}): ReactNode { + const theme = useTheme(); + const focusRef = useFocusTracking(INPUT_BOX_FOCUS_CONTEXT_KEY); + const nodeRef = useRef(null); + // Stable identity across re-renders — see `QuickPickBody`'s identical + // `inputRef` for the full "focus-identity-churn" explanation. + const inputRef = useCallback( + (node: FocusableNode | null) => { + focusRef(node); + nodeRef.current = node; + }, + [focusRef], + ); + useEffect(() => { + nodeRef.current?.focus(); + }, []); + + return ( + + {props.state.options?.prompt ? ( + {props.state.options.prompt} + ) : null} + + {props.state.validationMessage ? ( + {props.state.validationMessage} + ) : null} + + ); +} + +/** The modal overlay (Task 3.1, Req 10.1, design.md §12; this module's + * TSDoc). */ +export function ModalOverlay(props: ModalOverlayProps): ReactNode { + const renderer = useRenderer(); + const [, forceRender] = useReducer((n: number) => n + 1, 0); + useEffect(() => { + const sub = props.modalService.onDidChange(() => forceRender()); + // Closes the subscribe-after-render race — see `shell.tsx`'s + // `useSlotViews`'s TSDoc for the full explanation of this shape. + forceRender(); + return () => sub.dispose(); + }, [props.modalService]); + + const state = props.modalService.getState(); + const isOpen = state.mode !== null; + + const wasOpenRef = useRef(false); + const previousFocusRef = useRef(null); + // Captured DURING RENDER, not in an effect — see this module's TSDoc for + // why the ordering matters here. + if (isOpen && !wasOpenRef.current) { + previousFocusRef.current = (renderer.currentFocusedRenderable as unknown as FocusableNode | null) ?? null; + } + useEffect(() => { + if (wasOpenRef.current && !isOpen) { + try { + previousFocusRef.current?.focus(); + } catch { + // The previously-focused node may have been destroyed while the + // modal was open (e.g. its owning view was unregistered) — never + // throw out of a focus-restore attempt. + } + previousFocusRef.current = null; + } + wasOpenRef.current = isOpen; + }, [isOpen]); + + if (state.mode === null) return null; + + return ( + + {state.mode === "quickPick" ? ( + + ) : ( + + )} + + ); +} diff --git a/packages/core/src/ui/modalService.test.ts b/packages/core/src/ui/modalService.test.ts new file mode 100644 index 0000000..73fc3fd --- /dev/null +++ b/packages/core/src/ui/modalService.test.ts @@ -0,0 +1,230 @@ +/** + * `ModalService` tests (Task 3.1, Req 10.1, `modalService.ts`'s TSDoc): + * filtering, accept/cancel resolution, wrap-around navigation, input-box + * validation, and the "opening supersedes an already-open modal" contract. + */ + +import { describe, expect, test } from "bun:test"; +import type { QuickPickItem } from "@tecode/api"; +import { createModalService, filterQuickPickItems } from "./modalService"; + +const ITEMS: QuickPickItem[] = [ + { label: "Alpha", description: "first" }, + { label: "Beta", detail: "second item" }, + { label: "Gamma", description: "third", detail: "final" }, +]; + +describe("filterQuickPickItems (pure)", () => { + test("an empty query matches every item", () => { + expect(filterQuickPickItems(ITEMS, "")).toEqual(ITEMS); + }); + + test("matches case-insensitively against label", () => { + expect(filterQuickPickItems(ITEMS, "alpha").map((i) => i.label)).toEqual(["Alpha"]); + }); + + test("matches against description", () => { + expect(filterQuickPickItems(ITEMS, "third").map((i) => i.label)).toEqual(["Gamma"]); + }); + + test("matches against detail", () => { + expect(filterQuickPickItems(ITEMS, "second").map((i) => i.label)).toEqual(["Beta"]); + }); + + test("no match narrows to an empty list", () => { + expect(filterQuickPickItems(ITEMS, "zzz")).toEqual([]); + }); +}); + +describe("ModalService — quick pick", () => { + test("getState reports the open quick pick with every item visible", () => { + const service = createModalService(); + void service.openQuickPick(ITEMS); + const state = service.getState(); + expect(state.mode).toBe("quickPick"); + if (state.mode !== "quickPick") throw new Error("unreachable"); + expect(state.items).toEqual(ITEMS); + expect(state.activeIndex).toBe(0); + }); + + test("setFilter narrows getState().items and re-clamps activeIndex", () => { + const service = createModalService(); + void service.openQuickPick(ITEMS); + service.selectNext(); + service.selectNext(); // activeIndex now 2 (Gamma) against the unfiltered list + service.setFilter("beta"); + + const state = service.getState(); + if (state.mode !== "quickPick") throw new Error("unreachable"); + expect(state.items.map((i) => i.label)).toEqual(["Beta"]); + // Only one item is visible now — the raw index (2) clamps down to it. + expect(state.activeIndex).toBe(0); + }); + + test("accept resolves the active FILTERED item", async () => { + const service = createModalService(); + const pending = service.openQuickPick(ITEMS); + service.setFilter("gamma"); + service.accept(); + expect(await pending).toEqual(ITEMS[2]); + }); + + test("accept resolves undefined when filtering leaves nothing visible", async () => { + const service = createModalService(); + const pending = service.openQuickPick(ITEMS); + service.setFilter("nothing matches this"); + service.accept(); + expect(await pending).toBeUndefined(); + }); + + test("cancel (escape) resolves undefined and closes the modal", async () => { + const service = createModalService(); + const pending = service.openQuickPick(ITEMS); + service.cancel(); + expect(await pending).toBeUndefined(); + expect(service.getState().mode).toBeNull(); + }); + + test("selectNext/selectPrevious wrap around the filtered list", () => { + const service = createModalService(); + void service.openQuickPick(ITEMS); + + service.selectPrevious(); // wraps from 0 to the last item + let state = service.getState(); + if (state.mode !== "quickPick") throw new Error("unreachable"); + expect(state.activeIndex).toBe(ITEMS.length - 1); + + service.selectNext(); // wraps back to the first item + state = service.getState(); + if (state.mode !== "quickPick") throw new Error("unreachable"); + expect(state.activeIndex).toBe(0); + }); + + test("selectNext/selectPrevious are no-ops when the filtered list is empty", () => { + const service = createModalService(); + void service.openQuickPick(ITEMS); + service.setFilter("zzz"); + expect(() => service.selectNext()).not.toThrow(); + expect(() => service.selectPrevious()).not.toThrow(); + const state = service.getState(); + if (state.mode !== "quickPick") throw new Error("unreachable"); + expect(state.activeIndex).toBe(-1); + }); + + test("opening a new quick pick while one is open cancels the previous one", async () => { + const service = createModalService(); + const first = service.openQuickPick(ITEMS); + const second = service.openQuickPick([{ label: "Only" }]); + + expect(await first).toBeUndefined(); + service.accept(); + expect(await second).toEqual({ label: "Only" }); + }); +}); + +describe("ModalService — input box", () => { + test("getState reports the open input box, seeded from options.value", () => { + const service = createModalService(); + void service.openInputBox({ value: "seed" }); + const state = service.getState(); + expect(state.mode).toBe("inputBox"); + if (state.mode !== "inputBox") throw new Error("unreachable"); + expect(state.value).toBe("seed"); + expect(state.validationMessage).toBeUndefined(); + }); + + test("accept resolves the current value", async () => { + const service = createModalService(); + const pending = service.openInputBox(); + service.setInputValue("hello"); + service.accept(); + expect(await pending).toBe("hello"); + }); + + test("cancel (escape) resolves undefined", async () => { + const service = createModalService(); + const pending = service.openInputBox({ value: "abc" }); + service.cancel(); + expect(await pending).toBeUndefined(); + }); + + test("validateInput blocks accept while it reports an error", async () => { + const service = createModalService(); + const pending = service.openInputBox({ + validateInput: (value) => (value.length === 0 ? "Required" : undefined), + }); + + service.accept(); // still empty — validation fails, modal stays open + expect(service.getState().mode).toBe("inputBox"); + + service.setInputValue("ok"); + const state = service.getState(); + if (state.mode !== "inputBox") throw new Error("unreachable"); + expect(state.validationMessage).toBeUndefined(); + + service.accept(); + expect(await pending).toBe("ok"); + }); + + test("a throwing validateInput is treated as valid rather than breaking the modal", async () => { + const service = createModalService(); + const pending = service.openInputBox({ + validateInput: () => { + throw new Error("boom"); + }, + }); + expect(service.getState().mode).toBe("inputBox"); + service.accept(); + expect(await pending).toBe(""); + }); +}); + +describe("ModalService — onDidChange / dispose", () => { + test("onDidChange fires on open, filter, select, accept, and cancel", () => { + const service = createModalService(); + let fireCount = 0; + service.onDidChange(() => { + fireCount += 1; + }); + + void service.openQuickPick(ITEMS); + expect(fireCount).toBe(1); + service.setFilter("a"); + expect(fireCount).toBe(2); + service.selectNext(); + expect(fireCount).toBe(3); + service.accept(); + expect(fireCount).toBe(4); + }); + + test("a throwing listener does not stop other listeners", () => { + const service = createModalService(); + let secondCalled = false; + service.onDidChange(() => { + throw new Error("boom"); + }); + service.onDidChange(() => { + secondCalled = true; + }); + expect(() => void service.openQuickPick(ITEMS)).not.toThrow(); + expect(secondCalled).toBe(true); + }); + + test("dispose cancels a pending modal and clears listeners", async () => { + const service = createModalService(); + const pending = service.openQuickPick(ITEMS); + let fired = false; + service.onDidChange(() => { + fired = true; + }); + fired = false; // reset after the open's own initial fire + + service.dispose(); + expect(await pending).toBeUndefined(); + expect(fired).toBe(true); + + fired = false; + service.dispose(); // idempotent — no throw, no further listener calls (already cleared) + expect(fired).toBe(false); + }); +}); diff --git a/packages/core/src/ui/modalService.ts b/packages/core/src/ui/modalService.ts new file mode 100644 index 0000000..a6a63d1 --- /dev/null +++ b/packages/core/src/ui/modalService.ts @@ -0,0 +1,377 @@ +/** + * `ModalService` (Task 3.1, Req 10.1's `tecode.window`, design.md §12: " + * `tecode.window.showQuickPick/showInputBox` are implemented on the shell's + * modal layer — a centered overlay component owned by core, since the + * palette and pickers must exist before any extension UI"): the stateful + * layer behind `tecode.window.showQuickPick`/`showInputBox` + * (`api/create.ts`) and `modalOverlay.tsx`. Owns every mutation of "what + * modal is open and what it currently shows" — `api/create.ts`'s real + * `WindowNamespace` backing and `modalOverlay.tsx` both only ever call + * through here, mirroring `findService.ts`'s "all state/logic lives in + * `@tecode/core`, the component only renders it" split (`ui/findService.ts`'s + * TSDoc). + * + * **One modal at a time, deliberately** (this task's plan): `openQuickPick`/ + * `openInputBox` each return a `Promise` that resolves once the picker + * closes (accept or cancel) — exactly `WindowNamespace.showQuickPick`/ + * `showInputBox`'s own documented contract. If a modal is ALREADY open when + * either is called again, the previous one is cancelled (its promise + * resolves `undefined`, as if the user pressed Escape) before the new one + * opens — never queued, never rejected, never silently ignored. This keeps + * "what promise resolves when" simple for every caller (including two + * concurrent `commands.execute` calls racing each other) at the cost of the + * first caller's picker vanishing out from under the user — an acceptable + * MVP trade-off given nothing in this codebase yet opens two pickers + * concurrently on purpose. + * + * **Filtering — pure, separately testable** ({@link filterQuickPickItems}): + * case-insensitive substring matching against `label`, `description`, AND + * `detail` (a query matching any ONE of the three counts as a match) — the + * same three fields `QuickPickItem` exposes (`@tecode/api`'s + * `namespaces.ts`). `getState()` always DERIVES the filtered list and a + * freshly clamped `activeIndex` from the raw `items`/`filterQuery`/ + * `activeIndex` triple rather than caching a filtered snapshot — the single + * source of truth for "does the active index still point at something + * real" lives in exactly one place (this function), so `setFilter`, + * `selectNext`/`selectPrevious`, and `accept` all stay simple: they mutate + * the RAW `activeIndex` (or leave it alone, for `setFilter`) and let + * `getState`/`accept` reconcile it against whatever the CURRENT filtered + * list happens to be, on every read. + * + * **`accept()`** resolves the highlighted item in the CURRENT filtered list + * (or `undefined` if filtering has left nothing visible) for a quick pick; + * for an input box, it only resolves (closing the modal) when + * `InputBoxOptions.validateInput` — re-run on every {@link setInputValue} + * call — currently reports no error, exactly like a real form's "cannot + * submit while invalid" contract. `cancel()` always resolves `undefined` + * regardless of validation state, matching Escape's "never mind" semantics. + */ + +import type { + Disposable, + Event, + InputBoxOptions, + Listener, + QuickPickItem, + QuickPickOptions, +} from "@tecode/api"; + +/** A read-only snapshot of what the modal overlay should currently render + * (this module's TSDoc) — `modalOverlay.tsx`'s only way to read + * {@link ModalService} state. `items`/`activeIndex` for `"quickPick"` are + * already filtered/clamped against the CURRENT `filterQuery` (this module's + * TSDoc's "pure, separately testable" note) — a renderer never needs to + * re-filter or re-clamp anything itself. */ +export type ModalState = + | { mode: null } + | { + mode: "quickPick"; + /** The filtered items, in original order. */ + items: readonly QuickPickItem[]; + filterQuery: string; + /** Index into `items` above (already clamped into range, or `-1` + * when `items` is empty) — the item `accept()`/Enter would pick right + * now. */ + activeIndex: number; + options: QuickPickOptions | undefined; + } + | { + mode: "inputBox"; + value: string; + /** `InputBoxOptions.validateInput`'s current result — `undefined` + * means the current `value` is valid. */ + validationMessage: string | undefined; + options: InputBoxOptions | undefined; + }; + +/** The modal service's public shape (this module's TSDoc). */ +export interface ModalService { + /** The modal overlay's current, fully-derived state — see + * {@link ModalState}'s TSDoc. */ + getState(): ModalState; + /** Open a quick pick over `items` (Req 10.1's `WindowNamespace. + * showQuickPick`) — resolves the accepted item, or `undefined` on cancel + * (Escape) or if another modal was already open (this module's TSDoc's + * "one modal at a time"). */ + openQuickPick(items: QuickPickItem[], options?: QuickPickOptions): Promise; + /** Open an input box (Req 10.1's `WindowNamespace.showInputBox`) — + * resolves the accepted text, or `undefined` on cancel/supersession (this + * module's TSDoc). */ + openInputBox(options?: InputBoxOptions): Promise; + /** Update the quick pick's filter query — a no-op when no quick pick is + * open. */ + setFilter(query: string): void; + /** Update the input box's value, re-running `validateInput` (this + * module's TSDoc) — a no-op when no input box is open. */ + setInputValue(value: string): void; + /** Move the quick pick's active selection to the next filtered item, + * wrapping past the end back to the first — a no-op when no quick pick is + * open or its filtered list is empty. */ + selectNext(): void; + /** Move the quick pick's active selection to the previous filtered item, + * wrapping before the start back to the last — a no-op when no quick pick + * is open or its filtered list is empty. */ + selectPrevious(): void; + /** Accept the current modal (this module's TSDoc's `accept()` note). A + * no-op when no modal is open. */ + accept(): void; + /** Cancel the current modal, resolving `undefined` — a no-op when no + * modal is open. */ + cancel(): void; + /** Fires after every state change this service makes — same "just + * re-render, don't diff what changed" shape as `findService.ts`'s + * `onDidChange`. */ + onDidChange: Event; + /** Cancels whatever modal is open (if any) and clears every listener. + * Idempotent. */ + dispose(): void; +} + +interface QuickPickInternal { + mode: "quickPick"; + items: QuickPickItem[]; + filterQuery: string; + /** The RAW active index — always re-clamped against the CURRENT filtered + * list by `getState`/`accept`/`selectNext`/`selectPrevious` before use + * (this module's TSDoc); never trusted as already-in-range on its own. */ + activeIndex: number; + options: QuickPickOptions | undefined; + resolve: (value: QuickPickItem | undefined) => void; +} + +interface InputBoxInternal { + mode: "inputBox"; + value: string; + validationMessage: string | undefined; + options: InputBoxOptions | undefined; + resolve: (value: string | undefined) => void; +} + +type InternalState = QuickPickInternal | InputBoxInternal | { mode: null }; + +/** Whether `item` matches `lowerQuery` (already lower-cased by the caller) + * — a case-insensitive substring test against `label`, `description`, OR + * `detail` (this module's TSDoc). An empty query matches everything. */ +function matchesQuery(item: QuickPickItem, lowerQuery: string): boolean { + if (lowerQuery.length === 0) return true; + if (item.label.toLowerCase().includes(lowerQuery)) return true; + if (item.description && item.description.toLowerCase().includes(lowerQuery)) return true; + if (item.detail && item.detail.toLowerCase().includes(lowerQuery)) return true; + return false; +} + +/** The pure quick-pick filter (this module's TSDoc) — separately + * unit-testable, with no {@link ModalService} instance needed. */ +export function filterQuickPickItems( + items: readonly QuickPickItem[], + query: string, +): QuickPickItem[] { + const lowerQuery = query.toLowerCase(); + return items.filter((item) => matchesQuery(item, lowerQuery)); +} + +/** Clamp `index` into `[0, length - 1]`, or `-1` when `length` is 0 — the + * shared "derive a safe active index" rule `getState`/`selectNext`/ + * `selectPrevious`/`accept` all apply against the CURRENT filtered list + * (this module's TSDoc). Never wraps: a negative or too-large `index` + * lands on the nearest valid end, not the opposite end (wrapping is + * `selectNext`/`selectPrevious`'s own, deliberately different, ring + * behavior below). */ +function clampIndex(index: number, length: number): number { + if (length === 0) return -1; + if (index < 0) return 0; + if (index >= length) return length - 1; + return index; +} + +/** Build a {@link ModalService} (Task 3.1, Req 10.1, design.md §12). Takes + * no dependencies — a self-contained UI-state store, matching + * `createContextService()`'s zero-deps factory shape (`keymap/context.ts`) + * rather than `findService.ts`'s document-backed one, since nothing about + * "what modal is open" needs any other core service. */ +export function createModalService(): ModalService { + let state: InternalState = { mode: null }; + const listeners = new Set>(); + + function fireChange(): void { + // Snapshot before iterating, isolate listener failures — matches every + // other `onDidChange` in this codebase (`findService.ts`, + // `slotRegistry.ts`, `context.ts`). + for (const listener of Array.from(listeners)) { + try { + listener(undefined); + } catch { + // Isolate listener failures. + } + } + } + + /** Resolve+clear whatever modal is currently open with `undefined` + * (this module's TSDoc's "one modal at a time") — used both by + * `cancel()` and by `openQuickPick`/`openInputBox` superseding a modal + * that was already open. Does NOT fire `onDidChange` itself: `cancel()` + * fires once after calling this; the open-supersedes-open path relies on + * the NEW open's own `fireChange()` immediately after, so callers never + * observe the momentary `{ mode: null }` in between. */ + function resolveCurrentAsCancelled(): void { + if (state.mode === "quickPick" || state.mode === "inputBox") { + const resolve = state.resolve; + state = { mode: null }; + resolve(undefined); + } + } + + function getState(): ModalState { + if (state.mode === "quickPick") { + const items = filterQuickPickItems(state.items, state.filterQuery); + return { + mode: "quickPick", + items, + filterQuery: state.filterQuery, + activeIndex: clampIndex(state.activeIndex, items.length), + options: state.options, + }; + } + if (state.mode === "inputBox") { + return { + mode: "inputBox", + value: state.value, + validationMessage: state.validationMessage, + options: state.options, + }; + } + return { mode: null }; + } + + function openQuickPick( + items: QuickPickItem[], + options?: QuickPickOptions, + ): Promise { + resolveCurrentAsCancelled(); + return new Promise((resolve) => { + state = { + mode: "quickPick", + items: items.slice(), + filterQuery: "", + activeIndex: items.length > 0 ? 0 : -1, + options, + resolve, + }; + fireChange(); + }); + } + + function openInputBox(options?: InputBoxOptions): Promise { + resolveCurrentAsCancelled(); + return new Promise((resolve) => { + const value = options?.value ?? ""; + let validationMessage: string | undefined; + try { + validationMessage = options?.validateInput?.(value); + } catch { + // A throwing validator must not break opening the input box — + // treat it as "no validation error" rather than propagating. + validationMessage = undefined; + } + state = { mode: "inputBox", value, validationMessage, options, resolve }; + fireChange(); + }); + } + + function setFilter(query: string): void { + if (state.mode !== "quickPick") return; + if (state.filterQuery === query) return; + state = { ...state, filterQuery: query }; + fireChange(); + } + + function setInputValue(value: string): void { + if (state.mode !== "inputBox") return; + let validationMessage: string | undefined; + try { + validationMessage = state.options?.validateInput?.(value); + } catch { + validationMessage = undefined; + } + state = { ...state, value, validationMessage }; + fireChange(); + } + + function selectNext(): void { + if (state.mode !== "quickPick") return; + const filtered = filterQuickPickItems(state.items, state.filterQuery); + if (filtered.length === 0) return; + const current = clampIndex(state.activeIndex, filtered.length); + state = { ...state, activeIndex: (current + 1) % filtered.length }; + fireChange(); + } + + function selectPrevious(): void { + if (state.mode !== "quickPick") return; + const filtered = filterQuickPickItems(state.items, state.filterQuery); + if (filtered.length === 0) return; + const current = clampIndex(state.activeIndex, filtered.length); + state = { ...state, activeIndex: (current - 1 + filtered.length) % filtered.length }; + fireChange(); + } + + function accept(): void { + if (state.mode === "quickPick") { + const filtered = filterQuickPickItems(state.items, state.filterQuery); + const index = clampIndex(state.activeIndex, filtered.length); + const picked = index >= 0 ? filtered[index] : undefined; + const resolve = state.resolve; + state = { mode: null }; + resolve(picked); + fireChange(); + return; + } + if (state.mode === "inputBox") { + // Validation blocks accept (this module's TSDoc) — the modal stays + // open, with whatever `validationMessage` is already showing. + if (state.validationMessage !== undefined) return; + const resolve = state.resolve; + const value = state.value; + state = { mode: null }; + resolve(value); + fireChange(); + } + } + + function cancel(): void { + if (state.mode !== "quickPick" && state.mode !== "inputBox") return; + resolveCurrentAsCancelled(); + fireChange(); + } + + function onDidChange(listener: Listener): Disposable { + listeners.add(listener); + let listenerDisposed = false; + return { + dispose() { + if (listenerDisposed) return; + listenerDisposed = true; + listeners.delete(listener); + }, + }; + } + + function dispose(): void { + cancel(); + listeners.clear(); + } + + return { + getState, + openQuickPick, + openInputBox, + setFilter, + setInputValue, + selectNext, + selectPrevious, + accept, + cancel, + onDidChange, + dispose, + }; +} diff --git a/packages/core/src/ui/slotRegistry.ts b/packages/core/src/ui/slotRegistry.ts index 617ecbf..3f92dba 100644 --- a/packages/core/src/ui/slotRegistry.ts +++ b/packages/core/src/ui/slotRegistry.ts @@ -135,11 +135,21 @@ export interface SlotRegistry { * unless `meta` overrides them. Returns a {@link Disposable} that removes * the entry; idempotent, and a no-op if a later registration has already * superseded it (identity-checked, same as `storeEntry`). + * + * `component` is optional here — wider than `@tecode/api`'s + * `UiNamespace.registerView`, which always requires one (a function + * assignable to a required-parameter type may itself accept `undefined` + * too; `create.ts`'s `uiNamespace.registerView` stays exactly as strict as + * extensions see). A core-internal caller with no component to render + * (Task 3.1's `windowService.ts` backing `tecode.window.setStatusBarItem` + * with a plain text item) omits it and gets `SlotViewEntry.component: + * undefined` — `StatusBar` (`shell.tsx`) already falls back to rendering + * `item.title` as plain text in exactly that case. */ registerView( slot: SlotId, id: string, - component: ComponentType, + component?: ComponentType, meta?: RegisterViewMeta, ): Disposable; /** Every entry currently registered in `slot`, in registration order. */ @@ -287,7 +297,7 @@ export function createSlotRegistry(deps: SlotRegistryDeps = {}): SlotRegistry { function registerView( slot: SlotId, id: string, - component: ComponentType, + component?: ComponentType, meta?: RegisterViewMeta, ): Disposable { const existing = slots.get(slot)?.get(id); diff --git a/packages/core/src/ui/themeSelectCommand.ts b/packages/core/src/ui/themeSelectCommand.ts index e55b998..50a4bcf 100644 --- a/packages/core/src/ui/themeSelectCommand.ts +++ b/packages/core/src/ui/themeSelectCommand.ts @@ -15,16 +15,16 @@ * same privilege boundary `workbench.view.`'s handler has over * `LayoutStateService`. * - * **Structured around preview/commit/revert, ahead of the real quick-pick - * UI** (this task's plan): `WindowNamespace.showQuickPick` is still - * `createWindowStub`'s inert stub (Task 3.1 gives it a real implementation - * with a live "highlighted item changed" callback) — so today's handler can - * only preview-then-immediately-commit on accept, or revert on cancel, not - * preview *while the user is still browsing*. This module is written so - * that upgrade is additive: {@link createThemeSelectHandler} takes - * `showQuickPick` as an injected dependency (not hardcoded to - * `window.showQuickPick`), so Task 3.1's real picker — once it can report - * "the active item changed" — only needs a new call site here, not a + * **Structured around preview/commit/revert, ahead of live-preview-while- + * browsing** (this task's plan): Task 3.1 gave `WindowNamespace. + * showQuickPick` a real implementation (`ui/modalService.ts`), but + * `QuickPickOptions` still carries no "the highlighted item changed" + * callback — so this handler can only preview-then-immediately-commit on + * accept, or revert on cancel, not preview *while the user is still + * browsing*. This module is written so that upgrade is additive: + * {@link createThemeSelectHandler} takes `showQuickPick` as an injected + * dependency (not hardcoded to `window.showQuickPick`), so a future + * "active item changed" callback only needs a new call site here, not a * rewrite of the preview/commit/revert sequencing itself. */ diff --git a/packages/core/src/ui/windowMessageService.test.ts b/packages/core/src/ui/windowMessageService.test.ts new file mode 100644 index 0000000..61d2e76 --- /dev/null +++ b/packages/core/src/ui/windowMessageService.test.ts @@ -0,0 +1,154 @@ +/** + * `WindowMessageService` tests (Task 3.1, Req 10.1, `windowMessageService. + * ts`'s TSDoc): `setStatusBarItem` registers against the real slot registry + * with no component, `showMessage` reuses that same path, replaces a prior + * message, and clears after the injected timeout. + */ + +import { describe, expect, test } from "bun:test"; +import { createSlotRegistry } from "./slotRegistry"; +import { + createWindowMessageService, + WINDOW_MESSAGE_STATUS_BAR_ITEM_ID, +} from "./windowMessageService"; + +/** A fake timer scheduler — captures the callback so a test can fire it + * manually instead of racing a real `setTimeout` (this module's own + * injectable-timer-seam TSDoc). */ +function createFakeTimer(): { + setTimeout: (callback: () => void, ms: number) => unknown; + clearTimeout: (handle: unknown) => void; + fire: () => void; + scheduledMs: number[]; + cleared: unknown[]; +} { + let nextHandle = 0; + const pending = new Map void>(); + const scheduledMs: number[] = []; + const cleared: unknown[] = []; + return { + setTimeout: (callback, ms) => { + const handle = nextHandle++; + pending.set(handle, callback); + scheduledMs.push(ms); + return handle; + }, + clearTimeout: (handle) => { + cleared.push(handle); + pending.delete(handle as number); + }, + fire: () => { + for (const callback of Array.from(pending.values())) callback(); + pending.clear(); + }, + scheduledMs, + cleared, + }; +} + +describe("WindowMessageService.setStatusBarItem", () => { + test("registers into the real slot registry's statusBar.item slot with no component", () => { + const slotRegistry = createSlotRegistry(); + const timer = createFakeTimer(); + const service = createWindowMessageService({ + slotRegistry, + setTimeout: timer.setTimeout, + clearTimeout: timer.clearTimeout, + }); + + service.setStatusBarItem({ id: "ext.item", text: "hello", side: "right", priority: 5 }); + + const [entry] = slotRegistry.getViews("statusBar.item"); + expect(entry?.title).toBe("hello"); + expect(entry?.component).toBeUndefined(); + expect(entry?.statusBar).toEqual({ side: "right", priority: 5 }); + }); + + test("the returned Disposable removes the entry, idempotently", () => { + const slotRegistry = createSlotRegistry(); + const service = createWindowMessageService({ slotRegistry }); + const disposable = service.setStatusBarItem({ id: "x", text: "t", side: "left", priority: 0 }); + + expect(slotRegistry.getViews("statusBar.item").length).toBe(1); + disposable.dispose(); + expect(slotRegistry.getViews("statusBar.item").length).toBe(0); + expect(() => disposable.dispose()).not.toThrow(); + }); +}); + +describe("WindowMessageService.showMessage", () => { + test("registers a statusBar.item under the well-known message id", () => { + const slotRegistry = createSlotRegistry(); + const timer = createFakeTimer(); + const service = createWindowMessageService({ + slotRegistry, + setTimeout: timer.setTimeout, + clearTimeout: timer.clearTimeout, + }); + + service.showMessage("Saved.", "info"); + + const entry = slotRegistry.getView("statusBar.item", WINDOW_MESSAGE_STATUS_BAR_ITEM_ID); + expect(entry?.title).toContain("Saved."); + }); + + test("kind changes the rendered glyph prefix", () => { + const slotRegistry = createSlotRegistry(); + const service = createWindowMessageService({ slotRegistry, setTimeout: () => 0, clearTimeout: () => {} }); + + service.showMessage("Oops", "error"); + expect(slotRegistry.getView("statusBar.item", WINDOW_MESSAGE_STATUS_BAR_ITEM_ID)?.title).toBe("✖ Oops"); + + service.showMessage("Careful", "warning"); + expect(slotRegistry.getView("statusBar.item", WINDOW_MESSAGE_STATUS_BAR_ITEM_ID)?.title).toBe("⚠ Careful"); + }); + + test("a second showMessage call replaces the first rather than stacking", () => { + const slotRegistry = createSlotRegistry(); + const timer = createFakeTimer(); + const service = createWindowMessageService({ + slotRegistry, + setTimeout: timer.setTimeout, + clearTimeout: timer.clearTimeout, + }); + + service.showMessage("First"); + service.showMessage("Second"); + + expect(slotRegistry.getViews("statusBar.item").length).toBe(1); + expect(slotRegistry.getView("statusBar.item", WINDOW_MESSAGE_STATUS_BAR_ITEM_ID)?.title).toBe("Second"); + // The first message's own timer was cancelled when the second replaced it. + expect(timer.cleared.length).toBe(1); + }); + + test("the message clears itself once the injected timeout fires", () => { + const slotRegistry = createSlotRegistry(); + const timer = createFakeTimer(); + const service = createWindowMessageService({ + slotRegistry, + setTimeout: timer.setTimeout, + clearTimeout: timer.clearTimeout, + }); + + service.showMessage("Transient"); + expect(slotRegistry.getViews("statusBar.item").length).toBe(1); + + timer.fire(); + expect(slotRegistry.getViews("statusBar.item").length).toBe(0); + }); + + test("dispose clears a pending message immediately", () => { + const slotRegistry = createSlotRegistry(); + const timer = createFakeTimer(); + const service = createWindowMessageService({ + slotRegistry, + setTimeout: timer.setTimeout, + clearTimeout: timer.clearTimeout, + }); + + service.showMessage("Bye"); + service.dispose(); + expect(slotRegistry.getViews("statusBar.item").length).toBe(0); + expect(() => service.dispose()).not.toThrow(); + }); +}); diff --git a/packages/core/src/ui/windowMessageService.ts b/packages/core/src/ui/windowMessageService.ts new file mode 100644 index 0000000..43b186b --- /dev/null +++ b/packages/core/src/ui/windowMessageService.ts @@ -0,0 +1,173 @@ +/** + * `WindowMessageService` (Task 3.1, Req 10.1's `tecode.window.showMessage`/ + * `setStatusBarItem`): the real backing for `WindowNamespace. + * setStatusBarItem` — a plain, disposable `statusBar.item` registration + * against the SAME live {@link SlotRegistry} the rendered `Shell`'s + * `StatusBar` reads from (`shell.tsx`'s `useStatusBarItems`) — and + * `showMessage`, built as a transient use of THAT SAME registration path + * rather than a second, parallel one (this task's plan: "unify with + * `window.setStatusBarItem`'s mechanism"). + * + * **Why `setStatusBarItem` needed a real backing at all in this task**: + * before Task 3.1, `create.ts` wired `tecode.window.setStatusBarItem` + * straight to `stubs.ts`'s `createWindowStub()`, which registers into its + * OWN internal `Set` — never the real `SlotRegistry` `StatusBar` (`shell. + * tsx`) actually renders from (`stubs.ts`'s own TSDoc: "no renderer yet to + * observe it through"). `showMessage`'s spec ("route to the status bar via + * the EXISTING SlotRegistry statusBar.item path") only has an existing path + * to reuse once `setStatusBarItem` itself is real — so this module gives + * BOTH a genuine backing together, rather than routing `showMessage` + * through a bespoke mechanism `setStatusBarItem` doesn't share. + * + * **`StatusBar`'s text-fallback rendering, reused as-is**: `shell.tsx`'s + * `StatusBar.renderItem` already renders `item.title ?? item.id` as plain + * `` whenever a `statusBar.item` entry has no `component` (its own + * TSDoc/body) — exactly the "no new rendering" the plan calls for. + * `setStatusBarItem` therefore calls `slotRegistry.registerView( + * "statusBar.item", item.id, undefined, { title: item.text, statusBar: + * {...} })` (component omitted — `SlotRegistry.registerView`'s `component` + * param is optional precisely for this caller, see its own TSDoc) rather + * than wrapping `item.text` in a throwaway `ComponentType`. + * + * **`showMessage`'s transience** (Req 10.1; no notification-area/toast + * mechanism exists yet, design.md §14's error-reporting story is still + * "surface through the status bar" for the MVP): each call registers ONE + * well-known `statusBar.item` id (so a second message REPLACES the first + * rather than stacking) with a high `priority` (renders leftmost among + * `"left"`-side items — `slotRegistry.ts`'s `listStatusBarItems` sort) and + * a `kind`-dependent glyph prefix, then disposes it again after + * {@link WindowMessageServiceDeps.messageTimeoutMs}. The timer is an + * injectable seam (house convention — matches `layoutState.ts`'s/ + * `themeSettingsWriter.ts`'s own injectable-timer seams) so tests can + * observe the schedule/cancel calls directly instead of racing a real + * `setTimeout`. + */ + +import type { Disposable, MessageKind, StatusBarItem } from "@tecode/api"; +import type { SlotRegistry } from "./slotRegistry"; + +/** The well-known `statusBar.item` id every `showMessage` call reuses + * (this module's TSDoc's "replace, don't stack"). Namespaced like every + * other core-owned id in this codebase (`theme.select`, `modal.accept`). */ +export const WINDOW_MESSAGE_STATUS_BAR_ITEM_ID = "tecode.window.message"; + +/** How long a `showMessage` notice stays visible before auto-clearing, + * when {@link WindowMessageServiceDeps.messageTimeoutMs} is not given. */ +export const DEFAULT_MESSAGE_TIMEOUT_MS = 5000; + +/** Dependencies for {@link createWindowMessageService}. */ +export interface WindowMessageServiceDeps { + /** The live slot registry `Shell`'s `StatusBar` renders from — narrowed + * to `registerView`, the only method this service calls. */ + slotRegistry: Pick; + /** Injectable `setTimeout` (this module's TSDoc) — defaults to the real + * global. Matches the return type of the real `setTimeout` loosely (` + * unknown`) so a test's fake scheduler need not fabricate a real timer + * handle. */ + setTimeout?: (callback: () => void, ms: number) => unknown; + /** Injectable `clearTimeout` counterpart — defaults to the real global. */ + clearTimeout?: (handle: unknown) => void; + /** Overrides {@link DEFAULT_MESSAGE_TIMEOUT_MS}. */ + messageTimeoutMs?: number; +} + +/** {@link createWindowMessageService}'s return shape. */ +export interface WindowMessageService { + /** + * Identity token: the exact `SlotRegistry` this service registers its + * `statusBar.item` views against. `createTecodeApi` compares it against + * its own `slotRegistry` dep and falls back to the window stub on a + * mismatch — a service registered against registry B while the rendered + * `Shell`'s `StatusBar` reads registry A would otherwise accept + * `showMessage` calls that never render anywhere (the same + * cross-instance guard as `FindService.session`). + */ + readonly registry: WindowMessageServiceDeps["slotRegistry"]; + /** The real `WindowNamespace.setStatusBarItem` backing (this module's + * TSDoc). */ + setStatusBarItem(item: StatusBarItem): Disposable; + /** The real `WindowNamespace.showMessage` backing (this module's TSDoc). */ + showMessage(message: string, kind?: MessageKind): void; + /** Clears any pending `showMessage` notice/timer immediately — called on + * shutdown so a headless run's final `statusBar.item` registration + * doesn't linger (matches every other startup-owned subscription's + * disposal in `main.ts`'s `wireProcessExit`). Idempotent. */ + dispose(): void; +} + +/** The glyph prefix for each `MessageKind` (Req 10.1) — undecorated for a + * missing/unrecognized kind, matching `showMessage`'s own optional `kind` + * parameter. */ +function kindGlyph(kind: MessageKind | undefined): string { + switch (kind) { + case "warning": + return "⚠ "; + case "error": + return "✖ "; + case "info": + return "ℹ "; + default: + return ""; + } +} + +/** Build a {@link WindowMessageService} (Task 3.1, Req 10.1). */ +export function createWindowMessageService(deps: WindowMessageServiceDeps): WindowMessageService { + const { slotRegistry } = deps; + const scheduleTimeout = deps.setTimeout ?? ((callback, ms) => setTimeout(callback, ms)); + const cancelTimeout = deps.clearTimeout ?? ((handle) => clearTimeout(handle as ReturnType)); + const messageTimeoutMs = deps.messageTimeoutMs ?? DEFAULT_MESSAGE_TIMEOUT_MS; + + let pendingMessage: Disposable | undefined; + let pendingTimer: unknown; + + function setStatusBarItem(item: StatusBarItem): Disposable { + // `component` omitted deliberately (this module's TSDoc) — `StatusBar` + // (`shell.tsx`) already renders `item.title` as plain text whenever a + // `statusBar.item` entry has no component. + return slotRegistry.registerView("statusBar.item", item.id, undefined, { + title: item.text, + statusBar: { side: item.side, priority: item.priority }, + }); + } + + /** Cancel whatever `showMessage` notice/timer is currently pending, if + * any — shared by `showMessage` (replacing the previous notice) and + * `dispose` (this module's TSDoc). */ + function clearPendingMessage(): void { + if (pendingTimer !== undefined) { + try { + cancelTimeout(pendingTimer); + } catch { + // Never let a broken timer implementation break message handling. + } + pendingTimer = undefined; + } + pendingMessage?.dispose(); + pendingMessage = undefined; + } + + function showMessage(message: string, kind?: MessageKind): void { + clearPendingMessage(); + pendingMessage = setStatusBarItem({ + id: WINDOW_MESSAGE_STATUS_BAR_ITEM_ID, + text: `${kindGlyph(kind)}${message}`, + side: "left", + // Highest realistic priority (design.md §8.2's "sorted... by + // descending priority") — a transient user-facing notice should read + // before any extension's own left-side status item. + priority: 1_000_000, + }); + pendingTimer = scheduleTimeout(() => { + pendingTimer = undefined; + pendingMessage?.dispose(); + pendingMessage = undefined; + }, messageTimeoutMs); + } + + function dispose(): void { + clearPendingMessage(); + } + + return { registry: slotRegistry, setStatusBarItem, showMessage, dispose }; +}