feat(tui): developer view in sections, with a scrollable pane and real JSON

The record and table developer views were one wall of text: panel blocks
flattened to prose, tables with ad-hoc headers, web paths as links, and the
ESB sample event broken into fragments that looked duplicated. That came
from the prose word-wrap: the minified event was split at the spaces inside
its strings ("Clone of: Blair", in both record and oldRecord) and every
longer piece was cut at the width.

Both views are now a section bar (left/right or 1-9) over a scrollable pane
with a scroll bar. Record: Security, Events, Scripts (revisions, code, logs,
test runs, store from $EDITOR), JSON (endpoints, display and raw values),
Metadata, Audits, APIs, then any further panel. Table: Events (ESB counters),
JSON, Metadata, APIs. Grids use the main grid's column layout with a dash
for no value and a note on why a column is empty; JSON is pretty-printed,
tinted and cut per line, with v for the full value viewer and y to copy.
Web links map to TUI screens or are shown as browser only. F3 goes to the
record or the grid; the help overlay lists the developer keys.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
james.maesandClaude Fable 5.1 committed 2026-11-01 05:24:09 -06:00
1 parent 213c605d95
commit f236c7b833
16 files changed
+1718 -92

No files matched your search

+12
View File
@@ -23,6 +23,13 @@ candidate's, plus the changes since it listed first.
F keys so letters always type (Ctrl+N add, Ctrl+D remove, Ctrl+O AND/OR, Ctrl+G group, F2 mode, F10
apply); Esc asks before discarding changes. Before, a true/false quick filter could be set to true but
not changed from the quick row.
- The UI's developer view (`D` on a record or a grid) was redesigned: sections (Security, Events,
Scripts, JSON, Metadata, Audits, APIs, then any further developer panel; ←→ or 1-9) over a scrollable
pane with a scroll bar; tables in the grid's column layout with `—` for no value and a line saying why
a column is empty; JSON pretty-printed and tinted, `v` opens the value viewer and `y` copies; web links
become TUI actions (the table developer view, records, grids, processes) or show their URL as browser
only; the record view adds field values, API endpoints, audits and script test runs, and the table view
the ESB counters, the table metadata and its JSON; F3 goes to the record or the grid.
- The curl installer (`scripts/install.sh`) installs the newest **stable** `cli-v*` release, the channel
Homebrew and Scoop follow; `--pre` (or `QQQ_PRE=1`) takes prereleases, `--dry-run` prints what it would
download, and `--help` works when piped from curl.
@@ -176,6 +183,11 @@ candidate's, plus the changes since it listed first.
### Fixed
- The UI's developer view drew the ESB sample event as broken fragments that looked duplicated (`…/table/pe`,
`of:`, `Blair","lastName"…` twice): the event's minified JSON went through the prose word-wrap, which
split it at the spaces inside its string values (`"Clone of: Blair"`, in both `record` and `oldRecord`)
and cut every piece longer than the width. Code blocks are now pretty-printed and drawn one source line
per screen line, cut with `…`, never wrapped as prose.
- `npm test` runs on Node 20 (the test glob is expanded by the shell; `node --test` only expands globs
from Node 21).
- A question closed with Ctrl-D no longer hangs on Node 22.12 (readline left the question pending).
+24 -1
View File
@@ -48,7 +48,7 @@ shared with the CLI. `QQQ_UI_NO_QUIT=1` is for the soak test only.
| F10, `:quit` | Quit, Reset (home screen, metadata refreshed, session kept) or Cancel; `:reset` and `:home` reset | `x`, `g`, `v` | export, go to by key, view settings |
| `:` | command line (history ↑↓, Ctrl+R, Tab completes) | Ctrl+S | save a form, continue a process step |
| Ctrl+C | quit at once (the terminal is restored) | `c` | copy a record |
| `a` | actions here (the F9 Context entries) | `A`, `D` | audits, developer (record); `D` on a grid: table APIs |
| `a` | actions here (the F9 Context entries) | `A`, `D` | audits, developer view (record or grid) |
| `t` | tags of a record (collaboration extension) | Esc then 1..0, Ctrl+1..0 | F1..F10 when the terminal takes the F-keys |
Keys on one screen:
@@ -61,12 +61,35 @@ Keys on one screen:
| Process | Ctrl+S continue, F4 back (when the step allows), Esc cancel, `w` save the step's file, `n` `p` page the records |
| Bulk wizard | Space switch a field on (bulk edit), Enter choose, `h` header row, `l` layout, `k` key fields, `P` saved profiles, `v` validate, Ctrl+S next, F4 back |
| Widget | Enter on its heading: reload, dropdowns, export CSV, run, versions, setup editors |
| Developer (`D`) | ←→ or 1-9 sections, ↑↓ PgUp PgDn scroll, Enter follow the line, `v` view the value in full, `y` copy it, F3 the record (or the grid), Esc back; see [Developer view](#developer-view) |
| Command line | ↑↓ history, Ctrl+R search the history, Tab complete, Enter run, Esc close |
| Filter (F6) | ↑↓ rows, ←→ Tab cells, Enter or typing edits the focused cell (text and numbers in place, choices from a list, Space cycles true/false), Backspace clears it to `(any)`, Ctrl+N add, Ctrl+D remove, Ctrl+O AND/OR, Ctrl+G group, F2 Advanced (grammar, cheat sheet, caret under errors), F10 apply, Esc cancel; see [docs/filter-dialog.md](docs/filter-dialog.md) |
Single-key shortcuts follow the server preference `keyboard.singleKeyShortcuts` and can be switched in F9
Settings; every F-key action is also in the F9 menu and on the `:` line.
## Developer view
`D` on a record opens its developer view, `D` on a grid the table's. Each is a row of sections (←→ or
the digits switch) over a scrollable pane with a scroll bar and a "lines a–b of n" counter:
- Record: **Security** (record security locks), **Events** (CloudEvents attributes, where the table
publishes, its subscribers, a sample event), **Scripts** (associated scripts and their revisions:
Enter shows the code, the run logs or test-runs a revision; a line stores a new version from
`$EDITOR`), **JSON** (API endpoints, each field's display and raw value, the raw values), **Metadata**
(summary, fields, sections), **Audits** (Enter shows an entry's changes), **APIs** (application APIs;
Enter on a version opens its OpenAPI document), then one section per further developer panel the
server adds (Quick Search, SQL Workbench, Assistant...).
- Table: **Events** (publications and subscribers with their message counters; Enter on a row shows
every counter), **JSON** (the full table metadata), **Metadata**, **APIs**.
Tables use the grid's column layout (cells cut with `…`, `—` for no value, with a line saying why when
a whole column is empty). JSON is pretty-printed and tinted; a long line is cut in the pane, and `v`
opens the value viewer, which wraps it in full. `y` copies the selected value (pbcopy, wl-copy, xclip or
xsel when present, else the terminal's OSC 52). Links of the web UI become TUI actions (the table
developer view, a record, a grid, a process); those with no TUI screen, such as the ESB overview, show
their URL marked "browser only". Design notes: [docs/developer-view.md](docs/developer-view.md).
## Screens
Home is the landing page (and the target of F10 › Reset): greeting and connection, counts, recently viewed
+56
View File
@@ -0,0 +1,56 @@
# Developer view
`D` on a record opens the record developer view; `D` on a table grid opens the table developer
view. Both show what the web UI's developer mode shows (record `/app/{table}/{id}/dev`, table
`/app/{table}/dev`), split into sections instead of one long page.
## Layout
```
┌─ Developer · Person 12 ──────────────────────────────────────────────┐
│ 1 Security 2 Events 3 Scripts 4 JSON 5 Metadata 6 Audits 7 APIs│ section bar
│ ESB Events · Publishing │ title · status (tone colour)
│ This table publishes its record changes to the ESB as CloudEvents. │ description, dim
│──────────────────────────────────────────────────────────────────────│
│ body: headings, key/value pairs, grids, JSON, actions █│ scrollable pane
│ ░│ with a scroll bar
│ ←→ 1-7 section · ↑↓ PgUp PgDn scroll · Enter open · v view · y copy │ key line
└──────────────────────────────────────────────── lines 1–20 of 48 ────┘
```
The bar windows around the active section (with `‹ ›`) when the sections do not fit. The standard
status line and F-key bar stay below: F1 Help, F2 Go to, F3 Record (record view) or Table (table
view: back to the grid), F5 Refresh, F9 Menu, F10 Quit; F4 F6 F7 F8 do nothing here. Esc goes back
to the screen underneath (the record or the grid, at the row it was on).
## Sections
| Record view | Table view | Source |
|-----------------------------------------------|-----------------------------------------------|--------|
| Security (record security locks) | — | developer panel `recordSecurity` |
| Events (CloudEvents, published to, subscribers, sample event, ESB links) | Events (publications and subscribers with counters, dead-letter queues, last errors) | panel `esb`; `GET /esb/table/{t}` |
| Scripts (associated scripts, revisions: code, run logs, test run, store a new version from `$EDITOR`) | — | `GET /table/{t}/{pk}/developer` |
| JSON (raw values; API endpoints for the record) | JSON (the full table metadata) | the developer route; table metadata |
| Metadata (summary, fields, sections) | Metadata (the same) | table metadata |
| Audits (newest first; Enter shows the changes)| — | kernel `getAudits` |
| APIs (application APIs and OpenAPI documents) | APIs (the same) | `listTableApis` |
| any further provider panel (Quick Search, SQL Workbench, Assistant…), one section each | — | other developer panels |
Shared sections have the same name, order and rendering in both views.
## Body rules
- Grids use the main grid's column layout (`layoutColumns`): fixed columns, cells cut with `…`,
empty cells show `—`. When a column is `—` for every row a dim line under the grid says why
(the broker does not report queue counts, for example).
- JSON is pretty-printed with two-space indent and tinted (keys, strings, numbers, literals). In
the pane, a line longer than the width is cut with `…`; it is never wrapped onto a new line, so
values cannot be split or repeated. `v` (or Enter on the block) opens the full value viewer,
which soft-wraps long lines without losing characters. `y` copies the value under the cursor
(system clipboard tool when present, else OSC 52; a message says when neither is available).
- An empty section says why in one dim line (no panel from the server, no scripts on the table,
no audit source).
- Links become actions: `/app/{t}/dev` opens the table developer view, `/app/{t}/{id}/dev` the
record developer view, `/app/{t}/{id}` the record, `/app/{t}` the grid, `/app/{process}` the
process. Anything else (the ESB overview, the SQL workbench) has no TUI screen: the line shows the
web URL with a dim "browser only" note, and `y` copies it.
+7
View File
@@ -60,6 +60,13 @@ keys Escape 'T:?'
expect 12-help 'Help (F1' 'Keys'
keys Escape T:: 'T:app SampleWidgetsDashboard' Enter
expect 13-dashboard 'Sample Widgets Dashboard'
keys Escape T:: 'T:table get person 12' Enter
expect 13b-record-12 'Person ›' 'Identity'
keys D
expect 13c-developer '1 Security' 'Record Security' '3Record'
keys 2
expect 13d-developer-events 'ESB Events' 'Published to' 'v view'
keys Escape
keys F10
expect 14-quit-confirm 'Quit qqq?' 'Reset: go to the home screen'
keys Enter
+3 -3
View File
@@ -19,7 +19,7 @@ import { CommandLineOverlay, HelpOverlay, LockOverlay, MenuOverlay, PaletteOverl
import { chooseFolder } from './overlays/files-helpers'
import { OutputScreen } from './screens/output'
import { openReport } from './screens/report'
import { TableApisScreen } from './screens/developer'
import { TableDeveloperScreen } from './screens/developer'
import { actionsOverlay } from './overlays/actions'
import { SignInScreen } from './screens/signin'
import type { AppStore, CommandsLike } from './store'
@@ -61,7 +61,7 @@ export class Commands implements CommandsLike {
const topic = screen?.helpTopic?.()
const topicHelp: string[] = []
const actions = screen?.menu?.() ?? []
const keys = GLOBAL_KEYS.filter(([k]) => s.singleKeys || !/^[a-z?/.\]]/.test(k.split(/\s+/).pop() ?? ''))
const keys = [...(screen?.helpKeys?.() ?? []), ...GLOBAL_KEYS].filter(([k]) => s.singleKeys || !/^[a-z?/.\]]/.test(k.split(/\s+/).pop() ?? ''))
const overlay = new HelpOverlay(s, keys, actions, topicHelp, async (q) => {
const data = await s.client!.data<{ results?: Array<{ kind: string; label?: string; name: string; table?: string; key?: string }> }>('searchHelp', { query: { q, limit: '30' } })
return (data.results ?? []).map((r) => ({
@@ -291,7 +291,7 @@ export class Commands implements CommandsLike {
case 'dev': case 'developer': {
const screen = s.screen as { kind?: string; openDeveloper?: () => void; tableName?: string } | undefined
if (screen?.kind === 'record') return screen.openDeveloper?.()
if (screen?.kind === 'table' && screen.tableName) return s.push(new TableApisScreen(s, screen.tableName))
if (screen?.kind === 'table' && screen.tableName) return s.push(new TableDeveloperScreen(s, screen.tableName))
return fail(':dev works on a record (scripts, panels) or a table (APIs)')
}
case 'tags': {
+169
View File
@@ -0,0 +1,169 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The developer view's JSON and code renderer: values pretty-printed into lines of tinted segments,
* then fitted to a width by cutting (the pane: one source line is one screen line, never split or
* repeated) or by soft-wrapping (the value viewer: every character kept, in order). Pure; the
* screens map tones to theme colours.
*/
export type Tone = 'key' | 'string' | 'number' | 'literal' | 'punct' | 'plain'
export interface Seg {
text: string
tone: Tone
}
export type SegLine = Seg[]
/** The text of a line. */
export function lineText(line: SegLine): string {
return line.map((s) => s.text).join('')
}
/** Pretty-prints a value as JSON (two-space indent) into lines of tinted segments. */
export function jsonSegLines(value: unknown, indent = 2): SegLine[] {
const lines: SegLine[] = []
let current: SegLine = []
const emit = (text: string, tone: Tone) => { if (text) current.push({ text, tone }) }
const newline = (depth: number) => {
lines.push(current)
current = depth ? [{ text: ' '.repeat(depth * indent), tone: 'plain' }] : []
}
const scalar = (v: unknown) => {
if (typeof v === 'string') emit(JSON.stringify(v), 'string')
else if (typeof v === 'number' || typeof v === 'bigint') emit(Number.isFinite(Number(v)) ? String(v) : 'null', Number.isFinite(Number(v)) ? 'number' : 'literal')
else if (typeof v === 'boolean' || v === null) emit(String(v), 'literal')
else emit('null', 'literal')
}
const walk = (v: unknown, depth: number) => {
if (Array.isArray(v)) {
if (!v.length) return emit('[]', 'punct')
emit('[', 'punct')
v.forEach((item, i) => {
newline(depth + 1)
walk(item === undefined || typeof item === 'function' ? null : item, depth + 1)
if (i < v.length - 1) emit(',', 'punct')
})
newline(depth)
return emit(']', 'punct')
}
if (v && typeof v === 'object') {
const entries = Object.entries(v as Record<string, unknown>).filter(([, x]) => x !== undefined && typeof x !== 'function')
if (!entries.length) return emit('{}', 'punct')
emit('{', 'punct')
entries.forEach(([k, x], i) => {
newline(depth + 1)
emit(JSON.stringify(k), 'key')
emit(': ', 'punct')
walk(x, depth + 1)
if (i < entries.length - 1) emit(',', 'punct')
})
newline(depth)
return emit('}', 'punct')
}
scalar(v)
}
walk(value, 0)
lines.push(current)
return lines
}
/** Plain text (code, logs) as lines, tabs as two spaces. */
export function textSegLines(text: string): SegLine[] {
return text.replace(/\r\n?/g, '\n').split('\n').map((l) => (l ? [{ text: l.replace(/\t/g, ' '), tone: 'plain' as const }] : []))
}
/**
* Code as lines: JSON (by language, or when it parses as an object or array) pretty-printed and
* tinted, anything else as plain text. Returns the parsed value when it was JSON.
*/
export function codeSegLines(code: string, language?: string): { lines: SegLine[]; json?: unknown } {
const looksJson = language?.toLowerCase() === 'json' || /^\s*[[{]/.test(code)
if (looksJson) {
try {
const json = JSON.parse(code) as unknown
return { lines: jsonSegLines(json), json }
} catch {
// not JSON after all: shown as written
}
}
return { lines: textSegLines(code) }
}
/** Cuts a line to a width: when it was longer, the last cell is the ellipsis. */
export function clipLine(line: SegLine, width: number, ellipsis = '…'): SegLine {
if (width <= 0) return []
const total = line.reduce((a, s) => a + s.text.length, 0)
if (total <= width) return line
const room = width - ellipsis.length
const out: SegLine = []
let used = 0
for (const s of line) {
if (used >= room) break
const take = s.text.slice(0, room - used)
out.push({ text: take, tone: s.tone })
used += take.length
}
out.push({ text: ellipsis, tone: 'punct' })
return out
}
/**
* Soft-wraps a line to a width: consecutive pieces of at most `width` cells whose texts, joined,
* are exactly the original line (nothing dropped, nothing repeated).
*/
export function wrapLine(line: SegLine, width: number): SegLine[] {
const w = Math.max(1, width)
const out: SegLine[] = []
let current: SegLine = []
let used = 0
for (const s of line) {
let rest = s.text
while (rest) {
if (used === w) {
out.push(current)
current = []
used = 0
}
const take = rest.slice(0, w - used)
current.push({ text: take, tone: s.tone })
used += take.length
rest = rest.slice(take.length)
}
}
out.push(current)
return out
}
/**
* Word-wraps prose to a width without losing characters: words move to the next line, a word longer
* than the width is broken across lines.
*/
export function wrapText(text: string, width: number): string[] {
const w = Math.max(1, width)
const out: string[] = []
for (const para of text.replace(/\r\n?/g, '\n').split('\n')) {
let line = ''
for (const raw of para.split(/[ \t]+/)) {
if (!raw) continue
let word = raw
if (line && line.length + 1 + word.length <= w) {
line += ' ' + word
continue
}
if (line) out.push(line)
line = ''
while (word.length > w) {
out.push(word.slice(0, w))
word = word.slice(w)
}
line = word
}
out.push(line)
}
return out
}
+325
View File
@@ -0,0 +1,325 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* The developer view's section model (docs/developer-view.md): sections of width-independent items
* built from the server's developer panels, the ESB table section, the table metadata and the
* record; the mapping of web links to TUI destinations; and the section bar's navigation. Pure: the
* screens draw items and turn targets into actions.
*/
import type { DeveloperPanel } from '@qrun-io/qqq-core'
type J = Record<string, unknown>
export type ItemTone = 'fg' | 'dim' | 'acc' | 'acc2' | 'ok' | 'warn' | 'err'
/** Where a link of the web UI goes in the TUI; `browser` when there is no TUI screen for it. */
export type LinkTarget =
| { kind: 'table-developer'; table: string }
| { kind: 'record-developer'; table: string; primaryKey: string }
| { kind: 'record'; table: string; primaryKey: string }
| { kind: 'table'; table: string }
| { kind: 'process'; process: string; recordId?: string }
| { kind: 'browser'; url: string }
export interface Pair {
label: string
value: string
target?: LinkTarget
}
export interface GridRow {
cells: string[]
/** What Enter does on the row. */
run?: () => void
/** The row's raw value, for `v` (viewer) and `y` (copy). */
value?: unknown
}
export type Item =
| { kind: 'heading'; text: string }
| { kind: 'text'; text: string; tone?: ItemTone }
| { kind: 'pairs'; pairs: Pair[] }
| { kind: 'grid'; headers: string[]; rows: GridRow[]; note?: string; empty?: string; title?: string }
| { kind: 'code'; label: string; language?: string; code?: string; value?: unknown }
| { kind: 'link'; label: string; target: LinkTarget; description?: string }
| { kind: 'action'; label: string; detail?: string; run: () => void }
| { kind: 'empty'; text: string }
export interface Section {
id: string
/** The name in the section bar. */
label: string
/** The heading over the body. */
title: string
status?: string
statusTone?: ItemTone
description?: string
items: Item[]
loading?: boolean
}
/** What the link mapping knows about the server: its address and which names are tables or processes. */
export interface LinkContext {
server: string
tables: Record<string, unknown>
processes?: Record<string, unknown>
}
/** A server-relative web path as an absolute URL on the server. */
export function webUrl(server: string, href: string): string {
if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return href
return `${server.replace(/\/+$/, '')}/${href.replace(/^\/+/, '')}`
}
/**
* Maps a web UI link to a TUI destination: `/app/{table}/dev` the table developer view,
* `/app/{table}/{id}/dev` the record developer view, `/app/{table}/{id}` the record, `/app/{table}`
* the grid, `/app/{process}` the process; anything else (the ESB overview, the SQL workbench, other
* sites) is browser only.
*/
export function linkTarget(href: string, ctx: LinkContext): LinkTarget {
const browser: LinkTarget = { kind: 'browser', url: webUrl(ctx.server, href) }
if (/^[a-z][a-z0-9+.-]*:/i.test(href)) return browser
const path = href.split(/[?#]/)[0]!
const parts = path.split('/').filter(Boolean).map((p) => { try { return decodeURIComponent(p) } catch { return p } })
if (parts[0] !== 'app' || parts.length < 2) return browser
const [, name, second, third] = parts as [string, string, string?, string?]
if (Object.hasOwn(ctx.tables, name)) {
if (parts.length === 2) return { kind: 'table', table: name }
if (parts.length === 3 && second === 'dev') return { kind: 'table-developer', table: name }
if (parts.length === 3) return { kind: 'record', table: name, primaryKey: second! }
if (parts.length === 4 && third === 'dev') return { kind: 'record-developer', table: name, primaryKey: second! }
return browser
}
if (parts.length === 2 && ctx.processes && Object.hasOwn(ctx.processes, name)) return { kind: 'process', process: name }
return browser
}
/** A short description of where a target goes, for the line beside a link. */
export function targetText(target: LinkTarget): string {
switch (target.kind) {
case 'table-developer': return `table developer view · ${target.table}`
case 'record-developer': return `record developer view · ${target.table} ${target.primaryKey}`
case 'record': return `record · ${target.table} ${target.primaryKey}`
case 'table': return `grid · ${target.table}`
case 'process': return `process · ${target.process}`
case 'browser': return target.url
}
}
const TONES: Record<string, ItemTone> = { INFO: 'fg', SUCCESS: 'ok', WARNING: 'warn', ERROR: 'err' }
/** A panel status tone as an item tone (INFO stays the normal colour). */
export function toneOf(tone: unknown): ItemTone | undefined {
return typeof tone === 'string' ? TONES[tone.toUpperCase()] : undefined
}
/** Why a column of a developer-panel table can be empty everywhere, by its header. */
const EMPTY_COLUMN_REASONS: Record<string, string> = {
Queued: 'the broker did not report a queue depth (its management API is off, or the destination is a topic)',
'Dead letters': 'the broker did not report a dead-letter queue depth (its management API is off)',
}
/** One dim line explaining the columns that are empty in every row (`—`), or undefined when none is. */
export function emptyColumnsNote(headers: string[], rows: string[][]): string | undefined {
if (!rows.length) return undefined
const empty = headers.filter((_, i) => rows.every((r) => (r[i] ?? '').trim() === ''))
if (!empty.length) return undefined
const reasons = empty.map((h) => EMPTY_COLUMN_REASONS[h] ? `${h}: ${EMPTY_COLUMN_REASONS[h]}` : undefined).filter(Boolean)
return reasons.length === empty.length ? `— ${reasons.join('; ')}.` : `— ${empty.join(', ')}: the server reported no value.`
}
/** A developer panel's blocks as items (TEXT, KEY_VALUES, TABLE, CODE, LINKS, ACTIONS; others shown raw). */
export function panelItems(panel: DeveloperPanel, ctx: LinkContext, recordId?: string): Item[] {
const items: Item[] = []
if (panel.error) items.push({ kind: 'text', text: panel.error, tone: 'err' })
for (const block of panel.blocks) {
const label = typeof block.label === 'string' && block.label ? block.label : undefined
switch (block.type) {
case 'TEXT': {
if (label) items.push({ kind: 'heading', text: label })
items.push({ kind: 'text', text: String(block.text ?? ''), tone: toneOf(block.tone) ?? 'fg' })
break
}
case 'KEY_VALUES':
if (label) items.push({ kind: 'heading', text: label })
items.push({ kind: 'pairs', pairs: ((block.items ?? []) as J[]).map((i) => ({ label: String(i.label ?? ''), value: String(i.value ?? ''), ...(typeof i.href === 'string' && i.href ? { target: linkTarget(i.href, ctx) } : {}) })) })
break
case 'TABLE': {
const headers = ((block.columns ?? []) as unknown[]).map(String)
const rows = ((block.rows ?? []) as unknown[][]).map((row) => headers.map((_, i) => { const v = row?.[i]; return v === null || v === undefined ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v) }))
if (label) items.push({ kind: 'heading', text: label })
const note = emptyColumnsNote(headers, rows)
items.push({ kind: 'grid', headers, rows: rows.map((cells) => ({ cells, value: Object.fromEntries(headers.map((h, i) => [h, cells[i]])) })), empty: 'No rows.', ...(label ? { title: label } : {}), ...(note ? { note } : {}) })
break
}
case 'CODE':
items.push({ kind: 'code', label: label ?? 'Code', ...(typeof block.language === 'string' ? { language: block.language } : {}), code: String(block.code ?? '') })
break
case 'LINKS':
if (label) items.push({ kind: 'heading', text: label })
for (const link of (block.links ?? []) as J[]) items.push({ kind: 'link', label: String(link.label ?? link.href ?? ''), target: linkTarget(String(link.href ?? ''), ctx), ...(typeof link.description === 'string' ? { description: link.description } : {}) })
break
case 'ACTIONS':
if (label) items.push({ kind: 'heading', text: label })
for (const a of (block.actions ?? []) as J[]) {
const process = String(a.processName ?? '')
items.push({ kind: 'link', label: String(a.label ?? process), target: { kind: 'process', process, ...(a.includeRecordId && recordId !== undefined ? { recordId } : {}) }, ...(typeof a.description === 'string' ? { description: a.description } : {}) })
}
break
default:
items.push({ kind: 'code', label: `${label ?? String(block.type ?? 'Block')} (${String(block.type ?? 'block')}: no terminal rendering; shown as data)`, value: block })
}
}
if (!items.length) items.push({ kind: 'empty', text: 'This panel has nothing to show.' })
return items
}
/** A developer panel as a section. */
export function panelSection(panel: DeveloperPanel, ctx: LinkContext, options: { id?: string; label?: string; recordId?: string } = {}): Section {
return {
id: options.id ?? `panel:${panel.id}`,
label: options.label ?? panel.title,
title: panel.title,
...(panel.statusLabel ? { status: panel.statusLabel } : {}),
...(toneOf(panel.statusTone) ? { statusTone: toneOf(panel.statusTone)! } : {}),
...(panel.description ? { description: panel.description } : {}),
items: panelItems(panel, ctx, options.recordId),
}
}
/** A section with one dim line saying why it is empty. */
export function emptySection(id: string, label: string, title: string, why: string, description?: string): Section {
return { id, label, title, items: [{ kind: 'empty', text: why }], ...(description ? { description } : {}) }
}
/** A section still loading. */
export function loadingSection(id: string, label: string, title: string): Section {
return { id, label, title, loading: true, items: [{ kind: 'empty', text: 'loading…' }] }
}
function yesNo(v: unknown): string {
return v === true ? 'yes' : v === false ? 'no' : ''
}
interface FieldMetaLike { name?: string; label?: string; type?: string; isRequired?: boolean; isHidden?: boolean; isHeavy?: boolean; isEditable?: boolean; possibleValueSourceName?: string | null; backendName?: string | null }
/** A table's metadata as items: summary, fields and sections. */
export function metadataItems(meta: J): Item[] {
const fields = Object.entries((meta.fields ?? {}) as Record<string, FieldMetaLike>)
const sections = (meta.sections ?? []) as Array<{ name?: string; label?: string; tier?: string; fieldNames?: string[]; hidden?: boolean }>
const permissions = ['read', 'insert', 'edit', 'delete'].filter((p) => meta[`${p}Permission`] === true)
const capabilities = Array.isArray(meta.capabilities) ? (meta.capabilities as unknown[]).map(String) : []
const features = Array.isArray(meta.recordFeatures) ? (meta.recordFeatures as unknown[]).map((f) => (typeof f === 'string' ? f : String((f as J).name ?? (f as J).type ?? JSON.stringify(f)))) : []
const pairs: Pair[] = [
{ label: 'Name', value: String(meta.name ?? '') },
{ label: 'Label', value: String(meta.label ?? '') },
{ label: 'Primary key', value: String(meta.primaryKeyField ?? '') },
{ label: 'Fields', value: String(fields.length) },
{ label: 'Sections', value: String(sections.length) },
{ label: 'Permissions', value: permissions.join(', ') || 'none' },
{ label: 'Capabilities', value: capabilities.join(', ') },
...(features.length ? [{ label: 'Record features', value: features.join(', ') }] : []),
...(meta.backendName ? [{ label: 'Backend', value: String(meta.backendName) }] : []),
...(meta.usesVariants !== undefined ? [{ label: 'Uses variants', value: yesNo(meta.usesVariants) }] : []),
...(meta.hasRestrictedFields !== undefined ? [{ label: 'Restricted fields', value: yesNo(meta.hasRestrictedFields) }] : []),
]
const flags = (f: FieldMetaLike) => [f.isHidden ? 'hidden' : '', f.isHeavy ? 'heavy' : '', f.isEditable === false ? 'read-only' : ''].filter(Boolean).join(', ')
return [
{ kind: 'pairs', pairs },
{ kind: 'heading', text: 'Fields' },
{ kind: 'grid', title: 'Fields', headers: ['Name', 'Label', 'Type', 'Required', 'Possible values', 'Flags'], rows: fields.map(([name, f]) => ({ cells: [name, String(f.label ?? ''), String(f.type ?? ''), f.isRequired ? 'yes' : '', String(f.possibleValueSourceName ?? ''), flags(f)], value: f })), empty: 'The table has no fields.' },
{ kind: 'heading', text: 'Sections' },
{ kind: 'grid', title: 'Sections', headers: ['Name', 'Label', 'Tier', 'Fields'], rows: sections.map((s) => ({ cells: [String(s.name ?? ''), String(s.label ?? ''), String(s.tier ?? ''), (s.fieldNames ?? []).join(', ')], value: s })), empty: 'The table has no sections.' },
]
}
/** The ESB of a table (`GET /esb/table/{t}`) as items: publications and subscribers with their counters. */
export function esbTableItems(data: J, ctx: LinkContext, when: (iso: unknown) => string): Item[] {
const publications = (data.publications ?? []) as J[]
const subscribers = (data.subscribers ?? []) as J[]
const counters = (o: J | undefined) => ((o?.counters ?? {}) as J)
const count = (v: unknown) => (v === null || v === undefined ? '' : String(v))
const items: Item[] = []
if (!publications.length && !subscribers.length) {
items.push({ kind: 'empty', text: 'This table does not publish to the ESB, and no process subscribes to its events.' })
}
if (publications.length) {
items.push({ kind: 'heading', text: 'Published to' })
const rows = publications.map((p) => {
const d = (p.destination ?? {}) as J
const c = counters(d)
return { cells: [String(d.name ?? ''), String(d.type ?? ''), String(d.provider ?? ''), ((p.events ?? []) as unknown[]).join(', '), count(c.published), count(c.publishFailures), count(((d.queueInfo ?? {}) as J).messageCount)], value: { ...p, lastActivity: c.lastActivity ? when(c.lastActivity) : null } }
})
const headers = ['Destination', 'Type', 'Provider', 'Events', 'Published', 'Failures', 'Queued']
const note = emptyColumnsNote(headers, rows.map((r) => r.cells))
items.push({ kind: 'grid', title: 'Published to', headers, rows, ...(note ? { note } : {}) })
}
if (subscribers.length) {
items.push({ kind: 'heading', text: 'Subscribers (triggers)' })
const rows = subscribers.map((s) => {
const c = counters(s)
return { cells: [String(s.name ?? ''), String(s.processLabel ?? s.processName ?? ''), String(s.state ?? ''), count(c.consumed), count(c.failed), count(c.deadLettered), count(((s.deadLetter ?? {}) as J).messageCount)], value: { ...s, lastActivity: c.lastActivity ? when(c.lastActivity) : null } }
})
const headers = ['Trigger', 'Process', 'State', 'Consumed', 'Failed', 'Dead-lettered', 'Dead letters']
const note = emptyColumnsNote(headers, rows.map((r) => r.cells))
items.push({ kind: 'grid', title: 'Subscribers', headers, rows, ...(note ? { note } : {}) })
}
if (publications.length || subscribers.length) items.push({ kind: 'text', text: 'Enter or v on a row shows every counter (retries, timings, last activity) and the broker names.', tone: 'dim' })
const errors = [...publications.map((p) => (p.destination ?? {}) as J), ...subscribers].map((o) => ({ name: String(o.name ?? ''), error: counters(o).lastError })).filter((e) => e.error)
if (errors.length) {
items.push({ kind: 'heading', text: 'Last errors' })
for (const e of errors) items.push({ kind: 'text', text: `${e.name}: ${typeof e.error === 'string' ? e.error : JSON.stringify(e.error)}`, tone: 'err' })
}
items.push({ kind: 'heading', text: 'ESB tools' })
items.push({ kind: 'link', label: 'ESB overview', target: linkTarget('/app/esb', ctx) })
return items
}
/** The section bar's state: which section is active; ←→ move, 1-9 jump. */
export class SectionNav {
index = 0
constructor(public sections: Section[] = []) {}
get current(): Section | undefined {
return this.sections[this.index]
}
/** Replaces the sections, staying on the same section (by id) when it is still there. */
set(sections: Section[]): void {
const id = this.current?.id
this.sections = sections
const again = id ? sections.findIndex((s) => s.id === id) : -1
this.index = again >= 0 ? again : Math.min(this.index, Math.max(0, sections.length - 1))
}
/** Goes to the section with this id; false when there is none. */
select(id: string): boolean {
const i = this.sections.findIndex((s) => s.id === id)
if (i < 0) return false
this.index = i
return true
}
/** Handles ← → (wrapping) and the digits 1-9; returns whether the key was one of those. */
onKey(name: string): boolean {
const n = this.sections.length
if (!n) return false
if (name === 'left') this.index = (this.index - 1 + n) % n
else if (name === 'right') this.index = (this.index + 1) % n
else if (/^[1-9]$/.test(name) && Number(name) <= n) this.index = Number(name) - 1
else return false
return true
}
/** The keys that switch sections, for the key line: `1-7`. */
digits(): string {
const n = Math.min(9, this.sections.length)
return n <= 1 ? '1' : `1-${n}`
}
}
+391
View File
@@ -0,0 +1,391 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
/**
* Drawing for the developer view: section items as screen lines for a width (grids through the main
* grid's column layout, JSON through the developer JSON renderer), the scrollable pane with its
* cursor and scroll bar, and the section bar.
*/
import type { ReactNode } from 'react'
import type { FieldMetaData } from '../client'
import type { Key } from '../keys'
import { layoutColumns, visibleFrom } from '../screens/table'
import { glyphs, theme, type ThemeTokens } from '../theme'
import { pad, trunc } from '../util/text'
import { clipLine, codeSegLines, jsonSegLines, wrapText, type SegLine, type Tone } from './json'
import { targetText, type Item, type ItemTone, type LinkTarget, type Section } from './model'
/** A span of a screen line. */
export interface Span {
text: string
fg?: string
bold?: boolean
}
/** What `v` opens in the value viewer. */
export interface Viewable {
title: string
value?: unknown
code?: string
language?: string
}
/** One line of the pane; selectable when it has an id. */
export interface PaneLine {
spans: Span[]
id?: string
/** Enter. */
run?: () => void
/** `v` (and Enter when there is no run). */
view?: Viewable
/** `y`. */
copy?: string
/** Describes the line in the menu. */
label?: string
}
/** Turns a link target into its action (the screen knows the store). */
export type TargetAction = (target: LinkTarget) => (() => void) | undefined
export function toneColor(t: ThemeTokens, tone: ItemTone | undefined): string {
return tone ? t[tone] : t.fg
}
export function segColor(t: ThemeTokens, tone: Tone): string {
switch (tone) {
case 'key': return t.acc
case 'string': return t.ok
case 'number': return t.acc2
case 'literal': return t.warn
case 'punct': return t.dim
default: return t.fg
}
}
/** Cell text with `—` for empty. */
export function cellText(v: string): string {
return v.trim() === '' ? '—' : v
}
/**
* Grid lines for headers and rows in a width: column widths from the main grid's layout
* (`layoutColumns`), shrunk from the widest when they do not fit, cells cut with `…`, `—` for empty.
* Columns that still do not fit are left out with a dim line saying how many.
*/
export function gridLines(headers: string[], rows: string[][], width: number): { header: Span[]; rule: Span[]; rows: Span[][]; hidden: number } {
const t = theme()
const fields = headers.map((h, i) => ({ name: `c${i}`, label: h, type: 'STRING', adornments: null }) as unknown as FieldMetaData)
const records = rows.map((r) => ({ values: Object.fromEntries(r.map((v, i) => [`c${i}`, cellText(v)])) }))
// the grid's layout, without the growth it leaves for values on later pages (these grids have one
// page): each column at most its longest value or header, plus the gap the grid keeps for a sort mark
const longestOf = (i: number) => Math.max(headers[i]!.length, ...rows.map((r) => cellText(r[i] ?? '').length)) + 1
const cols = layoutColumns(fields, records as never, width, { fit: 'natural' } as never).map((c, i) => ({ ...c, width: Math.min(c.width, 40, longestOf(i)) }))
// too wide: every column gives up width in proportion to what it has above its floor (its longest
// value up to 10 cells), so headers are cut before short values are
const longest = headers.map((_, i) => Math.max(1, ...rows.map((r) => cellText(r[i] ?? '').length)))
const total = cols.reduce((a, c) => a + c.width, 0) + Math.max(0, cols.length - 1)
if (total > width) {
const floor = (c: { name: string; width: number }) => Math.min(c.width, Math.max(3, Math.min(10, longest[Number(c.name.slice(1))]!)))
const slack = cols.reduce((a, c) => a + c.width - floor(c), 0)
const deficit = Math.min(slack, total - width)
let taken = 0
cols.forEach((c, i) => {
const share = i === cols.length - 1 ? deficit - taken : Math.min(deficit - taken, Math.round((deficit * (c.width - floor(c))) / Math.max(1, slack)))
c.width -= share
taken += share
})
}
// visibleFrom counts a gap after every column; the last one needs none
const shown = visibleFrom(cols, 0, width + 1)
const index = (name: string) => Number(name.slice(1))
const header: Span[] = shown.map((c, i) => ({ text: (i ? ' ' : '') + pad(c.label, c.width), fg: t.acc, bold: true }))
const used = shown.reduce((a, c, i) => a + c.width + (i ? 1 : 0), 0)
const rule: Span[] = [{ text: glyphs().rule.repeat(Math.max(0, used)), fg: t.border }]
const body = rows.map((r) => shown.map((c, i) => {
const raw = r[index(c.name)] ?? ''
const empty = raw.trim() === ''
return { text: (i ? ' ' : '') + pad(empty ? '—' : raw, c.width), fg: empty ? t.dim : t.fg }
}))
return { header, rule, rows: body, hidden: cols.length - shown.length }
}
/** Lines of JSON or code cut to a width (one source line, one screen line), indented. */
export function codeLines(item: { code?: string; value?: unknown; language?: string }, width: number, indent = 2): { lines: SegLine[]; json?: unknown } {
const parsed = item.code !== undefined ? codeSegLines(item.code, item.language) : { lines: jsonSegLines(item.value), json: item.value }
const prefix: SegLine = indent ? [{ text: ' '.repeat(indent), tone: 'plain' }] : []
return { lines: parsed.lines.map((l) => clipLine([...prefix, ...l], width, glyphs().ellipsis)), ...(parsed.json !== undefined ? { json: parsed.json } : {}) }
}
/** The text a viewable copies (pretty JSON, or the code as written). */
export function viewableText(v: Viewable): string {
if (v.code !== undefined) {
const parsed = codeSegLines(v.code, v.language)
return parsed.json !== undefined ? JSON.stringify(parsed.json, null, 2) : v.code
}
return typeof v.value === 'string' ? v.value : JSON.stringify(v.value, null, 2) ?? ''
}
/** A section's items as pane lines for a width. */
export function itemLines(section: Section, width: number, act: TargetAction): PaneLine[] {
const t = theme()
const g = glyphs()
const out: PaneLine[] = []
const gap = () => { if (out.length && out[out.length - 1]!.spans.length) out.push({ spans: [] }) }
section.items.forEach((item: Item, n) => {
switch (item.kind) {
case 'heading':
gap()
out.push({ spans: [{ text: trunc(item.text, width), fg: t.acc, bold: true }] })
break
case 'text':
for (const line of wrapText(item.text, width)) out.push({ spans: [{ text: line, fg: toneColor(t, item.tone) }] })
break
case 'empty':
for (const line of wrapText(item.text, width)) out.push({ spans: [{ text: line, fg: t.dim }] })
break
case 'pairs': {
const lw = Math.min(24, Math.max(4, ...item.pairs.map((p) => p.label.length)), Math.max(4, Math.floor(width / 3)))
item.pairs.forEach((p, i) => {
const action = p.target ? act(p.target) : undefined
const room = width - lw - 2
const browserOnly = p.target?.kind === 'browser'
const mark = p.target && !browserOnly ? ` ${g.link}` : ''
const value = cellText(p.value)
const spans: Span[] = [{ text: pad(p.label, lw) + ' ', fg: t.dim }, { text: trunc(value + mark, room), fg: p.value.trim() === '' ? t.dim : p.target && !browserOnly ? t.acc : t.fg }]
out.push({ spans, id: `${section.id}:${n}:p${i}`, label: `${p.label}: ${p.value}`, copy: p.value, ...(action ? { run: action } : {}), ...(value.length + mark.length > room ? { view: { title: p.label, value: p.value } } : {}) })
})
break
}
case 'grid': {
if (!item.rows.length) {
out.push({ spans: [{ text: trunc(item.empty ?? 'No rows.', width), fg: t.dim }] })
break
}
const grid = gridLines(item.headers, item.rows.map((r) => r.cells), width)
out.push({ spans: grid.header })
out.push({ spans: grid.rule })
grid.rows.forEach((spans, i) => {
const row = item.rows[i]!
const value = row.value ?? Object.fromEntries(item.headers.map((h, k) => [h, row.cells[k]]))
out.push({ spans, id: `${section.id}:${n}:r${i}`, label: `${item.title ?? 'Row'}: ${row.cells.filter(Boolean).slice(0, 3).join(' · ')}`, copy: JSON.stringify(value, null, 2), view: { title: `${item.title ?? section.title} · ${row.cells[0] ?? ''}`, value }, ...(row.run ? { run: row.run } : {}) })
})
if (grid.hidden) out.push({ spans: [{ text: trunc(`+${grid.hidden} more column${grid.hidden === 1 ? '' : 's'}: v on a row shows all of it`, width), fg: t.dim }] })
if (item.note) for (const line of wrapText(item.note, width)) out.push({ spans: [{ text: line, fg: t.dim }] })
break
}
case 'code': {
gap()
const view: Viewable = { title: item.label, ...(item.code !== undefined ? { code: item.code } : { value: item.value }), ...(item.language ? { language: item.language } : {}) }
const hint = 'v view · y copy'
const titles = wrapText(`${item.label}${item.language ? ` (${item.language})` : ''}`, width)
const last = titles[titles.length - 1]!
const inline = last.length + 2 + hint.length <= width
titles.forEach((text, i) => {
const spans: Span[] = [{ text, fg: t.acc, bold: true }]
if (i === titles.length - 1 && inline) spans.push({ text: ` ${hint}`, fg: t.dim })
out.push({ spans, ...(i === 0 ? { id: `${section.id}:${n}:code`, label: `View ${item.label}`, view, copy: viewableText(view) } : {}) })
})
if (!inline) out.push({ spans: [{ text: trunc(hint, width), fg: t.dim }] })
for (const line of codeLines(item, width).lines) out.push({ spans: line.map((s) => ({ text: s.text, fg: segColor(t, s.tone) })) })
break
}
case 'link': {
const action = act(item.target)
const browser = item.target.kind === 'browser'
const head = `${g.link} ${item.label}`
const tail = browser ? ` ${item.target.kind === 'browser' ? item.target.url : ''} (browser only)` : ` ${g.pointer} ${targetText(item.target)}`
out.push({
spans: [{ text: trunc(head, width), fg: browser ? t.fg : t.acc }, { text: trunc(tail, Math.max(0, width - head.length)), fg: t.dim }],
id: `${section.id}:${n}:link`,
label: browser ? `${item.label} (browser only)` : item.label,
copy: item.target.kind === 'browser' ? item.target.url : targetText(item.target),
...(action ? { run: action } : {}),
})
if (item.description) out.push({ spans: [{ text: trunc(` ${item.description}`, width), fg: t.dim }] })
break
}
case 'action':
out.push({ spans: [{ text: trunc(`${g.closed} ${item.label}`, width), fg: t.acc2 }, { text: item.detail ? trunc(` ${item.detail}`, Math.max(0, width - item.label.length - 2)) : '', fg: t.dim }], id: `${section.id}:${n}:action`, label: item.label, run: item.run })
break
}
})
return out
}
/**
* A scrollable pane over lines: a free scroll offset plus a cursor on the selectable line, if one is
* in view. ↑↓ move to the next selectable line when it is in view, else scroll one line; PgUp PgDn
* Space page; Home End.
*/
export class PaneModel {
top = 0
/** The id of the selected line. */
selected?: string
height = 10
lines: PaneLine[] = []
get cursor(): number {
return this.selected === undefined ? -1 : this.lines.findIndex((l) => l.id === this.selected)
}
get current(): PaneLine | undefined {
const i = this.cursor
return i >= 0 ? this.lines[i] : undefined
}
private maxTop(): number {
return Math.max(0, this.lines.length - this.height)
}
private visible(i: number): boolean {
return i >= this.top && i < this.top + this.height
}
/** Keeps the selection on a visible selectable line (the first one in view when it scrolled away). */
private settle(prefer: 1 | -1 = 1): void {
this.top = Math.max(0, Math.min(this.top, this.maxTop()))
const i = this.cursor
if (i >= 0 && this.visible(i)) return
const range = prefer === 1 ? [...Array(this.height).keys()].map((k) => this.top + k) : [...Array(this.height).keys()].map((k) => this.top + this.height - 1 - k)
const hit = range.find((k) => this.lines[k]?.id)
this.selected = hit !== undefined ? this.lines[hit]!.id : undefined
}
/** Sets the lines (each render) and the height; keeps the selection when its line is still there. */
update(lines: PaneLine[], height: number): void {
this.lines = lines
this.height = Math.max(1, height)
if (this.selected !== undefined && this.cursor < 0) this.selected = undefined
this.settle()
}
private step(dir: 1 | -1): void {
const i = this.cursor
const from = i >= 0 ? i : dir === 1 ? this.top - 1 : this.top + this.height
for (let k = from + dir; k >= 0 && k < this.lines.length; k += dir) {
if (!this.lines[k]!.id) continue
// the next selectable line, when it is in view or one line past the edge (scroll to it)
if (this.visible(k)) { this.selected = this.lines[k]!.id; return }
if (dir === 1 && k === this.top + this.height) { this.top++; this.selected = this.lines[k]!.id; return }
if (dir === -1 && k === this.top - 1) { this.top--; this.selected = this.lines[k]!.id; return }
break
}
this.top = Math.max(0, Math.min(this.maxTop(), this.top + dir))
if (i >= 0 && !this.visible(i)) this.selected = undefined
this.settle(dir)
}
onKey(raw: Key): boolean {
const key = raw.name === 'space' ? (raw.shift ? 'pageup' : 'pagedown') : raw.name
const page = Math.max(1, this.height - 1)
switch (key) {
case 'down': this.step(1); break
case 'up': this.step(-1); break
case 'pagedown': this.top = Math.min(this.maxTop(), this.top + page); this.settle(1); break
case 'pageup': this.top = Math.max(0, this.top - page); this.settle(1); break
case 'home': this.top = 0; this.selected = undefined; this.settle(1); break
case 'end': this.top = this.maxTop(); this.selected = undefined; this.settle(-1); break
default: return false
}
return true
}
/** "lines 1–20 of 48", or "" when everything fits. */
position(): string {
if (this.lines.length <= this.height) return ''
return `lines ${this.top + 1}–${Math.min(this.lines.length, this.top + this.height)} of ${this.lines.length}`
}
/** The visible lines after a one-cell margin, with the scroll bar in the last column. */
view(width: number, focused: boolean): ReactNode {
const t = theme()
const ascii = glyphs().border !== undefined
const scroll = this.lines.length > this.height
const inner = width - 1 - (scroll ? 1 : 0)
const thumbSize = scroll ? Math.max(1, Math.round((this.height * this.height) / this.lines.length)) : 0
const thumbTop = scroll ? Math.round((this.top / Math.max(1, this.lines.length - this.height)) * (this.height - thumbSize)) : 0
const cursor = this.cursor
const rows: ReactNode[] = []
for (let k = 0; k < this.height; k++) {
const i = this.top + k
const line = this.lines[i]
const sel = i === cursor && line?.id !== undefined
const bar = scroll ? (k >= thumbTop && k < thumbTop + thumbSize ? (ascii ? '#' : '█') : (ascii ? '|' : '░')) : ''
let used = 0
const spans = (line?.spans ?? []).map((s, j) => {
const text = trunc(s.text, Math.max(0, inner - used))
used += text.length
return <span key={j} fg={sel ? (focused ? t.selFg : t.fg) : s.fg ?? t.fg} bg={sel ? (focused ? t.selBg : t.selInactiveBg) : undefined}>{s.bold ? <strong>{text}</strong> : text}</span>
})
rows.push(
<text flexShrink={0} wrapMode="none" key={k}>
<span> </span>
{spans}
<span bg={sel ? (focused ? t.selBg : t.selInactiveBg) : undefined}>{' '.repeat(Math.max(0, inner - used))}</span>
{scroll ? <span fg={t.dim}>{bar}</span> : null}
</text>,
)
}
return <>{rows}</>
}
}
/** The section bar: `1 Security 2 Events …`, the active one highlighted, windowed with ‹ › when it does not fit. */
export function sectionBar(sections: Section[], active: number, width: number, focused: boolean): ReactNode {
const t = theme()
const ascii = glyphs().border !== undefined
const labels = sections.map((s, i) => `${i < 9 ? `${i + 1} ` : ''}${s.label}`)
// padded cells when they all fit, else tighter ones (then a window around the active section)
const padded = labels.map((l) => ` ${l} `)
const cells = padded.reduce((n, c) => n + c.length, 0) <= width ? padded : labels.map((l) => ` ${l}`)
// the window of sections that fits, keeping the active one in view
let from = 0
let to = cells.length
const size = (a: number, b: number) => cells.slice(a, b).reduce((n, c) => n + c.length, 0) + (a > 0 ? 1 : 0) + (b < cells.length ? 1 : 0)
while (size(from, to) > width && to - from > 1) {
if (active - from > to - 1 - active) from++
else to--
}
const left = from > 0 ? (ascii ? '<' : '‹') : ''
const right = to < cells.length ? (ascii ? '>' : '›') : ''
let used = left.length + right.length
const spans = cells.slice(from, to).map((c, k) => {
const i = from + k
const text = trunc(c, Math.max(0, width - used))
used += text.length
const on = i === active
return <span key={i} fg={on ? (focused ? t.selFg : t.fg) : t.acc} bg={on ? (focused ? t.selBg : t.selInactiveBg) : undefined}>{on ? <strong>{text}</strong> : text}</span>
})
return (
<text flexShrink={0} wrapMode="none">
<span fg={t.dim}>{left}</span>
{spans}
<span fg={t.dim}>{right}</span>
<span>{' '.repeat(Math.max(0, width - used))}</span>
</text>
)
}
/** The section's title line: title · status (tone colour). */
export function sectionTitle(section: Section, full: number): ReactNode {
const t = theme()
const width = full - 1
const status = section.status ? ` ${section.status}` : ''
const title = trunc(section.title, Math.max(1, width - status.length))
return (
<text flexShrink={0} wrapMode="none">
<span> </span>
<span fg={t.fg}><strong>{title}</strong></span>
<span fg={toneColor(t, section.statusTone ?? 'dim')}>{trunc(status, Math.max(0, width - title.length))}</span>
<span>{' '.repeat(Math.max(0, width - title.length - Math.min(status.length, Math.max(0, width - title.length))))}</span>
</text>
)
}
/** Plain text of a pane line (tests, menu). */
export function paneText(line: PaneLine): string {
return line.spans.map((s) => s.text).join('')
}
+12
View File
@@ -81,4 +81,16 @@ store.editExternal = async (text: string) => {
}
}
// the system clipboard tool when there is one, else the terminal's OSC 52 (tmux and most terminals)
store.copyText = (text: string) => {
const tools: Array<[string, string[]]> = process.platform === 'darwin' ? [['pbcopy', []]] : process.platform === 'win32' ? [['clip', []]] : [['wl-copy', []], ['xclip', ['-selection', 'clipboard']], ['xsel', ['--clipboard', '--input']]]
for (const [tool, toolArgs] of tools) {
if (tool === 'wl-copy' && !process.env.WAYLAND_DISPLAY) continue
if ((tool === 'xclip' || tool === 'xsel') && !process.env.DISPLAY) continue
const r = spawnSync(tool, toolArgs, { input: text, stdio: ['pipe', 'ignore', 'ignore'], timeout: 2000 })
if (!r.error && r.status === 0) return tool
}
return renderer.isOsc52Supported() && renderer.copyToClipboardOSC52(text) ? 'OSC 52' : undefined
}
createRoot(renderer).render(<App store={store} />)
+491 -78
View File
@@ -4,92 +4,488 @@
*/
import { writeFileSync } from 'node:fs'
import { getRecordDeveloper, getScriptLogs, getScriptRevisionFiles, getTableApiDocument, listDeveloperPanels, listTableApis, panelToBlocks, scriptText, storeScript, type AssociatedScript } from '@qrun-io/qqq-core'
import { auditSource, displayDateTime, getAudits, getRecordDeveloper, getScriptLogs, getScriptRevisionFiles, getTableApiDocument, listDeveloperPanels, listTableApis, scriptText, storeScript, testScript, type AssociatedScript, type AuditEntry, type DeveloperPanel, type RecordDeveloper, type TableApi } from '@qrun-io/qqq-core'
import type { TableMetaData } from '../client'
import { emptySection, esbTableItems, loadingSection, metadataItems, panelSection, SectionNav, type Item, type LinkContext, type LinkTarget, type Section } from '../developer/model'
import { itemLines, PaneModel, sectionBar, sectionTitle, segColor, viewableText, type PaneLine, type Viewable } from '../developer/pane'
import { codeSegLines, jsonSegLines, textSegLines, wrapLine, wrapText } from '../developer/json'
import type { Key } from '../keys'
import { charOf } from '../keys'
import { openProcess, openRecord, openTable } from '../nav'
import { ChoiceOverlay, PromptOverlay } from '../overlays/basic'
import { saveFile } from '../overlays/files-helpers'
import type { AppStore } from '../store'
import { theme } from '../theme'
import { errorText } from '../util/text'
import { blockLines } from '../widgets'
import type { Row } from './list'
import { LinesScreen } from './lines'
import { glyphs, theme } from '../theme'
import type { FKeyLabels, MenuEntry, Screen } from '../types'
import { errorText, trunc } from '../util/text'
import { Panel } from './list'
type J = Record<string, unknown>
/** Text shown in a scrollable pane (a script revision, logs, an OpenAPI document). */
export class TextScreen extends LinesScreen {
readonly kind = 'text'
constructor(store: AppStore, heading: string, text: string) {
super(store, heading)
this.start(async () => text.split('\n').map((l) => ({ text: l })))
}
const values = (record: unknown): J => (((record as J | undefined)?.values ?? {}) as J)
/** Copies text to the clipboard through the store, with a message saying how (or why it could not). */
function copy(store: AppStore, text: string, what: string): void {
const how = store.copyText(text)
if (how) store.flash(`copied ${what} (${text.length} characters, ${how})`)
else store.flash('no clipboard here (no pbcopy, wl-copy, xclip or xsel, and the terminal has no OSC 52); v shows the value to select', 'error')
}
/**
* Developer mode for a record (REC-053, REC-054 through the kernel records/developer module): the
* server's developer panels, and the record's associated scripts with their revisions; a revision
* can be read, a new version stored from $EDITOR, and its run logs shown.
* The full value viewer: JSON pretty-printed and tinted, code and text as written; lines longer than
* the width are soft-wrapped (every character kept, in order). y copies, Esc goes back.
*/
export class DeveloperScreen extends LinesScreen {
readonly kind = 'developer'
export class ValueScreen implements Screen {
readonly kind = 'value'
readonly id: string
readonly pane = new PaneModel()
constructor(store: AppStore, readonly tableName: string, readonly primaryKey: string) {
super(store, `Developer · ${store.meta?.tables?.[tableName]?.label ?? tableName} ${primaryKey}`)
constructor(private store: AppStore, readonly viewable: Viewable) {
this.id = `value:${viewable.title}:${Date.now()}`
}
title(): string {
return this.viewable.title
}
/** The value's lines soft-wrapped to a width. */
lines(width: number): PaneLine[] {
const t = theme()
const v = this.viewable
const source = v.code !== undefined ? codeSegLines(v.code, v.language).lines : typeof v.value === 'string' ? textSegLines(v.value) : jsonSegLines(v.value)
return source.flatMap((line) => wrapLine(line, width).map((piece) => ({ spans: piece.map((s) => ({ text: s.text, fg: segColor(t, s.tone) })) })))
}
fkeys(): FKeyLabels {
return { 3: null, 4: null, 6: null, 7: null, 8: null }
}
onKey(key: Key): boolean {
if (this.pane.onKey(key)) return true
if (charOf(key) === 'y') {
copy(this.store, viewableText(this.viewable), this.viewable.title)
return true
}
return false
}
menu(): MenuEntry[] {
return [{ section: 'Context', label: `Copy ${this.viewable.title}`, key: 'y', run: () => copy(this.store, viewableText(this.viewable), this.viewable.title) }]
}
render(width: number, height: number, focused: boolean) {
const t = theme()
const inner = width - 2
this.pane.update(this.lines(Math.max(1, inner - 2)), height - 3)
return (
<Panel title={this.title()} width={width} height={height} focused={focused} {...(this.pane.position() ? { bottomTitle: this.pane.position() } : {})}>
{this.pane.view(inner, focused)}
<text flexShrink={0} wrapMode="none" fg={t.dim}>{trunc(' ↑↓ PgUp PgDn scroll · y copy · Esc back', inner).padEnd(inner)}</text>
</Panel>
)
}
}
/** Text in the value viewer (a script revision, run logs, an OpenAPI document). */
export function textScreen(store: AppStore, title: string, text: string, language?: string): ValueScreen {
return new ValueScreen(store, { title, code: text, ...(language ? { language } : {}) })
}
/**
* The developer view shared by records and tables (docs/developer-view.md): a section bar (← → or
* 1-9), the section's title, status and description, and its body in a scrollable pane with a
* scroll bar. Enter follows the selected line, v opens the value viewer, y copies.
*/
abstract class DeveloperView implements Screen {
abstract readonly kind: string
readonly id: string
readonly nav = new SectionNav()
private panes = new Map<string, PaneModel>()
protected loading = 0
constructor(protected store: AppStore, readonly tableName: string, readonly heading: string) {
this.id = `${heading}:${Date.now()}`
}
abstract refresh(): void
/** The sections from what is loaded so far. */
protected abstract sections(): Section[]
/** F3: the record or the grid this view belongs to. */
protected abstract up(): void
protected abstract upLabel(): string
title(): string {
return this.heading
}
protected tableLabel(): string {
return this.store.meta?.tables?.[this.tableName]?.label ?? this.tableName
}
/** Rebuilds the sections (after each load), staying on the same one. */
protected rebuild(): void {
this.nav.set(this.sections())
this.store.bump()
}
/** Runs a load; the view repaints as each part arrives. */
protected load<T>(work: Promise<T>, done: (value: T) => void, failed: (error: unknown) => void): void {
this.loading++
work.then(done, failed).finally(() => {
this.loading--
this.rebuild()
})
}
protected linkContext(): LinkContext {
return { server: this.store.client?.server ?? '', tables: (this.store.meta?.tables ?? {}) as Record<string, unknown>, processes: (this.store.meta?.processes ?? {}) as Record<string, unknown> }
}
/** What Enter does on a link: TUI screens for TUI destinations; browser-only ones say so. */
readonly act = (target: LinkTarget): (() => void) | undefined => {
const s = this.store
switch (target.kind) {
case 'table-developer': return () => s.push(new TableDeveloperScreen(s, target.table))
case 'record-developer': return () => s.push(new DeveloperScreen(s, target.table, target.primaryKey))
case 'record': return () => openRecord(s, target.table, target.primaryKey)
case 'table': return () => openTable(s, target.table)
case 'process': return () => openProcess(s, target.process, target.recordId !== undefined ? { recordIds: [target.recordId] } : {})
case 'browser': return () => s.flash(`browser only: ${target.url} (the TUI has no screen for it; y copies the URL)`)
}
}
pane(): PaneModel {
const id = this.nav.current?.id ?? ''
let pane = this.panes.get(id)
if (!pane) {
pane = new PaneModel()
this.panes.set(id, pane)
}
return pane
}
/** Opens the value viewer on a viewable. */
view(v: Viewable): void {
this.store.push(new ValueScreen(this.store, v))
}
fkeys(): FKeyLabels {
return { 3: this.upLabel(), 4: null, 6: null, 7: null, 8: null }
}
keyLine(): string {
const single = this.store.singleKeys
return `←→${single ? ` ${this.nav.digits()}` : ''} section · ↑↓ scroll · Enter open${single ? ' · v view · y copy' : ''} · Esc back`
}
/** Developer keys for the help overlay (F1, ?). */
helpKeys(): Array<[string, string]> {
const names = this.nav.sections.slice(0, 9).map((s, i) => `${i + 1} ${s.label}`).join(', ')
return [
['← → 1-9', `developer section: ${names}`],
['↑↓ PgUp PgDn', 'scroll the section; the cursor stops on lines that do something'],
['Enter', 'follow the line: open a link, a revision, a row, a value'],
['v', 'view the selected value in full (JSON pretty-printed, long lines wrapped)'],
['y', 'copy the selected value to the clipboard'],
[`F3`, this.upLabel() === 'Record' ? 'the record this view is for' : 'the table grid'],
['F5', 'reload every section'],
['Esc', 'back, to the row you were on'],
]
}
onKey(key: Key): boolean {
const pane = this.pane()
const ch = charOf(key)
if (key.name === 'left' || key.name === 'right' || (this.store.singleKeys && /^[1-9]$/.test(ch ?? '') && !key.ctrl && !key.meta)) {
return this.nav.onKey(key.name === 'left' || key.name === 'right' ? key.name : ch!)
}
if (pane.onKey(key)) return true
const line = pane.current
if (key.name === 'return') {
if (line?.run) line.run()
else if (line?.view) this.view(line.view)
return true
}
if (key.name === 'f3') {
this.up()
return true
}
if (this.store.singleKeys && ch === 'v') {
if (line?.view) this.view(line.view)
else this.store.flash('nothing to view on this line: move to a value, a row or a JSON block')
return true
}
if (this.store.singleKeys && ch === 'y') {
if (line?.copy !== undefined) copy(this.store, line.copy, line.label ?? 'the value')
else this.store.flash('nothing to copy on this line')
return true
}
return false
}
menu(): MenuEntry[] {
const out: MenuEntry[] = this.nav.sections.map((s, i) => ({ section: 'Context' as const, label: `Section: ${s.label}${s.status ? ` (${s.status})` : ''}`, ...(i < 9 ? { key: String(i + 1) } : {}), run: () => { this.nav.index = i } }))
const line = this.pane().current
if (line?.view) out.push({ section: 'Context', label: `View ${line.label ?? 'the value'}`, key: 'v', run: () => this.view(line.view!) })
if (line?.copy !== undefined) out.push({ section: 'Context', label: `Copy ${line.label ?? 'the value'}`, key: 'y', run: () => copy(this.store, line.copy!, line.label ?? 'the value') })
for (const l of this.pane().lines.filter((x) => x.run).slice(0, 20)) out.push({ section: 'Context', label: l.label ?? 'Open', run: l.run })
out.push({ section: 'Context', label: this.upLabel() === 'Record' ? 'View the record' : 'Back to the grid', key: 'F3', run: () => this.up() })
return out
}
render(width: number, height: number, focused: boolean) {
const t = theme()
const inner = width - 2
if (!this.nav.sections.length) this.nav.set(this.sections())
const section = this.nav.current
const description = section?.description ? wrapText(section.description, inner - 1).slice(0, 2) : []
const bodyHeight = Math.max(1, height - 2 - 1 - 1 - description.length - 1 - 1)
const pane = this.pane()
// the body: a one-cell margin, the lines, and a column for the scroll bar
pane.update(section ? itemLines(section, Math.max(1, inner - 2), this.act) : [], bodyHeight)
const busy = this.loading > 0 ? ' · loading…' : ''
return (
<Panel title={this.heading} width={width} height={height} focused={focused} {...(pane.position() ? { bottomTitle: pane.position() } : {})}>
{sectionBar(this.nav.sections, this.nav.index, inner, focused)}
{section ? sectionTitle(section, inner) : <text flexShrink={0} wrapMode="none">{''.padEnd(inner)}</text>}
{description.map((d, i) => <text flexShrink={0} wrapMode="none" key={`d${i}`} fg={t.dim}>{` ${trunc(d, inner - 1)}`.padEnd(inner)}</text>)}
<text flexShrink={0} wrapMode="none" fg={t.border}>{glyphs().rule.repeat(Math.max(0, inner))}</text>
{pane.view(inner, focused)}
<text flexShrink={0} wrapMode="none" fg={t.dim}>{trunc(` ${this.keyLine()}${busy}`, inner).padEnd(inner)}</text>
</Panel>
)
}
}
/** The metadata, JSON and APIs sections, shared by the record and table views. */
function apisItems(view: DeveloperView, store: AppStore, tableName: string, apis: TableApi[]): Item[] {
if (!apis.length) return [{ kind: 'empty', text: 'No application API exposes this table.' }]
const items: Item[] = []
for (const a of apis) {
items.push({ kind: 'heading', text: `${a.label} (${a.name})` })
items.push({ kind: 'pairs', pairs: [{ label: 'Path', value: a.path }, { label: 'Current version', value: a.currentVersion ?? '' }] })
items.push({
kind: 'grid', title: a.label, headers: ['Version', 'Current', 'Semantic version', 'Table spec'], empty: 'The API has no versions.',
rows: a.versions.map((v) => ({ cells: [v.version, v.version === a.currentVersion ? 'current' : '', v.semanticVersion ?? '', v.tableSpecPath ?? ''], value: v, run: () => apiDocument(view, store, tableName, a.name, v.version) })),
})
}
items.push({ kind: 'text', text: 'Enter on a version opens its OpenAPI document (view it here or save it to a file).', tone: 'dim' })
return items
}
function apiDocument(view: DeveloperView, store: AppStore, tableName: string, api: string, version: string): void {
const c = store.client!
store.run(getTableApiDocument(c.server, c.fetch, tableName, api, version), (doc) => {
const text = JSON.stringify(doc, null, 2)
store.open(new ChoiceOverlay(store, `${api} ${version} OpenAPI document`, [{ label: 'View it here', value: 'view' }, { label: 'Save it to a file', value: 'save' }], (what) => {
if (what === 'view') view.view({ title: `${api} ${version} · ${tableName}`, value: doc })
else saveFile(store, 'Save the OpenAPI document', `${tableName}-${api}-${version}.json`, (path) => { writeFileSync(path, text); store.message(`Wrote ${path}.`) })
}))
})
}
/**
* Developer mode for a record (REC-053, REC-054): Security, Events, Scripts, JSON, Metadata, Audits
* and APIs sections, then one section per further developer panel (Quick Search, SQL Workbench…).
*/
export class DeveloperScreen extends DeveloperView {
readonly kind = 'developer'
private panels?: DeveloperPanel[]
private panelsError?: string
private developer?: RecordDeveloper
private developerError?: string
private meta?: TableMetaData
private metaError?: string
private audits?: { entries: AuditEntry[]; total: number | null }
private auditsError?: string
private apis?: TableApi[]
private apisError?: string
constructor(store: AppStore, tableName: string, readonly primaryKey: string) {
super(store, tableName, `Developer · ${store.meta?.tables?.[tableName]?.label ?? tableName} ${primaryKey}`)
this.refresh()
}
protected upLabel(): string {
return 'Record'
}
protected up(): void {
const below = this.store.routes[this.store.routes.length - 2] as { kind?: string; tableName?: string; primaryKey?: unknown } | undefined
if (below?.kind === 'record' && below.tableName === this.tableName && String(below.primaryKey) === this.primaryKey) this.store.back()
else openRecord(this.store, this.tableName, this.primaryKey)
}
helpTopic() {
return { kind: 'table' as const, name: this.tableName }
}
refresh(): void {
const s = this.store
const c = s.client!
this.start(async () => {
const t = theme()
const rows: Row[] = []
const [panels, developer] = await Promise.all([
listDeveloperPanels(c.server, c.fetch, this.tableName, this.primaryKey).catch((e) => { rows.push({ text: `Panels: ${errorText(e)}`, fg: t.err }); return [] }),
getRecordDeveloper(c.server, c.fetch, this.tableName, this.primaryKey).catch((e) => { rows.push({ text: `Scripts: ${errorText(e)}`, fg: t.err }); return undefined }),
])
for (const p of panels) {
rows.push(...blockLines(panelToBlocks(p), 100).map((l) => ({ ...l })))
rows.push({ text: '' })
}
rows.push({ text: 'Associated scripts', fg: t.acc, bold: true })
if (!developer?.scripts.length) rows.push({ text: ' this table has no associated script fields', fg: t.dim })
for (const script of developer?.scripts ?? []) this.scriptRows(script, rows)
return rows
})
this.panels = this.developer = this.meta = this.audits = this.apis = undefined
this.panelsError = this.developerError = this.metaError = this.auditsError = this.apisError = undefined
this.load(listDeveloperPanels(c.server, c.fetch, this.tableName, this.primaryKey), (p) => { this.panels = p }, (e) => { this.panelsError = errorText(e); this.panels = [] })
this.load(getRecordDeveloper(c.server, c.fetch, this.tableName, this.primaryKey), (d) => { this.developer = d }, (e) => { this.developerError = errorText(e) })
this.load(listTableApis(c.server, c.fetch, this.tableName), (a) => { this.apis = a }, (e) => { this.apisError = errorText(e) })
this.load(c.tableMeta(this.tableName).then((meta) => {
this.meta = meta
const source = auditSource(meta as never, (s.meta ?? {}) as never)
if (!source) throw new Error(`No audit history is available for ${meta.label}: there is no audit process or audit table you may read.`)
return getAudits(c.server, c.fetch, source, this.tableName, this.primaryKey)
}), (a) => { this.audits = a }, (e) => { this.auditsError = errorText(e) })
this.rebuild()
}
scriptRows(script: AssociatedScript, rows: Row[]): void {
const t = theme()
const name = String((script.script?.values as J | undefined)?.name ?? '(no script yet)')
rows.push({ text: ` ${script.fieldName}: ${name} · Enter stores a new version from $EDITOR`, key: `s:${script.fieldName}`, action: () => this.storeVersion(script) })
for (const r of script.revisions.slice(0, 20)) {
const v = (r.values ?? {}) as J
const label = ` v${String(v.sequenceNo ?? '?')} ${String(v.commitMessage ?? '')} ${String(v.author ?? '')} ${String(v.createDate ?? '')}`
rows.push({ text: label, fg: t.fg, key: `r:${String(v.id)}`, action: () => this.revisionActions(script, String(v.id), label.trim()) })
protected sections(): Section[] {
const ctx = this.linkContext()
const out: Section[] = []
const panel = (id: string) => this.panels?.find((p) => p.id === id)
const panelOr = (id: string, sid: string, label: string, title: string, why: string): Section => {
if (!this.panels) return loadingSection(sid, label, title)
const p = panel(id)
if (p) return panelSection(p, ctx, { id: sid, label, recordId: this.primaryKey })
return emptySection(sid, label, title, this.panelsError ? `The developer panels could not be loaded: ${this.panelsError}` : why)
}
out.push(panelOr('recordSecurity', 'security', 'Security', 'Record Security', 'The server has no record security panel for this table (the provider is not installed).'))
out.push(panelOr('esb', 'events', 'Events', 'ESB Events', 'The server has no ESB panel for this table: the ESB module is not installed, or the table does not publish events.'))
out.push(this.scriptsSection())
out.push(this.jsonSection())
out.push(this.metaError ? emptySection('metadata', 'Metadata', 'Table Metadata', `The table metadata could not be loaded: ${this.metaError}`) : this.meta ? { id: 'metadata', label: 'Metadata', title: 'Table Metadata', description: `${this.meta.label} (${this.meta.name}): fields, sections, permissions and capabilities.`, items: metadataItems(this.meta as unknown as J) } : loadingSection('metadata', 'Metadata', 'Table Metadata'))
out.push(this.auditsSection())
out.push(this.apisError ? emptySection('apis', 'APIs', 'Application APIs', `The application APIs could not be listed: ${this.apisError}`) : this.apis ? { id: 'apis', label: 'APIs', title: 'Application APIs', description: 'The application APIs that expose this table, with their OpenAPI documents.', items: apisItems(this, this.store, this.tableName, this.apis) } : loadingSection('apis', 'APIs', 'Application APIs'))
for (const p of this.panels ?? []) if (p.id !== 'recordSecurity' && p.id !== 'esb') out.push(panelSection(p, ctx, { recordId: this.primaryKey }))
return out
}
private fieldLabel(name: string): string {
return this.meta?.fields?.[name]?.label ?? name
}
private scriptsSection(): Section {
const base = { id: 'scripts', label: 'Scripts', title: 'Associated Scripts' }
if (this.developerError) return emptySection(base.id, base.label, base.title, `The record's developer data could not be loaded: ${this.developerError}`)
if (!this.developer) return loadingSection(base.id, base.label, base.title)
const scripts = this.developer.scripts
if (!scripts.length) return emptySection(base.id, base.label, base.title, 'This table has no associated script fields.')
const items: Item[] = []
for (const script of scripts) {
const v = values(script.script)
const type = values(script.scriptType)
const current = script.revisions.find((r) => String(values(r).id) === String(v.currentScriptRevisionId))
items.push({ kind: 'heading', text: `${this.fieldLabel(script.fieldName)} (${script.fieldName})` })
items.push({ kind: 'pairs', pairs: [
{ label: 'Script', value: script.script ? `${String(v.name ?? '')} (id ${String(v.id ?? '')})` : '' },
{ label: 'Type', value: String(type.name ?? script.scriptTypeId ?? '') },
{ label: 'Current revision', value: current ? `v${String(values(current).sequenceNo ?? '?')}` : '' },
{ label: 'Test inputs', value: script.testInputFields.map((f) => String(f.label ?? f.name ?? '')).join(', ') },
] })
items.push({ kind: 'action', label: script.script ? 'Store a new version from $EDITOR' : 'Create the script from $EDITOR', detail: 'opens the latest code in your editor, then asks for a commit message', run: () => this.storeVersion(script) })
items.push({
kind: 'grid', title: `${script.fieldName} revisions`, headers: ['Version', 'Message', 'Author', 'Created'], empty: 'No revisions yet: the line above stores the first version.',
rows: script.revisions.slice(0, 50).map((r) => {
const rv = values(r)
return { cells: [`v${String(rv.sequenceNo ?? '?')}`, String(rv.commitMessage ?? ''), String(rv.author ?? ''), rv.createDate ? displayDateTime(rv.createDate) : ''], value: rv, run: () => this.revisionActions(script, String(rv.id), `v${String(rv.sequenceNo ?? '?')} ${String(rv.commitMessage ?? '')}`.trim()) }
}),
})
}
items.push({ kind: 'text', text: 'Enter on a revision: show its code, its run logs, or test-run it.', tone: 'dim' })
return { ...base, description: 'Scripts referenced by this record, with their revisions; store new versions, read code and logs, run tests.', items }
}
private jsonSection(): Section {
const base = { id: 'json', label: 'JSON', title: 'Record JSON' }
if (this.developerError) return emptySection(base.id, base.label, base.title, `The record could not be loaded: ${this.developerError}`)
if (!this.developer) return loadingSection(base.id, base.label, base.title)
const record = this.developer.record
const raw = values(record)
const display = ((record.displayValues ?? {}) as J)
const server = this.store.client?.server.replace(/\/+$/, '') ?? ''
const path = `/qqq/v1/table/${encodeURIComponent(this.tableName)}/${encodeURIComponent(this.primaryKey)}`
const names = [...Object.keys(raw), ...Object.keys(display).filter((f) => !(f in raw))]
const show = (x: unknown) => (x === null || x === undefined ? '' : typeof x === 'object' ? JSON.stringify(x) : String(x))
return {
...base,
description: 'The record as the API returns it: its endpoints, each field\'s display and raw value, and the raw values as JSON.',
items: [
{ kind: 'heading', text: 'API' },
{ kind: 'pairs', pairs: [{ label: 'Get', value: `GET ${server}${path}` }, { label: 'Update', value: `PATCH ${server}${path}` }, { label: 'Delete', value: `DELETE ${server}${path}` }] },
{ kind: 'heading', text: 'Values' },
{ kind: 'grid', title: 'Field', headers: ['Field', 'Name', 'Display value', 'Raw value'], rows: names.map((f) => ({ cells: [this.fieldLabel(f), f, show(display[f]), show(raw[f])], value: { field: f, displayValue: display[f], rawValue: raw[f] } })), empty: 'The record has no values.' },
{ kind: 'code', label: 'Record raw values', language: 'json', value: raw },
],
}
}
private auditsSection(): Section {
const base = { id: 'audits', label: 'Audits', title: 'Audits' }
if (this.auditsError) return emptySection(base.id, base.label, base.title, this.auditsError)
if (!this.audits) return loadingSection(base.id, base.label, base.title)
const { entries, total } = this.audits
if (!entries.length) return emptySection(base.id, base.label, base.title, 'No audit entries: this record has not been changed since auditing started.')
const show = (v: unknown) => (v === null || v === undefined || v === '' ? '(empty)' : typeof v === 'object' ? JSON.stringify(v) : String(v))
const changeText = (e: AuditEntry) => [e.message, ...e.changes.map((c) => c.message ?? `${c.fieldName}: ${show(c.oldValue)} → ${show(c.newValue)}`)].filter(Boolean).join('\n')
return {
...base,
description: `${entries.length}${total !== null ? ` of ${total}` : ''} audit entr${entries.length === 1 ? 'y' : 'ies'}, newest first. Enter shows an entry's changes.`,
items: [{
kind: 'grid', title: 'Audit', headers: ['When', 'User', 'Action', 'Changes'],
rows: entries.map((e) => ({ cells: [displayDateTime(e.timestamp), e.user || '(unknown user)', e.action, e.changes.length ? `${e.changes.length}: ${e.changes.map((c) => c.fieldName).join(', ')}` : e.message], value: e, run: () => this.view({ title: `Audit · ${displayDateTime(e.timestamp)} · ${e.user || '(unknown user)'} · ${e.action}`, code: changeText(e) || '(no field changes recorded)', language: 'text' }) })),
}],
}
}
revisionActions(script: AssociatedScript, revisionId: string, label: string): void {
const s = this.store
const c = s.client!
s.open(new ChoiceOverlay(s, label, [{ label: 'Show the code', value: 'code' }, { label: 'Show the run logs', value: 'logs' }], (what) => {
const canTest = Boolean(s.meta?.processes?.testScript) && script.script !== undefined
const choices = [{ label: 'Show the code', value: 'code' }, { label: 'Show the run logs', value: 'logs' }, ...(canTest ? [{ label: 'Test-run this revision', value: 'test' }] : [])]
s.open(new ChoiceOverlay(s, `${script.fieldName} · ${label}`, choices, (what) => {
const revision = script.revisions.find((r) => String(values(r).id) === revisionId)
if (what === 'code') {
s.run(getScriptRevisionFiles(c.server, c.fetch, revisionId), (files) => s.push(new TextScreen(s, `${script.fieldName} · ${label}`, scriptText(script.revisions.find((r) => String((r.values as J).id) === revisionId), files))))
} else {
s.run(getScriptRevisionFiles(c.server, c.fetch, revisionId), (files) => s.push(textScreen(s, `${script.fieldName} · ${label}`, scriptText(revision, files) || '(this revision has no code)')))
} else if (what === 'logs') {
s.run(getScriptLogs(c.server, c.fetch, this.tableName, this.primaryKey, script.fieldName, revisionId), (logs) => {
const text = logs.length ? logs.map((l) => [`${l.startTimestamp} → ${l.endTimestamp}${l.hadError ? ` ERROR ${l.error}` : ''}`, ...l.lines.map((x) => ` ${x.timestamp} ${x.text}`)].join('\n')).join('\n\n') : '(no runs logged)'
s.push(new TextScreen(s, `Logs · ${label}`, text))
const text = logs.length ? logs.map((l) => [`${l.startTimestamp} → ${l.endTimestamp}${l.hadError ? ` ERROR ${l.error}` : ''}`, ...l.lines.map((x) => ` ${x.timestamp} ${x.text}`)].join('\n')).join('\n\n') : '(no runs logged for this revision)'
s.push(textScreen(s, `Logs · ${script.fieldName} · ${label}`, text, 'text'))
})
} else {
this.testRun(script, revision, label)
}
}))
}
/** Asks for each test input, then runs the revision's code with the testScript process. */
private testRun(script: AssociatedScript, revision: J | undefined, label: string): void {
const s = this.store
const c = s.client!
const rv = values(revision)
const fields = script.testInputFields.map((f) => ({ name: String(f.name ?? ''), label: String(f.label ?? f.name ?? '') })).filter((f) => f.name)
const inputs: Record<string, string> = {}
const ask = (i: number) => {
if (i >= fields.length) {
s.run(getScriptRevisionFiles(c.server, c.fetch, String(rv.id)).then((files) => testScript(c.server, c.fetch, {
scriptId: values(script.script).id,
...(rv.apiName ? { apiName: String(rv.apiName) } : {}),
...(rv.apiVersion ? { apiVersion: String(rv.apiVersion) } : {}),
files: files.length ? files : [{ fileName: 'script.js', contents: scriptText(revision, files) }],
inputs,
})), (result) => {
this.view({ title: `Test run · ${script.fieldName} · ${label}`, value: { output: result.output ?? null, ...(result.exception ? { exception: result.exception } : {}), logLines: result.logLines } })
})
return
}
const f = fields[i]!
s.open(new PromptOverlay(s, `Test input: ${f.label}`, `${i + 1} of ${fields.length} · Enter goes on, Esc cancels the test run`, (value) => { inputs[f.name] = value; ask(i + 1) }))
}
ask(0)
}
storeVersion(script: AssociatedScript): void {
const s = this.store
const c = s.client!
const latest = script.revisions[0]
const load = latest ? getScriptRevisionFiles(c.server, c.fetch, String((latest.values as J).id)).then((files) => scriptText(latest, files)) : Promise.resolve('')
const load = latest ? getScriptRevisionFiles(c.server, c.fetch, String(values(latest).id)).then((files) => scriptText(latest, files)) : Promise.resolve('')
s.run(load.then((text) => s.editExternal(text)), (edited) => {
s.open(new PromptOverlay(s, 'Commit message', `A new version of ${script.fieldName} is stored with this message.`, (message) => {
s.run(storeScript(c.server, c.fetch, this.tableName, this.primaryKey, script.fieldName, edited, message || undefined), (r) => {
@@ -101,39 +497,56 @@ export class DeveloperScreen extends LinesScreen {
}
}
/** A table's application APIs (listTableApis) with their OpenAPI documents (getTableApiDocument). */
export class TableApisScreen extends LinesScreen {
readonly kind = 'apis'
/** Developer mode for a table: Events (the ESB with counters), JSON (the metadata), Metadata and APIs. */
export class TableDeveloperScreen extends DeveloperView {
readonly kind = 'table-developer'
private meta?: TableMetaData
private metaError?: string
private esb?: J
private esbError?: string
private apis?: TableApi[]
private apisError?: string
constructor(store: AppStore, readonly tableName: string) {
super(store, `APIs · ${store.meta?.tables?.[tableName]?.label ?? tableName}`)
constructor(store: AppStore, tableName: string) {
super(store, tableName, `Developer · ${store.meta?.tables?.[tableName]?.label ?? tableName}`)
this.refresh()
}
refresh(): void {
const s = this.store
const c = s.client!
this.start(async () => {
const apis = await listTableApis(c.server, c.fetch, this.tableName)
if (!apis.length) return [{ text: 'No application API exposes this table.', fg: theme().dim }]
const rows: Row[] = []
for (const a of apis) {
rows.push({ text: `${a.label} (${a.name}) ${a.path}`, fg: theme().acc, bold: true })
for (const v of a.versions) rows.push({ text: ` version ${v.version}${v.version === a.currentVersion ? ' (current)' : ''}`, key: `${a.name}:${v.version}`, action: () => this.document(a.name, v.version) })
}
return rows
})
protected upLabel(): string {
return 'Table'
}
document(api: string, version: string): void {
const s = this.store
const c = s.client!
s.run(getTableApiDocument(c.server, c.fetch, this.tableName, api, version), (doc) => {
const text = JSON.stringify(doc, null, 2)
s.open(new ChoiceOverlay(s, `${api} ${version} OpenAPI document`, [{ label: 'View it here', value: 'view' }, { label: 'Save it to a file', value: 'save' }], (what) => {
if (what === 'view') s.push(new TextScreen(s, `${api} ${version} · ${this.tableName}`, text))
else saveFile(s, 'Save the OpenAPI document', `${this.tableName}-${api}-${version}.json`, (path) => { writeFileSync(path, text); s.message(`Wrote ${path}.`) })
}))
})
protected up(): void {
const below = this.store.routes[this.store.routes.length - 2] as { kind?: string; tableName?: string } | undefined
if (below?.kind === 'table' && below.tableName === this.tableName) this.store.back()
else openTable(this.store, this.tableName)
}
helpTopic() {
return { kind: 'table' as const, name: this.tableName }
}
refresh(): void {
const c = this.store.client!
this.meta = this.esb = this.apis = undefined
this.metaError = this.esbError = this.apisError = undefined
this.load(c.tableMeta(this.tableName, true), (m) => { this.meta = m }, (e) => { this.metaError = errorText(e) })
this.load(c.data<J>('getEsbTableByTable', { path: { table: this.tableName } }), (d) => { this.esb = d }, (e) => { this.esbError = errorText(e) })
this.load(listTableApis(c.server, c.fetch, this.tableName), (a) => { this.apis = a }, (e) => { this.apisError = errorText(e) })
this.rebuild()
}
protected sections(): Section[] {
const ctx = this.linkContext()
const label = this.tableLabel()
const events: Section = this.esbError
? emptySection('events', 'Events', 'ESB Events', `No ESB information: the ESB module is not installed on this server, or you may not read it (${this.esbError}).`)
: this.esb
? { id: 'events', label: 'Events', title: 'ESB Events', description: `What ${label} publishes to the ESB and which processes its events trigger, with message counters since the server started.`, items: esbTableItems(this.esb, ctx, (iso) => displayDateTime(iso)) }
: loadingSection('events', 'Events', 'ESB Events')
const json: Section = this.metaError ? emptySection('json', 'JSON', 'Table JSON', `The table metadata could not be loaded: ${this.metaError}`) : this.meta ? { id: 'json', label: 'JSON', title: 'Table JSON', description: 'The full table metadata as the server sends it.', items: [{ kind: 'code', label: 'Full table metadata', language: 'json', value: this.meta }] } : loadingSection('json', 'JSON', 'Table JSON')
const metadata: Section = this.metaError ? emptySection('metadata', 'Metadata', 'Table Metadata', `The table metadata could not be loaded: ${this.metaError}`) : this.meta ? { id: 'metadata', label: 'Metadata', title: 'Table Metadata', description: `${this.meta.label} (${this.meta.name}): fields, sections, permissions and capabilities.`, items: metadataItems(this.meta as unknown as J) } : loadingSection('metadata', 'Metadata', 'Table Metadata')
const apis: Section = this.apisError ? emptySection('apis', 'APIs', 'Application APIs', `The application APIs could not be listed: ${this.apisError}`) : this.apis ? { id: 'apis', label: 'APIs', title: 'Application APIs', description: 'The application APIs that expose this table, with their OpenAPI documents.', items: apisItems(this, this.store, this.tableName, this.apis) } : loadingSection('apis', 'APIs', 'Application APIs')
return [events, json, metadata, apis]
}
}
+1 -1
View File
@@ -671,7 +671,7 @@ export class RecordScreen implements Screen {
for (const p of this.recordProcesses()) out.push({ section: 'Context', label: `Run ${p.label} on this record`, run: () => openProcess(this.store, p.name, { recordIds: [String(this.primaryKey)] }) })
if (this.store.extensions.has('collaboration')) out.push({ section: 'Context', label: 'Tags…', key: 't', command: 'tags', detail: this.tags?.map((x) => x.label).join(', '), run: () => this.openTags() })
out.push({ section: 'Context', label: 'Audit history', key: 'A', command: 'audits', run: () => this.openAudits() })
out.push({ section: 'Context', label: 'Developer: panels and associated scripts', key: 'D', command: 'dev', run: () => this.openDeveloper() })
out.push({ section: 'Context', label: 'Developer: security, events, scripts, JSON, metadata, audits, APIs', key: 'D', command: 'dev', run: () => this.openDeveloper() })
return out
}
+3 -3
View File
@@ -14,7 +14,7 @@ import { FilterOverlay } from '../overlays/filter'
import { saveFile } from '../overlays/files-helpers'
import { actionsOverlay } from '../overlays/actions'
import { openShareDialog, shareState } from '../overlays/share'
import { TableApisScreen } from './developer'
import { TableDeveloperScreen } from './developer'
import { GridViewSettingsOverlay } from '../overlays/view-settings'
import type { AppStore } from '../store'
import { glyphs, theme } from '../theme'
@@ -657,7 +657,7 @@ export class TableScreen implements Screen {
case 'q': this.quickSearch(); return true
case 'l': this.followCell(); return true
case 'S': this.columnStats(); return true
case 'D': this.store.push(new TableApisScreen(this.store, this.tableName)); return true
case 'D': this.store.push(new TableDeveloperScreen(this.store, this.tableName)); return true
default: return false
}
}
@@ -678,7 +678,7 @@ export class TableScreen implements Screen {
for (const f of ['csv', 'xlsx', 'json', 'tsv']) out.push({ section: 'Context', label: `Export as ${f}`, key: f === 'csv' ? 'x' : undefined, command: `export ${f}`, run: () => this.exportAs(f) })
out.push(...this.bulkEntries())
for (const p of this.tableProcesses()) out.push({ section: 'Context', label: `Run ${p.label}`, detail: this.selection.size ? `${this.selection.size} selected` : 'cursor record', disabled: p.parked, run: () => this.runProcess(p.name) })
out.push({ section: 'Context', label: 'Developer: application APIs of this table', key: 'D', command: 'dev', run: () => this.store.push(new TableApisScreen(this.store, this.tableName)) })
out.push({ section: 'Context', label: 'Developer: ESB events, metadata, JSON and APIs of this table', key: 'D', command: 'dev', run: () => this.store.push(new TableDeveloperScreen(this.store, this.tableName)) })
out.push({ section: 'Context', label: 'Saved views: open, save, delete', command: 'views', run: () => this.savedViews() })
out.push({ section: 'Context', label: `Column statistics: ${this.fields()[this.colCursor]?.label ?? ''}`, key: 'S', run: () => this.columnStats() })
out.push({ section: 'Context', label: 'Quick search', key: 'q', detail: this.quick || undefined, run: () => this.quickSearch() })
+2
View File
@@ -93,6 +93,8 @@ export class AppStore {
signInDeps?: import('./screens/signin').SignInDeps
/** Opens text in $EDITOR and returns the edited text (set by main; suspends the renderer). */
editExternal: (text: string) => Promise<string> = async () => { throw new Error('no external editor in this session') }
/** Copies text to the clipboard (set by main); returns how it was copied, or undefined when there is no clipboard. */
copyText: (text: string) => string | undefined = () => undefined
sidebar?: SidebarLike
private listeners = new Set<Listener>()
private timers: Array<ReturnType<typeof setInterval>> = []
+2
View File
@@ -58,6 +58,8 @@ export interface Screen {
cliEquivalent?(): string | undefined
/** The view-settings dialog for this screen, when it has one. */
viewSettings?(): Overlay | undefined
/** Keys of this screen for the help overlay, listed before the global ones. */
helpKeys?(): Array<[string, string]>
/** The help topic: table, process or app name, for F1. */
helpTopic?(): { kind: 'table' | 'process' | 'app'; name: string } | undefined
}
+19 -6
View File
@@ -10,6 +10,8 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { writeFilePreferences } from '@qrun-io/qqq-core'
import type { TableScreen } from '../src/screens/table'
import type { DeveloperScreen } from '../src/screens/developer'
import { paneText } from '../src/developer/pane'
import { command, isolate, press, startTui, waitFor, type Harness } from './helpers'
let h: Harness | undefined
@@ -80,27 +82,38 @@ test('column statistics of the column under the cursor', async () => {
expect(f).toContain('Alpha ×1')
})
test('developer: panels, a script revision, its logs; table APIs and an OpenAPI document', async () => {
test('developer: sections for panels, scripts (a revision, its logs), JSON; the table view with APIs and an OpenAPI document', async () => {
h = await startTui(130, 44)
await command(h, 'table get acc 1')
await waitFor(h, (x) => x.includes('Alpha'))
await key('D')
await waitFor(h, (x) => x.includes('Panel One') && x.includes('codeRef: acc-script') && x.includes('v2 second'))
const grid = h.store.screen as unknown as { list: { rows: Array<{ key?: string }>; cursor: number } }
grid.list.cursor = grid.list.rows.findIndex((r) => r.key === 'r:51')
await waitFor(h, (x) => x.includes('1 Security') && x.includes('8 Panel One') && x.includes('Record Security'))
// the F-key bar keeps the slots that apply here
const bar = await waitFor(h, (x) => x.includes('3Record'))
expect(bar).toContain('10Quit')
await key('8')
await waitFor(h, (x) => x.includes('panel text'))
await key('3')
await waitFor(h, (x) => x.includes('codeRef') && x.includes('acc-script') && x.includes('second'))
const dev = h.store.screen as unknown as DeveloperScreen
dev.pane().selected = dev.pane().lines.find((l) => paneText(l).startsWith('v2'))!.id
await enter()
await waitFor(h, (x) => x.includes('Show the run logs'))
await press(h, () => h!.setup.mockInput.pressArrow('down'))
await enter()
await waitFor(h, (x) => x.includes('log line one'))
await esc()
await esc()
await key('4')
await waitFor(h, (x) => x.includes('Record raw values (json)') && x.includes('"id": 1'))
await esc()
await command(h, 'table query acc')
await waitFor(h, (x) => x.includes('1 records'))
await key('D')
await waitFor(h, (x) => x.includes('1 Events') && x.includes('4 APIs') && x.includes('3Table'))
await key('4')
await waitFor(h, (x) => x.includes('Public API (pub)'))
await press(h, () => h!.setup.mockInput.pressArrow('down'))
const table = h.store.screen as unknown as DeveloperScreen
table.pane().selected = table.pane().lines.find((l) => l.run)!.id
await enter()
await waitFor(h, (x) => x.includes('View it here'))
await enter()
+201
View File
@@ -0,0 +1,201 @@
/*
* Copyright 2026 QRun.IO, Inc.
* Licensed under the Apache License, Version 2.0 (see LICENSE).
*/
import { expect, test } from 'bun:test'
import type { DeveloperPanel } from '@qrun-io/qqq-core'
import { clipLine, codeSegLines, jsonSegLines, lineText, wrapLine, wrapText } from '../src/developer/json'
import { emptyColumnsNote, esbTableItems, linkTarget, metadataItems, panelSection, SectionNav, webUrl, type Section } from '../src/developer/model'
import { codeLines, gridLines, itemLines, PaneModel, paneText } from '../src/developer/pane'
const record = { id: 12, firstName: 'Clone of: Blair', lastName: 'Sample', email: 'blair@example.invalid', annualSalary: 150000 }
// the ESB panel's sample event exactly as the server sends it: minified, with spaces inside strings
const sampleEvent = JSON.stringify({ specversion: '1.0', source: 'qqq://qqq-sample/table/person', subject: '12', data: { record, oldRecord: record, changedFields: [] } })
const ESB_PANEL: DeveloperPanel = {
id: 'esb', title: 'ESB Events', order: 300, description: 'This table publishes its record changes to the ESB as CloudEvents.', statusLabel: 'Publishing', statusTone: 'SUCCESS',
blocks: [
{ type: 'KEY_VALUES', label: 'CloudEvents attributes', items: [{ label: 'type', value: 'qqq.table.person.updated' }, { label: 'subject', value: '12' }] },
{ type: 'TABLE', label: 'Published to', columns: ['Destination', 'Type', 'Provider', 'Events', 'Queued'], rows: [['personEvents', 'TOPIC', 'sampleArtemis', 'INSERT, UPDATE, DELETE', '']] },
{ type: 'TABLE', label: 'Subscribers (triggers)', columns: ['Trigger', 'Process', 'Mode', 'State', 'Max attempts', 'Dead letters'], rows: [['syncPerson.personEvents', 'Sync Person', 'SINGLE', 'RUNNING', '3', '']] },
{ type: 'CODE', label: 'Sample event for this record', language: 'json', code: sampleEvent },
{ type: 'LINKS', label: 'ESB tools', links: [{ label: 'Table developer view (messages, dead letters, operate)', href: '/app/person/dev' }, { label: 'ESB overview', href: '/app/esb' }] },
],
}
const CTX = { server: 'http://localhost:8077', tables: { person: {}, order: {} }, processes: { syncPerson: {} } }
// ---- JSON renderer ----
test('JSON is pretty-printed with two-space indent and tinted by token', () => {
const lines = jsonSegLines({ a: 1, b: ['x', true, null], c: {} })
expect(lines.map(lineText)).toEqual(['{', ' "a": 1,', ' "b": [', ' "x",', ' true,', ' null', ' ],', ' "c": {}', '}'])
const b = lines[1]!
expect(b.find((s) => s.text === '"a"')!.tone).toBe('key')
expect(b.find((s) => s.text === '1')!.tone).toBe('number')
expect(lines[3]!.find((s) => s.text === '"x"')!.tone).toBe('string')
expect(lines[4]!.find((s) => s.text === 'true')!.tone).toBe('literal')
})
test('the sample event is never split on the spaces inside its strings, nor repeated: one source line, one screen line', () => {
for (const width of [30, 40, 60, 100]) {
const { lines, json } = codeLines({ code: sampleEvent, language: 'json' }, width)
expect(json).toEqual(JSON.parse(sampleEvent))
const texts = lines.map(lineText)
// as many lines as the pretty-printed value, each within the width
expect(texts.length).toBe(JSON.stringify(JSON.parse(sampleEvent), null, 2).split('\n').length)
for (const t of texts) expect(t.length).toBeLessThanOrEqual(width)
// the value with a space in it appears once per occurrence (record and oldRecord), never as fragments
expect(texts.filter((t) => t.includes('"firstName"')).length).toBe(2)
expect(texts.some((t) => t.trim() === 'of:' || t.trim().startsWith('Blair'))).toBe(false)
}
})
test('a long string is cut with an ellipsis in the pane, and soft-wrapped losslessly in the viewer', () => {
const long = 'x'.repeat(50) + ' tail'
const line = jsonSegLines({ note: long })[1]!
const clipped = clipLine(line, 20)
expect(lineText(clipped)).toHaveLength(20)
expect(lineText(clipped).endsWith('…')).toBe(true)
const pieces = wrapLine(line, 20)
expect(pieces.every((p) => lineText(p).length <= 20)).toBe(true)
expect(pieces.map(lineText).join('')).toBe(lineText(line))
expect(clipLine(line, 200)).toBe(line)
})
test('code that is not JSON is shown as written; prose wraps without losing characters', () => {
expect(codeSegLines('SELECT\n "id"\nFROM t', 'sql').lines.map(lineText)).toEqual(['SELECT', ' "id"', 'FROM t'])
expect(codeSegLines('{not json', 'json').json).toBeUndefined()
const text = 'Read-only SQL for this record on the h2 backend averyveryveryverylongidentifierthatdoesnotfit end'
const lines = wrapText(text, 16)
expect(lines.every((l) => l.length <= 16)).toBe(true)
expect(lines.join('').replace(/ /g, '')).toBe(text.replace(/ /g, ''))
})
// ---- section model ----
test('a developer panel becomes a section: status and tone, pairs, grids with — notes, code, links as targets', () => {
const s = panelSection(ESB_PANEL, CTX, { id: 'events', label: 'Events' })
expect(s).toMatchObject({ id: 'events', label: 'Events', title: 'ESB Events', status: 'Publishing', statusTone: 'ok' })
expect(s.items.map((i) => i.kind)).toEqual(['heading', 'pairs', 'heading', 'grid', 'heading', 'grid', 'code', 'heading', 'link', 'link'])
const grids = s.items.filter((i) => i.kind === 'grid') as Array<{ note?: string }>
expect(grids[0]!.note).toContain('Queued: the broker did not report a queue depth')
expect(grids[1]!.note).toContain('Dead letters')
const links = s.items.filter((i) => i.kind === 'link') as Array<{ target: unknown }>
expect(links[0]!.target).toEqual({ kind: 'table-developer', table: 'person' })
expect(links[1]!.target).toEqual({ kind: 'browser', url: 'http://localhost:8077/app/esb' })
})
test('the section lines: empty cells show —, the sample event once, links say where they go', () => {
const s = panelSection(ESB_PANEL, CTX, { id: 'events', label: 'Events' })
const lines = itemLines(s, 100, () => () => undefined)
const text = lines.map(paneText)
expect(text.every((t) => t.length <= 100)).toBe(true)
expect(text.find((t) => t.startsWith('personEvents'))).toMatch(/—\s*$/)
expect(text.filter((t) => t.includes('"firstName": "Clone of: Blair"')).length).toBe(2)
expect(text.find((t) => t.includes('ESB overview'))).toContain('(browser only)')
expect(text.find((t) => t.includes('Table developer view'))).toContain('table developer view · person')
// selectable: pairs, grid rows, the code block, links
const code = lines.find((l) => l.view?.code === sampleEvent)!
expect(code.copy).toBe(JSON.stringify(JSON.parse(sampleEvent), null, 2))
expect(lines.filter((l) => l.id).length).toBe(2 + 1 + 1 + 1 + 2)
})
test('empty-column notes: only for columns empty in every row; generic text for unknown headers', () => {
expect(emptyColumnsNote(['A', 'Queued'], [['x', ''], ['y', '']])).toContain('Queued:')
expect(emptyColumnsNote(['A', 'Queued'], [['x', ''], ['y', '4']])).toBeUndefined()
expect(emptyColumnsNote(['A', 'Other'], [['x', ' ']])).toBe('— Other: the server reported no value.')
expect(emptyColumnsNote(['A'], [])).toBeUndefined()
})
test('the section bar: ← → wrap, digits jump, the section is kept by id when sections change', () => {
const sec = (id: string): Section => ({ id, label: id, title: id, items: [] })
const nav = new SectionNav([sec('security'), sec('events'), sec('scripts')])
expect(nav.onKey('left')).toBe(true)
expect(nav.current!.id).toBe('scripts')
nav.onKey('right')
expect(nav.current!.id).toBe('security')
nav.onKey('2')
expect(nav.current!.id).toBe('events')
expect(nav.onKey('7')).toBe(false)
expect(nav.onKey('x')).toBe(false)
nav.set([sec('security'), sec('extra'), sec('events'), sec('scripts')])
expect(nav.current!.id).toBe('events')
expect(nav.digits()).toBe('1-4')
expect(nav.select('nope')).toBe(false)
})
test('the ESB table section: publications and subscribers with counters, — notes, the overview link browser only', () => {
const destination = { name: 'personEvents', type: 'TOPIC', provider: 'sampleArtemis', counters: { published: 39, publishFailures: 0, lastActivity: '2026-11-01T10:55:08Z' }, queueInfo: null }
const items = esbTableItems({ publications: [{ destination, events: ['INSERT'] }], subscribers: [{ name: 'syncPerson.personEvents', processLabel: 'Sync Person', state: 'RUNNING', counters: { consumed: 326, failed: 0, deadLettered: 0, lastError: 'boom' }, deadLetter: { messageCount: null } }] }, CTX, () => 'when')
const grids = items.filter((i) => i.kind === 'grid') as Array<{ rows: Array<{ cells: string[] }>; note?: string }>
expect(grids[0]!.rows[0]!.cells).toEqual(['personEvents', 'TOPIC', 'sampleArtemis', 'INSERT', '39', '0', ''])
expect(grids[1]!.rows[0]!.cells).toEqual(['syncPerson.personEvents', 'Sync Person', 'RUNNING', '326', '0', '0', ''])
expect(grids[1]!.note).toContain('Dead letters')
expect(items.some((i) => i.kind === 'text' && i.text === 'syncPerson.personEvents: boom')).toBe(true)
expect(items.at(-1)).toMatchObject({ kind: 'link', target: { kind: 'browser' } })
expect(esbTableItems({ publications: [], subscribers: [] }, CTX, String)[0]).toMatchObject({ kind: 'empty' })
})
test('metadata items: summary pairs, a fields grid and a sections grid', () => {
const items = metadataItems({ name: 'person', label: 'Person', primaryKeyField: 'id', readPermission: true, editPermission: true, capabilities: ['TABLE_QUERY'], fields: { id: { label: 'Id', type: 'INTEGER', isEditable: false }, email: { label: 'Email', type: 'STRING', isRequired: true } }, sections: [{ name: 'identity', label: 'Identity', tier: 'T1', fieldNames: ['id'] }] })
const pairs = (items[0] as { pairs: Array<{ label: string; value: string }> }).pairs
expect(pairs.find((p) => p.label === 'Permissions')!.value).toBe('read, edit')
expect(pairs.find((p) => p.label === 'Fields')!.value).toBe('2')
const fields = items[2] as { rows: Array<{ cells: string[] }> }
expect(fields.rows.map((r) => r.cells)).toEqual([['id', 'Id', 'INTEGER', '', '', 'read-only'], ['email', 'Email', 'STRING', 'yes', '', '']])
})
// ---- link-action mapping ----
test('web links map to TUI destinations; the rest are browser only with the server URL', () => {
expect(linkTarget('/app/person/dev', CTX)).toEqual({ kind: 'table-developer', table: 'person' })
expect(linkTarget('/app/person/12/dev', CTX)).toEqual({ kind: 'record-developer', table: 'person', primaryKey: '12' })
expect(linkTarget('/app/person/12', CTX)).toEqual({ kind: 'record', table: 'person', primaryKey: '12' })
expect(linkTarget('/app/person', CTX)).toEqual({ kind: 'table', table: 'person' })
expect(linkTarget('/app/person?filter=x', CTX)).toEqual({ kind: 'table', table: 'person' })
expect(linkTarget('/app/syncPerson', CTX)).toEqual({ kind: 'process', process: 'syncPerson' })
expect(linkTarget('/app/esb', CTX)).toEqual({ kind: 'browser', url: 'http://localhost:8077/app/esb' })
expect(linkTarget('/app/sqlWorkbenchRunner?sql=SELECT%201', CTX)).toEqual({ kind: 'browser', url: 'http://localhost:8077/app/sqlWorkbenchRunner?sql=SELECT%201' })
expect(linkTarget('https://example.test/x', CTX)).toEqual({ kind: 'browser', url: 'https://example.test/x' })
expect(linkTarget('/app/person/12/dev/extra', CTX).kind).toBe('browser')
expect(webUrl('http://h:1/', 'app/x')).toBe('http://h:1/app/x')
})
test('ACTIONS blocks become process targets, with the record id when the action asks for it', () => {
const s = panelSection({ id: 'qs', title: 'Quick Search', order: 1, blocks: [{ type: 'ACTIONS', label: 'Actions', actions: [{ label: 'Reindex', processName: 'reindex', includeRecordId: true }, { label: 'All', processName: 'all' }] }] }, CTX, { recordId: '7' })
const links = s.items.filter((i) => i.kind === 'link') as Array<{ target: unknown }>
expect(links.map((l) => l.target)).toEqual([{ kind: 'process', process: 'reindex', recordId: '7' }, { kind: 'process', process: 'all' }])
})
// ---- grid and pane ----
test('grids use the main grid layout: fixed columns within the width, cells cut with …, empty cells —', () => {
const g = gridLines(['Trigger', 'Process', 'State', 'Consumed', 'Dead letters'], [['syncPerson.personEvents', 'Sync Person', 'RUNNING', '326', '']], 40)
const header = g.header.map((s) => s.text).join('')
const row = g.rows[0]!.map((s) => s.text).join('')
expect(header.length).toBeLessThanOrEqual(40)
expect(row.length).toBe(header.length)
expect(row).toContain('…')
expect(row.trimEnd().endsWith('—')).toBe(true)
expect(g.hidden).toBe(0)
})
test('the pane scrolls through unselectable lines and stops the cursor on selectable ones', () => {
const pane = new PaneModel()
const lines = Array.from({ length: 30 }, (_, i) => ({ spans: [{ text: `line ${i}` }], ...(i === 2 || i === 20 ? { id: `s${i}` } : {}) }))
pane.update(lines, 10)
expect(pane.selected).toBe('s2')
pane.onKey({ name: 'down', sequence: '' })
// s20 is out of view: the pane scrolls one line instead of jumping
expect(pane.top).toBe(1)
for (let i = 0; i < 10; i++) pane.onKey({ name: 'down', sequence: '' })
expect(pane.selected).toBe('s20')
expect(pane.position()).toMatch(/^lines \d+–\d+ of 30$/)
pane.onKey({ name: 'home', sequence: '' })
expect(pane.top).toBe(0)
pane.onKey({ name: 'end', sequence: '' })
expect(pane.top).toBe(20)
pane.update(lines.slice(0, 5), 10)
expect(pane.position()).toBe('')
})