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:
1 parent
213c605d95
commit
f236c7b833
16 files changed
+1718
-92
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
@@ -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('')
|
||||
}
|
||||
@@ -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} />)
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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() })
|
||||
|
||||
@@ -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>> = []
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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('')
|
||||
})
|
||||
Reference in new issue
Block a user