diff --git a/docs/getting-started/create-rezi.md b/docs/getting-started/create-rezi.md index 63a3c11c..db628c78 100644 --- a/docs/getting-started/create-rezi.md +++ b/docs/getting-started/create-rezi.md @@ -5,14 +5,33 @@ The fastest way to start a new Rezi app is the scaffolding tool: ```bash npm create rezi my-app cd my-app -npm start +npm run start ``` This generates a TypeScript project with a multi-panel layout, list, status bar, and keybindings. +If you prefer Bun: + +```bash +npm create rezi my-app -- --pm bun +cd my-app +bun run start +``` + ## Templates -Choose a template interactively or pass `--template`: +Canonical template names for `--template`: + +- `dashboard`: Live ops dashboard with deterministic table updates. + Highlights: live-updating table with stable row keys, filter/sort/pin controls + incident telemetry. +- `form-app`: Multi-step form with validation and command modes. + Highlights: insert/command key modes with chords, modal help and toast notifications. +- `file-browser`: Explorer with async command palette search. + Highlights: async palette results with cancellation, table browser with details and preview. +- `streaming-viewer`: High-volume stream monitor with virtualized index. + Highlights: virtual list over 15k streams, live ingest feed with follow/pause controls. + +Choose a template interactively or pass a canonical name: ```bash npm create rezi my-app -- --template dashboard @@ -21,12 +40,18 @@ npm create rezi my-app -- --template file-browser npm create rezi my-app -- --template streaming-viewer ``` +To inspect templates and highlights from the CLI: + +```bash +npm create rezi -- --list-templates +``` + ## Options -- `--template `: Select a template. +- `--template `: Select a template. - `--no-install`: Skip dependency installation. - `--pm `: Choose a package manager. -- `--list-templates`: Print available templates. +- `--list-templates`: Print available templates and highlights. - `--help`: Show help. ## Next Steps diff --git a/docs/getting-started/faq.md b/docs/getting-started/faq.md index dbe5429d..d71a1366 100644 --- a/docs/getting-started/faq.md +++ b/docs/getting-started/faq.md @@ -17,6 +17,10 @@ No. While Rezi uses a declarative widget tree similar to React's component model Rezi is designed specifically for terminal UIs, not as a React port. +### I'm using Ink. How do I migrate? + +Use the [Ink to Rezi migration guide](../migration/ink-to-rezi.md) for a direct mental-model map and practical migration recipes. + ### What platforms does Rezi support? Rezi supports: diff --git a/docs/index.md b/docs/index.md index ded8b8e5..4128f3f5 100644 --- a/docs/index.md +++ b/docs/index.md @@ -204,6 +204,7 @@ User feedback: `spinner`, `skeleton`, `callout`, `errorDisplay`, `empty` ## Learn More - [Concepts](guide/concepts.md) - Understanding Rezi's architecture +- [Ink to Rezi Migration](migration/ink-to-rezi.md) - Mental model mapping and migration recipes - [Lifecycle & Updates](guide/lifecycle-and-updates.md) - State management patterns - [Layout](guide/layout.md) - Spacing, alignment, and constraints - [Input & Focus](guide/input-and-focus.md) - Keyboard navigation and focus management diff --git a/docs/migration/ink-to-rezi.md b/docs/migration/ink-to-rezi.md new file mode 100644 index 00000000..1121f007 --- /dev/null +++ b/docs/migration/ink-to-rezi.md @@ -0,0 +1,238 @@ +# Ink to Rezi Migration + +This guide maps common Ink mental models to Rezi and gives practical migration recipes. + +## Read this first + +Rezi is not a drop-in replacement for Ink. + +- Rezi does **not** promise API or behavior compatibility with Ink. +- Migration is intentional: move patterns, not component names. + +This is by design. Rezi prioritizes deterministic rendering, deterministic input routing, and deterministic layout in terminal cells. + +## Mental model map + +| Ink mental model | Rezi mental model | Why this is different | +|---|---|---| +| `render()` starts a React tree | `createNodeApp(...)` + `app.view((state) => VNode)` + `app.start()` | Rezi runs a runtime-owned state/render pipeline instead of React reconciliation | +| Re-render timing follows React scheduling | Re-renders happen at deterministic commit points after `app.update(...)`/event batches | Same input + state transitions produce the same frame sequence | +| Hooks live in React components | Stateful reusable widgets use `defineWidget((props, ctx) => ...)` with `ctx.useState/useRef/useEffect/useAppState` | Keeps root view pure while still allowing local widget state | +| Input usually handled with `useInput` and component focus assumptions | Input routes through stable widget `id`s, focus order, `app.keys()`, `app.modes()`, `focusZone`, `focusTrap` | Event routing and focus are explicit and deterministic | +| Yoga/flexbox layout expectations | Cell-based layout via `ui.row`, `ui.column`, `ui.box`, `width/height/flex`, `p/m/gap` | Terminal layout is measured in character cells, not pixels | + +## What's different (on purpose) + +- **No compatibility promise**: Rezi does not emulate Ink internals or component semantics. +- **Deterministic scheduling**: queued updates are coalesced at commit points; render-time updates throw. +- **Deterministic focus/input**: stable `id` + documented routing order avoids timing-dependent behavior. +- **Cell-based layout**: widths/heights/overflow are resolved in terminal cells for predictable output. + +## Recipes + +### 1) Layout: Yoga-style intent to Rezi cell layout + +Use stacks (`row`/`column`) plus explicit cell constraints. + +```typescript +app.view((state) => + ui.row({ gap: 1 }, [ + ui.box({ width: 24, border: "rounded", p: 1, title: "Sidebar" }, [ + ui.column({ gap: 1 }, state.menu.map((item) => ui.text(item, { key: item }))), + ]), + ui.box({ flex: 1, border: "single", p: 1, title: "Content" }, [ + ui.text(state.title, { style: { bold: true } }), + ui.text(state.body, { textOverflow: "ellipsis", maxWidth: 60 }), + ]), + ]) +); +``` + +Migration notes: +- `width`/`height` numbers are terminal cells. +- `flex` distributes remaining space. +- `p`, `m`, and `gap` are cell-based spacing. + +### 2) Lists and virtualization + +Small lists: map into `ui.column` with stable `key`. + +```typescript +ui.column({ gap: 0 }, state.items.map((it) => ui.text(it.label, { key: it.id }))); +``` + +Large lists: switch to `ui.virtualList`. + +```typescript +ui.virtualList({ + id: "results", + items: state.rows, + itemHeight: 1, + overscan: 3, + renderItem: (row, _i, focused) => + ui.text(`${focused ? ">" : " "} ${row.name}`, { + key: row.id, + style: focused ? { bold: true } : undefined, + }), +}); +``` + +### 3) Tables + +Use `ui.table` for sorting, selection, and built-in virtualization. + +```typescript +ui.table({ + id: "users", + columns: [ + { key: "name", header: "Name", flex: 1, sortable: true, overflow: "middle" }, + { key: "role", header: "Role", width: 12 }, + ], + data: state.users, + getRowKey: (u) => u.id, + selection: state.selection, + selectionMode: "multi", + onSelectionChange: (keys) => app.update((s) => ({ ...s, selection: keys })), + onSort: (column, direction) => app.update((s) => ({ ...s, sortColumn: column, sortDirection: direction })), + virtualized: true, +}); +``` + +### 4) Text styling and truncation + +`ui.text` handles style + deterministic cell-aware truncation. + +```typescript +import { rgb } from "@rezi-ui/core"; + +ui.column({ gap: 1 }, [ + ui.text("Build failed", { style: { fg: rgb(255, 110, 110), bold: true } }), + ui.text(state.path, { textOverflow: "middle", maxWidth: 40 }), +]); +``` + +### 5) Forms + +Use controlled `input` widgets and validate in update handlers, not during render. + +```typescript +ui.field({ + label: "Email", + required: true, + error: state.touched.email ? state.errors.email : undefined, + children: ui.input({ + id: "email", + value: state.email, + onInput: (value) => + app.update((s) => { + const next = { ...s, email: value }; + return { ...next, errors: validate(next) }; + }), + onBlur: () => app.update((s) => ({ ...s, touched: { ...s.touched, email: true } })), + }), +}); +``` + +### 6) Keybindings: global, modes, and chords + +Use `app.keys()` for global bindings and chord sequences; use `app.modes()` for contextual maps. + +```typescript +app.keys({ + "ctrl+s": () => save(), + "g g": () => jumpTop(), + "ctrl+x ctrl+s": () => save(), +}); + +app.modes({ + normal: { + i: () => app.setMode("insert"), + "d d": () => deleteLine(), + }, + insert: { + escape: () => app.setMode("normal"), + }, +}); + +app.setMode("normal"); +``` + +### 7) Overlays: modals and toasts + +Render overlays in `ui.layers(...)`; later children are on top. + +```typescript +ui.layers([ + MainScreen(state), + state.showConfirm && + ui.modal({ + id: "confirm", + title: "Delete file?", + content: ui.text("This action cannot be undone."), + actions: [ + ui.button({ id: "cancel", label: "Cancel" }), + ui.button({ id: "delete", label: "Delete" }), + ], + onClose: () => app.update((s) => ({ ...s, showConfirm: false })), + }), + ui.toastContainer({ + toasts: state.toasts, + position: "bottom-right", + onDismiss: (id) => app.update((s) => ({ ...s, toasts: s.toasts.filter((t) => t.id !== id) })), + }), +]); +``` + +### 8) Debugging and inspector + +Use the debug controller for traces and the inspector overlay for live frame/focus inspection. + +```typescript +import { createAppWithInspectorOverlay, createDebugController } from "@rezi-ui/core"; +import { createNodeBackend } from "@rezi-ui/node"; + +const backend = createNodeBackend(); +const debug = createDebugController({ + backend: backend.debug, + terminalCapsProvider: () => backend.getCaps(), +}); + +await debug.enable({ minSeverity: "info" }); + +const app = createAppWithInspectorOverlay({ + backend, + initialState, + inspector: { + hotkey: "ctrl+shift+i", + debug, + }, +}); +``` + +### 9) Performance + +Use Rezi's deterministic performance model directly: + +- Keep `app.view(state)` pure and cheap. +- Keep `key` stable in dynamic lists. +- Precompute expensive derived data during `app.update(...)`. +- Use `ui.virtualList`/`ui.table` for large datasets. +- Turn on debug `perf`/`frame` categories to inspect hotspots. + +## Quick migration checklist + +1. Replace `render(...)` entrypoint with `createNodeApp` + `app.view` + `app.start`. +2. Move React component local state to app state or `defineWidget` context hooks. +3. Replace `useInput`-style handlers with `app.keys`, `app.modes`, and widget callbacks. +4. Convert Yoga assumptions to cell-based layout (`row`/`column`/`box`, `flex`, cell spacing). +5. Add stable `id` (focus/routing) and stable `key` (list reconciliation). +6. Verify behavior with debug traces and inspector overlay before parity signoff. + +## Related docs + +- [Composition](../guide/composition.md) +- [Lifecycle & Updates](../guide/lifecycle-and-updates.md) +- [Layout](../guide/layout.md) +- [Input & Focus](../guide/input-and-focus.md) +- [Performance](../guide/performance.md) +- [Debugging](../guide/debugging.md) diff --git a/docs/packages/create-rezi.md b/docs/packages/create-rezi.md index c2e4429c..ee97c258 100644 --- a/docs/packages/create-rezi.md +++ b/docs/packages/create-rezi.md @@ -7,17 +7,26 @@ Scaffold a new Rezi terminal UI app in seconds. ```bash npm create rezi my-app cd my-app -npm start +npm run start + +# or bun +# npm create rezi my-app -- --pm bun +# cd my-app +# bun run start ``` ## Templates -- `dashboard`: Multi-panel ops dashboard with a status list and activity feed -- `form-app`: Data entry flow with a live preview panel -- `file-browser`: Split view file browser with details + preview -- `streaming-viewer`: Live viewer with chat and stream metrics +- `dashboard`: Live ops dashboard with deterministic table updates. + Highlights: live-updating table with stable row keys, filter/sort/pin controls + incident telemetry. +- `form-app`: Multi-step form with validation and command modes. + Highlights: insert/command key modes with chords, modal help and toast notifications. +- `file-browser`: Explorer with async command palette search. + Highlights: async palette results with cancellation, table browser with details and preview. +- `streaming-viewer`: High-volume stream monitor with virtualized index. + Highlights: virtual list over 15k streams, live ingest feed with follow/pause controls. -Choose a template interactively or pass `--template`: +Choose a template interactively or pass a canonical template name: ```bash npm create rezi my-app -- --template dashboard @@ -26,10 +35,24 @@ npm create rezi my-app -- --template file-browser npm create rezi my-app -- --template streaming-viewer ``` +List templates and highlights from the CLI: + +```bash +npm create rezi -- --list-templates +``` + ## Options -- `--template `: Select a template. +- `--template `: Select a template. - `--no-install`: Skip dependency installation. - `--pm `: Choose a package manager. -- `--list-templates`: Print available templates. +- `--list-templates`: Print available templates and highlights. - `--help`: Show help. + +## Template Smoke Check + +Run deterministic template smoke checks (metadata consistency + build/typecheck expectations): + +```bash +npm run check:create-rezi-templates +``` diff --git a/mkdocs.yml b/mkdocs.yml index 59c5bccb..441581c4 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -99,6 +99,8 @@ nav: - JSX: getting-started/jsx.md - Examples: getting-started/examples.md - FAQ: getting-started/faq.md + - Migration: + - Ink to Rezi: migration/ink-to-rezi.md - Guides: - Concepts: guide/concepts.md - Composition: guide/composition.md diff --git a/package.json b/package.json index 83f24c1b..b7092a35 100644 --- a/package.json +++ b/package.json @@ -22,10 +22,11 @@ "docs:build": "bash scripts/docs.sh build", "build:native": "npm -w @rezi-ui/native run build:native", "test:native:smoke": "npm -w @rezi-ui/native run test:native:smoke", + "check:create-rezi-templates": "node scripts/check-create-rezi-templates.mjs", "check:core-portability": "node scripts/check-core-portability.mjs", "check:docs": "npm run docs:build", "check:unicode": "node scripts/check-unicode-sync.mjs", - "check": "npm run check:core-portability && npm run check:docs && npm run check:unicode", + "check": "npm run check:create-rezi-templates && npm run check:core-portability && npm run check:docs && npm run check:unicode", "test": "node scripts/run-tests.mjs", "test:e2e": "node scripts/run-e2e.mjs", "test:e2e:reduced": "node scripts/run-e2e.mjs --profile reduced", diff --git a/packages/create-rezi/README.md b/packages/create-rezi/README.md index 734c74ac..0b951363 100644 --- a/packages/create-rezi/README.md +++ b/packages/create-rezi/README.md @@ -7,12 +7,17 @@ Scaffold a new Rezi terminal UI app. ```bash npm create rezi my-app cd my-app -npm start +npm run start + +# or bun +# npm create rezi my-app -- --pm bun +# cd my-app +# bun run start ``` ## Templates -Choose a template interactively, or pass `--template`. +Choose a template interactively, or pass `--template` with a canonical name. ```bash npm create rezi my-app -- --template dashboard @@ -21,10 +26,16 @@ npm create rezi my-app -- --template file-browser npm create rezi my-app -- --template streaming-viewer ``` +List templates and highlights from the CLI: + +```bash +npm create rezi -- --list-templates +``` + ## Options -- `--template `: Choose a template. +- `--template `: Choose a template. - `--no-install`: Skip dependency installation. - `--pm `: Choose a package manager. -- `--list-templates`: Print available templates. +- `--list-templates`: Print available templates and highlights. - `--help`: Show help. diff --git a/packages/create-rezi/src/__tests__/scaffold.test.ts b/packages/create-rezi/src/__tests__/scaffold.test.ts index 0b026ac5..1368956c 100644 --- a/packages/create-rezi/src/__tests__/scaffold.test.ts +++ b/packages/create-rezi/src/__tests__/scaffold.test.ts @@ -1,33 +1,68 @@ -import { mkdtemp, readFile } from "node:fs/promises"; +import { mkdtemp, readFile, readdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { assert, test } from "@rezi-ui/testkit"; -import { createProject, normalizeTemplateName, toValidPackageName } from "../scaffold.js"; +import { + TEMPLATE_DEFINITIONS, + createProject, + getTemplatesRoot, + normalizeTemplateName, + toValidPackageName, +} from "../scaffold.js"; test("normalizeTemplateName accepts friendly aliases", () => { + assert.equal(normalizeTemplateName("dashboard"), "dashboard"); + assert.equal(normalizeTemplateName("form-app"), "form-app"); assert.equal(normalizeTemplateName("form app"), "form-app"); assert.equal(normalizeTemplateName("file-browser"), "file-browser"); + assert.equal(normalizeTemplateName("filebrowser"), "file-browser"); assert.equal(normalizeTemplateName("streaming"), "streaming-viewer"); + assert.equal(normalizeTemplateName("streamingviewer"), "streaming-viewer"); }); -test("createProject scaffolds a dashboard project with substitutions", async () => { +test("template keys match template directories and include highlights", async () => { + const expectedKeys = ["dashboard", "form-app", "file-browser", "streaming-viewer"]; + const keys = TEMPLATE_DEFINITIONS.map((template) => template.key); + assert.equal(keys.join(","), expectedKeys.join(",")); + + const directories = (await readdir(getTemplatesRoot(), { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + const definedDirectories = TEMPLATE_DEFINITIONS.map((template) => template.dir).sort(); + assert.equal(directories.join(","), definedDirectories.join(",")); + + for (const template of TEMPLATE_DEFINITIONS) { + assert.equal(template.dir, template.key); + assert.ok(template.highlights.length >= 2); + } +}); + +test("createProject scaffolds each template with substitutions", async () => { const root = await mkdtemp(join(tmpdir(), "rezi-create-")); - const targetDir = join(root, "my-app"); - const displayName = "My Rezi App"; - const packageName = toValidPackageName("my-rezi-app"); - - await createProject({ - targetDir, - templateKey: "dashboard", - packageName, - displayName, - }); - - const pkgRaw = await readFile(join(targetDir, "package.json"), "utf8"); - const pkg = JSON.parse(pkgRaw) as { name: string }; - assert.equal(pkg.name, packageName); - - const main = await readFile(join(targetDir, "src", "main.ts"), "utf8"); - assert.ok(main.includes(displayName)); - assert.ok(!main.includes("__APP_NAME__")); + + for (const template of TEMPLATE_DEFINITIONS) { + const targetDir = join(root, template.key); + const displayName = `My ${template.label}`; + const packageName = toValidPackageName(`my-${template.key}`); + + await createProject({ + targetDir, + templateKey: template.key, + packageName, + displayName, + }); + + const pkgRaw = await readFile(join(targetDir, "package.json"), "utf8"); + const pkg = JSON.parse(pkgRaw) as { name: string }; + assert.equal(pkg.name, packageName); + + const main = await readFile(join(targetDir, "src", "main.ts"), "utf8"); + assert.ok(main.includes(displayName)); + assert.ok(!main.includes("__APP_NAME__")); + + const readme = await readFile(join(targetDir, "README.md"), "utf8"); + assert.ok(readme.includes(template.label)); + assert.ok(!readme.includes("__TEMPLATE_LABEL__")); + } }); diff --git a/packages/create-rezi/src/index.ts b/packages/create-rezi/src/index.ts index e430ab14..29f2ca9c 100644 --- a/packages/create-rezi/src/index.ts +++ b/packages/create-rezi/src/index.ts @@ -86,19 +86,23 @@ function printHelp(): void { stdout.write("Usage:\n"); stdout.write(" npm create rezi my-app\n\n"); stdout.write("Options:\n"); - stdout.write(" --template, -t Choose a template\n"); - stdout.write(" --no-install Skip dependency install\n"); + stdout.write( + " --template, -t dashboard | form-app | file-browser | streaming-viewer\n", + ); + stdout.write(" --no-install Skip dependency install\n"); stdout.write(" --pm Choose a package manager\n"); - stdout.write(" --list-templates Show available templates\n"); - stdout.write(" --help, -h Show this help\n"); + stdout.write(" --list-templates Show templates and highlights\n"); + stdout.write(" --help, -h Show this help\n"); } function printTemplates(): void { - stdout.write("Available templates:\n"); + stdout.write("Available templates (--template ):\n"); TEMPLATE_DEFINITIONS.forEach((template, index) => { - stdout.write( - ` ${index + 1}. ${template.key.padEnd(16)} ${template.label} - ${template.description}\n`, - ); + const defaultSuffix = index === 0 ? " (default)" : ""; + const highlights = template.highlights.join(" | "); + stdout.write(` ${index + 1}. ${template.key.padEnd(16)} ${template.label}${defaultSuffix}\n`); + stdout.write(` ${template.description}\n`); + stdout.write(` Highlights: ${highlights}\n`); }); } @@ -135,18 +139,23 @@ async function promptText( } async function promptTemplate(rl: ReturnType): Promise { - stdout.write("\nSelect a template:\n"); + stdout.write("\nSelect a template (name or number):\n"); TEMPLATE_DEFINITIONS.forEach((template, index) => { - stdout.write(` ${index + 1}. ${template.label} - ${template.description}\n`); + const defaultSuffix = index === 0 ? " (default)" : ""; + stdout.write(` ${index + 1}. ${template.key.padEnd(16)} ${template.label}${defaultSuffix}\n`); + stdout.write(` ${template.description}\n`); + stdout.write(` Highlights: ${template.highlights.join(" | ")}\n`); }); - const answer = await rl.question("Template (1-4, default 1): "); + const answer = await rl.question(`Template (1-${TEMPLATE_DEFINITIONS.length}, default 1): `); const trimmed = answer.trim(); - if (!trimmed) return "dashboard"; + if (!trimmed) { + return TEMPLATE_DEFINITIONS[0]?.key ?? "dashboard"; + } const asNumber = Number(trimmed); if (!Number.isNaN(asNumber) && asNumber >= 1 && asNumber <= TEMPLATE_DEFINITIONS.length) { - return TEMPLATE_DEFINITIONS[asNumber - 1]?.key ?? "dashboard"; + return TEMPLATE_DEFINITIONS[asNumber - 1]?.key ?? TEMPLATE_DEFINITIONS[0]?.key ?? "dashboard"; } return trimmed; @@ -167,15 +176,21 @@ function printNextSteps( packageManager: PackageManager, installRan: boolean, ): void { + const installCommand = `${packageManager} install`; + const startCommand = + packageManager === "npm" || packageManager === "bun" + ? `${packageManager} run start` + : `${packageManager} start`; + const rel = relative(cwd(), resolve(targetDir)) || "."; stdout.write("\nNext steps:\n"); if (rel !== ".") { stdout.write(` cd ${rel}\n`); } if (!installRan) { - stdout.write(` ${packageManager} install\n`); + stdout.write(` ${installCommand}\n`); } - stdout.write(` ${packageManager} start\n`); + stdout.write(` ${startCommand}\n`); } async function main(): Promise { @@ -198,8 +213,10 @@ async function main(): Promise { const templateInput = options.template || (await promptTemplate(rl)); const templateKey = normalizeTemplateName(templateInput); if (!templateKey) { + const allowed = TEMPLATE_DEFINITIONS.map((template) => template.key).join(", "); stdout.write(`\nUnknown template: ${templateInput}\n`); - printTemplates(); + stdout.write(`Use one of: ${allowed}\n`); + stdout.write("Run with --list-templates to see highlights.\n"); throw new Error("Invalid template"); } diff --git a/packages/create-rezi/src/scaffold.ts b/packages/create-rezi/src/scaffold.ts index 0d9d9b89..92c0520b 100644 --- a/packages/create-rezi/src/scaffold.ts +++ b/packages/create-rezi/src/scaffold.ts @@ -8,6 +8,7 @@ export type TemplateDefinition = { key: TemplateKey; label: string; description: string; + highlights: readonly string[]; dir: string; }; @@ -15,30 +16,52 @@ export const TEMPLATE_DEFINITIONS: readonly TemplateDefinition[] = [ { key: "dashboard", label: "Dashboard", - description: "Multi-panel ops dashboard with status list", + description: "Live ops dashboard with deterministic table updates", + highlights: [ + "live-updating table with stable row keys", + "filter/sort/pin controls + incident telemetry", + ], dir: "dashboard", }, { key: "form-app", label: "Form app", - description: "Data entry flow with preview panel", + description: "Multi-step form with validation and command modes", + highlights: ["insert/command key modes with chords", "modal help and toast notifications"], dir: "form-app", }, { key: "file-browser", label: "File browser", - description: "Split view with directory list + preview", + description: "Explorer with async command palette search", + highlights: [ + "async palette results with cancellation", + "table browser with details and preview", + ], dir: "file-browser", }, { key: "streaming-viewer", label: "Streaming viewer", - description: "Live stream list with chat + metrics", + description: "High-volume stream monitor with virtualized index", + highlights: ["virtual list over 15k streams", "live ingest feed with follow/pause controls"], dir: "streaming-viewer", }, ] as const; const TEMPLATE_BY_KEY = new Map(TEMPLATE_DEFINITIONS.map((template) => [template.key, template])); +const TEMPLATE_ALIASES = new Map( + TEMPLATE_DEFINITIONS.map((template) => [template.key, template.key]), +); + +TEMPLATE_ALIASES.set("form", "form-app"); +TEMPLATE_ALIASES.set("formapp", "form-app"); +TEMPLATE_ALIASES.set("file", "file-browser"); +TEMPLATE_ALIASES.set("files", "file-browser"); +TEMPLATE_ALIASES.set("filebrowser", "file-browser"); +TEMPLATE_ALIASES.set("stream", "streaming-viewer"); +TEMPLATE_ALIASES.set("streaming", "streaming-viewer"); +TEMPLATE_ALIASES.set("streamingviewer", "streaming-viewer"); const PACKAGE_NAME_RE = /^(?:@[a-z0-9-~][a-z0-9-._~]*\/)?[a-z0-9-~][a-z0-9-._~]*$/; @@ -46,27 +69,10 @@ export function normalizeTemplateName(value: string): TemplateKey | null { const cleaned = value.trim().toLowerCase(); if (!cleaned) return null; const slug = cleaned.replace(/[\s_]+/g, "-"); - - switch (slug) { - case "dashboard": - return "dashboard"; - case "form": - case "form-app": - case "formapp": - return "form-app"; - case "file": - case "files": - case "file-browser": - case "filebrowser": - return "file-browser"; - case "stream": - case "streaming": - case "streaming-viewer": - case "streamingviewer": - return "streaming-viewer"; - default: - return TEMPLATE_BY_KEY.has(slug as TemplateKey) ? (slug as TemplateKey) : null; - } + const normalized = TEMPLATE_ALIASES.get(slug); + if (normalized) return normalized; + const compact = slug.replace(/-/g, ""); + return TEMPLATE_ALIASES.get(compact) ?? null; } export function toDisplayName(value: string): string { diff --git a/packages/create-rezi/templates/dashboard/README.md b/packages/create-rezi/templates/dashboard/README.md index 12084dda..b8ce2782 100644 --- a/packages/create-rezi/templates/dashboard/README.md +++ b/packages/create-rezi/templates/dashboard/README.md @@ -5,14 +5,34 @@ Scaffolded with `npm create rezi` using the **__TEMPLATE_LABEL__** template. ## Quickstart ```bash +# npm npm install -npm start +npm run start + +# bun +bun install +bun run start ``` -## Keybindings +## Controls -- `up` / `down` or `j` / `k`: Move selection -- `f`: Cycle filter -- `enter`: Pin service -- `?`: Toggle help +- `up` / `down` or `j` / `k`: Move service selection +- `f`: Cycle health filter (`all`, `warning`, `down`, `healthy`) +- `s`: Cycle sort key (`latency`, `errors`, `traffic`, `name`) +- `o`: Toggle sort direction +- `p` or `space`: Pause/resume live updates +- `enter`: Pin/unpin selected service +- `d`: Toggle debug counters panel - `q`: Quit + +## What This Template Demonstrates + +- A live-updating operations dashboard using `ui.table` with stable row keys and immutable row patches to keep updates smooth. +- Production-style controls for filtering, sorting, pinning, and pausing metrics. +- An incident stream plus optional debug telemetry (update cadence and staleness) for observability workflows. + +## Key Code Patterns + +- Live update simulation and lifecycle cleanup in `src/main.ts` (`simulateTick`, interval setup/teardown). +- No-flicker table update pattern in `src/main.ts` (`ui.table` + `getRowKey` + immutable service updates). +- Interaction/state transitions in `src/main.ts` (`app.keys`, `moveSelection`, filter/sort helpers). diff --git a/packages/create-rezi/templates/dashboard/package.json b/packages/create-rezi/templates/dashboard/package.json index 7533b015..ab1efe52 100644 --- a/packages/create-rezi/templates/dashboard/package.json +++ b/packages/create-rezi/templates/dashboard/package.json @@ -6,11 +6,12 @@ "scripts": { "start": "tsx src/main.ts", "dev": "tsx watch src/main.ts", + "build": "tsc --pretty false", "typecheck": "tsc --noEmit" }, "dependencies": { - "@rezi-ui/core": "^0.1.0-alpha.0", - "@rezi-ui/node": "^0.1.0-alpha.0" + "@rezi-ui/core": "^0.1.0-alpha.11", + "@rezi-ui/node": "^0.1.0-alpha.11" }, "devDependencies": { "@types/node": "^22.13.1", diff --git a/packages/create-rezi/templates/dashboard/src/main.ts b/packages/create-rezi/templates/dashboard/src/main.ts index 66734de5..d7ea6e9f 100644 --- a/packages/create-rezi/templates/dashboard/src/main.ts +++ b/packages/create-rezi/templates/dashboard/src/main.ts @@ -1,83 +1,217 @@ -import { rgb, ui } from "@rezi-ui/core"; -import { createNodeApp } from "@rezi-ui/node"; +import type { BadgeVariant, TableColumn, VNode } from "@rezi-ui/core"; +import { createApp, rgb, ui } from "@rezi-ui/core"; +import { createNodeBackend } from "@rezi-ui/node"; type ServiceStatus = "healthy" | "warning" | "down"; +type Filter = "all" | ServiceStatus; +type SortKey = "name" | "latencyMs" | "errorRate" | "trafficRpm"; +type SortDirection = "asc" | "desc"; type Service = { + id: string; name: string; region: string; + tier: "edge" | "core" | "stateful"; status: ServiceStatus; latencyMs: number; errorRate: number; + trafficRpm: number; + saturation: number; + history: readonly number[]; }; -const services: readonly Service[] = [ - { name: "Auth Gateway", region: "us-east", status: "healthy", latencyMs: 18, errorRate: 0.2 }, - { name: "Billing", region: "us-west", status: "warning", latencyMs: 74, errorRate: 1.7 }, - { name: "Search", region: "eu-central", status: "healthy", latencyMs: 32, errorRate: 0.4 }, - { name: "Realtime", region: "ap-south", status: "warning", latencyMs: 96, errorRate: 2.1 }, - { name: "Exports", region: "us-east", status: "down", latencyMs: 0, errorRate: 9.4 }, -]; - -const incidents = [ - "Exports queue is backing up (ETA 15m)", - "Realtime jitter above 90ms in ap-south", - "Billing retries spiked 2x from baseline", -]; - -const activity = [ - "Deploy #491 rolled to 50%", - "Cache warm-up completed", - "Latency budget tightened to 80ms", - "Tracing sampling bumped to 20%", -]; - -type Filter = "all" | "healthy" | "warning" | "down"; +type Incident = { + id: number; + at: string; + severity: "info" | "warn" | "critical"; + message: string; +}; type State = { - selected: number; + services: readonly Service[]; + selectedId: string; + pinnedId: string | null; filter: Filter; - pinned: string | null; - showHelp: boolean; + sort: SortKey; + sortDirection: SortDirection; + paused: boolean; + debug: boolean; + ticks: number; + updatesApplied: number; + incidents: readonly Incident[]; + nextIncidentId: number; + startedAt: number; + lastUpdateAt: number; +}; + +function timeStamp(date = new Date()): string { + return date.toLocaleTimeString("en-US", { hour12: false }); +} + +const seedServices = [ + { + id: "auth", + name: "Auth Gateway", + region: "us-east-1", + tier: "edge", + status: "healthy", + latencyMs: 22, + errorRate: 0.18, + trafficRpm: 14250, + saturation: 41, + }, + { + id: "billing", + name: "Billing API", + region: "us-west-2", + tier: "stateful", + status: "warning", + latencyMs: 84, + errorRate: 1.12, + trafficRpm: 7340, + saturation: 66, + }, + { + id: "search", + name: "Search Index", + region: "eu-central-1", + tier: "core", + status: "healthy", + latencyMs: 36, + errorRate: 0.32, + trafficRpm: 9860, + saturation: 52, + }, + { + id: "realtime", + name: "Realtime Fanout", + region: "ap-south-1", + tier: "core", + status: "warning", + latencyMs: 98, + errorRate: 1.92, + trafficRpm: 12320, + saturation: 79, + }, + { + id: "exports", + name: "Export Workers", + region: "us-east-1", + tier: "stateful", + status: "down", + latencyMs: 0, + errorRate: 8.4, + trafficRpm: 640, + saturation: 97, + }, + { + id: "notify", + name: "Notification Bus", + region: "eu-west-1", + tier: "edge", + status: "healthy", + latencyMs: 31, + errorRate: 0.27, + trafficRpm: 8110, + saturation: 48, + }, +] as const satisfies readonly Omit[]; + +const initialServices: readonly Service[] = Object.freeze( + seedServices.map((service) => ({ + ...service, + history: Object.freeze(Array.from({ length: 18 }, () => service.latencyMs)), + })), +); + +const initialIncidents: readonly Incident[] = Object.freeze([ + { + id: 1, + at: timeStamp(), + severity: "critical", + message: "Export Workers entered fail-safe mode after queue timeout.", + }, + { + id: 2, + at: timeStamp(), + severity: "warn", + message: "Realtime Fanout jitter exceeded 90 ms in ap-south-1.", + }, + { + id: 3, + at: timeStamp(), + severity: "info", + message: "Deploy #491 fully rolled out with canary checks passing.", + }, +]); + +const colors = { + accent: rgb(96, 214, 255), + accentSoft: rgb(130, 176, 255), + muted: rgb(134, 148, 176), + text: rgb(214, 225, 245), + panel: rgb(13, 20, 33), + panelAlt: rgb(18, 28, 44), + panelBorder: rgb(62, 78, 107), + footer: rgb(8, 13, 22), + healthy: rgb(110, 223, 159), + warning: rgb(255, 196, 108), + down: rgb(255, 119, 139), + info: rgb(123, 194, 255), }; -const app = createNodeApp({ +const app = createApp({ + backend: createNodeBackend(), initialState: { - selected: 0, + services: initialServices, + selectedId: initialServices[0]?.id ?? "", + pinnedId: null, filter: "all", - pinned: null, - showHelp: false, + sort: "latencyMs", + sortDirection: "desc", + paused: false, + debug: false, + ticks: 0, + updatesApplied: 0, + incidents: initialIncidents, + nextIncidentId: 4, + startedAt: Date.now(), + lastUpdateAt: Date.now(), }, }); -const colors = { - accent: rgb(120, 200, 255), - muted: rgb(140, 150, 170), - panel: rgb(18, 22, 34), - panelAlt: rgb(22, 28, 44), - ok: rgb(120, 220, 160), - warn: rgb(255, 190, 120), - down: rgb(255, 120, 140), - ink: rgb(10, 14, 24), - inkSoft: rgb(30, 36, 54), -}; +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function round2(value: number): number { + return Math.round(value * 100) / 100; +} + +function formatTraffic(rpm: number): string { + if (rpm >= 1000) return `${(rpm / 1000).toFixed(1)}k`; + return `${rpm}`; +} function statusColor(status: ServiceStatus) { - if (status === "healthy") return colors.ok; - if (status === "warning") return colors.warn; + if (status === "healthy") return colors.healthy; + if (status === "warning") return colors.warning; return colors.down; } -function filterServices(filter: Filter): Service[] { - if (filter === "all") return [...services]; - return services.filter((svc) => svc.status === filter); +function severityColor(severity: Incident["severity"]) { + if (severity === "critical") return colors.down; + if (severity === "warn") return colors.warning; + return colors.info; } -function clamp(value: number, min: number, max: number) { - return Math.max(min, Math.min(max, value)); +function statusBadge(status: ServiceStatus): { text: string; variant: BadgeVariant } { + if (status === "healthy") return { text: "Healthy", variant: "success" }; + if (status === "warning") return { text: "At Risk", variant: "warning" }; + return { text: "Critical", variant: "error" }; } -function panel(title: string, children: ReturnType[], flex = 1) { +function panel(title: string, children: readonly VNode[], flex = 1): VNode { return ui.box( { title, @@ -91,168 +225,514 @@ function panel(title: string, children: ReturnType[], flex = 1 ); } +function applyFilter(services: readonly Service[], filter: Filter): Service[] { + if (filter === "all") return [...services]; + return services.filter((service) => service.status === filter); +} + +function sortServices( + services: readonly Service[], + sort: SortKey, + direction: SortDirection, +): Service[] { + const directionWeight = direction === "asc" ? 1 : -1; + const sorted = [...services].sort((a, b) => { + let baseComparison = 0; + + if (sort === "name") { + baseComparison = a.name.localeCompare(b.name); + } else if (sort === "latencyMs") { + baseComparison = a.latencyMs - b.latencyMs; + } else if (sort === "errorRate") { + baseComparison = a.errorRate - b.errorRate; + } else { + baseComparison = a.trafficRpm - b.trafficRpm; + } + + if (baseComparison === 0) { + baseComparison = a.name.localeCompare(b.name); + } + + return baseComparison * directionWeight; + }); + + return sorted; +} + +function visibleServicesFor( + services: readonly Service[], + filter: Filter, + sort: SortKey, + direction: SortDirection, +): Service[] { + return sortServices(applyFilter(services, filter), sort, direction); +} + +function selectedFromVisible(visible: readonly Service[], selectedId: string): Service | undefined { + return visible.find((service) => service.id === selectedId) ?? visible[0]; +} + +function moveSelection(state: State, delta: number): State { + const visible = visibleServicesFor(state.services, state.filter, state.sort, state.sortDirection); + if (visible.length === 0) return state; + + const currentIndex = visible.findIndex((service) => service.id === state.selectedId); + const nextIndex = clamp((currentIndex < 0 ? 0 : currentIndex) + delta, 0, visible.length - 1); + const next = visible[nextIndex]; + if (!next) return state; + + return { ...state, selectedId: next.id }; +} + +function cycleFilter(filter: Filter): Filter { + if (filter === "all") return "warning"; + if (filter === "warning") return "down"; + if (filter === "down") return "healthy"; + return "all"; +} + +function defaultDirectionForSort(sort: SortKey): SortDirection { + return sort === "name" ? "asc" : "desc"; +} + +function cycleSort(sort: SortKey): SortKey { + const sequence: readonly SortKey[] = ["latencyMs", "errorRate", "trafficRpm", "name"]; + const index = sequence.indexOf(sort); + const nextIndex = index < 0 ? 0 : (index + 1) % sequence.length; + const fallback: SortKey = "latencyMs"; + return sequence[nextIndex] ?? fallback; +} + +function toSortKey(value: string): SortKey | null { + if (value === "name") return "name"; + if (value === "latencyMs") return "latencyMs"; + if (value === "errorRate") return "errorRate"; + if (value === "trafficRpm") return "trafficRpm"; + return null; +} + +function deriveStatus(latencyMs: number, errorRate: number, saturation: number): ServiceStatus { + if (errorRate >= 4.2 || saturation >= 95 || latencyMs >= 180) { + return "down"; + } + if (errorRate >= 1.4 || saturation >= 82 || latencyMs >= 95) { + return "warning"; + } + return "healthy"; +} + +function pushHistory(history: readonly number[], latencyMs: number): readonly number[] { + const next = [...history, latencyMs].slice(-18); + return Object.freeze(next); +} + +function simulateTick(state: State): State { + const nextTick = state.ticks + 1; + if (state.paused) { + return { ...state, ticks: nextTick }; + } + + let nextIncidentId = state.nextIncidentId; + const freshIncidents: Incident[] = []; + + const addIncident = (severity: Incident["severity"], message: string): void => { + freshIncidents.push({ + id: nextIncidentId, + at: timeStamp(), + severity, + message, + }); + nextIncidentId += 1; + }; + + const nextServices = state.services.map((service, index) => { + const wave = Math.sin(nextTick * 0.65 + index * 1.7); + const burst = Math.cos(nextTick * 0.35 + index * 0.9); + + const latencyMs = clamp( + Math.round(service.latencyMs + wave * 8 + burst * 3 + (Math.random() - 0.5) * 14), + 12, + 280, + ); + + const errorRate = clamp( + round2(service.errorRate + wave * 0.16 + (Math.random() - 0.5) * 0.28), + 0.05, + 9.9, + ); + + const trafficRpm = clamp( + Math.round(service.trafficRpm + burst * 380 + (Math.random() - 0.5) * 560), + 400, + 32000, + ); + + const saturation = clamp( + Math.round(service.saturation + wave * 4 + (Math.random() - 0.5) * 6), + 20, + 99, + ); + + const status = deriveStatus(latencyMs, errorRate, saturation); + + if (status !== service.status) { + const transition = `${service.name} ${service.status.toUpperCase()} -> ${status.toUpperCase()}`; + addIncident( + status === "down" ? "critical" : status === "warning" ? "warn" : "info", + transition, + ); + } + + if (latencyMs >= 150 && status !== "down" && Math.random() > 0.9) { + addIncident("warn", `${service.name} crossed ${latencyMs} ms latency budget.`); + } + + return { + ...service, + status, + latencyMs, + errorRate, + trafficRpm, + saturation, + history: pushHistory(service.history, latencyMs), + }; + }); + + if (nextTick % 7 === 0 && nextServices.length > 0) { + const hottest = nextServices.reduce((current, candidate) => + candidate.saturation > current.saturation ? candidate : current, + ); + addIncident("info", `${hottest.name} saturation now ${hottest.saturation}%.`); + } + + const visible = visibleServicesFor(nextServices, state.filter, state.sort, state.sortDirection); + const selected = selectedFromVisible(visible, state.selectedId) ?? nextServices[0]; + + return { + ...state, + services: nextServices, + selectedId: selected ? selected.id : state.selectedId, + ticks: nextTick, + updatesApplied: state.updatesApplied + 1, + incidents: Object.freeze([...freshIncidents, ...state.incidents].slice(0, 10)), + nextIncidentId, + lastUpdateAt: Date.now(), + }; +} + app.view((state) => { - const visible = filterServices(state.filter); - const selected = visible[state.selected] ?? visible[0] ?? services[0]; - const pinned = state.pinned ?? selected?.name ?? "-"; + const visible = visibleServicesFor(state.services, state.filter, state.sort, state.sortDirection); + const selected = selectedFromVisible(visible, state.selectedId) ?? state.services[0]; + const pinned = state.pinnedId + ? (state.services.find((service) => service.id === state.pinnedId) ?? null) + : null; + + const healthyCount = state.services.filter((service) => service.status === "healthy").length; + const warningCount = state.services.filter((service) => service.status === "warning").length; + const downCount = state.services.filter((service) => service.status === "down").length; + + const overallStatus: ServiceStatus = + downCount > 0 ? "down" : warningCount > 0 ? "warning" : "healthy"; + + const overallBadge = statusBadge(overallStatus); + + const uptimeSeconds = Math.max(1, Math.floor((Date.now() - state.startedAt) / 1000)); + const updateRate = state.updatesApplied / uptimeSeconds; + const stalenessMs = Date.now() - state.lastUpdateAt; + + const tableColumns: readonly TableColumn[] = [ + { + key: "name", + header: "Service", + flex: 2, + minWidth: 18, + sortable: true, + render: (_, row) => { + const pinnedRow = row.id === state.pinnedId; + return ui.text(`${pinnedRow ? "* " : ""}${row.name}`, { + fg: pinnedRow ? colors.accent : colors.text, + bold: pinnedRow, + }); + }, + }, + { + key: "region", + header: "Region", + width: 13, + overflow: "clip", + render: (_, row) => ui.text(row.region, { fg: colors.muted }), + }, + { + key: "status", + header: "Status", + width: 10, + render: (_, row) => + ui.text(row.status.toUpperCase(), { fg: statusColor(row.status), bold: true }), + }, + { + key: "latencyMs", + header: "P95 ms", + width: 8, + align: "right", + sortable: true, + render: (_, row) => + ui.text(`${row.latencyMs}`, { + fg: row.latencyMs >= 110 ? colors.warning : colors.text, + }), + }, + { + key: "errorRate", + header: "Err %", + width: 8, + align: "right", + sortable: true, + render: (_, row) => + ui.text(row.errorRate.toFixed(2), { + fg: row.errorRate >= 1.5 ? colors.down : colors.text, + }), + }, + { + key: "trafficRpm", + header: "RPM", + width: 8, + align: "right", + sortable: true, + render: (_, row) => ui.text(formatTraffic(row.trafficRpm), { fg: colors.text }), + }, + ]; return ui.column({ flex: 1, p: 1, gap: 1, items: "stretch" }, [ ui.row({ justify: "between", items: "center" }, [ - ui.text("__APP_NAME__", { fg: colors.accent, bold: true }), - ui.row({ gap: 2, items: "center" }, [ - ui.text("Filter", { fg: colors.muted }), - ui.text(state.filter.toUpperCase(), { fg: colors.accent, bold: true }), - ui.text("Pinned", { fg: colors.muted }), - ui.text(pinned, { fg: colors.ok, bold: true }), + ui.row({ gap: 1, items: "center" }, [ + ui.text("__APP_NAME__", { fg: colors.accent, bold: true }), + ui.badge("Live Ops", { variant: "info" }), + ui.badge(state.paused ? "Paused" : "Streaming", { + variant: state.paused ? "warning" : "success", + }), + ]), + ui.row({ gap: 1, items: "center" }, [ + ui.text("Cluster", { fg: colors.muted }), + ui.badge(overallBadge.text, { variant: overallBadge.variant }), + ui.text(`Pinned: ${pinned?.name ?? "-"}`, { fg: colors.text }), ]), ]), + ui.box( + { + border: "rounded", + px: 1, + py: 0, + style: { bg: colors.panelAlt, fg: colors.muted }, + }, + [ + ui.row({ justify: "between", items: "center" }, [ + ui.row({ gap: 2 }, [ + ui.text(`Healthy ${healthyCount}`, { fg: colors.healthy, bold: true }), + ui.text(`Warning ${warningCount}`, { fg: colors.warning, bold: true }), + ui.text(`Down ${downCount}`, { fg: colors.down, bold: true }), + ui.text(`Total ${state.services.length}`, { fg: colors.text }), + ]), + ui.row({ gap: 2 }, [ + ui.text(`Filter ${state.filter.toUpperCase()}`, { fg: colors.accentSoft }), + ui.text(`Sort ${state.sort} (${state.sortDirection})`, { fg: colors.accentSoft }), + ]), + ]), + ], + ), + ui.row({ flex: 1, gap: 1, items: "stretch" }, [ panel( - "Services", + "Service Matrix", [ - ui.column( - { gap: 0 }, - visible.map((svc, index) => { - const active = index === state.selected; - return ui.row( - { - key: svc.name, - gap: 1, - style: active ? { bg: colors.inkSoft, fg: colors.accent } : { fg: colors.muted }, - }, - [ - ui.text(active ? ">" : " "), - ui.text(svc.name, { bold: active }), - ui.text(`(${svc.region})`, { fg: colors.muted }), - ui.text(svc.status.toUpperCase(), { - fg: statusColor(svc.status), - bold: true, - }), - ], - ); - }), - ), + ui.table({ + id: "service-table", + columns: tableColumns, + data: visible, + getRowKey: (row) => row.id, + selection: selected ? [selected.id] : [], + selectionMode: "single", + onSelectionChange: (keys) => { + const nextId = keys[0]; + if (!nextId) return; + app.update((s) => ({ ...s, selectedId: nextId })); + }, + onRowPress: (row) => { + app.update((s) => ({ ...s, selectedId: row.id, pinnedId: row.id })); + }, + sortColumn: state.sort, + sortDirection: state.sortDirection, + onSort: (column, direction) => { + const nextSort = toSortKey(column); + if (!nextSort) return; + app.update((s) => { + const nextVisible = visibleServicesFor(s.services, s.filter, nextSort, direction); + const nextSelected = selectedFromVisible(nextVisible, s.selectedId); + return { + ...s, + sort: nextSort, + sortDirection: direction, + selectedId: nextSelected ? nextSelected.id : s.selectedId, + }; + }); + }, + stripeStyle: { odd: colors.panelAlt }, + borderStyle: { variant: "rounded", color: colors.panelBorder }, + }), ], - 1, + 2, ), - panel( - "Service Health", - [ - ui.column({ gap: 1 }, [ - ui.text(selected ? selected.name : "-", { fg: colors.accent, bold: true }), - ui.row({ gap: 2 }, [ - ui.text(`Latency: ${selected ? selected.latencyMs : "-"} ms`), - ui.text(`Errors: ${selected ? selected.errorRate : "-"}%`, { - fg: selected && selected.errorRate > 2 ? colors.warn : colors.muted, + ui.column({ flex: 1, gap: 1, items: "stretch" }, [ + panel( + "Selected Service", + [ + ui.column({ gap: 1 }, [ + ui.row({ gap: 1, items: "center" }, [ + ui.text(selected?.name ?? "-", { fg: colors.accent, bold: true }), + ui.badge(statusBadge(selected?.status ?? "healthy").text, { + variant: statusBadge(selected?.status ?? "healthy").variant, + }), + ]), + ui.text(`Region: ${selected?.region ?? "-"}`), + ui.text(`Tier: ${selected?.tier ?? "-"}`), + ui.text(`Latency: ${selected?.latencyMs ?? "-"} ms`), + ui.text(`Error rate: ${selected?.errorRate.toFixed(2) ?? "-"}%`), + ui.text(`Saturation: ${selected?.saturation ?? "-"}%`, { + fg: (selected?.saturation ?? 0) >= 82 ? colors.warning : colors.text, + }), + ui.divider({ char: "-" }), + ui.text("Latency trend", { fg: colors.muted }), + ui.sparkline(selected?.history ?? [0], { + width: 22, + min: 0, + max: 220, + style: { fg: colors.accent }, }), ]), - ui.divider({ char: "-" }), - ui.text("Active incidents", { fg: colors.muted }), - ...incidents.map((line) => ui.text(`- ${line}`)), - ]), - ], - 2, - ), - - panel( - "Activity", - [ - ui.column({ gap: 1 }, [ - ui.text("Recent deploys", { fg: colors.muted }), - ...activity.map((line) => ui.text(`- ${line}`)), - ui.divider({ char: "-" }), - ui.text("Escalations", { fg: colors.muted }), - ui.text("- On-call rotation starts in 2h"), - ui.text("- 3 alerts awaiting acknowledgement"), - ]), - ], - 1, - ), - ]), + ], + 2, + ), - state.showHelp - ? ui.box( - { - border: "rounded", - px: 1, - py: 0, - style: { bg: colors.panelAlt, fg: colors.muted }, - }, + panel( + state.debug ? "Incident Feed + Debug" : "Incident Feed", [ - ui.row({ gap: 1 }, [ - ui.kbd(["up", "down"]), - ui.text("Move"), - ui.kbd("f"), - ui.text("Filter"), - ui.kbd("enter"), - ui.text("Pin"), - ui.kbd("?"), - ui.text("Help"), - ui.kbd("q"), - ui.text("Quit"), + ui.column({ gap: 1 }, [ + ...(state.incidents.length === 0 + ? [ui.text("No incidents", { fg: colors.muted })] + : state.incidents.slice(0, 6).map((incident) => + ui.text(`[${incident.at}] ${incident.message}`, { + fg: severityColor(incident.severity), + }), + )), + state.debug + ? ui.column({ gap: 1 }, [ + ui.divider({ char: "-" }), + ui.text("Debug", { fg: colors.accentSoft, bold: true }), + ui.text(`Ticks: ${state.ticks}`), + ui.text(`Applied updates: ${state.updatesApplied}`), + ui.text(`Update cadence: ${updateRate.toFixed(2)} Hz`), + ui.text(`Last update age: ${stalenessMs} ms`), + ui.text("No-flicker pattern: stable row keys + immutable row patching.", { + fg: colors.muted, + }), + ]) + : ui.text("Press d to show render/debug counters.", { fg: colors.muted }), ]), ], - ) - : ui.box({ px: 1, py: 0, style: { bg: colors.ink, fg: colors.muted } }, [ - ui.row({ justify: "between", items: "center" }, [ - ui.text("Status: nominal"), - ui.row({ gap: 1 }, [ - ui.kbd("up/down"), - ui.text("Move"), - ui.kbd("f"), - ui.text("Filter"), - ui.kbd("?"), - ui.text("Help"), - ]), - ]), + 2, + ), + ]), + ]), + + ui.box({ px: 1, py: 0, style: { bg: colors.footer, fg: colors.muted } }, [ + ui.row({ justify: "between", items: "center" }, [ + ui.text(state.paused ? "Live updates paused" : "Live updates running"), + ui.row({ gap: 1 }, [ + ui.kbd(["up", "down"]), + ui.text("Select"), + ui.kbd("f"), + ui.text("Filter"), + ui.kbd("s"), + ui.text("Sort"), + ui.kbd("o"), + ui.text("Order"), + ui.kbd(["p", "space"]), + ui.text("Pause"), + ui.kbd("enter"), + ui.text("Pin"), + ui.kbd("d"), + ui.text("Debug"), + ui.kbd("q"), + ui.text("Quit"), ]), + ]), + ]), ]); }); app.keys({ q: () => app.stop(), "ctrl+c": () => app.stop(), - up: () => - app.update((s) => { - const list = filterServices(s.filter); - return { ...s, selected: clamp(s.selected - 1, 0, Math.max(0, list.length - 1)) }; - }), - down: () => + up: () => app.update((s) => moveSelection(s, -1)), + down: () => app.update((s) => moveSelection(s, 1)), + k: () => app.update((s) => moveSelection(s, -1)), + j: () => app.update((s) => moveSelection(s, 1)), + f: () => app.update((s) => { - const list = filterServices(s.filter); - return { ...s, selected: clamp(s.selected + 1, 0, Math.max(0, list.length - 1)) }; + const filter = cycleFilter(s.filter); + const visible = visibleServicesFor(s.services, filter, s.sort, s.sortDirection); + const selected = selectedFromVisible(visible, s.selectedId); + return { + ...s, + filter, + selectedId: selected ? selected.id : s.selectedId, + }; }), - k: () => + s: () => app.update((s) => { - const list = filterServices(s.filter); - return { ...s, selected: clamp(s.selected - 1, 0, Math.max(0, list.length - 1)) }; + const sort = cycleSort(s.sort); + const sortDirection = defaultDirectionForSort(sort); + const visible = visibleServicesFor(s.services, s.filter, sort, sortDirection); + const selected = selectedFromVisible(visible, s.selectedId); + return { + ...s, + sort, + sortDirection, + selectedId: selected ? selected.id : s.selectedId, + }; }), - j: () => + o: () => app.update((s) => { - const list = filterServices(s.filter); - return { ...s, selected: clamp(s.selected + 1, 0, Math.max(0, list.length - 1)) }; - }), - f: () => - app.update((s) => { - const next: Filter = - s.filter === "all" - ? "warning" - : s.filter === "warning" - ? "healthy" - : s.filter === "healthy" - ? "down" - : "all"; - return { ...s, filter: next, selected: 0 }; + const sortDirection: SortDirection = s.sortDirection === "asc" ? "desc" : "asc"; + const visible = visibleServicesFor(s.services, s.filter, s.sort, sortDirection); + const selected = selectedFromVisible(visible, s.selectedId); + return { + ...s, + sortDirection, + selectedId: selected ? selected.id : s.selectedId, + }; }), + p: () => app.update((s) => ({ ...s, paused: !s.paused })), + space: () => app.update((s) => ({ ...s, paused: !s.paused })), enter: () => app.update((s) => { - const list = filterServices(s.filter); - const svc = list[s.selected]; - return { ...s, pinned: svc ? svc.name : s.pinned }; + const visible = visibleServicesFor(s.services, s.filter, s.sort, s.sortDirection); + const selected = selectedFromVisible(visible, s.selectedId); + if (!selected) return s; + return { + ...s, + pinnedId: s.pinnedId === selected.id ? null : selected.id, + }; }), - "?": () => app.update((s) => ({ ...s, showHelp: !s.showHelp })), + d: () => app.update((s) => ({ ...s, debug: !s.debug })), }); -await app.start(); +const ticker = setInterval(() => { + app.update((state) => simulateTick(state)); +}, 900); + +try { + await app.start(); +} finally { + clearInterval(ticker); +} diff --git a/packages/create-rezi/templates/file-browser/README.md b/packages/create-rezi/templates/file-browser/README.md index 703fc103..a03f507d 100644 --- a/packages/create-rezi/templates/file-browser/README.md +++ b/packages/create-rezi/templates/file-browser/README.md @@ -5,14 +5,33 @@ Scaffolded with `npm create rezi` using the **__TEMPLATE_LABEL__** template. ## Quickstart ```bash +# npm npm install -npm start +npm run start + +# bun +bun install +bun run start ``` -## Keybindings +## Controls -- `up` / `down` or `j` / `k`: Move selection -- `enter`: Open directory / preview file -- `backspace`: Go up +- `up` / `down` or `j` / `k`: Move file selection +- `enter`: Open directory or file +- `backspace`: Go to parent directory - `h`: Toggle hidden files +- `ctrl+p`: Open command palette +- In palette: `tab` cycles sources, `>` switches to command mode, `enter` selects, `esc` closes - `q`: Quit + +## What This Template Demonstrates + +- A command palette with async file search results and explicit request cancellation via `AbortController`. +- Mixed palette sources (files + commands) with typed payload routing to navigation/actions. +- Search telemetry (requests, completed, cancelled, last latency) surfaced in the UI for debugging async flows. + +## Key Code Patterns + +- Async search/cancellation pipeline in `src/main.ts` (`searchPaletteFiles`, `waitWithAbort`, `cancelActiveSearch`). +- Palette result handling and command routing in `src/main.ts` (`parsePaletteData`, `openPathFromPalette`, `applyPaletteAction`). +- Browser table + local navigation state updates in `src/main.ts` (`listEntriesAt`, `openEntry`, `ui.table`). diff --git a/packages/create-rezi/templates/file-browser/package.json b/packages/create-rezi/templates/file-browser/package.json index 7533b015..ab1efe52 100644 --- a/packages/create-rezi/templates/file-browser/package.json +++ b/packages/create-rezi/templates/file-browser/package.json @@ -6,11 +6,12 @@ "scripts": { "start": "tsx src/main.ts", "dev": "tsx watch src/main.ts", + "build": "tsc --pretty false", "typecheck": "tsc --noEmit" }, "dependencies": { - "@rezi-ui/core": "^0.1.0-alpha.0", - "@rezi-ui/node": "^0.1.0-alpha.0" + "@rezi-ui/core": "^0.1.0-alpha.11", + "@rezi-ui/node": "^0.1.0-alpha.11" }, "devDependencies": { "@types/node": "^22.13.1", diff --git a/packages/create-rezi/templates/file-browser/src/main.ts b/packages/create-rezi/templates/file-browser/src/main.ts index 312c1978..193b0a81 100644 --- a/packages/create-rezi/templates/file-browser/src/main.ts +++ b/packages/create-rezi/templates/file-browser/src/main.ts @@ -1,7 +1,9 @@ -import { rgb, ui } from "@rezi-ui/core"; -import { createNodeApp } from "@rezi-ui/node"; +import type { CommandItem, CommandSource, TableColumn, VNode } from "@rezi-ui/core"; +import { createApp, rgb, ui } from "@rezi-ui/core"; +import { createNodeBackend } from "@rezi-ui/node"; type EntryType = "dir" | "file"; +type PaletteAction = "toggle-hidden" | "go-root" | "go-parent" | "reveal-opened"; type Entry = { name: string; @@ -11,47 +13,136 @@ type Entry = { preview: string; }; -type Tree = Record; +type Tree = Record; + +type PaletteData = + | { + kind: "path"; + path: string; + entryType: EntryType; + } + | { + kind: "action"; + action: PaletteAction; + }; + +type PaletteStats = { + requests: number; + completed: number; + cancelled: number; + lastDurationMs: number; + activeRequestId: number | null; +}; + +type PaletteState = { + open: boolean; + query: string; + selectedIndex: number; + loading: boolean; + lastAction: string; + stats: PaletteStats; +}; + +type State = { + path: string; + selected: number; + showHidden: boolean; + opened: string | null; + palette: PaletteState; +}; + +type SearchCandidate = { + path: string; + name: string; + type: EntryType; + nameLower: string; + pathLower: string; +}; const tree: Tree = { "/": [ { name: "src", type: "dir", size: "-", modified: "2026-02-10", preview: "" }, { name: "docs", type: "dir", size: "-", modified: "2026-02-08", preview: "" }, + { name: "scripts", type: "dir", size: "-", modified: "2026-02-07", preview: "" }, { name: "package.json", type: "file", size: "2 KB", modified: "2026-02-07", - preview: '{\n "name": "__APP_NAME__"\n}', + preview: '{\n "name": "__APP_NAME__",\n "private": true\n}', + }, + { + name: "README.md", + type: "file", + size: "3 KB", + modified: "2026-02-06", + preview: "# __APP_NAME__\\nProject documentation.", }, { name: ".env", type: "file", size: "1 KB", modified: "2026-02-05", preview: "API_KEY=***" }, + { + name: ".gitignore", + type: "file", + size: "1 KB", + modified: "2026-02-05", + preview: "node_modules\\ndist\\n.env", + }, ], "/src": [ { name: "main.ts", type: "file", - size: "4 KB", + size: "5 KB", modified: "2026-02-10", preview: "import { createApp } from '@rezi-ui/core'", }, { name: "components", type: "dir", size: "-", modified: "2026-02-09", preview: "" }, { name: "styles", type: "dir", size: "-", modified: "2026-02-09", preview: "" }, + { + name: "search.ts", + type: "file", + size: "2 KB", + modified: "2026-02-09", + preview: "export function fuzzySearch(query: string) {}", + }, ], "/src/components": [ { name: "Panel.ts", type: "file", - size: "1 KB", + size: "2 KB", modified: "2026-02-09", preview: "export function Panel() {}", }, { name: "StatusBar.ts", type: "file", - size: "1 KB", + size: "2 KB", modified: "2026-02-09", preview: "export function StatusBar() {}", }, + { + name: "Palette.ts", + type: "file", + size: "3 KB", + modified: "2026-02-09", + preview: "export function Palette() {}", + }, + ], + "/src/styles": [ + { + name: "tokens.ts", + type: "file", + size: "2 KB", + modified: "2026-02-08", + preview: "export const colors = {}", + }, + { + name: "layout.css", + type: "file", + size: "4 KB", + modified: "2026-02-08", + preview: ".layout { display: grid; }", + }, ], "/docs": [ { @@ -62,43 +153,71 @@ const tree: Tree = { preview: "# Overview", }, { name: "usage.md", type: "file", size: "2 KB", modified: "2026-02-08", preview: "# Usage" }, + { + name: "architecture.md", + type: "file", + size: "5 KB", + modified: "2026-02-07", + preview: "# Architecture", + }, ], - "/src/styles": [ + "/scripts": [ { - name: "tokens.ts", + name: "sync-data.ts", type: "file", size: "2 KB", - modified: "2026-02-08", - preview: "export const colors = {}", + modified: "2026-02-07", + preview: "export async function syncData() {}", + }, + { + name: "seed-db.ts", + type: "file", + size: "2 KB", + modified: "2026-02-07", + preview: "export async function seedDb() {}", }, ], }; const colors = { - accent: rgb(120, 200, 255), - muted: rgb(140, 150, 170), - panel: rgb(18, 22, 34), - panelAlt: rgb(22, 28, 44), - ink: rgb(10, 14, 24), -}; - -type State = { - path: string; - selected: number; - showHidden: boolean; - opened: string | null; + accent: rgb(104, 216, 255), + accentSoft: rgb(138, 176, 255), + muted: rgb(134, 149, 178), + text: rgb(215, 225, 246), + panel: rgb(13, 20, 33), + panelAlt: rgb(18, 28, 44), + panelBorder: rgb(60, 78, 110), + footer: rgb(8, 13, 22), + info: rgb(120, 190, 255), + success: rgb(104, 223, 157), + warning: rgb(255, 194, 108), }; -const app = createNodeApp({ +const app = createApp({ + backend: createNodeBackend(), initialState: { path: "/", selected: 0, showHidden: false, opened: null, + palette: { + open: false, + query: "", + selectedIndex: 0, + loading: false, + lastAction: "Ready", + stats: { + requests: 0, + completed: 0, + cancelled: 0, + lastDurationMs: 0, + activeRequestId: null, + }, + }, }, }); -function clamp(value: number, min: number, max: number) { +function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); } @@ -114,89 +233,689 @@ function parentPath(path: string): string { return `/${parts.join("/")}` || "/"; } -function listEntries(state: State): Entry[] { - const entries = tree[state.path] ?? []; - if (state.showHidden) return entries; +function basename(path: string): string { + const parts = path.split("/").filter(Boolean); + return parts[parts.length - 1] ?? ""; +} + +function listEntriesAt(path: string, showHidden: boolean): Entry[] { + const entries = tree[path] ?? []; + if (showHidden) return [...entries]; return entries.filter((entry) => !entry.name.startsWith(".")); } -function panel(title: string, children: ReturnType[], flex = 1) { +function listEntries(state: Pick): Entry[] { + return listEntriesAt(state.path, state.showHidden); +} + +function panel(title: string, children: readonly VNode[], flex = 1): VNode { return ui.box( - { title, flex, border: "rounded", px: 1, py: 0, style: { bg: colors.panel, fg: colors.muted } }, + { + title, + flex, + border: "rounded", + px: 1, + py: 0, + style: { bg: colors.panel, fg: colors.muted }, + }, children, ); } +function openEntry(state: State, entry: Entry): State { + const fullPath = joinPath(state.path, entry.name); + + if (entry.type === "dir") { + return { + ...state, + path: fullPath, + selected: 0, + palette: { + ...state.palette, + lastAction: `Entered ${fullPath}`, + }, + }; + } + + return { + ...state, + opened: fullPath, + palette: { + ...state.palette, + lastAction: `Opened ${fullPath}`, + }, + }; +} + +function locatePath(path: string): { dir: string; entry: Entry } | null { + const name = basename(path); + if (!name) return null; + + const dir = parentPath(path); + const entries = tree[dir] ?? []; + const entry = entries.find((candidate) => candidate.name === name); + if (!entry) return null; + + return { dir, entry }; +} + +function parsePaletteData(data: unknown): PaletteData | null { + if (typeof data !== "object" || data === null) return null; + + const candidate = data as { + kind?: unknown; + path?: unknown; + entryType?: unknown; + action?: unknown; + }; + + if ( + candidate.kind === "path" && + typeof candidate.path === "string" && + (candidate.entryType === "dir" || candidate.entryType === "file") + ) { + return { + kind: "path", + path: candidate.path, + entryType: candidate.entryType, + }; + } + + if ( + candidate.kind === "action" && + (candidate.action === "toggle-hidden" || + candidate.action === "go-root" || + candidate.action === "go-parent" || + candidate.action === "reveal-opened") + ) { + return { + kind: "action", + action: candidate.action, + }; + } + + return null; +} + +function applyPaletteAction(state: State, action: PaletteAction): State { + if (action === "toggle-hidden") { + const showHidden = !state.showHidden; + const entries = listEntriesAt(state.path, showHidden); + const selected = clamp(state.selected, 0, Math.max(0, entries.length - 1)); + + return { + ...state, + showHidden, + selected, + palette: { + ...state.palette, + lastAction: showHidden ? "Hidden files are now visible" : "Hidden files are now hidden", + }, + }; + } + + if (action === "go-root") { + return { + ...state, + path: "/", + selected: 0, + palette: { + ...state.palette, + lastAction: "Moved to project root", + }, + }; + } + + if (action === "go-parent") { + const nextPath = parentPath(state.path); + return { + ...state, + path: nextPath, + selected: 0, + palette: { + ...state.palette, + lastAction: `Moved to ${nextPath}`, + }, + }; + } + + const openedPath = state.opened; + if (!openedPath) { + return { + ...state, + palette: { + ...state.palette, + lastAction: "No opened file to reveal", + }, + }; + } + + const located = locatePath(openedPath); + if (!located) { + return { + ...state, + palette: { + ...state.palette, + lastAction: "Opened file no longer exists in current tree", + }, + }; + } + + let showHidden = state.showHidden; + let entries = listEntriesAt(located.dir, showHidden); + let selected = entries.findIndex((entry) => entry.name === located.entry.name); + + if (selected < 0 && located.entry.name.startsWith(".")) { + showHidden = true; + entries = listEntriesAt(located.dir, true); + selected = entries.findIndex((entry) => entry.name === located.entry.name); + } + + return { + ...state, + path: located.dir, + showHidden, + selected: selected >= 0 ? selected : 0, + palette: { + ...state.palette, + lastAction: `Revealed ${openedPath}`, + }, + }; +} + +function openPathFromPalette(state: State, path: string, entryType: EntryType): State { + if (entryType === "dir") { + return { + ...state, + path, + selected: 0, + palette: { + ...state.palette, + lastAction: `Navigated to ${path}`, + }, + }; + } + + const located = locatePath(path); + if (!located) { + return { + ...state, + opened: path, + palette: { + ...state.palette, + lastAction: `Opened ${path}`, + }, + }; + } + + const entries = listEntriesAt(located.dir, state.showHidden); + const selected = entries.findIndex((entry) => entry.name === located.entry.name); + + return { + ...state, + path: located.dir, + selected: selected >= 0 ? selected : 0, + opened: path, + palette: { + ...state.palette, + lastAction: `Opened ${path}`, + }, + }; +} + +const searchCandidates: readonly SearchCandidate[] = Object.freeze( + Object.entries(tree).flatMap(([dir, entries]) => + entries.map((entry) => { + const path = joinPath(dir, entry.name); + return { + path, + name: entry.name, + type: entry.type, + nameLower: entry.name.toLowerCase(), + pathLower: path.toLowerCase(), + }; + }), + ), +); + +function scoreCandidate(candidate: SearchCandidate, query: string): number { + if (query.length === 0) { + return candidate.type === "dir" ? 80 : 70; + } + + if (candidate.nameLower === query) return 360; + if (candidate.nameLower.startsWith(query)) return 280 - candidate.name.length; + + const nameIndex = candidate.nameLower.indexOf(query); + if (nameIndex >= 0) return 230 - nameIndex * 3; + + const pathIndex = candidate.pathLower.indexOf(query); + if (pathIndex >= 0) return 170 - Math.min(120, pathIndex * 2); + + let queryIndex = 0; + let streak = 0; + let bonus = 0; + + for (const char of candidate.pathLower) { + if (queryIndex < query.length && char === query[queryIndex]) { + queryIndex += 1; + streak += 1; + bonus += streak * 2; + } else { + streak = 0; + } + } + + if (queryIndex === query.length) { + return 80 + bonus; + } + + return 0; +} + +function buildFileItems(query: string): readonly CommandItem[] { + const normalized = query.trim().toLowerCase(); + const ranked = searchCandidates + .map((candidate) => ({ + candidate, + score: scoreCandidate(candidate, normalized), + })) + .filter((rankedCandidate) => rankedCandidate.score > 0) + .sort((a, b) => { + const scoreDelta = b.score - a.score; + if (scoreDelta !== 0) return scoreDelta; + return a.candidate.path.localeCompare(b.candidate.path); + }) + .slice(0, 16); + + return Object.freeze( + ranked.map(({ candidate }) => ({ + id: `path:${candidate.path}`, + label: candidate.name, + description: candidate.path, + sourceId: "files", + icon: candidate.type === "dir" ? "D" : "F", + data: { + kind: "path", + path: candidate.path, + entryType: candidate.type, + } as PaletteData, + })), + ); +} + +function buildCommandItems(query: string): readonly CommandItem[] { + const lowerQuery = query.trim().toLowerCase(); + + const catalog: readonly CommandItem[] = Object.freeze([ + { + id: "cmd:toggle-hidden", + label: "Toggle hidden files", + description: "Show or hide dot-prefixed entries", + sourceId: "commands", + shortcut: "h", + data: { kind: "action", action: "toggle-hidden" } as PaletteData, + }, + { + id: "cmd:go-root", + label: "Go to project root", + description: "Jump to /", + sourceId: "commands", + shortcut: "g", + data: { kind: "action", action: "go-root" } as PaletteData, + }, + { + id: "cmd:go-parent", + label: "Go to parent directory", + description: "Move one level up", + sourceId: "commands", + shortcut: "backspace", + data: { kind: "action", action: "go-parent" } as PaletteData, + }, + { + id: "cmd:reveal-opened", + label: "Reveal last opened file", + description: "Focus the file currently in preview", + sourceId: "commands", + shortcut: "r", + data: { kind: "action", action: "reveal-opened" } as PaletteData, + }, + ]); + + if (!lowerQuery) return catalog; + + return Object.freeze( + catalog.filter((item) => { + const labelMatch = item.label.toLowerCase().includes(lowerQuery); + const descriptionMatch = item.description?.toLowerCase().includes(lowerQuery) ?? false; + return labelMatch || descriptionMatch; + }), + ); +} + +type ActiveSearch = { + id: number; + controller: AbortController; +}; + +let requestCounter = 0; +let activeSearch: ActiveSearch | null = null; + +function createAbortError(): Error { + const error = new Error("Request cancelled"); + error.name = "AbortError"; + return error; +} + +function isAbortError(error: unknown): boolean { + return error instanceof Error && error.name === "AbortError"; +} + +function waitWithAbort(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(createAbortError()); + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + + const onAbort = () => { + clearTimeout(timeout); + signal.removeEventListener("abort", onAbort); + reject(createAbortError()); + }; + + signal.addEventListener("abort", onAbort, { once: true }); + }); +} + +function cancelActiveSearch(): void { + if (!activeSearch) return; + activeSearch.controller.abort(); + activeSearch = null; +} + +async function searchPaletteFiles(query: string): Promise { + const requestId = ++requestCounter; + + if (activeSearch) { + activeSearch.controller.abort(); + } + + const controller = new AbortController(); + activeSearch = { id: requestId, controller }; + + app.update((state) => ({ + ...state, + palette: { + ...state.palette, + loading: true, + stats: { + ...state.palette.stats, + requests: state.palette.stats.requests + 1, + activeRequestId: requestId, + }, + }, + })); + + const startedAt = Date.now(); + + try { + await waitWithAbort(120 + Math.floor(Math.random() * 260), controller.signal); + const items = buildFileItems(query); + const durationMs = Date.now() - startedAt; + + app.update((state) => { + if (state.palette.stats.activeRequestId !== requestId) return state; + + return { + ...state, + palette: { + ...state.palette, + loading: false, + stats: { + ...state.palette.stats, + completed: state.palette.stats.completed + 1, + lastDurationMs: durationMs, + activeRequestId: null, + }, + }, + }; + }); + + if (activeSearch?.id === requestId) { + activeSearch = null; + } + + return items; + } catch (error) { + if (isAbortError(error)) { + app.update((state) => { + const isActive = state.palette.stats.activeRequestId === requestId; + return { + ...state, + palette: { + ...state.palette, + loading: isActive ? false : state.palette.loading, + stats: { + ...state.palette.stats, + cancelled: state.palette.stats.cancelled + 1, + activeRequestId: isActive ? null : state.palette.stats.activeRequestId, + }, + }, + }; + }); + + if (activeSearch?.id === requestId) { + activeSearch = null; + } + + return Object.freeze([]); + } + + app.update((state) => ({ + ...state, + palette: { + ...state.palette, + loading: false, + lastAction: "Search failed", + stats: { + ...state.palette.stats, + activeRequestId: null, + }, + }, + })); + + if (activeSearch?.id === requestId) { + activeSearch = null; + } + + return Object.freeze([]); + } +} + +const paletteSources: readonly CommandSource[] = Object.freeze([ + { + id: "files", + name: "Files", + getItems: (query) => searchPaletteFiles(query), + }, + { + id: "commands", + name: "Commands", + prefix: ">", + getItems: (query) => buildCommandItems(query), + }, +]); + +function closePalette(): void { + cancelActiveSearch(); + app.update((state) => ({ + ...state, + palette: { + ...state.palette, + open: false, + query: "", + selectedIndex: 0, + loading: false, + stats: { + ...state.palette.stats, + activeRequestId: null, + }, + }, + })); +} + app.view((state) => { const entries = listEntries(state); - const selected = entries[state.selected]; + const selected = entries[state.selected] ?? entries[0]; const preview = selected?.preview || "Select a file to preview."; + const activeRequest = state.palette.stats.activeRequestId + ? `#${state.palette.stats.activeRequestId}` + : "-"; + + const columns: readonly TableColumn[] = [ + { + key: "name", + header: "Name", + flex: 2, + minWidth: 18, + sortable: true, + render: (_, entry) => + ui.text(`${entry.type === "dir" ? "[D]" : "[F]"} ${entry.name}`, { + fg: entry.type === "dir" ? colors.accent : colors.text, + bold: entry.type === "dir", + }), + }, + { + key: "type", + header: "Type", + width: 8, + render: (_, entry) => ui.text(entry.type.toUpperCase(), { fg: colors.muted }), + }, + { + key: "size", + header: "Size", + width: 9, + align: "right", + render: (_, entry) => ui.text(entry.size, { fg: colors.text }), + }, + { + key: "modified", + header: "Modified", + width: 12, + render: (_, entry) => ui.text(entry.modified, { fg: colors.text }), + }, + ]; - return ui.column({ flex: 1, p: 1, gap: 1, items: "stretch" }, [ + const base = ui.column({ flex: 1, p: 1, gap: 1, items: "stretch" }, [ ui.row({ justify: "between", items: "center" }, [ - ui.text("__APP_NAME__", { fg: colors.accent, bold: true }), - ui.text(`Path: ${state.path}`, { fg: colors.muted }), + ui.row({ gap: 1, items: "center" }, [ + ui.text("__APP_NAME__", { fg: colors.accent, bold: true }), + ui.badge("File Browser", { variant: "info" }), + ui.badge(state.palette.open ? "Palette Open" : "Explorer", { + variant: state.palette.open ? "warning" : "success", + }), + ]), + ui.row({ gap: 2, items: "center" }, [ + ui.text(`Path: ${state.path}`, { fg: colors.accentSoft }), + ui.text(`Opened: ${state.opened ?? "-"}`, { fg: colors.text }), + ]), ]), ui.row({ flex: 1, gap: 1, items: "stretch" }, [ panel( - "Files", + "Directory", [ - ui.column( - { gap: 0 }, - entries.map((entry, index) => { - const active = index === state.selected; - const prefix = active ? ">" : " "; - const marker = entry.type === "dir" ? "[D]" : "[F]"; - return ui.text(`${prefix} ${marker} ${entry.name}`, { - key: entry.name, - style: { - fg: active ? colors.accent : colors.muted, - bold: active, - }, - }); - }), - ), - ], - 1, - ), + ui.table({ + id: "file-table", + columns, + data: entries, + getRowKey: (entry) => entry.name, + selection: selected ? [selected.name] : [], + selectionMode: "single", + onSelectionChange: (keys) => { + const key = keys[0]; + if (!key) return; - panel( - "Details", - [ - ui.column({ gap: 1 }, [ - ui.text(selected ? selected.name : "-", { fg: colors.accent, bold: true }), - ui.text(`Type: ${selected?.type ?? "-"}`), - ui.text(`Size: ${selected?.size ?? "-"}`), - ui.text(`Modified: ${selected?.modified ?? "-"}`), - ui.divider({ char: "-" }), - ui.text("Preview", { fg: colors.muted }), - ui.text(preview), - ]), + app.update((current) => { + const currentEntries = listEntries(current); + const nextIndex = currentEntries.findIndex((entry) => entry.name === key); + return nextIndex < 0 ? current : { ...current, selected: nextIndex }; + }); + }, + onRowPress: (entry) => { + app.update((current) => openEntry(current, entry)); + }, + stripeStyle: { odd: colors.panelAlt }, + borderStyle: { variant: "rounded", color: colors.panelBorder }, + }), ], 2, ), - panel( - "Pinned", - [ - ui.column({ gap: 1 }, [ - ui.text("Last opened", { fg: colors.muted }), - ui.text(state.opened ?? "-"), - ui.text(""), - ui.text("Hidden files"), - ui.text(state.showHidden ? "Visible" : "Hidden"), - ]), - ], - 1, - ), + ui.column({ flex: 1, gap: 1, items: "stretch" }, [ + panel( + "Details", + [ + ui.column({ gap: 1 }, [ + ui.text(selected?.name ?? "-", { fg: colors.accent, bold: true }), + ui.text(`Type: ${selected?.type ?? "-"}`), + ui.text(`Size: ${selected?.size ?? "-"}`), + ui.text(`Modified: ${selected?.modified ?? "-"}`), + ui.divider({ char: "-" }), + ui.text("Preview", { fg: colors.muted }), + ui.text(preview), + ]), + ], + 2, + ), + + panel( + "Palette Telemetry", + [ + ui.column({ gap: 1 }, [ + ui.row({ gap: 1 }, [ + ui.text("Hidden", { fg: colors.muted }), + ui.text(state.showHidden ? "Visible" : "Hidden", { + fg: state.showHidden ? colors.warning : colors.text, + }), + ]), + ui.row({ gap: 1 }, [ + ui.text("Requests", { fg: colors.muted }), + ui.text(`${state.palette.stats.requests}`, { fg: colors.text }), + ]), + ui.row({ gap: 1 }, [ + ui.text("Completed", { fg: colors.muted }), + ui.text(`${state.palette.stats.completed}`, { fg: colors.success }), + ]), + ui.row({ gap: 1 }, [ + ui.text("Cancelled", { fg: colors.muted }), + ui.text(`${state.palette.stats.cancelled}`, { fg: colors.warning }), + ]), + ui.row({ gap: 1 }, [ + ui.text("Last latency", { fg: colors.muted }), + ui.text(`${state.palette.stats.lastDurationMs} ms`, { fg: colors.text }), + ]), + ui.row({ gap: 1 }, [ + ui.text("In-flight", { fg: colors.muted }), + ui.text(activeRequest, { fg: colors.info }), + ]), + ui.divider({ char: "-" }), + ui.text(`Last action: ${state.palette.lastAction}`, { fg: colors.accentSoft }), + ]), + ], + 1, + ), + ]), ]), - ui.box({ px: 1, py: 0, style: { bg: colors.ink, fg: colors.muted } }, [ + ui.box({ px: 1, py: 0, style: { bg: colors.footer, fg: colors.muted } }, [ ui.row({ justify: "between", items: "center" }, [ - ui.text("File browser ready"), + ui.text(state.palette.loading ? "Palette searching..." : "Explorer ready"), ui.row({ gap: 1 }, [ - ui.kbd("up"), + ui.kbd(["up", "down"]), ui.text("Move"), ui.kbd("enter"), ui.text("Open"), @@ -204,54 +923,164 @@ app.view((state) => { ui.text("Up"), ui.kbd("h"), ui.text("Hidden"), + ui.kbd("ctrl+p"), + ui.text("Palette"), + ui.kbd("tab"), + ui.text("Source"), + ui.kbd(">"), + ui.text("Commands"), ui.kbd("q"), ui.text("Quit"), ]), ]), ]), ]); + + return ui.layers([ + base, + ui.commandPalette({ + id: "command-palette", + open: state.palette.open, + query: state.palette.query, + selectedIndex: state.palette.selectedIndex, + loading: state.palette.loading, + maxVisible: 12, + placeholder: "Search files or type > for commands", + frameStyle: { + background: colors.panel, + foreground: colors.text, + border: colors.panelBorder, + }, + sources: paletteSources, + onQueryChange: (query) => { + app.update((current) => ({ + ...current, + palette: { + ...current.palette, + query, + selectedIndex: 0, + }, + })); + }, + onSelectionChange: (selectedIndex) => { + app.update((current) => ({ + ...current, + palette: { + ...current.palette, + selectedIndex, + }, + })); + }, + onSelect: (item) => { + const data = parsePaletteData(item.data); + if (!data) return; + + app.update((current) => { + if (data.kind === "path") { + return openPathFromPalette(current, data.path, data.entryType); + } + + return applyPaletteAction(current, data.action); + }); + }, + onClose: () => { + closePalette(); + }, + }), + ]); }); app.keys({ q: () => app.stop(), "ctrl+c": () => app.stop(), up: () => - app.update((s) => { - const entries = listEntries(s); - return { ...s, selected: clamp(s.selected - 1, 0, Math.max(0, entries.length - 1)) }; + app.update((state) => { + if (state.palette.open) return state; + const entries = listEntries(state); + return { + ...state, + selected: clamp(state.selected - 1, 0, Math.max(0, entries.length - 1)), + }; }), down: () => - app.update((s) => { - const entries = listEntries(s); - return { ...s, selected: clamp(s.selected + 1, 0, Math.max(0, entries.length - 1)) }; + app.update((state) => { + if (state.palette.open) return state; + const entries = listEntries(state); + return { + ...state, + selected: clamp(state.selected + 1, 0, Math.max(0, entries.length - 1)), + }; }), k: () => - app.update((s) => { - const entries = listEntries(s); - return { ...s, selected: clamp(s.selected - 1, 0, Math.max(0, entries.length - 1)) }; + app.update((state) => { + if (state.palette.open) return state; + const entries = listEntries(state); + return { + ...state, + selected: clamp(state.selected - 1, 0, Math.max(0, entries.length - 1)), + }; }), j: () => - app.update((s) => { - const entries = listEntries(s); - return { ...s, selected: clamp(s.selected + 1, 0, Math.max(0, entries.length - 1)) }; + app.update((state) => { + if (state.palette.open) return state; + const entries = listEntries(state); + return { + ...state, + selected: clamp(state.selected + 1, 0, Math.max(0, entries.length - 1)), + }; + }), + h: () => + app.update((state) => { + if (state.palette.open) return state; + + const showHidden = !state.showHidden; + const entries = listEntriesAt(state.path, showHidden); + return { + ...state, + showHidden, + selected: clamp(state.selected, 0, Math.max(0, entries.length - 1)), + palette: { + ...state.palette, + lastAction: showHidden ? "Hidden files shown" : "Hidden files hidden", + }, + }; }), - h: () => app.update((s) => ({ ...s, showHidden: !s.showHidden, selected: 0 })), backspace: () => - app.update((s) => ({ - ...s, - path: parentPath(s.path), - selected: 0, - })), + app.update((state) => { + if (state.palette.open) return state; + const nextPath = parentPath(state.path); + return { + ...state, + path: nextPath, + selected: 0, + palette: { + ...state.palette, + lastAction: `Moved to ${nextPath}`, + }, + }; + }), enter: () => - app.update((s) => { - const entries = listEntries(s); - const entry = entries[s.selected]; - if (!entry) return s; - if (entry.type === "dir") { - return { ...s, path: joinPath(s.path, entry.name), selected: 0 }; - } - return { ...s, opened: joinPath(s.path, entry.name) }; + app.update((state) => { + if (state.palette.open) return state; + const entries = listEntries(state); + const entry = entries[state.selected]; + if (!entry) return state; + return openEntry(state, entry); }), + "ctrl+p": () => + app.update((state) => ({ + ...state, + palette: { + ...state.palette, + open: true, + query: state.palette.open ? state.palette.query : "", + selectedIndex: 0, + }, + })), }); -await app.start(); +try { + await app.start(); +} finally { + cancelActiveSearch(); +} diff --git a/packages/create-rezi/templates/form-app/README.md b/packages/create-rezi/templates/form-app/README.md index d3981203..9f939bf4 100644 --- a/packages/create-rezi/templates/form-app/README.md +++ b/packages/create-rezi/templates/form-app/README.md @@ -5,14 +5,35 @@ Scaffolded with `npm create rezi` using the **__TEMPLATE_LABEL__** template. ## Quickstart ```bash +# npm npm install -npm start +npm run start + +# bun +bun install +bun run start ``` -## Keybindings +## Controls -- `tab`: Move focus between fields -- `ctrl+s`: Save -- `ctrl+r`: Reset form -- `ctrl+up` / `ctrl+down`: Switch section +- `tab`: Move focus between form widgets +- `esc`: Switch to command mode +- `i`: Return to insert mode +- `ctrl+s` or `z s`: Save draft +- `ctrl+r` or `z r`: Reset form +- `ctrl+enter` or `enter` (command mode): Submit form +- `g p`, `g w`, `g s`, `g r`: Jump to Profile, Workspace, Security, Review +- `?`: Toggle controls overlay - `q`: Quit + +## What It Demonstrates + +- Multi-step form UX with validation, completion tracking, and review summary +- Mode-aware keybindings (`insert` + `command`) with real chord sequences +- Overlay composition using modal help plus toast notifications + +## Key Code Patterns + +- `src/main.ts`: `app.modes(...)` with parent mode inheritance and chord maps +- `src/main.ts`: `getValidationErrors(...)`, `completionPercent(...)`, and section-specific field rendering +- `src/main.ts`: `ui.layers(...)` composition with `ui.modal(...)`, `ui.toastContainer(...)`, and toast expiry sweep diff --git a/packages/create-rezi/templates/form-app/package.json b/packages/create-rezi/templates/form-app/package.json index 7533b015..ab1efe52 100644 --- a/packages/create-rezi/templates/form-app/package.json +++ b/packages/create-rezi/templates/form-app/package.json @@ -6,11 +6,12 @@ "scripts": { "start": "tsx src/main.ts", "dev": "tsx watch src/main.ts", + "build": "tsc --pretty false", "typecheck": "tsc --noEmit" }, "dependencies": { - "@rezi-ui/core": "^0.1.0-alpha.0", - "@rezi-ui/node": "^0.1.0-alpha.0" + "@rezi-ui/core": "^0.1.0-alpha.11", + "@rezi-ui/node": "^0.1.0-alpha.11" }, "devDependencies": { "@types/node": "^22.13.1", diff --git a/packages/create-rezi/templates/form-app/src/main.ts b/packages/create-rezi/templates/form-app/src/main.ts index 32ea0ba3..a9c91db9 100644 --- a/packages/create-rezi/templates/form-app/src/main.ts +++ b/packages/create-rezi/templates/form-app/src/main.ts @@ -1,52 +1,99 @@ -import { rgb, ui } from "@rezi-ui/core"; -import { createNodeApp } from "@rezi-ui/node"; +import { + type Toast, + type VNode, + addToast, + createApp, + filterExpiredToasts, + removeToast, + rgb, + ui, +} from "@rezi-ui/core"; +import { createNodeBackend } from "@rezi-ui/node"; type Plan = "starter" | "growth" | "enterprise"; +type BillingCycle = "monthly" | "annual"; +type KeyMode = "insert" | "command"; type State = { section: number; + mode: KeyMode; name: string; email: string; company: string; + role: string; + workspace: string; plan: Plan; + billing: BillingCycle; seats: string; - newsletter: boolean; + region: string; + ssoRequired: boolean; + auditNotes: string; + termsAccepted: boolean; notes: string; + toasts: readonly Toast[]; + showHelp: boolean; status: string; }; -const sections = ["Profile", "Plan", "Review"] as const; +type ValidationErrors = { + name?: string; + email?: string; + workspace?: string; + seats?: string; + terms?: string; +}; + +const sections = Object.freeze([ + { id: "profile", label: "Profile", hint: "Owner identity and contact channels." }, + { id: "workspace", label: "Workspace", hint: "Environment shape, capacity, and region." }, + { id: "security", label: "Security", hint: "Access defaults and governance metadata." }, + { id: "review", label: "Review", hint: "Confirm terms and submit for provisioning." }, +] as const); +type SectionId = (typeof sections)[number]["id"]; const initialState: State = { section: 0, + mode: "insert", name: "", email: "", company: "", + role: "", + workspace: "", plan: "growth", + billing: "annual", seats: "5", - newsletter: true, + region: "us-east", + ssoRequired: true, + auditNotes: "", + termsAccepted: false, notes: "", - status: "Draft", + toasts: Object.freeze([]), + showHelp: false, + status: "Draft not saved", }; -const app = createNodeApp({ +const app = createApp({ + backend: createNodeBackend(), initialState, }); const colors = { - accent: rgb(116, 200, 255), - muted: rgb(140, 150, 170), - panel: rgb(18, 22, 34), - panelAlt: rgb(22, 28, 44), - ok: rgb(130, 220, 170), + accent: rgb(88, 204, 242), + muted: rgb(142, 152, 170), + panel: rgb(20, 26, 38), + panelAlt: rgb(28, 36, 50), + ok: rgb(120, 220, 170), + warn: rgb(250, 191, 109), + danger: rgb(255, 128, 126), ink: rgb(10, 14, 24), + inkSoft: rgb(34, 42, 60), }; function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } -function panel(title: string, children: ReturnType[], flex = 1) { +function panel(title: string, children: readonly VNode[], flex = 1): VNode { return ui.box( { title, @@ -60,162 +107,561 @@ function panel(title: string, children: ReturnType[], flex = 1 ); } +function withError(error: string | undefined): { error: string } | Record { + return error ? { error } : {}; +} + +function nowLabel(): string { + return new Date().toLocaleTimeString("en-US", { hour12: false }); +} + +function currentSection(state: Readonly): SectionId { + return sections[state.section]?.id ?? "profile"; +} + +function parseSeatCount(raw: string): number | null { + const trimmed = raw.trim(); + if (!/^\d+$/.test(trimmed)) return null; + const value = Number(trimmed); + if (!Number.isFinite(value)) return null; + return value; +} + +function getValidationErrors(state: Readonly): ValidationErrors { + const errors: ValidationErrors = {}; + + if (state.name.trim().length < 2) { + errors.name = "Enter a full owner name."; + } + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(state.email.trim())) { + errors.email = "Enter a valid work email."; + } + + if (!/^[a-z0-9-]{3,30}$/.test(state.workspace.trim())) { + errors.workspace = "Use 3-30 chars: lowercase letters, numbers, dashes."; + } + + const seats = parseSeatCount(state.seats); + if (seats === null || seats < 1 || seats > 500) { + errors.seats = "Seats must be an integer between 1 and 500."; + } + + if (!state.termsAccepted) { + errors.terms = "Accept terms before submitting."; + } + + return errors; +} + +function completionPercent(state: Readonly): number { + let complete = 0; + if (state.name.trim().length >= 2) complete += 1; + if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(state.email.trim())) complete += 1; + if (/^[a-z0-9-]{3,30}$/.test(state.workspace.trim())) complete += 1; + + const seats = parseSeatCount(state.seats); + if (seats !== null && seats >= 1 && seats <= 500) complete += 1; + if (state.termsAccepted) complete += 1; + + return Math.round((complete / 5) * 100); +} + +const toastCreatedAt = new Map(); +let toastCounter = 0; + +function dismissToast(id: string): void { + toastCreatedAt.delete(id); + app.update((state) => ({ + ...state, + toasts: removeToast(state.toasts, id), + })); +} + +function notify(type: Toast["type"], message: string, duration = 2200): void { + const id = `toast-${String(Date.now())}-${String(toastCounter++)}`; + const createdAt = Date.now(); + toastCreatedAt.set(id, createdAt); + const toast: Toast = { id, type, message, duration }; + app.update((state) => ({ + ...state, + toasts: addToast(state.toasts, toast), + })); +} + +function switchMode(mode: KeyMode): void { + if (app.getMode() === mode) return; + app.setMode(mode); + app.update((state) => ({ + ...state, + mode, + status: `${mode} mode`, + })); +} + +function jumpToSection(section: number): void { + const nextSection = clamp(section, 0, sections.length - 1); + const nextLabel = sections[nextSection]?.label ?? "Section"; + app.update((state) => ({ + ...state, + section: nextSection, + status: `Focused ${nextLabel}`, + })); +} + +function moveSection(delta: number): void { + app.update((state) => { + const nextSection = clamp(state.section + delta, 0, sections.length - 1); + if (nextSection === state.section) return state; + const nextLabel = sections[nextSection]?.label ?? "Section"; + return { + ...state, + section: nextSection, + status: `Focused ${nextLabel}`, + }; + }); +} + +function saveDraft(): void { + const stamp = nowLabel(); + app.update((state) => ({ + ...state, + status: `Draft saved at ${stamp}`, + })); + notify("info", "Draft saved"); +} + +function resetForm(): void { + app.update((state) => ({ + ...initialState, + mode: state.mode, + toasts: state.toasts, + showHelp: false, + status: "Form reset to defaults", + })); + notify("warning", "Form reset"); +} + +function submitForm(): void { + const toast = { + type: "success" as Toast["type"], + message: "Provisioning request submitted.", + }; + + app.update((state) => { + const errors = getValidationErrors(state); + const errorCount = Object.keys(errors).length; + if (errorCount > 0) { + toast.type = "error"; + toast.message = "Fix validation errors before submit."; + return { + ...state, + section: 3, + status: `Submission blocked (${String(errorCount)} validation errors)`, + }; + } + + toast.type = "success"; + toast.message = "Provisioning request submitted."; + return { + ...state, + section: 3, + status: `Submitted at ${nowLabel()}`, + }; + }); + + notify(toast.type, toast.message, 2800); +} + +function renderSectionFields(state: Readonly, errors: ValidationErrors): readonly VNode[] { + const section = currentSection(state); + + if (section === "profile") { + return [ + ui.field({ + label: "Owner name", + required: true, + hint: "The primary contact for this workspace.", + ...withError(errors.name), + children: ui.input({ + id: "name", + value: state.name, + onInput: (value) => app.update((s) => ({ ...s, name: value })), + }), + }), + ui.field({ + label: "Work email", + required: true, + hint: "Used for alerts, invoices, and security notices.", + ...withError(errors.email), + children: ui.input({ + id: "email", + value: state.email, + onInput: (value) => app.update((s) => ({ ...s, email: value })), + }), + }), + ui.field({ + label: "Company", + hint: "Optional, but used for invoice naming.", + children: ui.input({ + id: "company", + value: state.company, + onInput: (value) => app.update((s) => ({ ...s, company: value })), + }), + }), + ui.field({ + label: "Role", + hint: "Example: Platform Engineer, CTO, Ops Lead.", + children: ui.input({ + id: "role", + value: state.role, + onInput: (value) => app.update((s) => ({ ...s, role: value })), + }), + }), + ]; + } + + if (section === "workspace") { + return [ + ui.field({ + label: "Workspace slug", + required: true, + hint: "Lowercase letters, numbers, dashes.", + ...withError(errors.workspace), + children: ui.input({ + id: "workspace", + value: state.workspace, + onInput: (value) => app.update((s) => ({ ...s, workspace: value })), + }), + }), + ui.field({ + label: "Plan", + children: ui.select({ + id: "plan", + value: state.plan, + options: [ + { value: "starter", label: "Starter" }, + { value: "growth", label: "Growth" }, + { value: "enterprise", label: "Enterprise" }, + ], + onChange: (value) => app.update((s) => ({ ...s, plan: value as Plan })), + }), + }), + ui.field({ + label: "Billing cycle", + children: ui.select({ + id: "billing", + value: state.billing, + options: [ + { value: "monthly", label: "Monthly" }, + { value: "annual", label: "Annual (2 months free)" }, + ], + onChange: (value) => app.update((s) => ({ ...s, billing: value as BillingCycle })), + }), + }), + ui.field({ + label: "Seats", + required: true, + hint: "Valid range: 1-500 seats.", + ...withError(errors.seats), + children: ui.input({ + id: "seats", + value: state.seats, + onInput: (value) => app.update((s) => ({ ...s, seats: value })), + }), + }), + ui.field({ + label: "Primary region", + children: ui.select({ + id: "region", + value: state.region, + options: [ + { value: "us-east", label: "US East (N. Virginia)" }, + { value: "us-west", label: "US West (Oregon)" }, + { value: "eu-central", label: "EU Central (Frankfurt)" }, + { value: "ap-south", label: "AP South (Mumbai)" }, + ], + onChange: (value) => app.update((s) => ({ ...s, region: value })), + }), + }), + ]; + } + + if (section === "security") { + return [ + ui.field({ + label: "Identity defaults", + hint: "SSO is recommended for teams above 20 seats.", + children: ui.checkbox({ + id: "sso-required", + label: "Require SSO for all project members", + checked: state.ssoRequired, + onChange: (checked) => app.update((s) => ({ ...s, ssoRequired: checked })), + }), + }), + ui.field({ + label: "Audit notes", + hint: "Internal notes for policy and compliance reviewers.", + children: ui.input({ + id: "audit-notes", + value: state.auditNotes, + onInput: (value) => app.update((s) => ({ ...s, auditNotes: value })), + }), + }), + ui.field({ + label: "Provisioning notes", + hint: "These notes are included in the handoff ticket.", + children: ui.input({ + id: "notes", + value: state.notes, + onInput: (value) => app.update((s) => ({ ...s, notes: value })), + }), + }), + ]; + } + + return [ + ui.field({ + label: "Final confirmation", + required: true, + hint: "Required to submit provisioning.", + ...withError(errors.terms), + children: ui.checkbox({ + id: "terms-accepted", + label: "I confirm pricing, policy, and data residency terms.", + checked: state.termsAccepted, + onChange: (checked) => app.update((s) => ({ ...s, termsAccepted: checked })), + }), + }), + ui.field({ + label: "Submission notes", + hint: "Optional context for onboarding reviewers.", + children: ui.input({ + id: "submit-notes", + value: state.notes, + onInput: (value) => app.update((s) => ({ ...s, notes: value })), + }), + }), + ]; +} + app.view((state) => { - return ui.column({ flex: 1, p: 1, gap: 1, items: "stretch" }, [ - ui.row({ justify: "between", items: "center" }, [ - ui.text("__APP_NAME__", { fg: colors.accent, bold: true }), - ui.text(`Status: ${state.status}`, { fg: colors.ok, bold: true }), - ]), + const errors = getValidationErrors(state); + const errorCount = Object.keys(errors).length; + const completion = completionPercent(state); + const activeSection = sections[state.section]; + const sectionLabel = activeSection?.label ?? "Profile"; + const sectionHint = activeSection?.hint ?? "Owner identity and contact channels."; + const seatCount = parseSeatCount(state.seats); - ui.row({ flex: 1, gap: 1, items: "stretch" }, [ - panel( - "Sections", - [ - ui.column( - { gap: 0 }, - sections.map((label, index) => { - const active = index === state.section; - return ui.text(`${active ? ">" : " "} ${label}`, { - key: label, - style: { - fg: active ? colors.accent : colors.muted, - bold: active, - }, - }); - }), - ), - ], - 1, - ), - - panel( - "Customer Details", - [ - ui.column({ gap: 1 }, [ - ui.field({ - label: "Name", - required: true, - children: ui.input({ - id: "name", - value: state.name, - onInput: (value) => app.update((s) => ({ ...s, name: value })), - }), - }), - ui.field({ - label: "Email", - required: true, - children: ui.input({ - id: "email", - value: state.email, - onInput: (value) => app.update((s) => ({ ...s, email: value })), - }), - }), - ui.field({ - label: "Company", - children: ui.input({ - id: "company", - value: state.company, - onInput: (value) => app.update((s) => ({ ...s, company: value })), - }), - }), - ui.field({ - label: "Plan", - children: ui.select({ - id: "plan", - value: state.plan, - options: [ - { value: "starter", label: "Starter" }, - { value: "growth", label: "Growth" }, - { value: "enterprise", label: "Enterprise" }, - ], - onChange: (value) => app.update((s) => ({ ...s, plan: value as Plan })), + return ui.layers([ + ui.column({ flex: 1, p: 1, gap: 1, items: "stretch" }, [ + ui.row({ justify: "between", items: "center" }, [ + ui.text("__APP_NAME__", { fg: colors.accent, bold: true }), + ui.row({ gap: 2, items: "center" }, [ + ui.text(`Mode: ${state.mode.toUpperCase()}`, { + fg: state.mode === "insert" ? colors.ok : colors.warn, + bold: true, + }), + ui.text(`Completion: ${String(completion)}%`, { + fg: completion === 100 ? colors.ok : colors.accent, + }), + ui.text(`Errors: ${String(errorCount)}`, { + fg: errorCount === 0 ? colors.ok : colors.danger, + }), + ]), + ]), + + ui.row({ flex: 1, gap: 1, items: "stretch" }, [ + panel( + "Sections", + [ + ui.column( + { gap: 1 }, + sections.map((section, index) => { + const active = index === state.section; + return ui.column( + { + key: section.id, + gap: 0, + style: active + ? { bg: colors.inkSoft, fg: colors.accent } + : { fg: colors.muted }, + }, + [ + ui.text(`${active ? ">" : " "} ${section.label}`, { bold: active }), + ui.text(` ${section.hint}`, { fg: colors.muted }), + ], + ); }), - }), - ui.field({ - label: "Seats", - children: ui.input({ - id: "seats", - value: state.seats, - onInput: (value) => app.update((s) => ({ ...s, seats: value })), + ), + ], + 1, + ), + + panel( + `Form Editor: ${sectionLabel}`, + [ + ui.column({ gap: 1 }, [ + ui.text(sectionHint, { fg: colors.muted }), + ...renderSectionFields(state, errors), + ]), + ], + 2, + ), + + panel( + "Review", + [ + ui.column({ gap: 1 }, [ + ui.text("Summary", { fg: colors.accent, bold: true }), + ui.text(`Owner: ${state.name || "-"}`), + ui.text(`Email: ${state.email || "-"}`), + ui.text(`Workspace: ${state.workspace || "-"}`), + ui.text(`Plan: ${state.plan} (${state.billing})`), + ui.text(`Seats: ${seatCount !== null ? String(seatCount) : "-"}`), + ui.text(`Region: ${state.region}`), + ui.text(`SSO required: ${state.ssoRequired ? "Yes" : "No"}`), + ui.text(`Terms accepted: ${state.termsAccepted ? "Yes" : "No"}`), + ui.divider({ char: "-" }), + ui.text(state.status, { + fg: errorCount === 0 ? colors.ok : colors.warn, }), - }), - ui.checkbox({ - id: "newsletter", - label: "Subscribe to release notes", - checked: state.newsletter, - onChange: (checked) => app.update((s) => ({ ...s, newsletter: checked })), - }), - ]), - ], - 2, - ), - - panel( - "Preview", - [ - ui.column({ gap: 1 }, [ - ui.text("Summary", { fg: colors.accent, bold: true }), - ui.text(`Name: ${state.name || "-"}`), - ui.text(`Email: ${state.email || "-"}`), - ui.text(`Company: ${state.company || "-"}`), - ui.text(`Plan: ${state.plan}`), - ui.text(`Seats: ${state.seats || "-"}`), - ui.text(`Newsletter: ${state.newsletter ? "Yes" : "No"}`), - ui.divider({ char: "-" }), - ui.text("Notes"), - ui.text(state.notes || "Add internal notes in Review."), - ui.button({ - id: "save", - label: "Save draft", - onPress: () => - app.update((s) => ({ - ...s, - status: `Saved at ${new Date().toLocaleTimeString()}`, - })), - }), - ]), - ], - 1, - ), - ]), + ui.row({ gap: 1 }, [ + ui.button({ id: "save-draft", label: "Save Draft", onPress: () => saveDraft() }), + ui.button({ id: "submit-form", label: "Submit", onPress: () => submitForm() }), + ]), + ui.button({ id: "reset-form", label: "Reset Form", onPress: () => resetForm() }), + ]), + ], + 1, + ), + ]), - ui.box({ px: 1, py: 0, style: { bg: colors.ink, fg: colors.muted } }, [ - ui.row({ justify: "between", items: "center" }, [ - ui.text("Form flow ready"), - ui.row({ gap: 1 }, [ - ui.kbd("tab"), - ui.text("Focus"), - ui.kbd("ctrl+s"), - ui.text("Save"), - ui.kbd("ctrl+r"), - ui.text("Reset"), - ui.kbd("q"), - ui.text("Quit"), + ui.box({ px: 1, py: 0, style: { bg: colors.ink, fg: colors.muted } }, [ + ui.row({ justify: "between", items: "center" }, [ + ui.text( + state.mode === "insert" + ? "Insert mode: type fields directly" + : "Command mode: chords active", + ), + ui.row({ gap: 1 }, [ + ui.kbd("tab"), + ui.text("Focus"), + ui.kbd("esc"), + ui.text("Command mode"), + ui.kbd("i"), + ui.text("Insert mode"), + ui.kbd("g p"), + ui.text("Jump section"), + ui.kbd("z s"), + ui.text("Save"), + ui.kbd("q"), + ui.text("Quit"), + ]), ]), ]), ]), + state.showHelp + ? ui.modal({ + id: "help-modal", + title: "Controls and Chords", + width: 72, + backdrop: "dim", + content: ui.column({ gap: 1 }, [ + ui.text("Insert mode", { fg: colors.accent, bold: true }), + ui.text("esc -> command mode, ctrl+s save, ctrl+r reset, ctrl+enter submit"), + ui.divider({ char: "-" }), + ui.text("Command mode", { fg: colors.accent, bold: true }), + ui.text("i -> insert mode"), + ui.text("g p / g w / g s / g r -> jump Profile/Workspace/Security/Review"), + ui.text("z s -> save draft, z r -> reset form, enter -> submit"), + ui.divider({ char: "-" }), + ui.text("Global"), + ui.text("? toggles this overlay, q exits"), + ]), + actions: [ + ui.button({ + id: "close-help", + label: "Close", + onPress: () => app.update((s) => ({ ...s, showHelp: false })), + }), + ], + onClose: () => app.update((s) => ({ ...s, showHelp: false })), + initialFocus: "close-help", + }) + : null, + ui.toastContainer({ + toasts: state.toasts, + position: "bottom-right", + maxVisible: 4, + frameStyle: { + background: colors.panelAlt, + foreground: colors.muted, + border: colors.accent, + }, + onDismiss: (id) => dismissToast(id), + }), ]); }); app.keys({ q: () => app.stop(), "ctrl+c": () => app.stop(), - "ctrl+s": () => - app.update((s) => ({ - ...s, - status: `Saved at ${new Date().toLocaleTimeString()}`, - })), - "ctrl+r": () => app.update(() => ({ ...initialState })), - "ctrl+up": () => - app.update((s) => ({ - ...s, - section: clamp(s.section - 1, 0, sections.length - 1), - })), - "ctrl+down": () => - app.update((s) => ({ - ...s, - section: clamp(s.section + 1, 0, sections.length - 1), - })), + "?": () => app.update((state) => ({ ...state, showHelp: !state.showHelp })), +}); + +app.modes({ + insert: { + parent: "default", + bindings: { + escape: () => switchMode("command"), + "ctrl+s": () => saveDraft(), + "ctrl+r": () => resetForm(), + "ctrl+down": () => moveSection(1), + "ctrl+up": () => moveSection(-1), + "ctrl+enter": () => submitForm(), + }, + }, + command: { + parent: "default", + bindings: { + i: () => switchMode("insert"), + h: () => moveSection(-1), + l: () => moveSection(1), + "g p": () => jumpToSection(0), + "g w": () => jumpToSection(1), + "g s": () => jumpToSection(2), + "g r": () => jumpToSection(3), + "z s": () => saveDraft(), + "z r": () => resetForm(), + enter: () => submitForm(), + }, + }, }); -await app.start(); +app.setMode("insert"); + +const toastSweepTimer = setInterval(() => { + const now = Date.now(); + app.update((state) => { + const filteredToasts = filterExpiredToasts(state.toasts, now, toastCreatedAt); + if (filteredToasts.length === state.toasts.length) return state; + + const activeIds = new Set(filteredToasts.map((toast) => toast.id)); + for (const id of toastCreatedAt.keys()) { + if (!activeIds.has(id)) toastCreatedAt.delete(id); + } + + return { + ...state, + toasts: filteredToasts, + }; + }); +}, 250); + +try { + await app.start(); +} finally { + clearInterval(toastSweepTimer); +} diff --git a/packages/create-rezi/templates/streaming-viewer/README.md b/packages/create-rezi/templates/streaming-viewer/README.md index d4920e17..2b44f441 100644 --- a/packages/create-rezi/templates/streaming-viewer/README.md +++ b/packages/create-rezi/templates/streaming-viewer/README.md @@ -5,13 +5,33 @@ Scaffolded with `npm create rezi` using the **__TEMPLATE_LABEL__** template. ## Quickstart ```bash +# npm npm install -npm start +npm run start + +# bun +bun install +bun run start ``` -## Keybindings +## Controls -- `up` / `down` or `j` / `k`: Switch stream -- `space`: Toggle pause -- `f`: Follow mode +- `tab`: Focus the virtual list +- `up` / `down`, `pageup` / `pagedown`, `home` / `end`: Fast-scroll the 15k stream index +- `enter`: Pin selected stream into the detail panel and feed +- `space`: Pause or resume ingest simulation +- `f`: Toggle follow mode (pinned stream vs global sampling) +- `ctrl+l`: Reset the live feed panel - `q`: Quit + +## What It Demonstrates + +- Production-scale virtualization with `ui.virtualList` over 15,360 streams +- Fast keyboard navigation and scroll telemetry (`scrollTop`, visible range) +- Live ingest context that updates independently from list rendering + +## Key Code Patterns + +- `src/main.ts`: `ui.virtualList` setup (`id: "streams-vlist"`) with `overscan`, `onScroll`, and `onSelect` +- `src/main.ts`: `streamSnapshot(...)` for deterministic large-data rendering without prebuilding 15k objects +- `src/main.ts`: Interval-driven live feed updates and follow/pause control wiring diff --git a/packages/create-rezi/templates/streaming-viewer/package.json b/packages/create-rezi/templates/streaming-viewer/package.json index 7533b015..ab1efe52 100644 --- a/packages/create-rezi/templates/streaming-viewer/package.json +++ b/packages/create-rezi/templates/streaming-viewer/package.json @@ -6,11 +6,12 @@ "scripts": { "start": "tsx src/main.ts", "dev": "tsx watch src/main.ts", + "build": "tsc --pretty false", "typecheck": "tsc --noEmit" }, "dependencies": { - "@rezi-ui/core": "^0.1.0-alpha.0", - "@rezi-ui/node": "^0.1.0-alpha.0" + "@rezi-ui/core": "^0.1.0-alpha.11", + "@rezi-ui/node": "^0.1.0-alpha.11" }, "devDependencies": { "@types/node": "^22.13.1", diff --git a/packages/create-rezi/templates/streaming-viewer/src/main.ts b/packages/create-rezi/templates/streaming-viewer/src/main.ts index db67b14e..e476c0ae 100644 --- a/packages/create-rezi/templates/streaming-viewer/src/main.ts +++ b/packages/create-rezi/templates/streaming-viewer/src/main.ts @@ -1,125 +1,292 @@ -import { rgb, ui } from "@rezi-ui/core"; -import { createNodeApp } from "@rezi-ui/node"; +import { type VNode, createApp, rgb, ui } from "@rezi-ui/core"; +import { createNodeBackend } from "@rezi-ui/node"; -type Stream = { +type StreamStatus = "healthy" | "unstable" | "critical"; + +type StreamSnapshot = Readonly<{ + index: number; name: string; + region: string; category: string; viewers: number; bitrate: number; - health: "good" | "warning"; -}; - -const streams: readonly Stream[] = [ - { name: "city-cam-01", category: "Urban", viewers: 1280, bitrate: 6.4, health: "good" }, - { name: "forest-node", category: "Nature", viewers: 860, bitrate: 4.8, health: "good" }, - { name: "harbor-live", category: "Maritime", viewers: 420, bitrate: 3.9, health: "warning" }, - { name: "lab-monitor", category: "Science", viewers: 96, bitrate: 2.1, health: "good" }, -]; - -const chatByStream: Record = { - "city-cam-01": ["switch to night mode?", "pan left a bit", "traffic spike @ 18:00"], - "forest-node": ["birdsong level: high", "new fox spotted", "wind noise up"], - "harbor-live": ["dock 3 unloading", "signal jitter", "fog incoming"], - "lab-monitor": ["temperature stable", "note: calibrate sensor", "airflow normal"], -}; + latency: number; + droppedFrames: number; + status: StreamStatus; +}>; type State = { - selected: number; + selectedRow: number; paused: boolean; follow: boolean; + liveTick: number; + ingestPerSecond: number; + scrollTop: number; + visibleRange: [number, number]; + feed: readonly string[]; + statusLine: string; }; -const app = createNodeApp({ +const STREAM_COUNT = 15360; +const LIVE_TICK_MS = 260; +const LIVE_FEED_LIMIT = 12; + +const regions = ["us-east", "us-west", "eu-central", "ap-south", "sa-east"] as const; +const categories = ["Urban", "Nature", "Transit", "Retail", "Industrial", "Campus"] as const; + +const streamIndexes: readonly number[] = Object.freeze( + Array.from({ length: STREAM_COUNT }, (_value, index) => index), +); + +const initialFeed: readonly string[] = Object.freeze([ + "00:00.0 ingest connected", + "00:00.0 receiving edge events", + "00:00.0 virtual list warm and ready", +]); + +const app = createApp({ + backend: createNodeBackend(), initialState: { - selected: 0, + selectedRow: 0, paused: false, follow: true, + liveTick: 0, + ingestPerSecond: 480, + scrollTop: 0, + visibleRange: [0, 0], + feed: initialFeed, + statusLine: "Live ingest started", }, }); const colors = { - accent: rgb(116, 200, 255), - muted: rgb(140, 150, 170), - panel: rgb(18, 22, 34), - panelAlt: rgb(22, 28, 44), - ok: rgb(130, 220, 170), - warn: rgb(255, 180, 120), - ink: rgb(10, 14, 24), + accent: rgb(112, 205, 248), + muted: rgb(139, 151, 173), + panel: rgb(17, 23, 34), + panelAlt: rgb(22, 30, 45), + ink: rgb(8, 12, 20), + inkSoft: rgb(34, 42, 60), + ok: rgb(120, 214, 163), + warn: rgb(249, 190, 114), + danger: rgb(255, 122, 124), }; function clamp(value: number, min: number, max: number) { return Math.max(min, Math.min(max, value)); } -function panel(title: string, children: ReturnType[], flex = 1) { +function formatCount(value: number): string { + return value.toLocaleString("en-US"); +} + +function clockLabel(tick: number): string { + const totalMs = tick * LIVE_TICK_MS; + const totalSeconds = Math.floor(totalMs / 1000); + const minutes = Math.floor(totalSeconds / 60) + .toString() + .padStart(2, "0"); + const seconds = (totalSeconds % 60).toString().padStart(2, "0"); + const tenths = Math.floor((totalMs % 1000) / 100); + return `${minutes}:${seconds}.${String(tenths)}`; +} + +function statusColor(status: StreamStatus) { + if (status === "healthy") return colors.ok; + if (status === "unstable") return colors.warn; + return colors.danger; +} + +function streamSnapshot(index: number, tick: number): StreamSnapshot { + const safeIndex = clamp(index, 0, STREAM_COUNT - 1); + const region = regions[safeIndex % regions.length] ?? "us-east"; + const category = categories[safeIndex % categories.length] ?? "Urban"; + const name = `${category.toLowerCase()}-${String(safeIndex).padStart(5, "0")}`; + + const viewersBase = 220 + ((safeIndex * 113) % 9400); + const viewersWave = Math.round((Math.sin((tick + safeIndex) / 9) + 1) * 130); + const viewers = viewersBase + viewersWave; + + const bitrateBase = 2.2 + (safeIndex % 9) * 0.45; + const bitrateWave = Math.sin((tick + safeIndex) / 13) * 0.35; + const bitrate = Math.max(1.1, Number((bitrateBase + bitrateWave).toFixed(2))); + + const latencyBase = 16 + (safeIndex % 48); + const latencyWave = Math.round((Math.cos((tick + safeIndex) / 7) + 1) * 8); + const latency = latencyBase + latencyWave; + + const dropDrift = Math.round((Math.sin((tick + safeIndex * 3) / 15) + 1) * 1.8); + const droppedFrames = Math.max(0, dropDrift - (safeIndex % 3 === 0 ? 1 : 0)); + + const status: StreamStatus = + droppedFrames >= 3 || latency > 68 + ? "critical" + : droppedFrames >= 1 || latency > 52 + ? "unstable" + : "healthy"; + + return { + index: safeIndex, + name, + region, + category, + viewers, + bitrate, + latency, + droppedFrames, + status, + }; +} + +function panel(title: string, children: readonly VNode[], flex = 1): VNode { return ui.box( - { title, flex, border: "rounded", px: 1, py: 0, style: { bg: colors.panel, fg: colors.muted } }, + { + title, + flex, + border: "rounded", + px: 1, + py: 0, + style: { bg: colors.panel, fg: colors.muted }, + }, children, ); } +function pushFeed(state: Readonly, line: string): readonly string[] { + return Object.freeze([line, ...state.feed].slice(0, LIVE_FEED_LIMIT)); +} + app.view((state) => { - const current = streams[state.selected] ?? streams[0]; - const chat = chatByStream[current?.name ?? ""] ?? []; - const bufferValue = state.paused ? 0.2 : current ? Math.min(1, current.bitrate / 7) : 0; + const selected = streamSnapshot(state.selectedRow, state.liveTick); + const quality = Math.max(0.05, 1 - selected.droppedFrames / 4); + const bitratePercent = Math.min(1, selected.bitrate / 8); + const visibleStart = state.visibleRange[0]; + const visibleEnd = Math.max(visibleStart, state.visibleRange[1] - 1); return ui.column({ flex: 1, p: 1, gap: 1, items: "stretch" }, [ ui.row({ justify: "between", items: "center" }, [ ui.text("__APP_NAME__", { fg: colors.accent, bold: true }), - ui.row({ gap: 2 }, [ + ui.row({ gap: 2, items: "center" }, [ ui.text(`Mode: ${state.paused ? "Paused" : "Live"}`, { fg: state.paused ? colors.warn : colors.ok, bold: true, }), - ui.text(`Follow: ${state.follow ? "On" : "Off"}`, { fg: colors.muted }), + ui.text(`Follow: ${state.follow ? "On" : "Off"}`, { + fg: state.follow ? colors.accent : colors.muted, + }), + ui.text(`Ingest: ${formatCount(state.ingestPerSecond)}/s`, { fg: colors.muted }), ]), ]), ui.row({ flex: 1, gap: 1, items: "stretch" }, [ panel( - "Streams", + "Global Stream Index (15,360 streams)", [ - ui.column( - { gap: 0 }, - streams.map((stream, index) => { - const active = index === state.selected; - const prefix = active ? ">" : " "; - return ui.text(`${prefix} ${stream.name}`, { - key: stream.name, - style: { - fg: active ? colors.accent : colors.muted, - bold: active, - }, - }); + ui.column({ flex: 1, gap: 1, items: "stretch" }, [ + ui.row({ justify: "between", items: "center" }, [ + ui.text(`Visible rows: ${String(visibleStart)}-${String(visibleEnd)}`), + ui.text(`Scroll: ${String(state.scrollTop)}`, { fg: colors.muted }), + ]), + ui.virtualList({ + id: "streams-vlist", + items: streamIndexes, + itemHeight: 1, + overscan: 6, + renderItem: (item, index, focused) => { + const snapshot = streamSnapshot(item, state.liveTick); + const active = index === state.selectedRow; + return ui.row( + { + key: `stream-${String(item)}`, + gap: 1, + style: active + ? { bg: colors.inkSoft, fg: colors.accent } + : { fg: colors.muted }, + }, + [ + ui.text(active ? ">" : " "), + ui.text(snapshot.name, { bold: active }), + ui.text(snapshot.region, { fg: colors.muted }), + ui.text(`${formatCount(snapshot.viewers)} viewers`), + ui.text(`${String(snapshot.latency)}ms`, { + fg: snapshot.latency > 58 ? colors.warn : colors.muted, + }), + ui.text(snapshot.status.toUpperCase(), { + fg: statusColor(snapshot.status), + bold: focused || snapshot.status !== "healthy", + }), + ], + ); + }, + onScroll: (scrollTop, visibleRange) => + app.update((s) => { + if ( + s.scrollTop === scrollTop && + s.visibleRange[0] === visibleRange[0] && + s.visibleRange[1] === visibleRange[1] + ) { + return s; + } + return { + ...s, + scrollTop, + visibleRange: [visibleRange[0], visibleRange[1]], + }; + }), + onSelect: (_item, index) => + app.update((s) => { + const snapshot = streamSnapshot(index, s.liveTick); + return { + ...s, + selectedRow: index, + feed: pushFeed(s, `${clockLabel(s.liveTick)} pinned ${snapshot.name}`), + statusLine: `Pinned ${snapshot.name}`, + }; + }), }), - ), + ]), ], - 1, + 2, ), - ui.column({ flex: 2, gap: 1, items: "stretch" }, [ + ui.column({ flex: 1, gap: 1, items: "stretch" }, [ panel( - "Viewer", + "Selected Stream", [ ui.column({ gap: 1 }, [ - ui.text(current ? current.name : "-", { fg: colors.accent, bold: true }), - ui.text(`Category: ${current?.category ?? "-"}`), - ui.text(`Viewers: ${current?.viewers ?? 0}`), - ui.text(`Health: ${current?.health ?? "-"}`, { - fg: current?.health === "warning" ? colors.warn : colors.ok, + ui.text(selected.name, { fg: colors.accent, bold: true }), + ui.row({ gap: 2 }, [ + ui.text(`Region: ${selected.region}`), + ui.text(`Category: ${selected.category}`), + ]), + ui.row({ gap: 2 }, [ + ui.text(`Viewers: ${formatCount(selected.viewers)}`), + ui.text(`Latency: ${String(selected.latency)}ms`, { + fg: selected.latency > 58 ? colors.warn : colors.ok, + }), + ]), + ui.text(`Status: ${selected.status.toUpperCase()}`, { + fg: statusColor(selected.status), + bold: true, }), - ui.text(`Bitrate: ${current?.bitrate ?? 0} Mbps`), - ui.progress(bufferValue, { label: "Buffer", showPercent: true }), + ui.progress(bitratePercent, { label: "Bitrate headroom", showPercent: true }), + ui.progress(quality, { label: "Frame quality", showPercent: true }), ]), ], 1, ), panel( - "Chat", + "Live Feed Context", [ ui.column({ gap: 1 }, [ - ui.text("Live chat", { fg: colors.muted }), - ...chat.map((line) => ui.text(`- ${line}`)), + ui.text(state.follow ? "Following pinned stream" : "Sampling global ingest", { + fg: colors.muted, + }), + ...state.feed.map((line, index) => + ui.text(line, { + key: `feed-${String(index)}`, + style: { fg: index === 0 ? colors.accent : colors.muted }, + }), + ), ]), ], 1, @@ -129,10 +296,18 @@ app.view((state) => { ui.box({ px: 1, py: 0, style: { bg: colors.ink, fg: colors.muted } }, [ ui.row({ justify: "between", items: "center" }, [ - ui.text("Streaming console ready"), + ui.text(state.statusLine), ui.row({ gap: 1 }, [ + ui.kbd("tab"), + ui.text("Focus list"), + ui.kbd(["up", "down"]), + ui.text("Scroll"), + ui.kbd(["pageup", "pagedown"]), + ui.text("Fast scroll"), + ui.kbd("enter"), + ui.text("Pin"), ui.kbd("up"), - ui.text("Stream"), + ui.text("Navigate"), ui.kbd("space"), ui.text("Pause"), ui.kbd("f"), @@ -148,28 +323,49 @@ app.view((state) => { app.keys({ q: () => app.stop(), "ctrl+c": () => app.stop(), - up: () => - app.update((s) => ({ - ...s, - selected: clamp(s.selected - 1, 0, streams.length - 1), - })), - down: () => + space: () => app.update((s) => ({ ...s, - selected: clamp(s.selected + 1, 0, streams.length - 1), + paused: !s.paused, + statusLine: s.paused ? "Ingest resumed" : "Ingest paused", })), - k: () => + f: () => app.update((s) => ({ ...s, - selected: clamp(s.selected - 1, 0, streams.length - 1), + follow: !s.follow, + statusLine: s.follow ? "Follow mode disabled" : "Follow mode enabled", })), - j: () => + "ctrl+l": () => app.update((s) => ({ ...s, - selected: clamp(s.selected + 1, 0, streams.length - 1), + feed: initialFeed, + statusLine: "Live feed reset", })), - space: () => app.update((s) => ({ ...s, paused: !s.paused })), - f: () => app.update((s) => ({ ...s, follow: !s.follow })), }); -await app.start(); +const liveTimer = setInterval(() => { + app.update((state) => { + if (state.paused) return state; + + const nextTick = state.liveTick + 1; + const eventIndex = state.follow ? state.selectedRow : (nextTick * 17) % STREAM_COUNT; + const snapshot = streamSnapshot(eventIndex, nextTick); + const eventLine = `${clockLabel(nextTick)} ${snapshot.name} ${snapshot.status.toUpperCase()} ${ + snapshot.latency + }ms`; + + return { + ...state, + liveTick: nextTick, + ingestPerSecond: 360 + ((nextTick * 31) % 340), + feed: pushFeed(state, eventLine), + statusLine: state.follow ? `Following ${snapshot.name}` : "Sampling global ingest stream", + }; + }); +}, LIVE_TICK_MS); + +try { + await app.start(); +} finally { + clearInterval(liveTimer); +} diff --git a/scripts/check-create-rezi-templates.mjs b/scripts/check-create-rezi-templates.mjs new file mode 100755 index 00000000..f368edb3 --- /dev/null +++ b/scripts/check-create-rezi-templates.mjs @@ -0,0 +1,163 @@ +#!/usr/bin/env node +/** + * check-create-rezi-templates.mjs + * + * Deterministic smoke checks for create-rezi templates. + * + * Validates: + * - Template metadata matches on-disk template directories. + * - Template package scripts/dependencies contain expected entries. + * - Template entry files build and typecheck against local @rezi-ui declarations. + */ + +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = join(__dirname, ".."); +const CREATE_REZI_SCAFFOLD_DIST = join(ROOT, "packages", "create-rezi", "dist", "scaffold.js"); +const TEMPLATES_ROOT = join(ROOT, "packages", "create-rezi", "templates"); +const CORE_TYPES = join(ROOT, "packages", "core", "dist", "index.d.ts"); +const NODE_TYPES = join(ROOT, "packages", "node", "dist", "index.d.ts"); +const TSC_CLI = join(ROOT, "node_modules", "typescript", "bin", "tsc"); +const NODE_TYPE_ROOT = join(ROOT, "node_modules", "@types"); + +function fail(message) { + process.stderr.write(`check-create-rezi-templates: FAIL\n${message}\n`); + process.exit(1); +} + +function toJson(value) { + return `${JSON.stringify(value, null, 2)}\n`; +} + +function runTsc(projectDir, templateKey, mode) { + const isBuild = mode === "build"; + const smokeConfigPath = join(projectDir, "tsconfig.smoke.json"); + const smokeOutDir = join(projectDir, "out"); + const smokeConfig = { + extends: join(TEMPLATES_ROOT, templateKey, "tsconfig.json"), + compilerOptions: { + noEmit: !isBuild, + outDir: smokeOutDir, + baseUrl: ".", + typeRoots: [NODE_TYPE_ROOT], + paths: { + "@rezi-ui/core": [CORE_TYPES], + "@rezi-ui/node": [NODE_TYPES], + }, + }, + include: [join(TEMPLATES_ROOT, templateKey, "src", "main.ts")], + }; + + writeFileSync(smokeConfigPath, toJson(smokeConfig), "utf8"); + + const result = spawnSync( + process.execPath, + [TSC_CLI, "-p", smokeConfigPath, "--pretty", "false"], + { + cwd: projectDir, + encoding: "utf8", + }, + ); + + if (result.status !== 0) { + const output = [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); + fail(`Template ${templateKey} failed ${mode}.\n${output}`); + } +} + +if (!existsSync(CREATE_REZI_SCAFFOLD_DIST)) { + fail( + [ + `Missing ${CREATE_REZI_SCAFFOLD_DIST}.`, + "Run `npm run build` before template smoke checks.", + ].join("\n"), + ); +} + +if (!existsSync(TSC_CLI)) { + fail(`Missing ${TSC_CLI}. Run npm install (or bun install) first.`); +} + +if (!existsSync(CORE_TYPES) || !existsSync(NODE_TYPES)) { + fail( + [ + "Missing package declaration outputs required for typecheck.", + `Expected: ${CORE_TYPES}`, + `Expected: ${NODE_TYPES}`, + "Run `npm run build` before template smoke checks.", + ].join("\n"), + ); +} + +const scaffoldModule = await import(pathToFileURL(CREATE_REZI_SCAFFOLD_DIST).href); +const templates = scaffoldModule.TEMPLATE_DEFINITIONS; +if (!Array.isArray(templates) || templates.length === 0) { + fail("TEMPLATE_DEFINITIONS is missing or empty in create-rezi scaffold output."); +} + +const templateDirsOnDisk = readdirSync(TEMPLATES_ROOT, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); +const templateDirsDefined = [...templates.map((template) => template.dir)].sort(); + +if (templateDirsOnDisk.join(",") !== templateDirsDefined.join(",")) { + fail( + [ + "Template metadata and template directories are out of sync.", + `Defined: ${templateDirsDefined.join(", ")}`, + `On disk: ${templateDirsOnDisk.join(", ")}`, + ].join("\n"), + ); +} + +process.stdout.write(`check-create-rezi-templates: checking ${templates.length} templates\n`); + +for (const template of templates) { + if (template.key !== template.dir) { + fail(`Template key/dir mismatch: key=${template.key}, dir=${template.dir}`); + } + + const templateDir = join(TEMPLATES_ROOT, template.dir); + const packagePath = join(templateDir, "package.json"); + const tsconfigPath = join(templateDir, "tsconfig.json"); + const mainPath = join(templateDir, "src", "main.ts"); + + if (!existsSync(packagePath) || !existsSync(tsconfigPath) || !existsSync(mainPath)) { + fail(`Template ${template.key} is missing required files.`); + } + + const packageJson = JSON.parse(readFileSync(packagePath, "utf8")); + const scripts = packageJson.scripts ?? {}; + if (typeof scripts.start !== "string" || typeof scripts.dev !== "string") { + fail(`Template ${template.key} package.json must include start/dev scripts.`); + } + if (typeof scripts.build !== "string") { + fail(`Template ${template.key} package.json must include a build script.`); + } + if (typeof scripts.typecheck !== "string") { + fail(`Template ${template.key} package.json must include a typecheck script.`); + } + + const deps = packageJson.dependencies ?? {}; + if (typeof deps["@rezi-ui/core"] !== "string" || typeof deps["@rezi-ui/node"] !== "string") { + fail(`Template ${template.key} must declare @rezi-ui/core and @rezi-ui/node dependencies.`); + } + + const tempProject = mkdtempSync(join(tmpdir(), `rezi-template-smoke-${template.key}-`)); + try { + runTsc(tempProject, template.dir, "build"); + runTsc(tempProject, template.dir, "typecheck"); + } finally { + rmSync(tempProject, { recursive: true, force: true }); + } + + process.stdout.write(` - ${template.key}: build/typecheck OK\n`); +} + +process.stdout.write("check-create-rezi-templates: OK\n");