Merge feat/m1-kernel-cli (165c002) into feat/m1-hardening
Brings in the TUI stream (program.ts with createProgram and runCli, the io object, qqq ui, session/login, local-state, filter completion, the result cursor, the file navigator and env/doctor). Resolutions: - main.ts is the base's thin entry; it still awaits the 0.1 credentials import. - program.ts carries the option fixes (--version only, --server without -s) and registers qqq doctor. - One set of ui helpers: the TUI's. readUiSettings/writeUiSettings are gone; qqq config set|get download-dir, upload-dir and download-mode use readFilePreferences/writeFilePreferences (ui.files), and the new qqq config set|get theme uses readTheme/writeTheme. readConfig keeps the corrupt-file refusal and unknown sections; the profiles test covers files, theme and an unknown section together. - qqq doctor's terminal rows come from checkEnvironment, with formatReport under the table; --fix asks before each applyLiveFix and calls appendTmuxConf only after a y typed at a terminal. - runCli and the prompts coexist (stdin is not a terminal in runCli, so prompts refuse; direct stdout writes are captured), tested in a child process. - @qrun-io/qqq-ui-api-conformance is a dependency of the CLI package. - The parity matrix is regenerated; docs/review-2026-10-31.md records each merge decision. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
77 files changed
+9250
-366
No files matched your search
@@ -28,51 +28,37 @@ qqq-sample-server status | log | restart | stop
|
||||
|
||||
Data resets on every restart. Mock auth means no sign-in; `POST /qqq/v1/manageSession` gives a session.
|
||||
|
||||
## M0 shell (2026-10-31): classic TUI against the sample server
|
||||
## TUI (`qqq ui`, M3)
|
||||
|
||||
```sh
|
||||
qqq-tui-spike # the shell: app tree, tables, records, processes, live footer
|
||||
qqq-tui-spike test # in-memory renderer tests
|
||||
qqq ui # default profile; qqq --profile sample ui
|
||||
qqq ui table query person -w "lastName STARTS_WITH S" # straight to a screen
|
||||
qqq ui --check # terminal and tmux check with fixes (also runs quickly at startup)
|
||||
npm run test:tui # in-memory renderer tests; packages/tui/scripts/smoke.sh drives it in tmux
|
||||
```
|
||||
|
||||
Keys (the function-key bar at the bottom is the truth):
|
||||
Keys (the function-key bar is the truth; F1 or `?` lists them; every action is also in F9 and on `:`):
|
||||
|
||||
| Key | Does | Key | Does |
|
||||
|---|---|---|---|
|
||||
| F1 | help | Esc | back a screen, or back to the Apps tree |
|
||||
| F2 | go to any table or process by name | Tab | switch between the Apps tree and the content |
|
||||
| F3 | view the record under the cursor | Enter | open (tree item, table row) |
|
||||
| F4 | edit (M3) | ↑ ↓ PgUp PgDn Home End | move and page, server-side paging on tables |
|
||||
| F5 | refresh | ← → | scroll columns; expand or collapse tree nodes |
|
||||
| F6 | filter a table: `field OPERATOR value[,value]` | `s` | sort by the bracketed column |
|
||||
| F7 | new record (M3) | `:` | command line: `table person lastName STARTS_WITH S`, `record pet 3`, `process sleepInteractive`, `home`, `quit` |
|
||||
| F8 | delete the record under the cursor, with confirm | F9 | menu |
|
||||
| F10 | quit | Ctrl+C | quit immediately |
|
||||
| F1 `?` | help, what you can do here, help search | Esc | back; the grid keeps its row, filter and page |
|
||||
| F2 `/` `.` Ctrl+K | go to anything (apps, tables, processes, recents, commands) | Tab | Apps pane / content |
|
||||
| F3 Enter | view / open | PgUp PgDn, Ctrl+F Ctrl+B | page (Ctrl+F/B work even when tmux takes PgUp) |
|
||||
| F4 `e` | edit | `]` `[` (PgDn PgUp in a record) | next / previous record of the list |
|
||||
| F5 `r` | refresh | `s` | sort by the column under the cursor |
|
||||
| F6 `f` | filter: guided builder or the grammar with completion | Space / `b` | select rows / run a process on them |
|
||||
| F7 `n` | new (a child on a child list) | `x` `g` `v` | export, go to by key, view settings |
|
||||
| F8 `d` | delete, asks | Ctrl+S | save a form or continue a process step |
|
||||
| F9 | menu: every command, searchable | `:` | command line, history and completion |
|
||||
| F10 | Quit, Reset to home, or Cancel (`:reset`) | Ctrl+C | quit now |
|
||||
|
||||
Footer status line, live: server, auth and session, UI API version, ping (every 5 s), realtime stream state
|
||||
(reconnects on its own), job count (`n/a` when the server has no `/jobs`), last stream event, clock.
|
||||
Things to try: `:table query person`, Enter on a row, PgDn through records, Esc (same row); F6, add a
|
||||
condition, Tab to the grammar and type `AND (fi` then Tab; `:table create placeAddress` (country, state, city
|
||||
narrow); Space on two people, `b`, Person Bulk Edit; `:app SampleWidgetsDashboard`; F9 Settings for themes,
|
||||
download folder, PIN and the screen lock; `:theme light`. See `packages/tui/README.md` for settings and for the
|
||||
keys your terminal or tmux may take (`qqq ui --check` finds them).
|
||||
|
||||
Things to try: open Person, PgDn through Pets or Cities, F6 with `birthDate GREATER_THAN 1990-01-01`,
|
||||
delete a pet with F8 and watch the count, restart the server with `qqq-sample-server restart` and watch
|
||||
the footer go to `reconnecting` and back.
|
||||
|
||||
## M0 legacy screens: generated grid and form
|
||||
|
||||
```sh
|
||||
QQQ_SPIKE_SCREEN=legacy qqq-tui-spike # Ctrl+G grid, Ctrl+F form, Ctrl+P process, Ctrl+Q quit
|
||||
qqq-tui-spike binary # the compiled macOS binary (legacy screens)
|
||||
```
|
||||
|
||||
What to look at:
|
||||
- Grid: 10,000 generated rows x 40 columns. Arrows, PageUp/Down, Home/End, Left/Right scroll columns,
|
||||
`s` sorts the cursor column. The status line shows key-to-frame latency. Resize the window.
|
||||
- Form: Tab between every field type, type into the Home State field to filter the picker, Enter to
|
||||
pick, Ctrl+S validates and shows errors beside fields.
|
||||
- Process: runs the sample's Sleep Interactive process on the server with an async job and polling.
|
||||
Change the milliseconds, press Enter. Watch the log lines at the bottom.
|
||||
|
||||
Known: typing in the same instant as Tab (paste, automation) lands in the previous field; a human does
|
||||
not hit this. Windows binary is cross-compiled and unverified.
|
||||
The M0 spike (`qqq-tui-spike`, `packages/tui-spike`) stays in the repo for reference.
|
||||
|
||||
## CLI (`qqq`, M1 in progress)
|
||||
|
||||
@@ -182,22 +168,25 @@ unreachable, rate limited or a 502/503/504, 5 not found, 6 validation, 70 server
|
||||
|
||||
Downloads (`table download`, `table export`, `report run`, `widget get --csv`, process files) go where `--out` or
|
||||
`--out-dir` says (`-` is stdout), else into the download directory when one is set, else the current directory;
|
||||
a default name never replaces a file (`name-1.ext`). The setting lives in the `ui` section of
|
||||
`~/.config/qqq/config.json`, which the TUI reads too:
|
||||
a default name never replaces a file (`name-1.ext`). The settings live in the `ui` section of
|
||||
`~/.config/qqq/config.json` and are the same ones the TUI's F9 Settings change:
|
||||
|
||||
```sh
|
||||
qqq config set download-dir ~/Downloads/qqq # created when missing; an empty value removes it
|
||||
qqq config get ; qqq config get download-dir # also upload-dir and download-prompt
|
||||
qqq config get ; qqq config get download-dir # also upload-dir, download-mode (ask|silent, the TUI) and theme
|
||||
qqq config set theme classic ; qqq -p sample config set theme light # globally, or for one profile
|
||||
qqq table export person # wrote ~/Downloads/qqq/person.csv
|
||||
```
|
||||
|
||||
Is everything in order? `qqq doctor` checks the profile, the server (reachable, UI API version within 1.6.0 to
|
||||
1.8.0, authentication type, the sign-in accepted, extensions), the keyring and the metadata cache, and exits 1 with
|
||||
what failed. `--conformance` adds the read-only conformance checks. The terminal checks (tmux keys, colour, size,
|
||||
`--fix`) arrive with the TUI's kernel module; until then the report says `skip` for them.
|
||||
what failed. `--conformance` adds the read-only conformance checks. The terminal rows come from the same check as
|
||||
`qqq ui --check` (tmux bindings that take PageUp/PageDown and F-keys, alternate screen, colour, unicode, size);
|
||||
`--fix` offers each live tmux fix (`--yes` answers those) and appends them to `~/.tmux.conf` only after a y typed at
|
||||
the terminal.
|
||||
|
||||
```sh
|
||||
qqq doctor ; qqq doctor --conformance
|
||||
qqq doctor ; qqq doctor --conformance ; qqq doctor --fix
|
||||
```
|
||||
|
||||
Contingent fields (field 1 narrows field 2) are handled by the kernel's form session, for tables and process
|
||||
|
||||
+164
-147
@@ -3,7 +3,7 @@
|
||||
Generated by `scripts/parity-matrix.py` from the Next UI acceptance matrix (`qqq-frontend-next/docs/acceptance/feature-matrix.md`,
|
||||
401 rows). Same ids; two columns added. **pass** = verified against the local sample server in this repo's
|
||||
development; **partial** = works with the noted gap; **todo** = planned; **n/a** = browser-only, with the reason.
|
||||
The TUI column fills in during M3.
|
||||
The TUI column is `qqq ui` (M3); its notes start with "TUI:".
|
||||
|
||||
## Summary (CLI column)
|
||||
|
||||
@@ -22,88 +22,105 @@ The TUI column fills in during M3.
|
||||
| WID | 69 | 29 | 27 | 0 | 13 |
|
||||
| **all** | 401 | 173 | 82 | 40 | 106 |
|
||||
|
||||
## Summary (TUI column)
|
||||
|
||||
| Family | rows | pass | todo | partial | n/a |
|
||||
|---|---|---|---|---|---|
|
||||
| HLP | 6 | 1 | 4 | 1 | 0 |
|
||||
| INT | 14 | 9 | 0 | 1 | 4 |
|
||||
| NAV | 40 | 15 | 1 | 7 | 17 |
|
||||
| PRC | 60 | 21 | 30 | 3 | 6 |
|
||||
| PRF | 5 | 3 | 2 | 0 | 0 |
|
||||
| QRY | 70 | 23 | 38 | 4 | 5 |
|
||||
| REC | 65 | 22 | 39 | 4 | 0 |
|
||||
| REL | 9 | 4 | 3 | 2 | 0 |
|
||||
| RPT | 20 | 0 | 20 | 0 | 0 |
|
||||
| SEC | 43 | 7 | 23 | 1 | 12 |
|
||||
| WID | 69 | 13 | 43 | 2 | 11 |
|
||||
| **all** | 401 | 118 | 203 | 25 | 55 |
|
||||
|
||||
## Rows
|
||||
|
||||
| ID | Feature | CLI | TUI | Note |
|
||||
|---|---|---|---|---|
|
||||
| NAV-001 | Direct link to a table list renders seeded rows | n/a | n/a | browser shell |
|
||||
| NAV-002 | Sidebar lists every permitted top-level app once, by label, in backend order, with nested apps only under thei | n/a | n/a | browser shell |
|
||||
| NAV-003 | Apps nested deeper than two levels render as nested sidebar groups; deep links expand every enclosing group | n/a | n/a | browser shell |
|
||||
| NAV-004 | Sidebar click navigation to app homes, tables and processes with active-entry highlighting and collapse/expand | n/a | n/a | browser shell |
|
||||
| NAV-005 | Declared icons (QIcon name, color, path) render on navigation surfaces | n/a | n/a | browser shell |
|
||||
| NAV-006 | Hidden tables and processes never appear in navigation; direct links to hidden objects the user may not access | pass | todo | hidden tables and processes are not listed; direct use is refused by the server |
|
||||
| NAV-007 | A hidden table the user may access opens by direct link (hidden is not denied) | pass | todo | qqq table get on a hidden table the user may read |
|
||||
| NAV-008 | App home sections list processes (Actions), reports (Reports) and tables (Data) by label with icons, and each | pass | todo | qqq apps show <app>: sections of processes, reports, tables and child apps, then widgets |
|
||||
| NAV-009 | App home table entries show the backend record count | pass | todo | qqq apps show counts each table with TABLE_COUNT (--no-counts skips) |
|
||||
| NAV-010 | App homes of apps with child apps, only processes, or nothing render without errors | n/a | todo | app home rendering |
|
||||
| NAV-011 | A dashboard app (widgets only) opens from navigation and shows one labelled container per permitted declared w | todo | todo | widgets per app |
|
||||
| NAV-012 | Branding: sidebar logo (Material parity), app name, favicon from the icon, accent color, and partial branding | n/a | n/a | browser shell |
|
||||
| NAV-001 | Direct link to a table list renders seeded rows | n/a | pass | browser shell; TUI: qqq ui table query <t>; :table query <t> |
|
||||
| NAV-002 | Sidebar lists every permitted top-level app once, by label, in backend order, with nested apps only under thei | n/a | pass | browser shell; TUI: Apps tree, backend order |
|
||||
| NAV-003 | Apps nested deeper than two levels render as nested sidebar groups; deep links expand every enclosing group | n/a | pass | browser shell; TUI: nested apps in the tree |
|
||||
| NAV-004 | Sidebar click navigation to app homes, tables and processes with active-entry highlighting and collapse/expand | n/a | pass | browser shell; TUI: Enter opens; current entry marked; arrows expand and collapse |
|
||||
| NAV-005 | Declared icons (QIcon name, color, path) render on navigation surfaces | n/a | partial | browser shell; TUI: markers for processes and reports; icons not drawn |
|
||||
| NAV-006 | Hidden tables and processes never appear in navigation; direct links to hidden objects the user may not access | pass | pass | hidden tables and processes are not listed; direct use is refused by the server; TUI: hidden tables and processes are not listed |
|
||||
| NAV-007 | A hidden table the user may access opens by direct link (hidden is not denied) | pass | pass | qqq table get on a hidden table the user may read; TUI: :table query and :record open hidden tables the user may read |
|
||||
| NAV-008 | App home sections list processes (Actions), reports (Reports) and tables (Data) by label with icons, and each | pass | pass | qqq apps show <app>: sections of processes, reports, tables and child apps, then widgets; TUI: app home: sections with tables, processes, reports |
|
||||
| NAV-009 | App home table entries show the backend record count | pass | pass | qqq apps show counts each table with TABLE_COUNT (--no-counts skips); TUI: record counts beside tables |
|
||||
| NAV-010 | App homes of apps with child apps, only processes, or nothing render without errors | n/a | pass | app home rendering |
|
||||
| NAV-011 | A dashboard app (widgets only) opens from navigation and shows one labelled container per permitted declared w | todo | pass | widgets per app; TUI: dashboard apps draw their widgets |
|
||||
| NAV-012 | Branding: sidebar logo (Material parity), app name, favicon from the icon, accent color, and partial branding | n/a | partial | browser shell; TUI: app name in the title bar |
|
||||
| NAV-013 | Branding banners render in their slots (top of site, top of body, side nav under logo) with text or sanitized | n/a | n/a | browser shell |
|
||||
| NAV-014 | Breadcrumbs show the full app hierarchy for tables, processes, records, create and edit pages, and each crumb | n/a | n/a | browser shell |
|
||||
| NAV-014 | Breadcrumbs show the full app hierarchy for tables, processes, records, create and edit pages, and each crumb | n/a | pass | browser shell; TUI: title-bar path: apps, table, record or form |
|
||||
| NAV-015 | Document title names the page, its enclosing apps and the application (Material parity) | n/a | n/a | browser shell |
|
||||
| NAV-016 | The site root redirects to the dashboard | n/a | n/a | browser shell |
|
||||
| NAV-017 | Dashboard landing page: counts, quick actions and application cards derived from navigable, permitted metadata | todo | todo | whoami shows counts; dashboard landing is the TUI |
|
||||
| NAV-018 | Direct (hard-loaded) links open every route shape with the shell, sidebar state and breadcrumbs | n/a | n/a | browser shell |
|
||||
| NAV-017 | Dashboard landing page: counts, quick actions and application cards derived from navigable, permitted metadata | todo | partial | whoami shows counts; dashboard landing is the TUI; TUI: home: counts and app list; no quick actions |
|
||||
| NAV-018 | Direct (hard-loaded) links open every route shape with the shell, sidebar state and breadcrumbs | n/a | pass | browser shell; TUI: qqq ui <route in the CLI grammar> |
|
||||
| NAV-019 | Browser refresh keeps the route, its query string and the rendered content | n/a | n/a | browser shell |
|
||||
| NAV-020 | Browser back and forward traverse navigation history with the right content | n/a | n/a | browser shell |
|
||||
| NAV-021 | An unknown /app/{name} shows an in-shell not-found state with a link to the dashboard | n/a | n/a | browser shell |
|
||||
| NAV-022 | Unknown deeper or top-level paths return the 404 page with a working home link | n/a | n/a | browser shell |
|
||||
| NAV-023 | Record lookup by key: /app/{table}/key?{field}={value} opens the single matching record | pass | todo | qqq table get <t> --key field=value: one match shows the record, none exits 5, several exit 2 |
|
||||
| NAV-024 | Command palette (Ctrl+K and '.') lists navigable apps, tables and processes by label with type and app context | todo | todo | shell completion covers names; a palette is the TUI |
|
||||
| NAV-025 | Header search, '/' search dialog and search page jump to navigation targets and recently viewed records by lab | pass | todo | qqq search |
|
||||
| NAV-023 | Record lookup by key: /app/{table}/key?{field}={value} opens the single matching record | pass | pass | qqq table get <t> --key field=value: one match shows the record, none exits 5, several exit 2; TUI: g: primary key or a configured unique key (kernel gotoKeys) |
|
||||
| NAV-024 | Command palette (Ctrl+K and '.') lists navigable apps, tables and processes by label with type and app context | todo | pass | shell completion covers names; a palette is the TUI; TUI: F2, /, ., Ctrl+K palette with type and app context |
|
||||
| NAV-025 | Header search, '/' search dialog and search page jump to navigation targets and recently viewed records by lab | pass | partial | qqq search; TUI: palette lists recents; no search page |
|
||||
| NAV-026 | Mobile navigation drawer opens from the header, navigates and closes | n/a | n/a | browser shell |
|
||||
| NAV-027 | Developer page reports the backend metadata counts and the running framework versions | pass | todo | qqq whoami: apiVersion, features, counts |
|
||||
| NAV-028 | Reports in the app tree are listed under their app (sidebar and app home Reports group) and open the report pa | partial | todo | qqq report list (reports with their app); the sample app has no reports to verify |
|
||||
| NAV-027 | Developer page reports the backend metadata counts and the running framework versions | pass | partial | qqq whoami: apiVersion, features, counts; TUI: F9 Connection: versions, features, extensions, metadata generation |
|
||||
| NAV-028 | Reports in the app tree are listed under their app (sidebar and app home Reports group) and open the report pa | partial | partial | qqq report list (reports with their app); the sample app has no reports to verify; TUI: reports listed; run through :report run |
|
||||
| NAV-029 | A user who may access no apps sees the Dashboard entry and the no-apps message (Material NoApps parity) | n/a | n/a | browser shell |
|
||||
| NAV-030 | Global search (header box, '/' dialog, search page) lists the records a backend record search (POST /qqq/v1/se | pass | todo | qqq search over POST /search |
|
||||
| NAV-030 | Global search (header box, '/' dialog, search page) lists the records a backend record search (POST /qqq/v1/se | pass | partial | qqq search over POST /search; TUI: palette calls POST /search; the sample has no provider |
|
||||
| NAV-031 | Record search respects table read permissions and record security locks, in the UI and the API | pass | todo | server-enforced; refusals shown |
|
||||
| NAV-032 | Without searchable tables (the backend declares no searchFields) global search stays local and never calls a s | n/a | n/a | browser shell |
|
||||
| NAV-033 | Login branding before sign-in: logo, app name, accent color, favicon and title from GET /qqq/v1/metaData/authe | n/a | n/a | browser shell |
|
||||
| NAV-034 | Material table-scoped URLs: /app/{table}/{process} opens the table process (or an instance process); /app/{tab | n/a | n/a | browser shell |
|
||||
| NAV-035 | Process runs launched from a record view or a filtered query return there (Return and Cancel honor returnTo) | n/a | n/a | browser shell |
|
||||
| NAV-035 | Process runs launched from a record view or a filtered query return there (Return and Cancel honor returnTo) | n/a | pass | browser shell; TUI: Esc returns to the record or query, cursor kept |
|
||||
| NAV-036 | Material app settings (MaterialDashboardAppMetaData): showAppLabelOnHomeScreen and includeTableCountsOnHomeScr | n/a | n/a | browser shell |
|
||||
| NAV-037 | Mobile navigation drawer is a keyboard-usable modal dialog: focus moves in on open, Tab stays inside, and Esca | n/a | n/a | browser shell |
|
||||
| NAV-038 | Shell on phones and tablets: skip link, header controls, breadcrumbs, navigation entries, user menu, app homes | n/a | n/a | browser shell |
|
||||
| NAV-055 | Material Dashboard CSS and test hooks remain available on navigation, banners, app homes, forms, record views | n/a | n/a | browser shell |
|
||||
| NAV-056 | The Material Dashboard theme from versioned QQQ metadata changes Next UI tokens, application CSS and the brand | n/a | n/a | browser shell |
|
||||
| PRF-001 | large table: a 10,000-row, 40-column table opens with every column and the exact first page within budget | pass | todo | 10,000 rows with --all and --page-size |
|
||||
| PRF-002 | large table: 250-row pages of 40 columns render, page and reach the last page within budget | pass | todo | --limit/--skip paging |
|
||||
| PRF-003 | large table: sorting and filtering 10,000 rows return exact results within budget | pass | todo | server sort and filter |
|
||||
| PRF-002 | large table: 250-row pages of 40 columns render, page and reach the last page within budget | pass | pass | --limit/--skip paging; TUI: server-paged grid, page = visible rows |
|
||||
| PRF-003 | large table: sorting and filtering 10,000 rows return exact results within budget | pass | pass | server sort and filter; TUI: server sort and filter |
|
||||
| PRF-004 | heavy dashboard: 24 widgets (statistics, bar, line and pie charts, 100-row tables) load once each within budge | todo | todo | one widget per call today |
|
||||
| PRF-005 | long process: a 10-second job over 10,000 rows reports progress, polls at a bounded rate and shows its result | pass | todo | process run polls every 500 ms and prints progress |
|
||||
| PRC-001 | Selected-record run: record ids reach the backend, screen values are submitted, mixed view and record-list res | pass | todo | process run -r ids |
|
||||
| PRC-002 | All-records run from a filter selection with no criteria | pass | todo | --filter-json |
|
||||
| PRF-005 | long process: a 10-second job over 10,000 rows reports progress, polls at a bounded rate and shows its result | pass | pass | process run polls every 500 ms and prints progress; TUI: progress N/M, elapsed, 250 ms polling |
|
||||
| PRC-001 | Selected-record run: record ids reach the backend, screen values are submitted, mixed view and record-list res | pass | pass | process run -r ids; TUI: grid selection or record, ids sent |
|
||||
| PRC-002 | All-records run from a filter selection with no criteria | pass | pass | --filter-json; TUI: without a selection: the grid filter |
|
||||
| PRC-003 | Zero-record runs from a filter that matches nothing | pass | todo | |
|
||||
| PRC-004 | Processes launched from an app (no records) | pass | todo | |
|
||||
| PRC-005 | Process launched from selected rows of a table query | n/a | todo | grid selection; use -r |
|
||||
| PRC-004 | Processes launched from an app (no records) | pass | pass | |
|
||||
| PRC-005 | Process launched from selected rows of a table query | n/a | pass | grid selection; use -r; TUI: Space selects, b runs a table process |
|
||||
| PRC-006 | Minimum and maximum input records | pass | todo | checked before init (filters counted); the sample declares no limits, verified by tests |
|
||||
| PRC-007 | Every declared component renders on one screen, in declared order, with one action bar | pass | todo | every component in order as text (kernel screen model); bulk load forms belong to qqq bulk |
|
||||
| PRC-008 | HELP_TEXT with line breaks and previewText toggle | pass | todo | text with line breaks; previewText printed above it |
|
||||
| PRC-009 | EDIT_FORM field subsets (includeFieldNames), section labels and persisted input values | pass | todo | includeFieldNames and values kept across steps |
|
||||
| PRC-010 | Required input validation on screens and in the backend | pass | todo | required checked locally and by the server |
|
||||
| PRC-011 | Process possible-value fields | pass | todo | labels or ids, prompts list choices |
|
||||
| PRC-012 | VIEW_FORM labels and backend values | pass | todo | label: value on every step |
|
||||
| PRC-007 | Every declared component renders on one screen, in declared order, with one action bar | pass | partial | every component in order as text (kernel screen model); bulk load forms belong to qqq bulk; TUI: EDIT_FORM, BULK_EDIT_FORM, VIEW_FORM, HELP_TEXT, VALIDATION_REVIEW_SCREEN, PROCESS_SUMMARY_RESULTS |
|
||||
| PRC-008 | HELP_TEXT with line breaks and previewText toggle | pass | partial | text with line breaks; previewText printed above it; TUI: HELP_TEXT shown above the form |
|
||||
| PRC-009 | EDIT_FORM field subsets (includeFieldNames), section labels and persisted input values | pass | pass | includeFieldNames and values kept across steps; TUI: includeFieldNames via the kernel runner |
|
||||
| PRC-010 | Required input validation on screens and in the backend | pass | pass | required checked locally and by the server; TUI: required checked by field before submit |
|
||||
| PRC-011 | Process possible-value fields | pass | pass | labels or ids, prompts list choices; TUI: picker |
|
||||
| PRC-012 | VIEW_FORM labels and backend values | pass | pass | label: value on every step; TUI: summary shows view fields |
|
||||
| PRC-013 | HTML component renders the step html value, sanitized | pass | todo | {step}.html as text (tags stripped) |
|
||||
| PRC-014 | DOWNLOAD_FORM delivers the generated file | pass | todo | GET /download/{file} saved to --out-dir, else the download directory; no sample process offers one, verified by tests |
|
||||
| PRC-015 | Step help content for process screens | pass | todo | step helpContents shown on the screen, process help at start and in describe; none in the sample |
|
||||
| PRC-016 | Back to the backend back step with entered values | partial | todo | b at the prompt when the step reports backStep (isStepBack=true); no sample process declares one, verified by tests |
|
||||
| PRC-017 | Dynamic step list from processMetaDataAdjustment.updatedFrontendStepList | pass | todo | updatedFrontendStepList followed |
|
||||
| PRC-018 | Updated field metadata from processMetaDataAdjustment.updatedFields | pass | todo | updatedFields applied |
|
||||
| PRC-019 | Async job progress in the live job view (message, N / M, determinate progress bar, elapsed) then continue | pass | todo | message, N/M, elapsed on stderr |
|
||||
| PRC-020 | Cancel from a screen and mid-job runs the cancel step and leaves | pass | todo | Ctrl-C during a job or a prompt calls POST …/cancel, exit 130 |
|
||||
| PRC-021 | Step errors: user-facing messages directly, internal errors behind a detail toggle, Close | pass | todo | userFacingError shown |
|
||||
| PRC-022 | Retry after an error restarts with the original record selection | n/a | todo | retry is re-running the command |
|
||||
| PRC-017 | Dynamic step list from processMetaDataAdjustment.updatedFrontendStepList | pass | pass | updatedFrontendStepList followed; TUI: kernel runner |
|
||||
| PRC-018 | Updated field metadata from processMetaDataAdjustment.updatedFields | pass | pass | updatedFields applied; TUI: kernel runner |
|
||||
| PRC-019 | Async job progress in the live job view (message, N / M, determinate progress bar, elapsed) then continue | pass | pass | message, N/M, elapsed on stderr; TUI: bar, N/M, message, elapsed |
|
||||
| PRC-020 | Cancel from a screen and mid-job runs the cancel step and leaves | pass | partial | Ctrl-C during a job or a prompt calls POST …/cancel, exit 130; TUI: Esc asks, then POST cancel; the sample keeps the run |
|
||||
| PRC-021 | Step errors: user-facing messages directly, internal errors behind a detail toggle, Close | pass | pass | userFacingError shown; TUI: failure message shown; F5 re-runs |
|
||||
| PRC-022 | Retry after an error restarts with the original record selection | n/a | pass | retry is re-running the command; TUI: F5 or r runs again with the same records |
|
||||
| PRC-023 | Completion: Next/Submit labels, Return on the last screen or when noMoreSteps | n/a | todo | screen labels |
|
||||
| PRC-024 | Linear stepper and step position | n/a | todo | stepper; step names printed |
|
||||
| PRC-024 | Linear stepper and step position | n/a | pass | stepper; step names printed; TUI: step list with marks |
|
||||
| PRC-025 | RECORD_LIST paging and page size over process records | pass | todo | GET …/records paged into a table, --record-limit |
|
||||
| PRC-026 | Validation review: input count, validate-or-skip choice, Next/Submit label, record preview | pass | todo | count and preview; asks validate or skip on a terminal, --validate / --skip-validation; Submit? after validation |
|
||||
| PRC-027 | Full validation summary lines (OK, WARNING, ERROR) with record links | pass | todo | status, count, message lines |
|
||||
| PRC-028 | Process summary results after execution and persisted effects | pass | todo | |
|
||||
| PRC-029 | Bulk edit: switched-on fields only, review and result | todo | todo | bulk edit |
|
||||
| PRC-026 | Validation review: input count, validate-or-skip choice, Next/Submit label, record preview | pass | pass | count and preview; asks validate or skip on a terminal, --validate / --skip-validation; Submit? after validation; TUI: validate or skip, then run |
|
||||
| PRC-027 | Full validation summary lines (OK, WARNING, ERROR) with record links | pass | pass | status, count, message lines; TUI: summary lines colored by status |
|
||||
| PRC-028 | Process summary results after execution and persisted effects | pass | pass | |
|
||||
| PRC-029 | Bulk edit: switched-on fields only, review and result | todo | pass | bulk edit; TUI: Ctrl+T marks fields; bulkEditEnabledFields |
|
||||
| PRC-030 | Bulk delete: review, validation and result | todo | todo | bulk delete |
|
||||
| PRC-031 | Bulk insert: upload, column mapping, value mapping, profile, review preview and result | todo | todo | bulk insert |
|
||||
| PRC-032 | Bulk load mapping validation and Back through mapping and upload | todo | todo | |
|
||||
| PRC-033 | Bulk edit with a file matched by key fields | todo | todo | |
|
||||
| PRC-031 | Bulk insert: upload, column mapping, value mapping, profile, review preview and result | todo | n/a | bulk insert; TUI: bulk load parked for the TUI |
|
||||
| PRC-032 | Bulk load mapping validation and Back through mapping and upload | todo | n/a | TUI: bulk load parked for the TUI |
|
||||
| PRC-033 | Bulk edit with a file matched by key fields | todo | n/a | TUI: bulk load parked for the TUI |
|
||||
| PRC-034 | Upload instructions HTML with downloadable template | todo | todo | |
|
||||
| PRC-035 | Named WIDGET fetched with the process UUID and values | pass | todo | named widget with the process UUID and values, kernel render model; none in the sample, verified by tests |
|
||||
| PRC-036 | Named composite WIDGET seeded from process values | pass | todo | seeded from the process value of the widget name |
|
||||
@@ -117,11 +134,11 @@ The TUI column fills in during M3.
|
||||
| PRC-044 | Scanner-format screens: no heading or action bar, input blocks submit | n/a | todo | scanner screens are interactive |
|
||||
| PRC-045 | State-machine step flow: repeated screens, no linear stepper | pass | todo | the runner follows nextStep whatever the flow |
|
||||
| PRC-046 | Saved bulk load profiles: save, choose for a later load, delete | todo | todo | saved bulk load profiles |
|
||||
| PRC-047 | A run with no frontend screens completes with a completion screen | pass | todo | completes on init |
|
||||
| PRC-048 | Process-field possible values load and search through the v1 API | pass | todo | searchProcessPossibleValues |
|
||||
| PRC-047 | A run with no frontend screens completes with a completion screen | pass | pass | completes on init |
|
||||
| PRC-048 | Process-field possible values load and search through the v1 API | pass | pass | searchProcessPossibleValues |
|
||||
| PRC-049 | Processes run on the v1 process routes (upload, step, back step, records, metadata) | pass | todo | v1 routes only |
|
||||
| PRC-050 | Processes the instance adds to every query and record screen (Material processNamesToAddToAllQueryAndViewScree | n/a | todo | Material-specific |
|
||||
| PRC-051 | Dependent possible values on a process screen: the choices follow the screen's other values | pass | todo | dependent choices narrow after each answer |
|
||||
| PRC-051 | Dependent possible values on a process screen: the choices follow the screen's other values | pass | pass | dependent choices narrow after each answer; TUI: contingent picker narrows |
|
||||
| PRC-058 | Saved bulk load profile menu (Material SavedBulkLoadProfiles): new profile, update after the confirm, rename, | todo | todo | |
|
||||
| PRC-059 | Saved bulk load profile ownership: yours vs shared with you, and only the owner may save, rename or delete a p | todo | todo | |
|
||||
| PRC-060 | Bulk load file mapping form fidelity: field help, column tooltips, repeated-header warning, header-row toggle | todo | todo | |
|
||||
@@ -138,61 +155,61 @@ The TUI column fills in during M3.
|
||||
| QRY-094 | Counted quick views render and open their saved-view route | partial | todo | saved-view list counts quick views with doCount (--counts counts all) |
|
||||
| QRY-095 | A new query defaults to fifty rows per page | n/a | todo | saved views and column UI |
|
||||
| QRY-096 | A saved view reconciles removed fields and warns the user | partial | todo | opening a view drops removed fields with Material's warnings on stderr |
|
||||
| QRY-001 | Record list renders metadata labels and all records in the default primary-key-descending order | pass | todo | default order by primary key |
|
||||
| QRY-002 | Pagination with Material page sizes and URL state | pass | todo | --limit, --skip, --all |
|
||||
| QRY-003 | Column header sort cycles ascending, descending and default; sort kept in the URL | pass | todo | --order, -field for descending |
|
||||
| QRY-004 | Column visibility and order persist per table | todo | todo | --fields per call; no persistence |
|
||||
| QRY-005 | Column widths, density and card view | n/a | todo | grid presentation |
|
||||
| QRY-006 | Opening a record and returning keeps the query; refresh shows backend changes | n/a | todo | |
|
||||
| QRY-007 | A failed query reports the backend error with a retry | pass | todo | error with code, status and trace id |
|
||||
| QRY-010 | Text operators match Material labels and backend semantics | pass | todo | all operators and shorthand |
|
||||
| QRY-011 | Number operators including ranges and lists | pass | todo | BETWEEN, IN lists |
|
||||
| QRY-012 | Date operators and relative date expressions | pass | todo | now(), now(-7d), thisPeriod(), startOf() |
|
||||
| QRY-001 | Record list renders metadata labels and all records in the default primary-key-descending order | pass | pass | default order by primary key |
|
||||
| QRY-002 | Pagination with Material page sizes and URL state | pass | pass | --limit, --skip, --all; TUI: PgUp/PgDn, Ctrl+F/B, Home/End across pages |
|
||||
| QRY-003 | Column header sort cycles ascending, descending and default; sort kept in the URL | pass | pass | --order, -field for descending; TUI: s cycles asc, desc, default |
|
||||
| QRY-004 | Column visibility and order persist per table | todo | pass | --fields per call; no persistence; TUI: v: columns on/off and order, kept per table in the config file |
|
||||
| QRY-005 | Column widths, density and card view | n/a | pass | grid presentation; TUI: v: widths, fit or fill, zebra, density |
|
||||
| QRY-006 | Opening a record and returning keeps the query; refresh shows backend changes | n/a | pass | TUI: Esc lands on the row last viewed, filter and page kept |
|
||||
| QRY-007 | A failed query reports the backend error with a retry | pass | pass | error with code, status and trace id; TUI: error shown in the grid; F5 retries |
|
||||
| QRY-010 | Text operators match Material labels and backend semantics | pass | pass | all operators and shorthand; TUI: operators by field type in the filter dialog |
|
||||
| QRY-011 | Number operators including ranges and lists | pass | pass | BETWEEN, IN lists |
|
||||
| QRY-012 | Date operators and relative date expressions | pass | pass | now(), now(-7d), thisPeriod(), startOf(); TUI: date presets: now(-7d), thisPeriod(month) … |
|
||||
| QRY-013 | Date-time operators convert local input to UTC and support relative values | pass | todo | --where/--group DATE_TIME values local to UTC (a date is local midnight); expressions as before |
|
||||
| QRY-014 | Boolean, blob and long-text operators | pass | todo | IS_BLANK, TRUE, FALSE |
|
||||
| QRY-015 | Possible-value selectors for table and enum sources show labels and send ids | todo | todo | filters take ids; labels in filters todo |
|
||||
| QRY-016 | AND/OR and nested groups combine as declared | pass | todo | --group with parentheses |
|
||||
| QRY-017 | Filters live in the URL: reload, back/forward and Material-style JSON links | pass | todo | --print-filter gives the JSON the web links accept |
|
||||
| QRY-015 | Possible-value selectors for table and enum sources show labels and send ids | todo | pass | filters take ids; labels in filters todo; TUI: labels in the picker, ids sent; typed labels resolved |
|
||||
| QRY-016 | AND/OR and nested groups combine as declared | pass | pass | --group with parentheses; TUI: AND/OR and groups, guided and advanced |
|
||||
| QRY-017 | Filters live in the URL: reload, back/forward and Material-style JSON links | pass | partial | --print-filter gives the JSON the web links accept; TUI: the grammar and the CLI command are shown; no URL |
|
||||
| QRY-018 | Backend-only operators from links render and filter correctly | pass | todo | any operator in --where |
|
||||
| QRY-019 | Quick search combines with the advanced filter | pass | todo | --search on query, count and export: CONTAINS over the shown text fields, ORed, ANDed with the filter |
|
||||
| QRY-020 | One-side exposed join: labelled columns, filters and sorts match SQL | partial | todo | --join and table.field in --fields/--where/--order, labelled "Join: Field"; the sample exposes no joins, verified through an association join (person to pet) |
|
||||
| QRY-021 | Many-side exposed join repeats rows and reports the distinct count | pass | todo | a many-side join repeats rows; count and --count add the distinct count (person --join pet: 21 rows, 16 distinct) |
|
||||
| QRY-022 | Column statistics aggregate the filtered rows | partial | todo | qqq table stats <t> <field> [-w …] through the columnStats process; the sample has neither QUERY_STATS nor the process, so --local (same figures from the matching rows) is what was verified |
|
||||
| QRY-023 | Tables without QUERY_STATS offer no column statistics | pass | todo | without QUERY_STATS (or the process) table stats exits 2 with the reason; --local is an explicit opt-in |
|
||||
| QRY-030 | Selection menu: this page, full query result, first N, clear | pass | todo | -r ids, --filter-json, --limit |
|
||||
| QRY-031 | Bulk Edit launches with the checked record ids | todo | todo | bulk edit |
|
||||
| QRY-030 | Selection menu: this page, full query result, first N, clear | pass | partial | -r ids, --filter-json, --limit; TUI: Space and Ctrl+A (page) |
|
||||
| QRY-031 | Bulk Edit launches with the checked record ids | todo | pass | bulk edit |
|
||||
| QRY-032 | Bulk Delete of the full query result sends the whole filter | todo | todo | bulk delete |
|
||||
| QRY-033 | First-N selection under a filter launches with that limit | pass | todo | process run --filter-json with limit |
|
||||
| QRY-034 | Actions menu offers bulk load, bulk processes and table processes | todo | todo | process list --table |
|
||||
| QRY-034 | Actions menu offers bulk load, bulk processes and table processes | todo | partial | process list --table; TUI: F9 lists table processes; bulk load parked |
|
||||
| QRY-035 | Viewers get no create, bulk or process actions and the backend refuses them | pass | todo | server refuses; exit 3 |
|
||||
| QRY-040 | CSV export has visible columns in order and exactly the filtered, sorted rows | pass | todo | table export: filtered (--where, --group, --search), sorted (--order), --fields in column order, --limit, --out - |
|
||||
| QRY-041 | XLSX and JSON exports carry the same rows | pass | todo | csv, xlsx, json, tsv; format from --out extension; --no-header; bad format or field exit 2 before the request |
|
||||
| QRY-040 | CSV export has visible columns in order and exactly the filtered, sorted rows | pass | pass | table export: filtered (--where, --group, --search), sorted (--order), --fields in column order, --limit, --out -; TUI: x: visible columns, filter and sort, file dialog |
|
||||
| QRY-041 | XLSX and JSON exports carry the same rows | pass | pass | csv, xlsx, json, tsv; format from --out extension; --no-header; bad format or field exit 2 before the request |
|
||||
| QRY-042 | Nothing to export disables the formats | n/a | todo | nothing to disable |
|
||||
| QRY-043 | Backend refuses exports of unreadable tables | pass | todo | server refuses |
|
||||
| QRY-050 | Your saved views are listed and open on their own route | partial | todo | qqq saved-view list <t>, open <t> <view>, table query --view; the sample has no saved views; verified with node tests and a local stub server |
|
||||
| QRY-050 | Your saved views are listed and open on their own route | partial | partial | qqq saved-view list <t>, open <t> <view>, table query --view; the sample has no saved views; verified with node tests and a local stub server; TUI: F9 runs saved-view list in a pane |
|
||||
| QRY-051 | Saving a new view stores filter, sort, columns and page size | partial | todo | saved-view save --name: filter, sort, columns and page size as the Material RecordQueryView |
|
||||
| QRY-052 | Update, rename and delete your view | partial | todo | saved-view save --replace, rename, delete (asks; --yes) |
|
||||
| QRY-053 | Duplicate names and unknown views are reported without changes | partial | todo | a name in use is refused before any change; an unknown view exits 2 with the list |
|
||||
| QRY-054 | A view shared with you opens read-only and the backend refuses changes | partial | todo | views shared with you are listed as shared by their owner; changes to them are the server's to refuse, its message shown |
|
||||
| QRY-060 | Variant table asks for a variant, queries with it and remembers it | partial | todo | table variants; --variant on query, count, get, copy, export, writes and process run; a terminal asks; not remembered between commands; no variant table in the sample |
|
||||
| QRY-061 | Dismissing the variant prompt queries nothing; backend requires a variant | partial | todo | no --variant and no terminal (or no answer): exit 2, nothing queried; no variant table in the sample |
|
||||
| QRY-062 | A table without count, export or write capabilities offers none of them | pass | todo | describe shows capabilities; server refuses |
|
||||
| QRY-062 | A table without count, export or write capabilities offers none of them | pass | pass | describe shows capabilities; server refuses; TUI: F-keys hidden without permission |
|
||||
| QRY-063 | Enum-backed Pet Species table is read-only | pass | todo | server refuses writes |
|
||||
| QRY-064 | Backend refuses count and export for a table without those capabilities | pass | todo | |
|
||||
| QRY-065 | Export streams from the v1 export route | pass | todo | POST /table/{t}/export |
|
||||
| QRY-065 | Export streams from the v1 export route | pass | pass | POST /table/{t}/export |
|
||||
| QRY-066 | Variant options come from the v1 variants route | partial | todo | GET /table/{t}/variants (called live; the sample lists none) |
|
||||
| QRY-067 | Go To opens a record by its primary key or a configured unique key (Material gotoFieldNames) | pass | todo | table get --key; the primary key and Material gotoFieldNames are the declared keys, other fields looked up with a note |
|
||||
| QRY-068 | Default grid columns follow the table sections (section order, each field once), not the metadata field order | pass | todo | default columns follow sections by tier |
|
||||
| QRY-067 | Go To opens a record by its primary key or a configured unique key (Material gotoFieldNames) | pass | pass | table get --key; the primary key and Material gotoFieldNames are the declared keys, other fields looked up with a note; TUI: g |
|
||||
| QRY-068 | Default grid columns follow the table sections (section order, each field once), not the metadata field order | pass | pass | default columns follow sections by tier |
|
||||
| QRY-069 | Pagination numbers are locale formatted in the shown range and the total | n/a | todo | locale formatting |
|
||||
| QRY-070 | Query screen keyboard shortcuts n (new record), r (refresh the query), f (open the filter builder) | n/a | todo | shortcuts |
|
||||
| QRY-070 | Query screen keyboard shortcuts n (new record), r (refresh the query), f (open the filter builder) | n/a | pass | shortcuts; TUI: n r f s and the F-keys; off with keyboard.singleKeyShortcuts |
|
||||
| QRY-071 | Processes launched from a variant table run with the chosen variant (tableVariant on init and every step) and | partial | todo | process run --variant: tableVariant on init, every step and status; sent live, no variant table to observe |
|
||||
| QRY-080 | Basic mode displays default quick filters from table metadata and applies their criteria | n/a | todo | quick filters UI |
|
||||
| QRY-081 | Add quick filter accepts metadata fields and remembers a chosen field | n/a | todo | |
|
||||
| QRY-082 | Basic mode cannot simplify repeated or nested advanced conditions and explains why | n/a | todo | |
|
||||
| QRY-083 | Advanced query shows and removes individual conditions (Next inline editor) | n/a | todo | |
|
||||
| QRY-084 | Sort picker changes field and direction; clearing filters requires confirmation and keeps the sort | n/a | todo | |
|
||||
| QRY-083 | Advanced query shows and removes individual conditions (Next inline editor) | n/a | pass | TUI: guided builder edits and removes conditions |
|
||||
| QRY-084 | Sort picker changes field and direction; clearing filters requires confirmation and keeps the sort | n/a | pass | TUI: sort in the builder; c clears |
|
||||
| QRY-085 | Weekday criteria use the metadata-enabled date function and ISO weekday values | todo | todo | weekday criteria |
|
||||
| QRY-086 | Relative date presets expose their evaluated date and filter records | pass | todo | expressions |
|
||||
| QRY-086 | Relative date presets expose their evaluated date and filter records | pass | pass | expressions |
|
||||
| QRY-087 | Bulk pasted values become a multi-value filter | pass | todo | IN lists; quoted values |
|
||||
| QRY-088 | Instance query help accompanies the bulk paste dialog | n/a | todo | |
|
||||
| QRY-089 | String filter input honors field case behavior from v1 metadata | pass | todo | behaviors |
|
||||
@@ -201,49 +218,49 @@ The TUI column fills in during M3.
|
||||
| QRY-024 | Column configuration is reachable and usable by touch on phones and tablets | n/a | n/a | phone layout |
|
||||
| QRY-036 | Bulk actions launch from a phone card selection | n/a | n/a | phone layout |
|
||||
| QRY-055 | Saved views and export menus fit the phone and are touch-sized | n/a | n/a | phone layout |
|
||||
| REL-001 | Possible-value cells show the related record label and link to it | pass | todo | display values; table get shows the target (Avery Sample → person 1), --links lists them, --follow opens one |
|
||||
| REL-002 | Parent lists exactly its own children; View All opens the filtered child list | pass | todo | table get --associations lists exactly the record's children; table children pages a list with its count (View All) |
|
||||
| REL-003 | Adding a child from its parent links it and shows it immediately | pass | todo | table create <child> --parent table=pk; table get --associations shows it at once |
|
||||
| REL-004 | Three-level associations: each pet shows only its own notes | pass | todo | person > pets > notes: each pet lists only its own notes |
|
||||
| REL-005 | Editing and deleting a child leaves no stale parent, list or unexpected rows | pass | todo | records are never cached: update or delete a child, the next get/children shows the current rows |
|
||||
| REL-006 | Denied child reads show an unavailable panel without data or actions | partial | todo | unreadable child tables shown as unavailable without data; table children shows the server message; the sample has no denied child table (unit-tested) |
|
||||
| REL-001 | Possible-value cells show the related record label and link to it | pass | partial | display values; table get shows the target (Avery Sample → person 1), --links lists them, --follow opens one; TUI: labels with links in the record view |
|
||||
| REL-002 | Parent lists exactly its own children; View All opens the filtered child list | pass | pass | table get --associations lists exactly the record's children; table children pages a list with its count (View All); TUI: child lists; F9 Query all opens the filtered list |
|
||||
| REL-003 | Adding a child from its parent links it and shows it immediately | pass | pass | table create <child> --parent table=pk; table get --associations shows it at once; TUI: F7 on a child list prefills the parent |
|
||||
| REL-004 | Three-level associations: each pet shows only its own notes | pass | partial | person > pets > notes: each pet lists only its own notes; TUI: one level shown; open a child for its own |
|
||||
| REL-005 | Editing and deleting a child leaves no stale parent, list or unexpected rows | pass | pass | records are never cached: update or delete a child, the next get/children shows the current rows; TUI: the parent reloads on return |
|
||||
| REL-006 | Denied child reads show an unavailable panel without data or actions | partial | pass | unreadable child tables shown as unavailable without data; table children shows the server message; the sample has no denied child table (unit-tested); TUI: unavailable panel (kernel associationGroups) |
|
||||
| REL-007 | Aliased and composite associations bind to their widgets and list the right members | partial | todo | composite joins and widget-section labels in the kernel, unit-tested; the sample declares none |
|
||||
| REL-008 | Adding through a composite association stores every join value | partial | todo | --parent sets every join value of a composite association, unit-tested; the sample declares none |
|
||||
| REL-009 | Deleting a parent removes associated children and leaves other parents intact | pass | todo | table delete of a parent: its pets are gone, other parents keep theirs |
|
||||
| REC-001 | Record view shows the record label, tiered sections and backend-formatted values | pass | todo | table get by section and tier |
|
||||
| REC-001 | Record view shows the record label, tiered sections and backend-formatted values | pass | pass | table get by section and tier; TUI: sections by tier, two columns when they fit |
|
||||
| REC-002 | Empty values render a placeholder; hidden fields and hidden sections are never shown | partial | todo | empty values show --; hidden sections and fields are dropped by the kernel record view (unit-tested; the sample has none) |
|
||||
| REC-003 | Record view of a missing record shows Record Not Found and a way back | pass | todo | exit 5 |
|
||||
| REC-003 | Record view of a missing record shows Record Not Found and a way back | pass | pass | exit 5; TUI: error shown in the record pane |
|
||||
| REC-004 | Record view tabs and list view expose every visible section | n/a | todo | tabs |
|
||||
| REC-005 | Create form validates required fields in the browser before any request | pass | todo | validated locally before any request |
|
||||
| REC-006 | Create persists the entered values and opens the new record | pass | todo | |
|
||||
| REC-005 | Create form validates required fields in the browser before any request | pass | pass | validated locally before any request; TUI: local validation by field |
|
||||
| REC-006 | Create persists the entered values and opens the new record | pass | pass | TUI: new record replaces the form |
|
||||
| REC-007 | Backend enforces required fields when the UI is bypassed | pass | todo | |
|
||||
| REC-008 | Cancel from create and edit, with the unsaved-changes guard | n/a | todo | guard |
|
||||
| REC-009 | Edit loads stored values, saves only what changed, and shows the updated record | pass | todo | update sends only changed fields |
|
||||
| REC-010 | Edit shows non-editable fields read-only and never submits them | pass | todo | non-editable fields excluded |
|
||||
| REC-008 | Cancel from create and edit, with the unsaved-changes guard | n/a | pass | guard; TUI: Esc asks when there are unsaved changes; F10 warns too |
|
||||
| REC-009 | Edit loads stored values, saves only what changed, and shows the updated record | pass | pass | update sends only changed fields; TUI: only changed fields sent |
|
||||
| REC-010 | Edit shows non-editable fields read-only and never submits them | pass | pass | non-editable fields excluded; TUI: non-editable fields read-only |
|
||||
| REC-011 | Edit of a missing record shows the load failure instead of a form | pass | todo | |
|
||||
| REC-012 | Delete asks for confirmation, removes the record and returns to the list without errors | pass | todo | confirm unless --yes |
|
||||
| REC-013 | Copy prefills a new record from the source and saves it as a separate record | pass | todo | qqq table copy <t> <pk> [--set f=v] [-i] [--dry-run]: primary key, non-editable and file fields dropped |
|
||||
| REC-014 | STRING and TEXT editors round-trip single and multi-line text | pass | todo | |
|
||||
| REC-015 | INTEGER, LONG and DECIMAL editors keep exact values | pass | todo | |
|
||||
| REC-016 | BOOLEAN editor applies the metadata default and saves true/false | pass | todo | defaults applied |
|
||||
| REC-017 | DATE and TIME editors round-trip values and clearing | pass | todo | |
|
||||
| REC-018 | DATE_TIME is entered and shown in the viewer's time zone and stored in UTC | pass | todo | typed and shown in TZ, stored UTC, --utc for raw; DST edges tested |
|
||||
| REC-012 | Delete asks for confirmation, removes the record and returns to the list without errors | pass | pass | confirm unless --yes; TUI: F8 asks; the grid moves on to the next record |
|
||||
| REC-013 | Copy prefills a new record from the source and saves it as a separate record | pass | pass | qqq table copy <t> <pk> [--set f=v] [-i] [--dry-run]: primary key, non-editable and file fields dropped; TUI: c: copyRecordValues |
|
||||
| REC-014 | STRING and TEXT editors round-trip single and multi-line text | pass | partial | TUI: TEXT single line with $EDITOR on Ctrl+E |
|
||||
| REC-015 | INTEGER, LONG and DECIMAL editors keep exact values | pass | pass | |
|
||||
| REC-016 | BOOLEAN editor applies the metadata default and saves true/false | pass | pass | defaults applied; TUI: Space toggles |
|
||||
| REC-017 | DATE and TIME editors round-trip values and clearing | pass | pass | |
|
||||
| REC-018 | DATE_TIME is entered and shown in the viewer's time zone and stored in UTC | pass | pass | typed and shown in TZ, stored UTC, --utc for raw; DST edges tested; TUI: local zone in and out (kernel datetime) |
|
||||
| REC-019 | Zoned DATE_TIME display behaviors (fixed zone and per-record zone) | n/a | todo | display zones |
|
||||
| REC-020 | HTML field edits in the rich-text editor and renders sanitized | todo | todo | raw HTML; $EDITOR todo |
|
||||
| REC-021 | PASSWORD is masked on read and never overwritten by the mask | pass | todo | server masks |
|
||||
| REC-021 | PASSWORD is masked on read and never overwritten by the mask | pass | pass | server masks; TUI: masked |
|
||||
| REC-022 | BLOB upload and download without a download adornment | pass | todo | table create/update --upload and table download (base64 BLOB value) verified on fieldLab; default name into the download directory |
|
||||
| REC-023 | Dynamic default values (user id, create day, modify day, explicit NONE) | pass | todo | server |
|
||||
| REC-024 | Max-length policies: truncate, ellipsis, pass-through and reject | pass | todo | maxLength checked locally |
|
||||
| REC-025 | Case and whitespace normalization on save | pass | todo | behaviors |
|
||||
| REC-026 | Numeric range policies: reject, exclusive reject, inclusive clip and exclusive clip | pass | todo | ranges checked locally |
|
||||
| REC-027 | Unique keys are enforced with the backend message on create and edit | pass | todo | server message |
|
||||
| REC-028 | LINK adornment: record links and URL links | pass | todo | record links name table and key, --follow opens them; URL links show the URL |
|
||||
| REC-028 | LINK adornment: record links and URL links | pass | pass | record links name table and key, --follow opens them; URL links show the URL; TUI: links marked, Enter follows (kernel record-view) |
|
||||
| REC-029 | CHIP adornment colors and icons by value | partial | todo | chip as [value]; colors and icons not shown (unit-tested; none in the sample) |
|
||||
| REC-030 | SIZE adornment sets the query grid column width | n/a | todo | |
|
||||
| REC-031 | CODE_EDITOR adornment: code view with formatting and a code editor on forms | partial | todo | raw code on view; no code editor (unit-tested) |
|
||||
| REC-032 | RENDER_HTML adornment renders sanitized HTML | partial | todo | HTML shown as text (unit-tested) |
|
||||
| REC-033 | REVEAL adornment masks, reveals and copies the value | partial | todo | masked until --reveal in the record view and in query and child-list cells; no copy (unit-tested; none in the sample) |
|
||||
| REC-034 | FILE_DOWNLOAD adornment links to the stored file with its name | partial | todo | name plus qqq table download via the field route (unit-tested); live only for BLOB values, server finding 16 |
|
||||
| REC-033 | REVEAL adornment masks, reveals and copies the value | partial | partial | masked until --reveal in the record view and in query and child-list cells; no copy (unit-tested; none in the sample); TUI: masked; Enter reveals |
|
||||
| REC-034 | FILE_DOWNLOAD adornment links to the stored file with its name | partial | partial | name plus qqq table download via the field route (unit-tested); live only for BLOB values, server finding 16; TUI: Enter saves through the file dialog; no sample field |
|
||||
| REC-035 | FILE_UPLOAD adornment formats, replacing and removing a stored file | partial | todo | create/update --upload field=path as multipart, replace and remove verified on a BLOB; FILE_UPLOAD formats not in the sample |
|
||||
| REC-036 | TOOLTIP adornment shows its text on the value | partial | todo | tooltip text in parentheses after the value (unit-tested) |
|
||||
| REC-037 | WIDGET adornment renders the field value as the named widget | partial | todo | value rendered through the widget render model as text (unit-tested) |
|
||||
@@ -251,22 +268,22 @@ The TUI column fills in during M3.
|
||||
| REC-039 | Field help content follows screen roles and formats | partial | todo | table get --help-content: view-screen roles, HTML/MARKDOWN as text (unit-tested; the sample has no help); qqq help show lists help alone |
|
||||
| REC-040 | Section help content on the record view | partial | todo | section help with --help-content (unit-tested) |
|
||||
| REC-041 | Table help content slots are delivered in table metadata | partial | todo | table helpContent and helpContents slots with --help-content (unit-tested) |
|
||||
| REC-042 | Audit history lists who changed what, newest first | partial | todo | qqq audit list <t> <pk> [--asc]: entries newest first with field changes; the sample audits no table, verified with node tests and a local stub server |
|
||||
| REC-042 | Audit history lists who changed what, newest first | partial | partial | qqq audit list <t> <pk> [--asc]: entries newest first with field changes; the sample audits no table, verified with node tests and a local stub server; TUI: F9 runs audit list in a pane |
|
||||
| REC-043 | Audit history without process permission reads the audit table | partial | todo | without the audit process the audit table is queried with its auditTable and auditDetail joins (node tests) |
|
||||
| REC-044 | Record label format is used for the record's title and dialogs | pass | todo | recordLabel |
|
||||
| REC-045 | Possible-value editor shows stored labels, looks up defaults and selects by keyboard | pass | todo | labels resolved, defaults looked up |
|
||||
| REC-044 | Record label format is used for the record's title and dialogs | pass | pass | recordLabel |
|
||||
| REC-045 | Possible-value editor shows stored labels, looks up defaults and selects by keyboard | pass | pass | labels resolved, defaults looked up; TUI: picker by keyboard |
|
||||
| REC-046 | Read-only persona sees no write actions and the backend refuses writes | pass | todo | server |
|
||||
| REC-047 | Forms are labelled and keyboard operable | n/a | todo | |
|
||||
| REC-048 | Server validation errors show the backend message once and keep the form | pass | todo | fieldErrors by field |
|
||||
| REC-048 | Server validation errors show the backend message once and keep the form | pass | pass | fieldErrors by field; TUI: fieldErrors by field |
|
||||
| REC-049 | pages of a table outside the app tree use the table label in the document title and breadcrumbs | n/a | todo | |
|
||||
| REC-051 | Table-field possible values search and resolve through the v1 API | pass | todo | |
|
||||
| REC-050 | Record create, view, edit and delete run on the v1 record routes | pass | todo | |
|
||||
| REC-051 | Table-field possible values search and resolve through the v1 API | pass | pass | |
|
||||
| REC-050 | Record create, view, edit and delete run on the v1 record routes | pass | pass | |
|
||||
| REC-056 | Record view hash links: #audit, #/launchProcess={process} and #{section} anchors | n/a | todo | |
|
||||
| REC-057 | Create presets from links: #/createChild= opens a child create dialog over the record; /create#/defaultValues= | n/a | todo | |
|
||||
| REC-052 | Dependent possible values: a field whose filter uses ${input.otherField} offers only the choices matching the | pass | todo | contingent fields |
|
||||
| REC-052 | Dependent possible values: a field whose filter uses ${input.otherField} offers only the choices matching the | pass | pass | contingent fields; TUI: contingent fields wait and narrow |
|
||||
| REC-053 | Table developer view shows the API Docs & Playground section for the table's application APIs | todo | todo | developer view |
|
||||
| REC-054 | Record developer view manages associated scripts: versions, new versions, test runs, logs, docs and script cre | todo | todo | |
|
||||
| REC-055 | Record view keyboard shortcuts n, e, c, d, a (Material) and their help text | n/a | todo | |
|
||||
| REC-055 | Record view keyboard shortcuts n, e, c, d, a (Material) and their help text | n/a | pass | TUI: n e c d; F-keys |
|
||||
| REC-058 | Record view on phones and tablets: the title stays readable, the header controls wrap onto their own row, and | n/a | todo | |
|
||||
| REC-059 | Record forms on phones and tablets: every editor type fits without sideways scrolling, controls are touch-size | n/a | todo | |
|
||||
| REC-061 | An editable dynamicForm widget on a record form validates its fields and persists them in the declared JSON re | todo | todo | |
|
||||
@@ -277,9 +294,9 @@ The TUI column fills in during M3.
|
||||
| REC-065 | Unchanged DATE_TIME instants and precision survive edit and copy | pass | todo | an unchanged wall-clock time keeps the original instant and its precision |
|
||||
| SEC-001 | Table hidden from a user (DenyBehavior.HIDDEN): absent from navigation, direct link loads nothing, backend ref | pass | todo | |
|
||||
| SEC-002 | Table disabled for a user (DenyBehavior.DISABLED): listed, but list/record/create/edit links explain the denia | todo | todo | disabled tables listed with a reason |
|
||||
| SEC-003 | Insert permission denied: create not offered, create link refused, backend writes nothing | pass | todo | |
|
||||
| SEC-004 | Edit permission denied: edit not offered, edit link refused, backend changes nothing | pass | todo | |
|
||||
| SEC-005 | Delete permission denied: delete not offered on record or bulk bar, bulk delete refused, backend deletes nothi | pass | todo | |
|
||||
| SEC-003 | Insert permission denied: create not offered, create link refused, backend writes nothing | pass | pass | TUI: F7 hidden without insert permission |
|
||||
| SEC-004 | Edit permission denied: edit not offered, edit link refused, backend changes nothing | pass | pass | |
|
||||
| SEC-005 | Delete permission denied: delete not offered on record or bulk bar, bulk delete refused, backend deletes nothi | pass | pass | |
|
||||
| SEC-006 | Copy requires insert permission: copy not offered and copy link refused | todo | todo | copy |
|
||||
| SEC-007 | Process permission denied (hidden): processes not offered in navigation or launcher, links do not start them, | pass | todo | |
|
||||
| SEC-008 | Process disabled for a user (DenyBehavior.DISABLED): shown disabled, link explains the denial, backend refuses | todo | todo | |
|
||||
@@ -290,19 +307,19 @@ The TUI column fills in during M3.
|
||||
| SEC-013 | Field protection: hidden fields never shown or returned, password fields masked and never overwritten by the m | pass | todo | server |
|
||||
| SEC-014 | Capability restrictions: tables without insert/update/delete capabilities offer none of them and refuse direct | pass | todo | |
|
||||
| SEC-015 | Permission revoked mid-session: stale UI actions are refused by the backend with a clear message, nothing is w | pass | todo | |
|
||||
| SEC-020 | MOCK sign-in: a deep link establishes the session and shows the session user from manageSession values | pass | todo | qqq login on a MOCK server |
|
||||
| SEC-021 | Logout: POST /qqq/v1/logout ends the session, protected pages stay on login, Sign in returns to the page, per- | pass | todo | qqq logout calls POST /logout |
|
||||
| SEC-020 | MOCK sign-in: a deep link establishes the session and shows the session user from manageSession values | pass | pass | qqq login on a MOCK server; TUI: MOCK session, shared with the CLI keyring |
|
||||
| SEC-021 | Logout: POST /qqq/v1/logout ends the session, protected pages stay on login, Sign in returns to the page, per- | pass | pass | qqq logout calls POST /logout; TUI: F9 Sign out |
|
||||
| SEC-022 | Session expiry: 401 redirects to login with returnTo and re-authenticates back to the page (initial load and m | pass | todo | exit 3 with the sign-in hint |
|
||||
| SEC-023 | Denied sign-in: a 401 from manageSession keeps the user out with an explanation (no silent anonymous fallback) | pass | todo | |
|
||||
| SEC-024 | Post-login returnTo is limited to in-app paths (open-redirect guard) | n/a | n/a | |
|
||||
| SEC-025 | OAUTH2 authorization code + PKCE sign-in with an owned OIDC provider (discovery, authorize, callback, backend | pass | todo | device flow and --browser (code + PKCE, loopback) verified against a sample on OAUTH2 with qqq-dev-idp |
|
||||
| SEC-025 | OAUTH2 authorization code + PKCE sign-in with an owned OIDC provider (discovery, authorize, callback, backend | pass | partial | device flow and --browser (code + PKCE, loopback) verified against a sample on OAUTH2 with qqq-dev-idp; TUI: device flow screen; not tried against a provider |
|
||||
| SEC-026 | OAUTH2 sign-in failures: provider denial, tampered state and failed code exchange are reported and create no s | pass | todo | denial, forged state and a refused code exchange exit 3 and save nothing; the token is checked against the server first |
|
||||
| SEC-027 | OAUTH2 logout: backend session deleted, provider end-session called, next visit requires provider sign-in | pass | todo | logout --provider-logout: refresh token revoked (RFC 7009), end-session URL opened; bearer sign-ins have no server session |
|
||||
| SEC-028 | OAUTH2 session revoked server-side mid-use: 401 sends the user through the provider and back to the page | pass | todo | a refresh the provider refuses exits 3 with sign in again; refresh before expiry verified |
|
||||
| SEC-029 | AUTH_0 authorization code + PKCE with an owned Auth0-compatible provider (browser token exchange, backend JWT | todo | todo | |
|
||||
| SEC-030 | AUTH_0 against a real Auth0 tenant | todo | todo | |
|
||||
| SEC-031 | FULLY_ANONYMOUS authentication: session without an identity provider, anonymous identity, logout and sign-in a | pass | todo | |
|
||||
| SEC-032 | Unsupported authentication type (a type this UI does not know, as a newer backend could declare) is reported, | pass | todo | unknown type refused |
|
||||
| SEC-031 | FULLY_ANONYMOUS authentication: session without an identity provider, anonymous identity, logout and sign-in a | pass | pass | |
|
||||
| SEC-032 | Unsupported authentication type (a type this UI does not know, as a newer backend could declare) is reported, | pass | pass | unknown type refused; TUI: unknown type refused with a message |
|
||||
| SEC-033 | Server-side logout invalidation: v1 logout expires every session cookie it issued and an ended Auth0 session c | n/a | n/a | |
|
||||
| SEC-034 | TABLE_BASED sign-in: a deep link asks for username and password under the app branding, POST /qqq/v1/manageSes | todo | todo | TABLE_BASED sign-in (no API recipe yet) |
|
||||
| SEC-035 | TABLE_BASED sign-in failure: a wrong password or unknown user is refused with one message, stores no session, | todo | todo | |
|
||||
@@ -317,44 +334,44 @@ The TUI column fills in during M3.
|
||||
| SEC-044 | Session cookies (sessionUUID, sessionId) are SameSite=Lax for the whole site, and Secure when the request arri | n/a | n/a | browser security |
|
||||
| SEC-048 | Session cookies (sessionUUID, sessionId) are HttpOnly: script in the page cannot read them, and resuming a ses | n/a | n/a | browser security |
|
||||
| SEC-049 | With HttpOnly session cookies the backend ends and replaces sessions: logout expires both cookies, an idle-exp | n/a | n/a | browser security |
|
||||
| INT-001 | Keyboard: navigate from the sidebar to a list, open a record, edit and save without a pointer | n/a | n/a | browser accessibility and layout |
|
||||
| INT-002 | Keyboard: run a process through its steps without a pointer | n/a | n/a | browser accessibility and layout |
|
||||
| INT-003 | Dialogs trap focus, close on Escape and restore focus to the trigger; confirm by keyboard | n/a | n/a | browser accessibility and layout |
|
||||
| INT-004 | Command menu, search and shortcut dialogs: keyboard open, focus inside, Escape restores focus | n/a | n/a | browser accessibility and layout |
|
||||
| INT-001 | Keyboard: navigate from the sidebar to a list, open a record, edit and save without a pointer | n/a | pass | browser accessibility and layout; TUI: keyboard only by design |
|
||||
| INT-002 | Keyboard: run a process through its steps without a pointer | n/a | pass | browser accessibility and layout |
|
||||
| INT-003 | Dialogs trap focus, close on Escape and restore focus to the trigger; confirm by keyboard | n/a | pass | browser accessibility and layout; TUI: modal dialogs, Esc closes |
|
||||
| INT-004 | Command menu, search and shortcut dialogs: keyboard open, focus inside, Escape restores focus | n/a | pass | browser accessibility and layout |
|
||||
| INT-005 | Automated accessibility scan (axe WCAG 2.1 AA) of login, dashboard, list, record, edit form and process step | n/a | n/a | browser accessibility and layout |
|
||||
| INT-006 | Form labels and ARIA: every field has an accessible label, required fields are marked, invalid fields announce | n/a | n/a | browser accessibility and layout |
|
||||
| INT-007 | Validation and error messages: client and backend validation name the field and nothing is saved | pass | todo | field named in every message |
|
||||
| INT-007 | Validation and error messages: client and backend validation name the field and nothing is saved | pass | pass | field named in every message |
|
||||
| INT-008 | Responsive layout at phone width (Pixel 7): navigation drawer, list, record and form usable without horizontal | n/a | n/a | browser accessibility and layout |
|
||||
| INT-009 | Loading states: a delayed list or record request shows a busy state, then the real data | n/a | n/a | browser accessibility and layout |
|
||||
| INT-010 | Failed request and recovery: a failed list request shows an error with retry; retry loads the data | pass | todo | exit 4 and a message; rerun |
|
||||
| INT-009 | Loading states: a delayed list or record request shows a busy state, then the real data | n/a | pass | browser accessibility and layout; TUI: loading lines |
|
||||
| INT-010 | Failed request and recovery: a failed list request shows an error with retry; retry loads the data | pass | pass | exit 4 and a message; rerun; TUI: F5 retries |
|
||||
| INT-011 | Delayed save: the submit control is disabled while saving and double submission creates one record | n/a | n/a | browser accessibility and layout |
|
||||
| INT-012 | Stale state: changes and deletions made elsewhere are shown after refresh/navigation, not stale cache | pass | todo | no client cache of records |
|
||||
| INT-013 | Empty state: a query with no results explains it and offers the create action | pass | todo | (no records) |
|
||||
| INT-014 | Backend unavailable at sign-in: the login page explains the failure and recovers with Try again | pass | todo | cannot reach the server |
|
||||
| INT-012 | Stale state: changes and deletions made elsewhere are shown after refresh/navigation, not stale cache | pass | pass | no client cache of records; TUI: F5 and on return |
|
||||
| INT-013 | Empty state: a query with no results explains it and offers the create action | pass | partial | (no records); TUI: no records message; F7 offered |
|
||||
| INT-014 | Backend unavailable at sign-in: the login page explains the failure and recovers with Try again | pass | pass | cannot reach the server; TUI: sign-in screen shows the failure |
|
||||
| SEC-050 | Optional QA capture (Jam, qbit-qa-capture qa-capture extension): Jam loads only for enabled users; Report a bu | n/a | n/a | browser security |
|
||||
| WID-001 | alert widget: severity from alertType, sanitized HTML and bullet list | pass | todo | rendered as text |
|
||||
| WID-002 | barChart widget: one bar per label in backend colors | pass | todo | rendered as text |
|
||||
| WID-001 | alert widget: severity from alertType, sanitized HTML and bullet list | pass | pass | rendered as text |
|
||||
| WID-002 | barChart widget: one bar per label in backend colors | pass | pass | rendered as text; TUI: bars in block characters |
|
||||
| WID-003 | chart output from an untyped widget renders as a bar chart | pass | todo | rendered as text |
|
||||
| WID-004 | divider widget renders a rule without card chrome | pass | todo | rendered as text |
|
||||
| WID-004 | divider widget renders a rule without card chrome | pass | pass | rendered as text |
|
||||
| WID-005 | fieldValueList widget: labels, display values, prefix icons, indentation | pass | todo | rendered as text |
|
||||
| WID-006 | generic widget renders its common payload fields (sublabel, footer) | todo | todo | generic |
|
||||
| WID-007 | horizontalBarChart widget: horizontal bars with negative values left of zero | pass | todo | rendered as text |
|
||||
| WID-008 | html widget (no-code) renders sanitized user HTML | pass | todo | rendered as text |
|
||||
| WID-009 | lineChart widget: line with a point per label and description | pass | todo | rendered as text |
|
||||
| WID-009 | lineChart widget: line with a point per label and description | pass | pass | rendered as text; TUI: sparkline and table |
|
||||
| WID-010 | smallLineChart widget: compact line with title and HTML description | pass | todo | rendered as text |
|
||||
| WID-011 | location widget: image, title, description, address and footer | todo | todo | location |
|
||||
| WID-012 | multiStatistics widget: groups with icon, header, subheader and statistics | todo | todo | multiStatistics untested |
|
||||
| WID-012 | multiStatistics widget: groups with icon, header, subheader and statistics | todo | pass | multiStatistics untested |
|
||||
| WID-013 | multiTable widget renders each table with its label and rows | pass | todo | rendered as text |
|
||||
| WID-014 | pieChart widget: colored slices, legend values and subheader | pass | todo | rendered as text |
|
||||
| WID-014 | pieChart widget: colored slices, legend values and subheader | pass | pass | rendered as text; TUI: percentage bars |
|
||||
| WID-015 | quickSightChart widget embeds the provider URL (real renderer, owned local provider endpoint) | pass | todo | prints the URL |
|
||||
| WID-016 | statistics widget: count, context, colored percentage change and links | pass | todo | rendered as text |
|
||||
| WID-017 | stackedBarChart widget stacks datasets with colors and legend | pass | todo | rendered as text |
|
||||
| WID-018 | stepper widget: completed, current (with link) and upcoming steps | pass | todo | rendered as text |
|
||||
| WID-019 | table widget: headers, rows, alignment and fixed totals row | pass | todo | rendered as text |
|
||||
| WID-016 | statistics widget: count, context, colored percentage change and links | pass | pass | rendered as text |
|
||||
| WID-017 | stackedBarChart widget stacks datasets with colors and legend | pass | pass | rendered as text; TUI: segments per dataset |
|
||||
| WID-018 | stepper widget: completed, current (with link) and upcoming steps | pass | pass | rendered as text |
|
||||
| WID-019 | table widget: headers, rows, alignment and fixed totals row | pass | pass | rendered as text |
|
||||
| WID-020 | usaMap widget plots each marker with name and coordinates | todo | todo | usaMap markers |
|
||||
| WID-021 | process widget runs the sample interactive process inline | todo | todo | process widget |
|
||||
| WID-022 | parentWidget renders its children in a 12-column grid | todo | todo | parent widget children |
|
||||
| WID-023 | composite widget renders QQQ blocks (big numbers, up/down, links, tooltips) | pass | todo | rendered as text |
|
||||
| WID-023 | composite widget renders QQQ blocks (big numbers, up/down, links, tooltips) | pass | pass | rendered as text; TUI: big numbers, up/down |
|
||||
| WID-024 | childRecordList widget without association binding lists joined child records | pass | todo | rendered as text |
|
||||
| WID-025 | customComponent widget loads its bundle and renders the named component | n/a | n/a | customComponent is web-only |
|
||||
| WID-026 | cronUI widget shows the record's schedule expression, description and time zone | todo | todo | developer and editor widgets |
|
||||
@@ -369,7 +386,7 @@ The TUI column fills in during M3.
|
||||
| WID-031 | rowBuilder widget without association binding shows its rows read-only | todo | todo | developer and editor widgets |
|
||||
| WID-032 | scriptViewer widget lists script revisions and shows the selected revision's files | todo | todo | developer and editor widgets |
|
||||
| WID-033 | quickSightChart against a real AWS QuickSight account and dashboard | n/a | todo | QuickSight |
|
||||
| WID-040 | dashboard renders app widgets in declared order sized by gridColumns (twelfths) | todo | todo | widgets per app |
|
||||
| WID-040 | dashboard renders app widgets in declared order sized by gridColumns (twelfths) | todo | partial | widgets per app; TUI: declared order, v reorders; one column |
|
||||
| WID-041 | widget chrome: card vs plain (isCard=false), sublabel, metadata and payload footer HTML | todo | todo | footer text |
|
||||
| WID-042 | header icons (topLeftInsideCard/topRightInsideCard) with metadata colors | n/a | n/a | card chrome |
|
||||
| WID-043 | widget label tooltip from metadata on hover and keyboard focus | n/a | n/a | card chrome |
|
||||
@@ -380,16 +397,16 @@ The TUI column fills in during M3.
|
||||
| WID-048 | DATE_PICKER dropdown sends the chosen date | pass | todo | --input |
|
||||
| WID-049 | required dropdowns: the parent waits with the backend message until selections are made | todo | todo | required dropdowns |
|
||||
| WID-050 | storeDropdownSelections persists selections across reload; stale stored options are dropped | n/a | todo | |
|
||||
| WID-051 | failing widget renderer shows a contained error with the backend message and retry | pass | todo | rendered as text |
|
||||
| WID-051 | failing widget renderer shows a contained error with the backend message and retry | pass | pass | rendered as text; TUI: error contained to the widget |
|
||||
| WID-052 | empty payloads show descriptive empty states for every display type | todo | todo | empty states |
|
||||
| WID-053 | malformed payloads are contained to their own widget | pass | todo | unknown shapes shown raw |
|
||||
| WID-053 | malformed payloads are contained to their own widget | pass | pass | unknown shapes shown raw |
|
||||
| WID-054 | widget permissions: a denied widget is absent from metadata, not rendered and not requested; the API denies it | pass | todo | rendered as text |
|
||||
| WID-055 | parent widget TABS layout switches tabs and remembers the selected tab | n/a | n/a | card chrome |
|
||||
| WID-056 | parent widget skips denied and unknown children without requests | todo | todo | |
|
||||
| WID-057 | composite renders every block type with its values and styles | todo | todo | every block type |
|
||||
| WID-058 | composite layouts and slot links/tooltips | n/a | n/a | card chrome |
|
||||
| WID-059 | unsupported block type shows a contained warning | pass | todo | rendered as text |
|
||||
| WID-060 | record-view section widgets receive the record context (id, tableName) | pass | todo | --input id=… |
|
||||
| WID-060 | record-view section widgets receive the record context (id, tableName) | pass | partial | --input id=…; TUI: record section widgets get the id |
|
||||
| WID-061 | process widget for a persona without process permission is contained | pass | todo | rendered as text |
|
||||
| WID-062 | custom component whose bundle cannot load shows a contained error | n/a | n/a | card chrome |
|
||||
| WID-063 | alerts with hideWidget or without HTML render nothing | pass | todo | |
|
||||
@@ -422,8 +439,8 @@ The TUI column fills in during M3.
|
||||
| WID-074 | Standalone composite scalar controls and metadata editors | todo | todo | developer and editor widgets |
|
||||
| WID-075 | Standalone DATE_TIME blocks localize seeded instants without losing seconds | todo | todo | developer and editor widgets |
|
||||
| HLP-001 | App and widget help | partial | todo | qqq help show app/widget/instance; the sample carries no help content |
|
||||
| HLP-002 | Table, field and section help | partial | todo | qqq help show table <t> [field]: table, section and field help; no sample content |
|
||||
| HLP-002 | Table, field and section help | partial | partial | qqq help show table <t> [field]: table, section and field help; no sample content; TUI: F1 shows table and field help; the sample has none |
|
||||
| HLP-003 | Query columns and table action help | partial | todo | field help is the column help; table action help slots print with their slot name; no sample content |
|
||||
| HLP-004 | Process, step and record action help | partial | todo | qqq help show process <p>: process, step, HELP_TEXT and step field help; no sample content |
|
||||
| HLP-005 | Help search | pass | todo | qqq help-search |
|
||||
| HLP-005 | Help search | pass | pass | qqq help-search; TUI: type in F1 |
|
||||
| HLP-006 | Help author affordance | n/a | todo | author affordance |
|
||||
@@ -35,16 +35,28 @@ code, hidden error or an inconsistency a user meets; **low** = polish.
|
||||
|
||||
## Requested additions (coordinator)
|
||||
|
||||
- **Download directory.** `qqq config set|get download-dir`, `upload-dir`, `download-prompt`, kept in the `ui`
|
||||
section of `~/.config/qqq/config.json` through `readUiSettings`/`writeUiSettings` in `session/profiles.ts` (the
|
||||
section and key names the TUI uses: `defaultDownloadDir`, `defaultUploadDir`, `downloadPrompt`). `table download`,
|
||||
- **Download directory.** `qqq config set|get download-dir`, `upload-dir`, `download-mode`, `theme`, kept in the
|
||||
`ui` section of `~/.config/qqq/config.json` through the TUI's kernel helpers (see "Merge with the TUI stream").
|
||||
`table download`,
|
||||
`table export`, `report run` and process downloads default to it (created when missing) when no `--out` /
|
||||
`--out-dir` is given. An explicit `widget get --csv <file>` is written where named (a named path is never moved).
|
||||
- **`qqq doctor`.** Profile, reachability, UI API version range (1.6.0 to 1.8.0, major from `supportedMajorVersions`),
|
||||
authentication type, sign-in accepted, extensions and minimums, keyring, cache age; `--conformance` runs the
|
||||
conformance package's read-only checks. Exit 1 lists the failures. The terminal half calls the kernel's
|
||||
`checkTerminalEnvironment()` by name (reported as `skip` until the TUI branch's module lands); `--fix` applies its
|
||||
fixes after two confirmations.
|
||||
conformance package's read-only checks. Exit 1 lists the failures. The terminal half is the kernel's
|
||||
`checkEnvironment`/`formatReport` (see "Merge with the TUI stream").
|
||||
|
||||
## Merge with the TUI stream (feat/m1-kernel-cli at 165c002)
|
||||
|
||||
One line per decision taken while merging the base into this branch:
|
||||
|
||||
- `main.ts` is the base's thin entry over `program.ts`; it still awaits `importLegacyCredentials()` before parsing (finding 4).
|
||||
- `program.ts` (`createProgram`) carries the option fixes of finding 1 (`--version` only, `--server` without `-s`) and registers `qqq doctor`.
|
||||
- `config.json` `ui` section: the TUI's names win, one set of helpers. My `readUiSettings`/`writeUiSettings` and their `defaultDownloadDir`/`defaultUploadDir`/`downloadPrompt` keys are gone; the CLI reads and writes `ui.files` through `readFilePreferences`/`writeFilePreferences` (files/navigator.ts) and the theme through `readTheme`/`writeTheme`. `download-prompt` became `download-mode ask|silent`, the TUI's setting; `qqq config set|get theme` was added (`--profile` sets one profile's theme).
|
||||
- `readConfig` keeps the base's shape (profiles, `ui` with theme, views, files) plus finding 3: a corrupt file is refused (`ConfigFileError`) and unknown sections are kept. The profiles test now exercises files, theme and an unknown section together.
|
||||
- `qqq doctor`'s terminal rows come from `checkEnvironment`, with `formatReport` printed under the table; `--fix` asks before each `applyLiveFix` (`--yes` answers those) and calls `appendTmuxConf` only after a y typed at a terminal (`--yes` never does).
|
||||
- Prompts and `runCli` coexist: `runCli` sets `stdin.isTTY` false, so `prompt.ask`, `confirm` and the variant chooser refuse with a usage error in the TUI's `:` pane instead of opening readline; output written straight to `process.stdout` (`--out -`) is captured by `runCli`. A test runs `runCli` in a child process to prove both.
|
||||
- `@qrun-io/qqq-ui-api-conformance` is a dependency of the CLI package (for `doctor --conformance`); the release agent sets the published version.
|
||||
- `docs/parity/feature-matrix.md` was regenerated from the merged `scripts/parity-matrix.py` (the base's TUI column plus this branch's REC-022, REC-033 and PRC-014 notes).
|
||||
|
||||
## Found and left
|
||||
|
||||
@@ -56,11 +68,10 @@ code, hidden error or an inconsistency a user meets; **low** = polish.
|
||||
| low | `apps show` and `saved-view list` show `?` for a count that failed, without the reason. | Display-only; acceptable, but could print the error on stderr. |
|
||||
| low | Remaining short-option overlaps without a collision: `-o` is `--order` on queries and `--out` on download/report/api; `-f` is `--fields` and `job logs --follow`; `-i` is `--interactive` and `widget get --input`. | No command has both meanings; renaming would break documented usage. |
|
||||
| low | `collab presence --watch` exits 0 on Ctrl-C while `tail` exits 130. | Presence leaves its lease cleanly, so 0 is defensible; align if `tail`'s 130 is the house rule. |
|
||||
| info | `qqq doctor --conformance` loads `@qrun-io/qqq-ui-api-conformance` dynamically; it is not a dependency of the CLI package. | Release engineering: add it as a dependency (or optional dependency) of `@qrun-io/qqq-cli`. |
|
||||
|
||||
## Verified
|
||||
|
||||
- `npm run build`, `npm test` (ui-api-client 17, core 96, conformance 3, cli 63 incl. the golden suite in about 4 s) and `npm run lint`.
|
||||
- `npm run build`, `npm test` after the merge (ui-api-client 17, core 109, conformance 3, cli 68 incl. the golden suite in about 5 s) and `npm run test:tui` (9) and `npm run lint`.
|
||||
- Every `qqq` command block in `TRY-IT.md` run against the sample server (UI API 1.8.0, MOCK) from this worktree,
|
||||
except the sign-in lines and the OAuth (8078) and collaboration (8098) servers, which need servers this review did
|
||||
not start. Commands that need a terminal (`-i`, delete without `--yes`, the validation choice) refuse with exit 2
|
||||
|
||||
@@ -25,3 +25,5 @@ the sample app and MOCK authentication. Each is a candidate issue for `QRun-IO/q
|
||||
| 18 | qbit-collaboration 1.1.0: when the last viewer leaves, `presence.changed` (and the table snapshot) is `{"table":"person"}` with no `viewers`, although `CollaborationPresenceSnapshotV1.viewers` is a non-nullable array. A client that waits for `viewers` keeps showing the last person. Seen on the qbit's `CollaborationDevServer` (QQQ 4.1.0-RC.15). | Two sessions (`?user=bob` and alice); bob POSTs then DELETEs `/qbit/collaboration/presence` while alice is subscribed to `table:person`. | The kernel treats a payload with `table` as the snapshot and missing `viewers` as none. The qbit should send `"viewers": []`. |
|
||||
| 19 | The v1 insert, update and delete routes do not read a table variant: `TableInsertExecutor`, `TableUpdateExecutor` and `TableDeleteExecutor` never call `ExecutorSessionUtils.setTableVariantInSession`, and the spec gives them no `tableVariant` (query, count, get, export and the process routes have it). A client cannot write to a variant-backed table over v1. | Read `executors/Table{Insert,Update,Delete}Executor.java`; the legacy `QJavalinImplementation.setupSession` read a `tableVariant` form or query parameter for every route. | `qqq table create/update/delete/copy --variant` send `tableVariant` as a query parameter (as the get route takes it) and say the server may ignore it. Add the parameter to the three routes. |
|
||||
| 20 | Joins do not reach the export route: `exportTable` has no `joins` member and a joined name in `fieldNames` (`pet.name` on `person`, a join the query route accepts) answers 500 `Field name pet.name was not found on the Person table.` The web UI exports joined columns by name; unclear whether exposed joins work there. | `qqq api exportTable --path tableName=person --body-json '{"format":"csv","fieldNames":["id","pet.name"]}'` | `qqq table export` accepts joined columns of exposed joins only and checks them before sending. |
|
||||
| 21 | `GET /version` lists the `recordSearch` feature, but `POST /search` on the sample answers `{"results":[],"tables":[],"provider":{"status":"NONE"}}` for any text: the feature is advertised with no provider behind it, so a client cannot tell "nothing matched" from "this server cannot search" without reading `provider.status`. | `qqq api searchRecords --body-json '{"query":"avery"}'` | The TUI palette reads `provider.status` and says the server has no record search. Either list `recordSearch` only when a provider is configured, or document `provider.status` as the signal. |
|
||||
| 22 | The `multiStatistics` widget payload names its list `statisticList` (singular) inside `statisticsGroupData`, while other statistics payloads and the kernel's first reading used `statisticsList`; the OpenAPI document does not type the group, so a client can only guess. | `qqq widget get SampleMultiStatisticsWidget --output json` | The kernel render model accepts both names. Type `MultiStatisticsData` group items in the spec. |
|
||||
Generated
+1
@@ -2959,6 +2959,7 @@
|
||||
"dependencies": {
|
||||
"@qrun-io/qqq-core": "0.1.0-dev",
|
||||
"@qrun-io/qqq-ui-api-client": "1.8.0-rc.1",
|
||||
"@qrun-io/qqq-ui-api-conformance": "1.6.0-rc.1",
|
||||
"commander": "15.0.0"
|
||||
},
|
||||
"bin": {
|
||||
|
||||
+3
-1
@@ -21,7 +21,9 @@
|
||||
"test": "npm test --workspaces --if-present",
|
||||
"mock": "prism mock --dynamic --port 4010 packages/ui-api-client/openapi/qqq-v1.json",
|
||||
"prepublishOnly": "npm run build",
|
||||
"lint": "node scripts/lint.mjs"
|
||||
"lint": "node scripts/lint.mjs",
|
||||
"test:tui": "cd packages/tui && bun install --frozen-lockfile && bun test",
|
||||
"typecheck:tui": "cd packages/tui && bun install --frozen-lockfile && npx tsc -p tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stoplight/prism-cli": "5.16.0",
|
||||
|
||||
@@ -25,7 +25,18 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@qrun-io/qqq-ui-api-client": "1.8.0-rc.1",
|
||||
"@qrun-io/qqq-ui-api-conformance": "1.6.0-rc.1",
|
||||
"commander": "15.0.0",
|
||||
"@qrun-io/qqq-core": "0.1.0-dev"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/main.d.ts",
|
||||
"default": "./dist/main.js"
|
||||
},
|
||||
"./program": {
|
||||
"types": "./dist/program.d.ts",
|
||||
"default": "./dist/program.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
import type { Command } from 'commander'
|
||||
import { homedir } from 'node:os'
|
||||
import { resolve } from 'node:path'
|
||||
import { configDir, readConfig, readUiSettings, removeProfile, upsertProfile, writeUiSettings, type UiSettings } from '@qrun-io/qqq-core'
|
||||
import { configDir, readConfig, readFilePreferences, readTheme, removeProfile, upsertProfile, writeFilePreferences, writeTheme } from '@qrun-io/qqq-core'
|
||||
import { UsageError, type Context } from '../context.js'
|
||||
import { getLogin, removeLogin, secretStoreKind } from '../session.js'
|
||||
|
||||
@@ -21,12 +21,32 @@ export function registerConfig(program: Command, ctx: () => Context): void {
|
||||
c.print({ data: { configDir: configDir(), secretStore: secretStoreKind(), profile: t.name, chosenBy: t.source, server: t.server, signedIn: login ? (login.kind === 'session' ? `${login.authType.toLowerCase()} session` : 'oauth2') : 'no', defaultProfile: cfg.defaultProfile ?? '', profiles: Object.keys(cfg.profiles).length } })
|
||||
})
|
||||
|
||||
/** Local settings: the CLI name and the key in the `ui` section the TUI shares. */
|
||||
const SETTINGS: Record<string, { key: keyof UiSettings & string; kind: 'dir' | 'boolean'; help: string }> = {
|
||||
'download-dir': { key: 'defaultDownloadDir', kind: 'dir', help: 'where downloads go without --out / --out-dir (created when missing)' },
|
||||
'upload-dir': { key: 'defaultUploadDir', kind: 'dir', help: 'where file pickers for uploads start' },
|
||||
'download-prompt': { key: 'downloadPrompt', kind: 'boolean', help: 'whether the TUI asks before saving a download' },
|
||||
/**
|
||||
* Local settings, kept in the `ui` section of config.json through the kernel helpers the TUI
|
||||
* uses: the file preferences (files/navigator.ts) and the theme (session/profiles.ts).
|
||||
*/
|
||||
const SETTINGS: Record<string, { help: string; get: () => unknown; set: (value: string | undefined, profile?: string) => unknown }> = {
|
||||
'download-dir': { help: 'where downloads go without --out / --out-dir (created when missing)', get: () => readFilePreferences().downloadDir, set: (v) => writeFilePreferences({ downloadDir: v === undefined ? undefined : absolute(v) }).downloadDir },
|
||||
'upload-dir': { help: 'where the TUI\'s upload chooser starts', get: () => readFilePreferences().uploadDir, set: (v) => writeFilePreferences({ uploadDir: v === undefined ? undefined : absolute(v) }).uploadDir },
|
||||
'download-mode': {
|
||||
help: 'the TUI: ask (a save dialog each time) or silent (straight to the download directory)',
|
||||
get: () => readFilePreferences().downloadMode,
|
||||
set: (v) => {
|
||||
if (v !== undefined && v !== 'ask' && v !== 'silent') throw new UsageError('download-mode takes ask or silent')
|
||||
return writeFilePreferences({ downloadMode: v }).downloadMode
|
||||
},
|
||||
},
|
||||
theme: {
|
||||
help: 'the TUI theme (--profile to set it for one profile; qqq ui lists the themes)',
|
||||
get: () => readTheme(selectedProfile()),
|
||||
set: (v, profile) => {
|
||||
writeTheme(v, { profile })
|
||||
return v
|
||||
},
|
||||
},
|
||||
}
|
||||
const absolute = (path: string) => resolve(path.replace(/^~(?=$|[\\/])/, homedir()))
|
||||
const selectedProfile = () => (program.opts() as { profile?: string }).profile ?? process.env.QQQ_PROFILE
|
||||
const setting = (name: string) => {
|
||||
const s = SETTINGS[name]
|
||||
if (!s) throw new UsageError(`No setting "${name}"; settings: ${Object.keys(SETTINGS).join(', ')}`)
|
||||
@@ -37,27 +57,20 @@ export function registerConfig(program: Command, ctx: () => Context): void {
|
||||
.description(`A local setting (${Object.keys(SETTINGS).join(', ')}), or all of them.`)
|
||||
.action((name: string | undefined) => {
|
||||
const c = ctx()
|
||||
const ui = readUiSettings()
|
||||
if (name) {
|
||||
const value = ui[setting(name).key]
|
||||
const value = setting(name).get()
|
||||
c.print({ data: value ?? null, rows: [{ setting: name, value: value ?? '' }], columns: ['setting', 'value'] })
|
||||
return
|
||||
}
|
||||
c.print({ data: ui, rows: Object.entries(SETTINGS).map(([n, s]) => ({ setting: n, value: ui[s.key] ?? '', meaning: s.help })), columns: ['setting', 'value', 'meaning'] })
|
||||
const all = Object.fromEntries(Object.entries(SETTINGS).map(([n, s]) => [n, s.get() ?? null]))
|
||||
c.print({ data: all, rows: Object.entries(SETTINGS).map(([n, s]) => ({ setting: n, value: s.get() ?? '', meaning: s.help })), columns: ['setting', 'value', 'meaning'] })
|
||||
})
|
||||
|
||||
config.command('set <setting> <value>')
|
||||
.description('Change a local setting (kept in the ui section of config.json, shared with the TUI); an empty value removes it.')
|
||||
.description('Change a local setting (the ui section of config.json, shared with the TUI); an empty value removes it.')
|
||||
.action((name: string, raw: string) => {
|
||||
const c = ctx()
|
||||
const s = setting(name)
|
||||
let value: string | boolean | undefined
|
||||
if (raw === '') value = undefined
|
||||
else if (s.kind === 'boolean') {
|
||||
if (!/^(true|false|on|off|yes|no)$/i.test(raw)) throw new UsageError(`${name} takes true or false`)
|
||||
value = /^(true|on|yes)$/i.test(raw)
|
||||
} else value = resolve(raw.replace(/^~(?=$|[\\/])/, homedir()))
|
||||
writeUiSettings({ [s.key]: value })
|
||||
const value = setting(name).set(raw === '' ? undefined : raw, name === 'theme' ? (program.opts() as { profile?: string }).profile : undefined)
|
||||
c.note(value === undefined ? `Removed ${name}.` : `${name}: ${String(value)}`)
|
||||
})
|
||||
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
* `qqq doctor`: is this client ready to use? The connectivity side (profile, server reachable, UI
|
||||
* API version within the supported range, authentication type, sign-in accepted, extensions,
|
||||
* keyring, metadata cache age, and with --conformance the read-only conformance checks) and the
|
||||
* terminal side (the kernel's checkTerminalEnvironment: tmux key bindings, alternate screen,
|
||||
* colour, unicode, size). Exit 0 when every check passes, 1 with the failing ones listed.
|
||||
* terminal side (the kernel's checkEnvironment: tmux key bindings, alternate screen, colour,
|
||||
* unicode, size; --fix applies its live tmux fixes and, after an explicit y, appends them to
|
||||
* ~/.tmux.conf). Exit 0 when every check passes, 1 with the failing ones listed.
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander'
|
||||
import { UI_API_VERSION, getAuthenticationMetaData } from '@qrun-io/qqq-ui-api-client'
|
||||
import * as core from '@qrun-io/qqq-core'
|
||||
import { callOperation, readCachedMetaData, type ServerVersion } from '@qrun-io/qqq-core'
|
||||
import { appendTmuxConf, applyLiveFix, callOperation, checkEnvironment, formatReport, readCachedMetaData, type Finding, type Report, type ServerVersion } from '@qrun-io/qqq-core'
|
||||
import { EXIT, type Context } from '../context.js'
|
||||
import { CLI_VERSION, authenticatedFetch, currentToken, getLogin, secretStoreKind } from '../session.js'
|
||||
import type { Json } from './shared.js'
|
||||
@@ -109,17 +109,40 @@ export async function connectivityChecks(c: Context, options: { conformance?: bo
|
||||
return out
|
||||
}
|
||||
|
||||
/** The terminal report of the kernel, when the kernel has it (the TUI branch adds it). */
|
||||
interface TerminalReport { checks: Array<{ check: string; status: CheckStatus; detail: string; fix?: unknown }>; applyFixes?: (options: { permanent?: boolean }) => Promise<string[]> }
|
||||
/** The terminal side: the kernel's checkEnvironment (tmux bindings, alternate screen, colour, unicode, size). */
|
||||
function terminalChecks(): { checks: DoctorCheck[]; report: Report } {
|
||||
const report = checkEnvironment({ columns: process.stdout.columns, rows: process.stdout.rows })
|
||||
const checks = report.findings.map((f): DoctorCheck => ({ area: 'terminal', check: f.id, status: f.level === 'warn' ? 'warn' : 'ok', detail: `${f.message}${f.advice ? ` (${f.advice})` : ''}${f.live ? ' [--fix]' : ''}` }))
|
||||
if (!checks.length) checks.push({ area: 'terminal', check: report.terminal || 'terminal', status: 'ok', detail: `nothing to report${report.inTmux ? ' (in tmux)' : ''}` })
|
||||
return { checks, report }
|
||||
}
|
||||
|
||||
async function terminalChecks(): Promise<{ checks: DoctorCheck[]; report?: TerminalReport }> {
|
||||
// TODO(merge with the TUI branch): checkTerminalEnvironment() is the kernel's terminal check
|
||||
// (tmux root bindings that take PageUp/PageDown/F-keys, alternate screen, truecolor, unicode,
|
||||
// size, terminal notes, tmux fixes). Looked up by name so this compiles before it lands.
|
||||
const check = (core as Record<string, unknown>).checkTerminalEnvironment as undefined | (() => Promise<TerminalReport> | TerminalReport)
|
||||
if (!check) return { checks: [{ area: 'terminal', check: 'environment', status: 'skip', detail: 'this build has no terminal checks yet (the kernel\'s checkTerminalEnvironment)' }] }
|
||||
const report = await check()
|
||||
return { report, checks: report.checks.map((x) => ({ area: 'terminal', check: x.check, status: x.status, detail: x.detail })) }
|
||||
/**
|
||||
* --fix: each live tmux fix after its own question (--yes answers them), then the ~/.tmux.conf
|
||||
* append only after an explicit y typed at the terminal (--yes does not answer that one).
|
||||
*/
|
||||
async function applyFixes(c: Context, report: Report): Promise<void> {
|
||||
const fixable = report.findings.filter((f) => f.live?.length)
|
||||
if (!fixable.length) {
|
||||
c.note('No terminal fixes to apply.')
|
||||
return
|
||||
}
|
||||
const applied: Finding[] = []
|
||||
for (const finding of fixable) {
|
||||
if (!await c.confirm(`${finding.message} Fix it for this tmux server now?`)) continue
|
||||
applyLiveFix(finding)
|
||||
applied.push(finding)
|
||||
c.note(` fixed for this tmux server: ${finding.id}`)
|
||||
}
|
||||
const permanent = applied.filter((f) => f.permanent)
|
||||
if (!permanent.length) return
|
||||
if (!process.stdin.isTTY) {
|
||||
c.note('Not made permanent: that needs an explicit y at a terminal (run qqq doctor --fix there).')
|
||||
return
|
||||
}
|
||||
const { ask } = await import('../prompt.js')
|
||||
const answer = await ask(`Make ${permanent.length === 1 ? 'it' : 'them'} permanent (append to ~/.tmux.conf)? [y/N] `)
|
||||
if (/^y(es)?$/i.test(answer)) c.note(` appended to ${appendTmuxConf(permanent)}`)
|
||||
}
|
||||
|
||||
export function registerDoctor(program: Command, ctx: () => Context): void {
|
||||
@@ -129,7 +152,7 @@ export function registerDoctor(program: Command, ctx: () => Context): void {
|
||||
.option('--fix', 'apply the terminal fixes the report offers (asks first)')
|
||||
.action(async (options: { conformance?: boolean; fix?: boolean }) => {
|
||||
const c = ctx()
|
||||
const terminal = await terminalChecks()
|
||||
const terminal = terminalChecks()
|
||||
const checks = [...await connectivityChecks(c, options), ...terminal.checks]
|
||||
const failed = checks.filter((x) => x.status === 'fail')
|
||||
c.print({
|
||||
@@ -138,13 +161,8 @@ export function registerDoctor(program: Command, ctx: () => Context): void {
|
||||
columns: ['area', 'check', 'status', 'detail'],
|
||||
footer: failed.length ? `(${failed.length} of ${checks.length} checks failed: ${failed.map((x) => x.check).join(', ')})` : `(all ${checks.length} checks passed${checks.some((x) => x.status === 'warn') ? '; see the warnings' : ''})`,
|
||||
})
|
||||
if (options.fix) {
|
||||
if (!terminal.report?.applyFixes) c.note('No terminal fixes to apply.')
|
||||
else if (await c.confirm('Apply the terminal fixes now (this tmux session)?')) {
|
||||
for (const line of await terminal.report.applyFixes({})) c.note(line)
|
||||
if (await c.confirm('Make them permanent (append to ~/.tmux.conf)?')) for (const line of await terminal.report.applyFixes({ permanent: true })) c.note(line)
|
||||
}
|
||||
}
|
||||
if (c.format === 'table' && terminal.report.findings.length) c.note(`\n${formatReport(terminal.report)}`)
|
||||
if (options.fix) await applyFixes(c, terminal.report)
|
||||
if (failed.length) process.exitCode = EXIT.error
|
||||
})
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
import { mkdirSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { displayDateTime, readUiSettings, saveDownload, valueText as recordValueText, type DisplayOptions, type FieldMeta, type RecordLike } from '@qrun-io/qqq-core'
|
||||
import { displayDateTime, readFilePreferences, saveDownload, valueText as recordValueText, type DisplayOptions, type FieldMeta, type RecordLike } from '@qrun-io/qqq-core'
|
||||
import { UsageError } from '../context.js'
|
||||
|
||||
export type Json = Record<string, unknown>
|
||||
@@ -63,11 +63,12 @@ export function readFileArg(path: string | number, flag: string): Uint8Array {
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a download goes when no --out / --out-dir names a place: the `download-dir` setting
|
||||
* (`qqq config set download-dir`), created when missing, else the current directory.
|
||||
* Where a download goes when no --out / --out-dir names a place: the download directory of the
|
||||
* file preferences the TUI shares (`qqq config set download-dir`), created when missing, else the
|
||||
* current directory.
|
||||
*/
|
||||
export function downloadDir(): string {
|
||||
const dir = readUiSettings().defaultDownloadDir
|
||||
const dir = readFilePreferences().downloadDir
|
||||
if (!dir) return '.'
|
||||
mkdirSync(dir, { recursive: true })
|
||||
return dir
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { dirname, join, resolve } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import type { Command } from 'commander'
|
||||
import { UsageError, type Context } from '../context.js'
|
||||
|
||||
/** The TUI entry in this checkout (dev); a packaged binary replaces this in M5. */
|
||||
export function tuiEntry(): string {
|
||||
const here = dirname(fileURLToPath(import.meta.url))
|
||||
return resolve(join(here, '..', '..', '..', 'tui', 'src', 'main.tsx'))
|
||||
}
|
||||
|
||||
export function registerUi(program: Command, _ctx: () => Context): void {
|
||||
program.command('ui [route...]')
|
||||
.description('Open the terminal UI (OpenTUI on Bun). A route opens a screen directly, in the CLI grammar: `qqq ui table query person -w "lastName STARTS_WITH S"`, `qqq ui table get person 3`, `qqq ui process run sleepInteractive`.')
|
||||
.option('--theme <name>', 'high-contrast (default), classic or light; saved for next time')
|
||||
.option('--ascii', 'ASCII glyphs instead of box drawing and block characters')
|
||||
.option('--linear', 'one region at a time, no side pane (as QQQ_UI_LINEAR=1)')
|
||||
.option('--mouse', 'enable the mouse (off by default)')
|
||||
.option('--check', 'check the terminal and tmux setup, offer fixes, and exit')
|
||||
.option('--no-check', 'skip the quick terminal check at startup')
|
||||
.allowUnknownOption(true)
|
||||
.action((route: string[], options: { theme?: string; ascii?: boolean; linear?: boolean; mouse?: boolean; check?: boolean }) => {
|
||||
const entry = tuiEntry()
|
||||
if (!existsSync(entry)) throw new UsageError(`The TUI is not in this installation (looked for ${entry}).`)
|
||||
const globals = program.opts<{ profile?: string; server?: string }>()
|
||||
const args = ['run', entry]
|
||||
if (globals.profile) args.push('--profile', globals.profile)
|
||||
if (globals.server) args.push('--server', globals.server)
|
||||
if (options.theme) args.push('--theme', options.theme)
|
||||
if (options.ascii) args.push('--ascii')
|
||||
if (options.linear) args.push('--linear')
|
||||
if (options.mouse) args.push('--mouse')
|
||||
if (options.check === true) args.push('--check')
|
||||
if (options.check === false) args.push('--no-check')
|
||||
if (route.length) args.push('--', ...route)
|
||||
const result = spawnSync(process.env.QQQ_BUN ?? 'bun', args, { stdio: 'inherit' })
|
||||
if (result.error) throw new UsageError(`Cannot start Bun (${result.error.message}); install it (https://bun.sh) or set QQQ_BUN.`)
|
||||
process.exitCode = result.status ?? 1
|
||||
})
|
||||
}
|
||||
@@ -14,6 +14,16 @@ import { Collaboration, ExtensionNotInstalledError, OperationInputError, Extensi
|
||||
import { openRealtimeStream } from '@qrun-io/qqq-ui-api-client'
|
||||
import { CLIENT_CHANNEL, authenticatedFetch, clientFor, currentToken, selectTarget, sessionCookieFor, target, type Target } from './session.js'
|
||||
|
||||
/**
|
||||
* Where output goes. The CLI writes to the process streams; `runCli` (the TUI's `:` command line)
|
||||
* points these at a pane for the duration of one command.
|
||||
*/
|
||||
export const io = {
|
||||
out: (text: string): void => { process.stdout.write(text) },
|
||||
err: (text: string): void => { process.stderr.write(text) },
|
||||
isTTY: (): boolean => Boolean(process.stdout.isTTY),
|
||||
}
|
||||
|
||||
/** Exit codes, as in the design doc (KofTwentyTwo ADR-0031 shape). */
|
||||
export const EXIT = { ok: 0, error: 1, usage: 2, auth: 3, transient: 4, notFound: 5, validation: 6, unexpected: 70 } as const
|
||||
|
||||
@@ -63,7 +73,7 @@ export class Context {
|
||||
get format(): OutputFormat {
|
||||
if (this.options.output) return this.options.output
|
||||
if (this.options.json) return 'json'
|
||||
return defaultFormat(Boolean(process.stdout.isTTY))
|
||||
return defaultFormat(io.isTTY())
|
||||
}
|
||||
|
||||
/** How DATE_TIME values are shown: in the TZ zone, or raw UTC instants with --utc. */
|
||||
@@ -78,7 +88,7 @@ export class Context {
|
||||
|
||||
/** Prints a result in the chosen format, after --query. */
|
||||
print(printable: Printable): void {
|
||||
process.stdout.write(render(printable, this.format, this.options.query) + '\n')
|
||||
io.out(render(printable, this.format, this.options.query) + '\n')
|
||||
}
|
||||
|
||||
/** Reshapes data with --query without printing (for callers that print themselves). */
|
||||
@@ -146,7 +156,7 @@ export class Context {
|
||||
|
||||
/** A note for people, on stderr so pipes stay clean. */
|
||||
note(text: string): void {
|
||||
process.stderr.write(text + '\n')
|
||||
io.err(text + '\n')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,85 +12,12 @@
|
||||
* commands of 0.1 (apps, tables, query, get, run, jobs, tail) as aliases.
|
||||
*/
|
||||
|
||||
import { Command, CommanderError } from 'commander'
|
||||
import { UI_API_VERSION } from '@qrun-io/qqq-ui-api-client'
|
||||
import { Context, EXIT, describeError } from './context.js'
|
||||
import { CLI_VERSION, importLegacyCredentials } from './session.js'
|
||||
import { registerApi } from './commands/api.js'
|
||||
import { registerAudit } from './commands/audit.js'
|
||||
import { registerAuth } from './commands/auth.js'
|
||||
import { registerCompletion } from './commands/completion.js'
|
||||
import { registerConfig } from './commands/config.js'
|
||||
import { registerDoctor } from './commands/doctor.js'
|
||||
import { registerExtensions } from './commands/extensions.js'
|
||||
import { registerHelp } from './commands/help.js'
|
||||
import { registerJob } from './commands/job.js'
|
||||
import { registerMisc } from './commands/misc.js'
|
||||
import { registerProcess } from './commands/process.js'
|
||||
import { registerSavedView } from './commands/saved-view.js'
|
||||
import { registerRelated } from './commands/related.js'
|
||||
import { registerTable } from './commands/table.js'
|
||||
import { registerWidget } from './commands/widget.js'
|
||||
|
||||
const program = new Command()
|
||||
program
|
||||
.name('qqq')
|
||||
.description('Command-line client for QQQ applications, built only on the QQQ UI API (/qqq/v1).')
|
||||
.version(`${CLI_VERSION} (UI API client ${UI_API_VERSION})`, '--version', 'print the version')
|
||||
.option('--server <url>', 'the QQQ server (default: QQQ_SERVER, or the default profile)')
|
||||
.option('-p, --profile <name>', 'the profile to use (default: QQQ_PROFILE, or the default profile)')
|
||||
.option('-O, --output <format>', 'json, table, text, csv or yaml (default: table on a terminal, json in a pipe; QQQ_OUTPUT)')
|
||||
.option('--json', 'same as --output json')
|
||||
.option('-Q, --query <jmespath>', 'reshape the output with a JMESPath expression, as `aws --query`')
|
||||
.option('--fields <names>', 'comma-separated columns to show in table, text and csv output')
|
||||
.option('-y, --yes', 'answer yes to confirmations (delete, cancel)')
|
||||
.option('--utc', 'show DATE_TIME values as raw UTC instants instead of in the local zone (TZ)')
|
||||
.option('--trace', 'print stack traces for unexpected errors')
|
||||
.exitOverride()
|
||||
.configureOutput({ writeErr: (text) => process.stderr.write(text) })
|
||||
|
||||
let context: Context | undefined
|
||||
const ctx = () => (context ??= new Context(program))
|
||||
|
||||
registerAuth(program, ctx)
|
||||
registerMisc(program, ctx)
|
||||
registerTable(program, ctx)
|
||||
registerAudit(program, ctx)
|
||||
registerRelated(program, ctx)
|
||||
registerProcess(program, ctx)
|
||||
registerSavedView(program, ctx)
|
||||
registerJob(program, ctx)
|
||||
registerWidget(program, ctx)
|
||||
registerApi(program, ctx)
|
||||
registerExtensions(program, ctx)
|
||||
registerCompletion(program, ctx)
|
||||
registerConfig(program, ctx)
|
||||
registerDoctor(program, ctx)
|
||||
registerHelp(program, ctx)
|
||||
|
||||
/** Flat aliases from 0.1: forward to the noun-verb commands with the same options. */
|
||||
function alias(flat: string, noun: string, verb: string, description: string): void {
|
||||
const target = program.commands.find((c) => c.name() === noun)!.commands.find((c) => c.name() === verb)!
|
||||
const cmd = program.command(`${flat} ${target.registeredArguments.map((a) => (a.required ? `<${a.name()}>` : `[${a.name()}]`)).join(' ')}`.trim(), { hidden: true }).description(`${description} (alias of \`qqq ${noun} ${verb}\`)`)
|
||||
for (const option of target.options) cmd.addOption(option)
|
||||
cmd.action(async (...args: unknown[]) => {
|
||||
const commandArgs = args.slice(0, -2) as string[]
|
||||
const options = args[args.length - 2] as Record<string, unknown>
|
||||
const argv: string[] = [...commandArgs]
|
||||
for (const option of target.options) {
|
||||
const value = options[option.attributeName()]
|
||||
if (value === undefined || value === false) continue
|
||||
if (value === true) argv.push(option.long!)
|
||||
else if (Array.isArray(value)) for (const v of value) argv.push(option.long!, String(v))
|
||||
else argv.push(option.long!, String(value))
|
||||
}
|
||||
await target.parseAsync(argv, { from: 'user' })
|
||||
})
|
||||
}
|
||||
alias('query', 'table', 'query', 'Query a table')
|
||||
alias('get', 'table', 'get', 'View one record')
|
||||
alias('run', 'process', 'run', 'Run a process')
|
||||
import { CommanderError } from 'commander'
|
||||
import { EXIT, describeError } from './context.js'
|
||||
import { createProgram } from './program.js'
|
||||
import { importLegacyCredentials } from './session.js'
|
||||
|
||||
const program = createProgram()
|
||||
importLegacyCredentials().then(() => program.parseAsync()).catch((error: unknown) => {
|
||||
if (error instanceof CommanderError) {
|
||||
process.exitCode = error.exitCode === 0 ? 0 : EXIT.usage
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The qqq program as a library: `createProgram()` builds the commander program with every command,
|
||||
* and `runCli(argv, { stdout, stderr })` runs one command line in-process with its output sent to
|
||||
* the given writers. The TUI's `:` command line uses runCli, so the CLI grammar is the TUI's URL
|
||||
* scheme and both heads share one dispatcher.
|
||||
*/
|
||||
|
||||
import { Command, CommanderError } from 'commander'
|
||||
import { UI_API_VERSION } from '@qrun-io/qqq-ui-api-client'
|
||||
import { Context, EXIT, describeError, io } from './context.js'
|
||||
import { CLI_VERSION } from './session.js'
|
||||
import { registerApi } from './commands/api.js'
|
||||
import { registerAudit } from './commands/audit.js'
|
||||
import { registerAuth } from './commands/auth.js'
|
||||
import { registerCompletion } from './commands/completion.js'
|
||||
import { registerConfig } from './commands/config.js'
|
||||
import { registerDoctor } from './commands/doctor.js'
|
||||
import { registerExtensions } from './commands/extensions.js'
|
||||
import { registerHelp } from './commands/help.js'
|
||||
import { registerJob } from './commands/job.js'
|
||||
import { registerMisc } from './commands/misc.js'
|
||||
import { registerProcess } from './commands/process.js'
|
||||
import { registerSavedView } from './commands/saved-view.js'
|
||||
import { registerRelated } from './commands/related.js'
|
||||
import { registerTable } from './commands/table.js'
|
||||
import { registerUi } from './commands/ui.js'
|
||||
import { registerWidget } from './commands/widget.js'
|
||||
|
||||
/** Builds a fresh program (a new Context per run). */
|
||||
export function createProgram(): Command {
|
||||
const program = new Command()
|
||||
program
|
||||
.name('qqq')
|
||||
.description('Command-line client for QQQ applications, built only on the QQQ UI API (/qqq/v1).')
|
||||
.version(`${CLI_VERSION} (UI API client ${UI_API_VERSION})`, '--version', 'print the version')
|
||||
.option('--server <url>', 'the QQQ server (default: QQQ_SERVER, or the default profile)')
|
||||
.option('-p, --profile <name>', 'the profile to use (default: QQQ_PROFILE, or the default profile)')
|
||||
.option('-O, --output <format>', 'json, table, text, csv or yaml (default: table on a terminal, json in a pipe; QQQ_OUTPUT)')
|
||||
.option('--json', 'same as --output json')
|
||||
.option('-Q, --query <jmespath>', 'reshape the output with a JMESPath expression, as `aws --query`')
|
||||
.option('--fields <names>', 'comma-separated columns to show in table, text and csv output')
|
||||
.option('-y, --yes', 'answer yes to confirmations (delete, cancel)')
|
||||
.option('--utc', 'show DATE_TIME values as raw UTC instants instead of in the local zone (TZ)')
|
||||
.option('--trace', 'print stack traces for unexpected errors')
|
||||
.exitOverride()
|
||||
.configureOutput({ writeOut: (text) => io.out(text), writeErr: (text) => io.err(text) })
|
||||
|
||||
let context: Context | undefined
|
||||
const ctx = () => (context ??= new Context(program))
|
||||
|
||||
registerAuth(program, ctx)
|
||||
registerMisc(program, ctx)
|
||||
registerTable(program, ctx)
|
||||
registerAudit(program, ctx)
|
||||
registerRelated(program, ctx)
|
||||
registerProcess(program, ctx)
|
||||
registerSavedView(program, ctx)
|
||||
registerJob(program, ctx)
|
||||
registerWidget(program, ctx)
|
||||
registerApi(program, ctx)
|
||||
registerExtensions(program, ctx)
|
||||
registerCompletion(program, ctx)
|
||||
registerConfig(program, ctx)
|
||||
registerDoctor(program, ctx)
|
||||
registerHelp(program, ctx)
|
||||
registerUi(program, ctx)
|
||||
|
||||
/** Flat aliases from 0.1: forward to the noun-verb commands with the same options. */
|
||||
function alias(flat: string, noun: string, verb: string, description: string): void {
|
||||
const target = program.commands.find((c) => c.name() === noun)!.commands.find((c) => c.name() === verb)!
|
||||
const cmd = program.command(`${flat} ${target.registeredArguments.map((a) => (a.required ? `<${a.name()}>` : `[${a.name()}]`)).join(' ')}`.trim(), { hidden: true }).description(`${description} (alias of \`qqq ${noun} ${verb}\`)`)
|
||||
for (const option of target.options) cmd.addOption(option)
|
||||
cmd.action(async (...args: unknown[]) => {
|
||||
const commandArgs = args.slice(0, -2) as string[]
|
||||
const options = args[args.length - 2] as Record<string, unknown>
|
||||
const argv: string[] = [...commandArgs]
|
||||
for (const option of target.options) {
|
||||
const value = options[option.attributeName()]
|
||||
if (value === undefined || value === false) continue
|
||||
if (value === true) argv.push(option.long!)
|
||||
else if (Array.isArray(value)) for (const v of value) argv.push(option.long!, String(v))
|
||||
else argv.push(option.long!, String(value))
|
||||
}
|
||||
await target.parseAsync(argv, { from: 'user' })
|
||||
})
|
||||
}
|
||||
alias('query', 'table', 'query', 'Query a table')
|
||||
alias('get', 'table', 'get', 'View one record')
|
||||
alias('run', 'process', 'run', 'Run a process')
|
||||
|
||||
return program
|
||||
}
|
||||
|
||||
export interface RunCliIo {
|
||||
stdout: (text: string) => void
|
||||
stderr: (text: string) => void
|
||||
/** Whether the output may use terminal formatting (default false: table output is still chosen by --output). */
|
||||
isTTY?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs one command line in-process (argv without the program name) and returns its exit code.
|
||||
* Output written by commands goes to the writers; prompts are off (no terminal), so commands that
|
||||
* would ask need their values or --yes. One run at a time.
|
||||
*/
|
||||
export async function runCli(argv: string[], options: RunCliIo): Promise<number> {
|
||||
const saved = { out: io.out, err: io.err, isTTY: io.isTTY, stdoutWrite: process.stdout.write, stderrWrite: process.stderr.write, stdinTTY: Object.getOwnPropertyDescriptor(process.stdin, 'isTTY') }
|
||||
io.out = options.stdout
|
||||
io.err = options.stderr
|
||||
io.isTTY = () => options.isTTY ?? false
|
||||
// a few commands write to the process streams directly; capture those too while the command runs
|
||||
process.stdout.write = ((chunk: string | Uint8Array) => { options.stdout(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)); return true }) as typeof process.stdout.write
|
||||
process.stderr.write = ((chunk: string | Uint8Array) => { options.stderr(typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk)); return true }) as typeof process.stderr.write
|
||||
Object.defineProperty(process.stdin, 'isTTY', { value: false, configurable: true, writable: true })
|
||||
const program = createProgram()
|
||||
try {
|
||||
await program.parseAsync(argv, { from: 'user' })
|
||||
return typeof process.exitCode === 'number' ? process.exitCode : 0
|
||||
} catch (error) {
|
||||
if (error instanceof CommanderError) return error.exitCode === 0 ? 0 : EXIT.usage
|
||||
const { code, text } = describeError(error, Boolean(program.opts().trace))
|
||||
options.stderr(text + '\n')
|
||||
return code
|
||||
} finally {
|
||||
process.exitCode = undefined
|
||||
io.out = saved.out
|
||||
io.err = saved.err
|
||||
io.isTTY = saved.isTTY
|
||||
process.stdout.write = saved.stdoutWrite
|
||||
process.stderr.write = saved.stderrWrite
|
||||
if (saved.stdinTTY) Object.defineProperty(process.stdin, 'isTTY', saved.stdinTTY)
|
||||
else delete (process.stdin as { isTTY?: boolean }).isTTY
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { after, before, describe, test } from 'node:test'
|
||||
@@ -191,6 +192,53 @@ describe('doctor', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('merged with the TUI stream', () => {
|
||||
test('config set|get theme and download-mode use the TUI\'s ui helpers', async () => {
|
||||
const local = sandbox()
|
||||
try {
|
||||
assert.equal((await runCli(['config', 'set', 'theme', 'classic'], local.env)).code, 0)
|
||||
assert.equal(JSON.parse((await runCli(['config', 'get', 'theme', '-O', 'json'], local.env)).stdout), 'classic')
|
||||
assert.equal((await runCli(['config', 'set', 'download-mode', 'loud'], local.env)).code, 2)
|
||||
assert.equal((await runCli(['config', 'set', 'download-mode', 'silent'], local.env)).code, 0)
|
||||
assert.equal((await runCli(['config', 'set', 'download-dir', join(local.env.HOME!, 'dl')], local.env)).code, 0)
|
||||
const ui = (JSON.parse(readFileSync(join(local.env.QQQ_CONFIG_DIR!, 'config.json'), 'utf8')) as { ui: unknown }).ui
|
||||
assert.deepEqual(ui, { theme: 'classic', files: { downloadMode: 'silent', downloadDir: join(local.env.HOME!, 'dl') } })
|
||||
} finally {
|
||||
local.dispose()
|
||||
}
|
||||
})
|
||||
test('doctor reports the terminal through the kernel check', async () => {
|
||||
const result = await qqq(['doctor', '-O', 'json'])
|
||||
const checks = (JSON.parse(result.stdout) as { checks: Array<{ area: string; status: string }> }).checks.filter((x) => x.area === 'terminal')
|
||||
assert.ok(checks.length > 0)
|
||||
assert.ok(checks.every((x) => x.status !== 'skip'))
|
||||
})
|
||||
test('runCli captures what commands write straight to stdout (export --out -) for the TUI pane', async () => {
|
||||
// in a child process: capturing process.stdout inside the test runner would eat its own reports
|
||||
const script = `
|
||||
import { runCli } from ${JSON.stringify(new URL('../program.js', import.meta.url).href)}
|
||||
let out = '', err = ''
|
||||
const io = { stdout: (t) => (out += t), stderr: (t) => (err += t) }
|
||||
const exported = await runCli(['--server', process.argv[1], 'table', 'export', 'person', '--out', '-'], io)
|
||||
const exportOut = out
|
||||
out = ''
|
||||
const refused = await runCli(['--server', process.argv[1], 'table', 'delete', 'pet', '7'], io)
|
||||
process.stdout.write(JSON.stringify({ exported, exportOut, refused, err }))`
|
||||
const result = await new Promise<string>((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ['--input-type=module', '-e', script, server.url], { env: { ...process.env, ...box.env, QQQ_TOKEN: 'test-token' } })
|
||||
let text = ''
|
||||
child.stdout.on('data', (d: Buffer) => (text += d.toString('utf8')))
|
||||
child.on('error', reject)
|
||||
child.on('close', () => resolve(text))
|
||||
})
|
||||
const r = JSON.parse(result) as { exported: number; exportOut: string; refused: number; err: string }
|
||||
assert.equal(r.exported, 0, r.err)
|
||||
assert.equal(r.exportOut, 'id,firstName\n1,Avery\n2,Blair\n')
|
||||
assert.equal(r.refused, 2)
|
||||
assert.match(r.err, /Pass --yes/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('local state', () => {
|
||||
test('a corrupt config.json is reported and left alone', async () => {
|
||||
const local = sandbox()
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { createProgram, runCli } from '../program.js'
|
||||
|
||||
test('createProgram builds every noun, including ui', () => {
|
||||
const names = createProgram().commands.map((c) => c.name())
|
||||
for (const n of ['table', 'process', 'widget', 'login', 'ui']) assert.ok(names.includes(n), n)
|
||||
})
|
||||
|
||||
test('runCli sends output to the writers and returns the exit code', async () => {
|
||||
const out: string[] = []
|
||||
const err: string[] = []
|
||||
const io = { stdout: (t: string) => { out.push(t) }, stderr: (t: string) => { err.push(t) } }
|
||||
assert.equal(await runCli(['nope'], io), 2)
|
||||
assert.match(err.join(''), /unknown command 'nope'/)
|
||||
assert.equal(await runCli(['completion', 'bash'], io), 0)
|
||||
assert.match(out.join(''), /complete/, 'a command that writes to process.stdout directly is captured')
|
||||
assert.equal(process.exitCode, undefined)
|
||||
})
|
||||
Vendored
+197
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The terminal check behind `qqq ui` (at startup, `--check`) and `qqq doctor`: tmux root-table
|
||||
* bindings that take keys the TUI needs (PgUp/PgDn, F1 to F10, Home/End, Esc, `/`, `?`, `:`),
|
||||
* tmux options (alternate screen, TERM, truecolor), the terminal program, colors, size. Each
|
||||
* finding can carry a live fix (tmux commands for the running server, never a file edit) and the
|
||||
* lines for a permanent fix (appended to ~/.tmux.conf only when the person says yes). No terminal
|
||||
* UI code here; tmux is run through an injectable function so tests use fake output.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { appendFileSync, existsSync, readFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { readConfig, writeConfig } from '../session/profiles.js'
|
||||
|
||||
export type Level = 'ok' | 'info' | 'warn'
|
||||
|
||||
export interface Finding {
|
||||
id: string
|
||||
level: Level
|
||||
message: string
|
||||
/** tmux commands (argv) that fix it for the running server. */
|
||||
live?: string[][]
|
||||
/** Lines to add to ~/.tmux.conf (or another file) for good. */
|
||||
permanent?: string
|
||||
/** A note when the fix is in the terminal's own settings. */
|
||||
advice?: string
|
||||
}
|
||||
|
||||
export interface Report {
|
||||
terminal: string
|
||||
inTmux: boolean
|
||||
findings: Finding[]
|
||||
}
|
||||
|
||||
/** Runs tmux and returns stdout ('' on failure); quick, never more than a second. */
|
||||
export type TmuxRunner = (args: string[]) => string
|
||||
|
||||
export const defaultTmux: TmuxRunner = (args) => {
|
||||
const r = spawnSync('tmux', args, { encoding: 'utf8', timeout: 1000 })
|
||||
return r.status === 0 ? String(r.stdout ?? '') : ''
|
||||
}
|
||||
|
||||
/** Keys the TUI needs, as tmux names them, with the key tmux should pass on. */
|
||||
const NEEDED: Record<string, string> = {
|
||||
PPage: 'PPage', NPage: 'NPage', PageUp: 'PPage', PageDown: 'NPage', Home: 'Home', End: 'End', Escape: 'Escape',
|
||||
F1: 'F1', F2: 'F2', F3: 'F3', F4: 'F4', F5: 'F5', F6: 'F6', F7: 'F7', F8: 'F8', F9: 'F9', F10: 'F10',
|
||||
'/': '/', '?': '?', ':': ':',
|
||||
}
|
||||
|
||||
export interface Binding {
|
||||
key: string
|
||||
command: string
|
||||
}
|
||||
|
||||
/** Parses `tmux list-keys -T root` lines into key and command. */
|
||||
export function parseRootBindings(output: string): Binding[] {
|
||||
const out: Binding[] = []
|
||||
for (const line of output.split('\n')) {
|
||||
const m = /^bind-key\s+(?:-r\s+)?-T\s+root\s+(\S+)\s+(.*)$/.exec(line.trim())
|
||||
if (m) out.push({ key: m[1]!.replace(/^\\/, ''), command: m[2]!.trim() })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Whether a binding already hands the key to full-screen programs (`#{alternate_on}` then `send-keys` of the key). */
|
||||
export function passesToFullScreen(binding: Binding): boolean {
|
||||
return /#\{alternate_on\}/.test(binding.command) && /send-keys\s+(\S+)/.test(binding.command)
|
||||
}
|
||||
|
||||
/** The live fix: rebind so full-screen programs get the key and the shell keeps the old behaviour. */
|
||||
export function tmuxFixCommand(binding: Binding): string[] {
|
||||
const pass = NEEDED[binding.key] ?? binding.key
|
||||
return ['bind-key', '-n', binding.key, 'if-shell', '-F', '#{alternate_on}', `send-keys ${pass}`, binding.command || '']
|
||||
}
|
||||
|
||||
/** The same fix as a ~/.tmux.conf line. */
|
||||
export function tmuxConfLine(binding: Binding): string {
|
||||
const pass = NEEDED[binding.key] ?? binding.key
|
||||
const original = binding.command ? `{ ${binding.command} }` : "''"
|
||||
return `bind-key -n ${binding.key} if-shell -F "#{alternate_on}" "send-keys ${pass}" ${original}`
|
||||
}
|
||||
|
||||
export const CONF_MARK_BEGIN = '# >>> qqq ui: let full-screen programs have their keys >>>'
|
||||
export const CONF_MARK_END = '# <<< qqq ui <<<'
|
||||
|
||||
/** Detects the terminal program from the environment. */
|
||||
export function detectTerminal(env: NodeJS.ProcessEnv = process.env): string {
|
||||
if (env.WT_SESSION) return 'Windows Terminal'
|
||||
if (env.KITTY_WINDOW_ID || /kitty/.test(env.TERM ?? '')) return 'kitty'
|
||||
if (env.WEZTERM_PANE || env.TERM_PROGRAM === 'WezTerm') return 'WezTerm'
|
||||
if (env.TERM_PROGRAM === 'iTerm.app' || env.LC_TERMINAL === 'iTerm2') return 'iTerm2'
|
||||
if (env.TERM_PROGRAM === 'Apple_Terminal') return 'Terminal.app'
|
||||
if (env.TERM_PROGRAM === 'vscode') return 'VS Code'
|
||||
return env.TERM_PROGRAM || env.TERM || 'unknown'
|
||||
}
|
||||
|
||||
export interface CheckOptions {
|
||||
env?: NodeJS.ProcessEnv
|
||||
columns?: number
|
||||
rows?: number
|
||||
tmux?: TmuxRunner
|
||||
}
|
||||
|
||||
/** Runs every check. */
|
||||
export function checkEnvironment(options: CheckOptions = {}): Report {
|
||||
const env = options.env ?? process.env
|
||||
const tmux = options.tmux ?? defaultTmux
|
||||
const findings: Finding[] = []
|
||||
const inTmux = Boolean(env.TMUX)
|
||||
const terminal = detectTerminal(env)
|
||||
if (inTmux) {
|
||||
const outer = tmux(['show-environment', '-g', 'TERM_PROGRAM']).replace(/^TERM_PROGRAM=/, '').trim()
|
||||
const bindings = parseRootBindings(tmux(['list-keys', '-T', 'root']))
|
||||
for (const b of bindings) {
|
||||
if (!(b.key in NEEDED) || passesToFullScreen(b)) continue
|
||||
findings.push({
|
||||
id: `tmux-key-${b.key}`,
|
||||
level: 'warn',
|
||||
message: `tmux takes ${b.key} before any program sees it (root binding: ${b.command.slice(0, 80)}${b.command.length > 80 ? '…' : ''}).`,
|
||||
live: [tmuxFixCommand(b)],
|
||||
permanent: tmuxConfLine(b),
|
||||
})
|
||||
}
|
||||
const alt = tmux(['show', '-gv', 'alternate-screen']).trim()
|
||||
if (alt === 'off') findings.push({ id: 'tmux-alternate-screen', level: 'warn', message: 'tmux alternate-screen is off: full-screen programs draw into the scrollback.', live: [['set', '-g', 'alternate-screen', 'on']], permanent: 'set -g alternate-screen on' })
|
||||
const term = env.TERM ?? ''
|
||||
if (!/^(tmux|screen)-256color$/.test(term)) findings.push({ id: 'tmux-term', level: 'info', message: `TERM is ${term || 'unset'} inside tmux; tmux-256color (or screen-256color) gives the right keys and colors.`, permanent: 'set -g default-terminal "tmux-256color"' })
|
||||
const overrides = tmux(['show', '-gv', 'terminal-overrides']) + tmux(['show', '-gv', 'terminal-features'])
|
||||
if (!/Tc|RGB/.test(overrides)) findings.push({ id: 'tmux-truecolor', level: 'info', message: 'tmux does not pass true color (no Tc or RGB in terminal-overrides/terminal-features); themes fall back to 256 colors.', live: [['set', '-as', 'terminal-overrides', ',*:Tc']], permanent: "set -as terminal-overrides ',*:Tc'" })
|
||||
if (outer) findings.push({ id: 'outer-terminal', level: 'ok', message: `tmux runs in ${outer}.` })
|
||||
}
|
||||
if (!/truecolor|24bit/i.test(env.COLORTERM ?? '')) findings.push({ id: 'truecolor', level: 'info', message: 'COLORTERM does not announce true color; colors are approximated.' })
|
||||
if (!/UTF-?8/i.test(`${env.LC_ALL ?? ''}${env.LC_CTYPE ?? ''}${env.LANG ?? ''}`)) findings.push({ id: 'unicode', level: 'warn', message: 'The locale is not UTF-8; box drawing and block characters may not show. qqq ui --ascii uses plain characters.', advice: 'export LANG=en_US.UTF-8 (or your locale with .UTF-8), or run qqq ui --ascii' })
|
||||
if (options.columns !== undefined && options.rows !== undefined && (options.columns < 80 || options.rows < 24)) findings.push({ id: 'size', level: 'warn', message: `The terminal is ${options.columns}x${options.rows}; qqq ui needs at least 80x24.` })
|
||||
if (env.TERM === 'dumb') findings.push({ id: 'dumb', level: 'warn', message: 'TERM is dumb: no alternate screen or cursor control. Use the qqq CLI.' })
|
||||
if (terminal === 'Terminal.app') findings.push({ id: 'terminal-app-fkeys', level: 'info', message: 'Terminal.app: F1 to F10 go to macOS unless "Use F1, F2, etc. keys as standard function keys" is on (System Settings › Keyboard), or hold fn.', advice: 'Every F-key action is also in the F9 menu (Esc then 9 in some setups) and on the : command line.' })
|
||||
if (terminal === 'iTerm2') findings.push({ id: 'iterm-page-keys', level: 'info', message: 'iTerm2: if PgUp/PgDn scroll the window instead of reaching qqq ui, set Profiles › Keys › Key Mappings for Page Up/Down to "Send Escape Sequence" ([5~ and [6~), and turn off "Scroll wheel sends arrow keys in alternate screen mode" only if you prefer wheel scrolling.' })
|
||||
return { terminal, inTmux, findings }
|
||||
}
|
||||
|
||||
/** Runs the live fix of a finding against the running tmux server. */
|
||||
export function applyLiveFix(finding: Finding, tmux: TmuxRunner = defaultTmux): void {
|
||||
for (const args of finding.live ?? []) tmux(args)
|
||||
}
|
||||
|
||||
/** Appends the permanent fixes in one marked block to a tmux config file (default ~/.tmux.conf); returns the file. */
|
||||
export function appendTmuxConf(findings: Finding[], file = join(homedir(), '.tmux.conf')): string {
|
||||
const lines = findings.map((f) => f.permanent).filter((l): l is string => Boolean(l))
|
||||
if (!lines.length) return file
|
||||
const existing = existsSync(file) ? readFileSync(file, 'utf8') : ''
|
||||
const fresh = lines.filter((l) => !existing.includes(l))
|
||||
if (!fresh.length) return file
|
||||
appendFileSync(file, `${existing && !existing.endsWith('\n') ? '\n' : ''}\n${CONF_MARK_BEGIN}\n${fresh.join('\n')}\n${CONF_MARK_END}\n`)
|
||||
return file
|
||||
}
|
||||
|
||||
/** The report as text lines. */
|
||||
export function formatReport(report: Report): string {
|
||||
const out = [`terminal: ${report.terminal}${report.inTmux ? ' (inside tmux)' : ''}`]
|
||||
if (!report.findings.filter((f) => f.level !== 'ok').length) out.push('everything qqq ui needs is in place')
|
||||
for (const f of report.findings) {
|
||||
out.push(`${f.level === 'warn' ? '!' : f.level === 'info' ? '-' : '✓'} ${f.message}`)
|
||||
if (f.permanent) out.push(` fix: ${f.permanent}`)
|
||||
if (f.advice) out.push(` ${f.advice}`)
|
||||
}
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
export interface DoctorState {
|
||||
lastCheck?: string
|
||||
applied?: string[]
|
||||
/** Findings the person asked not to be asked about again. */
|
||||
dontAsk?: string[]
|
||||
}
|
||||
|
||||
export function readDoctorState(env: NodeJS.ProcessEnv = process.env): DoctorState {
|
||||
return { ...(((readConfig(env).ui as { doctor?: DoctorState } | undefined)?.doctor) ?? {}) }
|
||||
}
|
||||
|
||||
export function writeDoctorState(patch: Partial<DoctorState>, env: NodeJS.ProcessEnv = process.env): DoctorState {
|
||||
const config = readConfig(env)
|
||||
const doctor = { ...readDoctorState(env), ...patch }
|
||||
config.ui = { ...config.ui, doctor } as never
|
||||
writeConfig(config, env)
|
||||
return doctor
|
||||
}
|
||||
|
||||
/** The findings worth asking about at startup: warnings with a fix that were not dismissed. */
|
||||
export function startupFindings(report: Report, state: DoctorState = readDoctorState()): Finding[] {
|
||||
return report.findings.filter((f) => f.level === 'warn' && (f.live || f.permanent) && !(state.dontAsk ?? []).includes(f.id))
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* What a file chooser needs, without any terminal code: directory listings (directories first,
|
||||
* sizes and dates), path expansion and Tab completion, and the local file preferences (default
|
||||
* download and upload directories, the last ones used, ask or save silently) kept in the config
|
||||
* file's ui section.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, isAbsolute, join, resolve, sep } from 'node:path'
|
||||
import { readConfig, writeConfig } from '../session/profiles.js'
|
||||
|
||||
export interface DirEntry {
|
||||
name: string
|
||||
path: string
|
||||
isDir: boolean
|
||||
size: number
|
||||
modified: Date
|
||||
hidden: boolean
|
||||
}
|
||||
|
||||
/** `~` and `~/x` become the home directory; relative paths resolve against `cwd`. */
|
||||
export function expandPath(text: string, cwd: string = process.cwd()): string {
|
||||
const t = text.trim()
|
||||
if (t === '~') return homedir()
|
||||
if (t.startsWith(`~${sep}`) || t.startsWith('~/')) return join(homedir(), t.slice(2))
|
||||
return isAbsolute(t) ? resolve(t) : resolve(cwd, t)
|
||||
}
|
||||
|
||||
/** The entries of a directory: directories first, then files, each by name (case-insensitive). Unreadable entries are skipped. */
|
||||
export function listDirectory(dir: string, options: { hidden?: boolean } = {}): DirEntry[] {
|
||||
const out: DirEntry[] = []
|
||||
for (const name of readdirSync(dir)) {
|
||||
const hidden = name.startsWith('.')
|
||||
if (hidden && !options.hidden) continue
|
||||
const path = join(dir, name)
|
||||
try {
|
||||
const st = statSync(path)
|
||||
out.push({ name, path, isDir: st.isDirectory(), size: st.isDirectory() ? 0 : st.size, modified: st.mtime, hidden })
|
||||
} catch {
|
||||
// a broken link or no permission
|
||||
}
|
||||
}
|
||||
return out.sort((a, b) => Number(b.isDir) - Number(a.isDir) || a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }))
|
||||
}
|
||||
|
||||
/** A size for people: 512 B, 1.4 KB, 3.2 MB. */
|
||||
export function formatSize(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`
|
||||
const units = ['KB', 'MB', 'GB', 'TB']
|
||||
let n = bytes / 1024
|
||||
let i = 0
|
||||
while (n >= 1024 && i < units.length - 1) {
|
||||
n /= 1024
|
||||
i++
|
||||
}
|
||||
return `${n.toFixed(n < 10 ? 1 : 0)} ${units[i]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab completion of a typed path: the longest common extension of the matching entries, and the
|
||||
* candidates. A directory that is the only match gets a trailing separator.
|
||||
*/
|
||||
export function completePath(text: string, options: { cwd?: string; hidden?: boolean; directoriesOnly?: boolean } = {}): { text: string; candidates: string[] } {
|
||||
const cwd = options.cwd ?? process.cwd()
|
||||
const typedDir = text.endsWith('/') || text.endsWith(sep) ? text : text.includes('/') || text.includes(sep) ? text.slice(0, Math.max(text.lastIndexOf('/'), text.lastIndexOf(sep)) + 1) : ''
|
||||
const prefix = text.slice(typedDir.length)
|
||||
const dir = typedDir ? expandPath(typedDir, cwd) : cwd
|
||||
let entries: DirEntry[] = []
|
||||
try {
|
||||
entries = listDirectory(dir, { hidden: options.hidden || prefix.startsWith('.') })
|
||||
} catch {
|
||||
return { text, candidates: [] }
|
||||
}
|
||||
const matches = entries.filter((e) => e.name.startsWith(prefix) && (!options.directoriesOnly || e.isDir))
|
||||
if (!matches.length) return { text, candidates: [] }
|
||||
let common = matches[0]!.name
|
||||
for (const m of matches) while (!m.name.startsWith(common)) common = common.slice(0, -1)
|
||||
const only = matches.length === 1 ? matches[0]! : undefined
|
||||
return { text: typedDir + common + (only?.isDir ? '/' : ''), candidates: matches.map((m) => m.name + (m.isDir ? '/' : '')) }
|
||||
}
|
||||
|
||||
/** Creates a folder (and its parents); returns its path. */
|
||||
export function createFolder(parent: string, name: string): string {
|
||||
if (!name.trim() || /[/\\]/.test(name)) throw new Error('A folder name cannot be empty or contain a slash')
|
||||
const path = join(parent, name.trim())
|
||||
mkdirSync(path, { recursive: true })
|
||||
return path
|
||||
}
|
||||
|
||||
/** Local file preferences of the clients. */
|
||||
export interface FilePreferences {
|
||||
/** Where downloads, exports and reports are saved by default. */
|
||||
downloadDir?: string
|
||||
/** Where the upload chooser starts by default. */
|
||||
uploadDir?: string
|
||||
/** `ask` (a save dialog each time) or `silent` (straight to the download directory). */
|
||||
downloadMode?: 'ask' | 'silent'
|
||||
lastDownloadDir?: string
|
||||
lastUploadDir?: string
|
||||
}
|
||||
|
||||
export function readFilePreferences(env: NodeJS.ProcessEnv = process.env): FilePreferences {
|
||||
return { ...(((readConfig(env).ui as { files?: FilePreferences } | undefined)?.files) ?? {}) }
|
||||
}
|
||||
|
||||
export function writeFilePreferences(patch: Partial<FilePreferences>, env: NodeJS.ProcessEnv = process.env): FilePreferences {
|
||||
const config = readConfig(env)
|
||||
const files = { ...(((config.ui as { files?: FilePreferences } | undefined)?.files) ?? {}), ...patch }
|
||||
for (const k of Object.keys(files) as Array<keyof FilePreferences>) if (files[k] === undefined || files[k] === '') delete files[k]
|
||||
config.ui = { ...config.ui, files } as never
|
||||
writeConfig(config, env)
|
||||
return files
|
||||
}
|
||||
|
||||
function firstExisting(...dirs: Array<string | undefined>): string {
|
||||
for (const d of dirs) if (d && existsSync(d)) return d
|
||||
return process.cwd()
|
||||
}
|
||||
|
||||
/** The directory a save dialog starts in: last used, the default download directory, ~/Downloads, the current directory. */
|
||||
export function startDirForSave(prefs: FilePreferences = readFilePreferences()): string {
|
||||
return firstExisting(prefs.lastDownloadDir, prefs.downloadDir, join(homedir(), 'Downloads'))
|
||||
}
|
||||
|
||||
/** The directory an upload chooser starts in: last used, the default upload directory, the current directory. */
|
||||
export function startDirForUpload(prefs: FilePreferences = readFilePreferences()): string {
|
||||
return firstExisting(prefs.lastUploadDir, prefs.uploadDir, process.cwd())
|
||||
}
|
||||
|
||||
/** Where a download goes without asking: the default download directory, else the current directory. */
|
||||
export function silentDownloadPath(fileName: string, prefs: FilePreferences = readFilePreferences()): string {
|
||||
return join(firstExisting(prefs.downloadDir), fileName)
|
||||
}
|
||||
|
||||
export { dirname }
|
||||
@@ -0,0 +1,250 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Completion for the filter grammar: given the text, the cursor and the table's fields, what can
|
||||
* come next (a field, an operator valid for that field, a value or expression, AND / OR / a
|
||||
* parenthesis) and which span of the text a candidate replaces. The TUI filter box uses it as the
|
||||
* person types; the CLI's shell completion can use it for --where and --group values.
|
||||
*/
|
||||
|
||||
import { FilterSyntaxError, LIST_OPERATORS, OPERATORS, UNARY_OPERATORS, type Operator, type QueryFilter } from './grammar.js'
|
||||
|
||||
/** The field metadata completion needs (a structural subset of FieldMetaData). */
|
||||
export interface CompletionField {
|
||||
name: string
|
||||
label?: string
|
||||
type?: string
|
||||
possibleValueSourceName?: string | null
|
||||
/** Operators the application allows on this field, when it restricts them. */
|
||||
allowedFilterOperators?: string[] | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface Completion {
|
||||
/** The text that replaces the span `from..to`. */
|
||||
value: string
|
||||
/** What a list shows. */
|
||||
label: string
|
||||
kind: 'field' | 'operator' | 'value' | 'expression' | 'keyword'
|
||||
detail?: string
|
||||
}
|
||||
|
||||
export interface CompletionResult {
|
||||
/** The span the candidate replaces (the partial word under the cursor). */
|
||||
from: number
|
||||
to: number
|
||||
/** What the grammar expects at the cursor. */
|
||||
expecting: 'field' | 'operator' | 'value' | 'connector'
|
||||
/** The field of the criterion being written, when expecting an operator or a value. */
|
||||
field?: string
|
||||
operator?: Operator
|
||||
candidates: Completion[]
|
||||
}
|
||||
|
||||
const SHORTHAND = new Set(['=', '==', '!=', '<>', '<', '<=', '>', '>=', '~', '!~'])
|
||||
const SHORTHAND_OP: Record<string, Operator> = { '=': 'EQUALS', '==': 'EQUALS', '!=': 'NOT_EQUALS', '<>': 'NOT_EQUALS', '<': 'LESS_THAN', '<=': 'LESS_THAN_OR_EQUALS', '>': 'GREATER_THAN', '>=': 'GREATER_THAN_OR_EQUALS', '~': 'CONTAINS', '!~': 'NOT_CONTAINS' }
|
||||
|
||||
const TEXT_OPS: Operator[] = ['EQUALS', 'NOT_EQUALS', 'CONTAINS', 'NOT_CONTAINS', 'STARTS_WITH', 'NOT_STARTS_WITH', 'ENDS_WITH', 'NOT_ENDS_WITH', 'IN', 'NOT_IN', 'LIKE', 'NOT_LIKE', 'IS_BLANK', 'IS_NOT_BLANK']
|
||||
const NUMBER_OPS: Operator[] = ['EQUALS', 'NOT_EQUALS', 'LESS_THAN', 'LESS_THAN_OR_EQUALS', 'GREATER_THAN', 'GREATER_THAN_OR_EQUALS', 'BETWEEN', 'NOT_BETWEEN', 'IN', 'NOT_IN', 'IS_BLANK', 'IS_NOT_BLANK']
|
||||
const DATE_OPS: Operator[] = ['EQUALS', 'NOT_EQUALS', 'LESS_THAN', 'LESS_THAN_OR_EQUALS', 'GREATER_THAN', 'GREATER_THAN_OR_EQUALS', 'BETWEEN', 'NOT_BETWEEN', 'IS_BLANK', 'IS_NOT_BLANK']
|
||||
const BOOLEAN_OPS: Operator[] = ['TRUE', 'FALSE', 'IS_BLANK', 'IS_NOT_BLANK']
|
||||
const CHOICE_OPS: Operator[] = ['EQUALS', 'NOT_EQUALS', 'IN', 'NOT_IN', 'IS_NULL_OR_IN', 'IS_BLANK', 'IS_NOT_BLANK']
|
||||
|
||||
/** The operators that make sense for a field, honouring `allowedFilterOperators`. */
|
||||
export function operatorsForField(field: Pick<CompletionField, 'type' | 'possibleValueSourceName' | 'allowedFilterOperators'> | undefined): Operator[] {
|
||||
if (!field) return [...OPERATORS]
|
||||
const type = String(field.type ?? 'STRING').toUpperCase()
|
||||
let ops = field.possibleValueSourceName ? CHOICE_OPS
|
||||
: type === 'INTEGER' || type === 'LONG' || type === 'DECIMAL' ? NUMBER_OPS
|
||||
: type === 'DATE' || type === 'DATE_TIME' || type === 'TIME' ? DATE_OPS
|
||||
: type === 'BOOLEAN' ? BOOLEAN_OPS : TEXT_OPS
|
||||
if (field.allowedFilterOperators?.length) {
|
||||
const allowed = new Set(field.allowedFilterOperators.map((o) => o.toUpperCase()))
|
||||
const narrowed = ops.filter((o) => allowed.has(o))
|
||||
ops = narrowed.length ? narrowed : OPERATORS.filter((o) => allowed.has(o))
|
||||
}
|
||||
return [...ops]
|
||||
}
|
||||
|
||||
/** Expression values for date and time fields (and me() for others), as a person would pick them. */
|
||||
export function expressionCandidates(type: string | undefined): Completion[] {
|
||||
const t = String(type ?? '').toUpperCase()
|
||||
if (t === 'DATE' || t === 'DATE_TIME' || t === 'TIME') {
|
||||
return [
|
||||
['now()', 'now'], ['now(-1d)', '1 day ago'], ['now(-7d)', '7 days ago'], ['now(-30d)', '30 days ago'], ['now(-1y)', '1 year ago'],
|
||||
['thisPeriod(day)', 'today'], ['thisPeriod(week)', 'this week'], ['thisPeriod(month)', 'this month'], ['thisPeriod(year)', 'this year'],
|
||||
['lastPeriod(week)', 'last week'], ['lastPeriod(month)', 'last month'], ['lastPeriod(year)', 'last year'],
|
||||
['startOf(month)', 'start of this month'], ['startOf(quarter)', 'start of this quarter'], ['startOf(year)', 'start of this year'], ['startOf(month, -1)', 'start of last month'],
|
||||
].map(([value, detail]) => ({ value: value!, label: value!, kind: 'expression' as const, detail }))
|
||||
}
|
||||
return [{ value: 'me()', label: 'me()', kind: 'expression', detail: 'the signed-in user' }]
|
||||
}
|
||||
|
||||
interface Word { text: string; start: number; end: number; kind: 'word' | '(' | ')' }
|
||||
|
||||
/** Splits text into words, group parentheses and function calls (now(-7d) is one word), keeping quotes. */
|
||||
function words(text: string): Word[] {
|
||||
const out: Word[] = []
|
||||
let i = 0
|
||||
while (i < text.length) {
|
||||
const ch = text[i]!
|
||||
if (/\s/.test(ch)) {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (ch === '(' || ch === ')') {
|
||||
out.push({ text: ch, start: i, end: i + 1, kind: ch })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
const start = i
|
||||
let quoted = false
|
||||
let depth = 0
|
||||
while (i < text.length) {
|
||||
const c = text[i]!
|
||||
if (c === '"' && text[i - 1] !== '\\') quoted = !quoted
|
||||
else if (!quoted && c === '(') {
|
||||
if (/[A-Za-z0-9_}]/.test(text[i - 1] ?? ' ')) depth++
|
||||
else break
|
||||
} else if (!quoted && c === ')') {
|
||||
if (depth > 0) depth--
|
||||
else break
|
||||
} else if (!quoted && depth === 0 && /\s/.test(c)) break
|
||||
i++
|
||||
}
|
||||
out.push({ text: text.slice(start, i), start, end: i, kind: 'word' })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface CompleteOptions {
|
||||
/** Possible-value candidates for a field (labels the caller has looked up for this prefix). */
|
||||
values?: (field: CompletionField, prefix: string) => Completion[]
|
||||
limit?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* What can be typed at the cursor. Words before the cursor drive a small state machine
|
||||
* (field → operator → value → connector → field …); the word under the cursor is the prefix.
|
||||
*/
|
||||
export function completeFilter(text: string, cursor: number, fields: CompletionField[], options: CompleteOptions = {}): CompletionResult {
|
||||
const before = text.slice(0, cursor)
|
||||
const all = words(before)
|
||||
const endsInSpace = before.length > 0 && /[\s(]$/.test(before) || before.length === 0
|
||||
const partial = !endsInSpace && all.length && all[all.length - 1]!.kind === 'word' ? all.pop()! : undefined
|
||||
const byName = new Map(fields.map((f) => [f.name, f]))
|
||||
let state: CompletionResult['expecting'] = 'field'
|
||||
let field: CompletionField | undefined
|
||||
let operator: Operator | undefined
|
||||
let depth = 0
|
||||
for (const w of all) {
|
||||
if (w.kind === '(') {
|
||||
if (state === 'field') depth++
|
||||
continue
|
||||
}
|
||||
if (w.kind === ')') {
|
||||
depth = Math.max(0, depth - 1)
|
||||
state = 'connector'
|
||||
continue
|
||||
}
|
||||
const upper = w.text.toUpperCase()
|
||||
switch (state) {
|
||||
case 'field':
|
||||
field = byName.get(w.text) ?? { name: w.text }
|
||||
state = 'operator'
|
||||
break
|
||||
case 'operator':
|
||||
operator = SHORTHAND.has(w.text) ? SHORTHAND_OP[w.text] : upper as Operator
|
||||
state = operator && UNARY_OPERATORS.has(operator) ? 'connector' : 'value'
|
||||
break
|
||||
case 'value':
|
||||
state = operator && LIST_OPERATORS.has(operator) && w.text.endsWith(',') ? 'value' : 'connector'
|
||||
break
|
||||
case 'connector':
|
||||
if (upper === 'AND' || upper === 'OR') {
|
||||
state = 'field'
|
||||
field = undefined
|
||||
operator = undefined
|
||||
} else if (operator && LIST_OPERATORS.has(operator) && w.text.startsWith(',')) {
|
||||
state = w.text.endsWith(',') ? 'value' : 'connector'
|
||||
}
|
||||
// any other word continues an unquoted value with spaces
|
||||
break
|
||||
}
|
||||
}
|
||||
let prefix = partial?.text ?? ''
|
||||
let from = partial?.start ?? cursor
|
||||
// a list value: complete the item after the last comma
|
||||
if (state === 'value' && operator && LIST_OPERATORS.has(operator) && prefix.includes(',')) {
|
||||
const at = prefix.lastIndexOf(',')
|
||||
from += at + 1
|
||||
prefix = prefix.slice(at + 1)
|
||||
}
|
||||
const needle = prefix.replace(/^"/, '').toLowerCase()
|
||||
const limit = options.limit ?? 50
|
||||
let candidates: Completion[] = []
|
||||
if (state === 'field') {
|
||||
candidates = fields.filter((f) => !needle || f.name.toLowerCase().startsWith(needle) || String(f.label ?? '').toLowerCase().includes(needle))
|
||||
.sort((a, b) => Number(!a.name.toLowerCase().startsWith(needle)) - Number(!b.name.toLowerCase().startsWith(needle)))
|
||||
.map((f) => ({ value: f.name, label: f.name, kind: 'field' as const, detail: `${f.label ?? f.name}${f.type ? ` · ${String(f.type).toLowerCase()}` : ''}` }))
|
||||
if (!needle) candidates.push({ value: '(', label: '(', kind: 'keyword', detail: 'start a group' })
|
||||
} else if (state === 'operator') {
|
||||
candidates = operatorsForField(field?.type || field?.possibleValueSourceName ? field : byName.get(field?.name ?? ''))
|
||||
.filter((o) => !needle || o.toLowerCase().startsWith(needle) || o.toLowerCase().includes(needle))
|
||||
.map((o) => ({ value: o, label: o, kind: 'operator' as const, detail: UNARY_OPERATORS.has(o) ? 'no value' : LIST_OPERATORS.has(o) ? (o.includes('BETWEEN') ? 'two values: a,b' : 'a list: a,b,c') : undefined }))
|
||||
} else if (state === 'value') {
|
||||
const f = field && byName.get(field.name)
|
||||
if (f?.possibleValueSourceName && options.values) candidates.push(...options.values(f, prefix.replace(/^"/, '')))
|
||||
if (String(f?.type ?? '').toUpperCase() === 'BOOLEAN') candidates.push(...['true', 'false'].map((v) => ({ value: v, label: v, kind: 'value' as const })))
|
||||
candidates.push(...expressionCandidates(f?.type).filter((c) => !needle || c.value.toLowerCase().startsWith(needle) || String(c.detail ?? '').toLowerCase().includes(needle)))
|
||||
} else {
|
||||
candidates = ['AND', 'OR', ...(depth > 0 ? [')'] : [])].filter((k) => !needle || k.toLowerCase().startsWith(needle))
|
||||
.map((k) => ({ value: k, label: k, kind: 'keyword' as const, detail: k === ')' ? 'close the group' : undefined }))
|
||||
}
|
||||
return { from, to: cursor, expecting: state, field: field?.name, operator, candidates: candidates.slice(0, limit) }
|
||||
}
|
||||
|
||||
/** Applies a completion: the new text and the cursor after it (a space follows fields, operators and keywords). */
|
||||
export function applyCompletion(text: string, result: Pick<CompletionResult, 'from' | 'to'>, completion: Completion): { text: string; cursor: number } {
|
||||
const tail = text.slice(result.to)
|
||||
const value = /[\s,]/.test(completion.value) && completion.kind === 'value' && !completion.value.startsWith('"') ? `"${completion.value.replace(/"/g, '\\"')}"` : completion.value
|
||||
const space = completion.value === '(' ? '' : tail.startsWith(' ') ? '' : ' '
|
||||
const insert = value + space
|
||||
return { text: text.slice(0, result.from) + insert + tail, cursor: result.from + insert.length }
|
||||
}
|
||||
|
||||
/** Where in the text a parse error points, from the quoted fragment in its message (best effort). */
|
||||
export function locateFilterError(text: string, error: unknown): number | undefined {
|
||||
if (!(error instanceof FilterSyntaxError)) return undefined
|
||||
const quoted = /"([^"]+)"/.exec(error.message)?.[1]
|
||||
if (quoted) {
|
||||
const at = text.indexOf(quoted)
|
||||
if (at >= 0) return at
|
||||
}
|
||||
if (/Missing "\)"/.test(error.message)) return text.length
|
||||
const tail = /: (.+)$/.exec(error.message)?.[1]
|
||||
if (tail) {
|
||||
const at = text.indexOf(tail)
|
||||
if (at >= 0) return at
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** A one-line description of a parsed filter: criteria and groups, for a preview. */
|
||||
export function describeFilter(filter: QueryFilter): string {
|
||||
let criteria = 0
|
||||
let groups = 0
|
||||
const visit = (f: QueryFilter) => {
|
||||
criteria += f.criteria.length
|
||||
for (const sub of f.subFilters ?? []) {
|
||||
groups++
|
||||
visit(sub)
|
||||
}
|
||||
}
|
||||
visit(filter)
|
||||
if (criteria === 0) return 'no criteria (every record)'
|
||||
return `${criteria} criteri${criteria === 1 ? 'on' : 'a'}${groups ? `, ${groups} group${groups === 1 ? '' : 's'}` : ''}, joined by ${filter.booleanOperator}`
|
||||
}
|
||||
@@ -16,6 +16,12 @@ export * from './widgets/render-model.js'
|
||||
export * from './metadata/cache.js'
|
||||
export * from './session/profiles.js'
|
||||
export * from './session/keyring.js'
|
||||
export * from './session/login.js'
|
||||
export * from './session/local-state.js'
|
||||
export * from './filter/complete.js'
|
||||
export * from './records/result-cursor.js'
|
||||
export * from './files/navigator.js'
|
||||
export * from './env/doctor.js'
|
||||
export * from './query/quick-search.js'
|
||||
export * from './records/goto.js'
|
||||
export * from './records/copy.js'
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* A position in a query result: the table, its filter and sort, a page size, the index of the
|
||||
* current record and a cache of loaded pages. A grid pages through it; a record view opened from
|
||||
* the grid steps next and previous through the same result across page boundaries, and the grid
|
||||
* lands on the same row when the person goes back. Works for any query: a table, a saved view, a
|
||||
* filtered child list.
|
||||
*/
|
||||
|
||||
import { callOperation } from '../api/call.js'
|
||||
import type { QueryFilter } from '../filter/grammar.js'
|
||||
|
||||
/** A record as the query endpoint returns it. */
|
||||
export interface QueryRecord {
|
||||
values: Record<string, unknown>
|
||||
displayValues?: Record<string, string>
|
||||
recordLabel?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface ResultCursorOptions {
|
||||
baseUrl: string
|
||||
fetch: typeof fetch
|
||||
table: string
|
||||
/** Criteria, groups and order; skip and limit are managed by the cursor. */
|
||||
filter: QueryFilter
|
||||
pageSize: number
|
||||
primaryKeyField?: string
|
||||
index?: number
|
||||
total?: number
|
||||
/** Extra query parameters (tableVariant). */
|
||||
query?: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
export class ResultCursor {
|
||||
readonly table: string
|
||||
readonly filter: QueryFilter
|
||||
readonly primaryKeyField: string
|
||||
pageSize: number
|
||||
index: number
|
||||
total?: number
|
||||
private readonly baseUrl: string
|
||||
private readonly fetchImpl: typeof fetch
|
||||
private readonly query?: Record<string, string | undefined>
|
||||
private pages = new Map<number, QueryRecord[]>()
|
||||
|
||||
constructor(options: ResultCursorOptions) {
|
||||
this.baseUrl = options.baseUrl
|
||||
this.fetchImpl = options.fetch
|
||||
this.table = options.table
|
||||
this.filter = { ...options.filter, skip: undefined, limit: undefined }
|
||||
this.pageSize = Math.max(1, options.pageSize)
|
||||
this.primaryKeyField = options.primaryKeyField ?? 'id'
|
||||
this.index = options.index ?? 0
|
||||
this.total = options.total
|
||||
this.query = options.query
|
||||
}
|
||||
|
||||
/** The page number holding an index. */
|
||||
pageOf(index: number): number {
|
||||
return Math.floor(index / this.pageSize)
|
||||
}
|
||||
|
||||
get pageCount(): number {
|
||||
return this.total === undefined ? 1 : Math.max(1, Math.ceil(this.total / this.pageSize))
|
||||
}
|
||||
|
||||
/** Counts the matching records (also done by the first page load when unknown). */
|
||||
async count(): Promise<number> {
|
||||
const result = await callOperation(this.baseUrl, 'countTable', { path: { tableName: this.table }, query: this.query, json: { filter: this.wireFilter() } }, this.fetchImpl)
|
||||
this.total = Number((result.data as { count?: number }).count ?? 0)
|
||||
return this.total
|
||||
}
|
||||
|
||||
/** One page of records, from the cache or the server. */
|
||||
async page(page: number, options: { refresh?: boolean } = {}): Promise<QueryRecord[]> {
|
||||
if (!options.refresh && this.pages.has(page)) return this.pages.get(page)!
|
||||
const filter = { ...this.wireFilter(), skip: page * this.pageSize, limit: this.pageSize }
|
||||
const [result] = await Promise.all([
|
||||
callOperation(this.baseUrl, 'queryTable', { path: { tableName: this.table }, query: this.query, json: { filter } }, this.fetchImpl),
|
||||
this.total === undefined || options.refresh ? this.count() : Promise.resolve(this.total),
|
||||
])
|
||||
const records = ((result.data as { records?: QueryRecord[] }).records ?? [])
|
||||
this.pages.set(page, records)
|
||||
return records
|
||||
}
|
||||
|
||||
/** Hands the cursor a page another component loaded with the same filter. */
|
||||
seed(page: number, records: QueryRecord[], total?: number): void {
|
||||
this.pages.set(page, records)
|
||||
if (total !== undefined) this.total = total
|
||||
}
|
||||
|
||||
/** The record at an index (loading its page), or undefined past the end. */
|
||||
async at(index: number): Promise<QueryRecord | undefined> {
|
||||
if (index < 0 || (this.total !== undefined && index >= this.total)) return undefined
|
||||
const records = await this.page(this.pageOf(index))
|
||||
return records[index % this.pageSize]
|
||||
}
|
||||
|
||||
async current(): Promise<QueryRecord | undefined> {
|
||||
return this.at(this.index)
|
||||
}
|
||||
|
||||
/** Moves to the next record; stays and returns undefined at the end. */
|
||||
async next(): Promise<QueryRecord | undefined> {
|
||||
const record = await this.at(this.index + 1)
|
||||
if (record) this.index++
|
||||
return record
|
||||
}
|
||||
|
||||
async previous(): Promise<QueryRecord | undefined> {
|
||||
if (this.index <= 0) return undefined
|
||||
const record = await this.at(this.index - 1)
|
||||
if (record) this.index--
|
||||
return record
|
||||
}
|
||||
|
||||
/** The primary key of a record. */
|
||||
keyOf(record: QueryRecord | undefined): unknown {
|
||||
return record?.values?.[this.primaryKeyField]
|
||||
}
|
||||
|
||||
/** The index of a primary key among the loaded pages, if loaded. */
|
||||
indexOfKey(key: unknown): number | undefined {
|
||||
for (const [page, records] of this.pages) {
|
||||
const at = records.findIndex((r) => String(this.keyOf(r)) === String(key))
|
||||
if (at >= 0) return page * this.pageSize + at
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** After the record at `index` was deleted: one fewer, pages from there on reloaded; the index stays (now the next record). */
|
||||
removed(index: number): void {
|
||||
if (this.total !== undefined) this.total = Math.max(0, this.total - 1)
|
||||
const from = this.pageOf(index)
|
||||
for (const page of [...this.pages.keys()]) if (page >= from) this.pages.delete(page)
|
||||
if (this.total !== undefined && this.index >= this.total) this.index = Math.max(0, this.total - 1)
|
||||
}
|
||||
|
||||
/** Drops every cached page (after a refresh or an edit that may reorder). */
|
||||
invalidate(): void {
|
||||
this.pages.clear()
|
||||
this.total = undefined
|
||||
}
|
||||
|
||||
/** "record 4 of 1,234" (1-based). */
|
||||
describe(): string {
|
||||
return `record ${(this.index + 1).toLocaleString('en-US')} of ${this.total === undefined ? '…' : this.total.toLocaleString('en-US')}`
|
||||
}
|
||||
|
||||
private wireFilter(): QueryFilter {
|
||||
const orderBys = this.filter.orderBys?.length ? this.filter.orderBys : [{ fieldName: this.primaryKeyField, isAscending: true }]
|
||||
const { skip: _skip, limit: _limit, ...rest } = this.filter
|
||||
return { ...rest, orderBys }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Local client state that is not configuration: command histories (~/.local/state/qqq/history-*),
|
||||
* recently viewed records, and the screen-lock PIN (a salted scrypt hash in the config file, never
|
||||
* the PIN). QQQ_STATE_DIR overrides the state directory; XDG_STATE_HOME is honoured.
|
||||
*/
|
||||
|
||||
import { randomBytes, scryptSync, timingSafeEqual } from 'node:crypto'
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { readConfig, writeConfig } from './profiles.js'
|
||||
|
||||
export function stateDir(env: NodeJS.ProcessEnv = process.env): string {
|
||||
return env.QQQ_STATE_DIR ?? join(env.XDG_STATE_HOME ?? join(homedir(), '.local', 'state'), 'qqq')
|
||||
}
|
||||
|
||||
/** Commands that carried a secret are never written to a history. */
|
||||
export function isSecretCommand(line: string): boolean {
|
||||
return /\b(pass(word)?|pin|secret|token|api[-_]?key)\b/i.test(line)
|
||||
}
|
||||
|
||||
/** A persistent, capped command history (newest last on disk, newest first from `entries`). */
|
||||
export class History {
|
||||
private lines: string[]
|
||||
private readonly file: string
|
||||
constructor(readonly name: string, private readonly cap = 3000, env: NodeJS.ProcessEnv = process.env) {
|
||||
this.file = join(stateDir(env), `history-${name.replace(/[^A-Za-z0-9_-]+/g, '_')}`)
|
||||
this.lines = existsSync(this.file) ? readFileSync(this.file, 'utf8').split('\n').filter(Boolean) : []
|
||||
}
|
||||
|
||||
/** Newest first. */
|
||||
entries(): string[] {
|
||||
return [...this.lines].reverse()
|
||||
}
|
||||
|
||||
/** Adds a line (not blank, not a repeat of the newest, not a secret) and saves. */
|
||||
add(line: string): void {
|
||||
const text = line.trim()
|
||||
if (!text || isSecretCommand(text) || this.lines[this.lines.length - 1] === text) return
|
||||
this.lines.push(text)
|
||||
if (this.lines.length > this.cap) this.lines = this.lines.slice(-this.cap)
|
||||
try {
|
||||
mkdirSync(stateDir(), { recursive: true, mode: 0o700 })
|
||||
writeFileSync(this.file, this.lines.join('\n') + '\n', { mode: 0o600 })
|
||||
} catch {
|
||||
// a read-only home must not break the client
|
||||
}
|
||||
}
|
||||
|
||||
/** Newest-first entries containing the text (Ctrl+R). */
|
||||
search(text: string): string[] {
|
||||
const needle = text.toLowerCase()
|
||||
return this.entries().filter((l) => l.toLowerCase().includes(needle))
|
||||
}
|
||||
}
|
||||
|
||||
export interface RecentItem {
|
||||
kind: 'record' | 'table' | 'process'
|
||||
table?: string
|
||||
key?: string
|
||||
name?: string
|
||||
label: string
|
||||
at: string
|
||||
}
|
||||
|
||||
/** Recently viewed records and screens of a server, newest first (kept in the config file's ui section). */
|
||||
export function readRecents(server: string, env: NodeJS.ProcessEnv = process.env): RecentItem[] {
|
||||
const ui = readConfig(env).ui as { recents?: Record<string, RecentItem[]> } | undefined
|
||||
return ui?.recents?.[server] ?? []
|
||||
}
|
||||
|
||||
export function addRecent(server: string, item: Omit<RecentItem, 'at'>, cap = 30, env: NodeJS.ProcessEnv = process.env): void {
|
||||
const config = readConfig(env)
|
||||
const ui = (config.ui ?? {}) as { recents?: Record<string, RecentItem[]> }
|
||||
const list = (ui.recents?.[server] ?? []).filter((r) => !(r.kind === item.kind && r.table === item.table && r.key === item.key && r.name === item.name))
|
||||
list.unshift({ ...item, at: new Date().toISOString() })
|
||||
config.ui = { ...config.ui, recents: { ...(ui.recents ?? {}), [server]: list.slice(0, cap) } } as never
|
||||
writeConfig(config, env)
|
||||
}
|
||||
|
||||
interface LockSettings { pinHash?: string; pinSalt?: string; idleMinutes?: number }
|
||||
|
||||
function lockSettings(env: NodeJS.ProcessEnv): LockSettings {
|
||||
return ((readConfig(env).ui as { lock?: LockSettings } | undefined)?.lock) ?? {}
|
||||
}
|
||||
|
||||
function writeLock(lock: LockSettings, env: NodeJS.ProcessEnv): void {
|
||||
const config = readConfig(env)
|
||||
config.ui = { ...config.ui, lock } as never
|
||||
writeConfig(config, env)
|
||||
}
|
||||
|
||||
/** Hashes a PIN with a salt (scrypt); the same PIN and salt give the same hash. */
|
||||
export function hashPin(pin: string, salt: string): string {
|
||||
return scryptSync(pin, salt, 32).toString('hex')
|
||||
}
|
||||
|
||||
export function hasPin(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return Boolean(lockSettings(env).pinHash)
|
||||
}
|
||||
|
||||
/** Sets the screen-lock PIN (4 or more characters); only a salted hash is stored. */
|
||||
export function setPin(pin: string, env: NodeJS.ProcessEnv = process.env): void {
|
||||
if (pin.length < 4) throw new Error('A PIN needs at least 4 characters')
|
||||
const salt = randomBytes(16).toString('hex')
|
||||
writeLock({ ...lockSettings(env), pinSalt: salt, pinHash: hashPin(pin, salt) }, env)
|
||||
}
|
||||
|
||||
export function verifyPin(pin: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const lock = lockSettings(env)
|
||||
if (!lock.pinHash || !lock.pinSalt) return true
|
||||
const a = Buffer.from(hashPin(pin, lock.pinSalt), 'hex')
|
||||
const b = Buffer.from(lock.pinHash, 'hex')
|
||||
return a.length === b.length && timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
/** Removes the PIN after checking the current one; false when it did not match. */
|
||||
export function removePin(current: string, env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
if (!verifyPin(current, env)) return false
|
||||
const { pinHash: _h, pinSalt: _s, ...rest } = lockSettings(env)
|
||||
writeLock(rest, env)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Minutes of no input before the screen saver starts (0 or undefined: never). */
|
||||
export function readIdleMinutes(env: NodeJS.ProcessEnv = process.env): number {
|
||||
return Number(lockSettings(env).idleMinutes ?? 0)
|
||||
}
|
||||
|
||||
export function writeIdleMinutes(minutes: number, env: NodeJS.ProcessEnv = process.env): void {
|
||||
writeLock({ ...lockSettings(env), idleMinutes: Math.max(0, Math.floor(minutes)) }, env)
|
||||
}
|
||||
|
||||
/** How long to wait after the n-th wrong PIN (grows: 0.5 s, 1 s, 2 s … capped at 30 s). */
|
||||
export function pinDelayMillis(failures: number): number {
|
||||
return failures <= 0 ? 0 : Math.min(30_000, 500 * 2 ** (failures - 1))
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* A profile's stored sign-in and the fetch that authenticates with it, shared by both heads so a
|
||||
* sign-in made by `qqq login` works in `qqq ui` and the other way round. The keyring account is
|
||||
* `{profile}@{server}`, as the CLI has always used.
|
||||
*
|
||||
* The rule for sending it: a server session (MOCK, FULLY_ANONYMOUS, TABLE_BASED via manageSession)
|
||||
* goes as the `sessionUUID` cookie, because the sample server cannot continue an asynchronous
|
||||
* process step with the same UUID as a bearer token (docs/server-findings.md); OAuth2 tokens go
|
||||
* as `Authorization: Bearer`, refreshed at the provider when they are about to expire.
|
||||
* QQQ_TOKEN overrides everything with a bearer token.
|
||||
*/
|
||||
|
||||
import { createSession, isExpiring, refreshTokens, type NativeSignInConfig, type TokenSet } from '@qrun-io/qqq-ui-api-client'
|
||||
import { upsertProfile, type ResolvedProfile } from './profiles.js'
|
||||
import type { SecretStore } from './keyring.js'
|
||||
|
||||
/** What is stored per profile: OAuth2 tokens from the device flow, or a server session from manageSession. */
|
||||
export type StoredLogin =
|
||||
| { kind?: 'oauth'; config: NativeSignInConfig; tokens: TokenSet }
|
||||
| { kind: 'session'; authType: string; sessionUUID: string; user?: Record<string, unknown>; createdAt: string }
|
||||
|
||||
/** The keyring account of a profile's sign-in. */
|
||||
export function loginAccount(target: Pick<ResolvedProfile, 'name' | 'server'>): string {
|
||||
return `${target.name}@${target.server}`
|
||||
}
|
||||
|
||||
export async function readLogin(store: SecretStore, target: Pick<ResolvedProfile, 'name' | 'server'>): Promise<StoredLogin | undefined> {
|
||||
const raw = await store.get(loginAccount(target))
|
||||
if (!raw) return undefined
|
||||
try {
|
||||
return JSON.parse(raw) as StoredLogin
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Keeps a sign-in and names the profile (the first profile becomes the default). */
|
||||
export async function writeLogin(store: SecretStore, target: Pick<ResolvedProfile, 'name' | 'server'>, login: StoredLogin, env: NodeJS.ProcessEnv = process.env): Promise<void> {
|
||||
upsertProfile(target.name, { server: target.server }, {}, env)
|
||||
await store.set(loginAccount(target), JSON.stringify(login))
|
||||
}
|
||||
|
||||
export async function deleteLogin(store: SecretStore, target: Pick<ResolvedProfile, 'name' | 'server'>): Promise<boolean> {
|
||||
return store.delete(loginAccount(target))
|
||||
}
|
||||
|
||||
/** Signs in to a MOCK or FULLY_ANONYMOUS server with manageSession and keeps the session. */
|
||||
export async function signInWithSession(store: SecretStore, target: Pick<ResolvedProfile, 'name' | 'server'>, authType: string, clientChannel: string, fetchImpl: typeof fetch = fetch): Promise<StoredLogin & { kind: 'session' }> {
|
||||
const session = await createSession(target.server, {}, clientChannel, fetchImpl)
|
||||
const login = { kind: 'session' as const, authType, sessionUUID: session.sessionUUID, user: (session.values.user ?? {}) as Record<string, unknown>, createdAt: new Date().toISOString() }
|
||||
await writeLogin(store, target, login)
|
||||
return login
|
||||
}
|
||||
|
||||
export interface LoginFetchOptions {
|
||||
store: SecretStore
|
||||
target: Pick<ResolvedProfile, 'name' | 'server'>
|
||||
clientChannel: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
fetch?: typeof fetch
|
||||
}
|
||||
|
||||
/** The credential to send now: a session cookie, a bearer token (refreshed when expiring), or nothing. */
|
||||
export async function currentCredential(options: LoginFetchOptions, force = false): Promise<{ cookie?: string; bearer?: string }> {
|
||||
const env = options.env ?? process.env
|
||||
if (env.QQQ_TOKEN) return { bearer: env.QQQ_TOKEN }
|
||||
const login = await readLogin(options.store, options.target)
|
||||
if (!login) return {}
|
||||
if (login.kind === 'session') return { cookie: `sessionUUID=${login.sessionUUID}` }
|
||||
if ((force || isExpiring(login.tokens)) && login.tokens.refreshToken) {
|
||||
const tokens = await refreshTokens(login.config, login.tokens.refreshToken, options.fetch ?? fetch)
|
||||
await writeLogin(options.store, options.target, { kind: 'oauth', config: login.config, tokens }, env)
|
||||
return { bearer: tokens.accessToken }
|
||||
}
|
||||
return { bearer: login.tokens.accessToken }
|
||||
}
|
||||
|
||||
/** A fetch that adds the profile's credential and the client channel; retries once after a refresh on 401. */
|
||||
export function loginFetch(options: LoginFetchOptions): typeof fetch {
|
||||
const base = options.fetch ?? fetch
|
||||
const send = async (input: RequestInfo | URL, init: RequestInit | undefined, force: boolean): Promise<Response> => {
|
||||
const request = new Request(input as RequestInfo, init)
|
||||
request.headers.set('X-QQQ-Client', options.clientChannel)
|
||||
const credential = await currentCredential(options, force)
|
||||
if (credential.cookie) request.headers.set('Cookie', credential.cookie)
|
||||
else if (credential.bearer) request.headers.set('Authorization', `Bearer ${credential.bearer}`)
|
||||
return base(request)
|
||||
}
|
||||
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const retryable = !init?.body || typeof init.body === 'string'
|
||||
const response = await send(input, init, false)
|
||||
if (response.status !== 401 || !retryable) return response
|
||||
const login = await readLogin(options.store, options.target)
|
||||
if (!login || login.kind === 'session' || !login.tokens.refreshToken) return response
|
||||
try {
|
||||
return await send(input, init, true)
|
||||
} catch {
|
||||
return response
|
||||
}
|
||||
}) as typeof fetch
|
||||
}
|
||||
@@ -18,28 +18,45 @@ export interface Profile {
|
||||
server: string
|
||||
note?: string
|
||||
createdAt?: string
|
||||
/** Display settings of the terminal UI for this profile only (override the global `ui`). */
|
||||
ui?: Pick<UiSettings, 'theme'>
|
||||
}
|
||||
|
||||
/** Local display settings of the terminal UI (not server preferences): theme and per-screen view settings. */
|
||||
export interface UiSettings {
|
||||
theme?: string
|
||||
/** Keyed by screen, e.g. `table:person`, `record:person`, `app:peopleApp`. */
|
||||
views?: Record<string, ViewSettings>
|
||||
}
|
||||
|
||||
/** How one screen is shown: grid columns and widths, stripes, density; record columns; widget order. */
|
||||
export interface ViewSettings {
|
||||
/** Visible grid columns, in order. */
|
||||
columns?: string[]
|
||||
/** Column widths in cells, by field name. */
|
||||
widths?: Record<string, number>
|
||||
/** How free width is used: fit columns to content, or fill the terminal width. */
|
||||
fit?: 'content' | 'fill'
|
||||
zebra?: boolean
|
||||
headerRule?: boolean
|
||||
density?: 'compact' | 'comfortable'
|
||||
/** Record view: one or two columns of fields, and the label width. */
|
||||
recordColumns?: 1 | 2
|
||||
labelWidth?: number
|
||||
/** App home: widget names in display order. */
|
||||
widgetOrder?: string[]
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
defaultProfile?: string
|
||||
profiles: Record<string, Profile>
|
||||
/** Local client settings shared by the CLI and the TUI (download and upload directories, …). */
|
||||
/** Local client settings shared by the CLI and the TUI: theme, views, files (files/navigator.ts). */
|
||||
ui?: UiSettings
|
||||
/** Sections this version does not know are kept as they are. */
|
||||
[section: string]: unknown
|
||||
}
|
||||
|
||||
/** The `ui` section of config.json. */
|
||||
export interface UiSettings {
|
||||
/** Where downloaded files go when no --out / --out-dir is given (created when missing). */
|
||||
defaultDownloadDir?: string
|
||||
/** Where file pickers for uploads start. */
|
||||
defaultUploadDir?: string
|
||||
/** Whether a head asks before writing a download. */
|
||||
downloadPrompt?: boolean
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
/** config.json is not valid JSON: refused, so a write never replaces the profiles with nothing. */
|
||||
export class ConfigFileError extends Error {}
|
||||
|
||||
@@ -60,25 +77,6 @@ export function readConfig(env: NodeJS.ProcessEnv = process.env): Config {
|
||||
return { ...parsed, profiles: parsed.profiles ?? {} }
|
||||
}
|
||||
|
||||
/** The local client settings (`ui` section). */
|
||||
export function readUiSettings(env: NodeJS.ProcessEnv = process.env): UiSettings {
|
||||
const ui = readConfig(env).ui
|
||||
return ui && typeof ui === 'object' ? { ...ui } : {}
|
||||
}
|
||||
|
||||
/** Merges settings into the `ui` section; an undefined value removes the key. */
|
||||
export function writeUiSettings(settings: Partial<UiSettings>, env: NodeJS.ProcessEnv = process.env): UiSettings {
|
||||
const config = readConfig(env)
|
||||
const ui: UiSettings = { ...(config.ui ?? {}) }
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
if (value === undefined) delete ui[key]
|
||||
else ui[key] = value
|
||||
}
|
||||
config.ui = ui
|
||||
writeConfig(config, env)
|
||||
return ui
|
||||
}
|
||||
|
||||
export function writeConfig(config: Config, env: NodeJS.ProcessEnv = process.env): void {
|
||||
const dir = configDir(env)
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 })
|
||||
@@ -141,3 +139,35 @@ export function removeProfile(name: string, env: NodeJS.ProcessEnv = process.env
|
||||
writeConfig(config, env)
|
||||
return had
|
||||
}
|
||||
|
||||
/** The terminal UI theme in effect: the profile's, else the global one, else undefined (the UI's default). */
|
||||
export function readTheme(profile?: string, env: NodeJS.ProcessEnv = process.env): string | undefined {
|
||||
const config = readConfig(env)
|
||||
return (profile ? config.profiles[profile]?.ui?.theme : undefined) ?? config.ui?.theme
|
||||
}
|
||||
|
||||
/** Saves the theme globally, or for one profile when `profile` is given. */
|
||||
export function writeTheme(theme: string | undefined, options: { profile?: string } = {}, env: NodeJS.ProcessEnv = process.env): void {
|
||||
const config = readConfig(env)
|
||||
if (options.profile && config.profiles[options.profile]) {
|
||||
config.profiles[options.profile]!.ui = { ...config.profiles[options.profile]!.ui, theme }
|
||||
} else {
|
||||
config.ui = { ...config.ui, theme }
|
||||
}
|
||||
writeConfig(config, env)
|
||||
}
|
||||
|
||||
/** The saved view settings of one screen (`table:person`), or an empty object. */
|
||||
export function readViewSettings(key: string, env: NodeJS.ProcessEnv = process.env): ViewSettings {
|
||||
return { ...(readConfig(env).ui?.views?.[key] ?? {}) }
|
||||
}
|
||||
|
||||
/** Saves the view settings of one screen, replacing what was saved; undefined removes them. */
|
||||
export function writeViewSettings(key: string, view: ViewSettings | undefined, env: NodeJS.ProcessEnv = process.env): void {
|
||||
const config = readConfig(env)
|
||||
const views = { ...(config.ui?.views ?? {}) }
|
||||
if (view === undefined) delete views[key]
|
||||
else views[key] = view
|
||||
config.ui = { ...config.ui, views }
|
||||
writeConfig(config, env)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { applyCompletion, completeFilter, describeFilter, locateFilterError, operatorsForField } from '../filter/complete.js'
|
||||
import { parseFilter } from '../filter/grammar.js'
|
||||
|
||||
const fields = [
|
||||
{ name: 'id', label: 'Id', type: 'INTEGER' },
|
||||
{ name: 'firstName', label: 'First Name', type: 'STRING' },
|
||||
{ name: 'lastName', label: 'Last Name', type: 'STRING' },
|
||||
{ name: 'birthDate', label: 'Birth Date', type: 'DATE' },
|
||||
{ name: 'isEmployed', label: 'Is Employed', type: 'BOOLEAN' },
|
||||
{ name: 'personId', label: 'Person', type: 'INTEGER', possibleValueSourceName: 'person' },
|
||||
]
|
||||
|
||||
test('operators follow the field type and allowedFilterOperators', () => {
|
||||
assert.ok(operatorsForField(fields[1]).includes('STARTS_WITH'))
|
||||
assert.ok(!operatorsForField(fields[3]).includes('STARTS_WITH'))
|
||||
assert.deepEqual(operatorsForField(fields[4]), ['TRUE', 'FALSE', 'IS_BLANK', 'IS_NOT_BLANK'])
|
||||
assert.ok(operatorsForField(fields[5]).includes('IN') && !operatorsForField(fields[5]).includes('GREATER_THAN'))
|
||||
assert.deepEqual(operatorsForField({ type: 'STRING', allowedFilterOperators: ['EQUALS', 'IN'] }), ['EQUALS', 'IN'])
|
||||
})
|
||||
|
||||
test('completion walks field, operator, value and connector', () => {
|
||||
let r = completeFilter('', 0, fields)
|
||||
assert.equal(r.expecting, 'field')
|
||||
assert.ok(r.candidates.some((c) => c.value === '('))
|
||||
r = completeFilter('la', 2, fields)
|
||||
assert.deepEqual([r.from, r.to, r.candidates[0]!.value], [0, 2, 'lastName'])
|
||||
r = completeFilter('last', 4, fields)
|
||||
assert.equal(r.candidates[0]!.value, 'lastName', 'label match: Last Name')
|
||||
r = completeFilter('lastName ST', 11, fields)
|
||||
assert.equal(r.expecting, 'operator')
|
||||
assert.deepEqual(r.candidates.map((c) => c.value), ['STARTS_WITH', 'NOT_STARTS_WITH'])
|
||||
r = completeFilter('birthDate > ', 12, fields)
|
||||
assert.equal(r.expecting, 'value')
|
||||
assert.ok(r.candidates.some((c) => c.value === 'now(-7d)'))
|
||||
r = completeFilter('birthDate > now(-7d) ', 21, fields)
|
||||
assert.equal(r.expecting, 'connector')
|
||||
assert.deepEqual(r.candidates.map((c) => c.value), ['AND', 'OR'])
|
||||
r = completeFilter('(isEmployed TRUE ', 17, fields)
|
||||
assert.deepEqual(r.candidates.map((c) => c.value), ['AND', 'OR', ')'])
|
||||
r = completeFilter('a = 1 AND (fi', 13, fields)
|
||||
assert.deepEqual([r.expecting, r.candidates[0]!.value], ['field', 'firstName'])
|
||||
})
|
||||
|
||||
test('possible values come from the caller and list items complete after the comma', () => {
|
||||
const values = (_f: unknown, prefix: string) => ['Avery Sample', 'Blair Sample'].filter((l) => l.toLowerCase().startsWith(prefix.toLowerCase())).map((l) => ({ value: l, label: l, kind: 'value' as const }))
|
||||
const r = completeFilter('personId IN Avery,Bl', 20, fields, { values })
|
||||
assert.equal(r.from, 18)
|
||||
assert.deepEqual(r.candidates.filter((c) => c.kind === 'value').map((c) => c.value), ['Blair Sample'])
|
||||
const applied = applyCompletion('personId IN Avery,Bl', r, r.candidates[0]!)
|
||||
assert.equal(applied.text, 'personId IN Avery,"Blair Sample" ')
|
||||
})
|
||||
|
||||
test('errors are located and filters described', () => {
|
||||
const text = 'lastName BOGUS x'
|
||||
let error: unknown
|
||||
try {
|
||||
parseFilter(text)
|
||||
} catch (e) {
|
||||
error = e
|
||||
}
|
||||
assert.equal(locateFilterError(text, error), 9)
|
||||
assert.equal(describeFilter(parseFilter('a = 1 AND (b = 2 OR c = 3)')), '3 criteria, 1 group, joined by AND')
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
import { CONF_MARK_BEGIN, appendTmuxConf, applyLiveFix, checkEnvironment, detectTerminal, parseRootBindings, readDoctorState, startupFindings, writeDoctorState } from '../env/doctor.js'
|
||||
|
||||
const LIST_KEYS = [
|
||||
'bind-key -T root F12 set-option prefix None',
|
||||
'bind-key -T root NPage if-shell -F "#{pane_in_mode}" "send-keys PageDown" \'\'',
|
||||
'bind-key -T root PPage if-shell -F "#{pane_in_mode}" "send-keys PageUp" "copy-mode -eu"',
|
||||
'bind-key -T root Home if-shell -F "#{alternate_on}" "send-keys Home" "copy-mode"',
|
||||
'bind-key -T root MouseDown1Pane select-pane -t = \; send-keys -M',
|
||||
].join('\n')
|
||||
|
||||
function fakeTmux(calls: string[][], overrides = 'xterm-256color:Tc') {
|
||||
return (args: string[]) => {
|
||||
calls.push(args)
|
||||
if (args[0] === 'list-keys') return LIST_KEYS
|
||||
if (args.join(' ') === 'show -gv alternate-screen') return 'on\n'
|
||||
if (args.join(' ') === 'show -gv terminal-overrides') return overrides
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
test('root bindings that take PgUp/PgDn are found; ones that pass to full-screen programs are not', () => {
|
||||
assert.deepEqual(parseRootBindings(LIST_KEYS).map((b) => b.key), ['F12', 'NPage', 'PPage', 'Home', 'MouseDown1Pane'])
|
||||
const calls: string[][] = []
|
||||
const report = checkEnvironment({ env: { TMUX: '/tmp/tmux-1/default,1,0', TERM: 'tmux-256color', COLORTERM: 'truecolor', LANG: 'en_US.UTF-8' }, columns: 120, rows: 40, tmux: fakeTmux(calls) })
|
||||
const keys = report.findings.filter((f) => f.id.startsWith('tmux-key-')).map((f) => f.id)
|
||||
assert.deepEqual(keys, ['tmux-key-NPage', 'tmux-key-PPage'])
|
||||
const ppage = report.findings.find((f) => f.id === 'tmux-key-PPage')!
|
||||
assert.deepEqual(ppage.live, [['bind-key', '-n', 'PPage', 'if-shell', '-F', '#{alternate_on}', 'send-keys PPage', 'if-shell -F "#{pane_in_mode}" "send-keys PageUp" "copy-mode -eu"']])
|
||||
assert.equal(ppage.permanent, 'bind-key -n PPage if-shell -F "#{alternate_on}" "send-keys PPage" { if-shell -F "#{pane_in_mode}" "send-keys PageUp" "copy-mode -eu" }')
|
||||
assert.ok(!report.findings.some((f) => f.id === 'tmux-truecolor'))
|
||||
applyLiveFix(ppage, fakeTmux(calls))
|
||||
assert.deepEqual(calls.at(-1), ppage.live![0])
|
||||
})
|
||||
|
||||
test('the permanent fix is appended once in a marked block; choices are remembered', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'qqq-doc-'))
|
||||
try {
|
||||
const conf = join(dir, 'tmux.conf')
|
||||
writeFileSync(conf, 'set -g mouse on')
|
||||
const report = checkEnvironment({ env: { TMUX: 'x', TERM: 'xterm', COLORTERM: '', LANG: 'C' }, columns: 70, rows: 20, tmux: fakeTmux([], '') })
|
||||
for (const id of ['tmux-term', 'tmux-truecolor', 'truecolor', 'unicode', 'size']) assert.ok(report.findings.some((f) => f.id === id), id)
|
||||
appendTmuxConf(report.findings, conf)
|
||||
appendTmuxConf(report.findings, conf)
|
||||
const text = readFileSync(conf, 'utf8')
|
||||
assert.equal(text.split(CONF_MARK_BEGIN).length, 2)
|
||||
assert.ok(text.startsWith('set -g mouse on\n'))
|
||||
const env = { QQQ_CONFIG_DIR: dir }
|
||||
writeDoctorState({ dontAsk: ['tmux-key-PPage'] }, env)
|
||||
assert.deepEqual(readDoctorState(env).dontAsk, ['tmux-key-PPage'])
|
||||
assert.ok(!startupFindings(report, readDoctorState(env)).some((f) => f.id === 'tmux-key-PPage'))
|
||||
assert.equal(detectTerminal({ TERM_PROGRAM: 'Apple_Terminal' }), 'Terminal.app')
|
||||
assert.equal(detectTerminal({ WT_SESSION: '1' }), 'Windows Terminal')
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
import { History, addRecent, hasPin, hashPin, pinDelayMillis, readRecents, removePin, setPin, verifyPin } from '../session/local-state.js'
|
||||
import { readConfig } from '../session/profiles.js'
|
||||
|
||||
test('the PIN is stored as a salted hash and verified', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'qqq-pin-'))
|
||||
const env = { QQQ_CONFIG_DIR: dir }
|
||||
try {
|
||||
assert.equal(hasPin(env), false)
|
||||
assert.equal(verifyPin('anything', env), true, 'no PIN: nothing to check')
|
||||
assert.throws(() => setPin('12', env), /at least 4/)
|
||||
setPin('2468', env)
|
||||
const text = readFileSync(join(dir, 'config.json'), 'utf8')
|
||||
assert.ok(!text.includes('2468'), 'the PIN itself is never written')
|
||||
const lock = (readConfig(env).ui as { lock: { pinSalt: string; pinHash: string } }).lock
|
||||
assert.equal(lock.pinHash, hashPin('2468', lock.pinSalt))
|
||||
assert.equal(verifyPin('2468', env), true)
|
||||
assert.equal(verifyPin('1357', env), false)
|
||||
assert.equal(removePin('0000', env), false)
|
||||
assert.equal(removePin('2468', env), true)
|
||||
assert.equal(hasPin(env), false)
|
||||
assert.deepEqual([1, 2, 3, 10].map(pinDelayMillis), [500, 1000, 2000, 30000])
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('histories persist, skip repeats and secrets, and search newest first', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'qqq-hist-'))
|
||||
const prev = process.env.QQQ_STATE_DIR
|
||||
process.env.QQQ_STATE_DIR = dir
|
||||
try {
|
||||
const h = new History('cmd', 3)
|
||||
for (const l of ['table query person', 'table query person', 'login --password x', 'table get pet 3', 'home', 'quit']) h.add(l)
|
||||
assert.deepEqual(new History('cmd', 3).entries(), ['quit', 'home', 'table get pet 3'])
|
||||
assert.deepEqual(h.search('e'), ['home', 'table get pet 3'])
|
||||
const env = { QQQ_CONFIG_DIR: dir }
|
||||
addRecent('http://h', { kind: 'record', table: 'person', key: '1', label: 'Avery' }, 30, env)
|
||||
addRecent('http://h', { kind: 'record', table: 'person', key: '2', label: 'Blair' }, 30, env)
|
||||
addRecent('http://h', { kind: 'record', table: 'person', key: '1', label: 'Avery' }, 30, env)
|
||||
assert.deepEqual(readRecents('http://h', env).map((r) => r.key), ['1', '2'])
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env.QQQ_STATE_DIR
|
||||
else process.env.QQQ_STATE_DIR = prev
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { homedir, tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
import { completePath, createFolder, expandPath, formatSize, listDirectory, readFilePreferences, startDirForSave, writeFilePreferences } from '../files/navigator.js'
|
||||
|
||||
test('listings put directories first and hide dot files unless asked', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'qqq-nav-'))
|
||||
try {
|
||||
mkdirSync(join(dir, 'zeta'))
|
||||
writeFileSync(join(dir, 'Alpha.csv'), 'a,b\n')
|
||||
writeFileSync(join(dir, '.secret'), 'x')
|
||||
mkdirSync(join(dir, 'beta'))
|
||||
assert.deepEqual(listDirectory(dir).map((e) => e.name), ['beta', 'zeta', 'Alpha.csv'])
|
||||
assert.ok(listDirectory(dir, { hidden: true }).some((e) => e.name === '.secret'))
|
||||
assert.equal(listDirectory(dir).find((e) => e.name === 'Alpha.csv')!.size, 4)
|
||||
assert.deepEqual(completePath('be', { cwd: dir }), { text: 'beta/', candidates: ['beta/'] })
|
||||
writeFileSync(join(dir, 'beta', 'report-1.xlsx'), '')
|
||||
writeFileSync(join(dir, 'beta', 'report-2.xlsx'), '')
|
||||
assert.deepEqual(completePath('beta/re', { cwd: dir }), { text: 'beta/report-', candidates: ['report-1.xlsx', 'report-2.xlsx'] })
|
||||
assert.deepEqual(completePath('nothing', { cwd: dir }).candidates, [])
|
||||
assert.equal(createFolder(dir, 'new'), join(dir, 'new'))
|
||||
assert.throws(() => createFolder(dir, 'a/b'), /slash/)
|
||||
assert.equal(expandPath('~'), homedir())
|
||||
assert.equal(expandPath('x', dir), join(dir, 'x'))
|
||||
assert.deepEqual([formatSize(512), formatSize(1536), formatSize(5 * 1024 * 1024)], ['512 B', '1.5 KB', '5.0 MB'])
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('file preferences persist in the config file', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'qqq-fp-'))
|
||||
const env = { QQQ_CONFIG_DIR: dir }
|
||||
try {
|
||||
writeFilePreferences({ downloadDir: dir, downloadMode: 'silent' }, env)
|
||||
assert.deepEqual(readFilePreferences(env), { downloadDir: dir, downloadMode: 'silent' })
|
||||
writeFilePreferences({ downloadMode: undefined }, env)
|
||||
assert.deepEqual(readFilePreferences(env), { downloadDir: dir })
|
||||
assert.equal(startDirForSave({ downloadDir: dir }), dir)
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@@ -9,7 +9,8 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
import { FileStore, SecretFileError, defaultSecretStore } from '../session/keyring.js'
|
||||
import { ConfigFileError, profileNameForServer, readConfig, readUiSettings, removeProfile, resolveProfile, upsertProfile, writeUiSettings } from '../session/profiles.js'
|
||||
import { readFilePreferences, writeFilePreferences } from '../files/navigator.js'
|
||||
import { ConfigFileError, profileNameForServer, readConfig, readTheme, removeProfile, resolveProfile, upsertProfile, writeTheme } from '../session/profiles.js'
|
||||
|
||||
test('profiles resolve from --profile, QQQ_PROFILE, --server, then the default', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'qqq-cfg-'))
|
||||
@@ -64,17 +65,20 @@ test('a corrupt config.json is refused, never overwritten with an empty profile
|
||||
}
|
||||
})
|
||||
|
||||
test('the ui section keeps local settings and survives profile writes', () => {
|
||||
test('the ui section (files, theme) survives profile writes, and unknown sections are kept', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'qqq-cfg-'))
|
||||
const env = { QQQ_CONFIG_DIR: dir }
|
||||
try {
|
||||
assert.deepEqual(readUiSettings(env), {})
|
||||
writeUiSettings({ defaultDownloadDir: '/tmp/dl' }, env)
|
||||
writeFileSync(join(dir, 'config.json'), JSON.stringify({ profiles: {}, future: { keep: true } }))
|
||||
writeFilePreferences({ downloadDir: '/tmp/dl' }, env)
|
||||
writeTheme('midnight', {}, env)
|
||||
upsertProfile('sample', { server: 'http://localhost:8077' }, {}, env)
|
||||
assert.deepEqual(readUiSettings(env), { defaultDownloadDir: '/tmp/dl' })
|
||||
writeUiSettings({ defaultDownloadDir: undefined, defaultUploadDir: '/tmp/up' }, env)
|
||||
assert.deepEqual(readUiSettings(env), { defaultUploadDir: '/tmp/up' })
|
||||
assert.deepEqual(readFilePreferences(env), { downloadDir: '/tmp/dl' })
|
||||
assert.equal(readTheme('sample', env), 'midnight')
|
||||
writeFilePreferences({ downloadDir: undefined, uploadDir: '/tmp/up' }, env)
|
||||
assert.deepEqual(readFilePreferences(env), { uploadDir: '/tmp/up' })
|
||||
assert.equal(readConfig(env).defaultProfile, 'sample')
|
||||
assert.deepEqual(readConfig(env).future, { keep: true })
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import { ResultCursor } from '../records/result-cursor.js'
|
||||
|
||||
function fakeServer(total: number) {
|
||||
const calls: Array<{ path: string; body: { filter: { skip?: number; limit?: number; orderBys?: unknown } } }> = []
|
||||
const fetchImpl: typeof fetch = async (input, init) => {
|
||||
const request = new Request(input as RequestInfo, init)
|
||||
const body = await request.json() as { filter: { skip?: number; limit?: number } }
|
||||
const path = new URL(request.url).pathname
|
||||
calls.push({ path, body: body as never })
|
||||
const json = (data: unknown) => new Response(JSON.stringify(data), { status: 200, headers: { 'content-type': 'application/json' } })
|
||||
if (path.endsWith('/count')) return json({ count: total })
|
||||
const skip = body.filter.skip ?? 0
|
||||
const ids = Array.from({ length: Math.max(0, Math.min(body.filter.limit ?? 10, total - skip)) }, (_, i) => skip + i + 1)
|
||||
return json({ records: ids.map((id) => ({ values: { id }, recordLabel: `R${id}` })) })
|
||||
}
|
||||
return { calls, fetchImpl }
|
||||
}
|
||||
|
||||
test('the cursor steps across pages and stops at the ends', async () => {
|
||||
const { calls, fetchImpl } = fakeServer(7)
|
||||
const cursor = new ResultCursor({ baseUrl: 'http://h', fetch: fetchImpl, table: 't', filter: { criteria: [], booleanOperator: 'AND' }, pageSize: 3, index: 2 })
|
||||
assert.equal((await cursor.current())!.recordLabel, 'R3')
|
||||
assert.equal(cursor.describe(), 'record 3 of 7')
|
||||
assert.equal((await cursor.next())!.recordLabel, 'R4')
|
||||
assert.equal(calls.filter((c) => c.path.endsWith('/query')).at(-1)!.body.filter.skip, 3)
|
||||
assert.deepEqual(calls[0]!.body.filter.orderBys, [{ fieldName: 'id', isAscending: true }])
|
||||
cursor.index = 6
|
||||
assert.equal(await cursor.next(), undefined)
|
||||
assert.equal(cursor.index, 6)
|
||||
cursor.index = 0
|
||||
assert.equal(await cursor.previous(), undefined)
|
||||
assert.equal(cursor.indexOfKey(4), 3)
|
||||
})
|
||||
|
||||
test('after a delete the cursor shows the next record and reloads pages', async () => {
|
||||
const { calls, fetchImpl } = fakeServer(5)
|
||||
const cursor = new ResultCursor({ baseUrl: 'http://h', fetch: fetchImpl, table: 't', filter: { criteria: [], booleanOperator: 'AND' }, pageSize: 2, index: 4 })
|
||||
cursor.seed(2, [{ values: { id: 5 } }], 5)
|
||||
assert.equal(cursor.keyOf(await cursor.current()), 5)
|
||||
assert.equal(calls.length, 0, 'a seeded page needs no request')
|
||||
cursor.removed(4)
|
||||
assert.equal(cursor.total, 4)
|
||||
assert.equal(cursor.index, 3)
|
||||
})
|
||||
@@ -34,3 +34,8 @@ test('tables drop hidden columns and strip html cells; composites nest', () => {
|
||||
assert.equal(blocksToText(c), 'Open: 123 orders\n\nhi')
|
||||
assert.equal(blocksToText(widgetToBlocks({ type: 'statistics', hasPermission: false, label: 'Secret' })), 'Secret\n[WARNING] You do not have permission to view this data.')
|
||||
})
|
||||
|
||||
test('multiStatistics reads statisticList (the sample server spelling) as well as statisticsList', () => {
|
||||
const blocks = widgetToBlocks({ type: 'multiStatistics', statisticsGroupData: [{ header: 'This Week', statisticList: [{ label: 'Red', value: 15 }] }] })
|
||||
assert.deepEqual(blocks.filter((b) => b.kind === 'stat'), [{ kind: 'stat', label: 'Red', value: '15', context: undefined }])
|
||||
})
|
||||
@@ -56,7 +56,7 @@ export function widgetToBlocks(data: unknown): RenderBlock[] {
|
||||
case 'multiStatistics':
|
||||
for (const group of ((w.statisticsGroupData ?? w.groups ?? []) as J[])) {
|
||||
if (group.header ?? group.label) blocks.push({ kind: 'text', text: String(group.header ?? group.label) })
|
||||
for (const s of ((group.statisticsList ?? group.statistics ?? []) as J[])) blocks.push({ kind: 'stat', label: str(s.label), value: str(s.count ?? s.value), context: s.context ? String(s.context) : undefined })
|
||||
for (const s of ((group.statisticsList ?? group.statisticList ?? group.statistics ?? []) as J[])) blocks.push({ kind: 'stat', label: str(s.label), value: str(s.count ?? s.value), context: s.context ? String(s.context) : undefined })
|
||||
}
|
||||
break
|
||||
case 'table':
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
# qqq ui: the QQQ terminal UI
|
||||
|
||||
A classic full-screen terminal UI for QQQ applications: function-key bar, PgUp/PgDn everywhere, Esc back,
|
||||
a `:` command line that speaks the CLI grammar, and a live footer. OpenTUI 0.8.1 (React 19) on Bun 1.4.4.
|
||||
Everything that is not drawing comes from the client kernel (`@qrun-io/qqq-core`): sign-ins and profiles,
|
||||
the metadata cache, the filter grammar and its completion, FormSession, runProcess, the widget render
|
||||
model, the record-view and association helpers, the result cursor, file and terminal checks.
|
||||
|
||||
## Run it
|
||||
|
||||
```sh
|
||||
npm install && npm run build # once, at the repo root (the kernel and CLI are used built)
|
||||
(cd packages/tui && bun install) # once
|
||||
qqq ui # the default profile; or: qqq --profile sample ui
|
||||
qqq ui table query person -w "lastName STARTS_WITH S" # open a screen directly (CLI grammar)
|
||||
qqq ui --theme light | --ascii | --linear | --mouse | --check | --no-check
|
||||
bun run packages/tui/src/main.tsx --profile sample # the same without the CLI
|
||||
```
|
||||
|
||||
`QQQ_UI_LINEAR=1` gives the one-region layout (no Apps pane), `QQQ_UI_ASCII=1` the ASCII glyphs.
|
||||
|
||||
## Keys
|
||||
|
||||
| Key | Does | Key | Does |
|
||||
|---|---|---|---|
|
||||
| F1, `?` | help: keys, what you can do here, help search | Esc | back (the screen below keeps its cursor and filter) |
|
||||
| F2, `/`, `.`, Ctrl+K | go to anything; record search when the server has a provider | Tab | Apps pane / content |
|
||||
| F3, Enter | view, open | ↑ ↓ PgUp PgDn Home End | move and page |
|
||||
| F4, `e` | edit | Ctrl+F, Ctrl+B | page forward and back (always works) |
|
||||
| F5, `r` | refresh | Space, Shift+Space | page lists (on a grid Space selects) |
|
||||
| F6, `f` | filter: guided builder or the grammar with completion | ← → | columns, tree |
|
||||
| F7, `n` | new record (on a child list: a child of this record) | `s` | sort by the column under the cursor |
|
||||
| F8, `d` | delete, after a question | `]` `[`, Ctrl+N Ctrl+P, PgDn PgUp in a record | next and previous record of the list it came from |
|
||||
| F9 | the command center: every action, searchable | `b` | run a table process on the selection |
|
||||
| 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 |
|
||||
|
||||
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.
|
||||
|
||||
## Screens
|
||||
|
||||
Sign-in (profiles; MOCK and FULLY_ANONYMOUS get a server session, OAUTH2 and AUTH_0 the device flow with the
|
||||
code on screen; the sign-in is shared with the CLI), home (apps with counts, recently viewed), app home
|
||||
(widgets drawn from render blocks: stats, bar charts in block characters, sparklines, percentage bars,
|
||||
tables, steppers, then sections with record counts), table grid (server paging, sort, columns that use the
|
||||
width, horizontal scroll, selection with Space and a bulk bar, export, go to by key), record view (sections
|
||||
by tier, links, child lists, next and previous through the list it came from), create/edit/copy forms
|
||||
(editors per type, a picker that narrows for contingent fields, local and server validation by field,
|
||||
DATE_TIME in the local zone), the process wizard (each step's form, validation review, progress with N/M
|
||||
and elapsed, summary, cancel), and the output pane of `:` commands run through the CLI in-process.
|
||||
|
||||
## The `:` command line
|
||||
|
||||
TUI commands: `home`, `back`, `quit`, `help`, `menu`, `goto`, `view`, `theme <name>`, `ascii on|off`,
|
||||
`linear on|off`, `shortcuts on|off`, `lock`, `pin`, `pin remove`, `idle <minutes>`, `filter [clear|<grammar>]`,
|
||||
`export <format>`, `jobs`, `profile [name]`, `server <url>`, `signout`, `connection`, `doctor`, `open <table>`,
|
||||
`app <name>`, `record <table> <key>`. `table query|get|create|update|delete` and `process run` open screens;
|
||||
any other CLI command runs in-process (`runCli` from `@qrun-io/qqq-cli/program`) and shows its output.
|
||||
|
||||
## Settings kept locally
|
||||
|
||||
In `~/.config/qqq/config.json` (the kernel owns the file), section `ui`: `theme` (also per profile),
|
||||
`views["table:person"]` (columns, widths, fit, zebra, header rule, density), `views["record:…"]`
|
||||
(columns, label width), `views["app:…"]` (widget order), `files` (download and upload folders, ask or save
|
||||
silently), `lock` (screen-lock PIN as a salted hash, idle minutes), `doctor` (terminal check choices),
|
||||
`recents`. Command histories live in `~/.local/state/qqq/history-commands` and `history-filters`.
|
||||
|
||||
Themes: `high-contrast` (default), `classic`, `light`; tokens in `src/theme.ts`, no color elsewhere.
|
||||
|
||||
## Keys that your terminal may intercept
|
||||
|
||||
The TUI runs in the alternate screen and reads PgUp/PgDn itself, but a terminal or tmux can take a key
|
||||
before any program sees it. `qqq ui --check` (and the quick check at startup) finds these and offers fixes.
|
||||
|
||||
- **tmux.** A root binding such as `bind -n PageUp if-shell -F "#{pane_in_mode}" "send-keys PageUp" "copy-mode -eu"`
|
||||
enters copy-mode on every PgUp, full-screen program or not. Let full-screen programs have the keys and keep
|
||||
scrollback in the shell:
|
||||
|
||||
```tmux
|
||||
bind -n PageUp if-shell -F "#{alternate_on}" "send-keys PageUp" "copy-mode -eu"
|
||||
bind -n PageDown if-shell -F "#{alternate_on}" "send-keys PageDown" ""
|
||||
```
|
||||
|
||||
Keep `set -g alternate-screen on`, `set -g default-terminal "tmux-256color"` and true color
|
||||
(`set -as terminal-overrides ',*:Tc'`).
|
||||
- **iTerm2.** If PgUp/PgDn scroll the window, map Page Up/Down in Profiles › Keys › Key Mappings to "Send Escape
|
||||
Sequence" `[5~` and `[6~`; "Scroll wheel sends arrow keys in alternate screen mode" affects the wheel only.
|
||||
- **Terminal.app.** F1 to F10 go to macOS unless "Use F1, F2, etc. keys as standard function keys" is on
|
||||
(System Settings › Keyboard), or hold fn.
|
||||
- Whatever the terminal does, Ctrl+F / Ctrl+B page and the F9 menu and `:` line reach every action.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
cd packages/tui && bun test # or npm run test:tui at the root: the whole TUI against a fake server in memory
|
||||
packages/tui/scripts/smoke.sh # tmux against a running server (profile "sample"); captures kept
|
||||
```
|
||||
|
||||
## Layout of the code
|
||||
|
||||
`src/store.ts` holds all state and dispatches every key (lock, overlay, global F-keys, focused pane, fallbacks),
|
||||
so handlers read live state, never a stale closure. Screens (`src/screens/*`) are route entries that keep their
|
||||
own state (grid cursor, filter, selection, scroll), which is why Esc finds them as they were. Overlays
|
||||
(`src/overlays/*`) are modal. Text fields are `LineEditor` objects driven by keys, not OpenTUI's focused
|
||||
input, so a burst of keys lands in the field that had focus. `src/app.tsx` draws the frame with an error
|
||||
boundary around every screen and overlay; `src/main.tsx` restores the terminal on any crash.
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"lockfileVersion": 2,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "@qrun-io/qqq-tui-spike",
|
||||
"dependencies": {
|
||||
"@opentui/core": "0.8.1",
|
||||
"@opentui/react": "0.8.1",
|
||||
"react": "19.3.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.4.4",
|
||||
"@types/react": "19.3.0",
|
||||
"typescript": "5.8.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@opentui/core": ["@opentui/core@0.8.1", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.8.1", "@opentui/core-darwin-x64": "0.8.1", "@opentui/core-linux-arm64": "0.8.1", "@opentui/core-linux-arm64-musl": "0.8.1", "@opentui/core-linux-x64": "0.8.1", "@opentui/core-linux-x64-musl": "0.8.1", "@opentui/core-win32-arm64": "0.8.1", "@opentui/core-win32-x64": "0.8.1" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-WmnpnVZn2MdyYNEahQNn4k3OrIkQKfcwPVrX/DjguLtl/S2g5fPeXCqHZjCg2GLMf1N4HJap0LEkTPJEvfrDXw=="],
|
||||
|
||||
"@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.8.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-22jdbN55W9hCraw6k1y3TklzCvF90UJG7d1wvtUYwsn9LOEvOZn+i3fUyqkpP+kBgL31RBt+sB4RKsUmNzjWaw=="],
|
||||
|
||||
"@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.8.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-rlqzXisgUl3U7WmrWySFLLZq6x9ZQiSRSGLyyULBFWTZLJY4qrs2a7y/8B0NpIFY7Ngku17qVIKcVwQZZBoLbw=="],
|
||||
|
||||
"@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.8.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-UtJzpwvAy67RU60T1IFNBYHH3tUrmcIHsLrNI+8WFFxoOk6btwRp67NKiZd3qoDcztTz2q/yKf9KQI0yr+P6MQ=="],
|
||||
|
||||
"@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.8.1", "", { "os": "linux", "cpu": "arm64", "libc": "musl" }, "sha512-BzVCOuG5Vw/nB9ffLwZq82uEg0f0BE4wkd5EvrqOET9qK5D5emT4Zo8tZJFY8NG0/xBuWj6S9xVpazQWjZR17A=="],
|
||||
|
||||
"@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.8.1", "", { "os": "linux", "cpu": "x64" }, "sha512-du4V6YlGvCWs/ggc6Tj7Oc7qaoYdPpzwAeJMKUbjrg+Zv5zweNHFMN4YLbzIutTi6Ukz4KsW/hwEaQIjXHlUcA=="],
|
||||
|
||||
"@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.8.1", "", { "os": "linux", "cpu": "x64", "libc": "musl" }, "sha512-RB/u2G5X4KpW1/rT5iabCLYbmfMWKF3OASj2Z29+RAj38I7caRU3/TTntpX3CpHzjQrDTVWyMYzcnu+WFF8thg=="],
|
||||
|
||||
"@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.8.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-wF5AZRWDCjkfRHT1cRfyvHkf+RI/Pxjb0oMFVg2h2ayndBu8CSgyQDhtNgZd0ON+xrP4xceikCkHDhDLe5bv1w=="],
|
||||
|
||||
"@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.8.1", "", { "os": "win32", "cpu": "x64" }, "sha512-+rIrkvwBJMGWdXn4GGQAmM4y+RH1Xvb9nveNyJ58SOsTOBqyWyi+63qMlxe58WVl+sVAJodEjAILcWdwrzMJ3Q=="],
|
||||
|
||||
"@opentui/react": ["@opentui/react@0.8.1", "", { "dependencies": { "@opentui/core": "0.8.1", "react-reconciler": "^0.33.0" }, "peerDependencies": { "react": ">=19.2.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-2Suc127MY7iLkgzFjyKJVsML4PZHhtc+lOXgQhP4bP6zxgNCBlGm/FxX0fDbp5RpuogKyIix9s57YnpxQB54nw=="],
|
||||
|
||||
"@types/bun": ["@types/bun@1.4.4", "", { "dependencies": { "bun-types": "1.4.4" } }, "sha512-9wkRDXUXK124pEnAutE6n/l18j+i9GC5IAHK8TmX/2nlySETmky0OU6yL5U8pW1AoKh++AN9auDCr647BfurjQ=="],
|
||||
|
||||
"@types/node": ["@types/node@26.6.6", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog=="],
|
||||
|
||||
"@types/react": ["@types/react@19.3.0", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg=="],
|
||||
|
||||
"ansi-regex": ["ansi-regex@6.5.0", "", {}, "sha512-zhvbbbdt1kbw7EJ9OQoxmQcuaNR9P5kTj7e/YdmOSU10aHWbycofJxA6jKfoEy3vlLdctOF9ZHaxLtmSm998wQ=="],
|
||||
|
||||
"bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="],
|
||||
|
||||
"bun-types": ["bun-types@1.4.4", "", { "dependencies": { "@types/node": "*", "undici-types": "*" } }, "sha512-yjrJh5oGX7mMPKGd19YXyIjmEuj4G4zNC30bs7PM9Az7NQ3ndF3j6hbeRdvJTRQEhJ/FYQwW0a2moweEi2hMxQ=="],
|
||||
|
||||
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
|
||||
|
||||
"diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||
|
||||
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
|
||||
|
||||
"get-east-asian-width": ["get-east-asian-width@1.7.0", "", {}, "sha512-XjH1AECxf0giL2V1aU8vKyRR2ppRUb5c0EvT7zuJTokQ74bNo52zOtghqdWIqrhUD79fo3x0WfKZdOqxF6LG1Q=="],
|
||||
|
||||
"marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
|
||||
|
||||
"react": ["react@19.3.1", "", {}, "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw=="],
|
||||
|
||||
"react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="],
|
||||
|
||||
"react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="],
|
||||
|
||||
"scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="],
|
||||
|
||||
"shell-quote": ["shell-quote@1.12.0", "", {}, "sha512-PcByqNyT/38F2kDNi006HAMRJaULuBzq/FOsw3qdZvX/GA9W/jamDaRskgHjubHiftXK5sIFxLNkvrXUwcof6Q=="],
|
||||
|
||||
"string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
|
||||
|
||||
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
|
||||
|
||||
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
||||
|
||||
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
|
||||
"web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
|
||||
|
||||
"ws": ["ws@8.22.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-oyL6OPYme/CmiFvNuGzeoxCJ4nOrZE2O1FwM9MWM1AsGri6nLXB2pg+lNA8aLeXMSav0J6eIVS1j6vUPzUtK9g=="],
|
||||
|
||||
"react-devtools-core/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "@qrun-io/qqq-tui",
|
||||
"version": "0.1.0-dev",
|
||||
"private": true,
|
||||
"description": "The QQQ terminal UI (qqq ui): OpenTUI (React) on Bun, built on the client kernel (@qrun-io/qqq-core).",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"start": "bun run src/main.tsx",
|
||||
"test": "bun test",
|
||||
"typecheck": "tsc -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentui/core": "0.8.1",
|
||||
"@opentui/react": "0.8.1",
|
||||
"react": "19.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.4.4",
|
||||
"@types/react": "19.3.0",
|
||||
"typescript": "5.8.3"
|
||||
}
|
||||
}
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Copyright 2026 QRun.IO, Inc.
|
||||
# Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
#
|
||||
# tmux smoke test of qqq ui against a running server (default: the sample server, profile "sample").
|
||||
# Drives the real TUI with per-key pauses and checks each screen's text; captures go to $OUT.
|
||||
# packages/tui/scripts/smoke.sh [profile] OUT=dir WIDTH=160 HEIGHT=45
|
||||
set -euo pipefail
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
pkg="$(cd "$here/.." && pwd)"
|
||||
profile="${1:-sample}"
|
||||
out="${OUT:-$(mktemp -d)}"
|
||||
w="${WIDTH:-160}"; h="${HEIGHT:-45}"
|
||||
s="qqq-smoke-$$"
|
||||
fails=0
|
||||
mkdir -p "$out"
|
||||
cleanup() { tmux kill-session -t "$s" 2>/dev/null || true; }
|
||||
trap cleanup EXIT
|
||||
keys() { for k in "$@"; do if [[ $k == T:* ]]; then tmux send-keys -t "$s" -l -- "${k#T:}"; else tmux send-keys -t "$s" "$k"; fi; sleep 0.25; done; }
|
||||
cap() { tmux capture-pane -t "$s" -p > "$out/$1.txt"; }
|
||||
expect() { # name text...
|
||||
local name=$1; shift
|
||||
for _ in $(seq 1 40); do
|
||||
cap "$name"
|
||||
local ok=1
|
||||
for t in "$@"; do grep -qF -- "$t" "$out/$name.txt" || ok=0; done
|
||||
[[ $ok == 1 ]] && { echo "ok $name"; return 0; }
|
||||
sleep 0.25
|
||||
done
|
||||
echo "FAIL $name (wanted: $*)"; fails=$((fails + 1))
|
||||
}
|
||||
tmux new-session -d -s "$s" -x "$w" -y "$h" "cd '$pkg' && bun run src/main.tsx --no-check --profile '$profile'; sleep 5"
|
||||
expect 01-home 'Apps' 'UI API'
|
||||
keys T:: 'T:table query person' Enter
|
||||
expect 02-grid 'records ·' 'First Name'
|
||||
keys Down Down Down Enter
|
||||
expect 03-record 'record 4 of'
|
||||
keys PageDown
|
||||
expect 04-record-next 'record 5 of'
|
||||
keys Escape
|
||||
expect 05-grid-row 'row 5 '
|
||||
keys F6
|
||||
expect 06-filter 'Filter Person' 'Guided'
|
||||
keys Tab 'T:lastName STARTS_WITH S' Enter
|
||||
expect 07-filtered 'lastName STARTS_WITH S'
|
||||
keys T:: 'T:table create placeAddress' Enter
|
||||
expect 08-form 'New Address book' 'set Country first'
|
||||
keys Escape
|
||||
keys T:: 'T:process run sleepInteractive' Enter
|
||||
expect 09-process 'Sleep duration'
|
||||
keys C-u T:500 C-s
|
||||
expect 10-process-done 'Finished in' 'Slept for 500'
|
||||
keys Escape F9
|
||||
expect 11-menu 'Menu (F9)' 'Context' 'Go to'
|
||||
keys Escape 'T:?'
|
||||
expect 12-help 'Help (F1' 'Keys'
|
||||
keys Escape T:: 'T:app SampleWidgetsDashboard' Enter
|
||||
expect 13-dashboard 'Sample Widgets Dashboard'
|
||||
keys F10
|
||||
expect 14-quit-confirm 'Quit qqq?' 'Reset: go to the home screen'
|
||||
keys Enter
|
||||
sleep 1
|
||||
echo "captures in $out"
|
||||
[[ $fails == 0 ]] && echo "smoke: ok" || { echo "smoke: $fails failed"; exit 1; }
|
||||
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { useKeyboard, useTerminalDimensions } from '@opentui/react'
|
||||
import { Component, useEffect, useReducer, type ReactNode } from 'react'
|
||||
import type { AppTreeNode } from './client'
|
||||
import { presenceText, usePresence } from './presence'
|
||||
import type { AppStore } from './store'
|
||||
import { glyphs, theme } from './theme'
|
||||
import { pad, trunc } from './util/text'
|
||||
|
||||
/** Catches a failing screen or overlay so it shows its error in place; the rest of the TUI keeps working. */
|
||||
export class ErrorBoundary extends Component<{ children: ReactNode; resetKey: string; width: number; label: string }, { error: Error | null }> {
|
||||
override state = { error: null as Error | null }
|
||||
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
|
||||
override componentDidUpdate(prev: { resetKey: string }) {
|
||||
if (prev.resetKey !== this.props.resetKey && this.state.error) this.setState({ error: null })
|
||||
}
|
||||
|
||||
override render() {
|
||||
if (this.state.error) {
|
||||
const t = theme()
|
||||
return (
|
||||
<box flexDirection="column" border borderColor={t.err} width={this.props.width} title={` ${this.props.label} error `}>
|
||||
<text fg={t.err}>{trunc(` ${this.state.error.message}`, this.props.width - 2)}</text>
|
||||
<text fg={t.dim}>{trunc(' Esc goes back · F5 retries · this is a bug in the screen; please report the steps', this.props.width - 2)}</text>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
/** The apps above a table, process or app in the app tree (labels, outermost first). */
|
||||
export function appPath(nodes: AppTreeNode[], name: string, type: string, path: string[] = []): string[] | undefined {
|
||||
for (const n of nodes) {
|
||||
if (n.name === name && n.type === type) return path
|
||||
if (n.children) {
|
||||
const found = appPath(n.children, name, type, [...path, n.label])
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** The title-bar path of the top screen: its apps, the table, then the record or form. */
|
||||
export function breadcrumbs(store: AppStore): string[] {
|
||||
const top = store.screen as (import('./types').Screen & { tableName?: string; processName?: string; appName?: string }) | undefined
|
||||
if (!top) return []
|
||||
const tree = (store.meta?.appTree ?? []) as AppTreeNode[]
|
||||
const tableLabel = top.tableName ? store.meta?.tables?.[top.tableName]?.label ?? top.tableName : undefined
|
||||
if (top.kind === 'table') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), top.title()]
|
||||
if (top.kind === 'record') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), tableLabel!, top.title()]
|
||||
if (top.kind === 'form') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), top.title()]
|
||||
if (top.kind === 'process') return [...(appPath(tree, top.processName!, 'PROCESS') ?? []), top.title()]
|
||||
if (top.kind === 'app') return [...(appPath(tree, top.appName!, 'APP') ?? []), top.title()]
|
||||
return [top.title()]
|
||||
}
|
||||
|
||||
function TitleBar({ store, width }: { store: AppStore; width: number }) {
|
||||
const t = theme()
|
||||
const presence = usePresence(store.screen?.presenceScope?.())
|
||||
const brand = (store.meta?.branding as { appName?: string } | undefined)?.appName ?? 'QQQ'
|
||||
const crumbs = breadcrumbs(store)
|
||||
const shown = crumbs.length > 5 ? ['…', ...crumbs.slice(-5)] : crumbs
|
||||
const ascii = glyphs().border !== undefined
|
||||
const left = ` ${brand} ${ascii ? '-' : '─'} ${shown.join(ascii ? ' > ' : ' › ')}`
|
||||
// collaboration presence slot: "N others viewing" (empty without the collaboration extension)
|
||||
const others = presenceText(presence)
|
||||
const right = [others, store.status.user, store.status.alerts ? `alerts ${store.status.alerts}` : '', store.status.jobs !== 'n/a' && store.status.jobs !== '0' ? `jobs ${store.status.jobs}` : ''].filter(Boolean).join(' ') + ' '
|
||||
const room = Math.max(0, width - right.length)
|
||||
return <text fg={t.titleFg} bg={t.titleBg}>{pad(trunc(left, room), room) + trunc(right, width - room)}</text>
|
||||
}
|
||||
|
||||
function StatusLine({ store, width }: { store: AppStore; width: number }) {
|
||||
const t = theme()
|
||||
const s = store.status
|
||||
const presence = usePresence(store.screen?.presenceScope?.())
|
||||
if (store.flashText) {
|
||||
return <text fg={store.flashText.kind === 'error' ? t.err : t.acc2} bg={t.statusBg}>{pad(` ${store.flashText.text}`, width)}</text>
|
||||
}
|
||||
const ping = s.pingMs === null ? 'ping --' : `ping ${s.pingMs.toFixed(0)} ms`
|
||||
const streamColor = s.stream === 'connected' ? t.ok : s.stream === 'reconnecting' ? t.warn : t.statusFg
|
||||
const left = ` ${s.server.replace(/^https?:\/\//, '')} · ${s.profile}${s.auth ? ` · ${s.auth}` : ''} · UI API ${s.apiVersion} · ${ping} · `
|
||||
const stream = `stream ${s.stream}`
|
||||
const right = ` · jobs ${s.jobs}${presence.count ? ` · ${presence.count} viewing` : ''} · ${s.lastEvent} · ${s.build} · ${s.clock} `
|
||||
if (left.length + stream.length + right.length > width) {
|
||||
const short = ` ${s.profile} · ${ping} · ${stream} · jobs ${s.jobs} · ${s.clock} `
|
||||
return <text fg={t.statusFg} bg={t.statusBg}>{pad(short, width)}</text>
|
||||
}
|
||||
return (
|
||||
<text bg={t.statusBg}>
|
||||
<span fg={t.statusFg} bg={t.statusBg}>{left}</span>
|
||||
<span fg={streamColor} bg={t.statusBg}>{stream}</span>
|
||||
<span fg={t.statusFg} bg={t.statusBg}>{pad(right, width - left.length - stream.length)}</span>
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
function FKeyBar({ store, width }: { store: AppStore; width: number }) {
|
||||
const t = theme()
|
||||
const keys = store.fkeys()
|
||||
const cell = Math.max(7, Math.floor(width / keys.length))
|
||||
let used = 0
|
||||
return (
|
||||
<text>
|
||||
{keys.map((k) => {
|
||||
const w = k.n === 10 ? Math.max(0, width - used) : cell
|
||||
used += w
|
||||
const num = String(k.n).padStart(2)
|
||||
const label = k.label && k.label.length > w - 2 ? ({ Refresh: 'Reload', NewChild: 'Child', 'Re-run': 'Again' } as Record<string, string>)[k.label] ?? k.label : k.label
|
||||
return (
|
||||
<span key={k.n}>
|
||||
<span fg={t.keyNum}>{num}</span>
|
||||
<span fg={t.keyLabelFg} bg={k.label === null ? undefined : t.keyLabelBg}>{pad(label ?? '', Math.max(0, w - 2))}</span>
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
export function App({ store }: { store: AppStore }) {
|
||||
const [, force] = useReducer((n: number) => n + 1, 0)
|
||||
const { width, height } = useTerminalDimensions()
|
||||
useEffect(() => store.subscribe(force), [store])
|
||||
useEffect(() => {
|
||||
if (width !== store.width || height !== store.height) store.resize(width, height)
|
||||
}, [width, height, store])
|
||||
useKeyboard((key) => store.handleKey(key))
|
||||
const t = theme()
|
||||
const w = Math.max(20, width)
|
||||
const h = Math.max(8, height)
|
||||
const bodyHeight = h - 3
|
||||
const sidebarWidth = store.showSidebar ? (w >= 160 ? 32 : 28) : 0
|
||||
const contentWidth = w - sidebarWidth
|
||||
const screen = store.screen
|
||||
const top = store.overlays[store.overlays.length - 1]
|
||||
return (
|
||||
<box flexDirection="column" width={w} height={h}>
|
||||
<TitleBar store={store} width={w} />
|
||||
<box flexDirection="row" width={w} height={bodyHeight}>
|
||||
{sidebarWidth > 0 && store.sidebar && (
|
||||
<ErrorBoundary resetKey="sidebar" width={sidebarWidth} label="Apps">{store.sidebar.render(sidebarWidth, bodyHeight, store.focus === 'sidebar' && !top)}</ErrorBoundary>
|
||||
)}
|
||||
{screen
|
||||
? <ErrorBoundary key={screen.id} resetKey={screen.id} width={contentWidth} label="Screen">{screen.render(contentWidth, bodyHeight, store.focus === 'content' && !top)}</ErrorBoundary>
|
||||
: <box width={contentWidth} height={bodyHeight}><text fg={t.dim}>{' starting…'}</text></box>}
|
||||
</box>
|
||||
<StatusLine store={store} width={w} />
|
||||
<FKeyBar store={store} width={w} />
|
||||
{store.overlays.map((o) => <ErrorBoundary key={o.id} resetKey={o.id} width={Math.min(60, w)} label="Dialog">{o.render(w, h)}</ErrorBoundary>)}
|
||||
{store.lock && <ErrorBoundary key="lock" resetKey="lock" width={w} label="Lock">{store.lock.render(w, h)}</ErrorBoundary>}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/** Builds the store for a run: settings from options, config and environment; sign-in; the first route. */
|
||||
|
||||
import { readTheme, resolveProfile, writeTheme, defaultSecretStore, type SecretStore } from '@qrun-io/qqq-core'
|
||||
import { installCommands } from './commands'
|
||||
import { SignInScreen, type SignInDeps } from './screens/signin'
|
||||
import { Sidebar } from './screens/sidebar'
|
||||
import { AppStore, setSecretStoreFactory, type Target } from './store'
|
||||
import { DEFAULT_THEME, THEMES } from './theme'
|
||||
|
||||
export interface CliArgs {
|
||||
profile?: string
|
||||
server?: string
|
||||
theme?: string
|
||||
ascii: boolean
|
||||
linear: boolean
|
||||
mouse: boolean
|
||||
check: 'startup' | 'full' | 'off'
|
||||
route: string[]
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env): CliArgs {
|
||||
const out: CliArgs = { ascii: env.QQQ_UI_ASCII === '1', linear: env.QQQ_UI_LINEAR === '1', mouse: false, check: 'startup', route: [] }
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i]!
|
||||
if (a === '--') {
|
||||
out.route = argv.slice(i + 1)
|
||||
break
|
||||
}
|
||||
if (a === '--profile' || a === '-p') out.profile = argv[++i]
|
||||
else if (a === '--server' || a === '-s') out.server = argv[++i]
|
||||
else if (a === '--theme') out.theme = argv[++i]
|
||||
else if (a === '--ascii') out.ascii = true
|
||||
else if (a === '--linear') out.linear = true
|
||||
else if (a === '--mouse') out.mouse = true
|
||||
else if (a === '--check') out.check = 'full'
|
||||
else if (a === '--no-check') out.check = 'off'
|
||||
else out.route.push(a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export interface BootOptions {
|
||||
args: CliArgs
|
||||
quit: () => void
|
||||
build?: string
|
||||
secrets?: () => SecretStore
|
||||
fetch?: typeof fetch
|
||||
timers?: boolean
|
||||
}
|
||||
|
||||
/** Creates the store and starts sign-in (or connects straight away with a stored sign-in). */
|
||||
export function boot(options: BootOptions): AppStore {
|
||||
const { args } = options
|
||||
let target: Target | undefined
|
||||
let note: string | undefined
|
||||
try {
|
||||
const r = resolveProfile({ profile: args.profile, server: args.server })
|
||||
target = { name: r.name, server: r.server }
|
||||
} catch (e) {
|
||||
note = e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
if (args.theme) {
|
||||
if (THEMES[args.theme]) writeTheme(args.theme)
|
||||
else note = `unknown theme "${args.theme}"; themes: ${Object.keys(THEMES).join(', ')}`
|
||||
}
|
||||
const theme = (args.theme && THEMES[args.theme] ? args.theme : undefined) ?? readTheme(target?.name) ?? DEFAULT_THEME
|
||||
const store = new AppStore({ settings: { theme, ascii: args.ascii, linear: args.linear, mouse: args.mouse, singleKeys: true }, quit: options.quit, build: options.build, target, timers: options.timers })
|
||||
let secrets: SecretStore | undefined
|
||||
const secretsFn = options.secrets ?? (() => (secrets ??= defaultSecretStore()))
|
||||
setSecretStoreFactory(secretsFn)
|
||||
installCommands(store)
|
||||
store.sidebarFactory = (s) => new Sidebar(s)
|
||||
const deps: SignInDeps = {
|
||||
secrets: secretsFn,
|
||||
fetch: options.fetch,
|
||||
timers: options.timers,
|
||||
afterConnect: (s) => {
|
||||
loadKeyboardPreference(s)
|
||||
if (args.route.length) {
|
||||
const route = args.route
|
||||
args.route = []
|
||||
void s.commands().run(route.join(' ').replace(/^(\S+ \S+ \S+ )(.+)$/, (m) => m) && routeLine(route))
|
||||
}
|
||||
},
|
||||
}
|
||||
store.signInDeps = deps
|
||||
const screen = new SignInScreen(store, deps, target, note)
|
||||
store.push(screen)
|
||||
if (target) screen.choose(target)
|
||||
return store
|
||||
}
|
||||
|
||||
/** Re-quotes route arguments into one command line. */
|
||||
function routeLine(route: string[]): string {
|
||||
return route.map((a) => (/^[A-Za-z0-9_./:=,@+-]+$/.test(a) ? a : `"${a.replace(/(["\\])/g, '\\$1')}"`)).join(' ')
|
||||
}
|
||||
|
||||
/** The server's keyboard.singleKeyShortcuts preference turns single-key shortcuts off. */
|
||||
function loadKeyboardPreference(store: AppStore): void {
|
||||
if (!(store.version?.features ?? []).includes('preferences')) return
|
||||
store.client?.data<{ values?: Record<string, unknown> }>('getPreferenceSection', { path: { sectionName: 'keyboard' } })
|
||||
.then((d) => {
|
||||
const v = d.values?.singleKeyShortcuts
|
||||
if (v === false || v === 'false') {
|
||||
store.settings.singleKeys = false
|
||||
store.bump()
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The TUI's view of one server, entirely through the kernel: every call is a UI API operation by
|
||||
* id (callOperation), metadata comes through the kernel's on-disk cache, and authentication is the
|
||||
* kernel's login fetch. Tests construct it with a fake fetch.
|
||||
*/
|
||||
|
||||
import { callOperation, getMetaData, getTableMetaData, type CallInput, type CallResult, type MetaDataDocument, type ProcessMeta, type QueryFilter, type QueryRecord } from '@qrun-io/qqq-core'
|
||||
|
||||
export const TUI_VERSION = '0.1.0-dev'
|
||||
export const TUI_CHANNEL = `tui/qqq ${TUI_VERSION}`
|
||||
|
||||
export type Json = Record<string, unknown>
|
||||
|
||||
export interface FieldMetaData {
|
||||
name: string
|
||||
label: string
|
||||
type: string
|
||||
isRequired?: boolean
|
||||
isEditable?: boolean | null
|
||||
isHidden?: boolean
|
||||
isHeavy?: boolean
|
||||
possibleValueSourceName?: string | null
|
||||
adornments?: Array<{ type: string; values?: Json }> | null
|
||||
helpContents?: Array<{ contentAsText?: string; content?: string; roles?: string[] | null }> | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface SectionMetaData {
|
||||
name: string
|
||||
label: string
|
||||
tier?: string
|
||||
fieldNames?: string[]
|
||||
hidden?: boolean
|
||||
widgetName?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface Association {
|
||||
name: string
|
||||
associatedTableName: string
|
||||
join?: { name?: string; type?: string; leftTable?: string; rightTable?: string; joinOns?: Array<{ leftField: string; rightField: string }> }
|
||||
label?: string
|
||||
}
|
||||
|
||||
export interface TableMetaData {
|
||||
name: string
|
||||
label: string
|
||||
primaryKeyField: string
|
||||
fields: Record<string, FieldMetaData>
|
||||
sections?: SectionMetaData[]
|
||||
associations?: Association[] | null
|
||||
capabilities?: string[]
|
||||
insertPermission?: boolean
|
||||
editPermission?: boolean
|
||||
deletePermission?: boolean
|
||||
helpContents?: Array<{ contentAsText?: string }> | null
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface AppTreeNode {
|
||||
type: string
|
||||
name: string
|
||||
label: string
|
||||
children?: AppTreeNode[]
|
||||
}
|
||||
|
||||
export interface VersionInfo {
|
||||
apiVersion: string
|
||||
features?: string[]
|
||||
extensions?: Array<{ name: string; version: string; pathPrefix?: string; description?: string }>
|
||||
}
|
||||
|
||||
export class TuiClient {
|
||||
readonly server: string
|
||||
readonly fetch: typeof fetch
|
||||
private readonly useCache: boolean
|
||||
private tableCache = new Map<string, TableMetaData>()
|
||||
private processCache = new Map<string, ProcessMeta>()
|
||||
metaDataFetchedAt?: number
|
||||
|
||||
constructor(options: { server: string; fetch: typeof fetch; useCache?: boolean }) {
|
||||
this.server = options.server
|
||||
this.fetch = options.fetch
|
||||
this.useCache = options.useCache ?? true
|
||||
}
|
||||
|
||||
call(id: string, input: CallInput = {}): Promise<CallResult> {
|
||||
return callOperation(this.server, id, input, this.fetch)
|
||||
}
|
||||
|
||||
async data<T = Json>(id: string, input: CallInput = {}): Promise<T> {
|
||||
return (await this.call(id, input)).data as T
|
||||
}
|
||||
|
||||
version(): Promise<VersionInfo> {
|
||||
return this.data<VersionInfo>('getVersion')
|
||||
}
|
||||
|
||||
async metaData(refresh = false): Promise<MetaDataDocument> {
|
||||
const doc = this.useCache ? await getMetaData(this.server, this.fetch, { refresh }) : await this.data<MetaDataDocument>('getMetaData')
|
||||
this.metaDataFetchedAt = Date.now()
|
||||
return doc
|
||||
}
|
||||
|
||||
async tableMeta(table: string, refresh = false): Promise<TableMetaData> {
|
||||
if (!refresh && this.tableCache.has(table)) return this.tableCache.get(table)!
|
||||
const meta = this.useCache ? await getTableMetaData<TableMetaData>(this.server, table, this.fetch, { refresh }) : await this.data<TableMetaData>('getTableMetaData', { path: { tableName: table } })
|
||||
this.tableCache.set(table, meta)
|
||||
return meta
|
||||
}
|
||||
|
||||
async processMeta(name: string): Promise<ProcessMeta> {
|
||||
if (this.processCache.has(name)) return this.processCache.get(name)!
|
||||
const meta = await this.data<ProcessMeta>('getProcessMetaData', { path: { processName: name } })
|
||||
this.processCache.set(name, meta)
|
||||
return meta
|
||||
}
|
||||
|
||||
clearCaches(): void {
|
||||
this.tableCache.clear()
|
||||
this.processCache.clear()
|
||||
}
|
||||
|
||||
async query(table: string, filter: QueryFilter): Promise<QueryRecord[]> {
|
||||
return ((await this.data<{ records?: QueryRecord[] }>('queryTable', { path: { tableName: table }, json: { filter } })).records ?? [])
|
||||
}
|
||||
|
||||
async count(table: string, filter: QueryFilter): Promise<number> {
|
||||
return Number((await this.data<{ count?: number }>('countTable', { path: { tableName: table }, json: { filter } })).count ?? 0)
|
||||
}
|
||||
|
||||
async record(table: string, primaryKey: unknown, associations = false): Promise<QueryRecord & { associatedRecords?: Record<string, QueryRecord[]> }> {
|
||||
const data = await this.data<Json>('getRecord', { path: { tableName: table, primaryKey: String(primaryKey) }, query: associations ? { includeAssociations: 'true' } : undefined })
|
||||
return (data.record ?? data) as QueryRecord
|
||||
}
|
||||
|
||||
async insert(table: string, values: Json): Promise<QueryRecord> {
|
||||
const data = await this.data<Json>('insertRecord', { path: { tableName: table }, json: values })
|
||||
return (data.record ?? data) as QueryRecord
|
||||
}
|
||||
|
||||
async update(table: string, primaryKey: unknown, values: Json): Promise<QueryRecord> {
|
||||
const data = await this.data<Json>('updateRecord', { path: { tableName: table, primaryKey: String(primaryKey) }, json: values })
|
||||
return (data.record ?? data) as QueryRecord
|
||||
}
|
||||
|
||||
async remove(table: string, primaryKey: unknown): Promise<void> {
|
||||
await this.call('deleteRecord', { path: { tableName: table, primaryKey: String(primaryKey) } })
|
||||
}
|
||||
|
||||
async export(table: string, format: string, filter: QueryFilter, fieldNames?: string[]): Promise<{ bytes: Uint8Array; contentType: string }> {
|
||||
const result = await this.call('exportTable', { path: { tableName: table }, json: { format, filter, fieldNames } })
|
||||
const bytes = result.bytes ?? new TextEncoder().encode(typeof result.data === 'string' ? result.data : JSON.stringify(result.data))
|
||||
return { bytes, contentType: result.contentType }
|
||||
}
|
||||
|
||||
widget(name: string, input: Json = {}): Promise<Json> {
|
||||
return this.data<Json>('getWidgetData', { path: { widgetName: name }, json: input })
|
||||
}
|
||||
|
||||
async ping(): Promise<number> {
|
||||
const t = performance.now()
|
||||
await this.call('getVersion')
|
||||
return performance.now() - t
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,534 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The command layer: the `:` line (TUI commands, CLI commands that open screens, everything else
|
||||
* run in-process through the CLI's runCli), the F9 command center, the palette and the help.
|
||||
* Menu entries and `:` commands are the same actions.
|
||||
*/
|
||||
|
||||
import { runCli } from '@qrun-io/qqq-cli/program'
|
||||
import { THEMES } from './theme'
|
||||
import { applyLiveFix, checkEnvironment, formatReport, buildFilter, readFilePreferences, startDirForSave, startDirForUpload, writeFilePreferences, hasPin, readConfig, readRecents, removePin, setPin, writeIdleMinutes, writeTheme, readIdleMinutes, type QueryFilter } from '@qrun-io/qqq-core'
|
||||
import type { AppTreeNode } from './client'
|
||||
import { openApp, openForm, openProcess, openRecord, openTable } from './nav'
|
||||
import { ChoiceOverlay, PromptOverlay } from './overlays/basic'
|
||||
import { CommandLineOverlay, HelpOverlay, LockOverlay, MenuOverlay, PaletteOverlay, type PaletteItem } from './overlays/center'
|
||||
import { chooseFolder } from './overlays/files-helpers'
|
||||
import { OutputScreen } from './screens/output'
|
||||
import { SignInScreen } from './screens/signin'
|
||||
import type { AppStore, CommandsLike } from './store'
|
||||
import type { MenuEntry } from './types'
|
||||
import { errorText, shellSplit } from './util/text'
|
||||
|
||||
const NOUNS: Record<string, string[]> = {
|
||||
table: ['list', 'describe', 'query', 'count', 'get', 'create', 'update', 'delete', 'export'],
|
||||
process: ['list', 'describe', 'run'],
|
||||
widget: ['list', 'get'],
|
||||
report: ['run'],
|
||||
prefs: ['list', 'get', 'set', 'action'],
|
||||
job: ['list', 'get', 'cancel', 'retry'],
|
||||
config: ['show', 'profile'],
|
||||
cache: ['show', 'refresh', 'clear'],
|
||||
api: [],
|
||||
apps: [], whoami: [], search: [], 'help-search': [],
|
||||
}
|
||||
|
||||
const TUI_COMMANDS = ['quit', 'home', 'reset', 'back', 'help', 'menu', 'goto', 'view', 'theme', 'ascii', 'linear', 'shortcuts', 'lock', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'profile', 'server', 'signout', 'connection', 'open', 'record', 'app']
|
||||
|
||||
/** Global keys for the help overlay. */
|
||||
export const GLOBAL_KEYS: Array<[string, string]> = [
|
||||
['F1 ?', 'help (this)'], ['F2 / .', 'go to anything'], ['F3 Enter', 'view / open'], ['F4 e', 'edit'], ['F5 r', 'refresh'],
|
||||
['F6 f', 'filter (grid)'], ['F7 n', 'new record'], ['F8 d', 'delete (asks)'], ['F9', 'menu: every command'], ['F10', 'quit, reset or cancel'],
|
||||
['Esc', 'back, cancel'], ['Tab', 'Apps tree / content'], ['↑↓ PgUp PgDn', 'move, page'], ['Ctrl+F Ctrl+B', 'page (always works)'], ['Space S+Space', 'page lists'], ['Home End', 'first, last'], ['← →', 'columns, tree'],
|
||||
['s', 'sort by column'], ['Space (grid)', 'select row'], ['b', 'run a process on selection'], ['x', 'export'], ['g', 'go to by key'], ['v', 'view settings'],
|
||||
['] [', 'next / previous record'], ['Ctrl+S', 'save a form'], [':', 'command line'], ['Ctrl+K', 'palette'], ['Ctrl+C', 'quit now'],
|
||||
]
|
||||
|
||||
export class Commands implements CommandsLike {
|
||||
constructor(private store: AppStore) {}
|
||||
|
||||
// ---- overlays ----
|
||||
|
||||
help(): void {
|
||||
const s = this.store
|
||||
const screen = s.screen
|
||||
const topic = screen?.helpTopic?.()
|
||||
const topicHelp: string[] = []
|
||||
if (topic?.kind === 'table') {
|
||||
const meta = s.client && (s.client as unknown as { tableCache?: Map<string, unknown> })
|
||||
void meta
|
||||
}
|
||||
const actions = screen?.menu?.() ?? []
|
||||
const keys = 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) => ({
|
||||
label: r.label ?? r.name,
|
||||
detail: `${r.kind}${r.table ? ` · ${r.table}` : ''}`,
|
||||
run: r.kind === 'table' ? () => openTable(s, r.name) : r.table ? () => openTable(s, r.table!) : undefined,
|
||||
}))
|
||||
})
|
||||
s.open(overlay)
|
||||
if (topic?.kind === 'table' && s.client) {
|
||||
s.client.tableMeta(topic.name).then((m) => {
|
||||
for (const h of m.helpContents ?? []) if (h.contentAsText) topicHelp.push(h.contentAsText)
|
||||
for (const f of Object.values(m.fields)) for (const h of f.helpContents ?? []) if (h.contentAsText) topicHelp.push(`${f.label}: ${h.contentAsText}`)
|
||||
if (!topicHelp.length) topicHelp.push(`${m.label}: no help content on this server. Fields: ${Object.values(m.fields).map((f) => f.label).join(', ')}.`)
|
||||
s.bump()
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
palette(): void {
|
||||
const s = this.store
|
||||
const meta = s.meta
|
||||
const items: PaletteItem[] = []
|
||||
const walk = (nodes: AppTreeNode[], path: string[]) => {
|
||||
for (const n of nodes) {
|
||||
const context = path.join(' › ')
|
||||
if (n.type === 'TABLE') items.push({ label: n.label, kind: 'table', context, run: () => openTable(s, n.name) })
|
||||
else if (n.type === 'PROCESS') items.push({ label: n.label, kind: 'process', context, run: () => openProcess(s, n.name) })
|
||||
else if (n.type === 'APP') items.push({ label: n.label, kind: 'app', context, run: () => openApp(s, n.name) })
|
||||
else if (n.type === 'REPORT') items.push({ label: n.label, kind: 'report', context, run: () => this.commandLine(`report run ${n.name} --format csv`) })
|
||||
if (n.children) walk(n.children, [...path, n.label])
|
||||
}
|
||||
}
|
||||
walk((meta?.appTree ?? []) as AppTreeNode[], [])
|
||||
const listed = new Set(items.map((i) => `${i.kind}:${i.label}`))
|
||||
for (const t of Object.values(meta?.tables ?? {})) if (!t.isHidden && !listed.has(`table:${t.label}`)) items.push({ label: t.label, kind: 'table', context: t.name, run: () => openTable(s, t.name) })
|
||||
try {
|
||||
for (const r of s.client ? readRecents(s.client.server) : []) {
|
||||
if (r.kind === 'record' && r.table) items.push({ label: r.label, kind: 'recent', context: meta?.tables?.[r.table]?.label ?? r.table, run: () => openRecord(s, r.table!, r.key) })
|
||||
}
|
||||
} catch {
|
||||
// no recents
|
||||
}
|
||||
for (const e of this.entries()) if (e.run && !e.disabled && e.section !== 'Context' && !e.label.startsWith('Recent:') && !/^(Table|Process|App): /.test(e.label)) items.push({ label: e.label, kind: 'command', context: e.command ? `:${e.command}` : e.section, run: e.run })
|
||||
const features = s.version?.features ?? []
|
||||
const quick = s.extensions.has('qbit-quick-search') || s.extensions.has('quick-search')
|
||||
const search = quick || features.includes('recordSearch') ? async (text: string): Promise<PaletteItem[]> => {
|
||||
const data = await s.client!.data<{ results?: Array<{ tableName?: string; table?: string; primaryKey?: unknown; recordLabel?: string; label?: string }>; provider?: { status?: string; message?: string | null } }>('searchRecords', { json: { query: text, searchTerm: text, limit: 20 } })
|
||||
if (data.provider?.status === 'NONE') return [{ label: 'No record search on this server (search provider: none)', kind: 'note', context: 'navigation only', run: () => undefined }]
|
||||
return (data.results ?? []).map((r) => {
|
||||
const table = r.tableName ?? r.table ?? ''
|
||||
return { label: r.recordLabel ?? r.label ?? String(r.primaryKey), kind: 'record', context: meta?.tables?.[table]?.label ?? table, run: () => openRecord(s, table, String(r.primaryKey ?? '')) }
|
||||
})
|
||||
} : undefined
|
||||
s.open(new PaletteOverlay(s, items, search, quick ? 'quick-search extension' : search ? 'POST /search' : ''))
|
||||
}
|
||||
|
||||
menu(): void {
|
||||
this.store.open(new MenuOverlay(this.store, this.entries()))
|
||||
}
|
||||
|
||||
commandLine(initial?: string): void {
|
||||
const s = this.store
|
||||
s.open(new CommandLineOverlay(s, s.history, (line, cursor) => this.completeLine(line, cursor), (line) => void this.run(line), initial ?? this.lastFailed ?? ''))
|
||||
this.lastFailed = undefined
|
||||
}
|
||||
|
||||
lastFailed?: string
|
||||
|
||||
viewSettings(): void {
|
||||
const o = this.store.screen?.viewSettings?.()
|
||||
if (o) this.store.open(o)
|
||||
else this.store.flash('this screen has no view settings')
|
||||
}
|
||||
|
||||
lock(): void {
|
||||
this.store.lock = new LockOverlay(this.store)
|
||||
this.store.bump()
|
||||
}
|
||||
|
||||
// ---- completion for the : line ----
|
||||
|
||||
completeLine(line: string, cursor: number): { from: number; candidates: string[] } {
|
||||
const before = line.slice(0, cursor)
|
||||
const words = shellSplit(before)
|
||||
const endsSpace = /\s$/.test(before) || before === ''
|
||||
const current = endsSpace ? '' : words.pop() ?? ''
|
||||
const from = cursor - current.length
|
||||
const pos = words.length
|
||||
const meta = this.store.meta
|
||||
const pick = (list: string[]) => ({ from, candidates: [...new Set(list)].filter((c) => c.toLowerCase().startsWith(current.toLowerCase())).sort() })
|
||||
if (pos === 0) return pick([...Object.keys(NOUNS), ...TUI_COMMANDS])
|
||||
const noun = words[0]!
|
||||
if (pos === 1 && NOUNS[noun]?.length) return pick(NOUNS[noun]!)
|
||||
if (noun === 'theme' && pos === 1) return pick(Object.keys(THEMES))
|
||||
if ((noun === 'table' && pos === 2) || (noun === 'open' && pos === 1) || (noun === 'record' && pos === 1)) return pick(Object.keys(meta?.tables ?? {}))
|
||||
if (noun === 'process' && pos === 2) return pick(Object.keys(meta?.processes ?? {}))
|
||||
if (noun === 'widget' && pos === 2) return pick(Object.keys(meta?.widgets ?? {}))
|
||||
if (noun === 'app' && pos === 1) return pick(Object.keys(meta?.apps ?? {}))
|
||||
if (noun === 'export' && pos === 1) return pick(['csv', 'xlsx', 'json', 'tsv'])
|
||||
if (noun === 'profile' && pos === 1) return pick(Object.keys(readConfig().profiles))
|
||||
if (noun === 'table' && words[1] === 'query') return pick(['-w', '-g', '--or', '-o', '--limit', '--fields', '--print-filter', '--count'])
|
||||
return { from, candidates: [] }
|
||||
}
|
||||
|
||||
// ---- the : dispatcher ----
|
||||
|
||||
async run(input: string): Promise<void> {
|
||||
const s = this.store
|
||||
const line = input.trim().replace(/^:/, '').replace(/^qqq\s+/, '')
|
||||
const argv = shellSplit(line)
|
||||
const [cmd, ...rest] = argv
|
||||
if (!cmd) return
|
||||
const fail = (text: string) => {
|
||||
this.lastFailed = line
|
||||
s.flash(text, 'error')
|
||||
}
|
||||
try {
|
||||
switch (cmd) {
|
||||
case 'quit': case 'q': case 'exit': return s.requestQuit()
|
||||
case 'home': case 'reset': return s.reset()
|
||||
case 'back': return s.back()
|
||||
case 'help': return this.help()
|
||||
case 'menu': return this.menu()
|
||||
case 'goto': case 'palette': return this.palette()
|
||||
case 'view': return this.viewSettings()
|
||||
case 'lock': return this.lock()
|
||||
case 'refresh': return s.screen?.refresh?.()
|
||||
case 'theme': {
|
||||
const name = rest[0]
|
||||
if (!name || !THEMES[name]) return fail(`themes: ${Object.keys(THEMES).join(', ')}`)
|
||||
s.setTheme(name)
|
||||
writeTheme(name)
|
||||
return s.flash(`theme ${name} (saved)`)
|
||||
}
|
||||
case 'ascii': s.setAscii(rest[0] !== 'off'); return s.flash(`ASCII glyphs ${s.settings.ascii ? 'on' : 'off'}`)
|
||||
case 'linear': s.settings.linear = rest[0] !== 'off'; s.bump(); return s.flash(`linear layout ${s.settings.linear ? 'on' : 'off'}`)
|
||||
case 'shortcuts': return this.setShortcuts(rest[0] !== 'off')
|
||||
case 'idle': {
|
||||
const m = Number(rest[0])
|
||||
if (!Number.isFinite(m)) return fail('idle <minutes>: 0 turns the screen saver timeout off')
|
||||
writeIdleMinutes(m)
|
||||
return s.flash(m ? `screen saver after ${m} idle minutes` : 'idle screen saver off')
|
||||
}
|
||||
case 'pin': return rest[0] === 'remove' ? this.removePin() : this.setPin()
|
||||
case 'filter': {
|
||||
const screen = s.screen as { kind?: string; setFilter?: (f: QueryFilter) => void; filterDialog?: () => void; meta?: { fields: Record<string, unknown> } } | undefined
|
||||
if (screen?.kind !== 'table') return fail(':filter works on a table grid')
|
||||
if (!rest.length) return screen.filterDialog?.()
|
||||
if (rest[0] === 'clear') return screen.setFilter?.({ criteria: [], booleanOperator: 'AND' })
|
||||
return screen.setFilter?.(buildFilter({ group: [rest.join(' ')], knownFields: Object.keys(screen.meta?.fields ?? {}) }))
|
||||
}
|
||||
case 'export': {
|
||||
const screen = s.screen as { kind?: string; exportAs?: (f: string) => void } | undefined
|
||||
if (screen?.kind !== 'table') return fail(':export works on a table grid')
|
||||
return screen.exportAs?.(rest[0] ?? 'csv')
|
||||
}
|
||||
case 'jobs': return this.jobs()
|
||||
case 'doctor': case 'check': return this.doctor()
|
||||
case 'profile': return rest[0] ? this.switchProfile(rest[0]) : this.chooseProfile()
|
||||
case 'server': return rest[0] ? this.switchServer(rest[0]) : fail('server <url>')
|
||||
case 'signout': return this.signOut()
|
||||
case 'connection': return this.connection()
|
||||
case 'open': return rest[0] ? openTable(s, rest[0]) : fail('open <table>')
|
||||
case 'app': return rest[0] ? openApp(s, rest[0]) : fail('app <name>')
|
||||
case 'record': return rest[0] && rest[1] ? openRecord(s, rest[0], rest[1]) : fail('record <table> <primaryKey>')
|
||||
case 'table':
|
||||
if (this.tableCommand(rest)) return
|
||||
break
|
||||
case 'process':
|
||||
if (rest[0] === 'run' && rest[1] && !rest.includes('--help')) {
|
||||
const opts = parseOptions(rest.slice(2))
|
||||
const values = Object.fromEntries((opts.v ?? []).map((p) => [p.split('=')[0]!, p.slice(p.indexOf('=') + 1)]))
|
||||
return openProcess(s, rest[1], { recordIds: opts.r?.[0]?.split(',').map((x) => x.trim()), values })
|
||||
}
|
||||
break
|
||||
case 'login': case 'logout': case 'ui':
|
||||
return fail(`${cmd} is not available inside the TUI; use the menu (Connection) instead`)
|
||||
default:
|
||||
break
|
||||
}
|
||||
await this.runCli(line, argv)
|
||||
} catch (e) {
|
||||
fail(errorText(e))
|
||||
}
|
||||
}
|
||||
|
||||
/** `table query/get/create/update/delete` open screens; returns false for the rest (run by the CLI). */
|
||||
tableCommand(rest: string[]): boolean {
|
||||
const s = this.store
|
||||
const [verb, table, ...args] = rest
|
||||
if (!verb || !table || args.includes('--help') || args.includes('-h')) return false
|
||||
const opts = parseOptions(args)
|
||||
switch (verb) {
|
||||
case 'query': {
|
||||
if (opts['print-filter'] || opts.count || opts.output || opts.O || opts.json) return false
|
||||
const fields = Object.keys((s.meta?.tables?.[table] ? {} : {}))
|
||||
void fields
|
||||
const filter = buildFilter({ where: opts.w ?? opts.where, group: opts.g ?? opts.group, or: Boolean(opts.or), order: opts.o ?? opts.order })
|
||||
openTable(s, table, filter)
|
||||
return true
|
||||
}
|
||||
case 'get':
|
||||
if (!args[0]) return false
|
||||
openRecord(s, table, args[0])
|
||||
return true
|
||||
case 'create': {
|
||||
const initial = Object.fromEntries((opts.s ?? opts.set ?? []).map((p) => [p.split('=')[0]!, p.slice(p.indexOf('=') + 1)]))
|
||||
openForm(s, table, { mode: 'create', initial })
|
||||
return true
|
||||
}
|
||||
case 'update':
|
||||
if (!args[0]) return false
|
||||
openForm(s, table, { mode: 'edit', primaryKey: args[0] })
|
||||
return true
|
||||
case 'delete':
|
||||
if (!args[0]) return false
|
||||
s.confirm(`Delete ${s.meta?.tables?.[table]?.label ?? table} ${args[0]}?`, () => s.run(s.client!.remove(table, args[0]), () => s.flash(`deleted ${table} ${args[0]}`)))
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs a CLI command in-process and shows its output in a pane. */
|
||||
async runCli(line: string, argv: string[]): Promise<void> {
|
||||
const s = this.store
|
||||
const screen = new OutputScreen(s, line)
|
||||
s.push(screen)
|
||||
const globals = s.target ? ['--profile', s.target.name] : []
|
||||
const code = await runCli([...globals, ...argv], { stdout: (t) => screen.append(t), stderr: (t) => screen.append(t, true), isTTY: true })
|
||||
screen.done(code)
|
||||
if (code !== 0) this.lastFailed = line
|
||||
}
|
||||
|
||||
// ---- settings and connection ----
|
||||
|
||||
setShortcuts(on: boolean): void {
|
||||
const s = this.store
|
||||
s.settings.singleKeys = on
|
||||
s.bump()
|
||||
s.flash(`single-key shortcuts ${on ? 'on' : 'off'}`)
|
||||
if ((s.version?.features ?? []).includes('preferences')) {
|
||||
s.client?.call('updatePreferenceSection', { path: { sectionName: 'keyboard' }, json: { values: { singleKeyShortcuts: on } } }).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
setPin(): void {
|
||||
const s = this.store
|
||||
const ask = () => s.open(new PromptOverlay(s, 'Set a screen-lock PIN', 'At least 4 characters. Only a salted hash is kept in ~/.config/qqq/config.json.', (pin) => {
|
||||
s.open(new PromptOverlay(s, 'Repeat the PIN', '', (again) => {
|
||||
if (pin !== again) return s.flash('the PINs differ; not set', 'error')
|
||||
try {
|
||||
setPin(pin)
|
||||
s.flash('PIN set; the screen lock asks for it')
|
||||
} catch (e) {
|
||||
s.flash(errorText(e), 'error')
|
||||
}
|
||||
}, '', { masked: true }))
|
||||
}, '', { masked: true }))
|
||||
if (hasPin()) s.confirm('A PIN is set. Replace it?', () => s.open(new PromptOverlay(s, 'Current PIN', '', (cur) => (removePin(cur) ? ask() : s.flash('wrong PIN', 'error')), '', { masked: true })))
|
||||
else ask()
|
||||
}
|
||||
|
||||
removePin(): void {
|
||||
const s = this.store
|
||||
if (!hasPin()) return s.flash('no PIN is set')
|
||||
s.open(new PromptOverlay(s, 'Remove the PIN: current PIN', '', (cur) => s.flash(removePin(cur) ? 'PIN removed' : 'wrong PIN', removePin.length ? 'info' : 'info'), '', { masked: true }))
|
||||
}
|
||||
|
||||
jobs(): void {
|
||||
const s = this.store
|
||||
if (!(s.version?.features ?? []).includes('durableJobs')) return s.message('This server lists no jobs operations (no durableJobs feature).', 'info', 'Jobs')
|
||||
s.run(s.client!.data<{ jobs?: Array<{ jobId?: string; id?: string; label?: string; state?: string; processName?: string }> }>('listJobs', { query: { scope: 'mine' } }), (data) => {
|
||||
const jobs = data.jobs ?? []
|
||||
if (!jobs.length) return s.message('No jobs.', 'info', 'Jobs')
|
||||
s.open(new ChoiceOverlay(s, 'Jobs', jobs.map((j) => ({ label: `${j.label ?? j.processName ?? j.jobId ?? j.id}`, value: String(j.jobId ?? j.id), detail: j.state })), (id) => this.run(`job get ${id}`)))
|
||||
})
|
||||
}
|
||||
|
||||
chooseProfile(): void {
|
||||
const s = this.store
|
||||
const config = readConfig()
|
||||
s.open(new ChoiceOverlay(s, 'Switch profile', Object.entries(config.profiles).map(([n, p]) => ({ label: n, value: n, detail: p.server + (n === s.target?.name ? ' (current)' : '') })), (n) => this.switchProfile(n)))
|
||||
}
|
||||
|
||||
switchProfile(name: string): void {
|
||||
const s = this.store
|
||||
const p = readConfig().profiles[name]
|
||||
if (!p) return s.flash(`no profile ${name}`, 'error')
|
||||
this.reconnect({ name, server: p.server })
|
||||
}
|
||||
|
||||
switchServer(url: string): void {
|
||||
try {
|
||||
const origin = new URL(url).origin
|
||||
const existing = Object.entries(readConfig().profiles).find(([, p]) => p.server === origin)
|
||||
this.reconnect({ name: existing?.[0] ?? new URL(origin).host.replace(/[^A-Za-z0-9.-]+/g, '-'), server: origin })
|
||||
} catch {
|
||||
this.store.flash(`not a URL: ${url}`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
reconnect(target: { name: string; server: string }): void {
|
||||
const s = this.store
|
||||
const deps = s.signInDeps
|
||||
if (!deps) return s.flash('cannot switch here', 'error')
|
||||
s.dispose()
|
||||
s.client = undefined
|
||||
s.meta = undefined
|
||||
s.sidebar = undefined
|
||||
while (s.routes.length) s.routes.pop()
|
||||
const screen = new SignInScreen(s, deps, target)
|
||||
s.push(screen)
|
||||
screen.choose(target)
|
||||
}
|
||||
|
||||
signOut(): void {
|
||||
const s = this.store
|
||||
s.confirm(`Sign out of ${s.target?.server}?`, () => {
|
||||
const deps = s.signInDeps
|
||||
const target = s.target
|
||||
if (!deps || !target) return
|
||||
s.client?.call('logout').catch(() => undefined)
|
||||
import('@qrun-io/qqq-core').then(({ deleteLogin }) => deleteLogin(deps.secrets(), target)).finally(() => {
|
||||
s.dispose()
|
||||
s.client = undefined
|
||||
s.meta = undefined
|
||||
s.sidebar = undefined
|
||||
while (s.routes.length) s.routes.pop()
|
||||
s.push(new SignInScreen(s, deps, target, 'signed out'))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
connectionText(): string {
|
||||
const s = this.store
|
||||
const v = s.version
|
||||
const age = s.client?.metaDataFetchedAt ? `${Math.round((Date.now() - s.client.metaDataFetchedAt) / 1000)} s ago` : 'unknown'
|
||||
return [
|
||||
`server ${s.status.server}`,
|
||||
`profile ${s.status.profile}`,
|
||||
`user ${s.status.user || '(unknown)'} (${s.status.auth})`,
|
||||
`UI API ${v?.apiVersion ?? '?'}`,
|
||||
`features ${(v?.features ?? []).join(', ')}`,
|
||||
`extensions ${s.extensions.all().map((e) => `${e.name} ${e.version}`).join(', ') || 'none'}`,
|
||||
`ping ${s.status.pingMs === null ? '--' : `${s.status.pingMs.toFixed(0)} ms`}`,
|
||||
`stream ${s.status.stream} · last event: ${s.status.lastEvent}`,
|
||||
`metadata generation ${String(s.meta?.metaDataGeneration ?? '?')} · fetched ${age}`,
|
||||
`client qqq ui ${s.status.build}`,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
connection(): void {
|
||||
this.store.message(this.connectionText(), 'info', 'Connection')
|
||||
}
|
||||
|
||||
/** Every command-center entry for the current context. */
|
||||
entries(): MenuEntry[] {
|
||||
const s = this.store
|
||||
const meta = s.meta
|
||||
const out: MenuEntry[] = [...(s.screen?.menu?.() ?? [])]
|
||||
out.push({ section: 'Context', label: 'Back', key: 'Esc', command: 'back', run: () => s.back() })
|
||||
// Go to
|
||||
out.push({ section: 'Go to', label: 'Reset: go to the home screen', key: 'F10', command: 'reset', detail: 'refreshes metadata, keeps the session', run: () => s.reset() })
|
||||
out.push({ section: 'Go to', label: 'Go to anything…', key: 'F2 / .', command: 'goto', run: () => this.palette() })
|
||||
for (const n of (meta?.appTree ?? []) as AppTreeNode[]) if (n.type === 'APP') out.push({ section: 'Go to', label: `App: ${n.label}`, command: `app ${n.name}`, run: () => openApp(s, n.name) })
|
||||
for (const t of Object.values(meta?.tables ?? {}).filter((x) => !x.isHidden).sort((a, b) => a.label.localeCompare(b.label))) out.push({ section: 'Go to', label: `Table: ${t.label}`, command: `table query ${t.name}`, run: () => openTable(s, t.name) })
|
||||
for (const p of Object.values(meta?.processes ?? {}).filter((x) => !x.isHidden).sort((a, b) => a.label.localeCompare(b.label))) out.push({ section: 'Go to', label: `Process: ${p.label}`, command: `process run ${p.name}`, run: () => openProcess(s, p.name) })
|
||||
for (const r of Object.values((meta?.reports ?? {}) as Record<string, { name: string; label: string }>)) out.push({ section: 'Go to', label: `Report: ${r.label}`, command: `report run ${r.name}`, run: () => this.commandLine(`report run ${r.name} --format csv`) })
|
||||
for (const a of Object.values((meta?.apps ?? {}) as Record<string, { name: string; label: string; widgets?: string[] }>)) if (a.widgets?.length) out.push({ section: 'Go to', label: `Dashboard: ${a.label}`, detail: `${a.widgets.length} widgets`, run: () => openApp(s, a.name) })
|
||||
try {
|
||||
for (const r of s.client ? readRecents(s.client.server).slice(0, 10) : []) if (r.kind === 'record' && r.table) out.push({ section: 'Go to', label: `Recent: ${meta?.tables?.[r.table]?.label ?? r.table} › ${r.label}`, run: () => openRecord(s, r.table!, r.key) })
|
||||
} catch {
|
||||
// none
|
||||
}
|
||||
out.push({ section: 'Go to', label: 'Favorites', disabled: s.extensions.has('collaboration') ? undefined : 'needs the collaboration extension' })
|
||||
// Work
|
||||
out.push({ section: 'Work', label: 'Jobs', detail: `active ${s.status.jobs}`, command: 'jobs', disabled: (s.version?.features ?? []).includes('durableJobs') ? undefined : 'n/a: the server lists no jobs', run: () => this.jobs() })
|
||||
out.push({ section: 'Work', label: 'Notifications', disabled: s.extensions.has('alerts') ? undefined : 'needs the alerts extension' })
|
||||
out.push({ section: 'Work', label: 'My work', disabled: s.extensions.has('collaboration') ? undefined : 'needs the collaboration extension' })
|
||||
for (const e of s.extensions.all()) out.push({ section: 'Work', label: `Extension: ${e.name} ${e.version}`, detail: e.description, command: `api --list`, run: () => s.message(`${e.name} ${e.version}\n${e.description ?? ''}\nPath: ${e.pathPrefix ?? ''}\nIts panels are not drawn by this TUI yet; its operations are reachable with :api.`, 'info', 'Extension') })
|
||||
// Connection
|
||||
out.push({ section: 'Connection', label: `Server ${s.status.server}`, detail: `UI API ${s.status.apiVersion}`, command: 'connection', run: () => this.connection() })
|
||||
out.push({ section: 'Connection', label: `Profile ${s.status.profile}`, detail: s.status.user, run: () => this.connection() })
|
||||
out.push({ section: 'Connection', label: `Stream ${s.status.stream}`, detail: `ping ${s.status.pingMs === null ? '--' : s.status.pingMs.toFixed(0) + ' ms'}`, run: () => this.connection() })
|
||||
out.push({ section: 'Connection', label: 'Switch profile…', command: 'profile', run: () => this.chooseProfile() })
|
||||
out.push({ section: 'Connection', label: 'Connect to another server…', command: 'server <url>', run: () => s.open(new PromptOverlay(s, 'Connect to a server', 'The server URL.', (u) => this.switchServer(u.trim()), 'http://localhost:8077')) })
|
||||
out.push({ section: 'Connection', label: 'Refresh metadata', run: () => s.run(s.client!.metaData(true), (m) => { s.meta = m; s.client!.clearCaches(); s.flash('metadata refreshed') }) })
|
||||
out.push({ section: 'Connection', label: 'Copy connection details', run: () => s.message(this.connectionText(), 'info', 'Connection (select with the mouse to copy)') })
|
||||
out.push({ section: 'Connection', label: 'Sign out', command: 'signout', run: () => this.signOut() })
|
||||
// Settings
|
||||
for (const t of Object.values(THEMES)) out.push({ section: 'Settings', label: `Theme: ${t.label}`, detail: t.name === s.settings.theme ? 'current' : undefined, command: `theme ${t.name}`, run: () => { s.setTheme(t.name); writeTheme(t.name) } })
|
||||
out.push({ section: 'Settings', label: 'View settings for this screen', key: 'v', command: 'view', run: () => this.viewSettings() })
|
||||
out.push({ section: 'Settings', label: `Single-key shortcuts: ${s.settings.singleKeys ? 'on' : 'off'}`, command: `shortcuts ${s.settings.singleKeys ? 'off' : 'on'}`, run: () => this.setShortcuts(!s.settings.singleKeys) })
|
||||
out.push({ section: 'Settings', label: `Linear layout: ${s.settings.linear ? 'on' : 'off'}`, command: `linear ${s.settings.linear ? 'off' : 'on'}`, run: () => { s.settings.linear = !s.settings.linear; s.bump() } })
|
||||
out.push({ section: 'Settings', label: `ASCII glyphs: ${s.settings.ascii ? 'on' : 'off'}`, command: `ascii ${s.settings.ascii ? 'off' : 'on'}`, run: () => s.setAscii(!s.settings.ascii) })
|
||||
out.push({ section: 'Settings', label: 'Screen saver / lock', command: 'lock', run: () => this.lock() })
|
||||
let idle = 0
|
||||
try { idle = readIdleMinutes() } catch { idle = 0 }
|
||||
out.push({ section: 'Settings', label: `Idle timeout: ${idle ? `${idle} min` : 'off'}`, command: 'idle <minutes>', run: () => s.open(new PromptOverlay(s, 'Screen saver after idle minutes', '0 turns it off.', (v) => void this.run(`idle ${v}`), String(idle))) })
|
||||
out.push({ section: 'Settings', label: hasPin() ? 'Change PIN' : 'Set PIN', command: 'pin', run: () => this.setPin() })
|
||||
if (hasPin()) out.push({ section: 'Settings', label: 'Remove PIN', command: 'pin remove', run: () => this.removePin() })
|
||||
const fp = readFilePreferences()
|
||||
out.push({ section: 'Settings', label: `Download folder: ${fp.downloadDir ?? '(last used, else ~/Downloads)'}`, run: () => chooseFolder(s, 'Default download folder', startDirForSave(fp), (dir) => { writeFilePreferences({ downloadDir: dir }); s.flash(`downloads go to ${dir}`) }) })
|
||||
out.push({ section: 'Settings', label: `Downloads: ${fp.downloadMode === 'silent' ? 'save silently to the download folder' : 'ask every time'}`, run: () => { writeFilePreferences({ downloadMode: fp.downloadMode === 'silent' ? 'ask' : 'silent' }); s.flash('download mode changed') } })
|
||||
out.push({ section: 'Settings', label: `Upload folder: ${fp.uploadDir ?? '(last used, else the current folder)'}`, run: () => chooseFolder(s, 'Default upload folder', startDirForUpload(fp), (dir) => { writeFilePreferences({ uploadDir: dir }); s.flash(`uploads start in ${dir}`) }) })
|
||||
out.push({ section: 'Settings', label: 'Server preferences…', disabled: (s.version?.features ?? []).includes('preferences') ? undefined : 'the server has no preferences', run: () => this.prefs() })
|
||||
// Help
|
||||
out.push({ section: 'Help', label: 'Keyboard reference and help', key: 'F1 ?', command: 'help', run: () => this.help() })
|
||||
out.push({ section: 'Help', label: 'Search help…', run: () => this.help() })
|
||||
out.push({ section: 'Help', label: 'Check my terminal', command: 'doctor', run: () => this.doctor() })
|
||||
out.push({ section: 'Help', label: 'About', run: () => s.message(`qqq ui ${s.status.build}\nOpenTUI 0.8.1 (React) on Bun ${typeof Bun !== 'undefined' ? Bun.version : '?'}\nUI API ${s.status.apiVersion} at ${s.status.server}`, 'info', 'About') })
|
||||
out.push({ section: 'Help', label: 'Quit', key: 'F10', command: 'quit', run: () => s.requestQuit() })
|
||||
return out
|
||||
}
|
||||
|
||||
/** The terminal check in a dialog; live tmux fixes asked one by one. */
|
||||
doctor(): void {
|
||||
const s = this.store
|
||||
const report = checkEnvironment({ columns: s.width, rows: s.height })
|
||||
s.message(formatReport(report), 'info', 'Terminal check')
|
||||
const fixes = report.findings.filter((f) => f.live)
|
||||
const next = (i: number) => {
|
||||
const f = fixes[i]
|
||||
if (!f) return
|
||||
s.confirm(`${f.message} Fix for this tmux server now?`, () => { applyLiveFix(f); s.flash('fixed for this tmux server'); next(i + 1) }, () => next(i + 1))
|
||||
}
|
||||
next(0)
|
||||
}
|
||||
|
||||
prefs(): void {
|
||||
const s = this.store
|
||||
s.run(s.client!.data<{ sections?: Array<{ name: string; label: string; description?: string }> }>('listPreferenceSections'), (data) => {
|
||||
s.open(new ChoiceOverlay(s, 'Preferences', (data.sections ?? []).map((x) => ({ label: x.label, value: x.name, detail: x.description })), (name) => void this.run(`prefs get ${name}`)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses `-w a -w b --or -o x` style options into lists (flags become ['true']). */
|
||||
export function parseOptions(args: string[]): Record<string, string[] | undefined> {
|
||||
const out: Record<string, string[]> = {}
|
||||
const flags = new Set(['or', 'print-filter', 'count', 'all', 'json', 'yes', 'y'])
|
||||
const takesValue = new Set(['o', 'order', 'l', 'limit', 'skip', 'fields', 'f', 'r', 'records', 'output', 'O'])
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i]!
|
||||
const m = /^--?([A-Za-z][\w-]*)(?:=(.*))?$/.exec(a)
|
||||
if (!m) continue
|
||||
const name = m[1]!
|
||||
if (m[2] !== undefined) (out[name] ??= []).push(m[2])
|
||||
else if (takesValue.has(name) && i + 1 < args.length) {
|
||||
// -o -id: a sort value may itself start with a dash; repeated values follow while they are not options
|
||||
;(out[name] ??= []).push(args[++i]!)
|
||||
while (name.startsWith('o') && i + 1 < args.length && /^-?[A-Za-z_][\w.]*$/.test(args[i + 1]!) && !/^-[a-zA-Z]$/.test(args[i + 1]!) && !/^--/.test(args[i + 1]!)) out[name]!.push(args[++i]!)
|
||||
} else if (flags.has(name) || i + 1 >= args.length || /^-/.test(args[i + 1]!)) (out[name] ??= []).push('true')
|
||||
else {
|
||||
// multi-value options (-w a b) take every following non-option argument
|
||||
const values: string[] = []
|
||||
while (i + 1 < args.length && !/^-[A-Za-z-]/.test(args[i + 1]!)) values.push(args[++i]!)
|
||||
;(out[name] ??= []).push(...values)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export function installCommands(store: AppStore): void {
|
||||
const commands = new Commands(store)
|
||||
store.commandsImpl = () => commands
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The terminal check before the TUI starts (plain prompts on the terminal, nothing changes without
|
||||
* a y) and `qqq ui --check`. Detection and fixes are the kernel's (env/doctor).
|
||||
*/
|
||||
|
||||
import { createInterface } from 'node:readline/promises'
|
||||
import { appendTmuxConf, applyLiveFix, checkEnvironment, formatReport, readDoctorState, startupFindings, writeDoctorState, type Report } from '@qrun-io/qqq-core'
|
||||
|
||||
export async function runDoctor(options: { full: boolean }): Promise<{ report: Report }> {
|
||||
const started = Date.now()
|
||||
const report = checkEnvironment({ columns: process.stdout.columns, rows: process.stdout.rows })
|
||||
const state = readDoctorState()
|
||||
const ask = options.full ? report.findings.filter((f) => f.live || f.permanent) : startupFindings(report, state)
|
||||
if (options.full) process.stdout.write(formatReport(report) + '\n\n')
|
||||
if (!ask.length || !process.stdin.isTTY) {
|
||||
try {
|
||||
writeDoctorState({ lastCheck: new Date().toISOString() })
|
||||
} catch {
|
||||
// read-only config
|
||||
}
|
||||
return { report }
|
||||
}
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout })
|
||||
const applied = new Set(state.applied ?? [])
|
||||
const dontAsk = new Set(state.dontAsk ?? [])
|
||||
const permanent = []
|
||||
try {
|
||||
if (!options.full) process.stdout.write(`qqq ui terminal check (${Date.now() - started} ms; qqq ui --no-check skips it):\n`)
|
||||
for (const f of ask) {
|
||||
process.stdout.write(`\n! ${f.message}\n`)
|
||||
if (f.live) {
|
||||
const a = (await rl.question(' Fix for this tmux server now? [y/N/never] ')).trim().toLowerCase()
|
||||
if (a === 'never') {
|
||||
dontAsk.add(f.id)
|
||||
continue
|
||||
}
|
||||
if (a === 'y' || a === 'yes') {
|
||||
applyLiveFix(f)
|
||||
applied.add(f.id)
|
||||
process.stdout.write(' fixed for this tmux server.\n')
|
||||
}
|
||||
}
|
||||
if (f.permanent) permanent.push(f)
|
||||
}
|
||||
if (permanent.length) {
|
||||
const a = (await rl.question(`\nMake ${permanent.length === 1 ? 'it' : 'them'} permanent in ~/.tmux.conf? [y/N] `)).trim().toLowerCase()
|
||||
if (a === 'y' || a === 'yes') process.stdout.write(` appended to ${appendTmuxConf(permanent)}\n`)
|
||||
else process.stdout.write(` To keep them, add to ~/.tmux.conf:\n${permanent.map((f) => ` ${f.permanent}`).join('\n')}\n`)
|
||||
}
|
||||
} finally {
|
||||
rl.close()
|
||||
try {
|
||||
writeDoctorState({ lastCheck: new Date().toISOString(), applied: [...applied], dontAsk: [...dontAsk] })
|
||||
} catch {
|
||||
// read-only config
|
||||
}
|
||||
}
|
||||
return { report }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The installed server extensions, through the kernel's ExtensionRegistry (from GET /version).
|
||||
* Panels and `:` commands of an extension appear only when it is installed; otherwise the
|
||||
* command says it is not.
|
||||
*/
|
||||
|
||||
import { ExtensionRegistry, type InstalledExtension } from '@qrun-io/qqq-core'
|
||||
import type { VersionInfo } from './client'
|
||||
|
||||
export class Extensions {
|
||||
registry = new ExtensionRegistry([])
|
||||
|
||||
load(version: VersionInfo | undefined): void {
|
||||
this.registry = ExtensionRegistry.fromVersion((version ?? { apiVersion: '' }) as never)
|
||||
}
|
||||
|
||||
has(name: string): boolean {
|
||||
return this.registry.has(name)
|
||||
}
|
||||
|
||||
get(name: string): InstalledExtension | undefined {
|
||||
return this.registry.installed.find((e) => e.name === name)
|
||||
}
|
||||
|
||||
all(): InstalledExtension[] {
|
||||
return [...this.registry.installed]
|
||||
}
|
||||
|
||||
describe(): string {
|
||||
return this.registry.describe()
|
||||
}
|
||||
|
||||
/** The message for a command of an extension that is not installed. */
|
||||
missing(name: string): string {
|
||||
return `The ${name} extension is not installed on this server (installed: ${this.registry.describe()}).`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/** The parts of an OpenTUI KeyEvent the TUI reads (tests build these directly). */
|
||||
export interface Key {
|
||||
name: string
|
||||
sequence: string
|
||||
ctrl?: boolean
|
||||
shift?: boolean
|
||||
meta?: boolean
|
||||
}
|
||||
|
||||
/** A key that types a character. */
|
||||
export function isPrintable(key: Key): boolean {
|
||||
if (key.ctrl || key.meta) return false
|
||||
const s = key.sequence ?? ''
|
||||
return s.length >= 1 && !/[\x00-\x1f\x7f]/.test(s) && !s.startsWith('\x1b')
|
||||
}
|
||||
|
||||
/** The character a key types ('' for none). */
|
||||
export function charOf(key: Key): string {
|
||||
return isPrintable(key) ? key.sequence : ''
|
||||
}
|
||||
|
||||
export function isFKey(key: Key, n: number): boolean {
|
||||
return key.name === `f${n}`
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import type { Key } from './keys'
|
||||
import { isPrintable } from './keys'
|
||||
|
||||
/**
|
||||
* A one-line text editor driven by key events, not by OpenTUI's focused input: the value and the
|
||||
* cursor live in this object, so keys that arrive in one burst (paste, automation, a fast typist)
|
||||
* all land in the field that had focus when they were read, never in a stale one.
|
||||
*/
|
||||
export class LineEditor {
|
||||
value: string
|
||||
cursor: number
|
||||
masked: boolean
|
||||
|
||||
constructor(value = '', options: { masked?: boolean } = {}) {
|
||||
this.value = value
|
||||
this.cursor = value.length
|
||||
this.masked = Boolean(options.masked)
|
||||
}
|
||||
|
||||
set(value: string): void {
|
||||
this.value = value
|
||||
this.cursor = value.length
|
||||
}
|
||||
|
||||
insert(text: string): void {
|
||||
const clean = text.replace(/[\r\n]+/g, ' ')
|
||||
this.value = this.value.slice(0, this.cursor) + clean + this.value.slice(this.cursor)
|
||||
this.cursor += clean.length
|
||||
}
|
||||
|
||||
/** Handles an editing key; returns whether it was one. */
|
||||
handle(key: Key): boolean {
|
||||
if (key.ctrl && key.name === 'u') {
|
||||
this.value = this.value.slice(this.cursor)
|
||||
this.cursor = 0
|
||||
return true
|
||||
}
|
||||
if (key.ctrl && key.name === 'k') {
|
||||
this.value = this.value.slice(0, this.cursor)
|
||||
return true
|
||||
}
|
||||
if (key.ctrl && key.name === 'a') {
|
||||
this.cursor = 0
|
||||
return true
|
||||
}
|
||||
if (key.ctrl && key.name === 'e') {
|
||||
this.cursor = this.value.length
|
||||
return true
|
||||
}
|
||||
if (key.ctrl && key.name === 'w') {
|
||||
const before = this.value.slice(0, this.cursor).replace(/\S+\s*$/, '')
|
||||
this.value = before + this.value.slice(this.cursor)
|
||||
this.cursor = before.length
|
||||
return true
|
||||
}
|
||||
switch (key.name) {
|
||||
case 'backspace':
|
||||
if (this.cursor > 0) {
|
||||
this.value = this.value.slice(0, this.cursor - 1) + this.value.slice(this.cursor)
|
||||
this.cursor--
|
||||
}
|
||||
return true
|
||||
case 'delete':
|
||||
this.value = this.value.slice(0, this.cursor) + this.value.slice(this.cursor + 1)
|
||||
return true
|
||||
case 'left':
|
||||
this.cursor = Math.max(0, this.cursor - 1)
|
||||
return true
|
||||
case 'right':
|
||||
this.cursor = Math.min(this.value.length, this.cursor + 1)
|
||||
return true
|
||||
case 'home':
|
||||
this.cursor = 0
|
||||
return true
|
||||
case 'end':
|
||||
this.cursor = this.value.length
|
||||
return true
|
||||
default:
|
||||
if (isPrintable(key)) {
|
||||
this.insert(key.sequence)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** The visible window of the text for a width, with the cursor position inside it. */
|
||||
view(width: number): { before: string; at: string; after: string } {
|
||||
const shown = this.masked ? '•'.repeat(this.value.length) : this.value
|
||||
const start = Math.max(0, this.cursor - width + 1)
|
||||
const visible = shown.slice(start, start + width)
|
||||
const c = this.cursor - start
|
||||
return { before: visible.slice(0, c), at: visible[c] ?? ' ', after: visible.slice(c + 1) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* qqq ui: the terminal UI. Runs in the alternate screen with the mouse off; any crash restores the
|
||||
* terminal (renderer.destroy) before the stack trace is printed.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { createCliRenderer } from '@opentui/core'
|
||||
import { createRoot } from '@opentui/react'
|
||||
import { App } from './app'
|
||||
import { boot, parseArgs } from './boot'
|
||||
import { runDoctor } from './doctor'
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
if (args.check === 'full') {
|
||||
await runDoctor({ full: true })
|
||||
process.exit(0)
|
||||
}
|
||||
if (!process.stdout.isTTY || process.env.TERM === 'dumb') {
|
||||
process.stderr.write('qqq ui needs a terminal with an alternate screen (TERM is dumb or stdout is not a terminal); use the qqq CLI instead.\n')
|
||||
process.exit(2)
|
||||
}
|
||||
if (args.check === 'startup') {
|
||||
const { report } = await runDoctor({ full: false })
|
||||
// no UTF-8 locale: plain glyphs rather than boxes of question marks
|
||||
if (report.findings.some((f) => f.id === 'unicode') && !args.ascii) args.ascii = true
|
||||
}
|
||||
|
||||
const renderer = await createCliRenderer({
|
||||
exitOnCtrlC: true,
|
||||
useMouse: args.mouse,
|
||||
screenMode: 'alternate-screen',
|
||||
openConsoleOnError: false,
|
||||
targetFps: 30,
|
||||
})
|
||||
|
||||
let destroyed = false
|
||||
function restore(): void {
|
||||
if (destroyed) return
|
||||
destroyed = true
|
||||
try {
|
||||
renderer.destroy()
|
||||
} catch {
|
||||
// already gone
|
||||
}
|
||||
}
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
restore()
|
||||
process.stderr.write(`qqq ui crashed: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`)
|
||||
process.exit(1)
|
||||
})
|
||||
renderer.on('destroy', () => {
|
||||
destroyed = true
|
||||
setTimeout(() => process.exit(0), 10)
|
||||
})
|
||||
|
||||
const store = boot({ args, quit: restore, build: process.env.QQQ_UI_BUILD ?? 'dev', timers: true })
|
||||
process.on('unhandledRejection', (reason) => store.error(reason))
|
||||
|
||||
store.editExternal = async (text: string) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'qqq-edit-'))
|
||||
const file = join(dir, 'value.txt')
|
||||
writeFileSync(file, text)
|
||||
await renderer.suspend()
|
||||
try {
|
||||
const editor = process.env.VISUAL ?? process.env.EDITOR ?? (process.platform === 'win32' ? 'notepad' : 'vi')
|
||||
spawnSync(editor, [file], { stdio: 'inherit', shell: process.platform === 'win32' })
|
||||
return readFileSync(file, 'utf8')
|
||||
} finally {
|
||||
await renderer.resume()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
createRoot(renderer).render(<App store={store} />)
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/** Opening screens: the one place that knows which screen shows what. */
|
||||
|
||||
import type { FieldValues, QueryFilter, ResultCursor } from '@qrun-io/qqq-core'
|
||||
import type { AppTreeNode } from './client'
|
||||
import { AppHomeScreen } from './screens/app-home'
|
||||
import { FormScreen, type FormOptions } from './screens/form'
|
||||
import { ProcessScreen, type ProcessOptions } from './screens/process'
|
||||
import { RecordScreen } from './screens/record'
|
||||
import { TableScreen } from './screens/table'
|
||||
import type { AppStore } from './store'
|
||||
|
||||
function known(store: AppStore, kind: 'tables' | 'processes' | 'apps', name: string): boolean {
|
||||
const map = store.meta?.[kind] as Record<string, unknown> | undefined
|
||||
if (map?.[name]) return true
|
||||
store.flash(`no ${kind === 'tables' ? 'table' : kind === 'processes' ? 'process' : 'app'} named "${name}"`, 'error')
|
||||
return false
|
||||
}
|
||||
|
||||
export function openTable(store: AppStore, table: string, filter?: QueryFilter): void {
|
||||
if (!known(store, 'tables', table)) return
|
||||
store.push(new TableScreen(store, table, filter))
|
||||
}
|
||||
|
||||
export function openRecord(store: AppStore, table: string, primaryKey: string | undefined, options: { cursor?: ResultCursor; label?: string } = {}): void {
|
||||
if (!known(store, 'tables', table)) return
|
||||
store.push(new RecordScreen(store, table, primaryKey, options.cursor, options.label))
|
||||
}
|
||||
|
||||
/** After a create: the new record replaces the form. */
|
||||
export function replaceWithRecord(store: AppStore, table: string, primaryKey: string): void {
|
||||
store.replace(new RecordScreen(store, table, primaryKey))
|
||||
}
|
||||
|
||||
export function openForm(store: AppStore, table: string, options: FormOptions & { initial?: FieldValues }): void {
|
||||
if (!known(store, 'tables', table)) return
|
||||
const meta = store.meta?.tables?.[table] as { insertPermission?: boolean; editPermission?: boolean } | undefined
|
||||
if (options.mode !== 'edit' && meta?.insertPermission === false) return store.flash('you may not create records in this table', 'error')
|
||||
if (options.mode === 'edit' && meta?.editPermission === false) return store.flash('you may not edit records of this table', 'error')
|
||||
store.push(new FormScreen(store, table, options))
|
||||
}
|
||||
|
||||
export function openProcess(store: AppStore, name: string, options: ProcessOptions = {}): void {
|
||||
if (!known(store, 'processes', name)) return
|
||||
store.push(new ProcessScreen(store, name, options))
|
||||
}
|
||||
|
||||
export function openApp(store: AppStore, name: string): void {
|
||||
if (!known(store, 'apps', name)) return
|
||||
store.push(new AppHomeScreen(store, name))
|
||||
}
|
||||
|
||||
export function openNode(store: AppStore, node: AppTreeNode): void {
|
||||
if (node.type === 'TABLE') openTable(store, node.name)
|
||||
else if (node.type === 'PROCESS') openProcess(store, node.name)
|
||||
else if (node.type === 'APP') openApp(store, node.name)
|
||||
else if (node.type === 'REPORT') store.commands().commandLine(`report run ${node.name} --format csv`)
|
||||
else store.flash(`${node.type} ${node.label} has no terminal screen yet`)
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import type { Key } from '../keys'
|
||||
import { charOf } from '../keys'
|
||||
import { LineEditor } from '../line-editor'
|
||||
import type { AppStore } from '../store'
|
||||
import { theme } from '../theme'
|
||||
import type { Overlay } from '../types'
|
||||
import { pad, trunc } from '../util/text'
|
||||
import { Frame, Line } from './frame'
|
||||
|
||||
let ids = 0
|
||||
export function overlayId(kind: string): string {
|
||||
return `${kind}:${++ids}`
|
||||
}
|
||||
|
||||
/** Wraps text into lines of a width. */
|
||||
export function wrap(text: string, width: number): string[] {
|
||||
const out: string[] = []
|
||||
for (const para of text.split('\n')) {
|
||||
let line = ''
|
||||
for (const word of para.split(/\s+/)) {
|
||||
if (!word) continue
|
||||
if ((line + ' ' + word).trim().length > width) {
|
||||
if (line) out.push(line)
|
||||
line = word.length > width ? word.slice(0, width) : word
|
||||
} else {
|
||||
line = (line + ' ' + word).trim()
|
||||
}
|
||||
}
|
||||
out.push(line)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export class MessageOverlay implements Overlay {
|
||||
readonly id = overlayId('message')
|
||||
constructor(private store: AppStore, readonly text: string, readonly kind: 'info' | 'error' = 'info', readonly title?: string) {}
|
||||
onKey(): void {
|
||||
this.store.closeOverlay(this)
|
||||
}
|
||||
render(sw: number, sh: number) {
|
||||
const width = Math.min(sw - 4, Math.max(36, Math.min(100, this.text.length + 4)))
|
||||
const lines = wrap(this.text, width - 4).slice(0, sh - 8)
|
||||
return (
|
||||
<Frame title={this.title ?? (this.kind === 'error' ? 'Error' : 'Message')} width={width} height={lines.length + 4} screenWidth={sw} screenHeight={sh}>
|
||||
{lines.map((l, i) => <Line key={i} text={` ${l}`} width={width - 2} fg={this.kind === 'error' ? theme().err : theme().fg} />)}
|
||||
<Line text="" width={width - 2} />
|
||||
<Line text=" any key closes" width={width - 2} fg={theme().dim} />
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class ConfirmOverlay implements Overlay {
|
||||
readonly id = overlayId('confirm')
|
||||
constructor(private store: AppStore, readonly text: string, private onYes: () => void, private onNo?: () => void, readonly defaultYes = false) {}
|
||||
onKey(key: Key): void {
|
||||
const ch = charOf(key).toLowerCase()
|
||||
if (ch === 'y' || (key.name === 'return' && this.defaultYes)) {
|
||||
this.store.closeOverlay(this)
|
||||
this.onYes()
|
||||
} else if (ch === 'n' || key.name === 'escape' || key.name === 'return') {
|
||||
this.store.closeOverlay(this)
|
||||
this.onNo?.()
|
||||
}
|
||||
}
|
||||
render(sw: number, sh: number) {
|
||||
const width = Math.min(sw - 4, Math.max(40, Math.min(96, this.text.length + 6)))
|
||||
const lines = wrap(this.text, width - 4)
|
||||
return (
|
||||
<Frame title="Confirm" width={width} height={lines.length + 4} screenWidth={sw} screenHeight={sh}>
|
||||
{lines.map((l, i) => <Line key={i} text={` ${l}`} width={width - 2} fg={theme().warn} />)}
|
||||
<Line text="" width={width - 2} />
|
||||
<Line text={this.defaultYes ? ' Y/n y or Enter = yes, n or Esc = no' : ' y/N y = yes, n, Esc or Enter = no'} width={width - 2} fg={theme().dim} />
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Draws a LineEditor with its cursor. */
|
||||
export function EditorLine(props: { editor: LineEditor; width: number; focused?: boolean; placeholder?: string }) {
|
||||
const t = theme()
|
||||
const w = Math.max(1, props.width)
|
||||
if (!props.editor.value && props.placeholder && props.focused !== false) {
|
||||
return <text bg={t.inputBg}><span fg={t.inputBg} bg={t.inputFg}>{' '}</span><span fg={t.dim} bg={t.inputBg}>{pad(props.placeholder, w - 1)}</span></text>
|
||||
}
|
||||
const v = props.editor.view(w - 1)
|
||||
if (props.focused === false) return <text fg={t.inputFg} bg={t.inputBg}>{pad(props.editor.masked ? '•'.repeat(props.editor.value.length) : props.editor.value, w)}</text>
|
||||
const rest = w - v.before.length - 1 - v.after.length
|
||||
return (
|
||||
<text bg={t.inputBg}>
|
||||
<span fg={t.inputFg} bg={t.inputBg}>{v.before}</span>
|
||||
<span fg={t.inputBg} bg={t.inputFg}>{v.at}</span>
|
||||
<span fg={t.inputFg} bg={t.inputBg}>{v.after + ' '.repeat(Math.max(0, rest))}</span>
|
||||
</text>
|
||||
)
|
||||
}
|
||||
|
||||
export class PromptOverlay implements Overlay {
|
||||
readonly id = overlayId('prompt')
|
||||
readonly editor: LineEditor
|
||||
constructor(private store: AppStore, readonly title: string, readonly hint: string, private onSubmit: (value: string) => void, initial = '', options: { masked?: boolean } = {}) {
|
||||
this.editor = new LineEditor(initial, options)
|
||||
}
|
||||
onKey(key: Key): void {
|
||||
if (key.name === 'escape') this.store.closeOverlay(this)
|
||||
else if (key.name === 'return') {
|
||||
this.store.closeOverlay(this)
|
||||
this.onSubmit(this.editor.value)
|
||||
} else this.editor.handle(key)
|
||||
}
|
||||
render(sw: number, sh: number) {
|
||||
const width = Math.min(sw - 4, 90)
|
||||
const hint = wrap(this.hint, width - 4)
|
||||
return (
|
||||
<Frame title={this.title} width={width} height={hint.length + 4} screenWidth={sw} screenHeight={sh}>
|
||||
<box paddingLeft={1}><EditorLine editor={this.editor} width={width - 4} /></box>
|
||||
<Line text="" width={width - 2} />
|
||||
{hint.map((l, i) => <Line key={i} text={` ${l}`} width={width - 2} fg={theme().dim} />)}
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export interface Choice<T> {
|
||||
label: string
|
||||
value: T
|
||||
detail?: string
|
||||
disabled?: string
|
||||
}
|
||||
|
||||
/** A list to pick from, filtered as you type. */
|
||||
export class ChoiceOverlay<T> implements Overlay {
|
||||
readonly id = overlayId('choice')
|
||||
readonly filter = new LineEditor('')
|
||||
index = 0
|
||||
constructor(private store: AppStore, readonly title: string, readonly choices: Choice<T>[], private onPick: (value: T) => void, readonly hint = '', initialIndex = 0, private onCancel?: () => void) {
|
||||
this.index = Math.max(0, initialIndex)
|
||||
}
|
||||
matches(): Choice<T>[] {
|
||||
const needle = this.filter.value.toLowerCase()
|
||||
return needle ? this.choices.filter((c) => c.label.toLowerCase().includes(needle) || String(c.detail ?? '').toLowerCase().includes(needle)) : this.choices
|
||||
}
|
||||
onKey(key: Key): void {
|
||||
const list = this.matches()
|
||||
if (key.name === 'escape') {
|
||||
this.store.closeOverlay(this)
|
||||
this.onCancel?.()
|
||||
} else if (key.name === 'down') this.index = Math.min(list.length - 1, this.index + 1)
|
||||
else if (key.name === 'up') this.index = Math.max(0, this.index - 1)
|
||||
else if (key.name === 'pagedown') this.index = Math.min(list.length - 1, this.index + 10)
|
||||
else if (key.name === 'pageup') this.index = Math.max(0, this.index - 10)
|
||||
else if (key.name === 'return') {
|
||||
const c = list[this.index]
|
||||
if (c && !c.disabled) {
|
||||
this.store.closeOverlay(this)
|
||||
this.onPick(c.value)
|
||||
} else if (c?.disabled) this.store.flash(c.disabled)
|
||||
} else if (this.filter.handle(key)) this.index = 0
|
||||
}
|
||||
render(sw: number, sh: number) {
|
||||
const t = theme()
|
||||
const list = this.matches()
|
||||
const width = Math.min(sw - 4, Math.max(40, Math.min(100, Math.max(...this.choices.map((c) => c.label.length + (c.detail ? c.detail.length + 4 : 0) + 6), this.title.length + 6))))
|
||||
const rows = Math.max(3, Math.min(list.length, sh - 10))
|
||||
const top = Math.max(0, Math.min(this.index - Math.floor(rows / 2), list.length - rows))
|
||||
const shown = list.slice(top, top + rows)
|
||||
return (
|
||||
<Frame title={this.title} width={width} height={rows + 5} screenWidth={sw} screenHeight={sh}>
|
||||
<box paddingLeft={1}><EditorLine editor={this.filter} width={width - 4} placeholder="type to filter" /></box>
|
||||
{shown.map((c, i) => {
|
||||
const sel = top + i === this.index
|
||||
const detail = c.disabled ?? c.detail ?? ''
|
||||
const labelWidth = width - 4 - (detail ? Math.min(detail.length, Math.floor(width / 2)) + 1 : 0)
|
||||
return <text key={i} fg={sel ? t.selFg : c.disabled ? t.dim : t.fg} bg={sel ? t.selBg : undefined}>{pad(` ${pad(c.label, labelWidth)}${detail ? ' ' + trunc(detail, width - 5 - labelWidth) : ''}`, width - 2)}</text>
|
||||
})}
|
||||
{Array.from({ length: rows - shown.length }, (_, i) => <Line key={`e${i}`} text={i === 0 && !shown.length ? ' (nothing matches)' : ''} width={width - 2} fg={t.dim} />)}
|
||||
<Line text={` ${list.length} of ${this.choices.length} · ↑↓ Enter · Esc${this.hint ? ' · ' + this.hint : ''}`} width={width - 2} fg={t.dim} />
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,458 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/** The F9 command center, the F2 palette, the F1 help, the `:` command line and the screen lock. */
|
||||
|
||||
import { hasPin, pinDelayMillis, verifyPin, type History } from '@qrun-io/qqq-core'
|
||||
import type { Key } from '../keys'
|
||||
import { charOf } from '../keys'
|
||||
import { LineEditor } from '../line-editor'
|
||||
import type { AppStore } from '../store'
|
||||
import { glyphs, theme } from '../theme'
|
||||
import type { MenuEntry, Overlay } from '../types'
|
||||
import { center, errorText, pad, trunc } from '../util/text'
|
||||
import { EditorLine, overlayId, wrap } from './basic'
|
||||
import { Frame, Line } from './frame'
|
||||
|
||||
const SECTIONS: MenuEntry['section'][] = ['Context', 'Go to', 'Work', 'Connection', 'Settings', 'Help']
|
||||
|
||||
function matches(e: MenuEntry, needle: string): boolean {
|
||||
return !needle || `${e.section} ${e.label} ${e.detail ?? ''} ${e.command ?? ''} ${e.key ?? ''}`.toLowerCase().includes(needle)
|
||||
}
|
||||
|
||||
/** The F9 command center: every action by section, searchable, shortcuts beside entries. */
|
||||
export class MenuOverlay implements Overlay {
|
||||
readonly id = overlayId('menu')
|
||||
readonly filter = new LineEditor('')
|
||||
index = 0
|
||||
entries: MenuEntry[]
|
||||
|
||||
constructor(private store: AppStore, entries: MenuEntry[]) {
|
||||
this.entries = entries
|
||||
const section = SECTIONS[store.lastMenuSection] ?? 'Context'
|
||||
const first = this.visible().findIndex((e) => e.section === section)
|
||||
this.index = Math.max(0, first)
|
||||
}
|
||||
|
||||
visible(): MenuEntry[] {
|
||||
const needle = this.filter.value.toLowerCase()
|
||||
const list = this.entries.filter((e) => matches(e, needle))
|
||||
return SECTIONS.flatMap((s) => list.filter((e) => e.section === s))
|
||||
}
|
||||
|
||||
onKey(key: Key): void {
|
||||
const list = this.visible()
|
||||
if (key.name === 'escape' || key.name === 'f9') return this.store.closeOverlay(this)
|
||||
if (key.name === 'down') this.index = Math.min(list.length - 1, this.index + 1)
|
||||
else if (key.name === 'up') this.index = Math.max(0, this.index - 1)
|
||||
else if (key.name === 'pagedown') this.index = Math.min(list.length - 1, this.index + 10)
|
||||
else if (key.name === 'pageup') this.index = Math.max(0, this.index - 10)
|
||||
else if (key.name === 'right' || key.name === 'tab' || key.name === 'left') {
|
||||
const cur = list[this.index]?.section
|
||||
const si = SECTIONS.indexOf(cur ?? 'Context')
|
||||
const dir = key.name === 'left' || key.shift ? -1 : 1
|
||||
for (let k = 1; k <= SECTIONS.length; k++) {
|
||||
const s = SECTIONS[(si + dir * k + SECTIONS.length * 2) % SECTIONS.length]
|
||||
const at = list.findIndex((e) => e.section === s)
|
||||
if (at >= 0) {
|
||||
this.index = at
|
||||
break
|
||||
}
|
||||
}
|
||||
} else if (key.name === 'return') {
|
||||
const e = list[this.index]
|
||||
if (!e) return
|
||||
if (e.disabled) return this.store.flash(`${e.label}: ${e.disabled}`)
|
||||
if (!e.run) return
|
||||
this.store.lastMenuSection = SECTIONS.indexOf(e.section)
|
||||
this.store.closeOverlay(this)
|
||||
e.run()
|
||||
} else if (this.filter.handle(key)) this.index = 0
|
||||
}
|
||||
|
||||
render(sw: number, sh: number) {
|
||||
const t = theme()
|
||||
const width = Math.min(sw - 2, 180)
|
||||
const height = Math.min(sh - 2, 40)
|
||||
const inner = width - 2
|
||||
const list = this.visible()
|
||||
const columns = this.filter.value ? 1 : width >= 160 ? 3 : width >= 110 ? 2 : 1
|
||||
const colWidth = Math.floor(inner / columns)
|
||||
const rows = height - 6
|
||||
const lines: Array<{ text: string; fg: string; bg?: string }[]> = Array.from({ length: columns }, () => [])
|
||||
// lay sections out in columns, splitting the flat list into chunks that fit
|
||||
type Item = { kind: 'h'; text: string } | { kind: 'e'; e: MenuEntry; i: number }
|
||||
const items: Item[] = []
|
||||
let last: string | undefined
|
||||
list.forEach((e, i) => {
|
||||
if (e.section !== last) {
|
||||
items.push({ kind: 'h', text: e.section })
|
||||
last = e.section
|
||||
}
|
||||
items.push({ kind: 'e', e, i })
|
||||
})
|
||||
// items flow down columns of `rows`; a window of `columns` columns follows the selection
|
||||
const selItem = Math.max(0, items.findIndex((it) => it.kind === 'e' && it.i === this.index))
|
||||
const selCol = Math.floor(selItem / rows)
|
||||
const firstCol = Math.max(0, selCol - columns + 1)
|
||||
items.forEach((it, k) => {
|
||||
const col = Math.floor(k / rows) - firstCol
|
||||
if (col < 0 || col >= columns) return
|
||||
if (it.kind === 'h') lines[col]!.push({ text: ` ${it.text}`, fg: t.acc })
|
||||
else {
|
||||
const sel = it.i === this.index
|
||||
const keyText = it.e.key ? ` ${it.e.key} ` : ''
|
||||
const detail = it.e.disabled ? ` (${it.e.disabled})` : it.e.detail ? ` ${it.e.detail}` : ''
|
||||
const room = colWidth - 3 - keyText.length
|
||||
const text = ` ${trunc(it.e.label + detail, room)}`
|
||||
lines[col]!.push({ text: pad(text, colWidth - keyText.length) + keyText, fg: sel ? t.selFg : it.e.disabled ? t.dim : t.fg, bg: sel ? t.selBg : undefined })
|
||||
}
|
||||
})
|
||||
const out: React.ReactNode[] = []
|
||||
for (let r = 0; r < rows; r++) {
|
||||
out.push(
|
||||
<text key={r}>
|
||||
{lines.map((col, c) => {
|
||||
const cellLine = col[r]
|
||||
return <span key={c} fg={cellLine?.fg ?? t.fg} bg={cellLine?.bg}>{pad(cellLine?.text ?? '', colWidth)}</span>
|
||||
})}
|
||||
</text>,
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Frame title="Menu (F9)" width={width} height={height} screenWidth={sw} screenHeight={sh} top={1}>
|
||||
<box paddingLeft={1}><EditorLine editor={this.filter} width={inner - 2} placeholder="type to search every command" /></box>
|
||||
<Line text="" width={inner} />
|
||||
{out}
|
||||
<Line text={` ${list.length} entries · ↑↓ move · ←→ Tab sections · Enter runs · Esc or F9 closes`} width={inner} fg={t.dim} />
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export interface PaletteItem {
|
||||
label: string
|
||||
kind: string
|
||||
context?: string
|
||||
run: () => void
|
||||
}
|
||||
|
||||
/** The palette (F2, /, ., Ctrl+K): jump to anything by label; searches records when the server can. */
|
||||
export class PaletteOverlay implements Overlay {
|
||||
readonly id = overlayId('palette')
|
||||
readonly input = new LineEditor('')
|
||||
index = 0
|
||||
records: PaletteItem[] = []
|
||||
searching = false
|
||||
private timer?: ReturnType<typeof setTimeout>
|
||||
private generation = 0
|
||||
|
||||
constructor(private store: AppStore, readonly items: PaletteItem[], readonly searchRecords?: (text: string) => Promise<PaletteItem[]>, readonly searchNote = '') {}
|
||||
|
||||
list(): PaletteItem[] {
|
||||
const needle = this.input.value.toLowerCase().trim()
|
||||
const words = needle.split(/\s+/).filter(Boolean)
|
||||
const hits = this.items.filter((it) => words.every((w) => `${it.label} ${it.kind} ${it.context ?? ''}`.toLowerCase().includes(w)))
|
||||
hits.sort((a, b) => Number(!a.label.toLowerCase().startsWith(needle)) - Number(!b.label.toLowerCase().startsWith(needle)))
|
||||
return [...hits.slice(0, 200), ...this.records]
|
||||
}
|
||||
|
||||
onKey(key: Key): void {
|
||||
const list = this.list()
|
||||
if (key.name === 'escape') return this.store.closeOverlay(this)
|
||||
if (key.name === 'down') this.index = Math.min(list.length - 1, this.index + 1)
|
||||
else if (key.name === 'up') this.index = Math.max(0, this.index - 1)
|
||||
else if (key.name === 'pagedown') this.index = Math.min(list.length - 1, this.index + 10)
|
||||
else if (key.name === 'pageup') this.index = Math.max(0, this.index - 10)
|
||||
else if (key.name === 'return') {
|
||||
const it = list[this.index]
|
||||
if (!it) return
|
||||
this.store.closeOverlay(this)
|
||||
it.run()
|
||||
} else if (this.input.handle(key)) {
|
||||
this.index = 0
|
||||
this.records = []
|
||||
clearTimeout(this.timer)
|
||||
const text = this.input.value.trim()
|
||||
if (this.searchRecords && text.length >= 2) {
|
||||
this.timer = setTimeout(() => {
|
||||
const gen = ++this.generation
|
||||
this.searching = true
|
||||
this.searchRecords!(text).then((r) => { if (gen === this.generation) this.records = r }).catch(() => undefined).finally(() => { this.searching = false; this.store.bump() })
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render(sw: number, sh: number) {
|
||||
const t = theme()
|
||||
const width = Math.min(sw - 4, 110)
|
||||
const inner = width - 2
|
||||
const list = this.list()
|
||||
const rows = Math.max(5, Math.min(20, sh - 10))
|
||||
const top = Math.max(0, Math.min(this.index - Math.floor(rows / 2), list.length - rows))
|
||||
const footer = this.searchRecords ? `${this.searching ? 'searching records… · ' : ''}${list.length} matches · records searched by the server${this.searchNote ? ` (${this.searchNote})` : ''}` : `${list.length} matches · no record search on this server: navigation only`
|
||||
return (
|
||||
<Frame title="Go to (F2 / . Ctrl+K)" width={width} height={rows + 5} screenWidth={sw} screenHeight={sh} top={2}>
|
||||
<box paddingLeft={1}><EditorLine editor={this.input} width={inner - 2} placeholder="a table, process, app, report, recent record, command, or text to find records" /></box>
|
||||
{list.slice(top, top + rows).map((it, i) => {
|
||||
const sel = top + i === this.index
|
||||
return <text key={i} fg={sel ? t.selFg : t.fg} bg={sel ? t.selBg : undefined}>{pad(` ${pad(it.label, 38)} ${pad(it.kind, 10)} ${it.context ?? ''}`, inner)}</text>
|
||||
})}
|
||||
{Array.from({ length: Math.max(0, rows - Math.min(rows, list.length - top)) }, (_, i) => <Line key={`e${i}`} text="" width={inner} />)}
|
||||
<Line text="" width={inner} />
|
||||
<Line text={` ${footer}`} width={inner} fg={t.dim} />
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The F1 / ? help: keys for this screen, what can be done here, the topic's help text, and help search. */
|
||||
export class HelpOverlay implements Overlay {
|
||||
readonly id = overlayId('help')
|
||||
readonly input = new LineEditor('')
|
||||
top = 0
|
||||
results: Array<{ label: string; detail: string; run?: () => void }> = []
|
||||
index = 0
|
||||
private timer?: ReturnType<typeof setTimeout>
|
||||
|
||||
constructor(private store: AppStore, readonly keys: Array<[string, string]>, readonly actions: MenuEntry[], readonly topicHelp: string[], readonly search: (q: string) => Promise<Array<{ label: string; detail: string; run?: () => void }>>) {}
|
||||
|
||||
onKey(key: Key): void {
|
||||
if (key.name === 'escape' || key.name === 'f1') return this.store.closeOverlay(this)
|
||||
if (this.input.value && this.results.length) {
|
||||
if (key.name === 'down') return void (this.index = Math.min(this.results.length - 1, this.index + 1))
|
||||
if (key.name === 'up') return void (this.index = Math.max(0, this.index - 1))
|
||||
if (key.name === 'return') {
|
||||
const r = this.results[this.index]
|
||||
if (r?.run) {
|
||||
this.store.closeOverlay(this)
|
||||
r.run()
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
if (key.name === 'down') this.top++
|
||||
else if (key.name === 'up') this.top = Math.max(0, this.top - 1)
|
||||
else if (key.name === 'pagedown') this.top += 10
|
||||
else if (key.name === 'pageup') this.top = Math.max(0, this.top - 10)
|
||||
else if (key.name === 'return' && !this.input.value) {
|
||||
const a = this.actions.find((e) => e.run && !e.disabled)
|
||||
void a
|
||||
} else if (this.input.handle(key)) {
|
||||
this.index = 0
|
||||
clearTimeout(this.timer)
|
||||
const q = this.input.value.trim()
|
||||
if (!q) {
|
||||
this.results = []
|
||||
return
|
||||
}
|
||||
this.timer = setTimeout(() => {
|
||||
this.search(q).then((r) => { this.results = r }).catch((e) => { this.results = [{ label: errorText(e), detail: '' }] }).finally(() => this.store.bump())
|
||||
}, 250)
|
||||
}
|
||||
}
|
||||
|
||||
render(sw: number, sh: number) {
|
||||
const t = theme()
|
||||
const width = Math.min(sw - 4, 110)
|
||||
const inner = width - 2
|
||||
const height = Math.min(sh - 2, 40)
|
||||
const body: Array<{ text: string; fg?: string; bg?: string }> = []
|
||||
if (this.input.value) {
|
||||
body.push({ text: ` Help search: ${this.results.length} results (Enter opens)`, fg: t.acc })
|
||||
this.results.forEach((r, i) => body.push({ text: ` ${pad(r.label, 40)} ${r.detail}`, fg: i === this.index ? t.selFg : t.fg, bg: i === this.index ? t.selBg : undefined }))
|
||||
} else {
|
||||
body.push({ text: ' Keys', fg: t.acc })
|
||||
const half = Math.ceil(this.keys.length / 2)
|
||||
for (let i = 0; i < half; i++) {
|
||||
const a = this.keys[i]!
|
||||
const b = this.keys[i + half]
|
||||
body.push({ text: ` ${pad(a[0], 14)}${pad(a[1], Math.floor(inner / 2) - 17)}${b ? `${pad(b[0], 14)}${b[1]}` : ''}` })
|
||||
}
|
||||
if (this.topicHelp.length) {
|
||||
body.push({ text: '' })
|
||||
body.push({ text: ' About this screen', fg: t.acc })
|
||||
for (const p of this.topicHelp) for (const l of wrap(p, inner - 4)) body.push({ text: ` ${l}` })
|
||||
}
|
||||
if (this.actions.length) {
|
||||
body.push({ text: '' })
|
||||
body.push({ text: ' What you can do here (also in F9)', fg: t.acc })
|
||||
for (const a of this.actions) body.push({ text: ` ${pad(a.label, inner - 22)} ${a.key ?? ''}`, fg: a.disabled ? t.dim : t.fg })
|
||||
}
|
||||
}
|
||||
const rows = height - 5
|
||||
this.top = Math.min(this.top, Math.max(0, body.length - rows))
|
||||
const shown = body.slice(this.input.value ? 0 : this.top, (this.input.value ? 0 : this.top) + rows)
|
||||
return (
|
||||
<Frame title="Help (F1 ?)" width={width} height={height} screenWidth={sw} screenHeight={sh} top={1}>
|
||||
<box paddingLeft={1}><EditorLine editor={this.input} width={inner - 2} placeholder="type to search the application's help" /></box>
|
||||
{shown.map((l, i) => <Line key={i} text={l.text} width={inner} fg={l.fg} bg={l.bg} />)}
|
||||
{Array.from({ length: Math.max(0, rows - shown.length) }, (_, i) => <Line key={`e${i}`} text="" width={inner} />)}
|
||||
<Line text="" width={inner} />
|
||||
<Line text=" ↑↓ PgUp PgDn scroll · type to search help · Esc or F1 closes" width={inner} fg={t.dim} />
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The `:` command line: the CLI grammar plus TUI commands, history (↑↓, Ctrl+R), Tab completion. */
|
||||
export class CommandLineOverlay implements Overlay {
|
||||
readonly id = overlayId('cmdline')
|
||||
readonly editor: LineEditor
|
||||
historyIndex = -1
|
||||
candidates: string[] = []
|
||||
candidate = 0
|
||||
search?: LineEditor
|
||||
|
||||
constructor(private store: AppStore, private history: History, private complete: (line: string, cursor: number) => { from: number; candidates: string[] }, private onRun: (line: string) => void, initial = '') {
|
||||
this.editor = new LineEditor(initial)
|
||||
}
|
||||
|
||||
onKey(key: Key): void {
|
||||
if (this.search) {
|
||||
if (key.name === 'escape') {
|
||||
this.search = undefined
|
||||
return
|
||||
}
|
||||
if (key.name === 'return' || (key.ctrl && key.name === 'r')) {
|
||||
const hit = this.history.search(this.search.value)[0]
|
||||
if (hit) this.editor.set(hit)
|
||||
this.search = undefined
|
||||
return
|
||||
}
|
||||
this.search.handle(key)
|
||||
return
|
||||
}
|
||||
if (key.name === 'escape') return this.store.closeOverlay(this)
|
||||
if (key.ctrl && key.name === 'r') {
|
||||
this.search = new LineEditor('')
|
||||
return
|
||||
}
|
||||
if (key.name === 'return') {
|
||||
const line = this.editor.value.trim()
|
||||
this.store.closeOverlay(this)
|
||||
if (line) {
|
||||
this.history.add(line)
|
||||
this.onRun(line)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.name === 'tab') {
|
||||
const r = this.complete(this.editor.value, this.editor.cursor)
|
||||
if (!r.candidates.length) return
|
||||
if (this.candidates.join('\u0000') === r.candidates.join('\u0000') && r.candidates.length > 1) this.candidate = (this.candidate + 1) % r.candidates.length
|
||||
else {
|
||||
this.candidates = r.candidates
|
||||
this.candidate = 0
|
||||
}
|
||||
const pick = r.candidates[this.candidate]!
|
||||
const before = this.editor.value.slice(0, r.from)
|
||||
const after = this.editor.value.slice(this.editor.cursor)
|
||||
this.editor.value = before + pick + (after.startsWith(' ') ? '' : ' ') + after
|
||||
this.editor.cursor = before.length + pick.length + 1
|
||||
if (r.candidates.length === 1) this.candidates = []
|
||||
return
|
||||
}
|
||||
if (key.name === 'up' || key.name === 'down') {
|
||||
const hist = this.history.entries()
|
||||
this.historyIndex = Math.max(-1, Math.min(hist.length - 1, this.historyIndex + (key.name === 'up' ? 1 : -1)))
|
||||
this.editor.set(this.historyIndex >= 0 ? hist[this.historyIndex]! : '')
|
||||
return
|
||||
}
|
||||
if (this.editor.handle(key)) this.candidates = []
|
||||
}
|
||||
|
||||
render(sw: number, sh: number) {
|
||||
const t = theme()
|
||||
const width = Math.min(sw - 2, 140)
|
||||
const inner = width - 2
|
||||
const hint = this.search
|
||||
? ` history search: ${this.search.value}_ → ${this.history.search(this.search.value)[0] ?? '(no match)'}`
|
||||
: this.candidates.length > 1 ? ` ${trunc(this.candidates.map((c, i) => (i === this.candidate ? `[${c}]` : c)).join(' '), inner - 2)}` : ' ↑↓ history · Ctrl+R search · Tab completes · Enter runs · Esc · e.g. table query person -w "lastName STARTS_WITH S" · theme light · lock · quit'
|
||||
return (
|
||||
<box position="absolute" left={Math.max(0, Math.floor((sw - width) / 2))} top={Math.max(0, sh - 6)} width={width} height={4} flexDirection="column" border borderStyle="single" borderColor={t.focusBorder} {...(glyphs().border ? { customBorderChars: glyphs().border } : {})} backgroundColor={t.overlayBg} title=" : command " zIndex={30}>
|
||||
<text><span fg={t.acc2}>{':'}</span></text>
|
||||
<box position="absolute" left={2} top={0}><EditorLine editor={this.editor} width={inner - 3} /></box>
|
||||
<Line text={hint} width={inner} fg={this.search ? t.acc2 : t.dim} />
|
||||
</box>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** The screen saver and lock: a slowly moving clock on the theme's colors; a PIN when one is set. */
|
||||
export class LockOverlay implements Overlay {
|
||||
readonly id = overlayId('lock')
|
||||
asking = false
|
||||
readonly pin = new LineEditor('', { masked: true })
|
||||
failures = 0
|
||||
waitUntil = 0
|
||||
message = ''
|
||||
started = Date.now()
|
||||
|
||||
constructor(private store: AppStore) {}
|
||||
|
||||
onKey(key: Key): void {
|
||||
if (!hasPin()) {
|
||||
this.store.lock = undefined
|
||||
return
|
||||
}
|
||||
if (!this.asking) {
|
||||
this.asking = true
|
||||
return
|
||||
}
|
||||
if (key.name === 'escape') {
|
||||
this.asking = false
|
||||
this.pin.set('')
|
||||
return
|
||||
}
|
||||
if (key.name === 'return') {
|
||||
if (Date.now() < this.waitUntil) {
|
||||
this.message = `wait ${Math.ceil((this.waitUntil - Date.now()) / 1000)} s`
|
||||
return
|
||||
}
|
||||
if (verifyPin(this.pin.value)) {
|
||||
this.store.lock = undefined
|
||||
return
|
||||
}
|
||||
this.failures++
|
||||
this.waitUntil = Date.now() + pinDelayMillis(this.failures)
|
||||
this.message = `wrong PIN (attempt ${this.failures}); wait ${(pinDelayMillis(this.failures) / 1000).toFixed(1)} s`
|
||||
this.pin.set('')
|
||||
return
|
||||
}
|
||||
this.pin.handle(key)
|
||||
}
|
||||
|
||||
render(sw: number, sh: number) {
|
||||
const t = theme()
|
||||
const elapsed = Math.floor((Date.now() - this.started) / 4000)
|
||||
const brand = (this.store.meta?.branding as { appName?: string } | undefined)?.appName ?? 'QQQ'
|
||||
const lines = [brand, new Date().toLocaleTimeString('en-GB'), `${this.store.status.profile} · ${this.store.status.server.replace(/^https?:\/\//, '')}`, this.asking ? 'PIN:' : hasPin() ? 'locked · any key to unlock' : 'any key returns']
|
||||
const boxW = 44
|
||||
const maxX = Math.max(0, sw - boxW)
|
||||
const maxY = Math.max(0, sh - lines.length - 4)
|
||||
const bounce = (n: number, max: number) => (max === 0 ? 0 : Math.abs(((n % (2 * max)) + 2 * max) % (2 * max) - max))
|
||||
const x = bounce(elapsed * 3, maxX)
|
||||
const y = bounce(elapsed, maxY)
|
||||
const rows: React.ReactNode[] = []
|
||||
for (let r = 0; r < sh; r++) rows.push(<text key={r} bg={t.overlayBg}>{' '.repeat(sw)}</text>)
|
||||
return (
|
||||
<box position="absolute" left={0} top={0} width={sw} height={sh} zIndex={100} backgroundColor={t.overlayBg}>
|
||||
{rows}
|
||||
<box position="absolute" left={x} top={y} width={boxW} flexDirection="column">
|
||||
<text fg={t.acc}><strong>{center(lines[0]!, boxW)}</strong></text>
|
||||
<text fg={t.acc2}><strong>{center(lines[1]!, boxW)}</strong></text>
|
||||
<text fg={t.dim}>{center(lines[2]!, boxW)}</text>
|
||||
<text fg={t.fg}>{center(lines[3]!, boxW)}</text>
|
||||
{this.asking ? <box paddingLeft={12}><EditorLine editor={this.pin} width={20} /></box> : null}
|
||||
{this.message ? <text fg={t.err}>{center(this.message, boxW)}</text> : null}
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export { charOf }
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { existsSync } from 'node:fs'
|
||||
import { readFilePreferences, safeFileName, silentDownloadPath, startDirForSave, startDirForUpload } from '@qrun-io/qqq-core'
|
||||
import type { AppStore } from '../store'
|
||||
import { FileDialog } from './files'
|
||||
|
||||
/**
|
||||
* Where to save a download: with "save silently" set, straight to the default download directory
|
||||
* (a free name, never overwriting); otherwise the save dialog.
|
||||
*/
|
||||
export function saveFile(store: AppStore, title: string, suggested: string, write: (path: string) => void): void {
|
||||
const prefs = readFilePreferences()
|
||||
if (prefs.downloadMode === 'silent') {
|
||||
const base = silentDownloadPath(safeFileName(suggested), prefs)
|
||||
let path = base
|
||||
for (let i = 1; existsSync(path); i++) path = base.replace(/(\.[^./]*)?$/, (ext) => `-${i}${ext}`)
|
||||
write(path)
|
||||
return
|
||||
}
|
||||
store.open(new FileDialog(store, { mode: 'save', title, startDir: startDirForSave(prefs), fileName: suggested, onPick: write }))
|
||||
}
|
||||
|
||||
/** Chooses an existing file to upload. */
|
||||
export function openFile(store: AppStore, title: string, pick: (path: string) => void): void {
|
||||
store.open(new FileDialog(store, { mode: 'open', title, startDir: startDirForUpload(), onPick: pick }))
|
||||
}
|
||||
|
||||
/** Chooses a folder. */
|
||||
export function chooseFolder(store: AppStore, title: string, start: string, pick: (path: string) => void): void {
|
||||
store.open(new FileDialog(store, { mode: 'folder', title, startDir: start, onPick: pick }))
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { existsSync, statSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { completePath, createFolder, expandPath, formatSize, listDirectory, safeFileName, writeFilePreferences, type DirEntry } from '@qrun-io/qqq-core'
|
||||
import type { Key } from '../keys'
|
||||
import { charOf } from '../keys'
|
||||
import { LineEditor } from '../line-editor'
|
||||
import type { AppStore } from '../store'
|
||||
import { glyphs, theme } from '../theme'
|
||||
import type { Overlay } from '../types'
|
||||
import { errorText, pad, trunc } from '../util/text'
|
||||
import { ConfirmOverlay, EditorLine, PromptOverlay, overlayId } from './basic'
|
||||
import { Frame, Line } from './frame'
|
||||
|
||||
export interface FileDialogOptions {
|
||||
/** open: pick an existing file; save: choose a folder and a file name; folder: pick a directory. */
|
||||
mode: 'open' | 'save' | 'folder'
|
||||
title: string
|
||||
startDir: string
|
||||
/** Save: the suggested name (sanitized with the kernel's safeFileName). */
|
||||
fileName?: string
|
||||
onPick: (path: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A keyboard file navigator: directories first with sizes and dates, typing filters, Backspace or
|
||||
* `..` goes up, a path line with Tab completion, `h` hidden files, `~` home, Ctrl+N new folder,
|
||||
* and for saves a file name line that warns before overwriting. The listing and completion come
|
||||
* from the kernel (files/navigator), so this is only drawing and keys.
|
||||
*/
|
||||
export class FileDialog implements Overlay {
|
||||
readonly id = overlayId('files')
|
||||
dir: string
|
||||
entries: DirEntry[] = []
|
||||
index = 0
|
||||
hidden = false
|
||||
focus: 'list' | 'path' | 'name' = 'list'
|
||||
readonly filter = new LineEditor('')
|
||||
readonly path: LineEditor
|
||||
readonly name: LineEditor
|
||||
error?: string
|
||||
candidates: string[] = []
|
||||
|
||||
constructor(private store: AppStore, readonly options: FileDialogOptions) {
|
||||
this.dir = existsSync(options.startDir) ? options.startDir : process.cwd()
|
||||
this.path = new LineEditor(this.dir)
|
||||
this.name = new LineEditor(options.fileName ? safeFileName(options.fileName) : '')
|
||||
if (options.mode === 'save') this.focus = 'name'
|
||||
this.load()
|
||||
}
|
||||
|
||||
load(): void {
|
||||
try {
|
||||
this.entries = listDirectory(this.dir, { hidden: this.hidden })
|
||||
this.error = undefined
|
||||
} catch (e) {
|
||||
this.entries = []
|
||||
this.error = errorText(e)
|
||||
}
|
||||
this.path.set(this.dir)
|
||||
this.index = 0
|
||||
}
|
||||
|
||||
rows(): Array<DirEntry | { name: '..'; up: true } | { name: string; select: true }> {
|
||||
const needle = this.filter.value.toLowerCase()
|
||||
const list = this.entries.filter((e) => (this.options.mode !== 'folder' || e.isDir) && (!needle || e.name.toLowerCase().includes(needle)))
|
||||
const head: Array<{ name: '..'; up: true } | { name: string; select: true }> = []
|
||||
if (this.options.mode === 'folder' && !needle) head.push({ name: '[ select this folder ]', select: true })
|
||||
if (dirname(this.dir) !== this.dir && !needle) head.push({ name: '..', up: true })
|
||||
return [...head, ...list]
|
||||
}
|
||||
|
||||
go(dir: string): void {
|
||||
this.dir = dir
|
||||
this.filter.set('')
|
||||
this.load()
|
||||
}
|
||||
|
||||
pick(path: string): void {
|
||||
const remember = this.options.mode === 'open' ? { lastUploadDir: dirname(path) } : { lastDownloadDir: this.options.mode === 'folder' ? path : dirname(path) }
|
||||
try {
|
||||
writeFilePreferences(remember)
|
||||
} catch {
|
||||
// not critical
|
||||
}
|
||||
this.store.closeOverlay(this)
|
||||
this.options.onPick(path)
|
||||
}
|
||||
|
||||
save(): void {
|
||||
const name = this.name.value.trim()
|
||||
if (!name) {
|
||||
this.error = 'type a file name'
|
||||
return
|
||||
}
|
||||
let target: string
|
||||
try {
|
||||
target = join(this.dir, safeFileName(name))
|
||||
} catch (e) {
|
||||
this.error = errorText(e)
|
||||
return
|
||||
}
|
||||
if (existsSync(target)) this.store.open(new ConfirmOverlay(this.store, `${target} exists. Overwrite it?`, () => this.pick(target)))
|
||||
else this.pick(target)
|
||||
}
|
||||
|
||||
onKey(key: Key): void {
|
||||
if (key.name === 'escape') return this.store.closeOverlay(this)
|
||||
if (key.name === 'tab') {
|
||||
if (this.focus === 'path' && !key.shift) {
|
||||
const r = completePath(this.path.value, { hidden: this.hidden, directoriesOnly: this.options.mode === 'folder' })
|
||||
this.path.set(r.text)
|
||||
this.candidates = r.candidates.length > 1 ? r.candidates : []
|
||||
if (r.candidates.length === 1 && r.text.endsWith('/') && existsSync(expandPath(r.text))) this.go(expandPath(r.text))
|
||||
return
|
||||
}
|
||||
const order: Array<'list' | 'path' | 'name'> = this.options.mode === 'save' ? ['name', 'list', 'path'] : ['list', 'path']
|
||||
this.focus = order[(order.indexOf(this.focus) + (key.shift ? order.length - 1 : 1)) % order.length]!
|
||||
return
|
||||
}
|
||||
if (key.ctrl && key.name === 'n' && this.options.mode !== 'open') {
|
||||
this.store.open(new PromptOverlay(this.store, 'New folder', `In ${this.dir}`, (name) => {
|
||||
try {
|
||||
this.go(createFolder(this.dir, name))
|
||||
} catch (e) {
|
||||
this.error = errorText(e)
|
||||
}
|
||||
}))
|
||||
return
|
||||
}
|
||||
if (this.focus === 'path') {
|
||||
if (key.name === 'return') {
|
||||
const p = expandPath(this.path.value)
|
||||
if (existsSync(p) && statSync(p).isDirectory()) {
|
||||
this.go(p)
|
||||
this.focus = this.options.mode === 'save' ? 'name' : 'list'
|
||||
} else if (existsSync(p) && this.options.mode === 'open') this.pick(p)
|
||||
else if (this.options.mode === 'save' && existsSync(dirname(p))) {
|
||||
this.go(dirname(p))
|
||||
this.name.set(p.slice(dirname(p).length + 1))
|
||||
this.focus = 'name'
|
||||
} else this.error = `no such file or folder: ${p}`
|
||||
return
|
||||
}
|
||||
this.path.handle(key)
|
||||
return
|
||||
}
|
||||
if (this.focus === 'name') {
|
||||
if (key.name === 'return') return this.save()
|
||||
this.name.handle(key)
|
||||
return
|
||||
}
|
||||
const rows = this.rows()
|
||||
const ch = charOf(key)
|
||||
switch (key.name) {
|
||||
case 'down': this.index = Math.min(rows.length - 1, this.index + 1); return
|
||||
case 'up': this.index = Math.max(0, this.index - 1); return
|
||||
case 'pagedown': this.index = Math.min(rows.length - 1, this.index + 10); return
|
||||
case 'pageup': this.index = Math.max(0, this.index - 10); return
|
||||
case 'home': this.index = 0; return
|
||||
case 'end': this.index = rows.length - 1; return
|
||||
case 'return': {
|
||||
const r = rows[this.index]
|
||||
if (!r) return
|
||||
if ('up' in r) return this.go(dirname(this.dir))
|
||||
if ('select' in r) return this.pick(this.dir)
|
||||
if (r.isDir) return this.go(r.path)
|
||||
if (this.options.mode === 'open') return this.pick(r.path)
|
||||
if (this.options.mode === 'save') {
|
||||
this.name.set(r.name)
|
||||
this.focus = 'name'
|
||||
}
|
||||
return
|
||||
}
|
||||
case 'backspace':
|
||||
if (!this.filter.value) return this.go(dirname(this.dir))
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
if (!this.filter.value && ch === 'h') {
|
||||
this.hidden = !this.hidden
|
||||
return this.load()
|
||||
}
|
||||
if (!this.filter.value && ch === '~') return this.go(homedir())
|
||||
if (this.filter.handle(key)) this.index = 0
|
||||
}
|
||||
|
||||
render(sw: number, sh: number) {
|
||||
const t = theme()
|
||||
const g = glyphs()
|
||||
const width = Math.min(sw - 2, 110)
|
||||
const inner = width - 2
|
||||
const rows = this.rows()
|
||||
const listRows = Math.max(4, Math.min(sh - 12, 24))
|
||||
const top = Math.max(0, Math.min(this.index - Math.floor(listRows / 2), rows.length - listRows))
|
||||
const dateWidth = inner >= 70 ? 17 : 0
|
||||
const sizeWidth = 9
|
||||
const nameWidth = inner - 3 - sizeWidth - dateWidth
|
||||
const lines = rows.slice(top, top + listRows).map((r, i) => {
|
||||
const sel = top + i === this.index && this.focus === 'list'
|
||||
let text: string
|
||||
let fg = t.fg
|
||||
if ('up' in r) {
|
||||
text = ` ${pad('../', nameWidth)}`
|
||||
fg = t.acc
|
||||
} else if ('select' in r) {
|
||||
text = ` ${pad(r.name, nameWidth)}`
|
||||
fg = t.acc2
|
||||
} else {
|
||||
const date = dateWidth ? ' ' + r.modified.toISOString().slice(0, 16).replace('T', ' ') : ''
|
||||
text = ` ${pad(r.name + (r.isDir ? '/' : ''), nameWidth)} ${r.isDir ? pad('', sizeWidth - 1) : pad(formatSize(r.size), sizeWidth - 1)}${date}`
|
||||
fg = r.isDir ? t.acc : r.hidden ? t.dim : t.fg
|
||||
}
|
||||
return <text key={i} fg={sel ? t.selFg : fg} bg={sel ? t.selBg : undefined}>{pad(text, inner)}</text>
|
||||
})
|
||||
while (lines.length < listRows) lines.push(<text key={`e${lines.length}`}>{pad(lines.length === 0 && this.filter.value ? ' (nothing matches)' : '', inner)}</text>)
|
||||
const label = (f: 'list' | 'path' | 'name', text: string) => <span fg={this.focus === f ? t.acc2 : t.dim}>{text}</span>
|
||||
return (
|
||||
<Frame title={this.options.title} width={width} height={listRows + (this.options.mode === 'save' ? 9 : 8)} screenWidth={sw} screenHeight={sh} top={1}>
|
||||
<box flexDirection="row" height={1}><text>{label('path', ' Path ')}</text><EditorLine editor={this.path} width={inner - 7} focused={this.focus === 'path'} /></box>
|
||||
{this.options.mode === 'save' ? <box flexDirection="row" height={1}><text>{label('name', ' Name ')}</text><EditorLine editor={this.name} width={inner - 7} focused={this.focus === 'name'} /></box> : null}
|
||||
<box flexDirection="row" height={1}><text>{label('list', ' Find ')}</text><EditorLine editor={this.filter} width={inner - 7} focused={this.focus === 'list'} placeholder="type to filter this folder" /></box>
|
||||
<Line text={this.candidates.length ? ` ${trunc(this.candidates.join(' '), inner - 2)}` : ''} width={inner} fg={t.dim} />
|
||||
{lines}
|
||||
<Line text={this.error ? ` ${this.error}` : ` ${rows.filter((r) => 'isDir' in r).length} entries${this.hidden ? ' (hidden shown)' : ''}`} width={inner} fg={this.error ? t.err : t.dim} />
|
||||
<Line text={` Enter open/pick · Backspace up · Tab ${this.options.mode === 'save' ? 'name/list/path' : 'list/path'} · h hidden · ~ home${this.options.mode !== 'open' ? ' · Ctrl+N folder' : ''} · Esc`} width={inner} fg={t.dim} />
|
||||
<Line text={` ${g.sep} ${trunc(this.dir, inner - 4)}`} width={inner} fg={t.dim} />
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The F6 filter dialog, two modes over one QueryFilter (through the kernel grammar):
|
||||
* - guided: a builder over the table's fields (field, then an operator valid for its type, then a
|
||||
* value editor that fits: possible-value picker, date presets, list values), AND/OR, one level of
|
||||
* groups, and the sort; the grammar text is shown live;
|
||||
* - advanced: the grammar typed directly with completion from the kernel (fields, operators,
|
||||
* values and expressions, AND/OR/parentheses), inline parse errors and a preview.
|
||||
* Tab switches modes (guided prints the grammar; advanced parses back when it parses).
|
||||
*/
|
||||
|
||||
import {
|
||||
FilterSyntaxError, LIST_OPERATORS, UNARY_OPERATORS, applyCompletion, completeFilter, describeFilter, expressionCandidates, locateFilterError,
|
||||
operatorsForField, parseFilter, parseValue, printCriterion, printFilter, printValue, resolvePossibleValue, searchPossibleValues,
|
||||
type Completion, type CompletionResult, type Criterion, type FilterValue, type Operator, type QueryFilter,
|
||||
} from '@qrun-io/qqq-core'
|
||||
import type { FieldMetaData, TableMetaData } from '../client'
|
||||
import type { Key } from '../keys'
|
||||
import { charOf } from '../keys'
|
||||
import { LineEditor } from '../line-editor'
|
||||
import type { AppStore } from '../store'
|
||||
import { glyphs, theme } from '../theme'
|
||||
import type { Overlay } from '../types'
|
||||
import { errorText, pad, shellQuote, trunc } from '../util/text'
|
||||
import { ChoiceOverlay, EditorLine, PromptOverlay, overlayId, wrap } from './basic'
|
||||
import { Frame, Line } from './frame'
|
||||
|
||||
type Sort = { field: string; asc: boolean } | undefined
|
||||
|
||||
type GuidedRow =
|
||||
| { kind: 'crit'; group: number; index: number }
|
||||
| { kind: 'group'; group: number }
|
||||
| { kind: 'add'; group: number }
|
||||
| { kind: 'addGroup' }
|
||||
| { kind: 'join' }
|
||||
| { kind: 'sort' }
|
||||
|
||||
function clone(f: QueryFilter): QueryFilter {
|
||||
return JSON.parse(JSON.stringify(f)) as QueryFilter
|
||||
}
|
||||
|
||||
export class FilterOverlay implements Overlay {
|
||||
readonly id = overlayId('filter')
|
||||
mode: 'guided' | 'advanced' = 'guided'
|
||||
filter: QueryFilter
|
||||
sort: Sort
|
||||
row = 0
|
||||
/** Labels of possible-value ids, for display: `${field}\u0000${id}` -> label. */
|
||||
labels = new Map<string, string>()
|
||||
readonly text = new LineEditor('')
|
||||
completion?: CompletionResult
|
||||
candidate = 0
|
||||
parseError?: { message: string; at?: number }
|
||||
historyIndex = -1
|
||||
historySearch?: LineEditor
|
||||
private valueCache = new Map<string, Completion[]>()
|
||||
|
||||
constructor(private store: AppStore, readonly meta: TableMetaData, filter: QueryFilter, sort: Sort, private onApply: (filter: QueryFilter, sort: Sort | null) => void) {
|
||||
this.filter = clone({ ...filter, criteria: filter.criteria ?? [], booleanOperator: filter.booleanOperator ?? 'AND' })
|
||||
this.sort = sort
|
||||
this.collectLabels()
|
||||
}
|
||||
|
||||
fields(): FieldMetaData[] {
|
||||
return Object.values(this.meta.fields).filter((f) => !f.isHidden)
|
||||
}
|
||||
|
||||
field(name: string): FieldMetaData | undefined {
|
||||
return this.meta.fields[name]
|
||||
}
|
||||
|
||||
/** Looks up labels for possible-value ids already in the filter. */
|
||||
collectLabels(): void {
|
||||
const all = [...this.filter.criteria, ...(this.filter.subFilters ?? []).flatMap((s) => s.criteria)]
|
||||
for (const c of all) {
|
||||
const f = this.field(c.fieldName)
|
||||
const ids = c.values.filter((v): v is string => typeof v === 'string')
|
||||
if (!f?.possibleValueSourceName || !ids.length) continue
|
||||
searchPossibleValues(this.store.client!.server, this.store.client!.fetch, { table: this.meta.name }, f.name, { ids, useCase: 'FILTER' })
|
||||
.then((opts) => { for (const o of opts) this.labels.set(`${f.name}\u0000${String(o.id)}`, o.label); this.store.bump() })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- guided ----
|
||||
|
||||
groups(): QueryFilter[] {
|
||||
return this.filter.subFilters ?? []
|
||||
}
|
||||
|
||||
rows(): GuidedRow[] {
|
||||
const out: GuidedRow[] = []
|
||||
this.filter.criteria.forEach((_, i) => out.push({ kind: 'crit', group: -1, index: i }))
|
||||
out.push({ kind: 'add', group: -1 })
|
||||
this.groups().forEach((g, gi) => {
|
||||
out.push({ kind: 'group', group: gi })
|
||||
g.criteria.forEach((_, i) => out.push({ kind: 'crit', group: gi, index: i }))
|
||||
out.push({ kind: 'add', group: gi })
|
||||
})
|
||||
out.push({ kind: 'addGroup' }, { kind: 'join' }, { kind: 'sort' })
|
||||
return out
|
||||
}
|
||||
|
||||
groupOf(gi: number): QueryFilter {
|
||||
return gi < 0 ? this.filter : this.groups()[gi]!
|
||||
}
|
||||
|
||||
valueText(c: Criterion): string {
|
||||
return c.values.map((v) => (typeof v === 'string' ? this.labels.get(`${c.fieldName}\u0000${v}`) ?? v : printValue(v))).join(', ')
|
||||
}
|
||||
|
||||
critText(c: Criterion): string {
|
||||
const f = this.field(c.fieldName)
|
||||
const op = c.operator.toLowerCase().replace(/_/g, ' ')
|
||||
return `${f?.label ?? c.fieldName} ${op}${c.values.length ? ` ${this.valueText(c)}` : ''}`
|
||||
}
|
||||
|
||||
/** Field, then operator, then value; calls back with the criterion. */
|
||||
buildCriterion(start: Partial<Criterion>, done: (c: Criterion) => void): void {
|
||||
const pickField = (next: (f: FieldMetaData) => void) => {
|
||||
if (start.fieldName && this.field(start.fieldName)) return next(this.field(start.fieldName)!)
|
||||
this.store.open(new ChoiceOverlay(this.store, 'Filter on which field?', this.fields().map((f) => ({ label: f.label, value: f, detail: `${f.name} · ${f.possibleValueSourceName ? 'choice' : f.type.toLowerCase()}` })), next))
|
||||
}
|
||||
pickField((f) => {
|
||||
const pickOp = (next: (op: Operator) => void) => {
|
||||
if (start.operator && operatorsForField(f).includes(start.operator)) return next(start.operator)
|
||||
this.store.open(new ChoiceOverlay(this.store, `${f.label}: which comparison?`, operatorsForField(f).map((o) => ({ label: o.toLowerCase().replace(/_/g, ' '), value: o, detail: o })), next))
|
||||
}
|
||||
pickOp((op) => {
|
||||
if (UNARY_OPERATORS.has(op)) return done({ fieldName: f.name, operator: op, values: [] })
|
||||
this.editValues(f, op, start.values ?? [], (values) => done({ fieldName: f.name, operator: op, values }))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** The value editor that fits the field and operator. */
|
||||
editValues(f: FieldMetaData, op: Operator, current: FilterValue[], done: (values: FilterValue[]) => void): void {
|
||||
const list = LIST_OPERATORS.has(op)
|
||||
const between = op === 'BETWEEN' || op === 'NOT_BETWEEN'
|
||||
if (f.possibleValueSourceName && !between) {
|
||||
const pick = (acc: FilterValue[]) => {
|
||||
const term = new LineEditor('')
|
||||
const overlay = new ChoiceOverlay<string | null>(this.store, `${f.label}${list ? ` (${acc.length} chosen; Esc when done)` : ''}`, [], (id) => {
|
||||
if (id === null) return done(acc)
|
||||
const next = list ? [...acc, id] : [id]
|
||||
if (list) pick(next)
|
||||
else done(next)
|
||||
}, 'type to search', 0, () => { if (list && acc.length) done(acc) })
|
||||
void term
|
||||
const load = (search: string) => {
|
||||
searchPossibleValues(this.store.client!.server, this.store.client!.fetch, { table: this.meta.name }, f.name, { searchTerm: search, useCase: 'FILTER', limit: 100 }).then((opts) => {
|
||||
overlay.choices.splice(0, overlay.choices.length, ...(list && acc.length ? [{ label: `Done (${acc.length} chosen)`, value: null }] : []), ...opts.map((o) => {
|
||||
this.labels.set(`${f.name}\u0000${String(o.id)}`, o.label)
|
||||
return { label: o.label, value: String(o.id), detail: String(o.id) }
|
||||
}))
|
||||
this.store.bump()
|
||||
}).catch((e) => this.store.flash(errorText(e), 'error'))
|
||||
}
|
||||
const original = overlay.onKey.bind(overlay)
|
||||
overlay.onKey = (key: Key) => {
|
||||
const before = overlay.filter.value
|
||||
original(key)
|
||||
if (overlay.filter.value !== before) load(overlay.filter.value)
|
||||
}
|
||||
// the choice list filters locally too; server search narrows big sources
|
||||
overlay.matches = () => overlay.choices
|
||||
load('')
|
||||
this.store.open(overlay)
|
||||
}
|
||||
return pick(list ? current : [])
|
||||
}
|
||||
const type = f.type.toUpperCase()
|
||||
const isDate = type === 'DATE' || type === 'DATE_TIME' || type === 'TIME'
|
||||
const prompt = (initial: string) => this.store.open(new PromptOverlay(this.store, `${f.label} ${op.toLowerCase().replace(/_/g, ' ')}`, between ? 'Two values separated by a comma: from,to. Expressions work: now(-7d), startOf(month).' : list ? 'Values separated by commas; quote values that contain commas.' : isDate ? 'A date (YYYY-MM-DD) or an expression: now(-7d), thisPeriod(month), startOf(year).' : 'The value.', (text) => {
|
||||
try {
|
||||
const c = parseFilter(`${f.name} ${op} ${text}`).criteria[0]!
|
||||
done(c.values)
|
||||
} catch (e) {
|
||||
this.store.flash(errorText(e), 'error')
|
||||
}
|
||||
}, initial))
|
||||
const initial = current.map(printValue).join(',')
|
||||
if (isDate && !list) {
|
||||
const presets = expressionCandidates(type).map((c) => ({ label: c.detail ?? c.value, value: c.value, detail: c.value }))
|
||||
this.store.open(new ChoiceOverlay(this.store, `${f.label}: when?`, [{ label: 'Type a date or an expression…', value: '', detail: 'YYYY-MM-DD' }, ...presets], (v) => (v ? done([parseValue(v)]) : prompt(initial))))
|
||||
return
|
||||
}
|
||||
prompt(initial)
|
||||
}
|
||||
|
||||
editRow(r: GuidedRow): void {
|
||||
if (r.kind === 'crit') {
|
||||
const g = this.groupOf(r.group)
|
||||
const c = g.criteria[r.index]!
|
||||
const f = this.field(c.fieldName)
|
||||
this.store.open(new ChoiceOverlay(this.store, this.critText(c), [
|
||||
{ label: 'Change the value', value: 'value', disabled: UNARY_OPERATORS.has(c.operator) ? 'this comparison has no value' : undefined },
|
||||
{ label: 'Change the comparison', value: 'op' },
|
||||
{ label: 'Change the field', value: 'field' },
|
||||
{ label: 'Remove this condition', value: 'remove' },
|
||||
], (what) => {
|
||||
if (what === 'remove') g.criteria.splice(r.index, 1)
|
||||
else if (what === 'value' && f) this.editValues(f, c.operator, c.values, (values) => { c.values = values; this.store.bump() })
|
||||
else if (what === 'op') this.buildCriterion({ fieldName: c.fieldName }, (n) => { g.criteria[r.index] = n; this.store.bump() })
|
||||
else this.buildCriterion({}, (n) => { g.criteria[r.index] = n; this.store.bump() })
|
||||
}))
|
||||
} else if (r.kind === 'add') {
|
||||
this.buildCriterion({}, (c) => { this.groupOf(r.group).criteria.push(c); this.store.bump() })
|
||||
} else if (r.kind === 'addGroup') {
|
||||
this.filter.subFilters = [...this.groups(), { criteria: [], booleanOperator: this.filter.booleanOperator === 'AND' ? 'OR' : 'AND' }]
|
||||
this.buildCriterion({}, (c) => { this.groups()[this.groups().length - 1]!.criteria.push(c); this.store.bump() })
|
||||
} else if (r.kind === 'group' || r.kind === 'join') {
|
||||
const g = r.kind === 'group' ? this.groupOf(r.group) : this.filter
|
||||
g.booleanOperator = g.booleanOperator === 'AND' ? 'OR' : 'AND'
|
||||
} else if (r.kind === 'sort') {
|
||||
this.store.open(new ChoiceOverlay(this.store, 'Sort by', [{ label: '(default order)', value: '' }, ...this.fields().map((f) => ({ label: f.label, value: f.name, detail: f.name }))], (field) => {
|
||||
if (!field) {
|
||||
this.sort = undefined
|
||||
return
|
||||
}
|
||||
this.store.open(new ChoiceOverlay(this.store, 'Direction', [{ label: 'Ascending', value: true }, { label: 'Descending', value: false }], (asc) => { this.sort = { field, asc }; this.store.bump() }))
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
removeRow(r: GuidedRow): void {
|
||||
if (r.kind === 'crit') this.groupOf(r.group).criteria.splice(r.index, 1)
|
||||
else if (r.kind === 'group') this.filter.subFilters = this.groups().filter((_, i) => i !== r.group)
|
||||
else if (r.kind === 'sort') this.sort = undefined
|
||||
}
|
||||
|
||||
/** Empty groups are dropped before printing or applying. */
|
||||
normalized(): QueryFilter {
|
||||
const f = clone(this.filter)
|
||||
f.subFilters = (f.subFilters ?? []).filter((g) => g.criteria.length || g.subFilters?.length)
|
||||
if (!f.subFilters.length) delete f.subFilters
|
||||
return f
|
||||
}
|
||||
|
||||
// ---- advanced ----
|
||||
|
||||
toAdvanced(): void {
|
||||
this.text.set(printFilter(this.normalized()))
|
||||
this.mode = 'advanced'
|
||||
this.complete()
|
||||
}
|
||||
|
||||
toGuided(): void {
|
||||
const parsed = this.parse()
|
||||
if (!parsed) return this.store.flash('fix the filter text first (or Ctrl+U clears it)', 'error')
|
||||
this.filter = parsed
|
||||
this.collectLabels()
|
||||
this.mode = 'guided'
|
||||
this.row = 0
|
||||
}
|
||||
|
||||
parse(): QueryFilter | undefined {
|
||||
const text = this.text.value.trim()
|
||||
if (!text) {
|
||||
this.parseError = undefined
|
||||
return { criteria: [], booleanOperator: 'AND' }
|
||||
}
|
||||
try {
|
||||
const f = parseFilter(text, Object.keys(this.meta.fields))
|
||||
this.parseError = undefined
|
||||
return f
|
||||
} catch (e) {
|
||||
this.parseError = { message: e instanceof Error ? e.message : String(e), at: locateFilterError(text, e) }
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
complete(): void {
|
||||
const fields = this.fields().map((f) => ({ name: f.name, label: f.label, type: f.type, possibleValueSourceName: f.possibleValueSourceName, allowedFilterOperators: f.allowedFilterOperators as string[] | undefined }))
|
||||
this.completion = completeFilter(this.text.value, this.text.cursor, fields, {
|
||||
values: (f, prefix) => {
|
||||
const key = `${f.name}\u0000${prefix.toLowerCase()}`
|
||||
if (!this.valueCache.has(key)) {
|
||||
this.valueCache.set(key, [])
|
||||
searchPossibleValues(this.store.client!.server, this.store.client!.fetch, { table: this.meta.name }, f.name, { searchTerm: prefix, useCase: 'FILTER', limit: 20 }).then((opts) => {
|
||||
this.valueCache.set(key, opts.map((o) => ({ value: o.label, label: o.label, kind: 'value' as const, detail: String(o.id) })))
|
||||
if (this.mode === 'advanced') {
|
||||
this.complete()
|
||||
this.store.bump()
|
||||
}
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
return this.valueCache.get(key) ?? []
|
||||
},
|
||||
})
|
||||
this.candidate = 0
|
||||
this.parse()
|
||||
}
|
||||
|
||||
/** Turns possible-value labels typed in the text into ids (the server filters by id). */
|
||||
async resolveLabels(filter: QueryFilter): Promise<QueryFilter> {
|
||||
const visit = async (f: QueryFilter) => {
|
||||
for (const c of f.criteria) {
|
||||
const field = this.field(c.fieldName)
|
||||
if (!field?.possibleValueSourceName) continue
|
||||
const out: FilterValue[] = []
|
||||
for (const v of c.values) {
|
||||
if (typeof v !== 'string') {
|
||||
out.push(v)
|
||||
continue
|
||||
}
|
||||
const { option } = await resolvePossibleValue(this.store.client!.server, this.store.client!.fetch, { table: this.meta.name }, field.name, v, { fieldType: field.type })
|
||||
out.push(option ? String(option.id) : v)
|
||||
}
|
||||
c.values = out
|
||||
}
|
||||
for (const s of f.subFilters ?? []) await visit(s)
|
||||
}
|
||||
const copy = clone(filter)
|
||||
await visit(copy)
|
||||
return copy
|
||||
}
|
||||
|
||||
apply(): void {
|
||||
if (this.mode === 'advanced') {
|
||||
const parsed = this.parse()
|
||||
if (!parsed) return
|
||||
this.store.filterHistory.add(this.text.value)
|
||||
this.store.run(this.resolveLabels(parsed), (resolved) => {
|
||||
this.store.closeOverlay(this)
|
||||
this.onApply(resolved, this.sort ?? null)
|
||||
})
|
||||
return
|
||||
}
|
||||
const f = this.normalized()
|
||||
if (printFilter(f)) this.store.filterHistory.add(printFilter(f))
|
||||
this.store.closeOverlay(this)
|
||||
this.onApply(f, this.sort ?? null)
|
||||
}
|
||||
|
||||
onKey(key: Key): void {
|
||||
if (key.name === 'escape') {
|
||||
if (this.historySearch) {
|
||||
this.historySearch = undefined
|
||||
return
|
||||
}
|
||||
return this.store.closeOverlay(this)
|
||||
}
|
||||
if (key.ctrl && key.name === 's') return this.apply()
|
||||
if (this.mode === 'guided') return this.guidedKey(key)
|
||||
return this.advancedKey(key)
|
||||
}
|
||||
|
||||
guidedKey(key: Key): void {
|
||||
const rows = this.rows()
|
||||
const r = rows[this.row]
|
||||
const ch = charOf(key)
|
||||
if (key.name === 'tab') return this.toAdvanced()
|
||||
if (key.name === 'down') this.row = Math.min(rows.length - 1, this.row + 1)
|
||||
else if (key.name === 'up') this.row = Math.max(0, this.row - 1)
|
||||
else if (key.name === 'home') this.row = 0
|
||||
else if (key.name === 'end') this.row = rows.length - 1
|
||||
else if (key.name === 'return') {
|
||||
if (r && (r.kind === 'add' || r.kind === 'addGroup' || r.kind === 'sort' || r.kind === 'join' || r.kind === 'group')) this.editRow(r)
|
||||
else this.apply()
|
||||
} else if (ch === 'e' || key.name === 'right' || key.name === 'space') {
|
||||
if (r) this.editRow(r)
|
||||
} else if (ch === 'a' || key.name === 'insert') this.editRow({ kind: 'add', group: r && 'group' in r ? r.group : -1 })
|
||||
else if (ch === 'd' || key.name === 'delete' || key.name === 'backspace') {
|
||||
if (r) this.removeRow(r)
|
||||
this.row = Math.min(this.row, this.rows().length - 1)
|
||||
} else if (ch === 'o') {
|
||||
const g = r && 'group' in r && r.group >= 0 ? this.groupOf(r.group) : this.filter
|
||||
g.booleanOperator = g.booleanOperator === 'AND' ? 'OR' : 'AND'
|
||||
} else if (ch === 'g') this.editRow({ kind: 'addGroup' })
|
||||
else if (ch === 's') this.editRow({ kind: 'sort' })
|
||||
else if (ch === 'c' || (key.ctrl && key.name === 'u')) {
|
||||
this.filter = { criteria: [], booleanOperator: 'AND' }
|
||||
this.row = 0
|
||||
}
|
||||
}
|
||||
|
||||
advancedKey(key: Key): void {
|
||||
if (this.historySearch) {
|
||||
if (key.name === 'return' || (key.ctrl && key.name === 'r')) {
|
||||
const hit = this.store.filterHistory.search(this.historySearch.value)[0]
|
||||
if (hit) this.text.set(hit)
|
||||
this.historySearch = undefined
|
||||
this.complete()
|
||||
return
|
||||
}
|
||||
this.historySearch.handle(key)
|
||||
return
|
||||
}
|
||||
const cands = this.completion?.candidates ?? []
|
||||
if (key.ctrl && key.name === 'r') {
|
||||
this.historySearch = new LineEditor('')
|
||||
return
|
||||
}
|
||||
if (key.name === 'tab') {
|
||||
if (key.shift) return this.toGuided()
|
||||
const c = cands[this.candidate]
|
||||
if (c && this.completion) {
|
||||
const next = applyCompletion(this.text.value, this.completion, c)
|
||||
this.text.value = next.text
|
||||
this.text.cursor = next.cursor
|
||||
this.complete()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.name === 'down' || key.name === 'up') {
|
||||
if (cands.length && this.text.value.trim()) {
|
||||
this.candidate = (this.candidate + (key.name === 'down' ? 1 : cands.length - 1)) % cands.length
|
||||
} else {
|
||||
const hist = this.store.filterHistory.entries()
|
||||
this.historyIndex = Math.max(-1, Math.min(hist.length - 1, this.historyIndex + (key.name === 'up' ? 1 : -1)))
|
||||
this.text.set(this.historyIndex >= 0 ? hist[this.historyIndex]! : '')
|
||||
this.complete()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (key.name === 'return') return this.apply()
|
||||
if (key.ctrl && key.name === 'g') return this.toGuided()
|
||||
if (this.text.handle(key)) this.complete()
|
||||
}
|
||||
|
||||
render(sw: number, sh: number) {
|
||||
const t = theme()
|
||||
const g = glyphs()
|
||||
const width = Math.min(sw - 2, 110)
|
||||
const inner = width - 2
|
||||
const height = Math.min(sh - 2, 30)
|
||||
const lines: React.ReactNode[] = []
|
||||
const tabs = <text key="tabs"><span fg={this.mode === 'guided' ? t.selFg : t.fg} bg={this.mode === 'guided' ? t.selBg : undefined}>{' Guided '}</span><span fg={t.dim}>{' │ '}</span><span fg={this.mode === 'advanced' ? t.selFg : t.fg} bg={this.mode === 'advanced' ? t.selBg : undefined}>{' Advanced '}</span><span fg={t.dim}>{pad(' Tab switches', inner - 23)}</span></text>
|
||||
lines.push(tabs)
|
||||
lines.push(<Line key="sp" text="" width={inner} />)
|
||||
let grammar = ''
|
||||
let cli = ''
|
||||
if (this.mode === 'guided') {
|
||||
const rows = this.rows()
|
||||
const body = height - 9
|
||||
const top = Math.max(0, Math.min(this.row - Math.floor(body / 2), rows.length - body))
|
||||
rows.slice(top, top + body).forEach((r, i) => {
|
||||
const sel = top + i === this.row
|
||||
let text = ''
|
||||
let fg = t.fg
|
||||
if (r.kind === 'crit') {
|
||||
const c = this.groupOf(r.group).criteria[r.index]!
|
||||
const join = r.index > 0 ? this.groupOf(r.group).booleanOperator.padEnd(4) : ' '
|
||||
text = `${r.group >= 0 ? ' ' : ''}${join}${this.critText(c)}`
|
||||
} else if (r.kind === 'add') {
|
||||
text = `${r.group >= 0 ? ' ' : ''} + add a condition${r.group >= 0 ? ' to this group' : ''}`
|
||||
fg = t.acc
|
||||
} else if (r.kind === 'group') {
|
||||
text = `${r.group > 0 || this.filter.criteria.length ? this.filter.booleanOperator.padEnd(4) : ' '}( group, joined by ${this.groupOf(r.group).booleanOperator} ) d removes the group`
|
||||
fg = t.acc2
|
||||
} else if (r.kind === 'addGroup') {
|
||||
text = ' + add a group (conditions in parentheses)'
|
||||
fg = t.acc
|
||||
} else if (r.kind === 'join') {
|
||||
text = ` conditions are joined by ${this.filter.booleanOperator} (Enter or o switches to ${this.filter.booleanOperator === 'AND' ? 'OR' : 'AND'})`
|
||||
fg = t.dim
|
||||
} else {
|
||||
text = ` sort: ${this.sort ? `${this.field(this.sort.field)?.label ?? this.sort.field} ${this.sort.asc ? 'ascending' : 'descending'}` : 'default order'}`
|
||||
fg = t.dim
|
||||
}
|
||||
lines.push(<text key={`r${i}`} fg={sel ? t.selFg : fg} bg={sel ? t.selBg : undefined}>{pad(`${sel ? g.pointer : ' '}${text}`, inner)}</text>)
|
||||
})
|
||||
for (let i = lines.length; i < body + 2; i++) lines.push(<Line key={`p${i}`} text="" width={inner} />)
|
||||
grammar = printFilter(this.normalized())
|
||||
lines.push(<Line key="help" text=" a add · e edit · d remove · o AND/OR · g group · s sort · c clear · Enter applies · Esc cancels" width={inner} fg={t.dim} />)
|
||||
} else {
|
||||
lines.push(<box key="ed" paddingLeft={1}><EditorLine editor={this.text} width={inner - 2} placeholder="lastName STARTS_WITH S AND (birthDate > now(-40y) OR email IS_BLANK)" /></box>)
|
||||
if (this.historySearch) lines.push(<text key="hs" fg={t.acc2}>{pad(` history search: ${this.historySearch.value}_ → ${this.store.filterHistory.search(this.historySearch.value)[0] ?? '(no match)'}`, inner)}</text>)
|
||||
else if (this.parseError) {
|
||||
const at = this.parseError.at
|
||||
lines.push(<text key="caret" fg={t.err}>{pad(at !== undefined ? ' ' + ' '.repeat(at) + '^' : '', inner)}</text>)
|
||||
} else lines.push(<Line key="caret" text="" width={inner} />)
|
||||
const cands = this.completion?.candidates ?? []
|
||||
const body = height - 11
|
||||
const ctop = Math.max(0, Math.min(this.candidate - Math.floor(body / 2), cands.length - body))
|
||||
lines.push(<Line key="ex" text={` expecting ${this.completion?.expecting ?? 'field'}${this.completion?.field ? ` (${this.completion.field})` : ''} · Tab accepts · ↑↓ choose`} width={inner} fg={t.dim} />)
|
||||
cands.slice(ctop, ctop + body).forEach((c, i) => {
|
||||
const sel = ctop + i === this.candidate
|
||||
lines.push(<text key={`c${i}`} fg={sel ? t.selFg : c.kind === 'keyword' ? t.acc2 : t.fg} bg={sel ? t.selBg : undefined}>{pad(` ${pad(c.label, 28)} ${c.detail ?? ''}`, inner)}</text>)
|
||||
})
|
||||
for (let i = Math.min(body, cands.length - ctop); i < body; i++) lines.push(<Line key={`q${i}`} text="" width={inner} />)
|
||||
const parsed = this.parseError ? undefined : this.parse()
|
||||
grammar = this.text.value.trim()
|
||||
lines.push(<text key="pv" fg={this.parseError ? t.err : t.ok}>{pad(this.parseError ? ` ${trunc(this.parseError.message, inner - 2)}` : ` ${parsed ? describeFilter(parsed) : ''}`, inner)}</text>)
|
||||
lines.push(<Line key="help" text=" Enter applies · Shift+Tab or Ctrl+G guided · Ctrl+R history · ↑↓ history when empty · Esc cancels" width={inner} fg={t.dim} />)
|
||||
}
|
||||
cli = `qqq table query ${this.meta.name}${grammar ? ` -g ${shellQuote(grammar)}` : ''}${this.sort ? ` -o ${this.sort.asc ? '' : '-'}${this.sort.field}` : ''}`
|
||||
lines.push(<text key="gram" fg={t.acc}>{pad(` ${trunc(grammar || '(no criteria: every record)', inner - 2)}`, inner)}</text>)
|
||||
lines.push(<text key="cli" fg={t.dim}>{pad(` ${trunc(cli, inner - 2)}`, inner)}</text>)
|
||||
return <Frame title={`Filter ${this.meta.label}`} width={width} height={lines.length + 2} screenWidth={sw} screenHeight={sh} top={1}>{lines}</Frame>
|
||||
}
|
||||
}
|
||||
|
||||
export { printCriterion, wrap, FilterSyntaxError }
|
||||
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import { glyphs, theme } from '../theme'
|
||||
|
||||
/** A centred modal frame drawn over the screen. */
|
||||
export function Frame(props: { title: string; width: number; height?: number; screenWidth: number; screenHeight: number; children: ReactNode; top?: number }) {
|
||||
const t = theme()
|
||||
const left = Math.max(0, Math.floor((props.screenWidth - props.width) / 2))
|
||||
const top = props.top ?? Math.max(1, Math.floor((props.screenHeight - (props.height ?? 10)) / 3))
|
||||
return (
|
||||
<box position="absolute" left={left} top={top} width={props.width} {...(props.height ? { height: props.height } : {})} flexDirection="column"
|
||||
border borderStyle="double" borderColor={t.focusBorder} {...(glyphs().border ? { customBorderChars: glyphs().border } : {})}
|
||||
backgroundColor={t.overlayBg} title={` ${props.title} `} zIndex={20}>
|
||||
{props.children}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
/** One line of text with a background, padded to the width. */
|
||||
export function Line(props: { text: string; width: number; fg?: string; bg?: string; bold?: boolean }) {
|
||||
const text = props.text.length >= props.width ? props.text.slice(0, props.width) : props.text + ' '.repeat(props.width - props.text.length)
|
||||
return <text fg={props.fg ?? theme().fg} bg={props.bg}>{props.bold ? <strong>{text}</strong> : text}</text>
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { getDependencies, type FormSession, type PossibleValue } from '@qrun-io/qqq-core'
|
||||
import type { Key } from '../keys'
|
||||
import { LineEditor } from '../line-editor'
|
||||
import type { AppStore } from '../store'
|
||||
import { theme } from '../theme'
|
||||
import type { Overlay } from '../types'
|
||||
import { errorText, pad } from '../util/text'
|
||||
import { EditorLine, overlayId } from './basic'
|
||||
import { Frame, Line } from './frame'
|
||||
|
||||
/**
|
||||
* A possible-value picker: searches the field's choices on the server as you type, honouring the
|
||||
* values of the fields it depends on (contingent fields narrow: country, then state, then city).
|
||||
*/
|
||||
export class PickerOverlay implements Overlay {
|
||||
readonly id = overlayId('picker')
|
||||
readonly search: LineEditor
|
||||
options: PossibleValue[] = []
|
||||
index = 0
|
||||
loading = false
|
||||
error?: string
|
||||
private timer?: ReturnType<typeof setTimeout>
|
||||
private generation = 0
|
||||
|
||||
constructor(private store: AppStore, private session: FormSession, readonly field: string, private onPick: (option: PossibleValue | null) => void, initial = '') {
|
||||
this.search = new LineEditor(initial)
|
||||
this.fetch()
|
||||
}
|
||||
|
||||
fetch(): void {
|
||||
const gen = ++this.generation
|
||||
this.loading = true
|
||||
this.session.options(this.field, this.search.value, 100).then((options) => {
|
||||
if (gen !== this.generation) return
|
||||
this.options = options
|
||||
this.index = Math.min(this.index, Math.max(0, options.length - 1))
|
||||
this.loading = false
|
||||
this.error = undefined
|
||||
this.store.bump()
|
||||
}).catch((e) => {
|
||||
if (gen !== this.generation) return
|
||||
this.loading = false
|
||||
this.error = errorText(e)
|
||||
this.store.bump()
|
||||
})
|
||||
}
|
||||
|
||||
onKey(key: Key): void {
|
||||
if (key.name === 'escape') return this.store.closeOverlay(this)
|
||||
if (key.name === 'down') this.index = Math.min(this.options.length - 1, this.index + 1)
|
||||
else if (key.name === 'up') this.index = Math.max(0, this.index - 1)
|
||||
else if (key.name === 'pagedown') this.index = Math.min(this.options.length - 1, this.index + 10)
|
||||
else if (key.name === 'pageup') this.index = Math.max(0, this.index - 10)
|
||||
else if (key.name === 'return') {
|
||||
const o = this.options[this.index]
|
||||
if (!o) return
|
||||
this.store.closeOverlay(this)
|
||||
this.onPick(o)
|
||||
} else if (key.ctrl && key.name === 'd') {
|
||||
this.store.closeOverlay(this)
|
||||
this.onPick(null)
|
||||
} else if (this.search.handle(key)) {
|
||||
this.index = 0
|
||||
clearTimeout(this.timer)
|
||||
this.timer = setTimeout(() => this.fetch(), 150)
|
||||
}
|
||||
}
|
||||
|
||||
render(sw: number, sh: number) {
|
||||
const t = theme()
|
||||
const f = this.session.fields[this.field]!
|
||||
const deps = getDependencies(f).filter((d) => this.session.values[d] !== undefined && this.session.values[d] !== null && this.session.values[d] !== '')
|
||||
const narrowed = deps.map((d) => `${this.session.fields[d]?.label ?? d} = ${this.session.displayValues[d] ?? String(this.session.values[d])}`).join(', ')
|
||||
const width = Math.min(sw - 4, 70)
|
||||
const rows = Math.max(4, Math.min(16, sh - 12))
|
||||
const top = Math.max(0, Math.min(this.index - Math.floor(rows / 2), this.options.length - rows))
|
||||
const shown = this.options.slice(top, top + rows)
|
||||
return (
|
||||
<Frame title={`Choose ${f.label}`} width={width} height={rows + 6} screenWidth={sw} screenHeight={sh}>
|
||||
<box paddingLeft={1}><EditorLine editor={this.search} width={width - 4} placeholder="type to search" /></box>
|
||||
<Line text={narrowed ? ` narrowed by ${narrowed}` : ''} width={width - 2} fg={t.dim} />
|
||||
{shown.map((o, i) => {
|
||||
const sel = top + i === this.index
|
||||
return <text key={String(o.id)} fg={sel ? t.selFg : t.fg} bg={sel ? t.selBg : undefined}>{pad(` ${o.label}`, width - 2)}</text>
|
||||
})}
|
||||
{Array.from({ length: rows - shown.length }, (_, i) => <Line key={`e${i}`} text={i === 0 && !shown.length ? (this.error ? ` ${this.error}` : this.loading ? ' searching…' : ' (no choices)') : ''} width={width - 2} fg={this.error ? t.err : t.dim} />)}
|
||||
<Line text={` ${this.options.length} shown · ↑↓ Enter picks · Ctrl+D clears · Esc`} width={width - 2} fg={t.dim} />
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { readViewSettings, writeViewSettings, type ViewSettings } from '@qrun-io/qqq-core'
|
||||
import type { TableMetaData } from '../client'
|
||||
import type { Key } from '../keys'
|
||||
import { charOf } from '../keys'
|
||||
import type { AppStore } from '../store'
|
||||
import { glyphs, theme } from '../theme'
|
||||
import type { Overlay } from '../types'
|
||||
import { pad } from '../util/text'
|
||||
import { PromptOverlay, overlayId } from './basic'
|
||||
import { Frame, Line } from './frame'
|
||||
|
||||
/**
|
||||
* How a grid is shown, saved per table in the local config file: which columns and in what order,
|
||||
* column widths, fit to content or fill the width, zebra stripes, the header rule, density.
|
||||
*/
|
||||
export class GridViewSettingsOverlay implements Overlay {
|
||||
readonly id = overlayId('view')
|
||||
view: ViewSettings
|
||||
order: string[]
|
||||
visible: Set<string>
|
||||
row = 0
|
||||
|
||||
constructor(private store: AppStore, readonly meta: TableMetaData, readonly key: string, current: string[], private onDone: () => void) {
|
||||
this.view = readViewSettings(key)
|
||||
const all = Object.values(meta.fields).filter((f) => !f.isHidden).map((f) => f.name)
|
||||
this.order = [...current, ...all.filter((n) => !current.includes(n))]
|
||||
this.visible = new Set(current)
|
||||
}
|
||||
|
||||
options(): Array<{ label: string; value: string; toggle: () => void }> {
|
||||
const t = theme()
|
||||
return [
|
||||
{ label: 'Width use', value: this.view.fit === 'fill' ? 'fill the terminal width' : 'fit to content', toggle: () => { this.view.fit = this.view.fit === 'fill' ? 'content' : 'fill' } },
|
||||
{ label: 'Zebra stripes', value: (this.view.zebra ?? t.zebraByDefault) ? 'on' : 'off', toggle: () => { this.view.zebra = !(this.view.zebra ?? t.zebraByDefault) } },
|
||||
{ label: 'Header rule', value: (this.view.headerRule ?? true) ? 'on' : 'off', toggle: () => { this.view.headerRule = !(this.view.headerRule ?? true) } },
|
||||
{ label: 'Density', value: this.view.density === 'comfortable' ? 'comfortable (blank line between rows)' : 'compact', toggle: () => { this.view.density = this.view.density === 'comfortable' ? 'compact' : 'comfortable' } },
|
||||
]
|
||||
}
|
||||
|
||||
save(): void {
|
||||
const columns = this.order.filter((n) => this.visible.has(n))
|
||||
writeViewSettings(this.key, { ...this.view, columns: columns.length ? columns : undefined })
|
||||
this.store.closeOverlay(this)
|
||||
this.onDone()
|
||||
}
|
||||
|
||||
onKey(key: Key): void {
|
||||
const opts = this.options()
|
||||
const total = this.order.length + opts.length
|
||||
const ch = charOf(key)
|
||||
const onColumn = this.row < this.order.length
|
||||
const name = this.order[this.row]
|
||||
const move = (dir: number) => {
|
||||
const j = this.row + dir
|
||||
if (!onColumn || j < 0 || j >= this.order.length) return
|
||||
;[this.order[this.row], this.order[j]] = [this.order[j]!, this.order[this.row]!]
|
||||
this.row = j
|
||||
}
|
||||
const width = (delta: number) => {
|
||||
if (!name) return
|
||||
const w = this.view.widths?.[name] ?? Math.max(this.meta.fields[name]!.label.length + 1, 8)
|
||||
this.view.widths = { ...(this.view.widths ?? {}), [name]: Math.max(3, Math.min(120, w + delta)) }
|
||||
}
|
||||
if (key.name === 'escape') return this.store.closeOverlay(this)
|
||||
if (key.name === 'return' || (key.ctrl && key.name === 's')) return this.save()
|
||||
if (key.shift && key.name === 'up') return move(-1)
|
||||
if (key.shift && key.name === 'down') return move(1)
|
||||
if (key.name === 'down') this.row = Math.min(total - 1, this.row + 1)
|
||||
else if (key.name === 'up') this.row = Math.max(0, this.row - 1)
|
||||
else if (key.name === 'pagedown') this.row = Math.min(total - 1, this.row + 10)
|
||||
else if (key.name === 'pageup') this.row = Math.max(0, this.row - 10)
|
||||
else if (key.name === 'space') {
|
||||
if (onColumn && name) {
|
||||
if (this.visible.has(name)) this.visible.delete(name)
|
||||
else this.visible.add(name)
|
||||
} else opts[this.row - this.order.length]?.toggle()
|
||||
} else if (ch === '+' || ch === ']') move(1)
|
||||
else if (ch === '-' || ch === '[') move(-1)
|
||||
else if (ch === '>' || key.name === 'right') width(1)
|
||||
else if (ch === '<' || key.name === 'left') width(-1)
|
||||
else if (ch === 'a' && name) {
|
||||
const widths = { ...(this.view.widths ?? {}) }
|
||||
delete widths[name]
|
||||
this.view.widths = widths
|
||||
} else if (ch === 'w' && name) {
|
||||
this.store.open(new PromptOverlay(this.store, `Width of ${this.meta.fields[name]!.label}`, 'Cells (3 to 120); empty fits the column to its content.', (v) => {
|
||||
const widths = { ...(this.view.widths ?? {}) }
|
||||
if (!v.trim()) delete widths[name]
|
||||
else if (/^\d+$/.test(v.trim())) widths[name] = Math.max(3, Math.min(120, Number(v.trim())))
|
||||
this.view.widths = widths
|
||||
}, String(this.view.widths?.[name] ?? '')))
|
||||
} else if (ch === 'r') {
|
||||
this.view = {}
|
||||
this.visible = new Set(this.order.slice(0, 0))
|
||||
writeViewSettings(this.key, undefined)
|
||||
this.store.closeOverlay(this)
|
||||
this.onDone()
|
||||
}
|
||||
}
|
||||
|
||||
render(sw: number, sh: number) {
|
||||
const t = theme()
|
||||
const g = glyphs()
|
||||
const width = Math.min(sw - 4, 80)
|
||||
const inner = width - 2
|
||||
const opts = this.options()
|
||||
const items = [
|
||||
...this.order.map((n, i) => ({ text: `${this.visible.has(n) ? g.check : g.uncheck} ${pad(this.meta.fields[n]!.label, 28)} ${this.view.widths?.[n] ? `width ${this.view.widths[n]}` : 'auto width'}`, idx: i })),
|
||||
...opts.map((o, i) => ({ text: ` ${pad(o.label, 28)} ${o.value}`, idx: this.order.length + i })),
|
||||
]
|
||||
const rows = Math.max(5, Math.min(items.length, sh - 10))
|
||||
const top = Math.max(0, Math.min(this.row - Math.floor(rows / 2), items.length - rows))
|
||||
return (
|
||||
<Frame title={`View settings · ${this.meta.label}`} width={width} height={rows + 5} screenWidth={sw} screenHeight={sh}>
|
||||
{items.slice(top, top + rows).map((it) => {
|
||||
const sel = it.idx === this.row
|
||||
const isOpt = it.idx >= this.order.length
|
||||
return <text key={it.idx} fg={sel ? t.selFg : isOpt ? t.acc : t.fg} bg={sel ? t.selBg : undefined}>{pad(` ${it.text}`, inner)}</text>
|
||||
})}
|
||||
<Line text="" width={inner} />
|
||||
<Line text=" Space on/off · Shift+↑↓ or +/- move · ←→ width · w type width · a auto width" width={inner} fg={t.dim} />
|
||||
<Line text=" r reset to defaults · Enter saves (kept for this table) · Esc cancels" width={inner} fg={t.dim} />
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Collaboration presence: "N others viewing" for a screen scope (`table:person`, `record:person:3`).
|
||||
* The interface the TUI draws from; the collaboration extension's kernel hook (presence over the
|
||||
* realtime stream, being built on another branch) plugs in through `setPresenceSource`. Without
|
||||
* the extension the source is a no-op and the title bar slot and footer segment stay empty.
|
||||
*/
|
||||
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import { Collaboration, type ExtensionRegistry, type PresenceHandle } from '@qrun-io/qqq-core'
|
||||
import type { TuiClient } from './client'
|
||||
|
||||
export interface PresencePerson {
|
||||
name: string
|
||||
since?: string
|
||||
}
|
||||
|
||||
export interface Presence {
|
||||
count: number
|
||||
people: PresencePerson[]
|
||||
}
|
||||
|
||||
export interface PresenceSource {
|
||||
/** Current presence of a scope. */
|
||||
get(scope: string): Presence
|
||||
/** Subscribes to changes; returns the unsubscribe function. */
|
||||
subscribe(listener: () => void): () => void
|
||||
/** Announces that this client views a scope (and stops for the previous one). */
|
||||
enter?(scope: string | undefined): void
|
||||
}
|
||||
|
||||
const NONE: Presence = { count: 0, people: [] }
|
||||
|
||||
const noop: PresenceSource = {
|
||||
get: () => NONE,
|
||||
subscribe: () => () => undefined,
|
||||
}
|
||||
|
||||
let source: PresenceSource = noop
|
||||
|
||||
export function setPresenceSource(next: PresenceSource | undefined): void {
|
||||
source = next ?? noop
|
||||
}
|
||||
|
||||
export function presenceSource(): PresenceSource {
|
||||
return source
|
||||
}
|
||||
|
||||
/** React hook: presence of a scope, updated live (empty without the collaboration extension). */
|
||||
export function usePresence(scope: string | undefined): Presence {
|
||||
return useSyncExternalStore(source.subscribe, () => (scope ? source.get(scope) : NONE), () => NONE)
|
||||
}
|
||||
|
||||
/** Title-bar text for presence, empty when nobody else is there. */
|
||||
export function presenceText(p: Presence): string {
|
||||
if (!p.count) return ''
|
||||
const names = p.people.slice(0, 3).map((x) => x.name).join(', ')
|
||||
return `${p.count} other${p.count === 1 ? '' : 's'} viewing${names ? `: ${names}${p.count > 3 ? ', …' : ''}` : ''}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Presence from the kernel's collaboration module: joins the scope of the screen on top (a lease
|
||||
* renewed by the kernel), leaves the previous one. Others only: this client is not counted.
|
||||
*/
|
||||
export function collaborationPresence(registry: ExtensionRegistry, client: TuiClient, selfName?: string): PresenceSource | undefined {
|
||||
const collab = new Collaboration(registry, { call: (id, input) => client.call(id, input) })
|
||||
if (!collab.available) return undefined
|
||||
const listeners = new Set<() => void>()
|
||||
let current: { scope: string; handle?: PresenceHandle; value: Presence } | undefined
|
||||
const notify = () => { for (const l of listeners) l() }
|
||||
return {
|
||||
get: (scope) => (current?.scope === scope ? current.value : NONE),
|
||||
subscribe: (l) => {
|
||||
listeners.add(l)
|
||||
return () => listeners.delete(l)
|
||||
},
|
||||
enter: (scope) => {
|
||||
if (current?.scope === scope) return
|
||||
void current?.handle?.stop()
|
||||
current = undefined
|
||||
if (!scope) return notify()
|
||||
const [kind, table, ...pk] = scope.split(':')
|
||||
if (!table || (kind !== 'table' && kind !== 'record')) return
|
||||
const entry: { scope: string; handle?: PresenceHandle; value: Presence } = { scope, value: NONE }
|
||||
current = entry
|
||||
collab.joinPresence(kind === 'record' ? { table, pk: pk.join(':') } : { table }, {
|
||||
onChange: (state) => {
|
||||
const people = state.people.filter((p) => p.name !== selfName).map((p) => ({ name: p.name, since: p.since.toISOString() }))
|
||||
entry.value = { count: people.length, people }
|
||||
notify()
|
||||
},
|
||||
}).then((h) => { entry.handle = h }).catch(() => undefined)
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { readViewSettings, widgetToBlocks, writeViewSettings, type RenderBlock } from '@qrun-io/qqq-core'
|
||||
import type { AppTreeNode } from '../client'
|
||||
import type { Key } from '../keys'
|
||||
import { openApp, openProcess, openTable } from '../nav'
|
||||
import { ChoiceOverlay } from '../overlays/basic'
|
||||
import type { AppStore } from '../store'
|
||||
import { glyphs, theme } from '../theme'
|
||||
import type { FKeyLabels, MenuEntry, Overlay, Screen } from '../types'
|
||||
import { errorText, num, pad } from '../util/text'
|
||||
import { blockLines } from '../widgets'
|
||||
import { ListModel, Panel, type Row } from './list'
|
||||
|
||||
interface AppMeta {
|
||||
name: string
|
||||
label: string
|
||||
widgets?: string[]
|
||||
sections?: Array<{ name: string; label: string; role?: string; tables?: string[]; processes?: string[]; reports?: string[]; widgets?: string[] }>
|
||||
children?: AppTreeNode[]
|
||||
}
|
||||
|
||||
/** An app's home: its widgets drawn from render blocks, then its sections (tables with counts, processes, reports, sub-apps). */
|
||||
export class AppHomeScreen implements Screen {
|
||||
readonly kind = 'app'
|
||||
readonly id: string
|
||||
readonly list = new ListModel()
|
||||
widgets = new Map<string, RenderBlock[] | { error: string }>()
|
||||
counts = new Map<string, number | string>()
|
||||
width = 100
|
||||
|
||||
constructor(private store: AppStore, readonly appName: string) {
|
||||
this.id = `app:${appName}`
|
||||
this.load()
|
||||
}
|
||||
|
||||
get app(): AppMeta | undefined {
|
||||
return (this.store.meta?.apps as Record<string, AppMeta> | undefined)?.[this.appName]
|
||||
}
|
||||
|
||||
title(): string {
|
||||
return this.app?.label ?? this.appName
|
||||
}
|
||||
|
||||
helpTopic() {
|
||||
return { kind: 'app' as const, name: this.appName }
|
||||
}
|
||||
|
||||
fkeys(): FKeyLabels {
|
||||
return { 3: 'Open', 4: null, 6: null, 7: null, 8: null }
|
||||
}
|
||||
|
||||
widgetOrder(): string[] {
|
||||
const declared = this.app?.widgets ?? []
|
||||
const saved = readViewSettings(this.id).widgetOrder ?? []
|
||||
return [...saved.filter((w) => declared.includes(w)), ...declared.filter((w) => !saved.includes(w))]
|
||||
}
|
||||
|
||||
load(): void {
|
||||
const client = this.store.client
|
||||
if (!client) return
|
||||
for (const w of this.app?.widgets ?? []) {
|
||||
client.widget(w).then((data) => this.widgets.set(w, widgetToBlocks(data))).catch((e) => this.widgets.set(w, { error: errorText(e) })).finally(() => { this.build(); this.store.bump() })
|
||||
}
|
||||
for (const t of this.tableNames()) {
|
||||
client.count(t, { criteria: [], booleanOperator: 'AND' }).then((n) => this.counts.set(t, n)).catch(() => this.counts.set(t, '?')).finally(() => { this.build(); this.store.bump() })
|
||||
}
|
||||
this.build()
|
||||
}
|
||||
|
||||
tableNames(): string[] {
|
||||
const names = new Set<string>()
|
||||
for (const s of this.app?.sections ?? []) for (const t of s.tables ?? []) names.add(t)
|
||||
for (const c of this.app?.children ?? []) if (c.type === 'TABLE') names.add(c.name)
|
||||
return [...names]
|
||||
}
|
||||
|
||||
build(): void {
|
||||
const t = theme()
|
||||
const g = glyphs()
|
||||
const meta = this.store.meta
|
||||
const rows: Row[] = []
|
||||
const width = this.width
|
||||
for (const w of this.widgetOrder()) {
|
||||
const data = this.widgets.get(w)
|
||||
const label = (meta?.widgets as Record<string, { label?: string }> | undefined)?.[w]?.label ?? w
|
||||
if (!data) {
|
||||
rows.push({ text: label, fg: t.acc, bold: true })
|
||||
rows.push({ text: ' loading…', fg: t.dim })
|
||||
} else if ('error' in data) {
|
||||
rows.push({ text: label, fg: t.acc, bold: true })
|
||||
rows.push({ text: ` ${data.error}`, fg: t.err })
|
||||
} else {
|
||||
const heading = data[0]?.kind === 'heading' ? data[0] : undefined
|
||||
rows.push({ text: heading ? heading.text : label, fg: t.acc, bold: true })
|
||||
if (heading?.sub) rows.push({ text: ` ${heading.sub}`, fg: t.dim })
|
||||
rows.push(...blockLines(heading ? data.slice(1) : data, width - 4).map((l) => ({ ...l, text: ' ' + l.text })))
|
||||
}
|
||||
rows.push({ text: g.rule.repeat(Math.min(width - 2, 80)), fg: t.border })
|
||||
}
|
||||
const sections = this.app?.sections?.length ? this.app.sections : [{ name: this.appName, label: this.title(), tables: this.app?.children?.filter((c) => c.type === 'TABLE').map((c) => c.name), processes: this.app?.children?.filter((c) => c.type === 'PROCESS').map((c) => c.name) }]
|
||||
for (const s of sections) {
|
||||
rows.push({ text: s.label, fg: t.acc, bold: true })
|
||||
for (const name of s.tables ?? []) {
|
||||
const label = meta?.tables?.[name]?.label ?? name
|
||||
const c = this.counts.get(name)
|
||||
rows.push({ text: ` ${pad(label, 34)} ${typeof c === 'number' ? num(c) + ' records' : c ?? '…'}`, key: `t:${name}`, action: () => openTable(this.store, name) })
|
||||
}
|
||||
for (const name of s.processes ?? []) rows.push({ text: ` ${g.process} ${meta?.processes?.[name]?.label ?? name}`, key: `p:${name}`, action: () => openProcess(this.store, name) })
|
||||
for (const name of s.reports ?? []) rows.push({ text: ` ${g.report} ${(meta?.reports as Record<string, { label?: string }> | undefined)?.[name]?.label ?? name} (run with :report run ${name})`, key: `r:${name}`, action: () => this.store.commands().commandLine(`report run ${name} --format csv`) })
|
||||
}
|
||||
const listed = new Set(sections.flatMap((s) => [...(s.tables ?? []), ...(s.processes ?? []), ...(s.reports ?? [])]))
|
||||
const others = (this.app?.children ?? []).filter((c) => !listed.has(c.name))
|
||||
if (others.length) {
|
||||
rows.push({ text: 'More', fg: t.acc, bold: true })
|
||||
for (const c of others) {
|
||||
if (c.type === 'APP') rows.push({ text: ` ${g.app} ${c.label}`, key: `a:${c.name}`, action: () => openApp(this.store, c.name) })
|
||||
else if (c.type === 'TABLE') rows.push({ text: ` ${c.label}`, key: `t:${c.name}`, action: () => openTable(this.store, c.name) })
|
||||
else if (c.type === 'PROCESS') rows.push({ text: ` ${g.process} ${c.label}`, key: `p:${c.name}`, action: () => openProcess(this.store, c.name) })
|
||||
}
|
||||
}
|
||||
if (!rows.length) rows.push({ text: '(this app has no widgets or sections)', fg: t.dim })
|
||||
this.list.setRows(rows)
|
||||
}
|
||||
|
||||
onKey(key: Key): boolean {
|
||||
if (key.name === 'f3') {
|
||||
this.list.current?.action?.()
|
||||
return true
|
||||
}
|
||||
return this.list.onKey(key)
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.widgets.clear()
|
||||
this.counts.clear()
|
||||
this.load()
|
||||
}
|
||||
|
||||
menu(): MenuEntry[] {
|
||||
const out: MenuEntry[] = []
|
||||
for (const r of this.list.rows) if (r.action && r.key) out.push({ section: 'Context', label: `Open ${r.text.trim().replace(/\s{2,}.*/, '')}`, detail: r.key.startsWith('t:') ? String(this.counts.get(r.key.slice(2)) ?? '') : undefined, run: r.action })
|
||||
out.push({ section: 'Context', label: 'Refresh widgets and counts', key: 'F5', run: () => this.refresh() })
|
||||
return out
|
||||
}
|
||||
|
||||
viewSettings(): Overlay | undefined {
|
||||
const order = this.widgetOrder()
|
||||
if (!order.length) return undefined
|
||||
return new WidgetOrderOverlay(this.store, this, order)
|
||||
}
|
||||
|
||||
render(width: number, height: number, focused: boolean) {
|
||||
if (width !== this.width) {
|
||||
this.width = width
|
||||
this.build()
|
||||
}
|
||||
return <Panel title={this.title()} width={width} height={height} focused={focused}>{this.list.view(width - 2, height - 2, focused)}</Panel>
|
||||
}
|
||||
}
|
||||
|
||||
/** View settings of an app home: widget order (Shift+↑↓ or +/- moves the selected widget). */
|
||||
class WidgetOrderOverlay extends ChoiceOverlay<string> {
|
||||
constructor(private s: AppStore, private screen: AppHomeScreen, private order: string[]) {
|
||||
super(s, 'Widget order', order.map((w) => ({ label: w, value: w })), () => undefined, '+/- move · Enter saves')
|
||||
}
|
||||
override onKey(key: Key): void {
|
||||
const move = (dir: number) => {
|
||||
const i = this.index
|
||||
const j = i + dir
|
||||
if (j < 0 || j >= this.order.length) return
|
||||
;[this.order[i], this.order[j]] = [this.order[j]!, this.order[i]!]
|
||||
this.choices.splice(0, this.choices.length, ...this.order.map((w) => ({ label: w, value: w })))
|
||||
this.index = j
|
||||
}
|
||||
if (key.sequence === '+' || (key.shift && key.name === 'down')) return move(1)
|
||||
if (key.sequence === '-' || (key.shift && key.name === 'up')) return move(-1)
|
||||
if (key.name === 'return') {
|
||||
writeViewSettings(this.screen.id, { ...readViewSettings(this.screen.id), widgetOrder: this.order })
|
||||
this.s.closeOverlay(this)
|
||||
this.screen.build()
|
||||
return
|
||||
}
|
||||
super.onKey(key)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { FormInputError, FormSession, ResultCursor, copyRecordValues, dateTimesToUtc, defaultTimeZone, isInstant, isMissingValue, utcToLocalInput, validateValue, type FieldMeta, type FieldValues } from '@qrun-io/qqq-core'
|
||||
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
|
||||
import type { TableMetaData } from '../client'
|
||||
import type { Key } from '../keys'
|
||||
import { charOf, isPrintable } from '../keys'
|
||||
import { LineEditor } from '../line-editor'
|
||||
import { replaceWithRecord } from '../nav'
|
||||
import { EditorLine } from '../overlays/basic'
|
||||
import { PickerOverlay } from '../overlays/picker'
|
||||
import type { AppStore } from '../store'
|
||||
import { glyphs, theme } from '../theme'
|
||||
import type { FKeyLabels, Screen } from '../types'
|
||||
import { errorText, pad, trunc } from '../util/text'
|
||||
import { Panel } from './list'
|
||||
import { orderedSections } from './record'
|
||||
|
||||
const TEXT_TYPES = new Set(['STRING', 'TEXT', 'HTML', 'PASSWORD', 'INTEGER', 'LONG', 'DECIMAL', 'DATE', 'DATE_TIME', 'TIME', 'BLOB'])
|
||||
|
||||
function hintFor(f: FieldMeta): string {
|
||||
switch (f.type) {
|
||||
case 'DATE': return 'YYYY-MM-DD'
|
||||
case 'DATE_TIME': return `YYYY-MM-DD HH:MM (${defaultTimeZone()})`
|
||||
case 'TIME': return 'HH:MM'
|
||||
case 'INTEGER': case 'LONG': return 'whole number'
|
||||
case 'DECIMAL': return 'number'
|
||||
case 'BOOLEAN': return 'Space toggles'
|
||||
case 'TEXT': case 'HTML': return 'Ctrl+E opens $EDITOR'
|
||||
case 'BLOB': return 'file path'
|
||||
default: return ''
|
||||
}
|
||||
}
|
||||
|
||||
function textOf(value: unknown): string {
|
||||
return value === null || value === undefined ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* A form over a kernel FormSession: one editor per field type, a possible-value picker that narrows
|
||||
* for contingent fields, local validation shown by the field, server fieldErrors mapped back to
|
||||
* fields. Keys are read from the object's state (no React closures), so a burst of keys lands in
|
||||
* the field that had focus when each key was read. Used by table forms and process steps.
|
||||
*/
|
||||
export class FormEditor {
|
||||
index = 0
|
||||
top = 0
|
||||
editors = new Map<string, LineEditor>()
|
||||
/** Bulk edit: the fields that will be applied. */
|
||||
enabled = new Set<string>()
|
||||
formError?: string
|
||||
saving = false
|
||||
readonly initial: FieldValues
|
||||
|
||||
constructor(private store: AppStore, readonly session: FormSession, readonly order: string[], readonly options: { bulkEdit?: boolean; readOnly?: string[] } = {}) {
|
||||
this.initial = { ...session.values }
|
||||
for (const name of order) {
|
||||
const f = session.fields[name]
|
||||
if (f && TEXT_TYPES.has(f.type) && !session.hasChoices(name)) this.editors.set(name, new LineEditor(textOf(session.values[name]), { masked: f.type === 'PASSWORD' }))
|
||||
}
|
||||
}
|
||||
|
||||
get names(): string[] {
|
||||
return this.order.filter((n) => this.session.fields[n] && !this.session.fields[n]!.isHidden)
|
||||
}
|
||||
|
||||
get current(): string | undefined {
|
||||
return this.names[this.index]
|
||||
}
|
||||
|
||||
dirty(): boolean {
|
||||
return this.names.some((n) => textOf(this.valueFor(n)) !== textOf(this.initial[n]))
|
||||
}
|
||||
|
||||
/** The value a field has now (the editor text for text fields not yet committed). */
|
||||
valueFor(name: string): unknown {
|
||||
const ed = this.editors.get(name)
|
||||
return ed ? ed.value : this.session.values[name]
|
||||
}
|
||||
|
||||
/** Writes a text editor's value into the session (behaviors, adjusters, local check). */
|
||||
async commit(name: string | undefined): Promise<void> {
|
||||
if (!name) return
|
||||
const ed = this.editors.get(name)
|
||||
if (!ed) return
|
||||
const f = this.session.fields[name]!
|
||||
if (textOf(this.session.values[name]) === ed.value && !(name in this.session.errors)) return
|
||||
if (this.session.state(name).disabledBecause) return
|
||||
try {
|
||||
const result = await this.session.set(name, ed.value === '' ? null : ed.value)
|
||||
for (const n of result.cleared) this.editors.get(n)?.set('')
|
||||
if (this.options.bulkEdit && ed.value !== textOf(this.initial[name])) this.enabled.add(name)
|
||||
const problem = validateValue(f, ed.value)
|
||||
if (problem) this.session.errors[name] = `${problem}`
|
||||
for (const [n, v] of Object.entries(this.session.values)) {
|
||||
const other = this.editors.get(n)
|
||||
if (other && n !== name && other.value !== textOf(v)) other.set(textOf(v))
|
||||
}
|
||||
} catch (e) {
|
||||
this.session.errors[name] = e instanceof FormInputError ? e.message.replace(`${f.label}: `, '') : errorText(e)
|
||||
}
|
||||
}
|
||||
|
||||
move(delta: number): void {
|
||||
const from = this.current
|
||||
void this.commit(from).then(() => this.store.bump())
|
||||
this.index = Math.max(0, Math.min(this.names.length - 1, this.index + delta))
|
||||
}
|
||||
|
||||
/** Commits everything and validates; returns the problems (empty when the form can be sent). */
|
||||
async check(): Promise<Record<string, string>> {
|
||||
for (const n of this.names) await this.commit(n)
|
||||
const only = this.options.bulkEdit ? [...this.enabled] : undefined
|
||||
const problems = this.session.validate(only)
|
||||
return problems
|
||||
}
|
||||
|
||||
/** Maps a server validation error onto fields. */
|
||||
absorbServerError(error: unknown): void {
|
||||
if (error instanceof UiApiError && error.fieldErrors.length) {
|
||||
for (const fe of error.fieldErrors) if (fe.field && this.session.fields[fe.field]) this.session.errors[fe.field] = fe.message
|
||||
this.formError = error.message
|
||||
const first = this.names.findIndex((n) => this.session.errors[n])
|
||||
if (first >= 0) this.index = first
|
||||
} else {
|
||||
this.formError = errorText(error)
|
||||
}
|
||||
}
|
||||
|
||||
openPicker(name: string, initial = ''): void {
|
||||
if (this.session.state(name).disabledBecause) return this.store.flash(`${this.session.fields[name]!.label}: ${this.session.state(name).disabledBecause}`)
|
||||
this.store.open(new PickerOverlay(this.store, this.session, name, (option) => {
|
||||
this.store.run(this.session.set(name, option === null ? null : String(option.id)).then((r) => {
|
||||
if (option) this.session.displayValues[name] = option.label
|
||||
for (const n of r.cleared) this.editors.get(n)?.set('')
|
||||
delete this.session.errors[name]
|
||||
if (this.options.bulkEdit) this.enabled.add(name)
|
||||
if (option && this.current === name) this.index = Math.min(this.names.length - 1, this.index + 1)
|
||||
}))
|
||||
}, initial))
|
||||
}
|
||||
|
||||
/** Handles a key; returns true when the form used it. Ctrl+S and Esc are left to the owner. */
|
||||
onKey(key: Key): boolean {
|
||||
const name = this.current
|
||||
if (!name) return false
|
||||
const f = this.session.fields[name]!
|
||||
const readOnly = this.options.readOnly?.includes(name) || f.isEditable === false
|
||||
if (key.name === 'tab') {
|
||||
this.move(key.shift ? -1 : 1)
|
||||
return true
|
||||
}
|
||||
switch (key.name) {
|
||||
case 'down': this.move(1); return true
|
||||
case 'up': this.move(-1); return true
|
||||
case 'pagedown': this.move(8); return true
|
||||
case 'pageup': this.move(-8); return true
|
||||
default: break
|
||||
}
|
||||
if (key.ctrl && key.name === 't' && this.options.bulkEdit) {
|
||||
if (this.enabled.has(name)) this.enabled.delete(name)
|
||||
else this.enabled.add(name)
|
||||
return true
|
||||
}
|
||||
if (readOnly) return key.name === 'return' ? (this.move(1), true) : false
|
||||
if (this.session.hasChoices(name)) {
|
||||
if (key.name === 'return' || key.name === 'space') {
|
||||
this.openPicker(name)
|
||||
return true
|
||||
}
|
||||
if (key.name === 'backspace' || key.name === 'delete') {
|
||||
this.store.run(this.session.set(name, null).then((r) => { for (const n of r.cleared) this.editors.get(n)?.set('') }))
|
||||
return true
|
||||
}
|
||||
if (isPrintable(key)) {
|
||||
this.openPicker(name, charOf(key))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
if (f.type === 'BOOLEAN') {
|
||||
if (key.name === 'space' || key.name === 'return' || /^[yntf01]$/i.test(charOf(key))) {
|
||||
const ch = charOf(key).toLowerCase()
|
||||
const next = ch === 'y' || ch === 't' || ch === '1' ? true : ch === 'n' || ch === 'f' || ch === '0' ? false : !(this.session.values[name] === true)
|
||||
this.store.run(this.session.set(name, next).then(() => { if (this.options.bulkEdit) this.enabled.add(name) }))
|
||||
if (key.name === 'return') this.move(1)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
const ed = this.editors.get(name)
|
||||
if (!ed) return false
|
||||
if (key.ctrl && key.name === 'e' && (f.type === 'TEXT' || f.type === 'HTML')) {
|
||||
this.store.run(this.store.editExternal(ed.value), (text) => { ed.set(text.replace(/\n$/, '')); void this.commit(name) })
|
||||
return true
|
||||
}
|
||||
if (key.name === 'return') {
|
||||
this.move(1)
|
||||
return true
|
||||
}
|
||||
if (ed.handle(key)) {
|
||||
delete this.session.errors[name]
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
render(width: number, height: number, focused: boolean) {
|
||||
const t = theme()
|
||||
const g = glyphs()
|
||||
const names = this.names
|
||||
const labelWidth = Math.min(28, Math.max(8, ...names.map((n) => this.session.fields[n]!.label.length + 2)))
|
||||
const editorWidth = Math.max(10, Math.min(60, width - labelWidth - 6 - (this.options.bulkEdit ? 4 : 0) - Math.min(36, Math.floor(width / 3))))
|
||||
const msgWidth = Math.max(0, width - labelWidth - editorWidth - 6 - (this.options.bulkEdit ? 4 : 0))
|
||||
const rows = Math.max(1, height - (this.formError ? 1 : 0))
|
||||
if (this.index < this.top) this.top = this.index
|
||||
if (this.index >= this.top + rows) this.top = this.index - rows + 1
|
||||
const lines = names.slice(this.top, this.top + rows).map((name, i) => {
|
||||
const f = this.session.fields[name]!
|
||||
const idx = this.top + i
|
||||
const sel = idx === this.index && focused
|
||||
const state = this.session.state(name)
|
||||
const error = this.session.errors[name]
|
||||
const required = f.isRequired || f.validation?.required ? '*' : ' '
|
||||
const disabled = Boolean(state.disabledBecause) || f.isEditable === false || this.options.readOnly?.includes(name)
|
||||
const bulk = this.options.bulkEdit ? (this.enabled.has(name) ? g.check : g.uncheck) + ' ' : ''
|
||||
const ed = this.editors.get(name)
|
||||
let editor: React.ReactNode
|
||||
if (ed && !disabled) editor = <EditorLine editor={ed} width={editorWidth} focused={sel} />
|
||||
else {
|
||||
let shown: string
|
||||
if (f.type === 'BOOLEAN') shown = this.session.values[name] === true ? `${g.check} yes` : this.session.values[name] === false ? `${g.uncheck} no` : `${g.uncheck} (not set)`
|
||||
else if (this.session.hasChoices(name)) shown = isMissingValue(this.session.values[name]) ? (state.disabledBecause ? '' : '(choose…)') : `${this.session.displayValues[name] ?? textOf(this.session.values[name])}`
|
||||
else shown = ed ? ed.value : textOf(this.session.values[name])
|
||||
const pickGlyph = this.session.hasChoices(name) && !disabled ? ' ▾' : ''
|
||||
editor = <text fg={disabled ? t.dim : sel ? t.selFg : t.inputFg} bg={disabled ? undefined : sel ? t.selBg : t.inputBg}>{pad(trunc(shown, editorWidth - pickGlyph.length) + pickGlyph, editorWidth)}</text>
|
||||
}
|
||||
const msg = error ? `! ${error}` : state.disabledBecause ? state.disabledBecause : idx === this.index ? (this.session.hasChoices(name) ? 'Enter or type to choose · Backspace clears' : hintFor(f)) : ''
|
||||
return (
|
||||
<box key={name} flexDirection="row" height={1}>
|
||||
<text fg={sel ? t.acc2 : disabled ? t.dim : t.fg}>{`${sel ? g.pointer : ' '}${bulk}${pad(f.label, labelWidth - 1)}${required} `}</text>
|
||||
{editor}
|
||||
<text fg={error ? t.err : t.dim}>{pad(msgWidth > 2 ? ' ' + trunc(msg, msgWidth - 1) : '', msgWidth + 1)}</text>
|
||||
</box>
|
||||
)
|
||||
})
|
||||
return (
|
||||
<>
|
||||
{lines}
|
||||
{Array.from({ length: Math.max(0, rows - lines.length) }, (_, i) => <text key={`e${i}`}>{pad('', width)}</text>)}
|
||||
{this.formError ? <text fg={t.err}>{pad(` ${this.formError}`, width)}</text> : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export interface FormOptions {
|
||||
mode: 'create' | 'edit' | 'copy'
|
||||
primaryKey?: string
|
||||
initial?: FieldValues
|
||||
/** The result the record came from (edit from a grid): saving returns there. */
|
||||
cursor?: ResultCursor
|
||||
}
|
||||
|
||||
/** Create, edit or copy a table record. Ctrl+S saves; Esc cancels and asks when there are unsaved changes. */
|
||||
export class FormScreen implements Screen {
|
||||
readonly kind = 'form'
|
||||
readonly id: string
|
||||
meta?: TableMetaData
|
||||
editor?: FormEditor
|
||||
error?: string
|
||||
originals: FieldValues = {}
|
||||
|
||||
constructor(private store: AppStore, readonly tableName: string, readonly options: FormOptions) {
|
||||
this.id = `form:${tableName}:${options.mode}:${Date.now()}`
|
||||
store.run(this.init())
|
||||
}
|
||||
|
||||
async init(): Promise<void> {
|
||||
const client = this.store.client!
|
||||
try {
|
||||
const meta = await client.tableMeta(this.tableName)
|
||||
this.meta = meta
|
||||
let initial: FieldValues = { ...(this.options.initial ?? {}) }
|
||||
const display: Record<string, string> = {}
|
||||
if (this.options.mode !== 'create' && this.options.primaryKey !== undefined) {
|
||||
const rec = await client.record(this.tableName, this.options.primaryKey)
|
||||
initial = this.options.mode === 'copy' ? copyRecordValues(meta, rec) as FieldValues : { ...rec.values }
|
||||
Object.assign(display, rec.displayValues ?? {})
|
||||
}
|
||||
// DATE_TIME values are edited in the local zone and sent back as UTC
|
||||
this.originals = { ...initial }
|
||||
for (const f of Object.values(meta.fields)) if (f.type === 'DATE_TIME' && isInstant(initial[f.name])) initial[f.name] = utcToLocalInput(String(initial[f.name]), defaultTimeZone())
|
||||
const fields = Object.values(meta.fields) as FieldMeta[]
|
||||
const session = new FormSession({ baseUrl: client.server, fetch: client.fetch, scope: { table: this.tableName }, fields, initialValues: initial, tableMeta: meta as never, exclude: this.options.mode === 'edit' ? [] : [meta.primaryKeyField] })
|
||||
for (const [k, v] of Object.entries(display)) if (session.hasChoices(k) && !isMissingValue(initial[k])) session.displayValues[k] = v
|
||||
// labels for prefilled possible values (create child: the parent's id)
|
||||
for (const [k, v] of Object.entries(this.options.initial ?? {})) {
|
||||
if (session.hasChoices(k) && !isMissingValue(v) && !session.displayValues[k]) {
|
||||
try {
|
||||
session.displayValues[k] = (await session.resolveChoice(k, String(v))).label
|
||||
} catch {
|
||||
// keep the id
|
||||
}
|
||||
}
|
||||
}
|
||||
await session.load()
|
||||
const editable = new Set(session.editable.map((f) => f.name))
|
||||
const order: string[] = []
|
||||
for (const s of orderedSections(meta)) for (const n of s.fieldNames ?? []) if (editable.has(n) && !order.includes(n)) order.push(n)
|
||||
for (const n of session.order) if (editable.has(n) && !order.includes(n)) order.push(n)
|
||||
this.editor = new FormEditor(this.store, session, order)
|
||||
} catch (e) {
|
||||
this.error = errorText(e)
|
||||
}
|
||||
}
|
||||
|
||||
title(): string {
|
||||
const verb = this.options.mode === 'create' ? 'New' : this.options.mode === 'edit' ? 'Edit' : 'Copy'
|
||||
return `${verb} ${this.meta?.label ?? this.tableName}${this.options.mode === 'edit' ? ` ${this.options.primaryKey}` : ''}`
|
||||
}
|
||||
|
||||
presenceScope(): string | undefined {
|
||||
return this.options.mode === 'edit' ? `record:${this.tableName}:${this.options.primaryKey}` : undefined
|
||||
}
|
||||
|
||||
capturesText(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
dirty(): boolean {
|
||||
return this.editor?.dirty() ?? false
|
||||
}
|
||||
|
||||
fkeys(): FKeyLabels {
|
||||
return { 3: null, 4: null, 6: null, 7: null, 8: null, 5: null }
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.dirty()) this.store.confirm('Discard your changes?', () => this.store.back())
|
||||
else this.store.back()
|
||||
}
|
||||
|
||||
save(): void {
|
||||
const editor = this.editor
|
||||
const meta = this.meta
|
||||
if (!editor || !meta || editor.saving) return
|
||||
editor.saving = true
|
||||
editor.formError = undefined
|
||||
const client = this.store.client!
|
||||
const work = async () => {
|
||||
const problems = await editor.check()
|
||||
if (Object.keys(problems).length) {
|
||||
editor.formError = `${Object.keys(problems).length} field${Object.keys(problems).length === 1 ? '' : 's'} need attention`
|
||||
editor.index = Math.max(0, editor.names.findIndex((n) => problems[n]))
|
||||
return
|
||||
}
|
||||
try {
|
||||
if (this.options.mode === 'edit') {
|
||||
const changed = editor.names.filter((n) => textOf(editor.session.values[n]) !== textOf(editor.initial[n]))
|
||||
if (!changed.length) {
|
||||
this.store.flash('nothing changed')
|
||||
this.store.back()
|
||||
return
|
||||
}
|
||||
const values = dateTimesToUtc(Object.values(editor.session.fields), Object.fromEntries(changed.map((n) => [n, editor.session.values[n] ?? null])), { originals: this.originals })
|
||||
await client.update(this.tableName, this.options.primaryKey, values)
|
||||
this.store.flash(`saved ${meta.label} ${this.options.primaryKey}`)
|
||||
this.store.back()
|
||||
} else {
|
||||
const record = await client.insert(this.tableName, dateTimesToUtc(Object.values(editor.session.fields), editor.session.submission()))
|
||||
const pk = record.values?.[meta.primaryKeyField]
|
||||
this.store.flash(`created ${meta.label} ${String(pk ?? '')}`)
|
||||
replaceWithRecord(this.store, this.tableName, String(pk))
|
||||
}
|
||||
} catch (e) {
|
||||
editor.absorbServerError(e)
|
||||
}
|
||||
}
|
||||
work().catch((e) => editor.absorbServerError(e)).finally(() => {
|
||||
editor.saving = false
|
||||
this.store.bump()
|
||||
})
|
||||
}
|
||||
|
||||
onKey(key: Key): boolean {
|
||||
if (key.ctrl && key.name === 's') {
|
||||
this.save()
|
||||
return true
|
||||
}
|
||||
if (key.name === 'escape') {
|
||||
this.cancel()
|
||||
return true
|
||||
}
|
||||
if (key.name === 'f5') return true
|
||||
return this.editor?.onKey(key) ?? true
|
||||
}
|
||||
|
||||
render(width: number, height: number, focused: boolean) {
|
||||
const t = theme()
|
||||
const footer = this.editor?.saving ? ' saving…' : ' Ctrl+S save · Esc cancel · Tab ↓ next · ↑ previous · Enter or typing opens a picker · * required'
|
||||
return (
|
||||
<Panel title={this.title()} width={width} height={height} focused={focused}>
|
||||
{this.error ? <text fg={t.err}>{pad(` ${this.error}`, width - 2)}</text> : this.editor ? this.editor.render(width - 2, height - 3, focused) : <text fg={t.dim}>{pad(' loading…', width - 2)}</text>}
|
||||
{this.error || !this.editor ? Array.from({ length: Math.max(0, height - 4) }, (_, i) => <text key={i}>{''}</text>) : null}
|
||||
<text fg={t.dim}>{pad(footer, width - 2)}</text>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { readRecents } from '@qrun-io/qqq-core'
|
||||
import type { AppTreeNode } from '../client'
|
||||
import type { Key } from '../keys'
|
||||
import { openApp, openRecord, openTable, openProcess } from '../nav'
|
||||
import type { AppStore } from '../store'
|
||||
import { glyphs, theme } from '../theme'
|
||||
import type { FKeyLabels, MenuEntry, Screen } from '../types'
|
||||
import { pad } from '../util/text'
|
||||
import { ListModel, Panel, type Row } from './list'
|
||||
|
||||
/** Counts the tables and processes under an app node, recursively. */
|
||||
export function countUnder(node: AppTreeNode): { tables: number; processes: number; reports: number; apps: number } {
|
||||
const c = { tables: 0, processes: 0, reports: 0, apps: 0 }
|
||||
for (const ch of node.children ?? []) {
|
||||
if (ch.type === 'TABLE') c.tables++
|
||||
else if (ch.type === 'PROCESS') c.processes++
|
||||
else if (ch.type === 'REPORT') c.reports++
|
||||
else if (ch.type === 'APP') {
|
||||
c.apps++
|
||||
const sub = countUnder(ch)
|
||||
c.tables += sub.tables
|
||||
c.processes += sub.processes
|
||||
c.reports += sub.reports
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
/** The landing screen: apps with counts, the server's totals, and recently viewed records. */
|
||||
export class HomeScreen implements Screen {
|
||||
readonly kind = 'home'
|
||||
readonly id = 'home'
|
||||
readonly list = new ListModel()
|
||||
|
||||
constructor(private store: AppStore) {
|
||||
this.build()
|
||||
}
|
||||
|
||||
title(): string {
|
||||
return 'Home'
|
||||
}
|
||||
|
||||
fkeys(): FKeyLabels {
|
||||
return { 3: 'Open', 4: null, 6: null, 7: null, 8: null }
|
||||
}
|
||||
|
||||
build(): void {
|
||||
const t = theme()
|
||||
const g = glyphs()
|
||||
const meta = this.store.meta
|
||||
const rows: Row[] = []
|
||||
const tables = Object.values(meta?.tables ?? {}).filter((x) => !x.isHidden).length
|
||||
const processes = Object.values(meta?.processes ?? {}).filter((x) => !x.isHidden).length
|
||||
const apps = (meta?.appTree ?? []) as AppTreeNode[]
|
||||
rows.push({ text: `${apps.length} apps · ${tables} tables · ${processes} processes · ${Object.keys(meta?.widgets ?? {}).length} widgets · UI API ${this.store.status.apiVersion}`, fg: t.dim })
|
||||
rows.push({ text: '' })
|
||||
rows.push({ text: 'Apps', fg: t.acc, bold: true })
|
||||
for (const app of apps) {
|
||||
const c = countUnder(app)
|
||||
const parts = [c.tables && `${c.tables} tables`, c.processes && `${c.processes} processes`, c.reports && `${c.reports} reports`, c.apps && `${c.apps} apps`].filter(Boolean).join(' · ')
|
||||
if (app.type === 'APP') rows.push({ text: ` ${g.app} ${pad(app.label, 30)} ${parts}`, key: `app:${app.name}`, action: () => openApp(this.store, app.name) })
|
||||
else if (app.type === 'TABLE') rows.push({ text: ` ${pad(app.label, 30)} table`, key: `t:${app.name}`, action: () => openTable(this.store, app.name) })
|
||||
else if (app.type === 'PROCESS') rows.push({ text: ` ${g.process} ${pad(app.label, 30)} process`, key: `p:${app.name}`, action: () => openProcess(this.store, app.name) })
|
||||
}
|
||||
let recents: ReturnType<typeof readRecents> = []
|
||||
try {
|
||||
recents = this.store.client ? readRecents(this.store.client.server).slice(0, 8) : []
|
||||
} catch {
|
||||
recents = []
|
||||
}
|
||||
if (recents.length) {
|
||||
rows.push({ text: '' })
|
||||
rows.push({ text: 'Recently viewed', fg: t.acc, bold: true })
|
||||
for (const r of recents) {
|
||||
const label = r.kind === 'record' ? `${meta?.tables?.[r.table ?? '']?.label ?? r.table} › ${r.label}` : r.label
|
||||
rows.push({ text: ` ${pad(label, 50)} ${r.kind}`, key: `r:${r.kind}:${r.table}:${r.key}:${r.name}`, action: () => {
|
||||
if (r.kind === 'record' && r.table) openRecord(this.store, r.table, r.key)
|
||||
else if (r.kind === 'table' && r.table) openTable(this.store, r.table)
|
||||
else if (r.kind === 'process' && r.name) openProcess(this.store, r.name)
|
||||
} })
|
||||
}
|
||||
}
|
||||
rows.push({ text: '' })
|
||||
rows.push({ text: 'Enter opens · F2 or / goes anywhere by name · F9 menu · : command line (try :table query person -w "lastName STARTS_WITH S")', fg: t.dim })
|
||||
this.list.setRows(rows)
|
||||
}
|
||||
|
||||
onKey(key: Key): boolean {
|
||||
if (key.name === 'f3') {
|
||||
this.list.current?.action?.()
|
||||
return true
|
||||
}
|
||||
return this.list.onKey(key)
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
this.build()
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.store.run(this.store.client!.metaData(true).then((m) => {
|
||||
this.store.meta = m
|
||||
this.build()
|
||||
}))
|
||||
}
|
||||
|
||||
menu(): MenuEntry[] {
|
||||
return ((this.store.meta?.appTree ?? []) as AppTreeNode[]).filter((a) => a.type === 'APP').map((a) => ({ section: 'Context' as const, label: `Open app ${a.label}`, run: () => openApp(this.store, a.name) }))
|
||||
}
|
||||
|
||||
render(width: number, height: number, focused: boolean) {
|
||||
const brand = (this.store.meta?.branding as { appName?: string } | undefined)?.appName ?? 'QQQ'
|
||||
return <Panel title={brand} width={width} height={height} focused={focused}>{this.list.view(width - 2, height - 2, focused)}</Panel>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Key } from '../keys'
|
||||
import { borderProps, theme } from '../theme'
|
||||
import type { StyledLine } from '../widgets'
|
||||
import { pad } from '../util/text'
|
||||
|
||||
/** A row of a scrolling list: a line, optionally selectable with an action. */
|
||||
export interface Row extends StyledLine {
|
||||
action?: () => void
|
||||
/** Stable identity of a selectable row (kept across reloads). */
|
||||
key?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* A scrolling list of lines where some rows are selectable. The cursor moves between selectable
|
||||
* rows; PgUp/PgDn/Home/End page; the viewport follows the cursor. State lives in the object.
|
||||
*/
|
||||
export class ListModel {
|
||||
rows: Row[] = []
|
||||
cursor = 0
|
||||
top = 0
|
||||
height = 10
|
||||
|
||||
setRows(rows: Row[]): void {
|
||||
const key = this.rows[this.cursor]?.key
|
||||
this.rows = rows
|
||||
const again = key ? rows.findIndex((r) => r.key === key) : -1
|
||||
if (again >= 0) this.cursor = again
|
||||
else if (!rows[this.cursor]?.action) this.cursor = this.firstSelectable(0, 1) ?? 0
|
||||
}
|
||||
|
||||
get current(): Row | undefined {
|
||||
return this.rows[this.cursor]
|
||||
}
|
||||
|
||||
private firstSelectable(from: number, dir: 1 | -1): number | undefined {
|
||||
for (let i = from; i >= 0 && i < this.rows.length; i += dir) if (this.rows[i]!.action) return i
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Arrow, page and enter keys; returns whether handled. */
|
||||
onKey(raw: Key): boolean {
|
||||
const page = Math.max(1, this.height - 1)
|
||||
// Space and Shift+Space page too (PgUp/PgDn may be taken by the terminal or tmux)
|
||||
const key: Key = raw.name === 'space' ? { name: raw.shift ? 'pageup' : 'pagedown', sequence: '' } : raw
|
||||
switch (key.name) {
|
||||
case 'down': this.cursor = this.firstSelectable(this.cursor + 1, 1) ?? this.cursor; break
|
||||
case 'up': this.cursor = this.firstSelectable(this.cursor - 1, -1) ?? this.cursor; break
|
||||
case 'pagedown': this.cursor = this.firstSelectable(Math.min(this.rows.length - 1, this.cursor + page), 1) ?? this.firstSelectable(this.rows.length - 1, -1) ?? this.cursor; this.top = Math.min(Math.max(0, this.rows.length - this.height), this.top + page); break
|
||||
case 'pageup': this.cursor = this.firstSelectable(Math.max(0, this.cursor - page), -1) ?? this.firstSelectable(0, 1) ?? this.cursor; this.top = Math.max(0, this.top - page); break
|
||||
case 'home': this.cursor = this.firstSelectable(0, 1) ?? 0; this.top = 0; break
|
||||
case 'end': this.cursor = this.firstSelectable(this.rows.length - 1, -1) ?? this.cursor; this.top = Math.max(0, this.rows.length - this.height); break
|
||||
case 'return': this.current?.action?.(); break
|
||||
default: return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Draws the visible rows. */
|
||||
view(width: number, height: number, focused: boolean): ReactNode {
|
||||
const t = theme()
|
||||
this.height = height
|
||||
if (this.cursor < this.top) this.top = this.cursor
|
||||
if (this.cursor >= this.top + height) this.top = this.cursor - height + 1
|
||||
this.top = Math.max(0, Math.min(this.top, Math.max(0, this.rows.length - height)))
|
||||
const shown = this.rows.slice(this.top, this.top + height)
|
||||
const lines = shown.map((r, i) => {
|
||||
const sel = this.top + i === this.cursor && Boolean(r.action)
|
||||
const text = pad(r.text, width)
|
||||
return <text key={i} fg={sel ? (focused ? t.selFg : t.fg) : r.fg ?? t.fg} bg={sel ? (focused ? t.selBg : t.selInactiveBg) : r.bg}>{r.bold ? <strong>{text}</strong> : text}</text>
|
||||
})
|
||||
while (lines.length < height) lines.push(<text key={`pad${lines.length}`}>{pad('', width)}</text>)
|
||||
return <>{lines}</>
|
||||
}
|
||||
}
|
||||
|
||||
/** A bordered panel with a title, sized to the screen area. */
|
||||
export function Panel(props: { title: string; width: number; height: number; focused: boolean; children: ReactNode; bottomTitle?: string }) {
|
||||
return (
|
||||
<box flexDirection="column" width={props.width} height={props.height} {...borderProps(props.focused)} title={` ${props.title} `} {...(props.bottomTitle ? { bottomTitle: ` ${props.bottomTitle} ` } : {})}>
|
||||
{props.children}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import type { Key } from '../keys'
|
||||
import type { AppStore } from '../store'
|
||||
import { theme } from '../theme'
|
||||
import type { FKeyLabels, MenuEntry, Screen } from '../types'
|
||||
import { pad } from '../util/text'
|
||||
import { Panel } from './list'
|
||||
|
||||
/** The output of a `:` command run through the CLI in-process (runCli), scrollable. */
|
||||
export class OutputScreen implements Screen {
|
||||
readonly kind = 'output'
|
||||
readonly id: string
|
||||
lines: Array<{ text: string; err?: boolean }> = []
|
||||
top = 0
|
||||
height = 10
|
||||
code?: number
|
||||
running = true
|
||||
|
||||
constructor(private store: AppStore, readonly command: string) {
|
||||
this.id = `output:${Date.now()}`
|
||||
}
|
||||
|
||||
title(): string {
|
||||
return `: ${this.command}`
|
||||
}
|
||||
|
||||
append(text: string, err = false): void {
|
||||
const parts = text.replace(/\r/g, '').split('\n')
|
||||
if (parts[parts.length - 1] === '') parts.pop()
|
||||
for (const p of parts) this.lines.push({ text: p, err })
|
||||
this.store.bump()
|
||||
}
|
||||
|
||||
done(code: number): void {
|
||||
this.code = code
|
||||
this.running = false
|
||||
this.store.bump()
|
||||
}
|
||||
|
||||
fkeys(): FKeyLabels {
|
||||
return { 3: null, 4: null, 6: null, 7: null, 8: null, 5: 'Re-run' }
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.store.back()
|
||||
void this.store.commands().run(this.command)
|
||||
}
|
||||
|
||||
onKey(raw: Key): boolean {
|
||||
const key: Key = raw.name === 'space' ? { name: raw.shift ? 'pageup' : 'pagedown', sequence: '' } : raw
|
||||
const max = Math.max(0, this.lines.length - this.height)
|
||||
switch (key.name) {
|
||||
case 'down': this.top = Math.min(max, this.top + 1); return true
|
||||
case 'up': this.top = Math.max(0, this.top - 1); return true
|
||||
case 'pagedown': this.top = Math.min(max, this.top + this.height - 1); return true
|
||||
case 'pageup': this.top = Math.max(0, this.top - this.height + 1); return true
|
||||
case 'home': this.top = 0; return true
|
||||
case 'end': this.top = max; return true
|
||||
case 'right': case 'left': return true
|
||||
case 'return': this.store.back(); return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
menu(): MenuEntry[] {
|
||||
return [
|
||||
{ section: 'Context', label: 'Run the command again', key: 'F5', run: () => this.refresh() },
|
||||
{ section: 'Context', label: 'Edit the command', run: () => this.store.commands().commandLine(this.command) },
|
||||
]
|
||||
}
|
||||
|
||||
render(width: number, height: number, focused: boolean) {
|
||||
const t = theme()
|
||||
this.height = height - 3
|
||||
const shown = this.lines.slice(this.top, this.top + this.height)
|
||||
const rows = shown.map((l, i) => <text key={i} fg={l.err ? t.err : t.fg}>{pad(l.text, width - 2)}</text>)
|
||||
while (rows.length < this.height) rows.push(<text key={`p${rows.length}`}>{pad('', width - 2)}</text>)
|
||||
const status = this.running ? 'running…' : `exit ${this.code}${this.code === 0 ? '' : ' (see the message above)'} · ${this.lines.length} lines · ↑↓ PgUp PgDn scroll · F5 again · Enter or Esc closes`
|
||||
return (
|
||||
<Panel title={`qqq ${this.command}`} width={width} height={height} focused={focused}>
|
||||
{rows}
|
||||
<text fg={this.code ? t.err : t.dim}>{pad(` ${status}`, width - 2)}</text>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { FormSession, ProcessError, runProcess, type FieldMeta, type FieldValues, type ProcessMeta, type ProcessStep, type QueryFilter } from '@qrun-io/qqq-core'
|
||||
import type { Key } from '../keys'
|
||||
import { charOf } from '../keys'
|
||||
import type { AppStore } from '../store'
|
||||
import { glyphs, theme } from '../theme'
|
||||
import type { FKeyLabels, MenuEntry, Screen } from '../types'
|
||||
import { wrap } from '../overlays/basic'
|
||||
import { cell, errorText, pad, trunc } from '../util/text'
|
||||
import { FormEditor } from './form'
|
||||
import { Panel } from './list'
|
||||
|
||||
class Cancelled extends Error {
|
||||
constructor() {
|
||||
super('cancelled')
|
||||
}
|
||||
}
|
||||
|
||||
export interface ProcessOptions {
|
||||
recordIds?: string[]
|
||||
filter?: QueryFilter
|
||||
values?: FieldValues
|
||||
}
|
||||
|
||||
type Phase = 'starting' | 'form' | 'review' | 'running' | 'done' | 'error' | 'cancelled'
|
||||
|
||||
/**
|
||||
* A process wizard over the kernel's runProcess: each step's form through a FormSession (the
|
||||
* provider waits for Ctrl+S), the validation review choice, progress with N/M and elapsed time,
|
||||
* summary results, and Esc to cancel (POST cancel on the server).
|
||||
*/
|
||||
export class ProcessScreen implements Screen {
|
||||
readonly kind = 'process'
|
||||
readonly id: string
|
||||
meta?: ProcessMeta
|
||||
phase: Phase = 'starting'
|
||||
step?: ProcessStep
|
||||
stepValues: FieldValues = {}
|
||||
editor?: FormEditor
|
||||
reviewChoice = 0
|
||||
progress: { message?: string; current?: number; total?: number } = {}
|
||||
stepStarted = Date.now()
|
||||
started = Date.now()
|
||||
finished?: number
|
||||
result?: { values: FieldValues; lastStep?: ProcessStep }
|
||||
error?: string
|
||||
log: string[] = []
|
||||
processUUID?: string
|
||||
private cancelled = false
|
||||
private pending?: { resolve: (v: FieldValues) => void; reject: (e: Error) => void }
|
||||
|
||||
constructor(private store: AppStore, readonly processName: string, readonly options: ProcessOptions = {}) {
|
||||
this.id = `process:${processName}:${Date.now()}`
|
||||
this.start()
|
||||
}
|
||||
|
||||
title(): string {
|
||||
return this.meta?.label ?? (this.store.meta?.processes?.[this.processName]?.label ?? this.processName)
|
||||
}
|
||||
|
||||
helpTopic() {
|
||||
return { kind: 'process' as const, name: this.processName }
|
||||
}
|
||||
|
||||
cliEquivalent(): string {
|
||||
return `process run ${this.processName}${this.options.recordIds?.length ? ` -r ${this.options.recordIds.join(',')}` : ''}`
|
||||
}
|
||||
|
||||
busy(): boolean {
|
||||
return this.phase === 'running' || this.phase === 'starting'
|
||||
}
|
||||
|
||||
dirty(): boolean {
|
||||
return this.phase === 'form' && (this.editor?.dirty() ?? false)
|
||||
}
|
||||
|
||||
capturesText(): boolean {
|
||||
return this.phase === 'form'
|
||||
}
|
||||
|
||||
/** A fetch that stops when cancelled and notes the processUUID for the cancel call. */
|
||||
private fetchFor(): typeof fetch {
|
||||
const base = this.store.client!.fetch
|
||||
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (this.cancelled) throw new Cancelled()
|
||||
const response = await base(input, init)
|
||||
if (!this.processUUID && /\/processes\//.test(String(input instanceof Request ? input.url : input))) {
|
||||
try {
|
||||
const body = await response.clone().json() as { processUUID?: string }
|
||||
if (body.processUUID) this.processUUID = body.processUUID
|
||||
} catch {
|
||||
// not JSON
|
||||
}
|
||||
}
|
||||
return response
|
||||
}) as typeof fetch
|
||||
}
|
||||
|
||||
start(): void {
|
||||
this.phase = 'starting'
|
||||
this.cancelled = false
|
||||
this.processUUID = undefined
|
||||
this.result = undefined
|
||||
this.error = undefined
|
||||
this.log = []
|
||||
this.started = Date.now()
|
||||
this.finished = undefined
|
||||
const client = this.store.client!
|
||||
const work = async () => {
|
||||
this.meta = await client.processMeta(this.processName)
|
||||
this.store.remember({ kind: 'process', name: this.processName, label: this.meta.label ?? this.processName })
|
||||
return runProcess({
|
||||
baseUrl: client.server,
|
||||
fetch: this.fetchFor(),
|
||||
meta: this.meta,
|
||||
values: this.options.values,
|
||||
records: this.options.recordIds?.length ? { ids: this.options.recordIds } : this.options.filter ? { filter: this.options.filter } : undefined,
|
||||
stepTimeoutMillis: 0,
|
||||
pollMillis: 250,
|
||||
always: true,
|
||||
onProgress: (e) => {
|
||||
if (e.phase === 'init') this.note('starting')
|
||||
else if (e.phase === 'step') {
|
||||
this.phase = 'running'
|
||||
this.stepStarted = Date.now()
|
||||
this.progress = {}
|
||||
this.note(`step ${e.step}`)
|
||||
} else if (e.phase === 'running') {
|
||||
this.phase = 'running'
|
||||
this.progress = { message: e.message, current: e.current, total: e.total }
|
||||
}
|
||||
this.store.bump()
|
||||
},
|
||||
provide: (context) => this.provide(context.step, context.fields, context.values, context.response.values ?? {}),
|
||||
})
|
||||
}
|
||||
work().then((outcome) => {
|
||||
this.result = { values: outcome.values, lastStep: outcome.lastStep }
|
||||
this.phase = 'done'
|
||||
this.finished = Date.now()
|
||||
this.note('complete')
|
||||
this.store.bump()
|
||||
}).catch((e) => {
|
||||
if (e instanceof Cancelled || this.cancelled) {
|
||||
this.phase = 'cancelled'
|
||||
} else {
|
||||
this.phase = 'error'
|
||||
this.error = e instanceof ProcessError ? e.message : errorText(e)
|
||||
}
|
||||
this.finished = Date.now()
|
||||
this.store.bump()
|
||||
})
|
||||
}
|
||||
|
||||
note(text: string): void {
|
||||
this.log.push(`${new Date().toLocaleTimeString('en-GB')} ${text}`)
|
||||
if (this.log.length > 50) this.log.shift()
|
||||
}
|
||||
|
||||
/** The runner's provider: shows the step and resolves when the person submits it. */
|
||||
private provide(step: ProcessStep, fields: FieldMeta[], values: FieldValues, serverValues: FieldValues): Promise<FieldValues> {
|
||||
this.step = step
|
||||
this.stepValues = { ...serverValues, ...values }
|
||||
const types = (step.components ?? []).map((c) => c.type)
|
||||
if (types.includes('VALIDATION_REVIEW_SCREEN') && !fields.length) {
|
||||
this.phase = 'review'
|
||||
this.reviewChoice = this.stepValues.validationSummary ? 1 : 0
|
||||
} else if (fields.length) {
|
||||
const client = this.store.client!
|
||||
const session = new FormSession({ baseUrl: client.server, fetch: this.fetchFor(), scope: { process: this.processName }, fields, initialValues: values })
|
||||
this.editor = new FormEditor(this.store, session, session.promptOrder(fields.map((f) => f.name)), { bulkEdit: types.includes('BULK_EDIT_FORM') })
|
||||
this.phase = 'form'
|
||||
} else {
|
||||
// nothing to ask (a view step that is not the last): continue
|
||||
return Promise.resolve({})
|
||||
}
|
||||
this.store.bump()
|
||||
return new Promise<FieldValues>((resolve, reject) => {
|
||||
this.pending = { resolve, reject }
|
||||
})
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
const pending = this.pending
|
||||
if (!pending) return
|
||||
if (this.phase === 'review') {
|
||||
this.pending = undefined
|
||||
this.phase = 'running'
|
||||
pending.resolve({ doFullValidation: this.reviewChoice === 0 ? 'true' : 'false' })
|
||||
return
|
||||
}
|
||||
const editor = this.editor
|
||||
if (!editor || editor.saving) return
|
||||
editor.saving = true
|
||||
editor.check().then((problems) => {
|
||||
editor.saving = false
|
||||
if (Object.keys(problems).length) {
|
||||
editor.formError = `${Object.keys(problems).length} field(s) need attention`
|
||||
editor.index = Math.max(0, editor.names.findIndex((n) => problems[n]))
|
||||
return
|
||||
}
|
||||
const out: FieldValues = {}
|
||||
const enabled = editor.options.bulkEdit ? editor.enabled : undefined
|
||||
for (const n of editor.names) if (editor.session.values[n] !== undefined && (!enabled || enabled.has(n))) out[n] = editor.session.values[n]
|
||||
if (enabled) out.bulkEditEnabledFields = [...enabled].join(',')
|
||||
this.pending = undefined
|
||||
this.phase = 'running'
|
||||
this.stepStarted = Date.now()
|
||||
pending.resolve(out)
|
||||
}).finally(() => this.store.bump())
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
if (this.phase === 'done' || this.phase === 'error' || this.phase === 'cancelled') return this.store.back()
|
||||
this.store.confirm('Cancel this process?', () => {
|
||||
this.cancelled = true
|
||||
this.pending?.reject(new Cancelled())
|
||||
this.pending = undefined
|
||||
this.phase = 'cancelled'
|
||||
const uuid = this.processUUID
|
||||
if (uuid) this.store.client!.call('cancelProcess', { path: { processName: this.processName, processUUID: uuid } }).then(() => this.note('cancelled on the server')).catch((e) => this.note(`cancel: ${errorText(e)}`)).finally(() => this.store.bump())
|
||||
})
|
||||
}
|
||||
|
||||
fkeys(): FKeyLabels {
|
||||
return { 3: null, 4: null, 6: null, 7: null, 8: null, 5: this.phase === 'done' || this.phase === 'error' || this.phase === 'cancelled' ? 'Re-run' : null }
|
||||
}
|
||||
|
||||
onKey(key: Key): boolean {
|
||||
if (key.name === 'escape') {
|
||||
this.cancel()
|
||||
return true
|
||||
}
|
||||
if (this.phase === 'form') {
|
||||
if ((key.ctrl && key.name === 's')) {
|
||||
this.submit()
|
||||
return true
|
||||
}
|
||||
return this.editor?.onKey(key) ?? true
|
||||
}
|
||||
if (this.phase === 'review') {
|
||||
if (key.name === 'up' || key.name === 'down' || key.name === 'tab') this.reviewChoice = 1 - this.reviewChoice
|
||||
else if (key.name === 'return' || (key.ctrl && key.name === 's')) this.submit()
|
||||
return true
|
||||
}
|
||||
if ((this.phase === 'done' || this.phase === 'error' || this.phase === 'cancelled') && (key.name === 'f5' || charOf(key) === 'r')) {
|
||||
this.start()
|
||||
return true
|
||||
}
|
||||
if (this.phase === 'done' && key.name === 'return') {
|
||||
this.store.back()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
menu(): MenuEntry[] {
|
||||
const finished = this.phase === 'done' || this.phase === 'error' || this.phase === 'cancelled'
|
||||
return [
|
||||
{ section: 'Context', label: 'Cancel the process', key: 'Esc', disabled: finished ? 'finished' : undefined, run: () => this.cancel() },
|
||||
{ section: 'Context', label: 'Run it again', key: 'F5 r', disabled: finished ? undefined : 'still running', run: () => this.start() },
|
||||
{ section: 'Context', label: 'Copy as a CLI command', detail: `qqq ${this.cliEquivalent()}`, run: () => this.store.message(`qqq ${this.cliEquivalent()}`, 'info', 'CLI equivalent') },
|
||||
]
|
||||
}
|
||||
|
||||
private elapsed(from: number): string {
|
||||
const s = Math.max(0, Math.round(((this.finished ?? Date.now()) - from) / 1000))
|
||||
return s >= 60 ? `${Math.floor(s / 60)}m ${s % 60}s` : `${s}s`
|
||||
}
|
||||
|
||||
private summaryLines(width: number): Array<{ text: string; fg?: string }> {
|
||||
const t = theme()
|
||||
const out: Array<{ text: string; fg?: string }> = []
|
||||
const values = this.result?.values ?? {}
|
||||
const lines = (values.processResults ?? values.recordListSummary) as Array<{ status?: string; count?: number; message?: string }> | undefined
|
||||
if (Array.isArray(lines) && lines.length) {
|
||||
for (const l of lines) {
|
||||
const status = String(l.status ?? 'INFO')
|
||||
out.push({ text: trunc(`${pad(status, 8)} ${l.count !== undefined && l.count !== null ? String(l.count).padStart(6) : ' '} ${String(l.message ?? '').replace(/\s+/g, ' ').trim()}`, width), fg: status === 'OK' ? t.ok : status === 'ERROR' ? t.err : status === 'WARNING' ? t.warn : t.fg })
|
||||
}
|
||||
return out
|
||||
}
|
||||
const viewFields = (this.result?.lastStep?.viewFields ?? []) as FieldMeta[]
|
||||
const names = viewFields.length ? viewFields.map((f) => f.name) : Object.keys(values).filter((k) => values[k] !== null && typeof values[k] !== 'object').slice(0, 30)
|
||||
const labelWidth = Math.min(30, Math.max(6, ...names.map((n) => (viewFields.find((f) => f.name === n)?.label ?? n).length)))
|
||||
for (const n of names) out.push({ text: trunc(`${pad(viewFields.find((f) => f.name === n)?.label ?? n, labelWidth)} ${cell({ values }, n)}`, width) })
|
||||
if (!out.length) out.push({ text: 'Done.' })
|
||||
return out
|
||||
}
|
||||
|
||||
render(width: number, height: number, focused: boolean) {
|
||||
const t = theme()
|
||||
const g = glyphs()
|
||||
const inner = width - 2
|
||||
const steps = (this.meta?.frontendSteps ?? []) as ProcessStep[]
|
||||
const currentIdx = steps.findIndex((s) => s.name === this.step?.name)
|
||||
const stepper = steps.map((s, i) => `${i < currentIdx || this.phase === 'done' ? g.check : i === currentIdx ? g.pointer + ' ' : g.uncheck} ${s.label ?? s.name}`).join(' ')
|
||||
const body: React.ReactNode[] = []
|
||||
const lines: Array<{ text: string; fg?: string; bold?: boolean }> = []
|
||||
const help = (this.step?.components ?? []).filter((c) => c.type === 'HELP_TEXT' || c.type === 'HTML').map((c) => String(c.values?.text ?? c.values?.html ?? '').replace(/<[^>]+>/g, ''))
|
||||
if (this.phase === 'starting') lines.push({ text: 'starting…', fg: t.dim })
|
||||
if (this.phase === 'running') {
|
||||
const p = this.progress
|
||||
const determinate = p.total !== undefined && p.total > 0 && p.current !== undefined
|
||||
const barWidth = Math.max(10, Math.min(60, inner - 30))
|
||||
const filled = determinate ? Math.round((p.current! / p.total!) * barWidth) : (Math.floor(Date.now() / 250) % barWidth)
|
||||
const bar = determinate ? g.bar.repeat(filled).padEnd(barWidth, '·') : '·'.repeat(filled) + g.bar.repeat(3) + '·'.repeat(Math.max(0, barWidth - filled - 3))
|
||||
lines.push({ text: `Running ${this.step?.label ?? this.step?.name ?? ''}`, fg: t.acc, bold: true })
|
||||
lines.push({ text: `${bar.slice(0, barWidth)} ${determinate ? `${p.current}/${p.total}` : 'working'} · ${this.elapsed(this.stepStarted)} elapsed` })
|
||||
if (p.message) lines.push({ text: p.message, fg: t.dim })
|
||||
lines.push({ text: 'Esc cancels', fg: t.dim })
|
||||
}
|
||||
if (this.phase === 'review') {
|
||||
const v = this.stepValues
|
||||
lines.push({ text: this.step?.label ?? 'Review', fg: t.acc, bold: true })
|
||||
if (v.recordCount !== undefined) lines.push({ text: `${String(v.recordCount)} record(s) will be processed.` })
|
||||
const summary = v.validationSummary as Array<{ status?: string; count?: number; message?: string }> | undefined
|
||||
for (const l of summary ?? []) lines.push({ text: `${pad(String(l.status ?? ''), 8)} ${String(l.count ?? '').padStart(5)} ${String(l.message ?? '')}`, fg: l.status === 'ERROR' ? t.err : l.status === 'OK' ? t.ok : t.fg })
|
||||
lines.push({ text: '' })
|
||||
const opts = v.validationSummary ? ['Validate again', 'Run with these results'] : ['Validate first (check every record before running)', 'Skip validation and run']
|
||||
opts.forEach((o, i) => lines.push({ text: `${i === this.reviewChoice ? g.radioOn : g.radioOff} ${o}`, fg: i === this.reviewChoice ? t.acc2 : t.fg }))
|
||||
lines.push({ text: '' })
|
||||
lines.push({ text: '↑↓ choose · Enter continues · Esc cancels', fg: t.dim })
|
||||
}
|
||||
if (this.phase === 'done') {
|
||||
lines.push({ text: `Finished in ${this.elapsed(this.started)}`, fg: t.ok, bold: true })
|
||||
lines.push(...this.summaryLines(inner))
|
||||
lines.push({ text: '' })
|
||||
lines.push({ text: 'Enter or Esc closes · F5 or r runs it again', fg: t.dim })
|
||||
}
|
||||
if (this.phase === 'error') {
|
||||
lines.push({ text: 'The process failed', fg: t.err, bold: true })
|
||||
for (const l of wrap(this.error ?? '', inner)) lines.push({ text: l, fg: t.err })
|
||||
lines.push({ text: '' })
|
||||
lines.push({ text: 'Esc closes · F5 or r runs it again', fg: t.dim })
|
||||
}
|
||||
if (this.phase === 'cancelled') {
|
||||
lines.push({ text: 'Cancelled', fg: t.warn, bold: true })
|
||||
lines.push({ text: 'Esc closes · F5 or r runs it again', fg: t.dim })
|
||||
}
|
||||
body.push(<text key="stepper" fg={t.dim}>{pad(` ${trunc(stepper, inner - 1)}`, inner)}</text>)
|
||||
body.push(<text key="sp">{pad('', inner)}</text>)
|
||||
for (const h of help) for (const l of wrap(h, inner - 2)) body.push(<text key={`h${body.length}`} fg={t.dim}>{pad(` ${l}`, inner)}</text>)
|
||||
let used = body.length
|
||||
if (this.phase === 'form' && this.editor) {
|
||||
body.push(<text key="title" fg={t.acc}><strong>{pad(` ${this.step?.label ?? this.step?.name ?? ''}${this.editor.options.bulkEdit ? ' (Ctrl+T marks a field to apply)' : ''}`, inner)}</strong></text>)
|
||||
used++
|
||||
const formHeight = Math.max(3, height - 2 - used - 1)
|
||||
body.push(<box key="form" flexDirection="column" height={formHeight}>{this.editor.render(inner, formHeight, focused)}</box>)
|
||||
body.push(<text key="ft" fg={t.dim}>{pad(this.editor.saving ? ' sending…' : ' Ctrl+S continues · Esc cancels · Tab ↓ next · Enter or typing opens a picker', inner)}</text>)
|
||||
} else {
|
||||
for (const l of lines.slice(0, Math.max(0, height - 2 - used - 4))) body.push(<text key={`l${body.length}`} fg={l.fg ?? t.fg}>{l.bold ? <strong>{pad(` ${l.text}`, inner)}</strong> : pad(` ${l.text}`, inner)}</text>)
|
||||
const logRows = Math.max(0, Math.min(this.log.length, height - 2 - body.length - 1))
|
||||
const pad2 = height - 2 - body.length - logRows
|
||||
for (let i = 0; i < pad2; i++) body.push(<text key={`p${i}`}>{pad('', inner)}</text>)
|
||||
for (const l of this.log.slice(-logRows)) body.push(<text key={`g${body.length}`} fg={t.dim}>{pad(` ${l}`, inner)}</text>)
|
||||
}
|
||||
return <Panel title={this.title()} width={width} height={height} focused={focused}>{body}</Panel>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { ResultCursor, associationGroups, childFilter, childListFields, readViewSettings, relatedRecordTarget, saveDownload, valueText, widgetToBlocks, writeViewSettings, type AssociationGroup, type FieldMeta, type QueryRecord, type RenderBlock, type TableMetaLike } from '@qrun-io/qqq-core'
|
||||
import { saveFile } from '../overlays/files-helpers'
|
||||
import type { FieldMetaData, TableMetaData } from '../client'
|
||||
import type { Key } from '../keys'
|
||||
import { charOf } from '../keys'
|
||||
import { openForm, openProcess, openRecord } from '../nav'
|
||||
import { ChoiceOverlay } from '../overlays/basic'
|
||||
import type { AppStore } from '../store'
|
||||
import { glyphs, theme } from '../theme'
|
||||
import type { FKeyLabels, MenuEntry, Overlay, Screen } from '../types'
|
||||
import { cell, errorText, fieldCell, pad, trunc } from '../util/text'
|
||||
import { blockLines } from '../widgets'
|
||||
import { ListModel, Panel, type Row } from './list'
|
||||
import { defaultColumns } from './table'
|
||||
|
||||
type FullRecord = QueryRecord & { associatedRecords?: Record<string, QueryRecord[]> }
|
||||
|
||||
/** The record a field links to (LINK adornment, or a possible-value source that is a table), through the kernel. */
|
||||
export function linkTarget(store: AppStore, field: FieldMetaData, record?: QueryRecord): string | undefined {
|
||||
const tables = Object.keys(store.meta?.tables ?? {})
|
||||
if (record) return relatedRecordTarget(field as FieldMeta, record as never, tables)?.tableName
|
||||
const pvs = field.possibleValueSourceName
|
||||
return pvs && tables.includes(pvs) ? pvs : undefined
|
||||
}
|
||||
|
||||
/** Sections in tier order (T1, then T2, then T3, then the rest), hidden ones left out. */
|
||||
export function orderedSections(meta: TableMetaData) {
|
||||
const rank = (tier?: string) => (tier === 'T1' ? 0 : tier === 'T2' ? 1 : tier === 'T3' ? 2 : 3)
|
||||
return [...(meta.sections ?? [])].filter((s) => !s.hidden).sort((a, b) => rank(a.tier) - rank(b.tier))
|
||||
}
|
||||
|
||||
/**
|
||||
* A record: sections by tier, child lists from associations, widget sections. Opened from a grid it
|
||||
* shares the grid's ResultCursor, so PgDn/PgUp step through the same result and the grid lands on
|
||||
* the last record viewed when the person goes back.
|
||||
*/
|
||||
export class RecordScreen implements Screen {
|
||||
readonly kind = 'record'
|
||||
readonly id: string
|
||||
meta?: TableMetaData
|
||||
record?: FullRecord
|
||||
error?: string
|
||||
childMeta = new Map<string, TableMetaData>()
|
||||
widgets = new Map<string, RenderBlock[] | { error: string }>()
|
||||
readonly list = new ListModel()
|
||||
width = 100
|
||||
private generation = 0
|
||||
|
||||
constructor(private store: AppStore, readonly tableName: string, public primaryKey: string | undefined, readonly cursor?: ResultCursor, private label?: string) {
|
||||
this.id = `record:${tableName}:${Date.now()}`
|
||||
this.load()
|
||||
}
|
||||
|
||||
title(): string {
|
||||
return String(this.record?.recordLabel ?? this.label ?? this.primaryKey ?? '')
|
||||
}
|
||||
|
||||
presenceScope(): string {
|
||||
return `record:${this.tableName}:${this.primaryKey}`
|
||||
}
|
||||
|
||||
helpTopic() {
|
||||
return { kind: 'table' as const, name: this.tableName }
|
||||
}
|
||||
|
||||
cliEquivalent(): string {
|
||||
return `table get ${this.tableName} ${this.primaryKey}`
|
||||
}
|
||||
|
||||
load(): void {
|
||||
const client = this.store.client!
|
||||
const gen = ++this.generation
|
||||
const work = async () => {
|
||||
this.meta ??= await client.tableMeta(this.tableName)
|
||||
if (this.cursor && this.primaryKey === undefined) this.primaryKey = String(this.cursor.keyOf(await this.cursor.current()) ?? '')
|
||||
if (this.primaryKey === undefined || this.primaryKey === '') throw new Error('no record')
|
||||
const record = await client.record(this.tableName, this.primaryKey, Boolean(this.meta.associations?.length))
|
||||
for (const a of this.meta.associations ?? []) if (!this.childMeta.has(a.associatedTableName)) this.childMeta.set(a.associatedTableName, await client.tableMeta(a.associatedTableName))
|
||||
return record
|
||||
}
|
||||
work().then((record) => {
|
||||
if (gen !== this.generation) return
|
||||
this.record = record
|
||||
this.error = undefined
|
||||
this.label = record.recordLabel
|
||||
this.store.remember({ kind: 'record', table: this.tableName, key: this.primaryKey, label: record.recordLabel ?? String(this.primaryKey) })
|
||||
this.loadWidgets()
|
||||
this.build()
|
||||
this.store.bump()
|
||||
}).catch((e) => {
|
||||
if (gen !== this.generation) return
|
||||
this.error = errorText(e)
|
||||
this.build()
|
||||
this.store.bump()
|
||||
})
|
||||
}
|
||||
|
||||
loadWidgets(): void {
|
||||
for (const s of this.meta?.sections ?? []) {
|
||||
if (!s.widgetName) continue
|
||||
const name = s.widgetName
|
||||
this.store.client!.widget(name, { id: this.primaryKey }).then((d) => this.widgets.set(name, widgetToBlocks(d))).catch((e) => this.widgets.set(name, { error: errorText(e) })).finally(() => { this.build(); this.store.bump() })
|
||||
}
|
||||
}
|
||||
|
||||
/** Steps through the result the record came from. */
|
||||
step(direction: 1 | -1): void {
|
||||
const c = this.cursor
|
||||
if (!c) return this.store.flash('this record was not opened from a list; there is nothing to step through')
|
||||
this.store.run((direction === 1 ? c.next() : c.previous()).then((rec) => {
|
||||
if (!rec) return this.store.flash(direction === 1 ? 'last record of the result' : 'first record of the result')
|
||||
this.primaryKey = String(c.keyOf(rec))
|
||||
this.label = rec.recordLabel
|
||||
this.record = undefined
|
||||
this.list.cursor = 0
|
||||
this.list.top = 0
|
||||
this.widgets.clear()
|
||||
this.load()
|
||||
}))
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
this.load()
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.widgets.clear()
|
||||
this.load()
|
||||
}
|
||||
|
||||
view() {
|
||||
return readViewSettings(`record:${this.tableName}`)
|
||||
}
|
||||
|
||||
build(): void {
|
||||
const t = theme()
|
||||
const g = glyphs()
|
||||
const rows: Row[] = []
|
||||
const meta = this.meta
|
||||
const rec = this.record
|
||||
if (this.error && !rec) {
|
||||
rows.push({ text: this.error, fg: t.err })
|
||||
this.list.setRows(rows)
|
||||
return
|
||||
}
|
||||
if (!meta || !rec) {
|
||||
rows.push({ text: 'loading…', fg: t.dim })
|
||||
this.list.setRows(rows)
|
||||
return
|
||||
}
|
||||
const inner = this.width - 2
|
||||
const view = this.view()
|
||||
const labelWidth = view.labelWidth ?? Math.min(28, Math.max(8, ...Object.values(meta.fields).map((f) => f.label.length + 1)))
|
||||
rows.push({ text: `${rec.recordLabel ?? this.primaryKey}${this.cursor ? ` (${this.cursor.describe()})` : ''}`, fg: t.fg, bold: true })
|
||||
for (const s of orderedSections(meta)) {
|
||||
if (s.widgetName) {
|
||||
rows.push({ text: '' })
|
||||
rows.push({ text: `${s.label} ${g.rule.repeat(Math.max(0, Math.min(60, inner - s.label.length - 2)))}`, fg: t.acc, bold: true })
|
||||
const w = this.widgets.get(s.widgetName)
|
||||
if (!w) rows.push({ text: ' loading…', fg: t.dim })
|
||||
else if ('error' in w) rows.push({ text: ` ${w.error}`, fg: t.err })
|
||||
else rows.push(...blockLines(w.filter((b) => b.kind !== 'heading'), inner - 2).map((l) => ({ ...l, text: ' ' + l.text })))
|
||||
continue
|
||||
}
|
||||
const fields = (s.fieldNames ?? []).map((n) => meta.fields[n]).filter((f): f is FieldMetaData => Boolean(f) && !f!.isHidden)
|
||||
if (!fields.length) continue
|
||||
rows.push({ text: '' })
|
||||
rows.push({ text: `${s.label}${s.tier ? ` (${s.tier})` : ''} ${g.rule.repeat(Math.max(0, Math.min(60, inner - s.label.length - 8)))}`, fg: t.acc, bold: true })
|
||||
const tableNames = Object.keys(this.store.meta?.tables ?? {})
|
||||
const valueOf = (f: FieldMetaData) => valueText(f as FieldMeta, rec as never, { reveal: this.revealed.has(f.name), tableNames, tableName: this.tableName, primaryKey: this.primaryKey, widgets: this.store.meta?.widgets as never }).text
|
||||
const longest = Math.max(...fields.map((f) => valueOf(f).length + (linkTarget(this.store, f) ? 2 : 0)), 4)
|
||||
const colWidth = Math.min(60, labelWidth + 2 + longest)
|
||||
const two = (view.recordColumns ?? (inner >= 2 * colWidth + 6 ? 2 : 1)) === 2 && inner >= 2 * Math.min(colWidth, 30) + 6
|
||||
const fieldText = (f: FieldMetaData) => {
|
||||
const v = valueText(f as FieldMeta, rec as never, { tableNames, widgets: this.store.meta?.widgets as never })
|
||||
const link = v.link ? ` ${g.link}` : v.file ? ' [file: Enter saves]' : v.masked ? ' [Enter reveals]' : ''
|
||||
return `${pad(f.label, labelWidth)} ${trunc(valueOf(f) + link, (two ? colWidth : Math.min(inner - 4, 100)) - labelWidth - 2)}`
|
||||
}
|
||||
for (let i = 0; i < fields.length; i += two ? 2 : 1) {
|
||||
const pair = two ? fields.slice(i, i + 2) : [fields[i]!]
|
||||
const links = pair.filter((f) => { const v = valueText(f as FieldMeta, rec as never, { tableNames }); return Boolean(v.link || v.file || v.masked) })
|
||||
const text = ' ' + (two ? pad(fieldText(pair[0]!), colWidth) + ' ' + (pair[1] ? fieldText(pair[1]) : '') : fieldText(pair[0]!))
|
||||
rows.push({ text, key: `f:${pair[0]!.name}`, action: () => this.follow(links) })
|
||||
}
|
||||
}
|
||||
const tables: Record<string, TableMetaLike | undefined> = Object.fromEntries([...this.childMeta].map(([k, v]) => [k, v as unknown as TableMetaLike]))
|
||||
this.groups = associationGroups(rec as never, meta as unknown as TableMetaLike, tables, { widgets: this.store.meta?.widgets as never, maxDepth: 1 })
|
||||
this.groups.forEach((group, gi) => this.childRows(group, gi, rows, inner))
|
||||
this.list.setRows(rows)
|
||||
// back from a child record: land on the child that was viewed last (it may have stepped)
|
||||
if (this.openedChild) {
|
||||
const key = `c:${this.openedChild.gi}:${this.openedChild.cursor.index}`
|
||||
const at = rows.findIndex((r) => r.key === key)
|
||||
if (at >= 0) this.list.cursor = at
|
||||
}
|
||||
}
|
||||
|
||||
/** The child list and cursor of the child record opened last. */
|
||||
openedChild?: { gi: number; cursor: ResultCursor }
|
||||
|
||||
groups: AssociationGroup[] = []
|
||||
revealed = new Set<string>()
|
||||
|
||||
childRows(group: AssociationGroup, gi: number, rows: Row[], inner: number): void {
|
||||
const t = theme()
|
||||
const g = glyphs()
|
||||
const child = this.childMeta.get(group.table)
|
||||
const records = group.records as QueryRecord[]
|
||||
rows.push({ text: '' })
|
||||
rows.push({ text: `${group.label} (${records.length}) ${g.rule.repeat(Math.max(0, Math.min(60, inner - group.label.length - 8)))}`, fg: t.acc, bold: true, key: `c:${gi}`, action: () => this.newChild(group) })
|
||||
if (group.unavailable || !child) {
|
||||
rows.push({ text: ` ${group.unavailable ?? 'no metadata'}`, fg: t.dim })
|
||||
return
|
||||
}
|
||||
const binding = (group.association.join?.joinOns ?? []).map((j) => j.rightField)
|
||||
const names = childListFields(child as unknown as TableMetaLike, binding)
|
||||
const widths = names.map((n) => Math.min(30, Math.max(child.fields[n]!.label.length, ...records.map((r) => fieldCell(r, child.fields[n]!).length))))
|
||||
rows.push({ text: ' ' + names.map((n, i) => pad(child.fields[n]!.label, widths[i]!)).join(' '), fg: t.dim })
|
||||
records.forEach((r, idx) => {
|
||||
rows.push({ text: ' ' + names.map((n, i) => pad(fieldCell(r, child.fields[n]!), widths[i]!)).join(' '), key: `c:${gi}:${idx}`, action: () => this.openChild(group, idx) })
|
||||
})
|
||||
if (!records.length) rows.push({ text: ' (none) · Enter on the heading or F7 adds one', fg: t.dim })
|
||||
}
|
||||
|
||||
/** The filter selecting this record's children of a group (kernel childFilter). */
|
||||
childFilterOf(group: AssociationGroup) {
|
||||
const child = this.childMeta.get(group.table)!
|
||||
const f = childFilter(group.association, this.meta as unknown as TableMetaLike, this.record as never, child as unknown as TableMetaLike)
|
||||
return { criteria: f.criteria, booleanOperator: f.booleanOperator, ...(f.subFilters ? { subFilters: f.subFilters } : {}) }
|
||||
}
|
||||
|
||||
/** Opens a child record with a cursor over its siblings, so PgDn steps through them. */
|
||||
openChild(group: AssociationGroup, index: number): void {
|
||||
const child = this.childMeta.get(group.table)
|
||||
if (!child) return
|
||||
const records = group.records as QueryRecord[]
|
||||
const cursor = new ResultCursor({ baseUrl: this.store.client!.server, fetch: this.store.client!.fetch, table: group.table, filter: this.childFilterOf(group), pageSize: 50, primaryKeyField: child.primaryKeyField, index })
|
||||
cursor.seed(0, records.slice(0, 50), records.length)
|
||||
this.openedChild = { gi: this.groups.indexOf(group), cursor }
|
||||
openRecord(this.store, group.table, String(records[index]?.values?.[child.primaryKeyField] ?? ''), { cursor, label: records[index]?.recordLabel })
|
||||
}
|
||||
|
||||
newChild(group: AssociationGroup): void {
|
||||
const values = Object.fromEntries((group.association.join?.joinOns ?? []).map((j) => [j.rightField, this.record?.values?.[j.leftField]]))
|
||||
openForm(this.store, group.table, { mode: 'create', initial: values })
|
||||
}
|
||||
|
||||
/** Saves a FILE_DOWNLOAD field to a file (kernel fileDownload, safe names, no overwrite unless named). */
|
||||
download(f: FieldMetaData): void {
|
||||
const v = valueText(f as FieldMeta, this.record as never, {})
|
||||
const file = v.file
|
||||
if (!file) return
|
||||
if (!file.route) return this.store.message(`This file is outside the server: ${file.url ?? ''}`, 'info', 'Download')
|
||||
const route = file.route
|
||||
saveFile(this.store, `Save ${file.fileName}`, file.fileName, (out) => {
|
||||
this.store.run(this.store.client!.call('downloadRecordFieldFile', { path: { tableName: route.tableName, primaryKey: route.primaryKey, fieldName: route.fieldName, filename: route.filename } }), (r) => {
|
||||
const bytes = r.bytes ?? new TextEncoder().encode(typeof r.data === 'string' ? r.data : JSON.stringify(r.data))
|
||||
const saved = saveDownload(out, bytes, { explicit: true })
|
||||
this.store.message(`Saved ${saved} (${bytes.length} bytes).`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
follow(fields: FieldMetaData[]): void {
|
||||
const go = (f: FieldMetaData) => {
|
||||
const v = valueText(f as FieldMeta, this.record as never, { tableNames: Object.keys(this.store.meta?.tables ?? {}) })
|
||||
if (v.file) return this.download(f)
|
||||
if (v.masked) {
|
||||
this.revealed.add(f.name)
|
||||
return this.build()
|
||||
}
|
||||
if (v.link && 'tableName' in v.link) return openRecord(this.store, v.link.tableName, v.link.primaryKey)
|
||||
if (v.link && 'url' in v.link) return this.store.message(v.link.url, 'info', 'Link (open it in a browser)')
|
||||
}
|
||||
if (!fields.length) return this.store.flash('no link on this line (links are marked ' + glyphs().link + ')')
|
||||
if (fields.length === 1) return go(fields[0]!)
|
||||
this.store.open(new ChoiceOverlay(this.store, 'Follow which link?', fields.map((f) => ({ label: f.label, value: f, detail: cell(this.record, f.name) })), go))
|
||||
}
|
||||
|
||||
currentAssociation(): AssociationGroup | undefined {
|
||||
const key = this.list.current?.key ?? ''
|
||||
if (!key.startsWith('c:')) return undefined
|
||||
return this.groups[Number(key.split(':')[1])]
|
||||
}
|
||||
|
||||
deleteRecord(): void {
|
||||
if (this.meta?.deletePermission === false) return this.store.flash('you may not delete records of this table', 'error')
|
||||
this.store.confirm(`Delete ${this.meta?.label ?? this.tableName} ${this.primaryKey}${this.record?.recordLabel ? ` "${this.record.recordLabel}"` : ''}?`, () => {
|
||||
this.store.run(this.store.client!.remove(this.tableName, this.primaryKey), () => {
|
||||
this.store.flash(`deleted ${this.meta?.label ?? this.tableName} ${this.primaryKey}`)
|
||||
const c = this.cursor
|
||||
if (!c) return this.store.back()
|
||||
c.removed(c.index)
|
||||
if (c.total === 0) return this.store.back()
|
||||
this.primaryKey = undefined
|
||||
this.record = undefined
|
||||
this.load()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
recordProcesses() {
|
||||
return Object.values(this.store.meta?.processes ?? {}).filter((p) => p.tableName === this.tableName && !/bulkInsert|bulkEditWithFile|bulkLoad/i.test(p.name))
|
||||
}
|
||||
|
||||
fkeys(): FKeyLabels {
|
||||
return { 3: null, 4: this.meta?.editPermission === false ? null : 'Edit', 6: null, 7: this.currentAssociation() ? 'NewChild' : this.meta?.insertPermission === false ? null : 'New', 8: this.meta?.deletePermission === false ? null : 'Delete' }
|
||||
}
|
||||
|
||||
onKey(key: Key): boolean {
|
||||
const ch = charOf(key)
|
||||
if (key.name === 'pagedown' || ch === ']' || (key.ctrl && key.name === 'n')) {
|
||||
if (this.cursor) this.step(1)
|
||||
else this.list.onKey({ name: 'pagedown', sequence: '' })
|
||||
return true
|
||||
}
|
||||
if (key.name === 'pageup' || ch === '[' || (key.ctrl && key.name === 'p')) {
|
||||
if (this.cursor) this.step(-1)
|
||||
else this.list.onKey({ name: 'pageup', sequence: '' })
|
||||
return true
|
||||
}
|
||||
switch (key.name) {
|
||||
case 'f4': if (this.primaryKey !== undefined) openForm(this.store, this.tableName, { mode: 'edit', primaryKey: this.primaryKey }); return true
|
||||
case 'f7': {
|
||||
const a = this.currentAssociation()
|
||||
if (a) this.newChild(a)
|
||||
else openForm(this.store, this.tableName, { mode: 'create' })
|
||||
return true
|
||||
}
|
||||
case 'f8': this.deleteRecord(); return true
|
||||
default: break
|
||||
}
|
||||
if (this.list.onKey(key)) return true
|
||||
if (!this.store.singleKeys) return false
|
||||
switch (ch) {
|
||||
case 'e': return this.onKey({ name: 'f4', sequence: '' })
|
||||
case 'c': if (this.primaryKey !== undefined) openForm(this.store, this.tableName, { mode: 'copy', primaryKey: this.primaryKey }); return true
|
||||
case 'd': this.deleteRecord(); return true
|
||||
case 'n': openForm(this.store, this.tableName, { mode: 'create' }); return true
|
||||
case 'r': this.refresh(); return true
|
||||
case 'l': this.list.current?.action?.(); return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
menu(): MenuEntry[] {
|
||||
const out: MenuEntry[] = [
|
||||
{ section: 'Context', label: 'Edit this record', key: 'F4 e', run: () => this.onKey({ name: 'f4', sequence: '' }) },
|
||||
{ section: 'Context', label: 'Copy into a new record', key: 'c', run: () => openForm(this.store, this.tableName, { mode: 'copy', primaryKey: this.primaryKey }) },
|
||||
{ section: 'Context', label: 'Delete this record', key: 'F8 d', run: () => this.deleteRecord() },
|
||||
{ section: 'Context', label: `New ${this.meta?.label ?? this.tableName}`, key: 'F7 n', run: () => openForm(this.store, this.tableName, { mode: 'create' }) },
|
||||
{ section: 'Context', label: 'Next record', key: 'PgDn ] Ctrl+N', disabled: this.cursor ? undefined : 'not opened from a list', run: () => this.step(1) },
|
||||
{ section: 'Context', label: 'Previous record', key: 'PgUp [ Ctrl+P', disabled: this.cursor ? undefined : 'not opened from a list', run: () => this.step(-1) },
|
||||
{ section: 'Context', label: 'Refresh', key: 'F5 r', run: () => this.refresh() },
|
||||
{ section: 'Context', label: 'View settings (columns, label width)', key: 'v', run: () => { const o = this.viewSettings(); if (o) this.store.open(o) } },
|
||||
{ section: 'Context', label: 'Copy as a CLI command', detail: `qqq ${this.cliEquivalent()}`, run: () => this.store.message(`qqq ${this.cliEquivalent()}`, 'info', 'CLI equivalent') },
|
||||
]
|
||||
for (const group of this.groups) {
|
||||
out.push({ section: 'Context', label: `New ${group.label} for this record`, key: 'F7', run: () => this.newChild(group) })
|
||||
out.push({ section: 'Context', label: `Query all ${group.label} of this record`, run: () => import('../nav').then((n) => n.openTable(this.store, group.table, this.childFilterOf(group))) })
|
||||
}
|
||||
const tableNames = Object.keys(this.store.meta?.tables ?? {})
|
||||
for (const f of Object.values(this.meta?.fields ?? {})) {
|
||||
if (!this.record) break
|
||||
const v = valueText(f as FieldMeta, this.record as never, { tableNames })
|
||||
if (v.link) out.push({ section: 'Context', label: `Follow ${f.label}`, detail: cell(this.record, f.name), run: () => this.follow([f]) })
|
||||
if (v.file) out.push({ section: 'Context', label: `Download ${f.label}`, detail: v.file.fileName, run: () => this.download(f) })
|
||||
if (v.masked) out.push({ section: 'Context', label: `Reveal ${f.label}`, run: () => this.follow([f]) })
|
||||
}
|
||||
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)] }) })
|
||||
out.push({ section: 'Context', label: 'Audit history', command: `audit list ${this.tableName} ${this.primaryKey}`, run: () => void this.store.commands().run(`audit list ${this.tableName} ${this.primaryKey}`) })
|
||||
return out
|
||||
}
|
||||
|
||||
viewSettings(): Overlay | undefined {
|
||||
const key = `record:${this.tableName}`
|
||||
const v = readViewSettings(key)
|
||||
const choices = [
|
||||
{ label: 'Automatic columns (two when there is room)', value: 'auto' },
|
||||
{ label: 'One column', value: '1' },
|
||||
{ label: 'Two columns', value: '2' },
|
||||
{ label: 'Label width: narrower (-4)', value: 'narrow' },
|
||||
{ label: 'Label width: wider (+4)', value: 'wide' },
|
||||
{ label: 'Reset record view settings', value: 'reset' },
|
||||
]
|
||||
return new ChoiceOverlay(this.store, `Record view settings · ${this.meta?.label ?? this.tableName}`, choices, (value) => {
|
||||
const next = { ...v }
|
||||
if (value === 'auto') delete next.recordColumns
|
||||
else if (value === '1' || value === '2') next.recordColumns = Number(value) as 1 | 2
|
||||
else if (value === 'narrow' || value === 'wide') next.labelWidth = Math.max(6, Math.min(40, (v.labelWidth ?? 20) + (value === 'wide' ? 4 : -4)))
|
||||
writeViewSettings(key, value === 'reset' ? undefined : next)
|
||||
this.build()
|
||||
})
|
||||
}
|
||||
|
||||
render(width: number, height: number, focused: boolean) {
|
||||
if (width !== this.width) {
|
||||
this.width = width
|
||||
this.build()
|
||||
}
|
||||
const t = theme()
|
||||
const hint = this.cursor ? `${this.cursor.describe()} · PgDn/PgUp next/previous · Enter follows ${glyphs().link} or opens a child · Esc back` : `Enter follows ${glyphs().link} or opens a child · Esc back`
|
||||
return (
|
||||
<Panel title={`${this.meta?.label ?? this.tableName} › ${this.title()}`} width={width} height={height} focused={focused}>
|
||||
{this.list.view(width - 2, height - 3, focused)}
|
||||
<text fg={t.dim}>{pad(` ${hint}`, width - 2)}</text>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import type { AppTreeNode } from '../client'
|
||||
import type { Key } from '../keys'
|
||||
import { openNode } from '../nav'
|
||||
import type { AppStore, SidebarLike } from '../store'
|
||||
import { glyphs, theme } from '../theme'
|
||||
import { pad } from '../util/text'
|
||||
import { Panel } from './list'
|
||||
|
||||
export interface FlatNode {
|
||||
node: AppTreeNode
|
||||
depth: number
|
||||
expanded: boolean
|
||||
path: string
|
||||
}
|
||||
|
||||
/** The visible nodes of the app tree, descending only into expanded nodes; order as the server gives it. */
|
||||
export function flatten(nodes: AppTreeNode[], expanded: Set<string>, depth = 0, prefix = ''): FlatNode[] {
|
||||
const out: FlatNode[] = []
|
||||
for (const n of nodes) {
|
||||
const path = `${prefix}/${n.name}`
|
||||
const open = expanded.has(path)
|
||||
out.push({ node: n, depth, expanded: open, path })
|
||||
if (open && n.children?.length) out.push(...flatten(n.children, expanded, depth + 1, path))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The Apps tree pane (left), from metadata appTree. */
|
||||
export class Sidebar implements SidebarLike {
|
||||
expanded = new Set<string>()
|
||||
cursor = 0
|
||||
top = 0
|
||||
|
||||
constructor(private store: AppStore) {
|
||||
for (const n of this.tree()) this.expanded.add(`/${n.name}`)
|
||||
}
|
||||
|
||||
tree(): AppTreeNode[] {
|
||||
return (this.store.meta?.appTree ?? []) as AppTreeNode[]
|
||||
}
|
||||
|
||||
onKey(key: Key): boolean {
|
||||
const flat = flatten(this.tree(), this.expanded)
|
||||
const cur = flat[this.cursor]
|
||||
switch (key.name) {
|
||||
case 'down': this.cursor = Math.min(flat.length - 1, this.cursor + 1); return true
|
||||
case 'up': this.cursor = Math.max(0, this.cursor - 1); return true
|
||||
case 'pagedown': this.cursor = Math.min(flat.length - 1, this.cursor + 10); return true
|
||||
case 'pageup': this.cursor = Math.max(0, this.cursor - 10); return true
|
||||
case 'home': this.cursor = 0; return true
|
||||
case 'end': this.cursor = flat.length - 1; return true
|
||||
case 'right':
|
||||
if (cur?.node.children?.length) this.expanded.add(cur.path)
|
||||
return true
|
||||
case 'left':
|
||||
if (cur?.expanded) this.expanded.delete(cur.path)
|
||||
else if (cur && cur.depth > 0) this.cursor = Math.max(0, flat.findIndex((f) => f.path === cur.path.slice(0, cur.path.lastIndexOf('/'))))
|
||||
return true
|
||||
case 'space':
|
||||
if (cur?.node.children?.length) {
|
||||
if (cur.expanded) this.expanded.delete(cur.path)
|
||||
else this.expanded.add(cur.path)
|
||||
}
|
||||
return true
|
||||
case 'return':
|
||||
if (!cur) return true
|
||||
openNode(this.store, cur.node)
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
render(width: number, height: number, focused: boolean) {
|
||||
const t = theme()
|
||||
const g = glyphs()
|
||||
const flat = flatten(this.tree(), this.expanded)
|
||||
const inner = height - 2
|
||||
if (this.cursor < this.top) this.top = this.cursor
|
||||
if (this.cursor >= this.top + inner) this.top = this.cursor - inner + 1
|
||||
const current = (this.store.screen as { tableName?: string; processName?: string; appName?: string } | undefined)
|
||||
const here = current?.tableName ?? current?.processName ?? current?.appName
|
||||
const lines = flat.slice(this.top, this.top + inner).map((f, i) => {
|
||||
const idx = this.top + i
|
||||
const glyph = f.node.children?.length ? (f.expanded ? g.open : g.closed) + ' ' : ' '
|
||||
const marker = f.node.type === 'PROCESS' ? g.process + ' ' : f.node.type === 'REPORT' ? g.report + ' ' : ''
|
||||
const text = pad(' '.repeat(f.depth) + glyph + marker + f.node.label, width - 3) + (f.node.name === here ? '◂' : ' ')
|
||||
const sel = idx === this.cursor
|
||||
return <text key={f.path} fg={sel && focused ? t.selFg : f.node.type === 'APP' ? t.fg : t.dim} bg={sel ? (focused ? t.selBg : t.selInactiveBg) : undefined}>{text}</text>
|
||||
})
|
||||
while (lines.length < inner) lines.push(<text key={`p${lines.length}`}>{pad('', width - 2)}</text>)
|
||||
return <Panel title={focused ? 'Apps' : 'Apps (Tab)'} width={width} height={height} focused={focused}>{lines}</Panel>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { UiApiError, decodeJwtClaims, deviceSignIn, getAuthenticationMetaData, getNativeSignInConfig } from '@qrun-io/qqq-ui-api-client'
|
||||
import { loginFetch, profileNameForServer, readConfig, readLogin, signInWithSession, writeLogin, type SecretStore } from '@qrun-io/qqq-core'
|
||||
import { TUI_CHANNEL, TuiClient } from '../client'
|
||||
import type { Key } from '../keys'
|
||||
import { PromptOverlay } from '../overlays/basic'
|
||||
import type { AppStore, Target } from '../store'
|
||||
import { glyphs, theme } from '../theme'
|
||||
import type { FKeyLabels, Screen } from '../types'
|
||||
import { errorText, pad } from '../util/text'
|
||||
import { HomeScreen } from './home'
|
||||
import { ListModel, Panel, type Row } from './list'
|
||||
|
||||
export interface SignInDeps {
|
||||
secrets: () => SecretStore
|
||||
fetch?: typeof fetch
|
||||
/** Runs after the home screen is shown (the route given on the command line). */
|
||||
afterConnect?: (store: AppStore) => void
|
||||
timers?: boolean
|
||||
}
|
||||
|
||||
/** Connects the store to a profile: the stored sign-in, a client, metadata; then the home screen. */
|
||||
export async function connect(store: AppStore, target: Target, deps: SignInDeps): Promise<void> {
|
||||
const store_ = deps.secrets()
|
||||
const login = await readLogin(store_, target)
|
||||
if (!login && !process.env.QQQ_TOKEN) throw new NeedsSignIn()
|
||||
const fetchImpl = loginFetch({ store: store_, target, clientChannel: TUI_CHANNEL, fetch: deps.fetch })
|
||||
const client = new TuiClient({ server: target.server, fetch: fetchImpl })
|
||||
store.target = target
|
||||
store.status.profile = target.name
|
||||
store.status.server = target.server
|
||||
if (login?.kind === 'session') {
|
||||
const u = login.user ?? {}
|
||||
store.status.user = String(u.displayName ?? u.name ?? u.email ?? u.username ?? '')
|
||||
store.status.auth = `${login.authType.toLowerCase()} session`
|
||||
} else if (login) {
|
||||
const claims = decodeJwtClaims(login.tokens.accessToken)
|
||||
store.status.user = String(claims.email ?? claims.preferred_username ?? claims.sub ?? '')
|
||||
store.status.auth = 'oauth2'
|
||||
} else {
|
||||
store.status.auth = 'QQQ_TOKEN'
|
||||
}
|
||||
try {
|
||||
await store.connect(client, { timers: deps.timers })
|
||||
} catch (e) {
|
||||
if (e instanceof UiApiError && (e.status === 401 || e.status === 403)) throw new NeedsSignIn('your sign-in expired; sign in again')
|
||||
throw e
|
||||
}
|
||||
while (store.routes.length) store.routes.pop()?.dispose?.()
|
||||
store.push(new HomeScreen(store))
|
||||
deps.afterConnect?.(store)
|
||||
}
|
||||
|
||||
export class NeedsSignIn extends Error {
|
||||
constructor(message = 'not signed in') {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/** The sign-in screen: pick a profile (or a new server); MOCK/anonymous get a session, OAuth2 uses the device flow. */
|
||||
export class SignInScreen implements Screen {
|
||||
readonly kind = 'signin'
|
||||
readonly id = 'signin'
|
||||
readonly list = new ListModel()
|
||||
status = ''
|
||||
device?: { url: string; code: string; expires: number }
|
||||
connecting = false
|
||||
|
||||
constructor(private store: AppStore, private deps: SignInDeps, private initial?: Target, note?: string) {
|
||||
this.status = note ?? ''
|
||||
this.build()
|
||||
}
|
||||
|
||||
title(): string {
|
||||
return 'Sign in'
|
||||
}
|
||||
|
||||
fkeys(): FKeyLabels {
|
||||
return { 3: null, 4: null, 5: null, 6: null, 7: null, 8: null }
|
||||
}
|
||||
|
||||
build(): void {
|
||||
const t = theme()
|
||||
const g = glyphs()
|
||||
const config = readConfig()
|
||||
const rows: Row[] = [{ text: 'Choose a profile (a server and its sign-in), or connect to another server.', fg: t.dim }, { text: '' }]
|
||||
const names = Object.keys(config.profiles)
|
||||
if (this.initial && !config.profiles[this.initial.name]) rows.push({ text: ` ${g.pointer} ${pad(this.initial.name, 24)} ${this.initial.server} (new)`, key: `p:${this.initial.name}`, action: () => this.choose(this.initial!) })
|
||||
for (const n of names) {
|
||||
const p = config.profiles[n]!
|
||||
rows.push({ text: ` ${n === config.defaultProfile ? '*' : ' '} ${pad(n, 24)} ${p.server}`, key: `p:${n}`, action: () => this.choose({ name: n, server: p.server }) })
|
||||
}
|
||||
rows.push({ text: ` ${pad('Connect to another server…', 24)}`, key: 'new', action: () => this.newServer() })
|
||||
rows.push({ text: '' })
|
||||
rows.push({ text: '* is the default profile · Enter signs in · F10 quits', fg: t.dim })
|
||||
this.list.setRows(rows)
|
||||
if (this.initial) {
|
||||
const i = this.list.rows.findIndex((r) => r.key === `p:${this.initial!.name}`)
|
||||
if (i >= 0) this.list.cursor = i
|
||||
}
|
||||
}
|
||||
|
||||
newServer(): void {
|
||||
this.store.open(new PromptOverlay(this.store, 'Connect to a server', 'The server URL, e.g. http://localhost:8077. A profile named after the host is created.', (url) => {
|
||||
try {
|
||||
const origin = new URL(url.trim()).origin
|
||||
this.choose({ name: profileNameForServer(origin), server: origin })
|
||||
} catch {
|
||||
this.status = `not a URL: ${url}`
|
||||
}
|
||||
}, 'http://localhost:8077'))
|
||||
}
|
||||
|
||||
/** Uses the stored sign-in, or signs in now. */
|
||||
choose(target: Target): void {
|
||||
if (this.connecting) return
|
||||
this.connecting = true
|
||||
this.status = `connecting to ${target.server}…`
|
||||
this.store.bump()
|
||||
connect(this.store, target, this.deps).catch(async (e) => {
|
||||
if (!(e instanceof NeedsSignIn)) throw e
|
||||
await this.signIn(target)
|
||||
await connect(this.store, target, this.deps)
|
||||
}).catch((e) => {
|
||||
this.status = errorText(e)
|
||||
}).finally(() => {
|
||||
this.connecting = false
|
||||
this.device = undefined
|
||||
this.store.bump()
|
||||
})
|
||||
}
|
||||
|
||||
async signIn(target: Target): Promise<void> {
|
||||
const secrets = this.deps.secrets()
|
||||
const auth = await getAuthenticationMetaData(target.server, this.deps.fetch)
|
||||
const type = String(auth.type ?? '').toUpperCase()
|
||||
if (type === 'MOCK' || type === 'FULLY_ANONYMOUS') {
|
||||
this.status = `${type.toLowerCase()} authentication: creating a session…`
|
||||
this.store.bump()
|
||||
await signInWithSession(secrets, target, type, TUI_CHANNEL, this.deps.fetch)
|
||||
return
|
||||
}
|
||||
if (type === 'OAUTH2' || type === 'AUTH_0') {
|
||||
const config = await getNativeSignInConfig(target.server, this.deps.fetch)
|
||||
const tokens = await deviceSignIn(config, (prompt) => {
|
||||
this.device = { url: prompt.verificationUriComplete ?? prompt.verificationUri, code: prompt.userCode, expires: Date.now() + prompt.expiresIn * 1000 }
|
||||
this.status = 'waiting for approval in the browser…'
|
||||
this.store.bump()
|
||||
}, { fetch: this.deps.fetch })
|
||||
await writeLogin(secrets, target, { kind: 'oauth', config, tokens })
|
||||
return
|
||||
}
|
||||
if (type === 'TABLE_BASED') throw new Error('TABLE_BASED sign-in (username and password) is not supported yet; set QQQ_TOKEN to a session UUID.')
|
||||
throw new Error(`unknown authentication type "${auth.type}"`)
|
||||
}
|
||||
|
||||
onKey(key: Key): boolean {
|
||||
if (key.name === 'escape') return true
|
||||
return this.list.onKey(key)
|
||||
}
|
||||
|
||||
render(width: number, height: number, focused: boolean) {
|
||||
const t = theme()
|
||||
const extra: React.ReactNode[] = []
|
||||
if (this.device) {
|
||||
const left = Math.max(0, Math.round((this.device.expires - Date.now()) / 1000))
|
||||
extra.push(<text key="d1" fg={t.acc}>{pad(' Open this address in a browser:', width - 2)}</text>)
|
||||
extra.push(<text key="d2" fg={t.fg}><strong>{pad(` ${this.device.url}`, width - 2)}</strong></text>)
|
||||
extra.push(<text key="d3" fg={t.acc}>{pad(' and confirm the code:', width - 2)}</text>)
|
||||
extra.push(<text key="d4" fg={t.acc2}><strong>{pad(` ${this.device.code}`, width - 2)}</strong></text>)
|
||||
extra.push(<text key="d5" fg={t.dim}>{pad(` the code expires in ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`, width - 2)}</text>)
|
||||
}
|
||||
const listHeight = Math.max(3, height - 3 - extra.length - 1)
|
||||
return (
|
||||
<Panel title="Sign in" width={width} height={height} focused={focused}>
|
||||
{this.list.view(width - 2, listHeight, focused)}
|
||||
{extra}
|
||||
<text fg={/fail|error|cannot|not /i.test(this.status) ? t.err : t.dim}>{pad(` ${this.status}`, width - 2)}</text>
|
||||
</Panel>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,479 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { ResultCursor, findRecordByKey, gotoKeys, printFilter, readViewSettings, type QueryFilter, type QueryRecord, type ViewSettings } from '@qrun-io/qqq-core'
|
||||
import type { FieldMetaData, TableMetaData } from '../client'
|
||||
import type { Key } from '../keys'
|
||||
import { charOf } from '../keys'
|
||||
import { openForm, openProcess, openRecord } from '../nav'
|
||||
import { ChoiceOverlay, PromptOverlay } from '../overlays/basic'
|
||||
import { FilterOverlay } from '../overlays/filter'
|
||||
import { saveFile } from '../overlays/files-helpers'
|
||||
import { GridViewSettingsOverlay } from '../overlays/view-settings'
|
||||
import type { AppStore } from '../store'
|
||||
import { glyphs, theme } from '../theme'
|
||||
import type { FKeyLabels, MenuEntry, Overlay, Screen } from '../types'
|
||||
import { errorText, fieldCell, num, pad, padLeft, shellQuote, trunc } from '../util/text'
|
||||
import { Panel } from './list'
|
||||
|
||||
export interface GridColumn {
|
||||
name: string
|
||||
label: string
|
||||
type: string
|
||||
width: number
|
||||
numeric: boolean
|
||||
}
|
||||
|
||||
/** The default column order: primary key, then section fields by tier, then the rest (hidden and heavy left out). */
|
||||
export function defaultColumns(meta: TableMetaData): string[] {
|
||||
const order: string[] = [meta.primaryKeyField]
|
||||
for (const tier of ['T1', 'T2', 'T3']) {
|
||||
for (const s of (meta.sections ?? []).filter((x) => x.tier === tier && !x.hidden)) {
|
||||
for (const f of s.fieldNames ?? []) if (!order.includes(f) && meta.fields[f] && !meta.fields[f]!.isHidden) order.push(f)
|
||||
}
|
||||
}
|
||||
for (const [name, f] of Object.entries(meta.fields)) if (!order.includes(name) && !f.isHidden && !f.isHeavy) order.push(name)
|
||||
return order.filter((n) => meta.fields[n])
|
||||
}
|
||||
|
||||
/**
|
||||
* Column widths that use the available width: each column starts at the longest of its header and
|
||||
* the visible values (capped at 40, or its saved width), truncated columns then get the spare width,
|
||||
* and with "fill" what remains is spread over every column. Only when the natural widths do not fit
|
||||
* does the grid scroll horizontally.
|
||||
*/
|
||||
export function layoutColumns(fields: FieldMetaData[], rows: QueryRecord[], available: number, view: ViewSettings): GridColumn[] {
|
||||
const gap = 1
|
||||
const cols = fields.map((f) => {
|
||||
const numeric = ['INTEGER', 'LONG', 'DECIMAL'].includes(f.type) && !f.possibleValueSourceName
|
||||
const longest = Math.max(f.label.length + 1, ...rows.map((r) => fieldCell(r, f).length), 3)
|
||||
const saved = view.widths?.[f.name]
|
||||
return { name: f.name, label: f.label, type: f.type, numeric, longest, width: saved ?? Math.min(40, longest) }
|
||||
})
|
||||
const total = () => cols.reduce((a, c) => a + c.width + gap, 0)
|
||||
let spare = available - total()
|
||||
if (spare > 0) {
|
||||
// first give truncated columns what they need, the shortest need first
|
||||
for (const c of [...cols].filter((x) => !view.widths?.[x.name] && x.longest > x.width).sort((a, b) => (a.longest - a.width) - (b.longest - b.width))) {
|
||||
const add = Math.min(spare, c.longest - c.width)
|
||||
c.width += add
|
||||
spare -= add
|
||||
}
|
||||
if (spare > 0 && view.fit === 'fill' && cols.length) {
|
||||
const each = Math.floor(spare / cols.length)
|
||||
cols.forEach((c, i) => { c.width += each + (i < spare - each * cols.length ? 1 : 0) })
|
||||
}
|
||||
}
|
||||
return cols.map(({ longest: _l, ...c }) => c)
|
||||
}
|
||||
|
||||
/** The columns from `left` that fit in a width (at least one). */
|
||||
export function visibleFrom(cols: GridColumn[], left: number, width: number): GridColumn[] {
|
||||
const out: GridColumn[] = []
|
||||
let used = 0
|
||||
for (let i = left; i < cols.length; i++) {
|
||||
if (used + cols[i]!.width + 1 > width && out.length) break
|
||||
out.push(cols[i]!)
|
||||
used += cols[i]!.width + 1
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** A table query screen: a server-paged grid over a ResultCursor, with filter, sort, selection and export. */
|
||||
export class TableScreen implements Screen {
|
||||
readonly kind = 'table'
|
||||
readonly id: string
|
||||
meta?: TableMetaData
|
||||
cursor?: ResultCursor
|
||||
filter: QueryFilter
|
||||
sort?: { field: string; asc: boolean }
|
||||
colCursor = 0
|
||||
leftCol = 0
|
||||
selection = new Set<string>()
|
||||
rows: QueryRecord[] = []
|
||||
rowsPage = -1
|
||||
loading = false
|
||||
error?: string
|
||||
fetchMs?: number
|
||||
pageSize = 20
|
||||
view: ViewSettings = {}
|
||||
private generation = 0
|
||||
|
||||
constructor(private store: AppStore, readonly tableName: string, filter?: QueryFilter, options: { index?: number } = {}) {
|
||||
this.id = `table:${tableName}:${Date.now()}`
|
||||
this.filter = { criteria: [], booleanOperator: 'AND', ...(filter ?? {}) }
|
||||
if (this.filter.orderBys?.[0]) this.sort = { field: this.filter.orderBys[0].fieldName, asc: this.filter.orderBys[0].isAscending }
|
||||
delete this.filter.orderBys
|
||||
this.view = readViewSettings(`table:${tableName}`)
|
||||
store.run(store.client!.tableMeta(tableName), (meta) => {
|
||||
this.meta = meta
|
||||
this.newCursor(options.index ?? 0)
|
||||
this.store.remember({ kind: 'table', table: tableName, label: meta.label })
|
||||
})
|
||||
}
|
||||
|
||||
title(): string {
|
||||
return this.meta?.label ?? this.tableName
|
||||
}
|
||||
|
||||
presenceScope(): string {
|
||||
return `table:${this.tableName}`
|
||||
}
|
||||
|
||||
helpTopic() {
|
||||
return { kind: 'table' as const, name: this.tableName }
|
||||
}
|
||||
|
||||
/** The query as the CLI says it. */
|
||||
cliEquivalent(): string {
|
||||
const parts = ['table', 'query', this.tableName]
|
||||
const text = printFilter(this.filter)
|
||||
if (text) parts.push('-g', shellQuote(text))
|
||||
if (this.sort) parts.push('-o', (this.sort.asc ? '' : '-') + this.sort.field)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
fullFilter(): QueryFilter {
|
||||
return { ...this.filter, ...(this.sort ? { orderBys: [{ fieldName: this.sort.field, isAscending: this.sort.asc }] } : {}) }
|
||||
}
|
||||
|
||||
newCursor(index = 0): void {
|
||||
if (!this.meta) return
|
||||
this.cursor = new ResultCursor({ baseUrl: this.store.client!.server, fetch: this.store.client!.fetch, table: this.tableName, filter: this.fullFilter(), pageSize: this.pageSize, primaryKeyField: this.meta.primaryKeyField, index })
|
||||
this.rowsPage = -1
|
||||
this.load()
|
||||
}
|
||||
|
||||
/** Loads the page holding the cursor's index (ignores stale answers). */
|
||||
load(refresh = false): void {
|
||||
const cursor = this.cursor
|
||||
if (!cursor) return
|
||||
const page = cursor.pageOf(cursor.index)
|
||||
const gen = ++this.generation
|
||||
this.loading = true
|
||||
const started = performance.now()
|
||||
cursor.page(page, { refresh }).then((records) => {
|
||||
if (gen !== this.generation) return
|
||||
this.rows = records
|
||||
this.rowsPage = page
|
||||
this.loading = false
|
||||
this.error = undefined
|
||||
this.fetchMs = performance.now() - started
|
||||
if (cursor.total !== undefined && cursor.index >= cursor.total) cursor.index = Math.max(0, cursor.total - 1)
|
||||
this.store.bump()
|
||||
}).catch((e) => {
|
||||
if (gen !== this.generation) return
|
||||
this.loading = false
|
||||
this.error = errorText(e)
|
||||
this.store.bump()
|
||||
})
|
||||
}
|
||||
|
||||
get rowIndex(): number {
|
||||
return this.cursor ? this.cursor.index - this.rowsPage * this.cursor.pageSize : 0
|
||||
}
|
||||
|
||||
get currentRecord(): QueryRecord | undefined {
|
||||
return this.rows[this.rowIndex]
|
||||
}
|
||||
|
||||
keyOf(record: QueryRecord | undefined): string | undefined {
|
||||
const v = record?.values?.[this.meta?.primaryKeyField ?? 'id']
|
||||
return v === undefined || v === null ? undefined : String(v)
|
||||
}
|
||||
|
||||
fields(): FieldMetaData[] {
|
||||
if (!this.meta) return []
|
||||
const names = (this.view.columns?.length ? this.view.columns : defaultColumns(this.meta)).filter((n) => this.meta!.fields[n])
|
||||
return names.map((n) => this.meta!.fields[n]!)
|
||||
}
|
||||
|
||||
moveTo(index: number): void {
|
||||
const c = this.cursor
|
||||
if (!c) return
|
||||
const max = Math.max(0, (c.total ?? 1) - 1)
|
||||
c.index = Math.max(0, Math.min(max, index))
|
||||
if (c.pageOf(c.index) !== this.rowsPage) this.load()
|
||||
}
|
||||
|
||||
resume(): void {
|
||||
// back from a record or form: the cursor may have moved (next/previous) or records changed
|
||||
this.view = readViewSettings(`table:${this.tableName}`)
|
||||
this.cursor?.invalidate()
|
||||
this.load(true)
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
this.cursor?.invalidate()
|
||||
this.load(true)
|
||||
}
|
||||
|
||||
setFilter(filter: QueryFilter, sort?: { field: string; asc: boolean } | null): void {
|
||||
this.filter = { criteria: filter.criteria, booleanOperator: filter.booleanOperator, ...(filter.subFilters?.length ? { subFilters: filter.subFilters } : {}) }
|
||||
if (sort !== undefined) this.sort = sort ?? undefined
|
||||
else if (filter.orderBys?.[0]) this.sort = { field: filter.orderBys[0].fieldName, asc: filter.orderBys[0].isAscending }
|
||||
this.selection.clear()
|
||||
this.newCursor(0)
|
||||
}
|
||||
|
||||
openCurrent(): void {
|
||||
const rec = this.currentRecord
|
||||
const key = this.keyOf(rec)
|
||||
if (key === undefined || !this.cursor) return this.store.flash('no record under the cursor')
|
||||
openRecord(this.store, this.tableName, key, { cursor: this.cursor, label: rec?.recordLabel })
|
||||
}
|
||||
|
||||
deleteCurrent(): void {
|
||||
const rec = this.currentRecord
|
||||
const key = this.keyOf(rec)
|
||||
if (key === undefined) return this.store.flash('no record under the cursor')
|
||||
if (this.meta?.deletePermission === false) return this.store.flash('you may not delete records of this table', 'error')
|
||||
this.store.confirm(`Delete ${this.title()} ${key}${rec?.recordLabel ? ` "${rec.recordLabel}"` : ''}?`, () => {
|
||||
this.store.run(this.store.client!.remove(this.tableName, key), () => {
|
||||
this.cursor?.removed(this.cursor.index)
|
||||
this.selection.delete(key)
|
||||
this.load(true)
|
||||
this.store.flash(`deleted ${this.title()} ${key}`)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Table processes for the bulk bar and the menu (bulk-load file processes are parked). */
|
||||
tableProcesses(): Array<{ name: string; label: string; parked?: string }> {
|
||||
const procs = Object.values(this.store.meta?.processes ?? {}).filter((p) => p.tableName === this.tableName)
|
||||
return procs.map((p) => ({ name: p.name, label: p.label, parked: /bulkInsert|bulkEditWithFile|bulkLoad/i.test(p.name) ? 'parked: bulk load needs file uploads' : undefined }))
|
||||
.sort((a, b) => Number(Boolean(a.parked)) - Number(Boolean(b.parked)) || a.label.localeCompare(b.label))
|
||||
}
|
||||
|
||||
runProcess(name: string): void {
|
||||
const ids = [...this.selection]
|
||||
if (ids.length) openProcess(this.store, name, { recordIds: ids })
|
||||
else {
|
||||
const key = this.keyOf(this.currentRecord)
|
||||
openProcess(this.store, name, key !== undefined ? { recordIds: [key] } : { filter: this.fullFilter() })
|
||||
}
|
||||
}
|
||||
|
||||
bulkActions(): void {
|
||||
const procs = this.tableProcesses()
|
||||
if (!procs.length) return this.store.flash('this table has no processes')
|
||||
const what = this.selection.size ? `${this.selection.size} selected` : 'the record under the cursor'
|
||||
this.store.open(new ChoiceOverlay(this.store, `Run on ${what}`, procs.map((p) => ({ label: p.label, value: p.name, detail: p.name, disabled: p.parked })), (name) => this.runProcess(name)))
|
||||
}
|
||||
|
||||
exportDialog(): void {
|
||||
const formats = ['csv', 'xlsx', 'json', 'tsv']
|
||||
this.store.open(new ChoiceOverlay(this.store, 'Export to a file', formats.map((f) => ({ label: f, value: f, detail: f === 'xlsx' ? 'Excel' : undefined })), (format) => this.exportAs(format)))
|
||||
}
|
||||
|
||||
/** Go to a record by its primary key or a configured unique key (kernel gotoKeys / findRecordByKey). */
|
||||
gotoKey(): void {
|
||||
const meta = this.meta
|
||||
if (!meta) return
|
||||
const keys = gotoKeys(meta as never)
|
||||
const ask = (key: string[]) => {
|
||||
const labels = key.map((n) => meta.fields[n]?.label ?? n)
|
||||
this.store.open(new PromptOverlay(this.store, `Go to ${this.title()} by ${labels.join(' + ')}`, key.length > 1 ? `Values for ${labels.join(', ')}, separated by commas.` : 'Opens the record with this value.', (text) => {
|
||||
const parts = key.length > 1 ? text.split(',').map((x) => x.trim()) : [text.trim()]
|
||||
if (!parts.some(Boolean)) return
|
||||
const values = Object.fromEntries(key.map((n, i) => [n, parts[i] ?? '']))
|
||||
if (key.length === 1 && key[0] === meta.primaryKeyField) return openRecord(this.store, this.tableName, parts[0])
|
||||
this.store.run(findRecordByKey(this.store.client!.server, this.store.client!.fetch, meta as never, values), (r) => {
|
||||
if (r.found === 'one') openRecord(this.store, this.tableName, r.primaryKey)
|
||||
else this.store.flash(r.found === 'none' ? 'no record has that key' : 'more than one record has that key', 'error')
|
||||
})
|
||||
}))
|
||||
}
|
||||
if (keys.length === 1) return ask(keys[0]!)
|
||||
this.store.open(new ChoiceOverlay(this.store, 'Go to by which key?', keys.map((k) => ({ label: k.map((n) => meta.fields[n]?.label ?? n).join(' + '), value: k })), ask))
|
||||
}
|
||||
|
||||
filterDialog(): void {
|
||||
if (!this.meta) return
|
||||
this.store.open(new FilterOverlay(this.store, this.meta, this.filter, this.sort, (filter, sort) => this.setFilter(filter, sort)))
|
||||
}
|
||||
|
||||
viewSettings(): Overlay | undefined {
|
||||
if (!this.meta) return undefined
|
||||
return new GridViewSettingsOverlay(this.store, this.meta, `table:${this.tableName}`, this.fields().map((f) => f.name), () => {
|
||||
this.view = readViewSettings(`table:${this.tableName}`)
|
||||
this.store.bump()
|
||||
})
|
||||
}
|
||||
|
||||
fkeys(): FKeyLabels {
|
||||
const m = this.meta
|
||||
return { 3: 'View', 4: m?.editPermission === false ? null : 'Edit', 6: 'Filter', 7: m?.insertPermission === false ? null : 'New', 8: m?.deletePermission === false ? null : 'Delete' }
|
||||
}
|
||||
|
||||
onKey(key: Key): boolean {
|
||||
const c = this.cursor
|
||||
if (!this.meta || !c) return key.name !== 'escape' && key.name !== 'tab' && !key.name.startsWith('f')
|
||||
const ch = charOf(key)
|
||||
const single = this.store.singleKeys
|
||||
switch (key.name) {
|
||||
case 'down': this.moveTo(c.index + 1); return true
|
||||
case 'up': this.moveTo(c.index - 1); return true
|
||||
case 'pagedown': this.moveTo(this.rowIndex === this.rows.length - 1 || this.rows.length < c.pageSize ? c.index + c.pageSize : (this.rowsPage + 1) * c.pageSize - 1); return true
|
||||
case 'pageup': this.moveTo(this.rowIndex === 0 ? c.index - c.pageSize : this.rowsPage * c.pageSize); return true
|
||||
case 'home': this.moveTo(0); return true
|
||||
case 'end': this.moveTo((c.total ?? 1) - 1); return true
|
||||
case 'right': this.colCursor = Math.min(this.fields().length - 1, this.colCursor + 1); return true
|
||||
case 'left': this.colCursor = Math.max(0, this.colCursor - 1); return true
|
||||
case 'return': case 'f3': this.openCurrent(); return true
|
||||
case 'f4': {
|
||||
const key2 = this.keyOf(this.currentRecord)
|
||||
if (key2 !== undefined) openForm(this.store, this.tableName, { mode: 'edit', primaryKey: key2, cursor: c })
|
||||
return true
|
||||
}
|
||||
case 'f6': this.filterDialog(); return true
|
||||
case 'f7': openForm(this.store, this.tableName, { mode: 'create' }); return true
|
||||
case 'f8': this.deleteCurrent(); return true
|
||||
case 'space': {
|
||||
const k = this.keyOf(this.currentRecord)
|
||||
if (k !== undefined) {
|
||||
if (this.selection.has(k)) this.selection.delete(k)
|
||||
else this.selection.add(k)
|
||||
this.moveTo(c.index + 1)
|
||||
}
|
||||
return true
|
||||
}
|
||||
case 'escape':
|
||||
if (this.selection.size) {
|
||||
this.selection.clear()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
default: break
|
||||
}
|
||||
if (key.ctrl && key.name === 'a') {
|
||||
for (const r of this.rows) { const k = this.keyOf(r); if (k !== undefined) this.selection.add(k) }
|
||||
return true
|
||||
}
|
||||
if (!single) return false
|
||||
switch (ch) {
|
||||
case 's': {
|
||||
const field = this.fields()[this.colCursor]?.name
|
||||
if (!field) return true
|
||||
this.sort = !this.sort || this.sort.field !== field ? { field, asc: true } : this.sort.asc ? { field, asc: false } : undefined
|
||||
this.newCursor(0)
|
||||
return true
|
||||
}
|
||||
case 'f': this.filterDialog(); return true
|
||||
case 'n': openForm(this.store, this.tableName, { mode: 'create' }); return true
|
||||
case 'r': this.refresh(); return true
|
||||
case 'g': this.gotoKey(); return true
|
||||
case 'x': this.exportDialog(); return true
|
||||
case 'b': this.bulkActions(); return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
menu(): MenuEntry[] {
|
||||
const out: MenuEntry[] = [
|
||||
{ section: 'Context', label: 'View the record under the cursor', key: 'F3 Enter', run: () => this.openCurrent() },
|
||||
{ section: 'Context', label: 'Edit the record under the cursor', key: 'F4', run: () => this.onKey({ name: 'f4', sequence: '' }) },
|
||||
{ section: 'Context', label: `New ${this.title()}`, key: 'F7 n', run: () => openForm(this.store, this.tableName, { mode: 'create' }) },
|
||||
{ section: 'Context', label: 'Delete the record under the cursor', key: 'F8', run: () => this.deleteCurrent() },
|
||||
{ section: 'Context', label: 'Filter and sort', key: 'F6 f', command: 'filter', run: () => this.filterDialog() },
|
||||
{ section: 'Context', label: 'Clear the filter', command: 'filter clear', run: () => this.setFilter({ criteria: [], booleanOperator: 'AND' }) },
|
||||
{ section: 'Context', label: 'Go to a record by key', key: 'g', run: () => this.gotoKey() },
|
||||
{ section: 'Context', label: 'Refresh', key: 'F5 r', run: () => this.refresh() },
|
||||
{ section: 'Context', label: 'View settings (columns, widths, stripes, density)', key: 'v', command: 'view', run: () => { const o = this.viewSettings(); if (o) this.store.open(o) } },
|
||||
{ section: 'Context', label: 'Copy as a CLI command', detail: this.cliEquivalent(), run: () => this.store.message(`qqq ${this.cliEquivalent()}`, 'info', 'CLI equivalent') },
|
||||
]
|
||||
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) })
|
||||
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: 'Saved views of this table', command: `saved-view list ${this.tableName}`, run: () => void this.store.commands().run(`saved-view list ${this.tableName}`) })
|
||||
out.push({ section: 'Context', label: `Column statistics: ${this.fields()[this.colCursor]?.label ?? ''}`, command: `table stats ${this.tableName} ${this.fields()[this.colCursor]?.name ?? ''}`, run: () => void this.store.commands().run(`table stats ${this.tableName} ${this.fields()[this.colCursor]?.name ?? ''}`) })
|
||||
return out
|
||||
}
|
||||
|
||||
/** Exports with the current filter, sort and visible columns, to a file chosen in the file dialog (or silently to the download directory). */
|
||||
exportAs(format: string): void {
|
||||
const write = (out: string) => {
|
||||
this.store.run(this.store.client!.export(this.tableName, format, this.fullFilter(), this.fields().map((f) => f.name)), ({ bytes }) => {
|
||||
writeFileSync(out, bytes)
|
||||
this.store.message(`Wrote ${out} (${num(bytes.length)} bytes).`)
|
||||
})
|
||||
}
|
||||
saveFile(this.store, `Export ${this.title()} as ${format}`, `${this.tableName}.${format}`, write)
|
||||
}
|
||||
|
||||
render(width: number, height: number, focused: boolean) {
|
||||
const t = theme()
|
||||
const g = glyphs()
|
||||
const inner = width - 2
|
||||
const comfortable = this.view.density === 'comfortable'
|
||||
const headerRule = this.view.headerRule ?? true
|
||||
const chrome = 1 + (headerRule ? 1 : 0) + 2 + (this.selection.size ? 1 : 0)
|
||||
const bodyLines = Math.max(1, height - 2 - chrome)
|
||||
const pageSize = Math.max(1, comfortable ? Math.ceil(bodyLines / 2) : bodyLines)
|
||||
if (this.cursor && pageSize !== this.cursor.pageSize) {
|
||||
const index = this.cursor.index
|
||||
this.pageSize = pageSize
|
||||
this.cursor.pageSize = pageSize
|
||||
this.cursor.invalidate()
|
||||
this.cursor.index = index
|
||||
this.rowsPage = -1
|
||||
queueMicrotask(() => this.load())
|
||||
}
|
||||
const fields = this.fields()
|
||||
const cols = layoutColumns(fields, this.rows, inner - 2, this.view)
|
||||
this.colCursor = Math.min(this.colCursor, Math.max(0, cols.length - 1))
|
||||
if (this.colCursor < this.leftCol) this.leftCol = this.colCursor
|
||||
while (this.leftCol < this.colCursor && !visibleFrom(cols, this.leftCol, inner - 2).some((c) => c.name === cols[this.colCursor]!.name)) this.leftCol++
|
||||
const shown = visibleFrom(cols, this.leftCol, inner - 2).map((c) => ({ ...c }))
|
||||
if (this.view.fit === 'fill' && shown.length) {
|
||||
// fill: spread what is left of the width over the visible columns
|
||||
const spare = inner - 2 - shown.reduce((a, c) => a + c.width + 1, 0)
|
||||
if (spare > 0) shown.forEach((c, i) => { c.width += Math.floor(spare / shown.length) + (i < spare % shown.length ? 1 : 0) })
|
||||
}
|
||||
const zebra = this.view.zebra ?? t.zebraByDefault
|
||||
const header = ' ' + shown.map((c) => {
|
||||
const mark = this.sort?.field === c.name ? (this.sort.asc ? g.up : g.down) : ''
|
||||
const label = trunc(c.label, c.width - mark.length) + mark
|
||||
return c.numeric ? padLeft(label, c.width) : pad(label, c.width)
|
||||
}).join(' ')
|
||||
const lines: React.ReactNode[] = []
|
||||
lines.push(
|
||||
<text key="h">
|
||||
{shown.map((c, i) => {
|
||||
const hot = cols.findIndex((x) => x.name === c.name) === this.colCursor
|
||||
const mark = this.sort?.field === c.name ? (this.sort.asc ? g.up : g.down) : ''
|
||||
const label = trunc(c.label, c.width - mark.length) + mark
|
||||
return <span key={c.name} fg={hot && focused ? t.acc2 : t.acc}>{(i === 0 ? ' ' : ' ') + (c.numeric ? padLeft(label, c.width) : pad(label, c.width))}</span>
|
||||
})}
|
||||
<span>{' '.repeat(Math.max(0, inner - header.length))}</span>
|
||||
</text>,
|
||||
)
|
||||
if (headerRule) lines.push(<text key="r" fg={t.border}>{pad(g.rule.repeat(Math.min(inner, header.length)), inner)}</text>)
|
||||
const pk = this.meta?.primaryKeyField ?? 'id'
|
||||
this.rows.forEach((r, i) => {
|
||||
const sel = i === this.rowIndex
|
||||
const key = String(r.values?.[pk] ?? '')
|
||||
const marked = this.selection.has(key)
|
||||
const text = (marked ? g.mark + ' ' : ' ') + shown.map((c) => (c.numeric ? padLeft(fieldCell(r, c), c.width) : pad(fieldCell(r, c), c.width))).join(' ')
|
||||
const bg = sel ? (focused ? t.selBg : t.selInactiveBg) : zebra && i % 2 === 1 ? t.zebraBg : undefined
|
||||
lines.push(<text key={`r${i}`} fg={sel ? (focused ? t.selFg : t.fg) : marked ? t.markFg : t.fg} bg={bg}>{pad(text, inner)}</text>)
|
||||
if (comfortable) lines.push(<text key={`s${i}`}>{pad('', inner)}</text>)
|
||||
})
|
||||
const used = lines.length - 1 - (headerRule ? 1 : 0)
|
||||
for (let i = used; i < bodyLines; i++) {
|
||||
lines.push(<text key={`e${i}`} fg={this.error ? t.err : t.dim}>{pad(i === 0 && !this.rows.length ? (this.error ? ` ${this.error}` : this.loading || !this.meta ? ' loading…' : ' (no records match)') : '', inner)}</text>)
|
||||
}
|
||||
if (this.selection.size) {
|
||||
lines.push(<text key="bulk" fg={t.selFg} bg={t.acc}>{pad(` ${this.selection.size} selected · b run a process on them · Ctrl+A select page · Space toggle · Esc clear`, inner)}</text>)
|
||||
}
|
||||
const c = this.cursor
|
||||
const filterText = printFilter(this.filter) || 'no filter'
|
||||
lines.push(<text key="f" fg={t.dim}>{pad(` ${filterText}`, inner)}</text>)
|
||||
const status = c ? `${c.total === undefined ? '…' : num(c.total)} records · row ${num(c.index + 1)} · page ${this.rowsPage + 1}/${c.pageCount}${this.sort ? ` · sort ${this.sort.field} ${this.sort.asc ? 'asc' : 'desc'}` : ''} · col ${this.colCursor + 1}/${cols.length}${this.fetchMs !== undefined ? ` · ${this.fetchMs.toFixed(0)} ms` : ''}${this.loading ? ' · loading' : ''}` : 'loading…'
|
||||
const hint = this.store.singleKeys ? ' · s sort · f filter · v view · x export · Space select' : ''
|
||||
lines.push(<text key="s" fg={t.dim}>{pad(` ${status}${hint}`, inner)}</text>)
|
||||
return <Panel title={this.title()} width={width} height={height} focused={focused}>{lines}</Panel>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,540 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The application state and the one place keys are dispatched. React components only draw what is
|
||||
* here; key handlers read and change it directly (no closures over render-time state), then ask for
|
||||
* a repaint. Order of dispatch: the lock screen, the top overlay, the global function keys, the
|
||||
* focused pane (sidebar or screen), then the global fallbacks (Esc back, Tab, `:`).
|
||||
*/
|
||||
|
||||
import { openRealtimeStream } from '@qrun-io/qqq-ui-api-client'
|
||||
import { History, addRecent, currentCredential, readIdleMinutes, type MetaDataDocument, type RecentItem } from '@qrun-io/qqq-core'
|
||||
import { TUI_CHANNEL, TuiClient, type VersionInfo } from './client'
|
||||
import { charOf, isPrintable, type Key } from './keys'
|
||||
import { ChoiceOverlay, ConfirmOverlay, MessageOverlay } from './overlays/basic'
|
||||
import { Extensions } from './extensions'
|
||||
import { collaborationPresence, presenceSource, setPresenceSource } from './presence'
|
||||
import { setAscii, setTheme, theme } from './theme'
|
||||
import type { FKeyLabels, Overlay, Screen } from './types'
|
||||
import { errorText } from './util/text'
|
||||
|
||||
export interface Settings {
|
||||
theme: string
|
||||
ascii: boolean
|
||||
linear: boolean
|
||||
mouse: boolean
|
||||
/** Single-key shortcuts (s, v, n, e …); the server's keyboard.singleKeyShortcuts preference turns them off. */
|
||||
singleKeys: boolean
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
server: string
|
||||
profile: string
|
||||
user: string
|
||||
auth: string
|
||||
apiVersion: string
|
||||
pingMs: number | null
|
||||
stream: 'off' | 'connecting' | 'connected' | 'reconnecting' | 'n/a'
|
||||
lastEvent: string
|
||||
jobs: string
|
||||
build: string
|
||||
clock: string
|
||||
alerts?: number
|
||||
}
|
||||
|
||||
export interface Target {
|
||||
name: string
|
||||
server: string
|
||||
}
|
||||
|
||||
export interface StoreOptions {
|
||||
settings: Settings
|
||||
/** Ends the program (renderer.destroy). */
|
||||
quit: () => void
|
||||
build?: string
|
||||
target?: Target
|
||||
/** Starts the ping, clock and realtime timers (off in tests). */
|
||||
timers?: boolean
|
||||
}
|
||||
|
||||
type Listener = () => void
|
||||
|
||||
export class AppStore {
|
||||
routes: Screen[] = []
|
||||
overlays: Overlay[] = []
|
||||
focus: 'content' | 'sidebar' = 'content'
|
||||
width = 120
|
||||
height = 40
|
||||
client?: TuiClient
|
||||
meta?: MetaDataDocument
|
||||
version?: VersionInfo
|
||||
extensions = new Extensions()
|
||||
target?: Target
|
||||
settings: Settings
|
||||
status: Status
|
||||
flashText?: { text: string; kind: 'info' | 'error'; at: number }
|
||||
/** The screen saver or lock, when shown. */
|
||||
lock?: Overlay
|
||||
readonly history = new History('commands')
|
||||
readonly filterHistory = new History('filters')
|
||||
lastMenuSection = 0
|
||||
lastInput = Date.now()
|
||||
sidebarFactory?: (store: AppStore) => SidebarLike
|
||||
/** How to sign in again (profile switch, sign out); set by main. */
|
||||
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') }
|
||||
sidebar?: SidebarLike
|
||||
private listeners = new Set<Listener>()
|
||||
private timers: Array<ReturnType<typeof setInterval>> = []
|
||||
private streamAbort?: AbortController
|
||||
private readonly quitImpl: () => void
|
||||
private paintQueued = false
|
||||
|
||||
constructor(options: StoreOptions) {
|
||||
this.settings = options.settings
|
||||
this.quitImpl = options.quit
|
||||
this.target = options.target
|
||||
setTheme(options.settings.theme)
|
||||
setAscii(options.settings.ascii)
|
||||
this.status = {
|
||||
server: options.target?.server ?? '', profile: options.target?.name ?? '', user: '', auth: '', apiVersion: '…', pingMs: null,
|
||||
stream: 'off', lastEvent: 'no events yet', jobs: 'n/a', build: options.build ?? 'dev', clock: clock(),
|
||||
}
|
||||
if (options.timers) {
|
||||
this.timers.push(setInterval(() => {
|
||||
this.status.clock = clock()
|
||||
this.checkIdle()
|
||||
this.bump()
|
||||
}, 1000))
|
||||
}
|
||||
}
|
||||
|
||||
// ---- rendering ----
|
||||
|
||||
subscribe(listener: Listener): () => void {
|
||||
this.listeners.add(listener)
|
||||
return () => this.listeners.delete(listener)
|
||||
}
|
||||
|
||||
/** Asks for a repaint (coalesced). */
|
||||
bump(): void {
|
||||
if (this.paintQueued) return
|
||||
this.paintQueued = true
|
||||
queueMicrotask(() => {
|
||||
this.paintQueued = false
|
||||
this.syncPresence()
|
||||
for (const l of this.listeners) l()
|
||||
})
|
||||
}
|
||||
|
||||
get screen(): Screen | undefined {
|
||||
return this.routes[this.routes.length - 1]
|
||||
}
|
||||
|
||||
get singleKeys(): boolean {
|
||||
return this.settings.singleKeys
|
||||
}
|
||||
|
||||
get showSidebar(): boolean {
|
||||
return !this.settings.linear && this.width >= 100 && Boolean(this.meta) && Boolean(this.sidebar)
|
||||
}
|
||||
|
||||
// ---- navigation ----
|
||||
|
||||
push(screen: Screen): void {
|
||||
this.routes.push(screen)
|
||||
this.focus = 'content'
|
||||
this.bump()
|
||||
}
|
||||
|
||||
/** Tells the presence source which scope is on top (called on every repaint; cheap when unchanged). */
|
||||
private syncPresence(): void {
|
||||
const scope = this.lock ? undefined : this.screen?.presenceScope?.()
|
||||
presenceSource().enter?.(scope)
|
||||
}
|
||||
|
||||
/** Replaces the top screen (a form that saved becomes the record). */
|
||||
replace(screen: Screen): void {
|
||||
const old = this.routes.pop()
|
||||
old?.dispose?.()
|
||||
this.routes.push(screen)
|
||||
this.bump()
|
||||
}
|
||||
|
||||
/** Back one screen; the screen below resumes with its state intact. */
|
||||
back(): void {
|
||||
if (this.routes.length <= 1) {
|
||||
if (!this.settings.linear && this.sidebar && this.showSidebar) this.focus = 'sidebar'
|
||||
this.bump()
|
||||
return
|
||||
}
|
||||
const old = this.routes.pop()
|
||||
old?.dispose?.()
|
||||
this.screen?.resume?.()
|
||||
this.bump()
|
||||
}
|
||||
|
||||
/** Back to the first screen (home). */
|
||||
home(): void {
|
||||
while (this.routes.length > 1) this.routes.pop()?.dispose?.()
|
||||
this.screen?.resume?.()
|
||||
this.bump()
|
||||
}
|
||||
|
||||
// ---- overlays and messages ----
|
||||
|
||||
open(overlay: Overlay): void {
|
||||
this.overlays.push(overlay)
|
||||
this.bump()
|
||||
}
|
||||
|
||||
closeOverlay(overlay?: Overlay): void {
|
||||
if (!overlay) this.overlays.pop()
|
||||
else this.overlays = this.overlays.filter((o) => o !== overlay)
|
||||
this.bump()
|
||||
}
|
||||
|
||||
message(text: string, kind: 'info' | 'error' = 'info', title?: string): void {
|
||||
this.open(new MessageOverlay(this, text, kind, title))
|
||||
}
|
||||
|
||||
/** A short note in the status area that fades on its own. */
|
||||
flash(text: string, kind: 'info' | 'error' = 'info'): void {
|
||||
this.flashText = { text, kind, at: Date.now() }
|
||||
this.bump()
|
||||
setTimeout(() => {
|
||||
if (this.flashText && Date.now() - this.flashText.at >= 3900) {
|
||||
this.flashText = undefined
|
||||
this.bump()
|
||||
}
|
||||
}, 4000)
|
||||
}
|
||||
|
||||
confirm(text: string, onYes: () => void, onNo?: () => void): void {
|
||||
this.open(new ConfirmOverlay(this, text, onYes, onNo))
|
||||
}
|
||||
|
||||
error(error: unknown): void {
|
||||
this.message(errorText(error), 'error')
|
||||
}
|
||||
|
||||
/** Runs async work; failures become an error message instead of an unhandled rejection. */
|
||||
run<T>(work: Promise<T>, onDone?: (value: T) => void): void {
|
||||
work.then((value) => {
|
||||
onDone?.(value)
|
||||
this.bump()
|
||||
}).catch((e) => this.error(e))
|
||||
}
|
||||
|
||||
// ---- keys ----
|
||||
|
||||
handleKey(raw: Key): void {
|
||||
this.lastInput = Date.now()
|
||||
// Ctrl+F / Ctrl+B page forward and back everywhere: no terminal or tmux intercepts them
|
||||
const key: Key = raw.ctrl && (raw.name === 'f' || raw.name === 'b') ? { name: raw.name === 'f' ? 'pagedown' : 'pageup', sequence: '' } : raw
|
||||
try {
|
||||
this.dispatch(key)
|
||||
} catch (e) {
|
||||
this.error(e)
|
||||
}
|
||||
this.bump()
|
||||
}
|
||||
|
||||
private dispatch(key: Key): void {
|
||||
if (this.lock) {
|
||||
this.lock.onKey(key)
|
||||
return
|
||||
}
|
||||
const top = this.overlays[this.overlays.length - 1]
|
||||
if (top) {
|
||||
top.onKey(key)
|
||||
return
|
||||
}
|
||||
const screen = this.screen
|
||||
if (key.name === 'f1') return this.commands().help()
|
||||
if (this.backslash && charOf(key) === '?') {
|
||||
this.backslash = false
|
||||
return this.commands().help()
|
||||
}
|
||||
if (key.name === 'f2' || (key.ctrl && key.name === 'k')) return this.commands().palette()
|
||||
if (key.name === 'f9') return this.commands().menu()
|
||||
if (key.name === 'f10') return this.requestQuit()
|
||||
if (this.focus === 'sidebar' && this.sidebar && this.showSidebar) {
|
||||
if (this.sidebar.onKey(key)) return
|
||||
} else if (screen?.onKey(key)) {
|
||||
return
|
||||
}
|
||||
const typing = screen?.capturesText?.() ?? false
|
||||
switch (key.name) {
|
||||
case 'escape':
|
||||
if (this.focus === 'sidebar') this.focus = 'content'
|
||||
else this.back()
|
||||
return
|
||||
case 'tab':
|
||||
if (this.showSidebar) this.focus = this.focus === 'sidebar' ? 'content' : 'sidebar'
|
||||
return
|
||||
case 'f5':
|
||||
screen?.refresh?.()
|
||||
return
|
||||
case 'f3': case 'f4': case 'f6': case 'f7': case 'f8':
|
||||
this.flash(`${key.name.toUpperCase()} does nothing on this screen`)
|
||||
return
|
||||
default:
|
||||
break
|
||||
}
|
||||
if (typing) return
|
||||
const ch = charOf(key)
|
||||
if (ch === ':') return this.commands().commandLine()
|
||||
if (ch === '?') return this.commands().help()
|
||||
if (ch === '/' || ch === '.' || ch === '\\') return this.pendingBackslash(ch)
|
||||
this.backslash = false
|
||||
if (this.singleKeys && ch === 'v') return this.commands().viewSettings()
|
||||
}
|
||||
|
||||
private backslash = false
|
||||
/** `/` and `.` open the palette; a backslash followed by `?` is an alias of `?` (help). */
|
||||
private pendingBackslash(ch: string): void {
|
||||
if (ch === '\\') {
|
||||
this.backslash = true
|
||||
return
|
||||
}
|
||||
this.backslash = false
|
||||
this.commands().palette()
|
||||
}
|
||||
|
||||
/** The command layer (menu, palette, `:` line); set by commands.ts to avoid an import cycle. */
|
||||
commandsImpl?: (store: AppStore) => CommandsLike
|
||||
commands(): CommandsLike {
|
||||
return this.commandsImpl!(this)
|
||||
}
|
||||
|
||||
// ---- quitting ----
|
||||
|
||||
/** F10: a small dialog with Quit, Reset (home screen) and Cancel. */
|
||||
requestQuit(): void {
|
||||
this.open(new ChoiceOverlay<'quit' | 'reset' | 'cancel'>(this, 'Quit qqq?', [
|
||||
{ label: 'Quit', value: 'quit', detail: 'end the program' },
|
||||
{ label: 'Reset: go to the home screen', value: 'reset', detail: 'keeps the session' },
|
||||
{ label: 'Cancel', value: 'cancel', detail: 'Esc' },
|
||||
], (choice) => {
|
||||
if (choice === 'quit') this.guard('quit', () => this.quit())
|
||||
else if (choice === 'reset') this.reset()
|
||||
}, 'Enter chooses'))
|
||||
}
|
||||
|
||||
/** Warns about unsaved forms or running processes before `then`. */
|
||||
private guard(what: 'quit' | 'reset', then: () => void): void {
|
||||
const dirty = this.routes.some((s) => s.dirty?.())
|
||||
const busy = this.routes.some((s) => s.busy?.())
|
||||
if (!dirty && !busy) return then()
|
||||
const warn = dirty ? 'A form has unsaved changes that will be lost.' : 'A process is still running; it continues on the server.'
|
||||
this.confirm(`${warn} ${what === 'quit' ? 'Quit' : 'Reset'} anyway?`, then)
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset: closes dialogs and every screen above Home (after the same warning), refreshes the
|
||||
* metadata, and returns to Home with the Apps tree focused. The session and profile stay.
|
||||
*/
|
||||
reset(): void {
|
||||
this.guard('reset', () => {
|
||||
this.overlays = []
|
||||
while (this.routes.length > 1) this.routes.pop()?.dispose?.()
|
||||
const done = () => {
|
||||
this.sidebar = this.sidebarFactory?.(this) ?? this.sidebar
|
||||
this.screen?.resume?.()
|
||||
this.focus = this.showSidebar ? 'sidebar' : 'content'
|
||||
this.flash('reset: home screen, metadata refreshed')
|
||||
this.bump()
|
||||
}
|
||||
if (!this.client) return done()
|
||||
this.client.clearCaches()
|
||||
this.client.metaData(true).then((m) => { this.meta = m }).catch((e) => this.error(e)).finally(done)
|
||||
})
|
||||
}
|
||||
|
||||
quit(): void {
|
||||
this.dispose()
|
||||
this.quitImpl()
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
for (const t of this.timers) clearInterval(t)
|
||||
this.timers = []
|
||||
this.streamAbort?.abort()
|
||||
for (const s of this.routes) s.dispose?.()
|
||||
}
|
||||
|
||||
// ---- connection ----
|
||||
|
||||
/** Called after a sign-in: loads version and metadata, starts ping, jobs and the realtime stream. */
|
||||
async connect(client: TuiClient, options: { timers?: boolean } = {}): Promise<void> {
|
||||
this.client = client
|
||||
this.status.server = client.server
|
||||
const [version, meta] = await Promise.all([client.version(), client.metaData()])
|
||||
this.version = version
|
||||
this.meta = meta
|
||||
this.extensions.load(version)
|
||||
setPresenceSource(collaborationPresence(this.extensions.registry, client, this.status.user))
|
||||
this.status.apiVersion = version.apiVersion
|
||||
this.sidebar = this.sidebarFactory?.(this)
|
||||
if (options.timers) {
|
||||
const ping = () => client.ping().then((ms) => { this.status.pingMs = ms }).catch(() => { this.status.pingMs = null }).finally(() => this.bump())
|
||||
ping()
|
||||
this.timers.push(setInterval(ping, 5000))
|
||||
this.loadJobs()
|
||||
this.timers.push(setInterval(() => this.loadJobs(), 15000))
|
||||
this.startStream()
|
||||
}
|
||||
this.bump()
|
||||
}
|
||||
|
||||
private loadJobs(): void {
|
||||
const client = this.client
|
||||
if (!client) return
|
||||
if (!(this.version?.features ?? []).includes('durableJobs')) {
|
||||
this.status.jobs = 'n/a'
|
||||
return
|
||||
}
|
||||
client.data<{ activeCount?: number; jobs?: unknown[] }>('listJobs', { query: { scope: 'mine', state: 'active' } })
|
||||
.then((r) => { this.status.jobs = String(r.activeCount ?? r.jobs?.length ?? 0) })
|
||||
.catch(() => { this.status.jobs = 'n/a' })
|
||||
.finally(() => this.bump())
|
||||
}
|
||||
|
||||
/** The realtime stream: state in the footer, last event, reconnect with backoff, heartbeat timeout. */
|
||||
private startStream(): void {
|
||||
if (!(this.version?.features ?? []).includes('realtime') || !this.client || !this.target) {
|
||||
this.status.stream = 'n/a'
|
||||
return
|
||||
}
|
||||
const abort = new AbortController()
|
||||
this.streamAbort = abort
|
||||
const target = this.target
|
||||
let delay = 1000
|
||||
const loop = async () => {
|
||||
while (!abort.signal.aborted) {
|
||||
this.status.stream = this.status.stream === 'off' ? 'connecting' : 'reconnecting'
|
||||
this.bump()
|
||||
try {
|
||||
const credential = await currentCredential({ store: storeFor(), target, clientChannel: TUI_CHANNEL })
|
||||
let heartbeat = 30_000
|
||||
let last = Date.now()
|
||||
const watchdog = setInterval(() => { if (Date.now() - last > heartbeat * 2) inner.abort() }, 5000)
|
||||
const inner = new AbortController()
|
||||
abort.signal.addEventListener('abort', () => inner.abort())
|
||||
try {
|
||||
for await (const message of openRealtimeStream({ baseUrl: target.server, clientChannel: TUI_CHANNEL, signal: inner.signal, headers: credential.cookie ? { Cookie: credential.cookie } : undefined, token: () => credential.bearer })) {
|
||||
last = Date.now()
|
||||
delay = 1000
|
||||
this.status.stream = 'connected'
|
||||
if (message.event === 'hello') heartbeat = Number((message.data as { heartbeatSeconds?: number })?.heartbeatSeconds ?? 30) * 1000
|
||||
else this.status.lastEvent = `${message.event} @ ${clock()}`
|
||||
this.onRealtime?.(message.event, message.data)
|
||||
this.bump()
|
||||
}
|
||||
} finally {
|
||||
clearInterval(watchdog)
|
||||
}
|
||||
} catch {
|
||||
// reconnect below
|
||||
}
|
||||
if (abort.signal.aborted) return
|
||||
this.status.stream = 'reconnecting'
|
||||
this.bump()
|
||||
await new Promise((r) => setTimeout(r, delay))
|
||||
delay = Math.min(30_000, delay * 2)
|
||||
}
|
||||
}
|
||||
void loop()
|
||||
}
|
||||
|
||||
/** Realtime events for the presence and extension hooks. */
|
||||
onRealtime?: (event: string, data: unknown) => void
|
||||
|
||||
// ---- misc ----
|
||||
|
||||
remember(item: Omit<RecentItem, 'at'>): void {
|
||||
if (!this.client) return
|
||||
try {
|
||||
addRecent(this.client.server, item)
|
||||
} catch {
|
||||
// not critical
|
||||
}
|
||||
}
|
||||
|
||||
setTheme(name: string): void {
|
||||
this.settings.theme = setTheme(name).name
|
||||
this.bump()
|
||||
}
|
||||
|
||||
setAscii(ascii: boolean): void {
|
||||
this.settings.ascii = ascii
|
||||
setAscii(ascii)
|
||||
this.bump()
|
||||
}
|
||||
|
||||
resize(width: number, height: number): void {
|
||||
this.width = width
|
||||
this.height = height
|
||||
if (!this.showSidebar && this.focus === 'sidebar') this.focus = 'content'
|
||||
this.bump()
|
||||
}
|
||||
|
||||
/** The function-key bar for the current context. */
|
||||
fkeys(): Array<{ n: number; label: string | null }> {
|
||||
const defaults: Record<number, string> = { 1: 'Help', 2: 'Go to', 3: 'View', 4: 'Edit', 5: 'Refresh', 6: 'Filter', 7: 'New', 8: 'Delete', 9: 'Menu', 10: 'Quit' }
|
||||
const overrides: FKeyLabels = this.screen?.fkeys?.() ?? { 3: null, 4: null, 6: null, 7: null, 8: null }
|
||||
return Object.entries(defaults).map(([n, label]) => {
|
||||
const k = Number(n)
|
||||
return { n: k, label: k in overrides ? overrides[k]! : label }
|
||||
})
|
||||
}
|
||||
|
||||
themeTokens() {
|
||||
return theme()
|
||||
}
|
||||
|
||||
private checkIdle(): void {
|
||||
if (this.lock || !this.client) return
|
||||
let minutes = 0
|
||||
try {
|
||||
minutes = readIdleMinutes()
|
||||
} catch {
|
||||
minutes = 0
|
||||
}
|
||||
if (minutes > 0 && Date.now() - this.lastInput > minutes * 60_000) this.commands().lock()
|
||||
}
|
||||
}
|
||||
|
||||
/** What the store needs from the sidebar (screens/sidebar.tsx). */
|
||||
export interface SidebarLike {
|
||||
onKey(key: Key): boolean
|
||||
render(width: number, height: number, focused: boolean): import('react').ReactNode
|
||||
}
|
||||
|
||||
/** What the store needs from the command layer (commands.ts). */
|
||||
export interface CommandsLike {
|
||||
help(): void
|
||||
palette(): void
|
||||
menu(): void
|
||||
commandLine(initial?: string): void
|
||||
viewSettings(): void
|
||||
lock(): void
|
||||
run(line: string): Promise<void>
|
||||
}
|
||||
|
||||
let secretStoreFactory: () => import('@qrun-io/qqq-core').SecretStore = () => { throw new Error('no secret store') }
|
||||
export function setSecretStoreFactory(factory: () => import('@qrun-io/qqq-core').SecretStore): void {
|
||||
secretStoreFactory = factory
|
||||
}
|
||||
function storeFor() {
|
||||
return secretStoreFactory()
|
||||
}
|
||||
|
||||
export function clock(): string {
|
||||
return new Date().toLocaleTimeString('en-GB')
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Themes as token sets, and glyph sets (Unicode or ASCII). Every color the TUI draws comes from the
|
||||
* current token set; nothing else names a color. The choice is saved in ~/.config/qqq/config.json
|
||||
* through the kernel (readTheme / writeTheme).
|
||||
*/
|
||||
|
||||
import type { BorderCharacters } from '@opentui/core'
|
||||
|
||||
export interface ThemeTokens {
|
||||
name: string
|
||||
label: string
|
||||
/** Normal text. */
|
||||
fg: string
|
||||
/** Inactive text (unfocused sidebar entries, hints): readable on the background, never dark grey on black. */
|
||||
dim: string
|
||||
/** Accent: headings, column headers. */
|
||||
acc: string
|
||||
/** Second accent: keys, highlights. */
|
||||
acc2: string
|
||||
ok: string
|
||||
warn: string
|
||||
err: string
|
||||
titleBg: string
|
||||
titleFg: string
|
||||
selBg: string
|
||||
selFg: string
|
||||
/** The selected row when its pane does not have focus. */
|
||||
selInactiveBg: string
|
||||
focusBorder: string
|
||||
border: string
|
||||
keyNum: string
|
||||
keyLabelBg: string
|
||||
keyLabelFg: string
|
||||
overlayBg: string
|
||||
statusBg: string
|
||||
statusFg: string
|
||||
zebraBg: string
|
||||
inputBg: string
|
||||
inputFg: string
|
||||
/** Marked (Space-selected) rows. */
|
||||
markFg: string
|
||||
/** Whether alternating rows are shaded unless the view settings say otherwise. */
|
||||
zebraByDefault: boolean
|
||||
}
|
||||
|
||||
export const THEMES: Record<string, ThemeTokens> = {
|
||||
'high-contrast': {
|
||||
name: 'high-contrast', label: 'High contrast (bright text, strong accents)',
|
||||
fg: '#ffffff', dim: '#c6cbd1', acc: '#5fd7ff', acc2: '#ffd75f', ok: '#5fff87', warn: '#ffd75f', err: '#ff6b6b',
|
||||
titleBg: '#005f87', titleFg: '#ffffff', selBg: '#ffd75f', selFg: '#000000', selInactiveBg: '#4e4e4e',
|
||||
focusBorder: '#ffd75f', border: '#a8b0b8', keyNum: '#ffffff', keyLabelBg: '#005f87', keyLabelFg: '#ffff87',
|
||||
overlayBg: '#121212', statusBg: '#262626', statusFg: '#eeeeee', zebraBg: '#1c1c1c', inputBg: '#3a3a3a', inputFg: '#ffffff',
|
||||
markFg: '#5fff87', zebraByDefault: false,
|
||||
},
|
||||
classic: {
|
||||
name: 'classic', label: 'Classic (cyan title, yellow keys)',
|
||||
fg: '#d5dce5', dim: '#a0a8b0', acc: '#7cc2e8', acc2: '#ffd98a', ok: '#8bd49b', warn: '#f1c86e', err: '#ff9088',
|
||||
titleBg: '#1b6587', titleFg: '#ffffff', selBg: '#2d5f7c', selFg: '#ffffff', selInactiveBg: '#2a3540',
|
||||
focusBorder: '#7cc2e8', border: '#5a6a7a', keyNum: '#ffffff', keyLabelBg: '#1b6587', keyLabelFg: '#ffd98a',
|
||||
overlayBg: '#161d26', statusBg: '#10151b', statusFg: '#c0c8d0', zebraBg: '#18202a', inputBg: '#24313d', inputFg: '#ffffff',
|
||||
markFg: '#8bd49b', zebraByDefault: true,
|
||||
},
|
||||
light: {
|
||||
name: 'light', label: 'Light (for light terminal backgrounds)',
|
||||
fg: '#1a1a1a', dim: '#4e4e4e', acc: '#005f87', acc2: '#875f00', ok: '#1e7b34', warn: '#875f00', err: '#b00020',
|
||||
titleBg: '#005f87', titleFg: '#ffffff', selBg: '#005f87', selFg: '#ffffff', selInactiveBg: '#c6c6c6',
|
||||
focusBorder: '#005f87', border: '#6c6c6c', keyNum: '#000000', keyLabelBg: '#005f87', keyLabelFg: '#ffffff',
|
||||
overlayBg: '#f4f4f4', statusBg: '#dadada', statusFg: '#1a1a1a', zebraBg: '#ececec', inputBg: '#e0e0e0', inputFg: '#000000',
|
||||
markFg: '#1e7b34', zebraByDefault: true,
|
||||
},
|
||||
}
|
||||
|
||||
export const DEFAULT_THEME = 'high-contrast'
|
||||
|
||||
export interface Glyphs {
|
||||
ellipsis: string
|
||||
up: string
|
||||
down: string
|
||||
bar: string
|
||||
spark: string
|
||||
pointer: string
|
||||
closed: string
|
||||
open: string
|
||||
mark: string
|
||||
unmark: string
|
||||
check: string
|
||||
uncheck: string
|
||||
radioOn: string
|
||||
radioOff: string
|
||||
sep: string
|
||||
rule: string
|
||||
process: string
|
||||
report: string
|
||||
app: string
|
||||
link: string
|
||||
border?: BorderCharacters
|
||||
}
|
||||
|
||||
const UNICODE: Glyphs = {
|
||||
ellipsis: '…', up: '▲', down: '▼', bar: '█', spark: '▁▂▃▄▅▆▇█', pointer: '▶', closed: '▸', open: '▾', mark: '●', unmark: ' ',
|
||||
check: '[x]', uncheck: '[ ]', radioOn: '(•)', radioOff: '( )', sep: '│', rule: '─', process: '▷', report: '≡', app: '◆', link: '↗',
|
||||
}
|
||||
|
||||
const ASCII_BORDER: BorderCharacters = { topLeft: '+', topRight: '+', bottomLeft: '+', bottomRight: '+', horizontal: '-', vertical: '|', topT: '+', bottomT: '+', leftT: '+', rightT: '+', cross: '+' }
|
||||
|
||||
const ASCII: Glyphs = {
|
||||
ellipsis: '~', up: '^', down: 'v', bar: '#', spark: ' .:-=+*#', pointer: '>', closed: '+', open: '-', mark: '*', unmark: ' ',
|
||||
check: '[x]', uncheck: '[ ]', radioOn: '(*)', radioOff: '( )', sep: '|', rule: '-', process: '>', report: '=', app: '*', link: '->', border: ASCII_BORDER,
|
||||
}
|
||||
|
||||
let currentTheme: ThemeTokens = THEMES[DEFAULT_THEME]!
|
||||
let currentGlyphs: Glyphs = UNICODE
|
||||
|
||||
export function theme(): ThemeTokens {
|
||||
return currentTheme
|
||||
}
|
||||
|
||||
export function glyphs(): Glyphs {
|
||||
return currentGlyphs
|
||||
}
|
||||
|
||||
/** Switches the theme; unknown names fall back to the default. Returns the theme in effect. */
|
||||
export function setTheme(name: string | undefined): ThemeTokens {
|
||||
currentTheme = (name && THEMES[name]) || THEMES[DEFAULT_THEME]!
|
||||
return currentTheme
|
||||
}
|
||||
|
||||
export function setAscii(ascii: boolean): void {
|
||||
currentGlyphs = ascii ? ASCII : UNICODE
|
||||
}
|
||||
|
||||
export function isAscii(): boolean {
|
||||
return currentGlyphs === ASCII
|
||||
}
|
||||
|
||||
/** Box border props for the current glyph set. */
|
||||
export function borderProps(focused: boolean): { border: true; borderColor: string; customBorderChars?: BorderCharacters } {
|
||||
return { border: true, borderColor: focused ? currentTheme.focusBorder : currentTheme.border, ...(currentGlyphs.border ? { customBorderChars: currentGlyphs.border } : {}) }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react'
|
||||
import type { Key } from './keys'
|
||||
|
||||
/** A function-key label for the bar: a string, or null when the key does nothing here (the label is hidden). */
|
||||
export type FKeyLabels = Partial<Record<number, string | null>>
|
||||
|
||||
/** One entry of the F9 command center (and of the palette): runnable, searchable, with its shortcut. */
|
||||
export interface MenuEntry {
|
||||
section: 'Context' | 'Go to' | 'Work' | 'Connection' | 'Settings' | 'Help'
|
||||
label: string
|
||||
/** Shortcut shown beside the entry. */
|
||||
key?: string
|
||||
/** The `:` command that does the same, when there is one. */
|
||||
command?: string
|
||||
/** Shown but not runnable, with the reason (parked, not installed). */
|
||||
disabled?: string
|
||||
/** Extra text (a count, a state). */
|
||||
detail?: string
|
||||
run?: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* A screen on the navigation stack. The object is the route entry: its state (cursor, page,
|
||||
* filter, selection, scroll) lives here, not in React component state, so going back finds it
|
||||
* exactly as it was. Views read from it; key handlers mutate it and ask the store to repaint.
|
||||
*/
|
||||
export interface Screen {
|
||||
readonly kind: string
|
||||
/** Identity for the error boundary (a new identity resets a failed view). */
|
||||
readonly id: string
|
||||
title(): string
|
||||
onKey(key: Key): boolean
|
||||
render(width: number, height: number, focused: boolean): ReactNode
|
||||
/** Function-key labels that differ from the defaults (null hides a key that does nothing here). */
|
||||
fkeys?(): FKeyLabels
|
||||
/** Entries for the Context section of the menu. */
|
||||
menu?(): MenuEntry[]
|
||||
/** Unsaved changes (forms): leaving or quitting asks first. */
|
||||
dirty?(): boolean
|
||||
/** Work in progress (a running process): quitting warns. */
|
||||
busy?(): boolean
|
||||
/** Called when this screen is on top again after a screen above it closed. */
|
||||
resume?(): void
|
||||
refresh?(): void
|
||||
dispose?(): void
|
||||
/** While true, printable keys belong to the screen (a text field has focus): single-key shortcuts are off. */
|
||||
capturesText?(): boolean
|
||||
/** The presence scope of the screen (`table:person`, `record:person:3`), for the collaboration slot. */
|
||||
presenceScope?(): string | undefined
|
||||
/** The CLI command that shows the same thing, for copying. */
|
||||
cliEquivalent?(): string | undefined
|
||||
/** The view-settings dialog for this screen, when it has one. */
|
||||
viewSettings?(): Overlay | undefined
|
||||
/** The help topic: table, process or app name, for F1. */
|
||||
helpTopic?(): { kind: 'table' | 'process' | 'app'; name: string } | undefined
|
||||
}
|
||||
|
||||
/** A modal overlay: takes every key while it is on top. */
|
||||
export interface Overlay {
|
||||
readonly id: string
|
||||
onKey(key: Key): void
|
||||
render(width: number, height: number): ReactNode
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { displayDateTime } from '@qrun-io/qqq-core'
|
||||
import { glyphs } from '../theme'
|
||||
|
||||
/** Cuts text to a width, with an ellipsis when it was longer. */
|
||||
export function trunc(text: string, width: number): string {
|
||||
if (width <= 0) return ''
|
||||
const flat = text.replace(/[\r\n\t]+/g, ' ')
|
||||
return flat.length <= width ? flat : width === 1 ? glyphs().ellipsis : flat.slice(0, width - 1) + glyphs().ellipsis
|
||||
}
|
||||
|
||||
/** Pads (or cuts) text to exactly `width` cells. */
|
||||
export function pad(text: string, width: number): string {
|
||||
const t = trunc(text, width)
|
||||
return t + ' '.repeat(Math.max(0, width - t.length))
|
||||
}
|
||||
|
||||
/** Right-aligns text in `width` cells. */
|
||||
export function padLeft(text: string, width: number): string {
|
||||
const t = trunc(text, width)
|
||||
return ' '.repeat(Math.max(0, width - t.length)) + t
|
||||
}
|
||||
|
||||
/** Centres text in `width` cells. */
|
||||
export function center(text: string, width: number): string {
|
||||
const t = trunc(text, width)
|
||||
const left = Math.floor((width - t.length) / 2)
|
||||
return ' '.repeat(Math.max(0, left)) + t + ' '.repeat(Math.max(0, width - t.length - left))
|
||||
}
|
||||
|
||||
export function num(n: number | undefined | null): string {
|
||||
return n === undefined || n === null ? '…' : n.toLocaleString('en-US')
|
||||
}
|
||||
|
||||
/** A value as text: display value when present. */
|
||||
export function cell(record: { values?: Record<string, unknown>; displayValues?: Record<string, string> } | undefined, field: string): string {
|
||||
if (!record) return ''
|
||||
const display = record.displayValues?.[field]
|
||||
const raw = record.values?.[field]
|
||||
const v = display ?? raw
|
||||
if (v === null || v === undefined) return ''
|
||||
return typeof v === 'object' ? JSON.stringify(v) : String(v)
|
||||
}
|
||||
|
||||
/** A value as text for a field: DATE_TIME in the viewer's zone (kernel displayDateTime), else the display value. */
|
||||
export function fieldCell(record: { values?: Record<string, unknown>; displayValues?: Record<string, string> } | undefined, field: { name: string; type: string }): string {
|
||||
if (record && field.type === 'DATE_TIME') {
|
||||
const raw = record.values?.[field.name]
|
||||
if (raw !== null && raw !== undefined && raw !== '') return displayDateTime(raw, record.displayValues?.[field.name])
|
||||
}
|
||||
return cell(record, field.name)
|
||||
}
|
||||
|
||||
/** Splits a command line into arguments as a shell would (quotes, backslash escapes; no expansion). */
|
||||
export function shellSplit(line: string): string[] {
|
||||
const out: string[] = []
|
||||
let current = ''
|
||||
let quote: '"' | "'" | null = null
|
||||
let has = false
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const ch = line[i]!
|
||||
if (quote) {
|
||||
if (ch === quote) quote = null
|
||||
else if (ch === '\\' && quote === '"' && i + 1 < line.length) current += line[++i]
|
||||
else current += ch
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
quote = ch
|
||||
has = true
|
||||
} else if (ch === '\\' && i + 1 < line.length) {
|
||||
current += line[++i]
|
||||
has = true
|
||||
} else if (/\s/.test(ch)) {
|
||||
if (has || current) out.push(current)
|
||||
current = ''
|
||||
has = false
|
||||
} else {
|
||||
current += ch
|
||||
}
|
||||
}
|
||||
if (has || current) out.push(current)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Quotes an argument for a command line when it needs it. */
|
||||
export function shellQuote(arg: string): string {
|
||||
return /^[A-Za-z0-9_./:=,@+-]+$/.test(arg) ? arg : `"${arg.replace(/(["\\$`])/g, '\\$1')}"`
|
||||
}
|
||||
|
||||
export function errorText(error: unknown): string {
|
||||
if (error && typeof error === 'object' && 'fieldErrors' in error && Array.isArray((error as { fieldErrors: unknown[] }).fieldErrors) && (error as { fieldErrors: unknown[] }).fieldErrors.length) {
|
||||
const e = error as { message: string; fieldErrors: Array<{ field: string; message: string }> }
|
||||
return `${e.message}: ${e.fieldErrors.map((f) => `${f.field} ${f.message}`).join('; ')}`
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
const cause = (error as Error & { cause?: { message?: string } }).cause
|
||||
if (/fetch failed|Unable to connect|ECONNREFUSED/i.test(error.message + String(cause?.message ?? ''))) return `cannot reach the server (${cause?.message ?? error.message})`
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Draws the kernel's widget render model (RenderBlock) as styled terminal lines: stat boxes, bar
|
||||
* charts in block characters, sparklines for lines, percentage bars for pies, aligned tables,
|
||||
* steppers, alerts. New widget types are handled once in the kernel; this only draws blocks.
|
||||
*/
|
||||
|
||||
import type { RenderBlock } from '@qrun-io/qqq-core'
|
||||
import { wrap } from './overlays/basic'
|
||||
import { glyphs, theme } from './theme'
|
||||
import { pad, padLeft, trunc } from './util/text'
|
||||
|
||||
export interface StyledLine {
|
||||
text: string
|
||||
fg?: string
|
||||
bg?: string
|
||||
bold?: boolean
|
||||
}
|
||||
|
||||
function fmt(n: number): string {
|
||||
return Number.isFinite(n) ? n.toLocaleString('en-US', { maximumFractionDigits: 2 }) : String(n)
|
||||
}
|
||||
|
||||
/** Aligned table lines that fit a width (columns shrink from the widest). */
|
||||
export function tableLines(headers: string[], rows: string[][], width: number): StyledLine[] {
|
||||
const t = theme()
|
||||
if (!headers.length) return []
|
||||
const widths = headers.map((h, i) => Math.min(40, Math.max(h.length, ...rows.map((r) => (r[i] ?? '').length))))
|
||||
const total = () => widths.reduce((a, b) => a + b + 2, 0)
|
||||
while (total() > width && Math.max(...widths) > 4) widths[widths.indexOf(Math.max(...widths))]!--
|
||||
const line = (cells: string[]) => cells.map((c, i) => pad(c, widths[i]!)).join(' ')
|
||||
return [
|
||||
{ text: line(headers), fg: t.acc, bold: true },
|
||||
{ text: widths.map((w) => glyphs().rule.repeat(w)).join(' '), fg: t.dim },
|
||||
...rows.map((r) => ({ text: line(r) })),
|
||||
]
|
||||
}
|
||||
|
||||
/** Turns blocks into lines for a width. */
|
||||
export function blockLines(blocks: RenderBlock[], width: number): StyledLine[] {
|
||||
const t = theme()
|
||||
const g = glyphs()
|
||||
const out: StyledLine[] = []
|
||||
for (const b of blocks) {
|
||||
switch (b.kind) {
|
||||
case 'heading':
|
||||
out.push({ text: trunc(b.text, width), fg: t.acc, bold: true })
|
||||
if (b.sub) out.push(...wrap(b.sub, width).map((text) => ({ text, fg: t.dim })))
|
||||
break
|
||||
case 'text':
|
||||
out.push(...wrap(b.text, width).map((text) => ({ text })))
|
||||
break
|
||||
case 'stat': {
|
||||
const delta = b.delta ? ` ${b.delta.amount >= 0 ? '+' : ''}${b.delta.amount}%${b.delta.label ? ` ${b.delta.label}` : ''}` : ''
|
||||
out.push({ text: trunc(`${b.label ? `${b.label}: ` : ''}${b.value}${b.context ? ` ${b.context}` : ''}`, width), fg: t.fg, bold: true })
|
||||
if (delta) out.push({ text: trunc(delta.trim(), width), fg: b.delta?.good === true ? t.ok : b.delta?.good === false ? t.err : t.dim })
|
||||
break
|
||||
}
|
||||
case 'table':
|
||||
out.push(...tableLines(b.headers, b.rows.map((r) => b.columns.map((c) => String(r[c] ?? ''))), width))
|
||||
if (!b.rows.length) out.push({ text: '(no rows)', fg: t.dim })
|
||||
break
|
||||
case 'series': {
|
||||
const labelWidth = Math.min(18, Math.max(4, ...b.labels.map((l) => l.length)))
|
||||
if (b.chart === 'line' && b.datasets.length) {
|
||||
for (const d of b.datasets) {
|
||||
const max = Math.max(...d.data, 0)
|
||||
const min = Math.min(...d.data, 0)
|
||||
const spark = d.data.map((n) => g.spark[Math.round(((n - min) / ((max - min) || 1)) * (g.spark.length - 1))]).join('')
|
||||
out.push({ text: trunc(`${pad(d.label || 'value', labelWidth)} ${spark} ${fmt(min)} … ${fmt(max)}`, width), fg: t.acc2 })
|
||||
}
|
||||
out.push(...tableLines(['', ...b.datasets.map((d) => d.label || 'value')], b.labels.map((l, i) => [l, ...b.datasets.map((d) => fmt(d.data[i] ?? 0))]), width))
|
||||
} else if (b.chart === 'pie' && b.datasets[0]) {
|
||||
const d = b.datasets[0]
|
||||
const sum = d.data.reduce((a, n) => a + Math.abs(n), 0) || 1
|
||||
const barWidth = Math.max(10, width - labelWidth - 14)
|
||||
b.labels.forEach((l, i) => {
|
||||
const pct = (Math.abs(d.data[i] ?? 0) / sum) * 100
|
||||
out.push({ text: `${pad(l, labelWidth)} ${pad(g.bar.repeat(Math.round((pct / 100) * barWidth)), barWidth)} ${padLeft(pct.toFixed(1) + '%', 6)}`, fg: i % 2 ? t.acc : t.acc2 })
|
||||
})
|
||||
} else {
|
||||
const values = b.labels.map((_, i) => b.datasets.reduce((a, d) => a + (d.data[i] ?? 0), 0))
|
||||
const max = Math.max(...values.map(Math.abs), 1)
|
||||
const numWidth = Math.max(...values.map((v) => fmt(v).length), 1)
|
||||
const barWidth = Math.max(6, width - labelWidth - numWidth - 4)
|
||||
b.labels.forEach((l, i) => {
|
||||
if (b.datasets.length > 1 && b.chart === 'stackedBar') {
|
||||
// one segment per dataset, alternating accents
|
||||
let line = ''
|
||||
b.datasets.forEach((d, k) => { line += (k % 2 ? '▓' : g.bar).repeat(Math.round((Math.abs(d.data[i] ?? 0) / max) * barWidth)) })
|
||||
out.push({ text: `${pad(l, labelWidth)} ${pad(line, barWidth)} ${padLeft(fmt(values[i]!), numWidth)}`, fg: t.acc })
|
||||
} else {
|
||||
out.push({ text: `${pad(l, labelWidth)} ${pad(g.bar.repeat(Math.round((Math.abs(values[i]!) / max) * barWidth)), barWidth)} ${padLeft(fmt(values[i]!), numWidth)}`, fg: t.acc })
|
||||
}
|
||||
})
|
||||
if (b.datasets.length > 1) out.push({ text: trunc(b.datasets.map((d, k) => `${k % 2 ? '▓' : g.bar} ${d.label}`).join(' '), width), fg: t.dim })
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'pairs': {
|
||||
const w = Math.min(28, Math.max(...b.pairs.map((p) => p.label.length), 1))
|
||||
for (const p of b.pairs) out.push({ text: trunc(`${pad(p.label, w)} ${p.value}`, width) })
|
||||
break
|
||||
}
|
||||
case 'steps':
|
||||
for (const s of b.steps) out.push({ text: trunc(`${s.state === 'done' ? g.check : s.state === 'active' ? g.pointer + ' ' : g.uncheck} ${s.label}`, width), fg: s.state === 'active' ? t.acc2 : s.state === 'done' ? t.ok : t.dim })
|
||||
break
|
||||
case 'alert':
|
||||
out.push(...wrap(`[${b.level.toUpperCase()}] ${b.text}`, width).map((text) => ({ text, fg: b.level === 'error' ? t.err : b.level === 'warning' ? t.warn : b.level === 'success' ? t.ok : t.acc })))
|
||||
break
|
||||
case 'link':
|
||||
out.push({ text: trunc(`${g.link} ${b.label}: ${b.href}`, width), fg: t.acc })
|
||||
break
|
||||
case 'rule':
|
||||
out.push({ text: g.rule.repeat(Math.min(width, 60)), fg: t.dim })
|
||||
break
|
||||
case 'children':
|
||||
b.blocks.forEach((child, i) => {
|
||||
if (i) out.push({ text: '' })
|
||||
out.push(...blockLines(child, width))
|
||||
})
|
||||
break
|
||||
case 'raw':
|
||||
out.push({ text: trunc(`(${b.type}: no terminal rendering yet)`, width), fg: t.dim })
|
||||
break
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Draws lines as <text> rows, padded to the width. */
|
||||
export function Lines({ lines, width }: { lines: StyledLine[]; width: number }) {
|
||||
return (
|
||||
<>
|
||||
{lines.map((l, i) => <text key={i} fg={l.fg ?? theme().fg} bg={l.bg}>{l.bold ? <strong>{pad(l.text, width)}</strong> : pad(l.text, width)}</text>)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/** A fake UI API server for in-memory TUI tests: a person table with pets, a contingent address form, a process. */
|
||||
|
||||
type J = Record<string, unknown>
|
||||
|
||||
const json = (data: unknown, status = 200) => new Response(JSON.stringify(data), { status, headers: { 'content-type': 'application/json' } })
|
||||
|
||||
export const PEOPLE = Array.from({ length: 30 }, (_, i) => ({ id: i + 1, firstName: `First${i + 1}`, lastName: i % 2 ? 'Odd' : 'Even', birthDate: `19${70 + (i % 30)}-01-01` }))
|
||||
|
||||
const personMeta = {
|
||||
name: 'person', label: 'Person', primaryKeyField: 'id', insertPermission: true, editPermission: true, deletePermission: true, capabilities: ['TABLE_QUERY'],
|
||||
fields: {
|
||||
id: { name: 'id', label: 'Id', type: 'INTEGER', isEditable: false },
|
||||
firstName: { name: 'firstName', label: 'First Name', type: 'STRING', isRequired: true },
|
||||
lastName: { name: 'lastName', label: 'Last Name', type: 'STRING' },
|
||||
birthDate: { name: 'birthDate', label: 'Birth Date', type: 'DATE' },
|
||||
},
|
||||
sections: [{ name: 'identity', label: 'Identity', tier: 'T1', fieldNames: ['id', 'firstName', 'lastName'] }, { name: 'more', label: 'More', tier: 'T2', fieldNames: ['birthDate'] }],
|
||||
associations: [],
|
||||
}
|
||||
|
||||
const addressMeta = {
|
||||
name: 'address', label: 'Address', primaryKeyField: 'id', insertPermission: true, editPermission: true, deletePermission: true,
|
||||
fields: {
|
||||
id: { name: 'id', label: 'Id', type: 'INTEGER', isEditable: false },
|
||||
label: { name: 'label', label: 'Label', type: 'STRING' },
|
||||
countryId: { name: 'countryId', label: 'Country', type: 'STRING', possibleValueSourceName: 'country' },
|
||||
stateId: { name: 'stateId', label: 'State', type: 'STRING', possibleValueSourceName: 'state', possibleValueSourceDependsOn: ['countryId'], possibleValueSourceDependencyBehavior: 'DISABLE_UNTIL_SET' },
|
||||
},
|
||||
sections: [{ name: 's', label: 'Address', tier: 'T1', fieldNames: ['id', 'label', 'countryId', 'stateId'] }],
|
||||
}
|
||||
|
||||
const STATES: Record<string, Array<{ id: string; label: string }>> = { USA: [{ id: 'MO', label: 'Missouri' }, { id: 'IL', label: 'Illinois' }], CAN: [{ id: 'ON', label: 'Ontario' }] }
|
||||
|
||||
const processMeta = {
|
||||
name: 'sleeper', label: 'Sleeper', frontendSteps: [
|
||||
{ name: 'input', label: 'Input', components: [{ type: 'EDIT_FORM' }], formFields: [{ name: 'millis', label: 'Millis', type: 'INTEGER', isRequired: true }] },
|
||||
{ name: 'result', label: 'Result', components: [{ type: 'VIEW_FORM' }], viewFields: [{ name: 'message', label: 'Message', type: 'STRING' }] },
|
||||
],
|
||||
}
|
||||
|
||||
export interface FakeLog { requests: Array<{ method: string; path: string; body?: unknown }> }
|
||||
|
||||
/** A fetch answering the operations the TUI uses. */
|
||||
export function fakeFetch(log: FakeLog = { requests: [] }): typeof fetch {
|
||||
let statusPolls = 0
|
||||
return (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = new Request(input as RequestInfo, init)
|
||||
const url = new URL(request.url)
|
||||
const path = url.pathname.replace('/qqq/v1', '')
|
||||
let body: unknown
|
||||
const type = request.headers.get('content-type') ?? ''
|
||||
if (request.method !== 'GET') {
|
||||
if (type.includes('json')) body = await request.json()
|
||||
else if (type.includes('form')) body = Object.fromEntries((await request.formData()).entries())
|
||||
}
|
||||
log.requests.push({ method: request.method, path, body })
|
||||
if (path === '/version') return json({ apiVersion: '1.8.0', features: ['metaData', 'tables', 'processes'], extensions: [] })
|
||||
if (path === '/metaData') {
|
||||
return json({
|
||||
apps: { people: { name: 'people', label: 'People', children: [{ type: 'TABLE', name: 'person', label: 'Person' }, { type: 'TABLE', name: 'address', label: 'Address' }, { type: 'PROCESS', name: 'sleeper', label: 'Sleeper' }] } },
|
||||
appTree: [{ type: 'APP', name: 'people', label: 'People', children: [{ type: 'TABLE', name: 'person', label: 'Person' }, { type: 'TABLE', name: 'address', label: 'Address' }, { type: 'PROCESS', name: 'sleeper', label: 'Sleeper' }] }],
|
||||
tables: { person: { name: 'person', label: 'Person', insertPermission: true }, address: { name: 'address', label: 'Address', insertPermission: true } },
|
||||
processes: { sleeper: { name: 'sleeper', label: 'Sleeper' } },
|
||||
widgets: {}, branding: { appName: 'Fake' },
|
||||
})
|
||||
}
|
||||
if (path === '/metaData/table/person') return json(personMeta)
|
||||
if (path === '/metaData/table/address') return json(addressMeta)
|
||||
if (path === '/metaData/process/sleeper') return json(processMeta)
|
||||
const filter = ((body as J | undefined)?.filter ?? {}) as { skip?: number; limit?: number; orderBys?: Array<{ fieldName: string; isAscending: boolean }>; criteria?: Array<{ fieldName: string; values: string[] }> }
|
||||
let people = [...PEOPLE]
|
||||
for (const c of filter.criteria ?? []) people = people.filter((p) => String((p as J)[c.fieldName]) === String(c.values[0]))
|
||||
const order = filter.orderBys?.[0]
|
||||
const key = (o: J) => (typeof o[order?.fieldName ?? 'id'] === 'number' ? (o[order?.fieldName ?? 'id'] as number) : String(o[order?.fieldName ?? 'id']))
|
||||
if (order) people.sort((a, b) => (key(a) < key(b) ? -1 : 1) * (order.isAscending ? 1 : -1))
|
||||
if (path === '/table/person/count') return json({ count: people.length })
|
||||
if (path === '/table/person/query') {
|
||||
const page = people.slice(filter.skip ?? 0, (filter.skip ?? 0) + (filter.limit ?? 50))
|
||||
return json({ records: page.map((p) => ({ values: p, displayValues: {}, recordLabel: `${p.firstName} ${p.lastName}` })) })
|
||||
}
|
||||
const rec = /^\/table\/person\/(\d+)$/.exec(path)
|
||||
if (rec && request.method === 'GET') {
|
||||
const p = PEOPLE.find((x) => x.id === Number(rec[1]))
|
||||
return p ? json({ record: { values: p, displayValues: {}, recordLabel: `${p.firstName} ${p.lastName}`, associatedRecords: {} } }) : json({ code: 'NOT_FOUND', message: 'no' }, 404)
|
||||
}
|
||||
const pv = /^\/table\/address\/possibleValues\/(\w+)$/.exec(path)
|
||||
if (pv) {
|
||||
const b = body as { searchTerm?: string; ids?: string[]; labels?: string[]; values?: J }
|
||||
let options = pv[1] === 'countryId' ? [{ id: 'USA', label: 'USA' }, { id: 'CAN', label: 'Canada' }] : STATES[String(b.values?.countryId ?? '')] ?? []
|
||||
if (b.ids?.length) options = options.filter((o) => b.ids!.includes(o.id))
|
||||
if (b.labels?.length) options = options.filter((o) => b.labels!.some((l) => l.toLowerCase() === o.label.toLowerCase()))
|
||||
if (b.searchTerm) options = options.filter((o) => o.label.toLowerCase().includes(b.searchTerm!.toLowerCase()))
|
||||
return json({ options })
|
||||
}
|
||||
if (path === '/table/address' && request.method === 'POST') return json({ record: { values: { id: 9, ...(body as J) }, recordLabel: 'new' } })
|
||||
if (path === '/processes/sleeper/init') return json({ type: 'COMPLETE', processUUID: 'p1', nextStep: 'input', values: {} })
|
||||
if (path === '/processes/sleeper/p1/step/input') return json({ type: 'JOB_STARTED', processUUID: 'p1', jobUUID: 'j1' })
|
||||
if (path === '/processes/sleeper/p1/status/j1') {
|
||||
statusPolls++
|
||||
if (statusPolls < 2) return json({ type: 'RUNNING', processUUID: 'p1', message: 'sleeping', current: 1, total: 2 })
|
||||
const millis = (log.requests.find((r) => r.path === '/processes/sleeper/p1/step/input')?.body as J | undefined)?.values
|
||||
return json({ type: 'COMPLETE', processUUID: 'p1', nextStep: 'result', values: { message: `slept ${JSON.parse(String(millis ?? '{}')).millis}` } })
|
||||
}
|
||||
return json({ code: 'NOT_FOUND', message: `fake server has no ${request.method} ${path}` }, 404)
|
||||
}) as typeof fetch
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { act } from 'react'
|
||||
import { testRender } from '@opentui/react/test-utils'
|
||||
import type { TestRendererSetup } from '@opentui/core/testing'
|
||||
import { upsertProfile, type SecretStore } from '@qrun-io/qqq-core'
|
||||
import { App } from '../src/app'
|
||||
import { boot, parseArgs } from '../src/boot'
|
||||
import type { AppStore } from '../src/store'
|
||||
import { fakeFetch, type FakeLog } from './fake-server'
|
||||
|
||||
// async loads repaint outside act(); the frames are awaited explicitly, so the warning is noise here
|
||||
const consoleError = console.error
|
||||
console.error = (...args: unknown[]) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('not wrapped in act')) return
|
||||
consoleError(...args)
|
||||
}
|
||||
|
||||
/** Isolated config, cache and state directories for one test file. */
|
||||
export function isolate(): void {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'qqq-tui-test-'))
|
||||
process.env.QQQ_CONFIG_DIR = join(dir, 'config')
|
||||
process.env.QQQ_CACHE_DIR = join(dir, 'cache')
|
||||
process.env.QQQ_STATE_DIR = join(dir, 'state')
|
||||
delete process.env.QQQ_TOKEN
|
||||
delete process.env.QQQ_PROFILE
|
||||
delete process.env.QQQ_SERVER
|
||||
upsertProfile('fake', { server: 'http://fake.test' })
|
||||
}
|
||||
|
||||
/** A secret store holding a session sign-in for every account. */
|
||||
export function sessionSecrets(): SecretStore {
|
||||
const login = JSON.stringify({ kind: 'session', authType: 'MOCK', sessionUUID: 'test-session', user: { name: 'Tester' }, createdAt: new Date().toISOString() })
|
||||
return { kind: 'memory', get: async () => login, set: async () => undefined, delete: async () => true }
|
||||
}
|
||||
|
||||
export interface Harness {
|
||||
setup: TestRendererSetup
|
||||
store: AppStore
|
||||
log: FakeLog
|
||||
frame(): string
|
||||
}
|
||||
|
||||
/** Renders the whole TUI against the fake server and waits for the home screen. */
|
||||
export async function startTui(width = 120, height = 36): Promise<Harness> {
|
||||
const log: FakeLog = { requests: [] }
|
||||
const store = boot({ args: parseArgs(['--profile', 'fake']), quit: () => undefined, build: 'test', secrets: sessionSecrets, fetch: fakeFetch(log) })
|
||||
const setup = await testRender(<App store={store} />, { width, height })
|
||||
const h: Harness = { setup, store, log, frame: () => setup.captureCharFrame() }
|
||||
await waitFor(h, (f) => f.includes('Apps'))
|
||||
return h
|
||||
}
|
||||
|
||||
/** Fires input inside act(), lets data events settle, paints one frame. */
|
||||
export async function press(h: Harness, fire: () => void | Promise<void>, settleMs = 30): Promise<string> {
|
||||
await act(async () => {
|
||||
await fire()
|
||||
await new Promise((resolve) => setTimeout(resolve, settleMs))
|
||||
})
|
||||
await h.setup.renderOnce()
|
||||
return h.frame()
|
||||
}
|
||||
|
||||
/** Waits until the frame satisfies a predicate (async loads), or fails with the last frame. */
|
||||
export async function waitFor(h: Harness, ok: (frame: string) => boolean, timeoutMs = 3000): Promise<string> {
|
||||
const end = Date.now() + timeoutMs
|
||||
let frame = ''
|
||||
while (Date.now() < end) {
|
||||
frame = await press(h, () => undefined, 25)
|
||||
if (ok(frame)) return frame
|
||||
}
|
||||
throw new Error(`timed out; last frame:\n${frame}`)
|
||||
}
|
||||
|
||||
/** Types a `:` command and runs it. */
|
||||
export async function command(h: Harness, line: string): Promise<void> {
|
||||
await press(h, () => h.setup.mockInput.typeText(':'))
|
||||
await press(h, () => h.setup.mockInput.typeText(line))
|
||||
await press(h, () => h.setup.mockInput.pressEnter())
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { afterEach, beforeAll, expect, test } from 'bun:test'
|
||||
import { KeyCodes } from '@opentui/core/testing'
|
||||
import type { TableScreen } from '../src/screens/table'
|
||||
import { command, isolate, press, startTui, waitFor, type Harness } from './helpers'
|
||||
|
||||
let h: Harness | undefined
|
||||
|
||||
beforeAll(() => isolate())
|
||||
afterEach(() => {
|
||||
h?.store.dispose()
|
||||
h?.setup.renderer.destroy()
|
||||
h = undefined
|
||||
})
|
||||
|
||||
test('home, then the grid; Esc from a record lands on the same row, also after stepping', async () => {
|
||||
h = await startTui()
|
||||
expect(h.frame()).toContain('People')
|
||||
await command(h, 'table query person')
|
||||
await waitFor(h, (f) => f.includes('First1 ') && f.includes('30 records'))
|
||||
const grid = h.store.screen as TableScreen
|
||||
for (let i = 0; i < 3; i++) await press(h, () => h!.setup.mockInput.pressArrow('down'))
|
||||
await press(h, () => h!.setup.mockInput.pressEnter())
|
||||
await waitFor(h, (f) => f.includes('record 4 of 30'))
|
||||
await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
await waitFor(h, (f) => f.includes('row 4 '))
|
||||
expect(h.store.screen).toBe(grid)
|
||||
expect(grid.cursor!.index).toBe(3)
|
||||
await press(h, () => h!.setup.mockInput.pressEnter())
|
||||
await waitFor(h, (f) => f.includes('record 4 of 30'))
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await press(h, () => h!.setup.mockInput.pressKey(']'))
|
||||
await waitFor(h, (f) => f.includes(`record ${5 + i} of 30`))
|
||||
}
|
||||
await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
const back = await waitFor(h, (f) => f.includes('row 7 '))
|
||||
expect(grid.cursor!.index).toBe(6)
|
||||
expect(back).toContain('First7')
|
||||
})
|
||||
|
||||
test('the grid pages on the server, sorts with s and filters through the kernel grammar', async () => {
|
||||
h = await startTui(120, 20)
|
||||
await command(h, 'table query person')
|
||||
await waitFor(h, (f) => f.includes('30 records'))
|
||||
const grid = h.store.screen as TableScreen
|
||||
const pageSize = grid.cursor!.pageSize
|
||||
expect(pageSize).toBeLessThan(30)
|
||||
await press(h, () => h!.setup.mockInput.pressKey(KeyCodes.END))
|
||||
await waitFor(h, (f) => f.includes('First30'))
|
||||
const lastQuery = h.log.requests.filter((r) => r.path === '/table/person/query').at(-1)!
|
||||
expect((lastQuery.body as { filter: { skip: number } }).filter.skip).toBe(Math.floor(29 / pageSize) * pageSize)
|
||||
await press(h, () => h!.setup.mockInput.pressKey(KeyCodes.HOME))
|
||||
await press(h, () => h!.setup.mockInput.pressArrow('right'))
|
||||
await press(h, () => h!.setup.mockInput.pressKey('s'))
|
||||
await waitFor(h, (f) => f.includes('sort firstName asc'))
|
||||
const sorted = h.log.requests.filter((r) => r.path === '/table/person/query').at(-1)!
|
||||
expect((sorted.body as { filter: { orderBys: unknown } }).filter.orderBys).toEqual([{ fieldName: 'firstName', isAscending: true }])
|
||||
await command(h, 'filter lastName = Odd')
|
||||
const filtered = await waitFor(h, (f) => f.includes('15 records'))
|
||||
expect(filtered).toContain('lastName EQUALS Odd')
|
||||
})
|
||||
|
||||
test('a form with a contingent field: State waits for Country, then narrows to its states', async () => {
|
||||
h = await startTui()
|
||||
await command(h, 'table create address')
|
||||
let f = await waitFor(h, (x) => x.includes('New Address'))
|
||||
expect(f).toContain('set Country first')
|
||||
await press(h, () => h!.setup.mockInput.typeText('Home'))
|
||||
await press(h, () => h!.setup.mockInput.pressTab())
|
||||
await press(h, () => h!.setup.mockInput.pressEnter())
|
||||
await waitFor(h, (x) => x.includes('Choose Country') && x.includes('Canada'))
|
||||
await press(h, () => h!.setup.mockInput.pressEnter())
|
||||
f = await waitFor(h, (x) => !x.includes('Choose Country') && !x.includes('set Country first'))
|
||||
expect(f).toContain('USA')
|
||||
await press(h, () => h!.setup.mockInput.pressEnter())
|
||||
f = await waitFor(h, (x) => x.includes('Choose State') && x.includes('Missouri'))
|
||||
expect(f).toContain('narrowed by Country = USA')
|
||||
expect(f).not.toContain('Ontario')
|
||||
await press(h, () => h!.setup.mockInput.pressEnter())
|
||||
await waitFor(h, (x) => x.includes('Missouri') && !x.includes('Choose State'))
|
||||
await press(h, () => h!.setup.mockInput.pressKey('s', { ctrl: true }))
|
||||
await waitFor(h, (x) => x.includes('created Address 9'))
|
||||
const insert = h.log.requests.find((r) => r.path === '/table/address' && r.method === 'POST')!
|
||||
expect(insert.body).toEqual({ label: 'Home', countryId: 'USA', stateId: 'MO' })
|
||||
})
|
||||
|
||||
test('the process wizard: step form, asynchronous job with progress, summary', async () => {
|
||||
h = await startTui()
|
||||
await command(h, 'process run sleeper')
|
||||
await waitFor(h, (x) => x.includes('Millis'))
|
||||
await press(h, () => h!.setup.mockInput.typeText('250'))
|
||||
await press(h, () => h!.setup.mockInput.pressKey('s', { ctrl: true }))
|
||||
const done = await waitFor(h, (x) => x.includes('Finished in'), 6000)
|
||||
expect(done).toContain('slept 250')
|
||||
const step = h.log.requests.find((r) => r.path === '/processes/sleeper/p1/step/input')!
|
||||
expect(JSON.parse(String((step.body as { values: string }).values))).toEqual({ millis: '250' })
|
||||
})
|
||||
|
||||
test('F10 offers Quit, Reset and Cancel; Reset returns to Home with the Apps tree focused', async () => {
|
||||
h = await startTui()
|
||||
await command(h, 'table query person')
|
||||
await waitFor(h, (x) => x.includes('30 records'))
|
||||
let f = await press(h, () => h!.setup.mockInput.pressKey(KeyCodes.F10))
|
||||
expect(f).toContain('Quit qqq?')
|
||||
expect(f).toContain('Reset: go to the home screen')
|
||||
f = await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
expect(f).not.toContain('Quit qqq?')
|
||||
await press(h, () => h!.setup.mockInput.pressKey(KeyCodes.F10))
|
||||
await press(h, () => h!.setup.mockInput.pressArrow('down'))
|
||||
await press(h, () => h!.setup.mockInput.pressEnter())
|
||||
await waitFor(h, (x) => x.includes('reset: home screen'))
|
||||
expect(h.store.routes.length).toBe(1)
|
||||
expect(h.store.screen?.kind).toBe('home')
|
||||
expect(h.store.focus).toBe('sidebar')
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { expect, test } from 'bun:test'
|
||||
import { parseOptions } from '../src/commands'
|
||||
import { LineEditor } from '../src/line-editor'
|
||||
import { layoutColumns, visibleFrom } from '../src/screens/table'
|
||||
import { THEMES } from '../src/theme'
|
||||
import { shellSplit } from '../src/util/text'
|
||||
|
||||
const fields = [
|
||||
{ name: 'id', label: 'Id', type: 'INTEGER' },
|
||||
{ name: 'email', label: 'Email', type: 'STRING' },
|
||||
{ name: 'note', label: 'Note', type: 'TEXT' },
|
||||
]
|
||||
const rows = [{ values: { id: 1, email: 'a-rather-long-address@example.invalid', note: 'x'.repeat(80) } }]
|
||||
|
||||
test('columns start at content width (capped at 40) and truncated ones get the spare width', () => {
|
||||
const tight = layoutColumns(fields, rows, 50, {})
|
||||
expect(tight.map((c) => c.width)).toEqual([3, 37, 40])
|
||||
const wide = layoutColumns(fields, rows, 200, {})
|
||||
expect(wide.find((c) => c.name === 'note')!.width).toBe(80)
|
||||
expect(layoutColumns(fields, rows, 200, { widths: { note: 12 } }).find((c) => c.name === 'note')!.width).toBe(12)
|
||||
expect(visibleFrom(tight, 0, 50).map((c) => c.name)).toEqual(['id', 'email'])
|
||||
expect(visibleFrom(tight, 2, 10).map((c) => c.name)).toEqual(['note'])
|
||||
})
|
||||
|
||||
test('the line editor applies a burst of keys in order, from its own state', () => {
|
||||
const ed = new LineEditor('')
|
||||
for (const ch of 'Morgan') ed.handle({ name: ch, sequence: ch })
|
||||
ed.handle({ name: 'left', sequence: '' })
|
||||
ed.handle({ name: 'backspace', sequence: '' })
|
||||
expect(ed.value).toBe('Morgn')
|
||||
ed.handle({ name: 'u', sequence: '', ctrl: true })
|
||||
expect([ed.value, ed.cursor]).toEqual(['n', 0])
|
||||
})
|
||||
|
||||
test('command lines split like a shell and options collect repeated values', () => {
|
||||
expect(shellSplit('table query person -w "lastName = O\'Neil" -o -id')).toEqual(['table', 'query', 'person', '-w', "lastName = O'Neil", '-o', '-id'])
|
||||
expect(parseOptions(['-w', 'a = 1', '-w', 'b = 2', '--or', '-o', 'x'])).toEqual({ w: ['a = 1', 'b = 2'], or: ['true'], o: ['x'] })
|
||||
expect(parseOptions(['-w', 'lastName = Sample', '-o', '-id', 'firstName'])).toEqual({ w: ['lastName = Sample'], o: ['-id', 'firstName'] })
|
||||
})
|
||||
|
||||
test('every theme defines every token, and high-contrast has no dark grey text', () => {
|
||||
const keys = Object.keys(THEMES['high-contrast']!)
|
||||
for (const t of Object.values(THEMES)) expect(Object.keys(t).sort()).toEqual([...keys].sort())
|
||||
const lum = (hex: string) => { const n = parseInt(hex.slice(1), 16); return (0.2126 * (n >> 16) + 0.7152 * ((n >> 8) & 255) + 0.0722 * (n & 255)) / 255 }
|
||||
expect(lum(THEMES['high-contrast']!.dim)).toBeGreaterThan(0.6)
|
||||
expect(lum(THEMES.classic!.dim)).toBeGreaterThan(0.55)
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "@opentui/react",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["bun-types"]
|
||||
},
|
||||
"include": ["src", "test", "scripts"]
|
||||
}
|
||||
+1
-1
@@ -14,7 +14,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join, relative } from 'node:path'
|
||||
|
||||
const HEADER = 'Copyright 2026 QRun.IO, Inc.'
|
||||
const roots = ['packages/ui-api-client/src', 'packages/core/src', 'packages/conformance/src', 'packages/cli/src', 'scripts']
|
||||
const roots = ['packages/ui-api-client/src', 'packages/core/src', 'packages/conformance/src', 'packages/cli/src', 'packages/tui/src', 'packages/tui/test', 'packages/tui/scripts', 'scripts']
|
||||
const problems = []
|
||||
|
||||
function walk(dir) {
|
||||
|
||||
@@ -86,6 +86,45 @@ STATUS = {
|
||||
'QRY-093': (X, 'no screen state to keep: views are opened by name'), 'QRY-094': (N, 'saved-view list counts quick views with doCount (--counts counts all)'), 'QRY-096': (N, 'opening a view drops removed fields with Material\'s warnings on stderr'), 'HLP-006': (X, 'author affordance'),
|
||||
}
|
||||
|
||||
# TUI column (M3, qqq ui): rows verified in tmux against the sample server or by the in-memory tests in
|
||||
# packages/tui/test. Rows not listed fall back to n/a for browser-only rows and todo for the rest.
|
||||
TUI = {
|
||||
'NAV-001': (P, 'qqq ui table query <t>; :table query <t>'), 'NAV-002': (P, 'Apps tree, backend order'), 'NAV-003': (P, 'nested apps in the tree'),
|
||||
'NAV-004': (P, 'Enter opens; current entry marked; arrows expand and collapse'), 'NAV-005': (N, 'markers for processes and reports; icons not drawn'),
|
||||
'NAV-006': (P, 'hidden tables and processes are not listed'), 'NAV-007': (P, ':table query and :record open hidden tables the user may read'),
|
||||
'NAV-008': (P, 'app home: sections with tables, processes, reports'), 'NAV-009': (P, 'record counts beside tables'), 'NAV-010': (P, ''),
|
||||
'NAV-011': (P, 'dashboard apps draw their widgets'), 'NAV-012': (N, 'app name in the title bar'), 'NAV-014': (P, 'title-bar path: apps, table, record or form'),
|
||||
'NAV-017': (N, 'home: counts and app list; no quick actions'), 'NAV-018': (P, 'qqq ui <route in the CLI grammar>'), 'NAV-023': (P, 'g: primary key or a configured unique key (kernel gotoKeys)'),
|
||||
'NAV-024': (P, 'F2, /, ., Ctrl+K palette with type and app context'), 'NAV-025': (N, 'palette lists recents; no search page'), 'NAV-027': (N, 'F9 Connection: versions, features, extensions, metadata generation'),
|
||||
'NAV-028': (N, 'reports listed; run through :report run'), 'NAV-030': (N, 'palette calls POST /search; the sample has no provider'), 'NAV-035': (P, 'Esc returns to the record or query, cursor kept'),
|
||||
'PRF-002': (P, 'server-paged grid, page = visible rows'), 'PRF-003': (P, 'server sort and filter'), 'PRF-005': (P, 'progress N/M, elapsed, 250 ms polling'),
|
||||
'PRC-001': (P, 'grid selection or record, ids sent'), 'PRC-002': (P, 'without a selection: the grid filter'), 'PRC-004': (P, ''), 'PRC-005': (P, 'Space selects, b runs a table process'),
|
||||
'PRC-007': (N, 'EDIT_FORM, BULK_EDIT_FORM, VIEW_FORM, HELP_TEXT, VALIDATION_REVIEW_SCREEN, PROCESS_SUMMARY_RESULTS'), 'PRC-008': (N, 'HELP_TEXT shown above the form'),
|
||||
'PRC-009': (P, 'includeFieldNames via the kernel runner'), 'PRC-010': (P, 'required checked by field before submit'), 'PRC-011': (P, 'picker'), 'PRC-012': (P, 'summary shows view fields'),
|
||||
'PRC-017': (P, 'kernel runner'), 'PRC-018': (P, 'kernel runner'), 'PRC-019': (P, 'bar, N/M, message, elapsed'), 'PRC-020': (N, 'Esc asks, then POST cancel; the sample keeps the run'),
|
||||
'PRC-021': (P, 'failure message shown; F5 re-runs'), 'PRC-022': (P, 'F5 or r runs again with the same records'), 'PRC-024': (P, 'step list with marks'),
|
||||
'PRC-026': (P, 'validate or skip, then run'), 'PRC-027': (P, 'summary lines colored by status'), 'PRC-028': (P, ''), 'PRC-029': (P, 'Ctrl+T marks fields; bulkEditEnabledFields'),
|
||||
'PRC-031': (X, 'bulk load parked for the TUI'), 'PRC-032': (X, 'bulk load parked for the TUI'), 'PRC-033': (X, 'bulk load parked for the TUI'), 'PRC-047': (P, ''), 'PRC-048': (P, ''), 'PRC-051': (P, 'contingent picker narrows'),
|
||||
'QRY-001': (P, ''), 'QRY-002': (P, 'PgUp/PgDn, Ctrl+F/B, Home/End across pages'), 'QRY-003': (P, 's cycles asc, desc, default'), 'QRY-004': (P, 'v: columns on/off and order, kept per table in the config file'),
|
||||
'QRY-005': (P, 'v: widths, fit or fill, zebra, density'), 'QRY-006': (P, 'Esc lands on the row last viewed, filter and page kept'), 'QRY-007': (P, 'error shown in the grid; F5 retries'),
|
||||
'QRY-010': (P, 'operators by field type in the filter dialog'), 'QRY-011': (P, ''), 'QRY-012': (P, 'date presets: now(-7d), thisPeriod(month) …'), 'QRY-015': (P, 'labels in the picker, ids sent; typed labels resolved'),
|
||||
'QRY-016': (P, 'AND/OR and groups, guided and advanced'), 'QRY-017': (N, 'the grammar and the CLI command are shown; no URL'), 'QRY-030': (N, 'Space and Ctrl+A (page)'), 'QRY-031': (P, ''), 'QRY-034': (N, 'F9 lists table processes; bulk load parked'),
|
||||
'QRY-040': (P, 'x: visible columns, filter and sort, file dialog'), 'QRY-041': (P, ''), 'QRY-050': (N, 'F9 runs saved-view list in a pane'), 'QRY-062': (P, 'F-keys hidden without permission'),
|
||||
'QRY-065': (P, ''), 'QRY-067': (P, 'g'), 'QRY-068': (P, ''), 'QRY-070': (P, 'n r f s and the F-keys; off with keyboard.singleKeyShortcuts'), 'QRY-083': (P, 'guided builder edits and removes conditions'), 'QRY-084': (P, 'sort in the builder; c clears'), 'QRY-086': (P, ''),
|
||||
'REC-001': (P, 'sections by tier, two columns when they fit'), 'REC-003': (P, 'error shown in the record pane'), 'REC-005': (P, 'local validation by field'), 'REC-006': (P, 'new record replaces the form'),
|
||||
'REC-008': (P, 'Esc asks when there are unsaved changes; F10 warns too'), 'REC-009': (P, 'only changed fields sent'), 'REC-010': (P, 'non-editable fields read-only'), 'REC-012': (P, 'F8 asks; the grid moves on to the next record'),
|
||||
'REC-013': (P, 'c: copyRecordValues'), 'REC-014': (N, 'TEXT single line with $EDITOR on Ctrl+E'), 'REC-015': (P, ''), 'REC-016': (P, 'Space toggles'), 'REC-017': (P, ''), 'REC-018': (P, 'local zone in and out (kernel datetime)'),
|
||||
'REC-021': (P, 'masked'), 'REC-028': (P, 'links marked, Enter follows (kernel record-view)'), 'REC-033': (N, 'masked; Enter reveals'), 'REC-034': (N, 'Enter saves through the file dialog; no sample field'),
|
||||
'REC-042': (N, 'F9 runs audit list in a pane'), 'REC-044': (P, ''), 'REC-045': (P, 'picker by keyboard'), 'REC-048': (P, 'fieldErrors by field'), 'REC-050': (P, ''), 'REC-051': (P, ''), 'REC-052': (P, 'contingent fields wait and narrow'), 'REC-055': (P, 'n e c d; F-keys'),
|
||||
'REL-001': (N, 'labels with links in the record view'), 'REL-002': (P, 'child lists; F9 Query all opens the filtered list'), 'REL-003': (P, 'F7 on a child list prefills the parent'), 'REL-004': (N, 'one level shown; open a child for its own'),
|
||||
'REL-005': (P, 'the parent reloads on return'), 'REL-006': (P, 'unavailable panel (kernel associationGroups)'),
|
||||
'SEC-003': (P, 'F7 hidden without insert permission'), 'SEC-004': (P, ''), 'SEC-005': (P, ''), 'SEC-020': (P, 'MOCK session, shared with the CLI keyring'), 'SEC-021': (P, 'F9 Sign out'), 'SEC-025': (N, 'device flow screen; not tried against a provider'), 'SEC-031': (P, ''), 'SEC-032': (P, 'unknown type refused with a message'),
|
||||
'INT-001': (P, 'keyboard only by design'), 'INT-002': (P, ''), 'INT-003': (P, 'modal dialogs, Esc closes'), 'INT-004': (P, ''), 'INT-007': (P, ''), 'INT-009': (P, 'loading lines'), 'INT-010': (P, 'F5 retries'), 'INT-012': (P, 'F5 and on return'), 'INT-013': (N, 'no records message; F7 offered'), 'INT-014': (P, 'sign-in screen shows the failure'),
|
||||
'WID-001': (P, ''), 'WID-002': (P, 'bars in block characters'), 'WID-004': (P, ''), 'WID-009': (P, 'sparkline and table'), 'WID-012': (P, ''), 'WID-014': (P, 'percentage bars'), 'WID-016': (P, ''), 'WID-017': (P, 'segments per dataset'),
|
||||
'WID-018': (P, ''), 'WID-019': (P, ''), 'WID-023': (P, 'big numbers, up/down'), 'WID-040': (N, 'declared order, v reorders; one column'), 'WID-051': (P, 'error contained to the widget'), 'WID-053': (P, ''), 'WID-060': (N, 'record section widgets get the id'),
|
||||
'HLP-002': (N, 'F1 shows table and field help; the sample has none'), 'HLP-005': (P, 'type in F1'),
|
||||
}
|
||||
|
||||
rows = []
|
||||
for line in open(SRC, encoding='utf-8', errors='replace'):
|
||||
m = re.match(r'^\|\s*([A-Z]{3}-\d{3})\s*\|\s*(.*?)\s*\|', line)
|
||||
@@ -93,19 +132,25 @@ for line in open(SRC, encoding='utf-8', errors='replace'):
|
||||
rows.append((m.group(1), m.group(2)))
|
||||
|
||||
fam_counts = collections.defaultdict(collections.Counter)
|
||||
tui_counts = collections.defaultdict(collections.Counter)
|
||||
out = ['# Terminal clients parity matrix', '',
|
||||
'Generated by `scripts/parity-matrix.py` from the Next UI acceptance matrix (`qqq-frontend-next/docs/acceptance/feature-matrix.md`,',
|
||||
f'{len(rows)} rows). Same ids; two columns added. **pass** = verified against the local sample server in this repo\'s',
|
||||
'development; **partial** = works with the noted gap; **todo** = planned; **n/a** = browser-only, with the reason.',
|
||||
'The TUI column fills in during M3.', '']
|
||||
'The TUI column is `qqq ui` (M3); its notes start with "TUI:".', '']
|
||||
lines = []
|
||||
for rid, feature in rows:
|
||||
fam = rid[:3]
|
||||
cli, note = STATUS.get(rid, (T, ''))
|
||||
# the TUI column: browser-only rows stay n/a; everything else is M3 work
|
||||
tui = X if cli == X and (fam in ('INT', 'SEC') or 'phone' in note or 'browser' in note or 'chrome' in note or 'theme' in note or 'Google' in note or 'web-only' in note) else T
|
||||
tnote = ''
|
||||
if rid in TUI:
|
||||
tui, tnote = TUI[rid]
|
||||
fam_counts[fam][cli] += 1
|
||||
lines.append(f'| {rid} | {feature[:110].replace("|", "/")} | {cli} | {tui} | {note.replace("|", "/")} |')
|
||||
tui_counts[fam][tui] += 1
|
||||
both = note if not tnote else (f'{note}; TUI: {tnote}' if note else f'TUI: {tnote}')
|
||||
lines.append(f'| {rid} | {feature[:110].replace("|", "/")} | {cli} | {tui} | {both.replace("|", "/")} |')
|
||||
out.append('## Summary (CLI column)'); out.append('')
|
||||
out.append('| Family | rows | pass | todo | partial | n/a |'); out.append('|---|---|---|---|---|---|')
|
||||
total = collections.Counter()
|
||||
@@ -113,7 +158,14 @@ for fam in sorted(fam_counts):
|
||||
c = fam_counts[fam]; total.update(c)
|
||||
out.append(f'| {fam} | {sum(c.values())} | {c[P]} | {c[T]} | {c[N]} | {c[X]} |')
|
||||
out.append(f'| **all** | {sum(total.values())} | {total[P]} | {total[T]} | {total[N]} | {total[X]} |'); out.append('')
|
||||
out.append('## Summary (TUI column)'); out.append('')
|
||||
out.append('| Family | rows | pass | todo | partial | n/a |'); out.append('|---|---|---|---|---|---|')
|
||||
ttotal = collections.Counter()
|
||||
for fam in sorted(tui_counts):
|
||||
c = tui_counts[fam]; ttotal.update(c)
|
||||
out.append(f'| {fam} | {sum(c.values())} | {c[P]} | {c[T]} | {c[N]} | {c[X]} |')
|
||||
out.append(f'| **all** | {sum(ttotal.values())} | {ttotal[P]} | {ttotal[T]} | {ttotal[N]} | {ttotal[X]} |'); out.append('')
|
||||
out.append('## Rows'); out.append(''); out.append('| ID | Feature | CLI | TUI | Note |'); out.append('|---|---|---|---|---|')
|
||||
out.extend(lines); out.append('')
|
||||
open(OUT, 'w', encoding='utf-8').write('\n'.join(out))
|
||||
print(f'{len(rows)} rows -> {OUT}; CLI: ' + ', '.join(f'{k} {v}' for k, v in sorted(total.items())))
|
||||
print(f'{len(rows)} rows -> {OUT}; CLI: ' + ', '.join(f'{k} {v}' for k, v in sorted(total.items())) + '; TUI: ' + ', '.join(f'{k} {v}' for k, v in sorted(ttotal.items())))
|
||||
Reference in new issue
Block a user