Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30f8503100 | ||
|
|
2f47ea4ce9 | ||
|
|
b42e4eb9b8
|
||
|
|
074265e4d8
|
||
|
|
2639b2cccd
|
||
|
|
943721edef | ||
|
|
fd6f7ff6b9 | ||
|
|
3a4f8a6727 | ||
|
|
e4de04e510 |
No files matched your search
@@ -0,0 +1,17 @@
|
||||
name: Original CLI existing-tag packages
|
||||
on:
|
||||
create:
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
jobs:
|
||||
release:
|
||||
if: github.repository == 'QRun-IO/qqq-cli' && startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'create' || github.event_name == 'workflow_dispatch')
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qqq-cli.yaml@qrun-release-v1
|
||||
with:
|
||||
runner: linux-small
|
||||
capture: true
|
||||
postpublish:
|
||||
needs: [release]
|
||||
if: needs.release.result == 'success'
|
||||
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-cli-completion.yaml@qrun-release-v1
|
||||
+2
-1
@@ -4,4 +4,5 @@ default_branch: main
|
||||
publish:
|
||||
release: true
|
||||
publish_context: qqq-maven-registry-credentials
|
||||
platforms: [macos-arm64, windows-x64]
|
||||
# Native target notes; separate binary entrypoints do not read this CI manifest.
|
||||
# platforms: [macos-arm64, windows-x64]
|
||||
+44
-3
@@ -5,10 +5,18 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
## [1.1.0] - 2026-11-05
|
||||
|
||||
`qqq` 1.1: the terminal UI's Shift bank and two-row F-key bar, F12 for the Assistant panel, Save as PDF
|
||||
from the printer picker, a boot splash and a profile picker on start, rendered assistant answers, and the
|
||||
client regenerated from UI API 1.10.0 with the current extension documents. Servers: UI API 1.6.0 up to
|
||||
2.0, unchanged; servers on 1.9.0 and later also read the case-insensitive criteria option `qqq` sends, and the
|
||||
table variant the CLI sends on inserts, updates and deletes (`qqq ui` does not send it on writes yet).
|
||||
|
||||
Versions: `@qrun-io/qqq-ui-api-client` 1.10.0, `@qrun-io/qqq-core` 1.1.0, `@qrun-io/qqq-cli` 1.1.0,
|
||||
`@qrun-io/qqq-ui-api-conformance` 1.6.0 (unchanged: its checks validate against the server's own
|
||||
document, so no contract changed).
|
||||
|
||||
- The screen saver's Online tile said "nobody around" with two terminals signed in. It now counts signed-in sessions first (the session store's table: sessions active in the last 10 minutes and their users, your own included, "2 sessions · 1 user (you ×2)"), else you plus the people in collaboration presence; it never says nobody is around while you are signed in. The saver also keeps its presence lease on the screen under it (the extension has no idle state), so other clients see you still viewing it instead of gone. A server that stores no sessions and merges presence per user (mock auth) shows both terminals as "1 online · you".
|
||||
- Query in the screen saver looked like a different dog. All sizes are now generated from the drawing on https://www.qrun.io/query/ (`scripts/gen-query-art.mjs`) in its indigo, with a new medium size (25×13 cells, used where there is room) between the large (42×22) and the small (17×9).
|
||||
### Added
|
||||
|
||||
- **F12 shows and hides the Assistant panel** on every screen (Ctrl+G, `:ask` and a click on `12Ask` do
|
||||
@@ -48,9 +56,42 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht
|
||||
- `scripts/print-agent.sh` prints to `~/Downloads` unless `QQQ_PRINT_OUT` says otherwise (`--dir` reports it).
|
||||
- The kernel adds `renderToPdf`, `renderValues`, `canSaveAsPdf`, `chromeValues` and content sections on text
|
||||
prints; the TUI's breadcrumbs moved to `breadcrumbs.ts`.
|
||||
- **`qqq ui` starts on a splash**: Query (the qrun.io mascot) blinking and wagging while it boots, the `qqq`
|
||||
wordmark, the version and a status line that follows the boot (reading profiles, connecting, signing in,
|
||||
loading metadata with the table count). Large Query from about 34 rows, the small one beside the
|
||||
wordmark at 80×24, plain characters under `--ascii`; any key stops the animation, not the boot; shown at
|
||||
least 600 ms. `--no-splash`, `QQQ_UI_NO_SPLASH=1` or F9 › Settings › Splash on start (`:splash on|off`,
|
||||
saved as `ui.splash`) skip it.
|
||||
- **A profile picker on start**: with several profiles and none named (`--profile`, `QQQ_PROFILE`,
|
||||
`--server`, `QQQ_SERVER`), the Servers picker opens over the splash with the last used profile selected;
|
||||
Enter connects, `n` adds, the server status fills in as the probes answer, Esc quits with a one-line hint
|
||||
about `--profile`. With no profile at all the add form opens directly; with exactly one, it connects.
|
||||
- **Connection failures stay on the splash** with the reason: Enter retries, `c` chooses another profile, `n`
|
||||
adds one, Esc quits (instead of the bare error on the sign-in screen).
|
||||
|
||||
### Changed
|
||||
|
||||
- **`@qrun-io/qqq-ui-api-client` 1.10.0**, generated from qqq `develop` 7825261ab (UI API 1.10.0) and the
|
||||
extension documents on each qbit's `develop` (printing: `main`); 222 operations, 156 from extensions
|
||||
(`docs/extensions.md`, `openapi/extension-sources.json`). New since 1.8.0: `FilterCriteria.options`
|
||||
(`CASE_INSENSITIVE`), `tableVariant` on insert, update and delete, `joins` on export, `isStepBack` on
|
||||
step, url-encoded process bodies, a typed `statisticsGroupData`, native session login and logout
|
||||
(`nativeSessionLogin`, `nativeSessionLogout`, `authenticationFlow`, `expectedNonce`,
|
||||
`nativeLogoutData`); assistant 1.7.0 (`AssistantConfigResponseV1.message`); printing 2.3.0 (the
|
||||
operator printer catalog, submit and request operations, and media profiles); collaboration 1.2.0 now
|
||||
from `develop`, where per-session presence is merged.
|
||||
- `scripts/merge-spec.mjs` accepts an extension's older copy of a core schema (the same schema without
|
||||
properties or enum values added since) and keeps the newer one, with a warning: data-analysis 1.5.0
|
||||
still carries the pre-1.9.0 `FilterCriteria`.
|
||||
- The CLI's `--variant` note on writes now says only servers before UI API 1.9.0 ignore the variant.
|
||||
- TypeScript 5.8.3 to 6.0.3 and `@types/node` 22.15.3 to 26.6.6 across the workspace, `packages/tui` and `packages/tui-spike`. TypeScript 6 no longer loads every `@types/*` package implicitly, so `tsconfig.base.json` now lists `"types": ["node"]`; `@types/node` 26 no longer accepts an `undefined` assertion message, so one conformance test passes a real fallback message. TypeScript 7 is not adopted yet: `openapi-typescript` 7.13.0 (the client generator, peer `typescript ^5.x`) calls the TypeScript JS API (`ts.factory`), which the native 7 compiler does not provide. No behaviour change.
|
||||
- With several profiles and none named, `qqq ui` asks which to connect to instead of using the default
|
||||
profile (`--profile` or `QQQ_PROFILE` keeps the old behaviour).
|
||||
|
||||
### Fixed
|
||||
|
||||
- The screen saver's Online tile said "nobody around" with two terminals signed in. It now counts signed-in sessions first (the session store's table: sessions active in the last 10 minutes and their users, your own included, "2 sessions · 1 user (you ×2)"), else you plus the people in collaboration presence; it never says nobody is around while you are signed in. The saver also keeps its presence lease on the screen under it (the extension has no idle state), so other clients see you still viewing it instead of gone. A server that stores no sessions and merges presence per user (mock auth) shows both terminals as "1 online · you".
|
||||
- Query in the screen saver looked like a different dog. All sizes are now generated from the drawing on https://www.qrun.io/query/ (`scripts/gen-query-art.mjs`) in its indigo, with a new medium size (25×13 cells, used where there is room) between the large (42×22) and the small (17×9).
|
||||
- **The assistant's answers render** instead of showing raw markdown, in the TUI panel and in
|
||||
`qqq assistant ask`: HTML entities decoded (`healthy:` was shown as is), headings, wrapped
|
||||
paragraphs, lists, bold and code, and pipe tables laid out as a grid (CLI: sub-tables) instead of `| --- |`
|
||||
|
||||
@@ -6,8 +6,7 @@ uses, so apps, tables, fields, processes, widgets and extensions all come from t
|
||||
one binary works against any QQQ application.
|
||||
|
||||
This repository also holds the TypeScript libraries `qqq` is built on, for anyone writing their own CLI,
|
||||
TUI or native client. Current release: **1.0.0** ([release notes](docs/release-notes/1.0.0.md),
|
||||
[CHANGELOG](CHANGELOG.md)).
|
||||
TUI or native client. Current release: **1.1.0** ([CHANGELOG](CHANGELOG.md); 1.0.0: [release notes](docs/release-notes/1.0.0.md)).
|
||||
|
||||
- [Install](#install)
|
||||
- [The CLI in 60 seconds](#the-cli-in-60-seconds)
|
||||
@@ -29,7 +28,7 @@ musl for Alpine) and Windows (x64).
|
||||
|
||||
```sh
|
||||
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh
|
||||
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh -s -- --version 1.0.0 --dir /usr/local/bin
|
||||
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh -s -- --version 1.1.0 --dir /usr/local/bin
|
||||
```
|
||||
|
||||
**Homebrew** (macOS and Linux, from [`QRun-IO/homebrew-tap`](https://github.com/QRun-IO/homebrew-tap);
|
||||
@@ -248,7 +247,7 @@ and settings: [`packages/tui/README.md`](packages/tui/README.md).
|
||||
| npm packages | Node.js 20 or later. `@qrun-io/qqq-ui-api-client` needs only `fetch` and Web Crypto (no Node built-ins), so it runs on Bun and Deno too; `@qrun-io/qqq-core` and the CLI use Node built-ins (files, keyring). |
|
||||
|
||||
Versioning: `qqq` and `@qrun-io/qqq-core` follow semver from 1.0.0. `@qrun-io/qqq-ui-api-client`
|
||||
follows the UI API version of the document it is generated from (1.8.0 here), and
|
||||
follows the UI API version of the document it is generated from (1.10.0 here), and
|
||||
`@qrun-io/qqq-ui-api-conformance` has its own version. All four are released together; see
|
||||
[CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
@@ -256,9 +255,9 @@ follows the UI API version of the document it is generated from (1.8.0 here), an
|
||||
|
||||
| Package | Version | What it is |
|
||||
|---|---|---|
|
||||
| [`@qrun-io/qqq-cli`](packages/cli) | 1.0.0 | `qqq`, the CLI, and its program as a library (`runCli`) |
|
||||
| [`@qrun-io/qqq-core`](packages/core) | 1.0.0 | the client kernel: sessions and keyring, metadata cache, filter grammar, forms, processes, records, render model, output formats. No terminal code |
|
||||
| [`@qrun-io/qqq-ui-api-client`](packages/ui-api-client) | 1.8.0 | a typed TypeScript client generated from the merged UI API document (core plus 14 extensions), with OAuth2 device-flow and PKCE sign-in, token refresh and the realtime (SSE) stream |
|
||||
| [`@qrun-io/qqq-cli`](packages/cli) | 1.1.0 | `qqq`, the CLI, and its program as a library (`runCli`) |
|
||||
| [`@qrun-io/qqq-core`](packages/core) | 1.1.0 | the client kernel: sessions and keyring, metadata cache, filter grammar, forms, processes, records, render model, output formats. No terminal code |
|
||||
| [`@qrun-io/qqq-ui-api-client`](packages/ui-api-client) | 1.10.0 | a typed TypeScript client generated from the merged UI API document (core plus 14 extensions), with OAuth2 device-flow and PKCE sign-in, token refresh and the realtime (SSE) stream |
|
||||
| [`@qrun-io/qqq-ui-api-conformance`](packages/conformance) | 1.6.0 | a read-only conformance suite for any UI API server, and `qqq-dev-idp`, a local OIDC provider for testing native clients |
|
||||
| [`packages/tui`](packages/tui) | in the binaries | `qqq ui`, the full-screen terminal UI (OpenTUI on Bun), bundled into every `qqq` binary; not an npm package |
|
||||
|
||||
|
||||
+2
-2
@@ -4,8 +4,8 @@
|
||||
|
||||
| Version | Supported |
|
||||
|---|---|
|
||||
| 1.0.x (`qqq` 1.0, `@qrun-io/qqq-core` 1.0, `@qrun-io/qqq-ui-api-client` 1.8, `@qrun-io/qqq-ui-api-conformance` 1.6) | yes |
|
||||
| 1.0.0 release candidates, 0.x | no; upgrade to the latest 1.0.x |
|
||||
| 1.1.x (`qqq` 1.1, `@qrun-io/qqq-core` 1.1, `@qrun-io/qqq-ui-api-client` 1.10, `@qrun-io/qqq-ui-api-conformance` 1.6) | yes |
|
||||
| 1.0.x, 1.0.0 release candidates, 0.x | no; upgrade to the latest 1.1.x |
|
||||
|
||||
Fixes are released as a new patch version of the affected packages and binaries.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Try qqq against the sample server
|
||||
|
||||
A hands-on walkthrough of qqq 1.0.0 (the CLI and `qqq ui`) from a source checkout, against a local QQQ
|
||||
A hands-on walkthrough of qqq 1.1.0 (the CLI and `qqq ui`) from a source checkout, against a local QQQ
|
||||
sample server. Each section says what exists and the command that runs it. To install a release instead,
|
||||
see [Install](README.md#install) in the README; every command below works the same with an installed `qqq`.
|
||||
|
||||
@@ -28,7 +28,7 @@ and the terminal UI in one file, no Node or Bun. It is the same build a user dow
|
||||
from-source launcher. Both use the same profiles and keychain items.
|
||||
|
||||
```sh
|
||||
qqq-bin --version # qqq 1.0.0 (darwin-arm64 binary with ui, <commit>, bun 1.4.4)
|
||||
qqq-bin --version # qqq 1.1.0 (darwin-arm64 binary with ui, <commit>, bun 1.4.4)
|
||||
qqq-bin doctor # first line: the build, runtime and platform; then server, sign-in, terminal
|
||||
qqq-bin --profile sample ui # the terminal UI from the binary
|
||||
qqq-bin ui --check # the terminal check, with the build line support asks for
|
||||
|
||||
+31
-23
@@ -1,36 +1,44 @@
|
||||
# Extension documents in the client
|
||||
|
||||
The client document (`packages/ui-api-client/openapi/qqq-v1.json`) is the sample server's live
|
||||
document for core (QQQ 4.1.0-SNAPSHOT, `develop` 6ac64fc66, UI API 1.8.0, which already carries esb)
|
||||
merged with the extension document each repository commits at `src/main/resources/openapi/qqq-v1/`.
|
||||
Rebuild it with the sample server running:
|
||||
The client document (`packages/ui-api-client/openapi/qqq-v1.json`) is qqq's committed core document
|
||||
(`qqq-middleware-javalin/src/main/resources/openapi/v1/openapi.yaml`, qqq `develop` 7825261ab, UI API
|
||||
1.10.0) merged with the extension document each repository commits at `src/main/resources/openapi/qqq-v1/`
|
||||
(esb and health from the same qqq commit). Rebuild it from checkouts at the refs below:
|
||||
|
||||
```sh
|
||||
npm run spec:merge-extensions && npm run generate # writes openapi/extension-sources.json too
|
||||
npm run spec:merge-extensions -- --core <qqq>/qqq-middleware-javalin/src/main/resources/openapi/v1/openapi.yaml
|
||||
npm run generate # writes openapi/extension-sources.json too
|
||||
```
|
||||
|
||||
Versions merged on 2026-10-31 (machine-readable copy: `packages/ui-api-client/openapi/extension-sources.json`):
|
||||
Without `--core` the script reads the sample server's live document (`http://localhost:8077`), which is
|
||||
only as new as the server that is running. For 1.10.0 the documents were exported (`git archive`) from
|
||||
each repository's `origin/develop` (`origin/main` for qbit-printing, which has no `develop`) into one
|
||||
directory passed as `--root`, and `extension-sources.json` records those refs.
|
||||
|
||||
Versions merged on 2026-11-05 (machine-readable copy: `packages/ui-api-client/openapi/extension-sources.json`):
|
||||
|
||||
| Extension | Version | Prefix | Operations | Repository at | Minimum the client needs |
|
||||
|---|---|---|---|---|---|
|
||||
| esb | 1.1.0 | `/esb` | 5 | qqq `develop` 6ac64fc66 (qqq-esb) | 1.1.0 (operationIds, UI-API-CHANGELOG) |
|
||||
| health | 1.1.0 | `/health` | 4 | qqq `develop` 6ac64fc66 (qqq-middleware-javalin) | 1.1.0 (same) |
|
||||
| alerts | 1.2.0 | `/alerts` | 6 | qbit-alerts `develop` a3fc6ac | |
|
||||
| assistant | 1.6.0 | `/assistant` | 16 | qbit-assistant `develop` bcf173f | |
|
||||
| collaboration | 1.1.0 | `/qbit/collaboration` | 45 | qbit-collaboration `develop` 03407ae | 1.1.0 (presence schemas, error contract) |
|
||||
| data-analysis | 1.5.0 | `/qbit/data-analysis` | 21 | qbit-data-analysis `develop` 368d2c0 | |
|
||||
| db-performance | 1.0.0 | `/qbit/db-performance` | 4 | qbit-db-performance `develop` 4fd2c30 | |
|
||||
| edi | 1.1.0 | `/qbit/edi` | 4 | qbit-edi `develop` 75a4e6b | |
|
||||
| qa-capture | 1.0.0 | `/qbit/qa-capture` | 2 | qbit-qa-capture `develop` 1aee6cb | |
|
||||
| quick-search | 1.1.0 | `/qbit/quick-search` | 2 | qbit-quick-search `develop` bd50e1f | |
|
||||
| release-notes | 1.0.0 | `/qbit/release-notes` | 3 | qbit-release-notes `develop` 8fed18e | |
|
||||
| secured-data | 1.1.0 | `/qbit/secured-data` | 3 | qbit-secured-data `develop` 7b302ed | |
|
||||
| sql-workbench | 1.1.0 | `/sql-workbench` | 21 | qbit-sql-workbench `develop` a182076 | |
|
||||
| printing | 2.1.0 | `/qbit/printing` | 17 | qbit-printing `feat/print-anything` | 2.0.0 (realtime-v1); 2.1.0 adds the table-list and text prints Ctrl+P uses |
|
||||
| esb | 1.1.0 | `/esb` | 5 | qqq `develop` 7825261ab (qqq-esb) | 1.1.0 (operationIds, UI-API-CHANGELOG) |
|
||||
| health | 1.1.0 | `/health` | 4 | qqq `develop` 7825261ab (qqq-middleware-javalin) | 1.1.0 (same) |
|
||||
| alerts | 1.2.0 | `/alerts` | 6 | qbit-alerts `develop` 420cc69 | |
|
||||
| assistant | 1.7.0 | `/assistant` | 16 | qbit-assistant `develop` 67bdd32 | |
|
||||
| collaboration | 1.2.0 | `/qbit/collaboration` | 45 | qbit-collaboration `develop` 7d61f29 (per-session presence is merged there) | 1.1.0 (presence schemas, error contract); 1.2.0 adds per-connection presence (`connections`, `leaseCount`, `perConnection`), used when present |
|
||||
| data-analysis | 1.5.0 | `/qbit/data-analysis` | 21 | qbit-data-analysis `develop` f2fd8b1 | |
|
||||
| db-performance | 1.0.0 | `/qbit/db-performance` | 4 | qbit-db-performance `develop` b70c234 | |
|
||||
| edi | 1.1.0 | `/qbit/edi` | 4 | qbit-edi `develop` 683995c | |
|
||||
| printing | 2.3.0 | `/qbit/printing` | 20 | qbit-printing `main` 0ab07fc | 2.0.0 (realtime-v1); 2.1.0 adds the table-list and text prints Ctrl+P uses |
|
||||
| qa-capture | 1.0.0 | `/qbit/qa-capture` | 2 | qbit-qa-capture `develop` 8ca3fd7 | |
|
||||
| quick-search | 1.1.0 | `/qbit/quick-search` | 2 | qbit-quick-search `develop` e3aa05e | |
|
||||
| release-notes | 1.0.0 | `/qbit/release-notes` | 3 | qbit-release-notes `develop` 8abbe6f | |
|
||||
| secured-data | 1.1.0 | `/qbit/secured-data` | 3 | qbit-secured-data `develop` 5987cb5 | |
|
||||
| sql-workbench | 1.1.0 | `/sql-workbench` | 21 | qbit-sql-workbench `develop` 1784a5b | |
|
||||
|
||||
219 operations in all, 153 from extensions (printing was appended on 2026-11-01 with
|
||||
`scripts/merge-spec.mjs`, keeping the other documents as they were). No extension document conflicted with core or another
|
||||
(identical shared schemas such as `BasicErrorResponseV1`). There is no `qbit-middleware-health`: the
|
||||
222 operations in all, 156 from extensions. Shared schemas are identical (such as `BasicErrorResponseV1`)
|
||||
except one: data-analysis 1.5.0 still carries `FilterCriteria` as it was before UI API 1.9.0 (without
|
||||
`options`). `scripts/merge-spec.mjs` accepts an extension's older copy of a schema (the same schema
|
||||
without the properties and enum values added since) and keeps the newer core one, with a warning; a copy
|
||||
that differs in any other way still fails the merge. There is no `qbit-middleware-health`: the
|
||||
health extension lives in qqq's `qqq-middleware-javalin`. Other qbits in the workspace (authentik,
|
||||
jobs, help, middleware-mcp, ...) register no UI API extension.
|
||||
|
||||
|
||||
Generated
+18
-18
@@ -14,8 +14,8 @@
|
||||
],
|
||||
"devDependencies": {
|
||||
"@stoplight/prism-cli": "5.16.0",
|
||||
"@types/node": "22.15.3",
|
||||
"typescript": "5.8.3",
|
||||
"@types/node": "26.6.6",
|
||||
"typescript": "6.0.3",
|
||||
"yaml": "2.9.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -564,13 +564,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "22.15.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.3.tgz",
|
||||
"integrity": "sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==",
|
||||
"version": "26.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
|
||||
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
"undici-types": "~8.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/swagger-schema-official": {
|
||||
@@ -2708,9 +2708,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.8.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
|
||||
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -2722,9 +2722,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||
"version": "8.9.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
|
||||
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -2954,11 +2954,11 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@qrun-io/qqq-cli",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@qrun-io/qqq-core": "1.0.0",
|
||||
"@qrun-io/qqq-ui-api-client": "1.8.0",
|
||||
"@qrun-io/qqq-core": "1.1.0",
|
||||
"@qrun-io/qqq-ui-api-client": "1.10.0",
|
||||
"@qrun-io/qqq-ui-api-conformance": "1.6.0",
|
||||
"commander": "15.0.0"
|
||||
},
|
||||
@@ -2986,10 +2986,10 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@qrun-io/qqq-core",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@qrun-io/qqq-ui-api-client": "1.8.0",
|
||||
"@qrun-io/qqq-ui-api-client": "1.10.0",
|
||||
"jmespath": "0.16.0",
|
||||
"yaml": "2.9.1"
|
||||
},
|
||||
@@ -3002,7 +3002,7 @@
|
||||
},
|
||||
"packages/ui-api-client": {
|
||||
"name": "@qrun-io/qqq-ui-api-client",
|
||||
"version": "1.8.0",
|
||||
"version": "1.10.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"openapi-fetch": "0.17.0"
|
||||
|
||||
+2
-2
@@ -30,8 +30,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@stoplight/prism-cli": "5.16.0",
|
||||
"@types/node": "22.15.3",
|
||||
"typescript": "5.8.3",
|
||||
"@types/node": "26.6.6",
|
||||
"typescript": "6.0.3",
|
||||
"yaml": "2.9.1"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qrun-io/qqq-cli",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "Reference command-line client for QQQ applications, built only on the QQQ UI API (/qqq/v1).",
|
||||
"keywords": [
|
||||
"qqq",
|
||||
@@ -58,8 +58,8 @@
|
||||
"prepack": "node ../../scripts/copy-license.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@qrun-io/qqq-core": "1.0.0",
|
||||
"@qrun-io/qqq-ui-api-client": "1.8.0",
|
||||
"@qrun-io/qqq-core": "1.1.0",
|
||||
"@qrun-io/qqq-ui-api-client": "1.10.0",
|
||||
"@qrun-io/qqq-ui-api-conformance": "1.6.0",
|
||||
"commander": "15.0.0"
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander'
|
||||
import { COLLABORATION, type TagEntry, type CollaborationTarget, type CommentEntry, type FavoriteEntry, type PresenceState } from '@qrun-io/qqq-core'
|
||||
import { COLLABORATION, connectionsSuffix, type TagEntry, type CollaborationTarget, type CommentEntry, type FavoriteEntry, type PresenceState } from '@qrun-io/qqq-core'
|
||||
import type { Context } from '../context.js'
|
||||
import { UsageError } from '../context.js'
|
||||
import { COLUMNS_OPTION, chooseColumns, countFooter, instantText, positiveInt, type Json } from './shared.js'
|
||||
@@ -27,14 +27,15 @@ function hhmmss(date: Date): string {
|
||||
return date.toTimeString().slice(0, 8)
|
||||
}
|
||||
|
||||
/** `2 here: Alice Morgan (viewing since 17:50:01), Bob Chen (editing email since 17:51:10)`. */
|
||||
/** `2 on person 1: Alice Morgan ×2 (viewing since 17:50:01), Bob Chen (editing email since 17:51:10)`. */
|
||||
export function presenceLine(state: PresenceState): string {
|
||||
if (!state.available) return 'presence: the collaboration extension is not installed'
|
||||
if (state.error) return `presence: ${state.error}`
|
||||
const where = state.scope.pk !== undefined ? `${state.scope.table} ${state.scope.pk}` : state.scope.table
|
||||
if (!state.count) return `nobody on ${where}`
|
||||
const people = state.people.map((p) => `${p.name} (${String(p.mode).toLowerCase()}${p.fieldName ? ` ${p.fieldName}` : ''} since ${hhmmss(p.since)})`)
|
||||
return `${state.count} on ${where}: ${people.join(', ')}`
|
||||
const people = state.people.map((p) => `${p.name}${connectionsSuffix(p)} (${String(p.mode).toLowerCase()}${p.fieldName ? ` ${p.fieldName}` : ''} since ${hhmmss(p.since)})`)
|
||||
const connections = state.connections ?? state.count
|
||||
return `${state.count} on ${where}${connections > state.count ? ` (${connections} connections)` : ''}: ${people.join(', ')}`
|
||||
}
|
||||
|
||||
function favoriteRows(items: FavoriteEntry[]): Json[] {
|
||||
@@ -71,7 +72,8 @@ export function registerCollab(noun: Command, ctx: () => Context): void {
|
||||
.option('--watch', 'join and follow changes (heartbeat every 20 s, leaves on exit)')
|
||||
.option('--observe', 'follow changes without joining')
|
||||
.option('--editing [field]', 'with --watch: join as EDITING, optionally on a field')
|
||||
.action(async (table: string, pk: string | undefined, options: { watch?: boolean; observe?: boolean; editing?: string | boolean }) => {
|
||||
.option('--per-connection', 'one row per connection (tab, terminal) with its mode, field and start (collaboration 1.2.0)')
|
||||
.action(async (table: string, pk: string | undefined, options: { watch?: boolean; observe?: boolean; editing?: string | boolean; perConnection?: boolean }) => {
|
||||
const c = ctx()
|
||||
const collaboration = await collab(c)
|
||||
const scope = { table, pk }
|
||||
@@ -81,18 +83,30 @@ export function registerCollab(noun: Command, ctx: () => Context): void {
|
||||
else process.stdout.write(`${hhmmss(new Date())} ${presenceLine(state)}${state.live ? '' : ' (polling)'}\n`)
|
||||
}
|
||||
if (!options.watch && !options.observe) {
|
||||
const state = await collaboration.presenceSnapshot(scope)
|
||||
const rows = state.people.map((p) => ({ name: p.name, user: p.userId, mode: p.mode, field: p.fieldName ?? '' }))
|
||||
c.print({ data: { table, pk: pk ?? null, count: state.count, viewers: rows }, rows, columns: ['name', 'user', 'mode', 'field'], footer: countFooter(state.count, 'person', [`on ${pk !== undefined ? `${table} ${pk}` : table}`, '--watch to join and follow'], 'people') })
|
||||
const state = await collaboration.presenceSnapshot(scope, { perConnection: options.perConnection })
|
||||
const connections = state.connections ?? state.count
|
||||
const where = `on ${pk !== undefined ? `${table} ${pk}` : table}`
|
||||
const notes = [where, ...(connections > state.count ? [`${connections} connections`] : []), '--watch to join and follow']
|
||||
const rows = state.people.map((p) => ({ name: p.name, user: p.userId, mode: p.mode, field: p.fieldName ?? '', connections: p.connections ?? 1 }))
|
||||
if (options.perConnection) {
|
||||
// older servers send no per-connection detail: one row per person, connection unknown
|
||||
const leaseRows = state.people.flatMap((p) => p.leases?.length
|
||||
? p.leases.map((l) => ({ name: p.name, user: p.userId, mode: l.mode, field: l.fieldName ?? '', since: l.since ? hhmmss(l.since) : '', connection: l.connectionId }))
|
||||
: [{ name: p.name, user: p.userId, mode: p.mode, field: p.fieldName ?? '', since: '', connection: '' }])
|
||||
c.print({ data: { table, pk: pk ?? null, count: state.count, connections, viewers: rows, leases: leaseRows }, rows: leaseRows, columns: ['name', 'user', 'mode', 'field', 'since', 'connection'], footer: countFooter(state.count, 'person', notes, 'people') })
|
||||
return
|
||||
}
|
||||
c.print({ data: { table, pk: pk ?? null, count: state.count, connections, viewers: rows }, rows, columns: ['name', 'user', 'mode', 'field', 'connections'], footer: countFooter(state.count, 'person', notes, 'people') })
|
||||
return
|
||||
}
|
||||
let last = ''
|
||||
const handle = await collaboration.joinPresence(scope, {
|
||||
announce: Boolean(options.watch),
|
||||
perConnection: options.perConnection,
|
||||
mode: options.editing ? 'EDITING' : 'VIEWING',
|
||||
fieldName: typeof options.editing === 'string' ? options.editing : undefined,
|
||||
onChange: (state) => {
|
||||
const key = JSON.stringify([state.people.map((p) => [p.userId, p.mode, p.fieldName]), state.error, state.live])
|
||||
const key = JSON.stringify([state.people.map((p) => [p.userId, p.mode, p.fieldName, p.connections, p.leases?.map((l) => [l.connectionId, l.mode, l.fieldName])]), state.error, state.live])
|
||||
if (key !== last) {
|
||||
last = key
|
||||
emit(state)
|
||||
|
||||
@@ -61,7 +61,7 @@ export async function chooseVariant(c: Context, meta: Json, typed: string | unde
|
||||
throw new UsageError(`${meta.label} needs a ${label}: --variant <id|name>${variants.length ? ` (${variants.slice(0, 10).map((v) => `${v.name ?? v.id} (${v.id})`).join(', ')}${variants.length > 10 ? ', …' : ''})` : '; the server lists none'}`)
|
||||
}
|
||||
if (chosen && usesVariants(meta)) rememberVariant(c.server, table, chosen)
|
||||
// UI API 1.8.0 reads tableVariant on query, count, get, export and processes only (docs/server-findings.md #19)
|
||||
if (options.write) c.note(`note: the server may ignore the ${label} on inserts, updates and deletes (UI API ${'1.8.0'}); see docs/server-findings.md #19`)
|
||||
// servers before UI API 1.9.0 read tableVariant on query, count, get, export and processes only (docs/server-findings.md #19)
|
||||
if (options.write) c.note(`note: servers before UI API 1.9.0 ignore the ${label} on inserts, updates and deletes; see docs/server-findings.md #19`)
|
||||
return chosen
|
||||
}
|
||||
@@ -195,7 +195,7 @@ describe('doctor', () => {
|
||||
const { versionSupport } = await import('../commands/doctor.js')
|
||||
assert.equal(versionSupport('1.8.0', [1]).status, 'ok')
|
||||
assert.equal(versionSupport('1.5.2', [1]).status, 'fail')
|
||||
assert.equal(versionSupport('1.9.0', [1]).status, 'warn')
|
||||
assert.equal(versionSupport('1.11.0', [1]).status, 'warn')
|
||||
assert.equal(versionSupport('2.0.0', [2]).status, 'fail')
|
||||
})
|
||||
test('doctor words the supported UI API range exactly as --version does', async () => {
|
||||
@@ -204,13 +204,13 @@ describe('doctor', () => {
|
||||
assert.equal(UI_API_RANGE, '>=1.6.0 <2.0.0')
|
||||
const version = await runCli(['--version'], box.env)
|
||||
assert.match(version.stdout, new RegExp(`servers UI API ${UI_API_RANGE}`))
|
||||
for (const [server, status] of [['1.6.0', 'ok'], ['1.8.0', 'ok'], ['1.9.0', 'warn'], ['1.5.2', 'fail']] as const) {
|
||||
for (const [server, status] of [['1.6.0', 'ok'], ['1.8.0', 'ok'], ['1.10.0', 'ok'], ['1.11.0', 'warn'], ['1.5.2', 'fail']] as const) {
|
||||
const result = versionSupport(server, [1])
|
||||
assert.equal(result.status, status, server)
|
||||
assert.ok(result.detail.includes(UI_API_RANGE), `${server}: ${result.detail}`)
|
||||
assert.ok(!/supports \d+\.\d+\.\d+ to /.test(result.detail), `${server}: ${result.detail}`)
|
||||
}
|
||||
assert.match(versionSupport('1.9.0', [1]).detail, /^1\.9\.0; supported \(/)
|
||||
assert.match(versionSupport('1.11.0', [1]).detail, /^1\.11\.0; supported \(/)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -41,4 +41,5 @@ test('presence lines say who is there and since when', () => {
|
||||
assert.equal(presenceLine({ available: true, scope: { table: 'person', pk: '1' }, count: 0, people: [], live: true }), 'nobody on person 1')
|
||||
assert.equal(presenceLine({ available: true, scope: { table: 'person' }, count: 2, people: [{ userId: 'a', name: 'Alice', mode: 'VIEWING', since }, { userId: 'b', name: 'Bob', mode: 'EDITING', fieldName: 'email', since }], live: true }), '2 on person: Alice (viewing since 17:50:01), Bob (editing email since 17:50:01)')
|
||||
assert.match(presenceLine({ available: false, scope: { table: 'person' }, count: 0, people: [], live: false }), /not installed/)
|
||||
assert.equal(presenceLine({ available: true, scope: { table: 'person', pk: '1' }, count: 1, connections: 2, people: [{ userId: 'admin', name: 'Admin', mode: 'VIEWING', since, connections: 2 }], live: true }), '1 on person 1 (2 connections): Admin ×2 (viewing since 17:50:01)')
|
||||
})
|
||||
@@ -3,17 +3,17 @@ extension noun installed status client minimum o
|
||||
esb qqq esb 1.1.0 ok 1.1.0 1.1.0 5
|
||||
health qqq health not installed 1.1.0 1.1.0 4
|
||||
alerts qqq alerts not installed 1.2.0 6
|
||||
assistant qqq assistant not installed 1.6.0 16
|
||||
collaboration qqq collab not installed 1.1.0 1.1.0 45 record:{table}:{pk} table:{table} user:me
|
||||
assistant qqq assistant not installed 1.7.0 16
|
||||
collaboration qqq collab not installed 1.2.0 1.1.0 45 record:{table}:{pk} table:{table} user:me
|
||||
data-analysis qqq data-analysis not installed 1.5.0 21
|
||||
db-performance qqq db-performance not installed 1.0.0 4
|
||||
edi qqq edi not installed 1.1.0 4
|
||||
printing qqq printing not installed 2.3.0 2.0.0 20
|
||||
qa-capture qqq qa-capture not installed 1.0.0 2
|
||||
quick-search qqq quick-search not installed 1.1.0 2
|
||||
release-notes qqq release-notes not installed 1.0.0 3
|
||||
secured-data qqq secured-data not installed 1.1.0 3
|
||||
sql-workbench qqq sql-workbench not installed 1.1.0 21
|
||||
printing qqq printing not installed 2.1.0 2.0.0 17
|
||||
(installed: esb 1.1.0; 153 extension operations in this client)
|
||||
(installed: esb 1.1.0; 156 extension operations in this client)
|
||||
--- stderr ---
|
||||
--- exit 0
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* `qqq collab presence` against a stub: the connections column for two terminals of one user
|
||||
* (collaboration 1.2.0), `--per-connection` rows, and a 1.1.0 server that sends no connections.
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { after, before, describe, test } from 'node:test'
|
||||
import { runCli, sandbox } from './harness.js'
|
||||
import { startStub, type Stub } from './stub.js'
|
||||
|
||||
describe('qqq collab presence against a stub', () => {
|
||||
let stub: Stub
|
||||
let box: ReturnType<typeof sandbox>
|
||||
let version = '1.2.0'
|
||||
const qqq = (args: string[]) => runCli(['--server', stub.url, ...args], { QQQ_TOKEN: 't', ...box.env })
|
||||
const admin = { userId: 'admin', displayName: 'Admin', mode: 'VIEWING' }
|
||||
before(async () => {
|
||||
box = sandbox()
|
||||
stub = await startStub([
|
||||
[/^GET \/version$/, () => ({ apiVersion: '1.8.0', supportedMajorVersions: [1], features: [], extensions: [{ name: 'collaboration', version }] })],
|
||||
[/^GET \/qbit\/collaboration\/presence$/, (r) => {
|
||||
if (version === '1.1.0') return { table: 'person', viewers: [admin, { userId: 'bob', displayName: 'Bob', mode: 'EDITING' }] }
|
||||
const leases = [
|
||||
{ ...admin, connections: 1, connectionId: 'c0000000000000001', sessionId: 's1', since: '2026-11-01T12:00:00Z' },
|
||||
{ ...admin, mode: 'EDITING', fieldName: 'name', connections: 1, connectionId: 'c0000000000000002', sessionId: 's2', since: '2026-11-01T12:00:05Z' },
|
||||
]
|
||||
return { table: 'person', viewers: [{ ...admin, mode: 'EDITING', connections: 2 }], leaseCount: 2, ...(r.query.get('perConnection') === 'true' ? { leases } : {}) }
|
||||
}],
|
||||
])
|
||||
})
|
||||
after(async () => {
|
||||
await stub.close()
|
||||
box.dispose()
|
||||
})
|
||||
|
||||
test('two terminals of one user: one person with 2 connections', async () => {
|
||||
version = '1.2.0'
|
||||
const r = await qqq(['collab', 'presence', 'person', '-O', 'csv'])
|
||||
assert.equal(r.code, 0, r.stderr)
|
||||
assert.match(r.stdout, /^name,user,mode,field,connections\r?\nAdmin,admin,EDITING,,2/m)
|
||||
const json = JSON.parse((await qqq(['collab', 'presence', 'person', '-O', 'json'])).stdout)
|
||||
assert.deepEqual([json.count, json.connections, json.viewers[0].connections], [1, 2, 2])
|
||||
assert.equal(stub.requests.filter((q) => q.path === '/qbit/collaboration/presence').at(-1)!.query.get('perConnection'), null)
|
||||
})
|
||||
|
||||
test('--per-connection lists each terminal with its mode, field and start', async () => {
|
||||
version = '1.2.0'
|
||||
const r = await qqq(['collab', 'presence', 'person', '--per-connection', '-O', 'json'])
|
||||
assert.equal(r.code, 0, r.stderr)
|
||||
const json = JSON.parse(r.stdout)
|
||||
assert.deepEqual(json.leases.map((l: { mode: string; field: string; connection: string }) => [l.mode, l.field, l.connection]), [['VIEWING', '', 'c0000000000000001'], ['EDITING', 'name', 'c0000000000000002']])
|
||||
assert.equal(stub.requests.at(-1)!.query.get('perConnection'), 'true')
|
||||
})
|
||||
|
||||
test('a collaboration 1.1.0 server without connections: each person counts once', async () => {
|
||||
version = '1.1.0'
|
||||
const r = await qqq(['collab', 'presence', 'person', '-O', 'csv'])
|
||||
assert.equal(r.code, 0, r.stderr)
|
||||
assert.match(r.stdout, /Admin,admin,VIEWING,,1\r?\nBob,bob,EDITING,,1/)
|
||||
const per = JSON.parse((await qqq(['collab', 'presence', 'person', '--per-connection', '-O', 'json'])).stdout)
|
||||
assert.deepEqual([per.count, per.connections, per.leases.length], [2, 2, 2])
|
||||
})
|
||||
})
|
||||
@@ -18,7 +18,7 @@ declare const QQQ_BUILD_SHA: string | undefined
|
||||
declare const QQQ_BUILD_TARGET: string | undefined
|
||||
declare const QQQ_BUILD_UI: boolean | undefined
|
||||
|
||||
export const CLI_VERSION = '1.0.0'
|
||||
export const CLI_VERSION = '1.1.0'
|
||||
|
||||
/** The oldest UI API a server may serve for this CLI (operationIds, clientCapabilities and traceId since 1.6.0). */
|
||||
export const UI_API_MIN_VERSION = '1.6.0'
|
||||
|
||||
@@ -30,7 +30,7 @@ test('on a MOCK server without a token, every authenticated check (jobs too) use
|
||||
const seen: Array<{ path: string; authorization?: string; cookie?: string }> = []
|
||||
const results = await runConformance({ baseUrl: 'http://fake', fetch: fakeServer(seen) })
|
||||
const jobs = results.find((r) => r.name === 'GET /qqq/v1/jobs validates')!
|
||||
assert.equal(jobs.ok && !jobs.skipped, true, jobs.detail)
|
||||
assert.equal(jobs.ok && !jobs.skipped, true, jobs.detail ?? 'jobs check did not pass')
|
||||
assert.equal(seen.find((s) => s.path === '/qqq/v1/metaData')?.cookie, 'sessionUUID=s-1')
|
||||
assert.equal(seen.find((s) => s.path === '/qqq/v1/jobs')?.cookie, 'sessionUUID=s-1')
|
||||
assert.match(results.find((r) => r.name === 'GET /qqq/v1/metaData validates')!.detail!, /authenticated with a server session/)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qrun-io/qqq-core",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "Client kernel for QQQ terminal clients: sessions, metadata, filter grammar, records, processes, jobs, render model and output formats. No terminal code.",
|
||||
"keywords": [
|
||||
"qqq",
|
||||
@@ -50,7 +50,7 @@
|
||||
"prepack": "node ../../scripts/copy-license.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@qrun-io/qqq-ui-api-client": "1.8.0",
|
||||
"@qrun-io/qqq-ui-api-client": "1.10.0",
|
||||
"jmespath": "0.16.0",
|
||||
"yaml": "2.9.1"
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
// Generated by scripts/gen-operations.mjs from the UI API 1.8.0 document (core and 14 extensions). Do not edit.
|
||||
// Generated by scripts/gen-operations.mjs from the UI API 1.10.0 document (core and 14 extensions). Do not edit.
|
||||
|
||||
export interface OperationInfo {
|
||||
id: string
|
||||
@@ -26,24 +26,24 @@ export interface DocumentExtension {
|
||||
description?: string
|
||||
}
|
||||
|
||||
export const UI_API_DOCUMENT_VERSION = "1.8.0"
|
||||
export const UI_API_DOCUMENT_VERSION = "1.10.0"
|
||||
|
||||
/** The extensions merged into the client document, with the versions it was generated from. */
|
||||
export const DOCUMENT_EXTENSIONS: DocumentExtension[] = [
|
||||
{"name":"esb","version":"1.1.0","pathPrefix":"/esb","description":"ESB overview, the ESB of tables and processes, and messages and dead letters on destinations."},
|
||||
{"name":"health","version":"1.1.0","pathPrefix":"/health","description":"Central health summary, components, history and on-demand checks."},
|
||||
{"name":"alerts","version":"1.2.0","pathPrefix":"/alerts","description":"Alerts inbox and record panel"},
|
||||
{"name":"assistant","version":"1.6.0","pathPrefix":"/assistant","description":"QQQ Assistant: conversations, streamed turns, feedback, pending actions and data access explanations"},
|
||||
{"name":"collaboration","version":"1.1.0","pathPrefix":"/qbit/collaboration","description":"Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence"},
|
||||
{"name":"assistant","version":"1.7.0","pathPrefix":"/assistant","description":"QQQ Assistant: conversations, streamed turns, feedback, pending actions and data access explanations"},
|
||||
{"name":"collaboration","version":"1.2.0","pathPrefix":"/qbit/collaboration","description":"Record collaboration: favorites, recents, attachments, tags, conversations, following, timeline, people and presence"},
|
||||
{"name":"data-analysis","version":"1.5.0","pathPrefix":"/qbit/data-analysis","description":"Profiles, pivots, comparisons, distribution shifts, correlations, scatter samples, crosstabs and anomaly watch previews as the signed-in user."},
|
||||
{"name":"db-performance","version":"1.0.0","pathPrefix":"/qbit/db-performance","description":"Database performance, live activity and audited query plans"},
|
||||
{"name":"edi","version":"1.1.0","pathPrefix":"/qbit/edi","description":"EDI sandbox and mapper, using the signed-in user's permissions."},
|
||||
{"name":"printing","version":"2.3.0","pathPrefix":"/qbit/printing","description":"Native current-user printing with secondary device proof, device-control-v1 and realtime-v1; record, table-list and text printing processes; render to PDF with "},
|
||||
{"name":"qa-capture","version":"1.0.0","pathPrefix":"/qbit/qa-capture","description":"QA bug capture: the user's capture capability and QA report creation"},
|
||||
{"name":"quick-search","version":"1.1.0","pathPrefix":"/qbit/quick-search","description":"Quick Search administration and suggestions"},
|
||||
{"name":"release-notes","version":"1.0.0","pathPrefix":"/qbit/release-notes","description":"What's new: published release notes and each user's unread count"},
|
||||
{"name":"secured-data","version":"1.1.0","pathPrefix":"/qbit/secured-data","description":"Reveal grants and break-glass status"},
|
||||
{"name":"sql-workbench","version":"1.1.0","pathPrefix":"/sql-workbench","description":"Audited SQL execution and notebooks"},
|
||||
{"name":"printing","version":"2.1.0","pathPrefix":"/qbit/printing","description":"Native current-user printing with secondary device proof, device-control-v1 and realtime-v1; record, table-list and text printing processes."},
|
||||
]
|
||||
|
||||
export const OPERATIONS: OperationInfo[] = [
|
||||
@@ -59,7 +59,7 @@ export const OPERATIONS: OperationInfo[] = [
|
||||
{"id":"chooseDeliveryTargets","method":"POST","path":"/qqq/v1/delivery/destinations/{name}/targets","summary":"Describe the next delivery target","tag":"General","pathParams":["name"],"queryParams":[],"bodyTypes":["application/json"]},
|
||||
{"id":"countTable","method":"POST","path":"/qqq/v1/table/{tableName}/count","summary":"Count records in a table","tag":"Tables","pathParams":["tableName"],"queryParams":["includeDistinct"],"bodyTypes":["application/json"]},
|
||||
{"id":"dbPerformanceActivity","method":"GET","path":"/qqq/v1/qbit/db-performance/activity","summary":"Database Performance activity","tag":"","pathParams":[],"queryParams":["backend"],"bodyTypes":[],"extension":"db-performance"},
|
||||
{"id":"dbPerformanceExplain","method":"POST","path":"/qqq/v1/qbit/db-performance/explain","summary":"Explain a PostgreSQL query","tag":"","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"db-performance"},
|
||||
{"id":"dbPerformanceExplain","method":"POST","path":"/qqq/v1/qbit/db-performance/explain","summary":"Explain a query (PostgreSQL JSON or H2 text plan)","tag":"","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"db-performance"},
|
||||
{"id":"dbPerformanceLookup","method":"GET","path":"/qqq/v1/qbit/db-performance/statements/lookup","summary":"Database Performance lookup","tag":"","pathParams":[],"queryParams":["backend","sql"],"bodyTypes":[],"extension":"db-performance"},
|
||||
{"id":"dbPerformanceOverview","method":"GET","path":"/qqq/v1/qbit/db-performance/overview","summary":"Database Performance overview","tag":"","pathParams":[],"queryParams":["backend"],"bodyTypes":[],"extension":"db-performance"},
|
||||
{"id":"deleteAssistantConversationsById","method":"DELETE","path":"/qqq/v1/assistant/conversations/{id}","summary":"Delete a conversation","tag":"assistant","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"assistant"},
|
||||
@@ -69,7 +69,7 @@ export const OPERATIONS: OperationInfo[] = [
|
||||
{"id":"deleteQbitCollaborationPresence","method":"DELETE","path":"/qqq/v1/qbit/collaboration/presence","summary":"Leave presence","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"collaboration"},
|
||||
{"id":"deleteQbitCollaborationRecordsByTableByPkFollow","method":"DELETE","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/follow","summary":"Unfollow a record","tag":"collaboration","pathParams":["table","pk"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
|
||||
{"id":"deleteQbitDataAnalysisSavedByIdStar","method":"DELETE","path":"/qqq/v1/qbit/data-analysis/saved/{id}/star","summary":"Unstar a saved analysis","tag":"data-analysis","pathParams":["id"],"queryParams":[],"bodyTypes":[],"extension":"data-analysis"},
|
||||
{"id":"deleteRecord","method":"DELETE","path":"/qqq/v1/table/{tableName}/{primaryKey}","summary":"Delete a record from a table","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":[],"bodyTypes":[]},
|
||||
{"id":"deleteRecord","method":"DELETE","path":"/qqq/v1/table/{tableName}/{primaryKey}","summary":"Delete a record from a table","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":["tableVariant"],"bodyTypes":[]},
|
||||
{"id":"download","method":"GET","path":"/qqq/v1/download/{file}","summary":"Download a file","tag":"General","pathParams":["file"],"queryParams":["filePath","storageTableName","storageReference"],"bodyTypes":[]},
|
||||
{"id":"downloadRecordFieldFile","method":"GET","path":"/qqq/v1/table/{tableName}/{primaryKey}/{fieldName}/{filename}","summary":"Download a field value from a record","tag":"Tables","pathParams":["tableName","primaryKey","fieldName","filename"],"queryParams":[],"bodyTypes":[]},
|
||||
{"id":"exportTable","method":"POST","path":"/qqq/v1/table/{tableName}/export","summary":"Export records from a table","tag":"Tables","pathParams":["tableName"],"queryParams":[],"bodyTypes":["application/json"]},
|
||||
@@ -111,7 +111,7 @@ export const OPERATIONS: OperationInfo[] = [
|
||||
{"id":"getQbitCollaborationMyWork","method":"GET","path":"/qqq/v1/qbit/collaboration/my-work","summary":"Get my work summary","tag":"collaboration","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
|
||||
{"id":"getQbitCollaborationPeople","method":"GET","path":"/qqq/v1/qbit/collaboration/people","summary":"Search the active people directory","tag":"collaboration","pathParams":[],"queryParams":["q","limit"],"bodyTypes":[],"extension":"collaboration"},
|
||||
{"id":"getQbitCollaborationPeopleByUserId","method":"GET","path":"/qqq/v1/qbit/collaboration/people/{userId}","summary":"Get an active directory person","tag":"collaboration","pathParams":["userId"],"queryParams":[],"bodyTypes":[],"extension":"collaboration"},
|
||||
{"id":"getQbitCollaborationPresence","method":"GET","path":"/qqq/v1/qbit/collaboration/presence","summary":"Get presence snapshot","tag":"collaboration","pathParams":[],"queryParams":["table","pk"],"bodyTypes":[],"extension":"collaboration"},
|
||||
{"id":"getQbitCollaborationPresence","method":"GET","path":"/qqq/v1/qbit/collaboration/presence","summary":"Get presence snapshot","tag":"collaboration","pathParams":[],"queryParams":["table","pk","perConnection"],"bodyTypes":[],"extension":"collaboration"},
|
||||
{"id":"getQbitCollaborationRecents","method":"GET","path":"/qqq/v1/qbit/collaboration/recents","summary":"List recent targets","tag":"collaboration","pathParams":[],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
|
||||
{"id":"getQbitCollaborationRecordsByTableByPkAttachments","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/attachments","summary":"List record attachments","tag":"collaboration","pathParams":["table","pk"],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
|
||||
{"id":"getQbitCollaborationRecordsByTableByPkComments","method":"GET","path":"/qqq/v1/qbit/collaboration/records/{table}/{pk}/comments","summary":"Page record comment threads","tag":"collaboration","pathParams":["table","pk"],"queryParams":["limit","cursor"],"bodyTypes":[],"extension":"collaboration"},
|
||||
@@ -140,8 +140,8 @@ export const OPERATIONS: OperationInfo[] = [
|
||||
{"id":"getTableMetaData","method":"GET","path":"/qqq/v1/metaData/table/{tableName}","summary":"Get table metaData","tag":"Tables","pathParams":["tableName"],"queryParams":[],"bodyTypes":[]},
|
||||
{"id":"getVersion","method":"GET","path":"/qqq/v1/version","summary":"Get the UI API version","tag":"General","pathParams":[],"queryParams":[],"bodyTypes":[]},
|
||||
{"id":"getWidgetData","method":"POST","path":"/qqq/v1/widget/{widgetName}","summary":"Render a widget","tag":"Widgets","pathParams":["widgetName"],"queryParams":[],"bodyTypes":["application/json"]},
|
||||
{"id":"initProcess","method":"POST","path":"/qqq/v1/processes/{processName}/init","summary":"Initialize a process","tag":"Processes","pathParams":["processName"],"queryParams":["tableVariant"],"bodyTypes":["multipart/form-data"]},
|
||||
{"id":"insertRecord","method":"POST","path":"/qqq/v1/table/{tableName}","summary":"Insert a record into a table","tag":"Tables","pathParams":["tableName"],"queryParams":[],"bodyTypes":["application/json","multipart/form-data"]},
|
||||
{"id":"initProcess","method":"POST","path":"/qqq/v1/processes/{processName}/init","summary":"Initialize a process","tag":"Processes","pathParams":["processName"],"queryParams":["tableVariant"],"bodyTypes":["multipart/form-data","application/x-www-form-urlencoded"]},
|
||||
{"id":"insertRecord","method":"POST","path":"/qqq/v1/table/{tableName}","summary":"Insert a record into a table","tag":"Tables","pathParams":["tableName"],"queryParams":["tableVariant"],"bodyTypes":["application/json","multipart/form-data"]},
|
||||
{"id":"launchProcessJob","method":"POST","path":"/qqq/v1/processes/{processName}/jobs","summary":"Run a process as a durable background job","tag":"Processes","pathParams":["processName"],"queryParams":[],"bodyTypes":["application/json"]},
|
||||
{"id":"listDeliveryDestinations","method":"GET","path":"/qqq/v1/delivery/destinations","summary":"List authorized output destinations","tag":"General","pathParams":[],"queryParams":[],"bodyTypes":[]},
|
||||
{"id":"listJobs","method":"GET","path":"/qqq/v1/jobs","summary":"Jobs: list","tag":"Processes","pathParams":[],"queryParams":["scope","state","limit","processName"],"bodyTypes":[]},
|
||||
@@ -149,7 +149,7 @@ export const OPERATIONS: OperationInfo[] = [
|
||||
{"id":"listRecordDeveloperPanels","method":"GET","path":"/qqq/v1/table/{tableName}/{primaryKey}/developer/panels","summary":"Get a record's developer panels","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":[],"bodyTypes":[]},
|
||||
{"id":"listTableApis","method":"GET","path":"/qqq/v1/table/{tableName}/apis","summary":"List the APIs that expose a table","tag":"Tables","pathParams":["tableName"],"queryParams":[],"bodyTypes":[]},
|
||||
{"id":"listTableVariants","method":"GET","path":"/qqq/v1/table/{tableName}/variants","summary":"List a table's backend variants","tag":"Tables","pathParams":["tableName"],"queryParams":[],"bodyTypes":[]},
|
||||
{"id":"logout","method":"POST","path":"/qqq/v1/logout","summary":"Logout and invalidate session","tag":"Authentication","pathParams":[],"queryParams":[],"bodyTypes":[]},
|
||||
{"id":"logout","method":"POST","path":"/qqq/v1/logout","summary":"Logout and invalidate session","tag":"Authentication","pathParams":[],"queryParams":[],"bodyTypes":["application/json"]},
|
||||
{"id":"lookupAvatars","method":"POST","path":"/qqq/v1/avatars/lookup","summary":"Look up users' avatars","tag":"Authentication","pathParams":[],"queryParams":[],"bodyTypes":["application/json"]},
|
||||
{"id":"manageSession","method":"POST","path":"/qqq/v1/manageSession","summary":"Create a session","tag":"Authentication","pathParams":[],"queryParams":[],"bodyTypes":["application/json"]},
|
||||
{"id":"oidcBackChannelLogout","method":"POST","path":"/qqq/v1/oidc/backchannel-logout","summary":"OIDC Back-Channel Logout","tag":"Authentication","pathParams":[],"queryParams":[],"bodyTypes":[]},
|
||||
@@ -208,6 +208,9 @@ export const OPERATIONS: OperationInfo[] = [
|
||||
{"id":"printingEvent","method":"POST","path":"/qqq/v1/qbit/printing/device/jobs/{jobId}/events","summary":"Printing Event","tag":"printing","pathParams":["jobId"],"queryParams":[],"bodyTypes":["application/json"],"extension":"printing"},
|
||||
{"id":"printingHeartbeat","method":"POST","path":"/qqq/v1/qbit/printing/device/heartbeat","summary":"Printing Heartbeat","tag":"printing","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"printing"},
|
||||
{"id":"printingInventory","method":"PUT","path":"/qqq/v1/qbit/printing/device/printers","summary":"Printing Inventory","tag":"printing","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"printing"},
|
||||
{"id":"printingOperatorCatalog","method":"GET","path":"/qqq/v1/qbit/printing/operator/printers","summary":"Printing OperatorCatalog","tag":"printing","pathParams":[],"queryParams":["limit","after"],"bodyTypes":[],"extension":"printing"},
|
||||
{"id":"printingOperatorRequest","method":"GET","path":"/qqq/v1/qbit/printing/operator/requests/{requestId}","summary":"Printing OperatorRequest","tag":"printing","pathParams":["requestId"],"queryParams":[],"bodyTypes":[],"extension":"printing"},
|
||||
{"id":"printingOperatorSubmit","method":"POST","path":"/qqq/v1/qbit/printing/operator/jobs","summary":"Printing OperatorSubmit","tag":"printing","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"printing"},
|
||||
{"id":"printingPoll","method":"POST","path":"/qqq/v1/qbit/printing/device/control/poll","summary":"Printing Poll","tag":"printing","pathParams":[],"queryParams":[],"bodyTypes":["application/json"],"extension":"printing"},
|
||||
{"id":"printingQueued","method":"GET","path":"/qqq/v1/qbit/printing/device/jobs/queued","summary":"Printing Queued","tag":"printing","pathParams":[],"queryParams":[],"bodyTypes":[],"extension":"printing"},
|
||||
{"id":"printingReceipt","method":"POST","path":"/qqq/v1/qbit/printing/device/control/commands/{commandId}/receipt","summary":"Printing Receipt","tag":"printing","pathParams":["commandId"],"queryParams":[],"bodyTypes":["application/json"],"extension":"printing"},
|
||||
@@ -257,13 +260,13 @@ export const OPERATIONS: OperationInfo[] = [
|
||||
{"id":"sqlWorkbenchPublishNotebook","method":"POST","path":"/qqq/v1/sql-workbench/notebooks/{id}/publish","summary":"Publish or withdraw an owned notebook with share permission","tag":"sql-workbench","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
|
||||
{"id":"sqlWorkbenchRestoreVersion","method":"POST","path":"/qqq/v1/sql-workbench/notebooks/{id}/versions/{n}/restore","summary":"Restore an owned notebook version as a new version","tag":"sql-workbench","pathParams":["id","n"],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
|
||||
{"id":"sqlWorkbenchUpdateNotebook","method":"PUT","path":"/qqq/v1/sql-workbench/notebooks/{id}","summary":"Update an owned notebook and record a version","tag":"sql-workbench","pathParams":["id"],"queryParams":[],"bodyTypes":["application/json"],"extension":"sql-workbench"},
|
||||
{"id":"stepProcess","method":"POST","path":"/qqq/v1/processes/{processName}/{processUUID}/step/{stepName}","summary":"Run a step in a process","tag":"Processes","pathParams":["processName","processUUID","stepName"],"queryParams":["tableVariant"],"bodyTypes":["multipart/form-data"]},
|
||||
{"id":"stepProcess","method":"POST","path":"/qqq/v1/processes/{processName}/{processUUID}/step/{stepName}","summary":"Run a step in a process","tag":"Processes","pathParams":["processName","processUUID","stepName"],"queryParams":["isStepBack","tableVariant"],"bodyTypes":["multipart/form-data","application/x-www-form-urlencoded"]},
|
||||
{"id":"storeAssociatedScript","method":"POST","path":"/qqq/v1/table/{tableName}/{primaryKey}/developer/associatedScript/{fieldName}","summary":"Store a new revision of a record's associated script (developer mode)","tag":"Tables","pathParams":["tableName","primaryKey","fieldName"],"queryParams":[],"bodyTypes":["application/json"]},
|
||||
{"id":"suggestBulkLoadMapping","method":"POST","path":"/qqq/v1/bulkLoad/{tableName}/suggestMapping","summary":"Suggest a bulk load mapping for an uploaded file","tag":"Bulk Load","pathParams":["tableName"],"queryParams":[],"bodyTypes":["application/json"]},
|
||||
{"id":"trackJob","method":"POST","path":"/qqq/v1/jobs/track","summary":"Jobs: track","tag":"Processes","pathParams":[],"queryParams":[],"bodyTypes":["application/json"]},
|
||||
{"id":"updatePreferenceSection","method":"PATCH","path":"/qqq/v1/preferences/{sectionName}","summary":"Partially update the signed-in user's preferences","tag":"General","pathParams":["sectionName"],"queryParams":[],"bodyTypes":["application/json"]},
|
||||
{"id":"updateRealtimeSubscriptions","method":"POST","path":"/qqq/v1/realtime/subscriptions","summary":"Change realtime subscriptions","tag":"General","pathParams":[],"queryParams":[],"bodyTypes":["application/json"]},
|
||||
{"id":"updateRecord","method":"PATCH","path":"/qqq/v1/table/{tableName}/{primaryKey}","summary":"Update a record in a table","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":["expectedVersion"],"bodyTypes":["application/json","multipart/form-data"]},
|
||||
{"id":"updateRecord","method":"PATCH","path":"/qqq/v1/table/{tableName}/{primaryKey}","summary":"Update a record in a table","tag":"Tables","pathParams":["tableName","primaryKey"],"queryParams":["expectedVersion","tableVariant"],"bodyTypes":["application/json","multipart/form-data"]},
|
||||
{"id":"upload","method":"POST","path":"/qqq/v1/uploads","summary":"Upload a file for a job or a bulk load","tag":"Processes","pathParams":[],"queryParams":["fileName"],"bodyTypes":["multipart/form-data","application/octet-stream"]},
|
||||
{"id":"validateBulkLoad","method":"POST","path":"/qqq/v1/bulkLoad/{tableName}/validate","summary":"Dry-run a bulk load profile over an uploaded file","tag":"Bulk Load","pathParams":["tableName"],"queryParams":[],"bodyTypes":["application/json"]},
|
||||
]
|
||||
|
||||
@@ -43,6 +43,7 @@ const presence = await collab.joinPresence({ table: 'person', pk: '1' }, {
|
||||
announce: true, // false: watch without appearing
|
||||
})
|
||||
presence.count; presence.people; presence.state.live; presence.state.error
|
||||
presence.state.connections // two terminals of one user count twice (collaboration 1.2.0)
|
||||
await presence.stop() // DELETE /presence, stop the heartbeat, close the stream
|
||||
```
|
||||
|
||||
@@ -55,6 +56,12 @@ await presence.stop() // DELETE /presence, stop the heartbeat, close the s
|
||||
- `since` is when this client first saw the person; the qbit does not send join times.
|
||||
- The list includes the signed-in user when they hold a lease (tabs and clients merge per user;
|
||||
EDITING wins).
|
||||
- Collaboration 1.2.0: each person has `connections` (their live tabs and terminals) and
|
||||
`state.connections` totals them. `perConnection: true` (option of `joinPresence` and
|
||||
`presenceSnapshot`) also fills `person.leases`: each connection's mode, field, start and opaque
|
||||
ids, this client's own marked `self` (the server's id is `c` + 16 hex digits of SHA-256 of
|
||||
user id, newline, connection id; see `opaqueConnectionId`). Older servers send neither: each
|
||||
person is then one connection and `leases` stays unset.
|
||||
- One stream per joinPresence. A TUI that already holds a stream for other channels can pass an
|
||||
`openStream` that fans out its own connection's events instead of opening another.
|
||||
- `state.error` holds a refused heartbeat (404 when presence is off for the table or the record is
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* there) and the other calls throw ExtensionNotInstalledError.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { RealtimeMessage } from '@qrun-io/qqq-ui-api-client'
|
||||
import type { CallInput, CallResult } from '../api/call.js'
|
||||
import type { ExtensionRegistry } from './registry.js'
|
||||
@@ -104,6 +105,20 @@ export interface PresenceScope {
|
||||
pk?: string
|
||||
}
|
||||
|
||||
/** One live connection (a tab or terminal) of a person, from collaboration 1.2.0's per-connection presence. */
|
||||
export interface PresenceConnection {
|
||||
/** Opaque per-connection id from the server (never the client's own connection id). */
|
||||
connectionId: string
|
||||
mode: 'VIEWING' | 'EDITING' | string
|
||||
fieldName?: string | null
|
||||
/** When the lease started, as the server reports it. */
|
||||
since?: Date
|
||||
/** Opaque id of the signed-in session behind it (never the session itself). */
|
||||
sessionId?: string
|
||||
/** True for this client's own connection. */
|
||||
self?: boolean
|
||||
}
|
||||
|
||||
/** One person on the scope, merged across their tabs. */
|
||||
export interface PresencePerson {
|
||||
userId: string
|
||||
@@ -112,6 +127,10 @@ export interface PresencePerson {
|
||||
fieldName?: string | null
|
||||
/** When this client first saw them there (the qbit does not send join times). */
|
||||
since: Date
|
||||
/** Live connections (tabs, terminals) merged into this person; 1 from servers before collaboration 1.2.0 (always set by this module). */
|
||||
connections?: number
|
||||
/** Each connection, when the server sent per-connection presence (perConnection, or any 1.2.0 event). */
|
||||
leases?: PresenceConnection[]
|
||||
}
|
||||
|
||||
export interface PresenceState {
|
||||
@@ -120,6 +139,8 @@ export interface PresenceState {
|
||||
scope: PresenceScope
|
||||
count: number
|
||||
people: PresencePerson[]
|
||||
/** Live connections behind `people` (two terminals of one user count twice); equals `count` on older servers (always set by this module). */
|
||||
connections?: number
|
||||
/** True while the realtime stream is connected and subscribed (else presence is polled). */
|
||||
live: boolean
|
||||
/** The last error (presence not enabled on the table, record not visible, ...), cleared on success. */
|
||||
@@ -140,6 +161,8 @@ export interface PresenceOptions {
|
||||
announce?: boolean
|
||||
/** Delay before reconnecting a dropped stream; default 2000 ms, doubling to 30 s. */
|
||||
reconnectMs?: number
|
||||
/** Ask for each connection's mode, field and start (collaboration 1.2.0; ignored by older servers). */
|
||||
perConnection?: boolean
|
||||
}
|
||||
|
||||
export interface PresenceHandle {
|
||||
@@ -159,12 +182,64 @@ interface Viewer {
|
||||
displayName?: string
|
||||
mode?: string
|
||||
fieldName?: string | null
|
||||
connections?: number
|
||||
connectionId?: string
|
||||
sessionId?: string
|
||||
since?: string
|
||||
}
|
||||
|
||||
interface Snapshot {
|
||||
table?: string
|
||||
pk?: string | null
|
||||
viewers?: Viewer[]
|
||||
leaseCount?: number
|
||||
leases?: Viewer[]
|
||||
}
|
||||
|
||||
/**
|
||||
* The opaque id the server gives a connection (collaboration 1.2.0): `c` and the first 16 hex
|
||||
* digits of SHA-256 of the user id, a newline and the client's connection id.
|
||||
*/
|
||||
export function opaqueConnectionId(userId: string, connectionId: string): string {
|
||||
return `c${createHash('sha256').update(`${userId}\n${connectionId}`).digest('hex').slice(0, 16)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* People and connection totals from a snapshot or event. Servers before collaboration 1.2.0 send
|
||||
* neither `connections` nor `leases`: each person then counts as one connection.
|
||||
*/
|
||||
export function presencePeople(snapshot: Snapshot, firstSeen: (userId: string) => Date, ownConnectionId?: string): { people: PresencePerson[]; connections: number } {
|
||||
const viewers = (snapshot.viewers ?? []).filter((v) => v.userId !== undefined)
|
||||
const leases = new Map<string, PresenceConnection[]>()
|
||||
for (const l of snapshot.leases ?? []) {
|
||||
if (l.userId === undefined || !l.connectionId) continue
|
||||
const userId = String(l.userId)
|
||||
const since = l.since ? new Date(l.since) : undefined
|
||||
const entry: PresenceConnection = {
|
||||
connectionId: l.connectionId, mode: l.mode ?? 'VIEWING', fieldName: l.fieldName ?? null,
|
||||
...(since && !Number.isNaN(since.getTime()) ? { since } : {}), ...(l.sessionId ? { sessionId: l.sessionId } : {}),
|
||||
...(ownConnectionId && l.connectionId === opaqueConnectionId(userId, ownConnectionId) ? { self: true } : {}),
|
||||
}
|
||||
leases.set(userId, [...(leases.get(userId) ?? []), entry])
|
||||
}
|
||||
const people = viewers.map((v) => {
|
||||
const userId = String(v.userId)
|
||||
const own = leases.get(userId)
|
||||
const connections = typeof v.connections === 'number' && v.connections > 0 ? v.connections : own?.length || 1
|
||||
return { userId, name: String(v.displayName ?? userId), mode: v.mode ?? 'VIEWING', fieldName: v.fieldName ?? null, since: firstSeen(userId), connections, ...(own ? { leases: own } : {}) }
|
||||
})
|
||||
const sum = people.reduce((n, p) => n + p.connections, 0)
|
||||
return { people, connections: typeof snapshot.leaseCount === 'number' ? Math.max(snapshot.leaseCount, people.length) : sum }
|
||||
}
|
||||
|
||||
/** ` ×2` after a person with more than one connection, else nothing. */
|
||||
export function connectionsSuffix(p: { connections?: number }): string {
|
||||
return p.connections && p.connections > 1 ? ` ×${p.connections}` : ''
|
||||
}
|
||||
|
||||
/** GET /presence query for a scope; `perConnection` only when asked (older servers ignore it). */
|
||||
function presenceQuery(scope: PresenceScope, perConnection?: boolean): Record<string, string | undefined> {
|
||||
return { table: scope.table, pk: scope.pk, ...(perConnection ? { perConnection: 'true' } : {}) }
|
||||
}
|
||||
|
||||
/** The channel presence events for a scope arrive on. */
|
||||
@@ -281,13 +356,15 @@ export class Collaboration {
|
||||
}
|
||||
|
||||
/** Who is on a table or record right now, without joining. Empty when the extension is absent. */
|
||||
async presenceSnapshot(scope: PresenceScope): Promise<PresenceState> {
|
||||
const state: PresenceState = { available: this.available, scope, count: 0, people: [], live: false }
|
||||
async presenceSnapshot(scope: PresenceScope, options: { perConnection?: boolean } = {}): Promise<PresenceState> {
|
||||
const state: PresenceState = { available: this.available, scope, count: 0, people: [], connections: 0, live: false }
|
||||
if (!state.available) return state
|
||||
const snapshot = (await this.transport.call('getQbitCollaborationPresence', { query: { table: scope.table, pk: scope.pk } })).data as Snapshot
|
||||
const snapshot = (await this.transport.call('getQbitCollaborationPresence', { query: presenceQuery(scope, options.perConnection) })).data as Snapshot
|
||||
const now = new Date()
|
||||
state.people = (snapshot.viewers ?? []).map((v) => ({ userId: String(v.userId ?? ''), name: String(v.displayName ?? v.userId ?? ''), mode: v.mode ?? 'VIEWING', fieldName: v.fieldName ?? null, since: now }))
|
||||
state.count = state.people.length
|
||||
const { people, connections } = presencePeople(snapshot, () => now)
|
||||
state.people = people
|
||||
state.count = people.length
|
||||
state.connections = connections
|
||||
return state
|
||||
}
|
||||
|
||||
@@ -297,7 +374,7 @@ export class Collaboration {
|
||||
* at once with a no-op handle when the extension is absent.
|
||||
*/
|
||||
async joinPresence(scope: PresenceScope, options: PresenceOptions = {}): Promise<PresenceHandle> {
|
||||
const state: PresenceState = { available: this.available, scope, count: 0, people: [], live: false }
|
||||
const state: PresenceState = { available: this.available, scope, count: 0, people: [], connections: 0, live: false }
|
||||
const notify = () => options.onChange?.({ ...state, people: [...state.people] })
|
||||
if (!state.available) {
|
||||
return { state, get count() { return 0 }, get people() { return [] }, refresh: async () => {}, setMode: async () => {}, stop: async () => {} }
|
||||
@@ -313,22 +390,22 @@ export class Collaboration {
|
||||
|
||||
const apply = (snapshot: Snapshot) => {
|
||||
const now = new Date()
|
||||
const viewers = (snapshot.viewers ?? []).filter((v) => v.userId !== undefined)
|
||||
const ids = new Set(viewers.map((v) => String(v.userId)))
|
||||
const ids = new Set((snapshot.viewers ?? []).filter((v) => v.userId !== undefined).map((v) => String(v.userId)))
|
||||
for (const id of [...firstSeen.keys()]) if (!ids.has(id)) firstSeen.delete(id)
|
||||
state.people = viewers.map((v) => {
|
||||
const userId = String(v.userId)
|
||||
const { people, connections } = presencePeople(snapshot, (userId) => {
|
||||
if (!firstSeen.has(userId)) firstSeen.set(userId, now)
|
||||
return { userId, name: String(v.displayName ?? userId), mode: v.mode ?? 'VIEWING', fieldName: v.fieldName ?? null, since: firstSeen.get(userId)! }
|
||||
})
|
||||
state.count = state.people.length
|
||||
return firstSeen.get(userId)!
|
||||
}, announce ? connectionId : undefined)
|
||||
state.people = people
|
||||
state.count = people.length
|
||||
state.connections = connections
|
||||
state.error = undefined
|
||||
notify()
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
try {
|
||||
apply((await this.transport.call('getQbitCollaborationPresence', { query: { table: scope.table, pk: scope.pk } })).data as Snapshot)
|
||||
apply((await this.transport.call('getQbitCollaborationPresence', { query: presenceQuery(scope, options.perConnection) })).data as Snapshot)
|
||||
} catch (error) {
|
||||
state.error = messageOf(error)
|
||||
notify()
|
||||
@@ -341,8 +418,9 @@ export class Collaboration {
|
||||
try {
|
||||
const result = await this.transport.call('postQbitCollaborationPresence', { json: { connectionId, table: scope.table, pk: scope.pk, mode, fieldName } })
|
||||
const data = result.data as Snapshot | undefined
|
||||
// The qbit answers the heartbeat with the snapshot; older builds answer {success}.
|
||||
if (data && typeof data.table === 'string') {
|
||||
// The qbit answers the heartbeat with the merged snapshot; older builds answer {success}.
|
||||
// Per-connection detail only comes with GET, so re-read rather than drop it.
|
||||
if (data && typeof data.table === 'string' && !options.perConnection) {
|
||||
apply(data)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import type { RealtimeMessage } from '@qrun-io/qqq-ui-api-client'
|
||||
import type { CallInput, CallResult } from '../api/call.js'
|
||||
import { Collaboration, presenceChannel, type CollaborationTransport, type PresenceState } from '../extensions/collaboration.js'
|
||||
import { Collaboration, connectionsSuffix, opaqueConnectionId, presenceChannel, presencePeople, type CollaborationTransport, type PresenceState } from '../extensions/collaboration.js'
|
||||
import { ExtensionNotInstalledError, ExtensionRegistry } from '../extensions/registry.js'
|
||||
|
||||
const withCollab = ExtensionRegistry.fromVersion({ extensions: [{ name: 'collaboration', version: '1.1.0' }] })
|
||||
@@ -164,3 +164,65 @@ test('tags: vocabulary search, create, record tags and apply call the qbit opera
|
||||
assert.deepEqual(calls[1]!.input.json, { table: 'person', label: 'New one', color: 'teal' })
|
||||
assert.deepEqual(calls[3]!.input, { path: { table: 'person', pk: '3' }, json: { add: [9] } })
|
||||
})
|
||||
|
||||
test('two connections of one user: one person with connections 2, and per connection each mode, field and start', async () => {
|
||||
const leases = [
|
||||
{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 1, connectionId: opaqueConnectionId('admin', 'cli-mine'), sessionId: 's1', since: '2026-11-01T12:00:00Z' },
|
||||
{ userId: 'admin', displayName: 'Admin', mode: 'EDITING', fieldName: 'name', connections: 1, connectionId: opaqueConnectionId('admin', 'cli-other'), sessionId: 's2', since: '2026-11-01T12:00:05Z' },
|
||||
]
|
||||
const { calls, transport } = fake((id, input) => {
|
||||
if (id === 'deleteQbitCollaborationPresence') return { success: true }
|
||||
const per = (input.query as Record<string, string | undefined> | undefined)?.perConnection === 'true'
|
||||
return { table: 'person', pk: '1', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'EDITING', fieldName: 'name', connections: 2 }], leaseCount: 2, ...(per ? { leases } : {}) }
|
||||
})
|
||||
const collab = new Collaboration(withCollab, transport)
|
||||
const merged = await collab.presenceSnapshot({ table: 'person', pk: '1' })
|
||||
assert.deepEqual([merged.count, merged.connections, merged.people[0]!.connections, merged.people[0]!.leases], [1, 2, 2, undefined])
|
||||
assert.equal(connectionsSuffix(merged.people[0]!), ' ×2')
|
||||
assert.equal((calls.at(-1)!.input.query as Record<string, unknown>).perConnection, undefined, 'merged by default')
|
||||
|
||||
const handle = await collab.joinPresence({ table: 'person', pk: '1' }, { perConnection: true, connectionId: 'cli-mine', heartbeatMs: 10000 })
|
||||
try {
|
||||
const admin = handle.people[0]!
|
||||
assert.equal(admin.connections, 2)
|
||||
assert.deepEqual(admin.leases!.map((l) => [l.mode, l.fieldName, l.since?.toISOString(), l.self ?? false]), [
|
||||
['VIEWING', null, '2026-11-01T12:00:00.000Z', true],
|
||||
['EDITING', 'name', '2026-11-01T12:00:05.000Z', false],
|
||||
])
|
||||
assert.deepEqual(calls.filter((c) => c.id === 'getQbitCollaborationPresence').at(-1)!.input.query, { table: 'person', pk: '1', perConnection: 'true' })
|
||||
} finally {
|
||||
await handle.stop()
|
||||
}
|
||||
})
|
||||
|
||||
test('servers before collaboration 1.2.0 send no connections: each person is one connection', async () => {
|
||||
const { transport } = fake(() => ({ table: 'person', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING' }, { userId: 'bob', displayName: 'Bob' }] }))
|
||||
const state = await new Collaboration(withCollab, transport).presenceSnapshot({ table: 'person' }, { perConnection: true })
|
||||
assert.deepEqual([state.count, state.connections], [2, 2])
|
||||
assert.deepEqual(state.people.map((p) => [p.connections, p.leases, connectionsSuffix(p)]), [[1, undefined, ''], [1, undefined, '']])
|
||||
const fromEvent = presencePeople({ table: 'person', viewers: [{ userId: 'x', displayName: 'X', connections: 3 }], leaseCount: 3 }, () => new Date(0))
|
||||
assert.deepEqual([fromEvent.connections, fromEvent.people[0]!.connections], [3, 3])
|
||||
})
|
||||
|
||||
test('presence.changed with leases updates connections live', async () => {
|
||||
const events: RealtimeMessage[] = [
|
||||
{ event: 'hello', data: { connectionId: 'sse-1' } },
|
||||
{ event: 'presence.changed', data: { table: 'person', pk: '1', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 2 }], leaseCount: 2,
|
||||
leases: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 1, connectionId: 'c1', since: '2026-11-01T12:00:00Z' }, { userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 1, connectionId: 'c2', since: '2026-11-01T12:00:01Z' }] } },
|
||||
]
|
||||
async function* stream(signal: AbortSignal): AsyncIterable<RealtimeMessage> {
|
||||
let next = 0
|
||||
while (!signal.aborted) {
|
||||
await tick(2)
|
||||
if (next < events.length) yield events[next++]!
|
||||
}
|
||||
}
|
||||
const { transport } = fake((id, input) => (id === 'updateRealtimeSubscriptions' ? { active: (input.json as { add: string[] }).add } : { table: 'person', pk: '1', viewers: [] }), stream)
|
||||
const handle = await new Collaboration(withCollab, transport).joinPresence({ table: 'person', pk: '1' }, { announce: false, heartbeatMs: 10000 })
|
||||
try {
|
||||
await until('the event with two connections', () => handle.state.connections === 2)
|
||||
assert.equal(handle.people[0]!.leases?.length, 2)
|
||||
} finally {
|
||||
await handle.stop()
|
||||
}
|
||||
})
|
||||
@@ -18,6 +18,6 @@
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.4.4",
|
||||
"@types/react": "19.3.0",
|
||||
"typescript": "5.8.3"
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
+39
-7
@@ -15,15 +15,16 @@ says `binary with ui`. The npm package `@qrun-io/qqq-cli` does not carry it. Fro
|
||||
```sh
|
||||
npm ci && npm run build # once, at the repo root (the kernel and CLI are used built)
|
||||
(cd packages/tui && bun install --frozen-lockfile) # once
|
||||
qqq ui # the default profile; or: qqq --profile sample ui
|
||||
qqq ui # the splash, then the only profile (or a picker); 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 | --no-mouse | --check | --no-check
|
||||
qqq ui --theme light | --ascii | --linear | --no-mouse | --no-splash | --check | --no-check
|
||||
bun run packages/tui/src/main.tsx --profile sample # the same without the CLI
|
||||
```
|
||||
|
||||
Options: `--profile|-p <name>`, `--server|-s <url>` (a profile named after the host is made), `--theme
|
||||
<name>`, `--ascii` (ASCII glyphs), `--linear` (one region, no Apps pane), `--no-mouse` / `--mouse` (the
|
||||
mouse for this run; the saved Mouse setting is on by default, see [Mouse](#mouse)),
|
||||
mouse for this run; the saved Mouse setting is on by default, see [Mouse](#mouse)), `--no-splash` /
|
||||
`--splash` (the start-up splash for this run; see [Starting](#starting)),
|
||||
`--check` (the full terminal check, then exit), `--no-check` (skip the quick check at startup). Anything
|
||||
after the options is a route in the CLI grammar.
|
||||
|
||||
@@ -31,7 +32,36 @@ Environment: `QQQ_UI_LINEAR=1`, `QQQ_UI_ASCII=1` (as the options); `QQQ_UI_SIGN_
|
||||
OAUTH2 choice); `QQQ_UI_NO_BROWSER=1` (show the sign-in address without opening a browser);
|
||||
`QQQ_PROFILE`, `QQQ_SERVER`, `QQQ_TOKEN` (a bearer token instead of the stored sign-in), `QQQ_CONFIG_DIR`,
|
||||
`QQQ_STATE_DIR`, `QQQ_CACHE_DIR`, `QQQ_KEYRING=file` (keep sign-ins in a file instead of the OS keychain),
|
||||
shared with the CLI. `QQQ_UI_NO_QUIT=1` is for the soak test only.
|
||||
shared with the CLI. `QQQ_UI_NO_SPLASH=1` (as `--no-splash`). `QQQ_UI_NO_QUIT=1` is for the soak test only.
|
||||
|
||||
## Starting
|
||||
|
||||
`qqq ui` opens on a **splash**: Query (the qrun.io mascot) blinking, his tail wagging while it works, the
|
||||
`qqq` wordmark, "Terminal client for QQQ", the version (`qqq 1.1.0 · UI API client 1.10.0`) and one status
|
||||
line that follows the boot: reading profiles, connecting to the server, signing in, loading metadata (with
|
||||
the table count). The large Query needs about 34 rows; smaller terminals (80×24) get the small Query beside
|
||||
the wordmark, very small ones the text alone; `--ascii` draws it all in plain characters. Any key stops the
|
||||
animation (not the boot). The splash stays at least 600 ms so it never just flashes, then Home opens as
|
||||
before. `--no-splash`, `QQQ_UI_NO_SPLASH=1`, or F9 › Settings › **Splash on start: off** (`:splash off`,
|
||||
saved as `ui.splash: off`) skip it.
|
||||
|
||||
Which server:
|
||||
|
||||
- `--profile`, `QQQ_PROFILE`, `--server` or `QQQ_SERVER` names one: it connects straight away. So does a
|
||||
config with exactly one profile.
|
||||
- Several profiles and none named: the **Servers picker** opens over the splash (the rows of the Servers
|
||||
dialog, [Servers](#servers): name, short name, server, status, last used; who is signed in, the sign-in,
|
||||
the description), with the last used profile selected. Enter connects; `n` adds a profile, `e` edits, typing
|
||||
filters; the status column fills in as the servers answer (the picker never waits for them). Esc quits and
|
||||
prints how to skip the picker (`--profile NAME` or `QQQ_PROFILE`).
|
||||
- No profile at all (the first run): the add form opens directly; saving connects to the new profile. Esc
|
||||
closes it; then `n` opens it again and Esc quits.
|
||||
|
||||
The sign-in runs as before (a MOCK or anonymous session, the OAuth2 browser or device flow with the address
|
||||
or code on the splash, the username and password dialog). A failure stays on the splash with the reason:
|
||||
Enter (or `r`) retries, `c` opens the picker to choose another profile (Esc there returns), `n` adds one,
|
||||
Esc quits. Ctrl+O opens the picker too. With `--no-splash` the sign-in screen comes first, as in 1.0.0,
|
||||
and the picker or the add form opens over it in the same cases.
|
||||
|
||||
## Keys
|
||||
|
||||
@@ -581,7 +611,8 @@ Everything below is local to the machine and shared with the CLI; nothing is sen
|
||||
1.0.0-rc.1 `note`, `lastUsedAt` and `user` recorded by the UI, and `ui.theme` for that profile only)
|
||||
and `defaultProfile`. Fields are only ever added: a 1.0.0-rc.1 file loads unchanged.
|
||||
Sign-ins themselves are in the OS keychain (or the file keyring), one per profile.
|
||||
- `ui.theme`: the theme for every profile without its own; `ui.mouse`: `false` when the mouse is off.
|
||||
- `ui.theme`: the theme for every profile without its own; `ui.mouse`: `false` when the mouse is off;
|
||||
`ui.splash`: `off` when `qqq ui` starts without the splash.
|
||||
- `ui.dates` (global, or in a profile's `ui`): date order (`YMD`, `MDY`, `DMY`), `clock` (`24h`, `12h`),
|
||||
`weekStart`, `weekNumbers`, `pickerOnEnter`, set with `:dates`. Without them the clock and the first day
|
||||
of the week follow the locale (`LC_ALL`, `LC_TIME`, `LANG`), else 24-hour and Monday; the order is ISO.
|
||||
@@ -606,7 +637,7 @@ global choice. `--ascii` replaces box drawing and symbols. Colours come only fro
|
||||
|
||||
The CLI equivalent dialog copies its command with `y` (or Enter); F9 › Settings can copy it automatically, and Shift+drag selects it with the terminal.
|
||||
|
||||
F9 › Settings also holds the mouse on/off, single-key shortcuts on/off (default: the server preference
|
||||
F9 › Settings also holds the mouse on/off, the splash on start on/off, single-key shortcuts on/off (default: the server preference
|
||||
`keyboard.singleKeyShortcuts`), the download and upload folders, the PIN and idle lock, and the terminal check.
|
||||
|
||||
## Keys that your terminal may intercept
|
||||
@@ -640,10 +671,11 @@ before any program sees it. `qqq ui --check` (and the quick check at startup) fi
|
||||
```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
|
||||
# --server URL: an isolated config with two profiles, through the start-up picker
|
||||
packages/tui/scripts/mouse-smoke.sh # the mouse in tmux: SGR 1006 clicks, drags and wheel written into the pane
|
||||
packages/tui/scripts/smoke-widths.sh # fixed grid widths across pages (fake large-instance server)
|
||||
packages/tui/scripts/smoke-sql.sh [server] # SQL Runner at 80x24 and 120x36 (default the enterprise server on 8078)
|
||||
packages/tui/scripts/overflow-sweep.sh [server] # every screen and dialog at 80/100/120/200 x 24/50: no text past a border
|
||||
packages/tui/scripts/overflow-sweep.sh [server] # every screen and dialog (the splash, its picker, failure and add form too) at 80/100/120/200 x 24/50: no text past a border
|
||||
packages/tui/scripts/perf-large-table.sh # PRF-001: 10,000 rows x 40 columns, timings against a 2 s budget
|
||||
packages/tui/scripts/soak.sh [server] [minutes] # random keys for 10 min; fails on an exit or an error boundary
|
||||
```
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.4.4",
|
||||
"@types/react": "19.3.0",
|
||||
"typescript": "5.8.3",
|
||||
"typescript": "6.0.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -73,7 +73,7 @@
|
||||
|
||||
"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=="],
|
||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||
|
||||
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
||||
|
||||
|
||||
@@ -18,6 +18,6 @@
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.4.4",
|
||||
"@types/react": "19.3.0",
|
||||
"typescript": "5.8.3"
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -65,9 +65,22 @@ shot() {
|
||||
if [[ $bad == 0 ]]; then echo "ok $name"; else fails=$((fails + 1)); fi
|
||||
}
|
||||
cmd() { keys T:: "T:$1" Enter; }
|
||||
# start_splash NAME CONFIG-JSON: a fresh TUI on its own config, no --server (the start-up splash, picker or add form)
|
||||
start_splash() {
|
||||
tmux kill-session -t "$s" 2>/dev/null || true
|
||||
mkdir -p "$cfg/$1/config"
|
||||
printf '%s\n' "$2" > "$cfg/$1/config/config.json"
|
||||
tmux new-session -d -s "$s" -x 120 -y 36 "cd '$pkg' && QQQ_CONFIG_DIR='$cfg/$1/config' QQQ_STATE_DIR='$cfg/$1/state' QQQ_KEYRING=file QQQ_CACHE_DIR='$cfg/$1/cache' bun run src/main.tsx --no-check; sleep 30"
|
||||
tmux set-option -t "$s" window-size manual > /dev/null
|
||||
}
|
||||
long="$(printf 'Long value %.0s' $(seq 1 30))"
|
||||
shots=0
|
||||
|
||||
# the start-up splash: the picker (two profiles), a failed connection (retry / choose another), the add form (no profile)
|
||||
start_splash picker "{\"profiles\":{\"sweep\":{\"server\":\"$server\",\"description\":\"the sweep server\"},\"other\":{\"server\":\"$server\"}}}"; shot 00a-splash-picker 'Connect to a server' 'Terminal client for QQQ'
|
||||
start_splash failed '{"profiles":{"down":{"server":"http://127.0.0.1:9"}}}'; shot 00b-splash-failed 'Enter retries' 'Terminal client for QQQ'
|
||||
start_splash add '{"profiles":{}}'; shot 00c-splash-add 'Add a server profile'
|
||||
keys Escape; shot 00d-splash-no-profile 'n adds a server profile'
|
||||
start; shot 01-home 'Quick actions' 'Recently viewed'
|
||||
keys T:?; shot 02-help 'Keys'
|
||||
start; keys T:/; shot 03-search 'Search'
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
# packages/tui/scripts/smoke.sh [profile] a profile of your own config
|
||||
# packages/tui/scripts/smoke.sh --server URL [--profile NAME] any server, in an isolated config
|
||||
# (MOCK or anonymous sign-in; your
|
||||
# config, keyring and cache untouched)
|
||||
# config, keyring and cache untouched;
|
||||
# two profiles, so it starts at the
|
||||
# start-up picker and connects with Enter)
|
||||
# OUT=dir WIDTH=160 HEIGHT=45
|
||||
set -euo pipefail
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -26,11 +28,12 @@ done
|
||||
out="${OUT:-$(mktemp -d)}"
|
||||
env_prefix=""
|
||||
if [[ -n "$server" ]]; then
|
||||
# an isolated config with one profile for the server, so the TUI signs in there instead of showing the picker
|
||||
# an isolated config with two profiles: the start-up picker opens with this one (the last used) preselected
|
||||
profile="${profile:-smoke}"
|
||||
cfg="$(mktemp -d)"
|
||||
mkdir -p "$cfg/config"
|
||||
printf '{"profiles":{"%s":{"server":"%s"}},"defaultProfile":"%s"}\n' "$profile" "${server%/}" "$profile" > "$cfg/config/config.json"
|
||||
now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
printf '{"profiles":{"%s":{"server":"%s","lastUsedAt":"%s"},"smoke-other":{"server":"%s","lastUsedAt":"2020-01-01T00:00:00Z"}},"defaultProfile":"smoke-other"}\n' "$profile" "${server%/}" "$now" "${server%/}" > "$cfg/config/config.json"
|
||||
env_prefix="QQQ_CONFIG_DIR='$cfg/config' QQQ_CACHE_DIR='$cfg/cache' QQQ_STATE_DIR='$cfg/state' QQQ_KEYRING=file"
|
||||
fi
|
||||
profile="${profile:-sample}"
|
||||
@@ -54,7 +57,13 @@ expect() { # name text...
|
||||
echo "FAIL $name (wanted: $*)"; fails=$((fails + 1))
|
||||
}
|
||||
echo "smoke: profile $profile${server:+ at $server (isolated config)}; captures in $out"
|
||||
tmux new-session -d -s "$s" -x "$w" -y "$h" "cd '$pkg' && $env_prefix bun run src/main.tsx --no-check --profile '$profile'; sleep 5"
|
||||
if [[ -n "$server" ]]; then
|
||||
tmux new-session -d -s "$s" -x "$w" -y "$h" "cd '$pkg' && $env_prefix bun run src/main.tsx --no-check; sleep 5"
|
||||
expect 00-picker 'Connect to a server' 'Enter connects' 'Esc quits' 'Terminal client for QQQ' "$profile"
|
||||
keys Enter
|
||||
else
|
||||
tmux new-session -d -s "$s" -x "$w" -y "$h" "cd '$pkg' && $env_prefix bun run src/main.tsx --no-check --profile '$profile'; sleep 5"
|
||||
fi
|
||||
expect 01-home 'Apps' 'UI API' 'Recently viewed' 'Quick actions'
|
||||
keys T:: 'T:table query person' Enter
|
||||
expect 02-grid 'records ·' 'First Name'
|
||||
|
||||
@@ -143,6 +143,19 @@ export function App({ store }: { store: AppStore }) {
|
||||
const contentWidth = w - sidebarWidth - assistantWidth
|
||||
const screen = store.screen
|
||||
const top = store.overlays[store.overlays.length - 1]
|
||||
const overlays = store.overlays.map((o, i) => <MouseLayer key={o.id} layer={i + 1}><ErrorBoundary resetKey={o.id} width={Math.min(60, w)} label="Dialog">{o.render(w, h)}</ErrorBoundary></MouseLayer>)
|
||||
if (screen?.kind === 'splash') {
|
||||
// the start-up splash has the whole terminal: no title bar, status line or key bar yet
|
||||
return (
|
||||
<MouseRoot dispatcher={mouse} press={store.pressKey}>
|
||||
<box flexDirection="column" overflow="hidden" width={w} height={h}>
|
||||
<ErrorBoundary resetKey={screen.id} width={w} label="Splash">{screen.render(w, h, !top)}</ErrorBoundary>
|
||||
{overlays}
|
||||
<MouseCapture width={w} height={h} />
|
||||
</box>
|
||||
</MouseRoot>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<MouseRoot dispatcher={mouse} press={store.pressKey}>
|
||||
<box flexDirection="column" overflow="hidden" width={w} height={h}>
|
||||
@@ -161,7 +174,7 @@ export function App({ store }: { store: AppStore }) {
|
||||
<StatusLine store={store} width={w} />
|
||||
{rows === 1 && store.shiftRowShown() ? <ShiftRow store={store} width={w} slots={store.fkeys().length} /> : <FKeyBar store={store} width={w} />}
|
||||
{rows === 2 && <ShiftRow store={store} width={w} slots={store.fkeys().length} />}
|
||||
{store.overlays.map((o, i) => <MouseLayer key={o.id} layer={i + 1}><ErrorBoundary resetKey={o.id} width={Math.min(60, w)} label="Dialog">{o.render(w, h)}</ErrorBoundary></MouseLayer>)}
|
||||
{overlays}
|
||||
{store.lock && <ErrorBoundary key="lock" resetKey="lock" width={w} label="Lock">{store.lock.render(w, h)}</ErrorBoundary>}
|
||||
<MouseCapture width={w} height={h} />
|
||||
</box>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* What `qqq ui` does first, decided from the options, the environment and config.json before
|
||||
* anything is drawn: connect to one profile, ask which profile (several saved, none named), or add
|
||||
* the first one (none saved). Also the Splash setting (`ui.splash`: on unless off) and its timing.
|
||||
*/
|
||||
|
||||
import { readConfig, resolveProfile, writeConfig, type Config } from '@qrun-io/qqq-core'
|
||||
import type { Target } from './store'
|
||||
|
||||
/** The splash stays up at least this long (ms) so it never just flashes. */
|
||||
export const SPLASH_MIN_MS = 600
|
||||
|
||||
export type BootPlan =
|
||||
/** A profile was named (--profile, QQQ_PROFILE, --server, QQQ_SERVER) or only one exists. */
|
||||
| { kind: 'connect'; target: Target }
|
||||
/** Several profiles and none named: the Servers picker, `select` preselected (the last used). */
|
||||
| { kind: 'pick'; select?: string; note?: string }
|
||||
/** No profile at all: the add form. */
|
||||
| { kind: 'add'; note?: string }
|
||||
|
||||
/** Whether a profile was named for this run (an option or the environment). */
|
||||
export function namedProfile(args: { profile?: string; server?: string }, env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return Boolean(args.profile || args.server || env.QQQ_PROFILE || env.QQQ_SERVER)
|
||||
}
|
||||
|
||||
/** The most recently used profile (config lastUsedAt), else the default, else the first. */
|
||||
export function lastUsedProfile(config: Config): string | undefined {
|
||||
const names = Object.keys(config.profiles)
|
||||
let best: string | undefined
|
||||
let bestAt = -Infinity
|
||||
for (const n of names) {
|
||||
const at = Date.parse(config.profiles[n]!.lastUsedAt ?? '')
|
||||
if (!Number.isNaN(at) && at > bestAt) {
|
||||
best = n
|
||||
bestAt = at
|
||||
}
|
||||
}
|
||||
return best ?? (config.defaultProfile && config.profiles[config.defaultProfile] ? config.defaultProfile : names[0])
|
||||
}
|
||||
|
||||
/** Decides the first step of a run (pure: config and environment are passed in by tests). */
|
||||
export function planBoot(args: { profile?: string; server?: string }, env: NodeJS.ProcessEnv = process.env, config: Config = readConfig(env)): BootPlan {
|
||||
const names = Object.keys(config.profiles)
|
||||
if (!namedProfile(args, env) && names.length === 1) return { kind: 'connect', target: { name: names[0]!, server: config.profiles[names[0]!]!.server } }
|
||||
if (namedProfile(args, env)) {
|
||||
try {
|
||||
const r = resolveProfile({ profile: args.profile, server: args.server }, env, config)
|
||||
return { kind: 'connect', target: { name: r.name, server: r.server } }
|
||||
} catch (e) {
|
||||
// a name that does not exist: choose from what there is (or add the first)
|
||||
const note = e instanceof Error ? e.message : String(e)
|
||||
return names.length ? { kind: 'pick', select: lastUsedProfile(config), note } : { kind: 'add', note }
|
||||
}
|
||||
}
|
||||
if (!names.length) return { kind: 'add' }
|
||||
return { kind: 'pick', select: lastUsedProfile(config) }
|
||||
}
|
||||
|
||||
/** The Splash setting: on unless `ui.splash` is off (or false). */
|
||||
export function readSplash(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
try {
|
||||
const v = (readConfig(env).ui as { splash?: unknown } | undefined)?.splash
|
||||
return !(v === false || v === 'off')
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSplash(on: boolean, env: NodeJS.ProcessEnv = process.env): void {
|
||||
const config = readConfig(env)
|
||||
config.ui = { ...config.ui, splash: on ? 'on' : 'off' } as never
|
||||
writeConfig(config, env)
|
||||
}
|
||||
|
||||
/** Whether this run shows the splash: --no-splash (or QQQ_UI_NO_SPLASH=1) and the setting turn it off. */
|
||||
export function splashEnabled(args: { splash?: boolean }, env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
if (args.splash !== undefined) return args.splash
|
||||
if (env.QQQ_UI_NO_SPLASH === '1') return false
|
||||
return readSplash(env)
|
||||
}
|
||||
|
||||
/** How long to wait before leaving the splash, given when it was shown. */
|
||||
export function splashWait(shownAt: number, now: number, minMs = SPLASH_MIN_MS): number {
|
||||
return Math.max(0, shownAt + minMs - now)
|
||||
}
|
||||
|
||||
/** The one line printed when the picker is closed without choosing. */
|
||||
export const PICK_HINT = 'qqq ui: no profile chosen; --profile NAME (or QQQ_PROFILE) skips the picker'
|
||||
|
||||
/** Settings > Splash on start and `:splash on|off`: saved for the next start. */
|
||||
export function setSplashSetting(store: { flash: (text: string) => void; error: (e: unknown) => void }, on: boolean): void {
|
||||
try {
|
||||
writeSplash(on)
|
||||
} catch (e) {
|
||||
store.error(e)
|
||||
return
|
||||
}
|
||||
store.flash(on ? 'splash on (saved): the next qqq ui starts on the splash' : 'splash off (saved): the next qqq ui starts without it (--splash shows it once)')
|
||||
}
|
||||
+42
-10
@@ -5,11 +5,15 @@
|
||||
|
||||
/** Builds the store for a run: settings from options, config and environment; sign-in; the first route. */
|
||||
|
||||
import { readConfig, readTheme, resolveProfile, writeTheme, defaultSecretStore, type SecretStore } from '@qrun-io/qqq-core'
|
||||
import { probeServer, readConfig, readTheme, writeTheme, defaultSecretStore, type SecretStore } from '@qrun-io/qqq-core'
|
||||
import { PICK_HINT, SPLASH_MIN_MS, planBoot, splashEnabled, type BootPlan } from './boot-plan'
|
||||
import { installCommands } from './commands'
|
||||
import { readMouse } from './mouse/settings'
|
||||
import { readFKeyBar } from './keys/shift-bank'
|
||||
import { ProfileFormOverlay, ServersOverlay } from './overlays/servers'
|
||||
import { ServersModel } from './servers'
|
||||
import { SignInScreen, type SignInDeps } from './screens/signin'
|
||||
import { SplashScreen } from './screens/splash'
|
||||
import { Sidebar } from './screens/sidebar'
|
||||
import { AppStore, setSecretStoreFactory, type Target } from './store'
|
||||
import { DEFAULT_THEME, THEMES } from './theme'
|
||||
@@ -22,6 +26,8 @@ export interface CliArgs {
|
||||
linear: boolean
|
||||
/** --mouse / --no-mouse for this run; unset: the saved Mouse setting (on by default). */
|
||||
mouse?: boolean
|
||||
/** --splash / --no-splash for this run; unset: the Splash setting (ui.splash, on by default). */
|
||||
splash?: boolean
|
||||
check: 'startup' | 'full' | 'off'
|
||||
route: string[]
|
||||
}
|
||||
@@ -41,6 +47,8 @@ export function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env):
|
||||
else if (a === '--linear') out.linear = true
|
||||
else if (a === '--mouse') out.mouse = true
|
||||
else if (a === '--no-mouse') out.mouse = false
|
||||
else if (a === '--splash') out.splash = true
|
||||
else if (a === '--no-splash') out.splash = false
|
||||
else if (a === '--check') out.check = 'full'
|
||||
else if (a === '--no-check') out.check = 'off'
|
||||
else out.route.push(a)
|
||||
@@ -55,19 +63,23 @@ export interface BootOptions {
|
||||
secrets?: () => SecretStore
|
||||
fetch?: typeof fetch
|
||||
timers?: boolean
|
||||
/** Shows the splash; unset: --no-splash, QQQ_UI_NO_SPLASH=1 and the Splash setting decide. */
|
||||
splash?: boolean
|
||||
/** The splash's minimum display time; unset: 600 ms with timers (a real run), else none. */
|
||||
splashMinMs?: number
|
||||
/** Prints a line once the terminal is restored (the picker closed without a choice). */
|
||||
hint?: (text: string) => void
|
||||
}
|
||||
|
||||
/** Creates the store and starts sign-in (or connects straight away with a stored sign-in). */
|
||||
/**
|
||||
* Creates the store and starts the boot: the splash (unless switched off), then the profile named
|
||||
* or the only one; with several the Servers picker, with none the add form (boot-plan.ts).
|
||||
*/
|
||||
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)
|
||||
}
|
||||
const plan: BootPlan = planBoot(args)
|
||||
const target: Target | undefined = plan.kind === 'connect' ? plan.target : undefined
|
||||
let note = plan.kind === 'connect' ? undefined : plan.note
|
||||
if (args.theme) {
|
||||
if (THEMES[args.theme]) writeTheme(args.theme)
|
||||
else note = `unknown theme "${args.theme}"; themes: ${Object.keys(THEMES).join(', ')}`
|
||||
@@ -93,9 +105,29 @@ export function boot(options: BootOptions): AppStore {
|
||||
},
|
||||
}
|
||||
store.signInDeps = deps
|
||||
const exit = (hint: string) => {
|
||||
store.quit()
|
||||
if (hint) options.hint?.(hint)
|
||||
}
|
||||
if (options.splash ?? splashEnabled(args)) {
|
||||
const splash = new SplashScreen(store, { plan, deps, exit, minMs: options.splashMinMs ?? (options.timers ? SPLASH_MIN_MS : 0), animate: Boolean(options.timers) })
|
||||
store.push(splash)
|
||||
splash.start()
|
||||
return store
|
||||
}
|
||||
// --no-splash: the sign-in screen, with the same first step
|
||||
const screen = new SignInScreen(store, deps, target, note)
|
||||
store.push(screen)
|
||||
if (target) screen.choose(target)
|
||||
else {
|
||||
const model = new ServersModel({ secrets: secretsFn, probe: (server) => probeServer(server, { fetch: options.fetch }), onChange: () => store.bump() })
|
||||
if (plan.kind === 'pick') store.open(new ServersOverlay(store, (row) => screen.choose({ name: row.name, server: row.server }), model, { title: 'Connect to a server', enterLabel: 'Enter connects', escLabel: 'Esc quits', select: plan.select, onEscape: () => exit(PICK_HINT) }))
|
||||
else store.open(new ProfileFormOverlay(store, model, undefined, (name) => {
|
||||
const p = readConfig().profiles[name]
|
||||
screen.build()
|
||||
if (p) screen.choose({ name, server: p.server })
|
||||
}))
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
|
||||
@@ -210,10 +210,10 @@ export class RecordCollaboration {
|
||||
}
|
||||
|
||||
/** Who is on the record (the actions entry): everyone the server reports, this user marked "(you)". */
|
||||
export function viewingText(everyone: Array<{ name: string; self?: boolean }> | undefined): string {
|
||||
export function viewingText(everyone: Array<{ name: string; self?: boolean; connections?: number }> | undefined): string {
|
||||
if (!everyone) return 'Viewing now: …'
|
||||
if (!everyone.length) return 'Viewing now: no one else'
|
||||
const names = everyone.map((p) => (p.self ? `${p.name} (you)` : p.name))
|
||||
const names = everyone.map((p) => `${p.name}${p.connections && p.connections > 1 ? ` ×${p.connections}` : ''}${p.self ? ' (you)' : ''}`)
|
||||
const others = everyone.filter((p) => !p.self).length
|
||||
return `Viewing now: ${names.slice(0, 3).join(', ')}${names.length > 3 ? ` +${names.length - 3}` : ''}${others ? ` · ${others} other${others === 1 ? '' : 's'}` : ''}`
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { createProgram, runCli } from '@qrun-io/qqq-cli/program'
|
||||
import { setMouseSetting } from './mouse/settings'
|
||||
import { readSplash, setSplashSetting } from './boot-plan'
|
||||
import { THEMES } from './theme'
|
||||
import { metaHelp, processHelp, tableHelp, applyLiveFix, checkEnvironment, formatReport, buildFilter, readFilePreferences, startDirForSave, startDirForUpload, writeFilePreferences, hasPin, readConfig, readRecents, removePin, setPin, writeConfig, writeIdleMinutes, writeTheme, readIdleMinutes, type QueryFilter } from '@qrun-io/qqq-core'
|
||||
import { openApp, openForm, openProcess, openRecord, openTable } from './nav'
|
||||
@@ -54,7 +55,7 @@ const NOUNS: Record<string, string[]> = {
|
||||
apps: [], whoami: [], search: [], 'help-search': [],
|
||||
}
|
||||
|
||||
const TUI_COMMANDS = ['quit', 'home', 'reset', 'tags', 'audits', 'dev', 'actions', 'back', 'help', 'menu', 'goto', 'view', 'theme', 'ascii', 'linear', 'mouse', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'ask', 'dates', 'history', 'fkeys']
|
||||
const TUI_COMMANDS = ['quit', 'home', 'reset', 'tags', 'audits', 'dev', 'actions', 'back', 'help', 'menu', 'goto', 'view', 'theme', 'ascii', 'linear', 'mouse', 'splash', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'ask', 'dates', 'history', 'fkeys']
|
||||
|
||||
/** One line on each TUI command, for the `:` line's completion popup. */
|
||||
export const TUI_COMMAND_HELP: Record<string, string> = {
|
||||
@@ -357,6 +358,7 @@ export class Commands implements CommandsLike {
|
||||
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 'mouse': return setMouseSetting(s, rest[0] === undefined ? !s.settings.mouse : rest[0] !== 'off')
|
||||
case 'splash': return setSplashSetting(s, rest[0] === undefined ? !readSplash() : 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')
|
||||
@@ -764,6 +766,8 @@ export class Commands implements CommandsLike {
|
||||
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({ id: 'settings:mouse', section: 'Settings', label: `Mouse: ${s.settings.mouse ? 'on' : 'off'}`, command: `mouse ${s.settings.mouse ? 'off' : 'on'}`, detail: s.settings.mouse ? 'off restores plain terminal text selection' : undefined, run: () => setMouseSetting(s, !s.settings.mouse) })
|
||||
out.push({ id: 'settings:fkeys', section: 'Settings', label: `F-key bar: ${s.settings.fkeyBar ?? 'auto'}`, command: 'fkeys one|two|auto', detail: describeFKeyBar(s.settings.fkeyBar ?? 'auto'), run: () => s.open(new ChoiceOverlay<FKeyBarMode>(s, 'F-key bar', FKEY_BAR_MODES.map((m) => ({ label: m, value: m, detail: describeFKeyBar(m) })), (m) => setFKeyBar(s, m), 'Enter chooses')) })
|
||||
const splash = readSplash()
|
||||
out.push({ id: 'settings:splash', section: 'Settings', label: `Splash on start: ${splash ? 'on' : 'off'}`, command: `splash ${splash ? 'off' : 'on'}`, detail: 'Query and the boot steps while qqq ui starts', run: () => setSplashSetting(s, !splash) })
|
||||
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
|
||||
|
||||
@@ -30,7 +30,7 @@ function sameAction(command: string | undefined): string | undefined {
|
||||
* `:` commands the TUI does itself (theme, lock, the Servers dialog …): logged as `:…`, re-run on
|
||||
* the `:` line only. Everything else on the `:` line is a CLI command (`qqq …`).
|
||||
*/
|
||||
const TUI_ONLY = new Set(['quit', 'q', 'exit', 'home', 'reset', 'tags', 'audits', 'audit', 'dev', 'developer', 'actions', 'back', 'help', 'menu', 'goto', 'palette', 'view', 'theme', 'ascii', 'linear', 'mouse', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'dates', 'helpkeys', 'views', 'share', 'doctor', 'check', 'history'])
|
||||
const TUI_ONLY = new Set(['quit', 'q', 'exit', 'home', 'reset', 'tags', 'audits', 'audit', 'dev', 'developer', 'actions', 'back', 'help', 'menu', 'goto', 'palette', 'view', 'theme', 'ascii', 'linear', 'mouse', 'splash', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'dates', 'helpkeys', 'views', 'share', 'doctor', 'check', 'history'])
|
||||
|
||||
let last: { action?: string; at: number; words?: string[] } = { at: 0 }
|
||||
|
||||
|
||||
@@ -77,8 +77,14 @@ renderer.on('destroy', () => {
|
||||
setTimeout(() => process.exit(0), 10)
|
||||
})
|
||||
|
||||
// a line for the normal screen once the alternate screen is gone (the picker closed without a choice)
|
||||
let exitHint: string | undefined
|
||||
process.on('exit', () => {
|
||||
if (exitHint) process.stderr.write(`${exitHint}\n`)
|
||||
})
|
||||
|
||||
// QQQ_UI_NO_QUIT=1 (the soak test): quitting from a menu is ignored, so only a crash ends the program
|
||||
const store = boot({ args, quit: process.env.QQQ_UI_NO_QUIT === '1' ? () => store.flash('quit is off (QQQ_UI_NO_QUIT)') : restore, build: process.env.QQQ_UI_BUILD ?? 'dev', timers: true })
|
||||
const store = boot({ args, quit: process.env.QQQ_UI_NO_QUIT === '1' ? () => store.flash('quit is off (QQQ_UI_NO_QUIT)') : restore, build: process.env.QQQ_UI_BUILD ?? 'dev', timers: true, hint: (text) => { exitHint = text } })
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
if (destroyed && isShutdownNoise([reason])) return
|
||||
store.error(reason)
|
||||
|
||||
@@ -33,6 +33,21 @@ function hint(tokens: KeyToken[], width: number): string {
|
||||
/** Command keys while the filter is empty; any other character starts the filter (/ starts it too). */
|
||||
const COMMAND_KEYS = new Set(['n', 'e', 'd', '*', 'r', '/'])
|
||||
|
||||
/** The dialog as the start-up profile picker (over the splash): its title, Enter and Esc, the preselected row. */
|
||||
export interface ServersOverlayOptions {
|
||||
title?: string
|
||||
/** The Enter hint (`Enter connects`). */
|
||||
enterLabel?: string
|
||||
/** The Esc hint (`Esc quits`). */
|
||||
escLabel?: string
|
||||
/** Esc with an empty filter: instead of closing. */
|
||||
onEscape?: () => void
|
||||
/** The profile the cursor starts on (else the one in use). */
|
||||
select?: string
|
||||
/** The frame's first row for a screen height (below the splash's header), and only as tall as its rows need. */
|
||||
top?: (screenHeight: number) => number
|
||||
}
|
||||
|
||||
export class ServersOverlay implements Overlay {
|
||||
readonly id = overlayId('servers')
|
||||
readonly filter = new LineEditor('')
|
||||
@@ -45,9 +60,9 @@ export class ServersOverlay implements Overlay {
|
||||
/** `/` was pressed: every character goes to the filter. */
|
||||
private filtering = false
|
||||
|
||||
constructor(private store: AppStore, private switchTo: (row: ServerRow) => void, model?: ServersModel) {
|
||||
constructor(private store: AppStore, private switchTo: (row: ServerRow) => void, model?: ServersModel, readonly options: ServersOverlayOptions = {}) {
|
||||
this.model = model ?? new ServersModel({ current: store.target?.name, secrets: store.signInDeps?.secrets, probe: (server) => probeServer(server, { fetch: store.signInDeps?.fetch }), onChange: () => store.bump() })
|
||||
const at = this.model.rows.findIndex((r) => r.isCurrent)
|
||||
const at = options.select !== undefined && this.model.rows.some((r) => r.name === options.select) ? this.model.rows.findIndex((r) => r.name === options.select) : this.model.rows.findIndex((r) => r.isCurrent)
|
||||
this.index = Math.max(0, at)
|
||||
void this.model.probeAll()
|
||||
void this.model.readSignIns()
|
||||
@@ -81,7 +96,8 @@ export class ServersOverlay implements Overlay {
|
||||
this.filter.set('')
|
||||
this.filtering = false
|
||||
this.index = 0
|
||||
} else this.store.closeOverlay(this)
|
||||
} else if (this.options.onEscape) this.options.onEscape()
|
||||
else this.store.closeOverlay(this)
|
||||
} else if (key.name === 'down') this.index = Math.min(Math.max(0, 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(Math.max(0, list.length - 1), this.index + 5)
|
||||
@@ -152,7 +168,9 @@ export class ServersOverlay implements Overlay {
|
||||
const t = theme()
|
||||
const g = glyphs()
|
||||
const width = Math.max(30, Math.min(sw - 2, 120))
|
||||
const height = Math.max(10, Math.min(sh - 2, 30))
|
||||
const top = this.options.top ? this.options.top(sh) : 1
|
||||
// under the splash header: as tall as the profiles need, else up to the screen's foot
|
||||
const height = this.options.top ? Math.max(10, Math.min(sh - top - 1, 30, this.model.rows.length * 2 + 6)) : Math.max(10, Math.min(sh - 2, 30))
|
||||
const inner = width - 2
|
||||
const list = this.list()
|
||||
if (this.index >= list.length) this.index = Math.max(0, list.length - 1)
|
||||
@@ -186,12 +204,13 @@ export class ServersOverlay implements Overlay {
|
||||
const more = list.length > visibleRows ? ` ${this.scroll > 0 ? g.up : ' '}${this.scroll + visibleRows < list.length ? g.down : ' '}` : ''
|
||||
const header = ` ${pad('Name', nameW)}${pad('Short', shortW)}${pad('Server', serverW)}${pad('Status', statusW)}${pad('Last used', lastW)}`
|
||||
const commands = !this.filter.value && !this.filtering
|
||||
const enter = this.options.enterLabel ?? 'Enter switches'
|
||||
const keys = commands
|
||||
? [{ text: 'Enter switches' }, { text: 'n add' }, { text: 'e edit' }, { text: 'd remove', drop: 1 }, { text: '* default', drop: 1 }, { text: 'r probe', drop: 3 }, { text: 'type to filter', drop: 2 }, { text: 'Esc closes' }]
|
||||
: [{ text: `${list.length} of ${this.model.rows.length}` }, { text: 'Enter switches' }, { text: 'F7 add', drop: 2 }, { text: 'F4 edit', drop: 2 }, { text: 'F8 remove', drop: 3 }, { text: 'Esc clears' }]
|
||||
? [{ text: enter }, { text: 'n add' }, { text: 'e edit' }, { text: 'd remove', drop: 1 }, { text: '* default', drop: 1 }, { text: 'r probe', drop: 3 }, { text: 'type to filter', drop: 2 }, { text: this.options.escLabel ?? 'Esc closes' }]
|
||||
: [{ text: `${list.length} of ${this.model.rows.length}` }, { text: enter }, { text: 'F7 add', drop: 2 }, { text: 'F4 edit', drop: 2 }, { text: 'F8 remove', drop: 3 }, { text: 'Esc clears' }]
|
||||
const legend = `* default ${g.mark} in use`
|
||||
return (
|
||||
<Frame title="Servers (Ctrl+O)" width={width} height={height} screenWidth={sw} screenHeight={sh} top={1}>
|
||||
<Frame title={this.options.title ?? 'Servers (Ctrl+O)'} width={width} height={height} screenWidth={sw} screenHeight={sh} top={top}>
|
||||
<box paddingLeft={1}><EditorLine editor={this.filter} width={inner - 2} focused={!commands || undefined} placeholder="type to filter by name, short name, description or server · / then any letter" /></box>
|
||||
<Line text={pad(header, inner - more.length) + more} width={inner} fg={t.acc} />
|
||||
{lines}
|
||||
|
||||
@@ -20,6 +20,24 @@ export interface PresencePerson {
|
||||
/** VIEWING or EDITING (the server's mode), and the field being edited when it says. */
|
||||
mode?: string
|
||||
fieldName?: string | null
|
||||
/** Live connections (tabs, terminals) of this person; absent or 1 before collaboration 1.2.0. */
|
||||
connections?: number
|
||||
/** Each connection's mode, field and start when the server sends them (collaboration 1.2.0). */
|
||||
terminals?: PresenceTerminal[]
|
||||
}
|
||||
|
||||
/** One connection of a person: what that tab or terminal does. */
|
||||
export interface PresenceTerminal {
|
||||
mode?: string
|
||||
fieldName?: string | null
|
||||
since?: string
|
||||
/** This terminal. */
|
||||
self?: boolean
|
||||
}
|
||||
|
||||
/** ` ×2` after a person with more than one connection. */
|
||||
export function timesText(p: { connections?: number }): string {
|
||||
return p.connections && p.connections > 1 ? ` ×${p.connections}` : ''
|
||||
}
|
||||
|
||||
export interface Presence {
|
||||
@@ -64,7 +82,7 @@ export function usePresence(scope: string | undefined): Presence {
|
||||
/** 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(', ')
|
||||
const names = p.people.slice(0, 3).map((x) => `${x.name}${timesText(x)}`).join(', ')
|
||||
return `${p.count} other${p.count === 1 ? '' : 's'} viewing${names ? `: ${names}${p.count > 3 ? ', …' : ''}` : ''}`
|
||||
}
|
||||
|
||||
@@ -94,8 +112,13 @@ export function collaborationPresence(registry: ExtensionRegistry, client: TuiCl
|
||||
const entry: { scope: string; handle?: PresenceHandle; value: Presence } = { scope, value: NONE }
|
||||
current = entry
|
||||
collab.joinPresence(kind === 'record' ? { table, pk: pk.join(':') } : { table }, {
|
||||
perConnection: true,
|
||||
onChange: (state) => {
|
||||
const everyone = state.people.map((p) => ({ name: p.name, since: p.since.toISOString(), mode: p.mode, fieldName: p.fieldName, self: p.name === selfName }))
|
||||
const everyone = state.people.map((p) => ({
|
||||
name: p.name, since: p.since.toISOString(), mode: p.mode, fieldName: p.fieldName, self: p.name === selfName,
|
||||
...(p.connections && p.connections > 1 ? { connections: p.connections } : {}),
|
||||
...(p.leases?.length ? { terminals: p.leases.map((l) => ({ mode: l.mode, fieldName: l.fieldName, since: l.since?.toISOString(), ...(l.self ? { self: true } : {}) })) } : {}),
|
||||
}))
|
||||
const people = everyone.filter((p) => !p.self).map(({ self: _self, ...person }) => person)
|
||||
entry.value = { count: people.length, people, everyone }
|
||||
notify()
|
||||
|
||||
@@ -51,6 +51,8 @@ interface Viewer {
|
||||
userId: string
|
||||
displayName: string
|
||||
mode?: string
|
||||
/** Live connections (tabs, terminals) of this user on the table; absent before collaboration 1.2.0. */
|
||||
connections?: number
|
||||
}
|
||||
|
||||
/** Sessions that count as online: active in the last 10 minutes. */
|
||||
@@ -64,11 +66,13 @@ function userLabel(userId: string): string {
|
||||
* Who is online: signed-in sessions from the session store's table when the server has one and it
|
||||
* holds sessions active in the last 10 minutes (own sessions count: "2 sessions · 1 user (you ×2)"),
|
||||
* else this terminal plus everyone seen in collaboration presence across a few tables per refresh
|
||||
* (the saver keeps its lease, so the signed-in viewer is always one of them). Never "nobody around"
|
||||
* while the viewer is signed in.
|
||||
* (the saver keeps its lease, so the signed-in viewer is always one of them). Collaboration 1.2.0
|
||||
* counts connections, so two terminals of one user are "2 online · you ×2"; older servers count each
|
||||
* person once. Never "nobody around" while the viewer is signed in.
|
||||
*/
|
||||
export function presenceSource(ctx: SaverContext): TileSource {
|
||||
const seen = new Map<string, { name: string; table: string; mode?: string; at: number }>()
|
||||
// keyed by user and table: a user's terminals on different tables add up
|
||||
const seen = new Map<string, { userId: string; name: string; table: string; mode?: string; connections?: number; self: boolean; at: number }>()
|
||||
const skip = new Set<string>()
|
||||
let cursor = 0
|
||||
let sessionsGone = false
|
||||
@@ -130,10 +134,9 @@ export function presenceSource(ctx: SaverContext): TileSource {
|
||||
for (const [id, v] of seen) if (v.table === table) seen.delete(id)
|
||||
for (const v of snap.viewers ?? []) {
|
||||
const you = v.userId === self.userId || (Boolean(self.user) && v.displayName === self.user)
|
||||
// this terminal is you (it keeps its lease while the saver shows): not a second person
|
||||
if (you) continue
|
||||
names.set(v.userId, v.displayName || v.userId)
|
||||
seen.set(v.userId, { name: v.displayName || v.userId, table, mode: v.mode, at: t })
|
||||
const connections = typeof v.connections === 'number' && v.connections > 0 ? v.connections : undefined
|
||||
if (!you) names.set(v.userId, v.displayName || v.userId)
|
||||
seen.set(`${v.userId}\u0000${table}`, { userId: v.userId, name: v.displayName || v.userId, table, mode: v.mode, connections, self: you, at: t })
|
||||
}
|
||||
} catch (error) {
|
||||
const status = (error as { status?: number }).status
|
||||
@@ -146,9 +149,21 @@ export function presenceSource(ctx: SaverContext): TileSource {
|
||||
const ttl = Math.ceil(Math.max(1, all.length) / PRESENCE_TABLES_PER_REFRESH) * ctx.intervalMs() + 45_000
|
||||
const t = now()
|
||||
for (const [id, v] of seen) if (t - v.at > ttl) seen.delete(id)
|
||||
const others = [...seen.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||
const total = others.length + (signedIn ? 1 : 0)
|
||||
const parts = [...(signedIn ? ['you'] : []), ...others.map((p) => `${p.name}${p.mode === 'EDITING' ? ' editing' : ''} · ${ctx.tableLabel(p.table)}`)]
|
||||
// one entry per person, their connections summed over the tables they are on; a server
|
||||
// before collaboration 1.2.0 sends no connections, and then each person counts once
|
||||
const people = new Map<string, { name: string; table: string; mode?: string; connections: number; counted: boolean; self: boolean }>()
|
||||
for (const v of seen.values()) {
|
||||
const p = people.get(v.userId)
|
||||
if (!p) people.set(v.userId, { name: v.name, table: v.table, mode: v.mode, connections: v.connections ?? 1, counted: v.connections !== undefined, self: v.self })
|
||||
else if (p.counted && v.connections !== undefined) p.connections += v.connections
|
||||
else p.connections = 1, p.counted = false
|
||||
}
|
||||
const others = [...people.values()].filter((p) => !p.self).sort((a, b) => a.name.localeCompare(b.name))
|
||||
// this terminal is you (it keeps its lease while the saver shows); your other terminals count too
|
||||
const yours = signedIn ? Math.max(1, [...people.values()].filter((p) => p.self).reduce((n, p) => n + p.connections, 0)) : 0
|
||||
const times = (n: number) => (n > 1 ? ` ×${n}` : '')
|
||||
const total = others.reduce((n, p) => n + p.connections, 0) + yours
|
||||
const parts = [...(signedIn ? [`you${times(yours)}`] : []), ...others.map((p) => `${p.name}${times(p.connections)}${p.mode === 'EDITING' ? ' editing' : ''} · ${ctx.tableLabel(p.table)}`)]
|
||||
const detail = parts.length ? `${total} online · ${parts.join(', ')}` : 'nobody around'
|
||||
return { value: String(total), detail, big: String(total), interest: total ? 50 + total : 5 }
|
||||
},
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
import type { TagEntry } from '@qrun-io/qqq-core'
|
||||
import type { CollabSummary } from '../collab'
|
||||
import type { PresencePerson } from '../presence'
|
||||
import { timesText, type PresencePerson } from '../presence'
|
||||
import { ago } from '../servers'
|
||||
import { KEY_SEP, cellWidth, pad, trunc } from '../util/text'
|
||||
|
||||
@@ -77,7 +77,7 @@ export function tagsText(tags: TagEntry[] | undefined): string {
|
||||
}
|
||||
|
||||
/** What a person is doing on the record: "editing email", "editing", "viewing". */
|
||||
export function personState(p: PresencePerson): string {
|
||||
export function personState(p: Pick<PresencePerson, 'mode' | 'fieldName'>): string {
|
||||
return /edit/i.test(p.mode ?? '') ? `editing${p.fieldName ? ` ${p.fieldName}` : ''}` : 'viewing'
|
||||
}
|
||||
|
||||
@@ -92,11 +92,11 @@ function viewingParts(state: CollabState): StripPart[] {
|
||||
const others = othersOf(state)
|
||||
// others (with what they do) take the place of "(you)": the strip is about who else is here
|
||||
if (others.length) {
|
||||
const named = others.slice(0, 2).map((p) => `${p.name} (${personState(p)})`).join(', ')
|
||||
const named = others.slice(0, 2).map((p) => `${p.name}${timesText(p)} (${personState(p)})`).join(', ')
|
||||
return [{ text: 'viewing: ' }, { text: named + (others.length > 2 ? ` +${others.length - 2}` : ''), em: true }]
|
||||
}
|
||||
const self = everyone.find((p) => p.self)
|
||||
return [{ text: 'viewing: ' }, { text: self ? self.name : 'you', em: true }, ...(self ? [{ text: ' (you)' }] : [])]
|
||||
return [{ text: 'viewing: ' }, { text: self ? `${self.name}${timesText(self)}` : 'you', em: true }, ...(self ? [{ text: ' (you)' }] : [])]
|
||||
}
|
||||
|
||||
const count = (n: number | undefined, one: string, many = `${one}s`): StripPart[] => [{ text: n === undefined ? '…' : String(n), em: true }, { text: ` ${n === 1 ? one : many}` }]
|
||||
@@ -193,10 +193,21 @@ export function panelRows(state: CollabState, width: number, height: number, now
|
||||
else if (!everyone.length) add(' no one else', 'dim', 'viewing')
|
||||
// others first, with what they are doing; this user last
|
||||
const people: Array<PresencePerson & { self?: boolean }> = [...othersOf(state), ...(everyone ?? []).filter((x) => x.self)]
|
||||
for (const p of people) add(` ${p.name}${p.self ? ' (you)' : ''} · ${personState(p)}`, p.self ? 'fg' : 'accent', 'viewing')
|
||||
for (const p of people) {
|
||||
add(` ${p.name}${timesText(p)}${p.self ? ' (you)' : ''} · ${personState(p)}`, p.self ? 'fg' : 'accent', 'viewing')
|
||||
// with more than one terminal, what each does (this one first)
|
||||
const terminals = p.terminals && p.terminals.length > 1 ? [...p.terminals].sort((a, b) => Number(Boolean(b.self)) - Number(Boolean(a.self))) : []
|
||||
terminals.forEach((x, i) => add(` ${x.self ? 'this terminal' : `#${i + 1}`} · ${personState(x)}${x.since ? ` · ${hhmm(x.since)}` : ''}`, 'dim', 'viewing'))
|
||||
}
|
||||
return rows.slice(0, Math.max(0, height))
|
||||
}
|
||||
|
||||
/** A lease start as local "17:50". */
|
||||
function hhmm(iso: string): string {
|
||||
const at = new Date(iso)
|
||||
return Number.isNaN(at.getTime()) ? iso : at.toTimeString().slice(0, 5)
|
||||
}
|
||||
|
||||
/** A due date as "3 Nov" (and the year when it is not this one). */
|
||||
function dueText(iso: string): string {
|
||||
const at = new Date(iso)
|
||||
|
||||
@@ -12,7 +12,7 @@ import { actionsOverlay } from '../overlays/actions'
|
||||
import { TagOverlay, collaborationOf } from '../overlays/tags'
|
||||
import { RecordCollaboration, uploadBlocked, viewingText, type CollabSummary } from '../collab'
|
||||
import { CollabScreen, sharePreview, toggleFollow, uploadAttachment, type CollabView } from './collab'
|
||||
import { presenceSource } from '../presence'
|
||||
import { presenceSource, timesText } from '../presence'
|
||||
import { canPrintTable, printScreen } from '../printing'
|
||||
import { AuditScreen } from './audits'
|
||||
import { DeveloperScreen } from './developer'
|
||||
@@ -303,7 +303,9 @@ export class RecordScreen implements Screen {
|
||||
/** Who else is on the record, in a dialog. */
|
||||
showViewers(): void {
|
||||
const viewers = this.viewers()
|
||||
this.store.message(viewers.length ? `Also viewing ${this.title()}:\n${viewers.join('\n')}` : `No one else is viewing ${this.title()}.`, 'info', 'Presence')
|
||||
const self = presenceSource().get(this.presenceScope()).everyone?.find((p) => p.self)
|
||||
const yours = self?.connections && self.connections > 1 ? `\n\nYou have it open in ${self.connections} terminals.` : ''
|
||||
this.store.message((viewers.length ? `Also viewing ${this.title()}:\n${viewers.join('\n')}` : `No one else is viewing ${this.title()}.`) + yours, 'info', 'Presence')
|
||||
}
|
||||
|
||||
build(): void {
|
||||
@@ -644,7 +646,7 @@ export class RecordScreen implements Screen {
|
||||
|
||||
/** The names of the others viewing this record (presence; this client is not counted). */
|
||||
viewers(): string[] {
|
||||
return presenceSource().get(this.presenceScope()).people.map((p) => p.name)
|
||||
return presenceSource().get(this.presenceScope()).people.map((p) => `${p.name}${timesText(p)}`)
|
||||
}
|
||||
|
||||
/** Opens comments, attachments, the timeline or tasks. */
|
||||
|
||||
@@ -22,6 +22,10 @@ export interface SignInDeps {
|
||||
/** Runs after the home screen is shown (the route given on the command line). */
|
||||
afterConnect?: (store: AppStore) => void
|
||||
timers?: boolean
|
||||
/** Boot steps as they happen (the splash's status line): connecting, loading metadata. */
|
||||
progress?: (text: string) => void
|
||||
/** Awaited once connected, before Home replaces the screens (the splash's minimum display time). */
|
||||
beforeHome?: () => Promise<void>
|
||||
}
|
||||
|
||||
/** Connects the store to a profile: the stored sign-in, a client, metadata; then the home screen. */
|
||||
@@ -57,7 +61,9 @@ export async function connect(store: AppStore, target: Target, deps: SignInDeps)
|
||||
store.status.auth = 'QQQ_TOKEN'
|
||||
}
|
||||
try {
|
||||
deps.progress?.('loading metadata…')
|
||||
await store.connect(client, { timers: deps.timers })
|
||||
deps.progress?.(`loaded metadata (${Object.keys(store.meta?.tables ?? {}).length} tables)`)
|
||||
} catch (e) {
|
||||
if (e instanceof UiApiError && (e.status === 401 || e.status === 403)) throw new NeedsSignIn('your sign-in expired; sign in again')
|
||||
throw e
|
||||
@@ -69,6 +75,7 @@ export async function connect(store: AppStore, target: Target, deps: SignInDeps)
|
||||
// config.json unreadable: not critical here
|
||||
}
|
||||
history.record(store, { kind: 'auth', summary: `Signed in to ${target.server}${store.status.user ? ` as ${store.status.user}` : ''} (${store.status.auth})` })
|
||||
await deps.beforeHome?.()
|
||||
while (store.routes.length) store.routes.pop()?.dispose?.()
|
||||
store.push(new HomeScreen(store))
|
||||
deps.afterConnect?.(store)
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The start-up splash of `qqq ui`: Query (the qrun.io mascot) blinking and wagging while the TUI
|
||||
* boots, the `qqq` wordmark, the version, and one status line that follows the boot steps (reading
|
||||
* profiles, connecting, signing in, loading metadata). It drives the boot (boot-plan.ts decides the
|
||||
* first step): with several profiles and none named the Servers picker opens over it; with none the
|
||||
* add form; a failure stays on the splash with Enter retrying and c choosing another profile.
|
||||
* Any key stops the animation (not the boot); --no-splash and `ui.splash: off` skip the splash.
|
||||
*/
|
||||
|
||||
import { useEffect, useReducer } from 'react'
|
||||
import { UI_API_VERSION } from '@qrun-io/qqq-ui-api-client'
|
||||
import { CLI_VERSION } from '@qrun-io/qqq-cli/program'
|
||||
import { probeServer, readConfig } from '@qrun-io/qqq-core'
|
||||
import { PICK_HINT, splashWait, type BootPlan } from '../boot-plan'
|
||||
import type { Key } from '../keys'
|
||||
import { charOf } from '../keys'
|
||||
import { ProfileFormOverlay, ServersOverlay } from '../overlays/servers'
|
||||
import { blockText, blockWidth } from '../saver/font'
|
||||
import { queryFrame, querySize } from '../saver/query-art'
|
||||
import { ServersModel } from '../servers'
|
||||
import type { AppStore, Target } from '../store'
|
||||
import { isAscii, theme, type ThemeTokens } from '../theme'
|
||||
import type { FKeyLabels, Screen } from '../types'
|
||||
import { cellWidth, errorText, trunc } from '../util/text'
|
||||
import { NeedsSignIn, SignInScreen, connect, type SignInDeps } from './signin'
|
||||
|
||||
export type SplashPhase = 'starting' | 'picking' | 'adding' | 'connecting' | 'failed' | 'done'
|
||||
|
||||
/** The colour a piece of the splash is drawn in (a theme token). */
|
||||
export type SplashTone = 'art' | 'mark' | 'fg' | 'dim' | 'status' | 'err' | 'key'
|
||||
|
||||
export interface SplashSeg {
|
||||
text: string
|
||||
tone: SplashTone
|
||||
}
|
||||
|
||||
export const SPLASH_TAGLINE = 'Terminal client for QQQ'
|
||||
|
||||
/** `qqq 1.1.0 · UI API client 1.10.0` */
|
||||
export function versionLine(): string {
|
||||
return `qqq ${CLI_VERSION} · UI API client ${UI_API_VERSION}`
|
||||
}
|
||||
|
||||
/** Query's pose at a moment: a blink every 3 s; while the boot works, the tail wags (his wave). */
|
||||
export function splashPose(now: number, busy: boolean): { blink: boolean; wag: boolean } {
|
||||
return { blink: now % 3000 < 200, wag: busy && Math.floor(now / 300) % 2 === 1 }
|
||||
}
|
||||
|
||||
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']
|
||||
const SPINNER_ASCII = ['|', '/', '-', '\\']
|
||||
|
||||
export interface SplashLayoutInput {
|
||||
ascii: boolean
|
||||
pose: { blink: boolean; wag: boolean }
|
||||
status: SplashSeg
|
||||
/** Lines under the status (a sign-in address, a device code). */
|
||||
extra: SplashSeg[]
|
||||
/** The key hint line, or ''. */
|
||||
keys: string
|
||||
/** `top`: the header only (the picker is drawn below it), from `headerTop`, keeping `reserve` rows under it. */
|
||||
anchor: 'center' | 'top'
|
||||
reserve?: number
|
||||
headerTop?: number
|
||||
}
|
||||
|
||||
/** Rows of the large header (Query, gap, wordmark, gap, tagline, version) and of the compact one. */
|
||||
const LARGE_HEADER = querySize('large').height + 9
|
||||
const COMPACT_HEADER = querySize('small').height + 1
|
||||
|
||||
export type SplashLine = SplashSeg[]
|
||||
|
||||
/** Which composition fits with `rowsBelow` rows under it: the large Query over the wordmark, the small one beside it, or text. */
|
||||
export function splashTier(width: number, height: number, rowsBelow: number): 'large' | 'compact' | 'text' {
|
||||
// a free row to spare, so a header and the picker under it never meet the screen's foot
|
||||
if (width >= 40 && height > LARGE_HEADER + rowsBelow) return 'large'
|
||||
if (width >= 40 && height > COMPACT_HEADER + rowsBelow) return 'compact'
|
||||
return 'text'
|
||||
}
|
||||
|
||||
/**
|
||||
* The splash as lines of coloured pieces, centred in `width` and clipped to `width` × `height`
|
||||
* (pure: tests read it at 80×24 and in ASCII). Blank lines are empty arrays.
|
||||
*/
|
||||
export function splashLayout(width: number, height: number, input: SplashLayoutInput): SplashLine[] {
|
||||
const below: SplashLine[] = [[{ ...input.status }], ...input.extra.map((s) => [{ ...s }]), ...(input.keys ? [[{ text: input.keys, tone: 'key' as const }]] : [])]
|
||||
const header = input.anchor === 'top'
|
||||
const rowsBelow = header ? input.reserve ?? 0 : below.length + 1
|
||||
const tier = splashTier(width, height, rowsBelow)
|
||||
const mark = blockText('qqq', input.ascii)
|
||||
const lines: SplashLine[] = []
|
||||
if (tier === 'large') {
|
||||
for (const row of queryFrame('large', input.pose, input.ascii)) lines.push([{ text: row, tone: 'art' }])
|
||||
lines.push([])
|
||||
for (const row of mark) lines.push([{ text: row, tone: 'mark' }])
|
||||
lines.push([])
|
||||
lines.push([{ text: SPLASH_TAGLINE, tone: 'fg' }])
|
||||
lines.push([{ text: versionLine(), tone: 'dim' }])
|
||||
} else if (tier === 'compact') {
|
||||
// the small Query beside the wordmark, the tagline under the wordmark
|
||||
const art = queryFrame('small', input.pose, input.ascii)
|
||||
const right = Math.max(blockWidth('qqq'), SPLASH_TAGLINE.length)
|
||||
// the wordmark and the tagline (a row apart) centred against Query's height
|
||||
const from = Math.max(0, Math.floor((art.length - mark.length - 2) / 2))
|
||||
const taglineRow = Math.min(art.length - 1, from + mark.length + 1)
|
||||
art.forEach((row, i) => {
|
||||
const inMark = i >= from && i < from + mark.length
|
||||
const text = inMark ? mark[i - from]! : i === taglineRow ? SPLASH_TAGLINE : ''
|
||||
lines.push([{ text: row, tone: 'art' }, { text: ' ', tone: 'fg' }, { text: text.padEnd(right), tone: inMark ? 'mark' : 'fg' }])
|
||||
})
|
||||
lines.push([{ text: versionLine(), tone: 'dim' }])
|
||||
} else {
|
||||
if (width >= blockWidth('qqq') && height >= 7 + rowsBelow) for (const row of mark) lines.push([{ text: row, tone: 'mark' }])
|
||||
else lines.push([{ text: 'qqq', tone: 'mark' }])
|
||||
lines.push([{ text: SPLASH_TAGLINE, tone: 'fg' }])
|
||||
lines.push([{ text: versionLine(), tone: 'dim' }])
|
||||
}
|
||||
if (!header) {
|
||||
lines.push([])
|
||||
lines.push(...below)
|
||||
}
|
||||
// the ASCII glyph set (no UTF-8 locale): the middle dot and the ellipsis too
|
||||
if (input.ascii) for (const line of lines) for (const seg of line) seg.text = seg.text.replace(/·/g, '-').replace(/…/g, '...')
|
||||
const shown = lines.slice(0, Math.max(0, height))
|
||||
const top = header ? input.headerTop ?? 1 : Math.max(0, Math.floor((height - shown.length) / 2))
|
||||
const out: SplashLine[] = Array.from({ length: Math.min(top, height) }, () => [])
|
||||
for (const line of shown) {
|
||||
if (out.length >= height) break
|
||||
out.push(centerSegs(line, width))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Centres a line of pieces in `width` cells, cutting the last pieces with an ellipsis when it is wider. */
|
||||
export function centerSegs(line: SplashLine, width: number): SplashLine {
|
||||
const total = line.reduce((w, s) => w + cellWidth(s.text), 0)
|
||||
if (total <= width) {
|
||||
const left = Math.floor((width - total) / 2)
|
||||
return left > 0 ? [{ text: ' '.repeat(left), tone: 'fg' }, ...line] : line
|
||||
}
|
||||
const out: SplashLine = []
|
||||
let room = width
|
||||
for (const s of line) {
|
||||
if (room <= 0) break
|
||||
const w = cellWidth(s.text)
|
||||
if (w <= room) {
|
||||
out.push(s)
|
||||
room -= w
|
||||
} else {
|
||||
out.push({ text: trunc(s.text, room), tone: s.tone })
|
||||
room = 0
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The plain text of a laid-out splash (tests, the overflow sweep). */
|
||||
export function splashText(lines: SplashLine[]): string {
|
||||
return lines.map((l) => l.map((s) => s.text).join('')).join('\n')
|
||||
}
|
||||
|
||||
function toneColor(t: ThemeTokens, tone: SplashTone): string {
|
||||
return tone === 'art' ? t.acc : tone === 'mark' ? t.acc2 : tone === 'dim' || tone === 'key' ? t.dim : tone === 'err' ? t.err : tone === 'status' ? t.acc : t.fg
|
||||
}
|
||||
|
||||
export interface SplashOptions {
|
||||
plan: BootPlan
|
||||
/** How to connect and sign in (the store's signInDeps). */
|
||||
deps: SignInDeps
|
||||
/** Ends the program and prints a line (the picker closed without a choice). */
|
||||
exit: (hint: string) => void
|
||||
/** The splash stays at least this long (0 in tests). */
|
||||
minMs: number
|
||||
/** Animate Query (off in tests: one still frame). */
|
||||
animate: boolean
|
||||
}
|
||||
|
||||
export class SplashScreen implements Screen {
|
||||
readonly kind = 'splash'
|
||||
readonly id = 'splash'
|
||||
phase: SplashPhase = 'starting'
|
||||
status = 'reading profiles…'
|
||||
statusKind: 'info' | 'error' = 'info'
|
||||
/** The profile being connected to (Enter retries it after a failure). */
|
||||
target?: Target
|
||||
/** Query moves until a key is pressed. */
|
||||
animate: boolean
|
||||
readonly shownAt = Date.now()
|
||||
/** The sign-in itself (prompts, device code, browser address) is the sign-in screen's, unshown. */
|
||||
readonly signIn: SignInScreen
|
||||
private signingIn = false
|
||||
/** The picker's profiles (its height follows them). */
|
||||
private pickerModel?: ServersModel
|
||||
|
||||
constructor(readonly store: AppStore, readonly options: SplashOptions) {
|
||||
this.animate = options.animate
|
||||
this.signIn = new SignInScreen(store, options.deps)
|
||||
}
|
||||
|
||||
title(): string {
|
||||
return 'qqq'
|
||||
}
|
||||
|
||||
fkeys(): FKeyLabels {
|
||||
return {}
|
||||
}
|
||||
|
||||
/** Starts the boot plan. */
|
||||
start(): void {
|
||||
const plan = this.options.plan
|
||||
if (plan.kind === 'connect') this.connectTo(plan.target)
|
||||
else if (plan.kind === 'pick') this.pick(plan.note, false)
|
||||
else this.add(plan.note)
|
||||
}
|
||||
|
||||
private say(text: string, kind: 'info' | 'error' = 'info'): void {
|
||||
this.status = text
|
||||
this.statusKind = kind
|
||||
this.store.bump()
|
||||
}
|
||||
|
||||
private model(): ServersModel {
|
||||
const deps = this.options.deps
|
||||
return new ServersModel({ secrets: deps.secrets, probe: (server) => probeServer(server, { fetch: deps.fetch }), onChange: () => this.store.bump() })
|
||||
}
|
||||
|
||||
/** The Servers picker over the splash; Esc quits (or, after a failure, returns to it). */
|
||||
pick(note?: string, fromFailure = this.phase === 'failed'): void {
|
||||
const model = this.model()
|
||||
if (!model.rows.length) return this.add(note)
|
||||
this.pickerModel = model
|
||||
const before = { phase: this.phase, status: this.status, kind: this.statusKind }
|
||||
this.phase = 'picking'
|
||||
this.say(note ?? 'choose a server to connect to', note ? 'error' : 'info')
|
||||
const select = this.target?.name ?? (this.options.plan.kind === 'pick' ? this.options.plan.select : undefined)
|
||||
const overlay: ServersOverlay = new ServersOverlay(this.store, (row) => this.connectTo({ name: row.name, server: row.server }), model, {
|
||||
title: 'Connect to a server',
|
||||
enterLabel: 'Enter connects',
|
||||
escLabel: fromFailure ? 'Esc goes back' : 'Esc quits',
|
||||
select,
|
||||
top: (sh) => this.pickerPlace(sh).pickerTop,
|
||||
onEscape: () => {
|
||||
this.store.closeOverlay(overlay)
|
||||
if (!fromFailure) return this.options.exit(PICK_HINT)
|
||||
this.phase = before.phase
|
||||
this.say(before.status, before.kind)
|
||||
},
|
||||
})
|
||||
this.store.open(overlay)
|
||||
}
|
||||
|
||||
/** The picker's rows: two per profile and six of frame, filter, header and hints (the Servers dialog's own sum). */
|
||||
private pickerHeight(): number {
|
||||
return Math.max(10, Math.min(30, (this.pickerModel?.rows.length ?? 2) * 2 + 6))
|
||||
}
|
||||
|
||||
/**
|
||||
* The header and the picker under it, centred together; a terminal too short for both gets the
|
||||
* picker near the top, over the header.
|
||||
*/
|
||||
pickerPlace(screenHeight: number): { headerTop: number; pickerTop: number } {
|
||||
const pickerHeight = this.pickerHeight()
|
||||
const header = splashLayout(this.store.width, screenHeight, { ...this.layoutInput('top'), reserve: pickerHeight + 1, headerTop: 0 }).length
|
||||
const total = header + 1 + pickerHeight
|
||||
if (total > screenHeight - 1) return { headerTop: 1, pickerTop: 1 }
|
||||
const top = Math.max(0, Math.floor((screenHeight - total) / 2))
|
||||
return { headerTop: top, pickerTop: top + header + 1 }
|
||||
}
|
||||
|
||||
/** What to lay out at a height: the centred splash, or the header above the picker. */
|
||||
inputFor(height: number, now = Date.now()): SplashLayoutInput {
|
||||
if (!(this.phase === 'picking' && this.store.overlays.length)) return this.layoutInput('center', now)
|
||||
const place = this.pickerPlace(height)
|
||||
return { ...this.layoutInput('top', now), reserve: this.pickerHeight() + 1, headerTop: place.headerTop }
|
||||
}
|
||||
|
||||
/** The add form (no profile yet, or `n`); a saved profile is connected to at once. */
|
||||
add(note?: string): void {
|
||||
this.phase = 'adding'
|
||||
this.say(note ?? 'no server profile yet: add one to connect', note ? 'error' : 'info')
|
||||
this.store.open(new ProfileFormOverlay(this.store, this.model(), undefined, (name) => {
|
||||
const p = readConfig().profiles[name]
|
||||
if (p) this.connectTo({ name, server: p.server })
|
||||
}))
|
||||
}
|
||||
|
||||
/** Connects to a profile (signing in when there is no stored sign-in); Home replaces the splash. */
|
||||
connectTo(target: Target): void {
|
||||
if (this.phase === 'connecting') return
|
||||
this.phase = 'connecting'
|
||||
this.target = target
|
||||
const where = target.server.replace(/^https?:\/\//, '')
|
||||
this.say(`connecting to ${where}…`)
|
||||
const deps: SignInDeps = {
|
||||
...this.options.deps,
|
||||
progress: (text) => this.say(text),
|
||||
beforeHome: () => new Promise((resolve) => setTimeout(resolve, splashWait(this.shownAt, Date.now(), this.options.minMs))),
|
||||
}
|
||||
const run = async () => {
|
||||
try {
|
||||
await connect(this.store, target, deps)
|
||||
} catch (e) {
|
||||
if (!(e instanceof NeedsSignIn)) throw e
|
||||
this.signingIn = true
|
||||
this.signIn.status = ''
|
||||
this.say(`signing in to ${where}…`)
|
||||
await this.signIn.signIn(target)
|
||||
this.signingIn = false
|
||||
this.say(`connecting to ${where}…`)
|
||||
await connect(this.store, target, deps)
|
||||
}
|
||||
}
|
||||
run().then(() => {
|
||||
this.phase = 'done'
|
||||
}).catch((e) => {
|
||||
this.phase = 'failed'
|
||||
this.say(errorText(e), 'error')
|
||||
}).finally(() => {
|
||||
this.signingIn = false
|
||||
this.signIn.device = undefined
|
||||
this.signIn.browserUrl = undefined
|
||||
this.store.bump()
|
||||
})
|
||||
}
|
||||
|
||||
/** Every key is the splash's (no F-keys, menu or palette before a server is connected). */
|
||||
claimsKey(): boolean {
|
||||
return true
|
||||
}
|
||||
|
||||
onKey(key: Key): boolean {
|
||||
this.animate = false
|
||||
const ch = charOf(key).toLowerCase()
|
||||
if (key.name === 'escape') {
|
||||
if (this.phase === 'connecting') this.signIn.onKey(key)
|
||||
else if (this.phase === 'failed' || this.phase === 'adding' || this.phase === 'starting') this.options.exit(this.phase === 'failed' ? `qqq ui: ${this.status}` : PICK_HINT)
|
||||
} else if (key.name === 'f10' || (key.ctrl && key.name === 'q')) this.options.exit('')
|
||||
else if (this.phase === 'failed' && (key.name === 'return' || ch === 'r') && this.target) this.connectTo(this.target)
|
||||
else if (this.phase !== 'connecting' && (ch === 'c' || ch === 'p' || (key.ctrl && key.name === 'o'))) this.pick()
|
||||
else if (this.phase !== 'connecting' && ch === 'n') this.add()
|
||||
this.store.bump()
|
||||
return true
|
||||
}
|
||||
|
||||
/** The status line: the sign-in's own words while it runs (creating a session, waiting for the browser). */
|
||||
private statusSeg(now: number): SplashSeg {
|
||||
const text = this.signingIn && this.signIn.status ? this.signIn.status : this.status
|
||||
if (this.statusKind === 'error') return { text, tone: 'err' }
|
||||
const busy = this.phase === 'connecting' || this.phase === 'starting'
|
||||
if (!busy) return { text, tone: 'status' }
|
||||
const frames = isAscii() ? SPINNER_ASCII : SPINNER
|
||||
const spin = this.animate ? frames[Math.floor(now / 100) % frames.length]! : isAscii() ? '*' : '·'
|
||||
return { text: `${spin} ${text}`, tone: 'status' }
|
||||
}
|
||||
|
||||
private extra(): SplashSeg[] {
|
||||
const s = this.signIn
|
||||
if (s.browserUrl) return [{ text: 'Sign in in your browser (opened); if it did not open, visit:', tone: 'fg' }, { text: s.browserUrl, tone: 'fg' }]
|
||||
if (s.device) {
|
||||
const left = Math.max(0, Math.round((s.device.expires - Date.now()) / 1000))
|
||||
return [{ text: `Open ${s.device.url}`, tone: 'fg' }, { text: `and confirm the code ${s.device.code} (expires in ${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')})`, tone: 'fg' }]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private keys(): string {
|
||||
if (this.store.overlays.length) return ''
|
||||
if (this.phase === 'failed') return 'Enter retries · c chooses another profile · n adds one · Esc quits'
|
||||
if (this.phase === 'adding') return 'n adds a server profile · Esc quits'
|
||||
if (this.phase === 'connecting' && (this.signIn.browserUrl || this.signIn.device)) return 'Esc cancels the sign-in'
|
||||
return ''
|
||||
}
|
||||
|
||||
layoutInput(anchor: 'center' | 'top', now = Date.now()): SplashLayoutInput {
|
||||
const busy = this.phase === 'connecting' || this.phase === 'starting'
|
||||
return { ascii: isAscii(), pose: this.animate ? splashPose(now, busy) : { blink: false, wag: false }, status: this.statusSeg(now), extra: this.extra(), keys: this.keys(), anchor }
|
||||
}
|
||||
|
||||
/** The text as drawn now (tests). */
|
||||
text(width: number, height: number): string {
|
||||
return splashText(splashLayout(width, height, this.inputFor(height)))
|
||||
}
|
||||
|
||||
render(width: number, height: number) {
|
||||
return <SplashView splash={this} width={width} height={height} />
|
||||
}
|
||||
}
|
||||
|
||||
/** Repaints itself at about 10 fps while Query moves (only this component). */
|
||||
function SplashView({ splash, width, height }: { splash: SplashScreen; width: number; height: number }) {
|
||||
const [, tick] = useReducer((n: number) => n + 1, 0)
|
||||
const moving = splash.animate && splash.phase !== 'done'
|
||||
useEffect(() => {
|
||||
if (!moving) return
|
||||
const timer = setInterval(tick, 100)
|
||||
return () => clearInterval(timer)
|
||||
}, [moving])
|
||||
const t = theme()
|
||||
const lines = splashLayout(width, height, splash.inputFor(height))
|
||||
return (
|
||||
<box flexDirection="column" overflow="hidden" width={width} height={height}>
|
||||
{lines.map((line, i) => (
|
||||
<text key={i} flexShrink={0} wrapMode="none" height={1}>
|
||||
{line.length ? line.map((s, k) => <span key={k} fg={toneColor(t, s.tone)}>{s.text}</span>) : ' '}
|
||||
</text>
|
||||
))}
|
||||
</box>
|
||||
)
|
||||
}
|
||||
@@ -243,6 +243,7 @@ test('collaboration: who is viewing (this user marked) and comment lines', () =>
|
||||
expect(viewingText([{ name: 'Admin', self: true }])).toBe('Viewing now: Admin (you)')
|
||||
expect(viewingText([{ name: 'Tester', self: true }, { name: 'Pat Smith' }])).toBe('Viewing now: Tester (you), Pat Smith · 1 other')
|
||||
expect(viewingText(undefined)).toBe('Viewing now: …')
|
||||
expect(viewingText([{ name: 'Admin', self: true, connections: 2 }, { name: 'Pat Smith', connections: 1 }])).toBe('Viewing now: Admin ×2 (you), Pat Smith · 1 other')
|
||||
expect(commentLines({ authorDisplayName: 'Pat', body: 'hi @[Lee](user:7)', createdAt: undefined, replies: [{ authorDisplayName: 'Lee', body: 'ok' }] }, () => 'now')).toEqual(['Pat · now: hi @Lee', ' ↳ Lee · now: ok'])
|
||||
})
|
||||
|
||||
|
||||
@@ -50,7 +50,8 @@ export interface Harness {
|
||||
/** Renders the whole TUI against the fake server and waits for the home screen. */
|
||||
export async function startTui(width = 120, height = 36, options: { metaData?: unknown; auth?: FakeAuth; secrets?: () => SecretStore; until?: 'home' | 'signin' } = {}): Promise<Harness> {
|
||||
const log: FakeLog = { requests: [] }
|
||||
const store = boot({ args: parseArgs(['--profile', 'fake']), quit: () => undefined, build: 'test', secrets: options.secrets ?? sessionSecrets, fetch: fakeFetch(log, options.metaData, options.auth) })
|
||||
// the sign-in tests drive the sign-in screen (--no-splash); the rest go through the splash
|
||||
const store = boot({ args: parseArgs(['--profile', 'fake', ...(options.until === 'signin' ? ['--no-splash'] : [])]), quit: () => undefined, build: 'test', secrets: options.secrets ?? sessionSecrets, fetch: fakeFetch(log, options.metaData, options.auth) })
|
||||
const setup = await testRender(<App store={store} />, { width, height })
|
||||
const h: Harness = { setup, store, log, frame: () => setup.captureCharFrame() }
|
||||
store.captureScreen = () => setup.captureCharFrame()
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import { expect, test } from 'bun:test'
|
||||
import { ExtensionRegistry, opaqueConnectionId } from '@qrun-io/qqq-core'
|
||||
import { collaborationPresence, presenceText } from '../src/presence'
|
||||
|
||||
const registry = ExtensionRegistry.fromVersion({ extensions: [{ name: 'collaboration', version: '1.2.0' }] })
|
||||
|
||||
/** A client stub answering presence calls; records the GET queries. */
|
||||
function client(answer: (query: Record<string, string | undefined>, own: string | undefined) => unknown) {
|
||||
const queries: Array<Record<string, string | undefined>> = []
|
||||
let own: string | undefined
|
||||
return {
|
||||
queries,
|
||||
call: async (id: string, input: { query?: Record<string, string | undefined>; json?: { connectionId?: string } }) => {
|
||||
if (input.json?.connectionId) own = input.json.connectionId
|
||||
if (id === 'getQbitCollaborationPresence') queries.push(input.query ?? {})
|
||||
const data = id === 'getQbitCollaborationPresence' ? answer(input.query ?? {}, own) : id === 'deleteQbitCollaborationPresence' ? { success: true } : { table: 'person', pk: '7', viewers: [] }
|
||||
return { status: 200, headers: new Headers(), data, contentType: 'application/json' }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function until(condition: () => boolean): Promise<void> {
|
||||
for (let i = 0; i < 500 && !condition(); i++) await new Promise((r) => setTimeout(r, 2))
|
||||
}
|
||||
|
||||
test('the record presence asks per connection: "Admin ×2", this terminal marked among the terminals', async () => {
|
||||
const c = client((query, own) => ({
|
||||
table: 'person', pk: '7', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 2 }, { userId: 'pat', displayName: 'Pat', mode: 'VIEWING', connections: 2 }], leaseCount: 4,
|
||||
...(query.perConnection === 'true' ? { leases: [
|
||||
{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING', connections: 1, connectionId: opaqueConnectionId('admin', own ?? ''), since: '2026-11-01T12:00:00Z' },
|
||||
{ userId: 'admin', displayName: 'Admin', mode: 'EDITING', fieldName: 'name', connections: 1, connectionId: 'cffff', since: '2026-11-01T12:00:01Z' },
|
||||
] } : {}),
|
||||
}))
|
||||
const source = collaborationPresence(registry, c as never, 'Admin')!
|
||||
source.enter!('record:person:7')
|
||||
await until(() => Boolean(source.get('record:person:7').everyone?.length))
|
||||
const p = source.get('record:person:7')
|
||||
expect(c.queries[0]!.perConnection).toBe('true')
|
||||
const admin = p.everyone!.find((x) => x.self)!
|
||||
expect(admin.connections).toBe(2)
|
||||
expect(admin.terminals!.map((t) => [t.mode, t.self ?? false])).toEqual([['VIEWING', true], ['EDITING', false]])
|
||||
expect(presenceText(p)).toBe('1 other viewing: Pat ×2')
|
||||
source.enter!(undefined)
|
||||
})
|
||||
|
||||
test('a server without connections (collaboration 1.1.0) still shows everyone, once each', async () => {
|
||||
const c = client(() => ({ table: 'person', pk: '7', viewers: [{ userId: 'admin', displayName: 'Admin', mode: 'VIEWING' }, { userId: 'pat', displayName: 'Pat', mode: 'EDITING' }] }))
|
||||
const source = collaborationPresence(registry, c as never, 'Admin')!
|
||||
source.enter!('record:person:7')
|
||||
await until(() => Boolean(source.get('record:person:7').everyone?.length))
|
||||
const p = source.get('record:person:7')
|
||||
expect(p.everyone!.map((x) => [x.name, x.connections, x.terminals])).toEqual([['Admin', undefined, undefined], ['Pat', undefined, undefined]])
|
||||
expect(presenceText(p)).toBe('1 other viewing: Pat')
|
||||
source.enter!(undefined)
|
||||
})
|
||||
@@ -93,3 +93,29 @@ test('panel rows: tags, latest two comments, tasks with the next due, files, fol
|
||||
expect(rows.filter((r) => r.key === 'C').length).toBe(5)
|
||||
expect(panelRows(busy, PANEL_WIDTH - 1, 5)).toHaveLength(5)
|
||||
})
|
||||
|
||||
test('two terminals of one user: "×2" on the strip and in the panel, each terminal with its mode and start', () => {
|
||||
const since = '2026-11-01T12:00:05Z'
|
||||
const local = new Date(since).toTimeString().slice(0, 5)
|
||||
const twice: CollabState = {
|
||||
...alone,
|
||||
everyone: [
|
||||
{ name: 'Admin', self: true, mode: 'EDITING', fieldName: 'name', connections: 2, terminals: [{ mode: 'EDITING', fieldName: 'name', since }, { mode: 'VIEWING', self: true }] },
|
||||
{ name: 'Pat Smith', mode: 'VIEWING', connections: 3 },
|
||||
],
|
||||
}
|
||||
expect(text(twice, 200)).toContain('viewing: Pat Smith ×3 (viewing)')
|
||||
expect(text({ ...alone, everyone: [twice.everyone![0]!] }, 200).trimEnd()).toEndWith('viewing: Admin ×2 (you)')
|
||||
const lines = panelRows(twice, PANEL_WIDTH - 1, 40).map((r) => r.text.trimEnd())
|
||||
expect(lines.slice(lines.indexOf(' Viewing now'))).toEqual([
|
||||
' Viewing now',
|
||||
' Pat Smith ×3 · viewing',
|
||||
' Admin ×2 (you) · editing name',
|
||||
' this terminal · viewing',
|
||||
` #2 · editing name · ${local}`,
|
||||
])
|
||||
// a server before collaboration 1.2.0 sends no connections: no "×", no terminal lines
|
||||
const old = panelRows(busy, PANEL_WIDTH - 1, 40).map((r) => r.text.trimEnd())
|
||||
expect(old.join('\n')).not.toContain('×')
|
||||
expect(old.join('\n')).not.toContain('this terminal')
|
||||
})
|
||||
@@ -134,6 +134,22 @@ describe('sources', () => {
|
||||
expect((await s.load()).value).toBe('1')
|
||||
})
|
||||
|
||||
test('presence counts connections: two terminals of one user are 2 online; older servers count each person once', async () => {
|
||||
const viewers: Record<string, Array<{ userId: string; displayName: string; mode: string; connections?: number }>> = {
|
||||
person: [{ userId: 'core:me', displayName: 'Me', mode: 'VIEWING', connections: 1 }, { userId: 'u:bob', displayName: 'Bob', mode: 'VIEWING', connections: 2 }],
|
||||
order: [{ userId: 'core:me', displayName: 'Me', mode: 'VIEWING', connections: 1 }],
|
||||
}
|
||||
const s = presenceSource(ctx({ data: (_id, input) => ({ viewers: viewers[(input as { query: { table: string } }).query.table] ?? [] }) }))
|
||||
const v = await s.load()
|
||||
expect(v.value).toBe('4')
|
||||
expect(v.detail).toBe('4 online · you ×2, Bob ×2 · PERSON')
|
||||
// only this terminal's own lease, no "connections" (collaboration 1.1.0): you, once
|
||||
const old = presenceSource(ctx({ data: () => ({ viewers: [{ userId: 'core:me', displayName: 'Me', mode: 'VIEWING' }] }) }))
|
||||
const o = await old.load()
|
||||
expect(o.value).toBe('1')
|
||||
expect(o.detail).toBe('1 online · you')
|
||||
})
|
||||
|
||||
test('presence on a table that refuses it is skipped; the source hides when nothing is left', async () => {
|
||||
const refuse = () => { throw new UiApiError(404, {}) }
|
||||
// signed out: nothing to show once every table refuses
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/** The start-up splash and profile picker: the boot plan, the picker (preselect, Enter, Esc), the add form, timing, ASCII, failures. */
|
||||
|
||||
import { afterEach, beforeAll, expect, test } from 'bun:test'
|
||||
import { testRender } from '@opentui/react/test-utils'
|
||||
import { readConfig, writeConfig, type Config } from '@qrun-io/qqq-core'
|
||||
import { App } from '../src/app'
|
||||
import { boot, parseArgs } from '../src/boot'
|
||||
import { PICK_HINT, SPLASH_MIN_MS, lastUsedProfile, planBoot, readSplash, splashEnabled, splashWait, writeSplash } from '../src/boot-plan'
|
||||
import { ProfileFormOverlay, ServersOverlay } from '../src/overlays/servers'
|
||||
import { SplashScreen, splashLayout, splashText, versionLine, type SplashLayoutInput } from '../src/screens/splash'
|
||||
import { fakeFetch, type FakeLog } from './fake-server'
|
||||
import { command, isolate, press, sessionSecrets, waitFor, type Harness } from './helpers'
|
||||
import { querySize } from '../src/saver/query-art'
|
||||
import { setAscii } from '../src/theme'
|
||||
import { cellWidth } from '../src/util/text'
|
||||
|
||||
let h: Harness | undefined
|
||||
let exits: string[] = []
|
||||
let quits = 0
|
||||
|
||||
beforeAll(() => isolate())
|
||||
afterEach(() => {
|
||||
h?.store.dispose()
|
||||
h?.setup.renderer.destroy()
|
||||
h = undefined
|
||||
exits = []
|
||||
quits = 0
|
||||
setAscii(false)
|
||||
})
|
||||
|
||||
const hour = 3_600_000
|
||||
function profiles(map: Record<string, { server: string; lastUsedAt?: string }>, defaultProfile?: string): void {
|
||||
writeConfig({ profiles: map, ...(defaultProfile ? { defaultProfile } : {}) } as Config)
|
||||
}
|
||||
|
||||
function two(): void {
|
||||
profiles({
|
||||
alpha: { server: 'http://alpha.test', lastUsedAt: new Date(Date.now() - 5 * hour).toISOString() },
|
||||
beta: { server: 'http://beta.test', lastUsedAt: new Date(Date.now() - hour).toISOString() },
|
||||
}, 'alpha')
|
||||
}
|
||||
|
||||
async function start(argv: string[], options: { width?: number; height?: number; minMs?: number; fetch?: typeof fetch; log?: FakeLog } = {}): Promise<Harness> {
|
||||
const log = options.log ?? { requests: [] }
|
||||
const store = boot({ args: parseArgs(argv, {}), quit: () => { quits++ }, build: 'test', secrets: sessionSecrets, fetch: options.fetch ?? fakeFetch(log), splashMinMs: options.minMs, hint: (t) => exits.push(t) })
|
||||
const setup = await testRender(<App store={store} />, { width: options.width ?? 120, height: options.height ?? 40 })
|
||||
const harness: Harness = { setup, store, log, frame: () => setup.captureCharFrame() }
|
||||
store.captureScreen = () => setup.captureCharFrame()
|
||||
return harness
|
||||
}
|
||||
|
||||
test('the boot plan: a named profile or the only one connects; several ask (last used first); none adds', () => {
|
||||
const at = (n: number) => new Date(Date.now() - n * hour).toISOString()
|
||||
const several: Config = { profiles: { alpha: { server: 'http://a.test', lastUsedAt: at(5) }, beta: { server: 'http://b.test', lastUsedAt: at(1) }, gamma: { server: 'http://c.test' } }, defaultProfile: 'alpha' } as Config
|
||||
expect(planBoot({}, {}, several)).toEqual({ kind: 'pick', select: 'beta' })
|
||||
expect(planBoot({ profile: 'gamma' }, {}, several)).toEqual({ kind: 'connect', target: { name: 'gamma', server: 'http://c.test' } })
|
||||
expect(planBoot({}, { QQQ_PROFILE: 'alpha' }, several)).toEqual({ kind: 'connect', target: { name: 'alpha', server: 'http://a.test' } })
|
||||
expect(planBoot({ server: 'http://b.test/x' }, {}, several)).toEqual({ kind: 'connect', target: { name: 'beta', server: 'http://b.test' } })
|
||||
expect(planBoot({}, { QQQ_SERVER: 'http://new.test' }, several).kind).toBe('connect')
|
||||
const one: Config = { profiles: { solo: { server: 'http://solo.test' } } } as Config
|
||||
expect(planBoot({}, {}, one)).toEqual({ kind: 'connect', target: { name: 'solo', server: 'http://solo.test' } })
|
||||
expect(planBoot({}, {}, { profiles: {} } as Config)).toEqual({ kind: 'add' })
|
||||
// a name that does not exist: the picker (or the add form) with why
|
||||
const typo = planBoot({ profile: 'nope' }, {}, several)
|
||||
expect(typo.kind).toBe('pick')
|
||||
expect(typo.kind === 'pick' && typo.note).toMatch(/No profile "nope"/)
|
||||
expect(planBoot({ profile: 'nope' }, {}, { profiles: {} } as Config).kind).toBe('add')
|
||||
// never used: the default, else the first
|
||||
expect(lastUsedProfile({ profiles: { a: { server: 'x' }, b: { server: 'y' } }, defaultProfile: 'b' } as Config)).toBe('b')
|
||||
expect(lastUsedProfile({ profiles: { a: { server: 'x' }, b: { server: 'y' } } } as Config)).toBe('a')
|
||||
})
|
||||
|
||||
test('the splash switch: --no-splash, --splash, QQQ_UI_NO_SPLASH and the ui.splash setting', () => {
|
||||
expect(parseArgs(['--no-splash']).splash).toBe(false)
|
||||
expect(parseArgs(['--splash']).splash).toBe(true)
|
||||
expect(parseArgs([]).splash).toBeUndefined()
|
||||
expect(splashEnabled({ splash: false })).toBe(false)
|
||||
expect(splashEnabled({}, { ...process.env, QQQ_UI_NO_SPLASH: '1' })).toBe(false)
|
||||
expect(readSplash()).toBe(true)
|
||||
writeSplash(false)
|
||||
expect(readConfig().ui).toMatchObject({ splash: 'off' })
|
||||
expect(splashEnabled({})).toBe(false)
|
||||
expect(splashEnabled({ splash: true })).toBe(true)
|
||||
writeSplash(true)
|
||||
expect(splashEnabled({})).toBe(true)
|
||||
})
|
||||
|
||||
test('the minimum display time: never under 600 ms, nothing more once it has been up that long', async () => {
|
||||
expect(SPLASH_MIN_MS).toBe(600)
|
||||
expect(splashWait(1000, 1100)).toBe(500)
|
||||
expect(splashWait(1000, 1700)).toBe(0)
|
||||
profiles({ fake: { server: 'http://fake.test' } })
|
||||
const began = Date.now()
|
||||
h = await start(['--profile', 'fake'], { minMs: 600 })
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'splash')
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
|
||||
expect(Date.now() - began).toBeGreaterThanOrEqual(600)
|
||||
})
|
||||
|
||||
test('one profile: the splash shows Query, the wordmark, the version and the boot steps, then Home', async () => {
|
||||
profiles({ fake: { server: 'http://fake.test' } })
|
||||
h = await start([], { minMs: 400 })
|
||||
const f = await waitFor(h, (x) => x.includes('Terminal client for QQQ'))
|
||||
expect(h.store.screen?.kind).toBe('splash')
|
||||
expect(f).toContain(versionLine())
|
||||
expect(f).toMatch(/qqq \d+\.\d+\.\d+ · UI API client \d+\.\d+\.\d+/)
|
||||
expect(f).toContain('█')
|
||||
// no title bar, status line or key bar under the splash
|
||||
expect(f).not.toMatch(/10\s*Quit/)
|
||||
const steps = h.store.screen as SplashScreen
|
||||
await waitFor(h, () => /loaded metadata \(\d+ tables\)/.test(steps.status))
|
||||
await waitFor(h, (x) => h!.store.screen?.kind === 'home' && x.includes('Apps'), 4000)
|
||||
expect(h.store.overlays.length).toBe(0)
|
||||
})
|
||||
|
||||
test('several profiles: the picker over the splash, the last used preselected; Enter connects to it', async () => {
|
||||
two()
|
||||
h = await start([], { width: 120, height: 40 })
|
||||
const f = await waitFor(h, (x) => x.includes('Connect to a server'))
|
||||
const picker = h.store.overlays.at(-1) as ServersOverlay
|
||||
expect(picker).toBeInstanceOf(ServersOverlay)
|
||||
expect(picker.selected()?.name).toBe('beta')
|
||||
expect(f).toContain('Enter connects')
|
||||
expect(f).toContain('Esc quits')
|
||||
// the splash header stays visible above the picker
|
||||
expect(f).toContain('Terminal client for QQQ')
|
||||
// probes fill in on their own (the picker does not wait for them)
|
||||
await waitFor(h, (x) => x.includes('UI API'))
|
||||
await press(h, () => h!.setup.mockInput.pressEnter())
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
|
||||
expect(h.store.target?.name).toBe('beta')
|
||||
})
|
||||
|
||||
test('several profiles: Esc on the picker quits with the --profile hint', async () => {
|
||||
two()
|
||||
h = await start([])
|
||||
await waitFor(h, (x) => x.includes('Connect to a server'))
|
||||
await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
expect(quits).toBe(1)
|
||||
expect(exits).toEqual([PICK_HINT])
|
||||
expect(PICK_HINT).toContain('--profile')
|
||||
})
|
||||
|
||||
test('--profile skips the picker even with several profiles', async () => {
|
||||
two()
|
||||
h = await start(['--profile', 'alpha'])
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
|
||||
expect(h.store.target?.name).toBe('alpha')
|
||||
})
|
||||
|
||||
test('no profiles: the add form opens straight away; saving connects to the new profile', async () => {
|
||||
writeConfig({ profiles: {} } as Config)
|
||||
h = await start([])
|
||||
await waitFor(h, (x) => x.includes('Add a server profile'))
|
||||
const form = h.store.overlays.at(-1) as ProfileFormOverlay
|
||||
expect(form).toBeInstanceOf(ProfileFormOverlay)
|
||||
form.editors.name.set('local')
|
||||
form.editors.server.set('http://fake.test')
|
||||
await press(h, () => form.save(), 50)
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
|
||||
expect(h.store.target).toEqual({ name: 'local', server: 'http://fake.test' })
|
||||
})
|
||||
|
||||
test('no profiles, add form closed: the splash says n adds one and Esc quits', async () => {
|
||||
writeConfig({ profiles: {} } as Config)
|
||||
h = await start([], { width: 80, height: 24 })
|
||||
await waitFor(h, (x) => x.includes('Add a server profile'))
|
||||
const f = await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
expect(f).toContain('n adds a server profile')
|
||||
await press(h, () => h!.setup.mockInput.pressKey('n'))
|
||||
expect(h.store.overlays.at(-1)).toBeInstanceOf(ProfileFormOverlay)
|
||||
await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
expect(quits).toBe(1)
|
||||
})
|
||||
|
||||
test('--no-splash: the sign-in screen as before, then Home; with several profiles the picker opens over it', async () => {
|
||||
profiles({ fake: { server: 'http://fake.test' } })
|
||||
h = await start(['--no-splash'])
|
||||
expect(h.store.routes.some((r) => r.kind === 'splash')).toBe(false)
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
|
||||
h.store.dispose()
|
||||
h.setup.renderer.destroy()
|
||||
two()
|
||||
h = await start(['--no-splash'])
|
||||
await waitFor(h, (x) => x.includes('Connect to a server'))
|
||||
expect(h.store.screen?.kind).toBe('signin')
|
||||
expect((h.store.overlays.at(-1) as ServersOverlay).selected()?.name).toBe('beta')
|
||||
})
|
||||
|
||||
test('a failure stays on the splash with retry and choose-another; c opens the picker, Esc goes back to it', async () => {
|
||||
profiles({ fake: { server: 'http://fake.test' }, other: { server: 'http://other.test' } })
|
||||
let down = true
|
||||
const ok = fakeFetch()
|
||||
const flaky = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (down) throw new Error('connection refused')
|
||||
return ok(input, init)
|
||||
}) as typeof fetch
|
||||
h = await start(['--profile', 'fake'], { width: 80, height: 24, fetch: flaky })
|
||||
let f = await waitFor(h, (x) => x.includes('Enter retries'))
|
||||
expect(f).toContain('connection refused')
|
||||
expect(f).toContain('c chooses another profile')
|
||||
await press(h, () => h!.setup.mockInput.pressKey('c'))
|
||||
f = await waitFor(h, (x) => x.includes('Connect to a server'))
|
||||
expect(f).toContain('Esc goes back')
|
||||
await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
expect(quits).toBe(0)
|
||||
f = await waitFor(h, (x) => x.includes('Enter retries'))
|
||||
down = false
|
||||
await press(h, () => h!.setup.mockInput.pressEnter())
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
|
||||
})
|
||||
|
||||
test('any key stops the animation, not the boot', async () => {
|
||||
profiles({ fake: { server: 'http://fake.test' } })
|
||||
h = await start([], { minMs: 300 })
|
||||
const splash = h.store.screen as SplashScreen
|
||||
splash.animate = true
|
||||
await press(h, () => h!.setup.mockInput.pressKey('x'))
|
||||
expect(splash.animate).toBe(false)
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
|
||||
})
|
||||
|
||||
function input(ascii: boolean, anchor: 'center' | 'top' = 'center'): SplashLayoutInput {
|
||||
return { ascii, pose: { blink: false, wag: false }, status: { text: 'connecting to localhost:8078…', tone: 'status' }, extra: [], keys: '', anchor }
|
||||
}
|
||||
|
||||
test('the layout fits 80×24 (small Query beside the wordmark) and 120×40 (large Query); every line clipped', () => {
|
||||
for (const [w, hgt] of [[80, 24], [120, 40], [40, 12], [30, 8], [20, 5]] as const) {
|
||||
for (const ascii of [false, true]) {
|
||||
setAscii(ascii)
|
||||
const lines = splashLayout(w, hgt, input(ascii))
|
||||
expect(lines.length).toBeLessThanOrEqual(hgt)
|
||||
for (const l of lines) expect(cellWidth(l.map((s) => s.text).join(''))).toBeLessThanOrEqual(w)
|
||||
}
|
||||
}
|
||||
setAscii(false)
|
||||
const small = splashText(splashLayout(80, 24, input(false)))
|
||||
expect(small).toContain('Terminal client for QQQ')
|
||||
expect(small).toContain('connecting to localhost:8078…')
|
||||
const large = splashLayout(120, 40, input(false))
|
||||
expect(large.filter((l) => l.some((s) => s.tone === 'art')).length).toBe(querySize('large').height)
|
||||
const header = splashLayout(80, 24, input(false, 'top'))
|
||||
expect(splashText(header)).not.toContain('connecting')
|
||||
expect(header.length).toBeLessThanOrEqual(querySize('small').height + 2)
|
||||
})
|
||||
|
||||
test('ASCII: no braille or block characters, the wordmark in #', () => {
|
||||
setAscii(true)
|
||||
for (const [w, hgt] of [[80, 24], [120, 40]] as const) {
|
||||
const text = splashText(splashLayout(w, hgt, input(true)))
|
||||
expect(text).not.toMatch(/[⠀-⣿█]/)
|
||||
expect(text).toContain('####')
|
||||
expect(text).toContain('Terminal client for QQQ')
|
||||
expect(text).toMatch(/^[\x20-\x7e\n]*$/)
|
||||
expect(text).toContain('qqq')
|
||||
}
|
||||
})
|
||||
|
||||
test(':splash off and on save the setting (Settings > Splash on start)', async () => {
|
||||
profiles({ fake: { server: 'http://fake.test' } })
|
||||
h = await start([])
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
|
||||
await command(h, 'splash off')
|
||||
expect(readSplash()).toBe(false)
|
||||
expect(h.store.flashText?.text).toContain('splash off')
|
||||
await command(h, 'splash on')
|
||||
expect(readSplash()).toBe(true)
|
||||
})
|
||||
@@ -108,7 +108,7 @@ for await (const message of openRealtimeStream({ baseUrl, token: () => tokens.ac
|
||||
## Compatibility and versioning
|
||||
|
||||
- The package version follows the UI API version of the document it was generated from
|
||||
(`UI_API_VERSION`, 1.8.0 for this release). The client works with any server on UI API 1.6.0 up to 2.0:
|
||||
(`UI_API_VERSION`, 1.10.0 for this release). The client works with any server on UI API 1.6.0 up to 2.0:
|
||||
operations newer than a server answer 404, and unknown enum values and extra fields are tolerated.
|
||||
`GET /qqq/v1/version` tells you the server's version, features and installed extensions.
|
||||
- A new UI API minor version means a new minor version of this package; a 2.0 UI API would be a new major.
|
||||
|
||||
@@ -1,103 +1,103 @@
|
||||
{
|
||||
"core": "http://localhost:8077/qqq/v1/openapi.json",
|
||||
"core": "qqq/qqq-middleware-javalin/src/main/resources/openapi/v1/openapi.yaml (qqq origin/develop 7825261ab)",
|
||||
"extensions": [
|
||||
{
|
||||
"name": "esb",
|
||||
"version": "1.1.0",
|
||||
"document": ".worktrees/qqq-develop/qqq-esb/src/main/resources/openapi/qqq-v1/esb.yaml",
|
||||
"branch": "HEAD",
|
||||
"commit": "6ac64fc66"
|
||||
"document": "qqq/qqq-esb/src/main/resources/openapi/qqq-v1/esb.yaml",
|
||||
"branch": "develop",
|
||||
"commit": "7825261ab"
|
||||
},
|
||||
{
|
||||
"name": "health",
|
||||
"version": "1.1.0",
|
||||
"document": ".worktrees/qqq-develop/qqq-middleware-javalin/src/main/resources/openapi/qqq-v1/health.yaml",
|
||||
"branch": "HEAD",
|
||||
"commit": "6ac64fc66"
|
||||
"document": "qqq/qqq-middleware-javalin/src/main/resources/openapi/qqq-v1/health.yaml",
|
||||
"branch": "develop",
|
||||
"commit": "7825261ab"
|
||||
},
|
||||
{
|
||||
"name": "alerts",
|
||||
"version": "1.2.0",
|
||||
"document": "qbit-alerts/src/main/resources/openapi/qqq-v1/alerts.yaml",
|
||||
"branch": "develop",
|
||||
"commit": "a3fc6ac"
|
||||
"commit": "420cc69"
|
||||
},
|
||||
{
|
||||
"name": "assistant",
|
||||
"version": "1.6.0",
|
||||
"version": "1.7.0",
|
||||
"document": "qbit-assistant/qbit-assistant/src/main/resources/openapi/qqq-v1/assistant.yaml",
|
||||
"branch": "develop",
|
||||
"commit": "bcf173f"
|
||||
"commit": "67bdd32"
|
||||
},
|
||||
{
|
||||
"name": "collaboration",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"document": "qbit-collaboration/src/main/resources/openapi/qqq-v1/collaboration.yaml",
|
||||
"branch": "develop",
|
||||
"commit": "03407ae"
|
||||
"commit": "7d61f29"
|
||||
},
|
||||
{
|
||||
"name": "data-analysis",
|
||||
"version": "1.5.0",
|
||||
"document": "qbit-data-analysis/src/main/resources/openapi/qqq-v1/data-analysis.yaml",
|
||||
"branch": "develop",
|
||||
"commit": "368d2c0"
|
||||
"commit": "f2fd8b1"
|
||||
},
|
||||
{
|
||||
"name": "db-performance",
|
||||
"version": "1.0.0",
|
||||
"document": "qbit-db-performance/src/main/resources/openapi/qqq-v1/db-performance.yaml",
|
||||
"branch": "develop",
|
||||
"commit": "4fd2c30"
|
||||
"commit": "b70c234"
|
||||
},
|
||||
{
|
||||
"name": "edi",
|
||||
"version": "1.1.0",
|
||||
"document": "qbit-edi/src/main/resources/openapi/qqq-v1/edi.yaml",
|
||||
"branch": "develop",
|
||||
"commit": "75a4e6b"
|
||||
"commit": "683995c"
|
||||
},
|
||||
{
|
||||
"name": "printing",
|
||||
"version": "2.3.0",
|
||||
"document": "qbit-printing/src/main/resources/openapi/qqq-v1/printing.yaml",
|
||||
"branch": "main",
|
||||
"commit": "0ab07fc"
|
||||
},
|
||||
{
|
||||
"name": "qa-capture",
|
||||
"version": "1.0.0",
|
||||
"document": "qbit-qa-capture/src/main/resources/openapi/qqq-v1/qa-capture.yaml",
|
||||
"branch": "develop",
|
||||
"commit": "1aee6cb"
|
||||
"commit": "8ca3fd7"
|
||||
},
|
||||
{
|
||||
"name": "quick-search",
|
||||
"version": "1.1.0",
|
||||
"document": "qbit-quick-search/src/main/resources/openapi/qqq-v1/quick-search.yaml",
|
||||
"branch": "develop",
|
||||
"commit": "bd50e1f"
|
||||
"commit": "e3aa05e"
|
||||
},
|
||||
{
|
||||
"name": "release-notes",
|
||||
"version": "1.0.0",
|
||||
"document": "qbit-release-notes/src/main/resources/openapi/qqq-v1/release-notes.yaml",
|
||||
"branch": "develop",
|
||||
"commit": "8fed18e"
|
||||
"commit": "8abbe6f"
|
||||
},
|
||||
{
|
||||
"name": "secured-data",
|
||||
"version": "1.1.0",
|
||||
"document": "qbit-secured-data/src/main/resources/openapi/qqq-v1/secured-data.yaml",
|
||||
"branch": "develop",
|
||||
"commit": "7b302ed"
|
||||
"commit": "5987cb5"
|
||||
},
|
||||
{
|
||||
"name": "sql-workbench",
|
||||
"version": "1.1.0",
|
||||
"document": "qbit-sql-workbench/src/main/resources/openapi/qqq-v1/sql-workbench.yaml",
|
||||
"branch": "develop",
|
||||
"commit": "a182076"
|
||||
},
|
||||
{
|
||||
"name": "printing",
|
||||
"version": "2.1.0",
|
||||
"document": "qbit-printing/src/main/resources/openapi/qqq-v1/printing.yaml",
|
||||
"branch": "feat/print-anything",
|
||||
"commit": "bda172a"
|
||||
"commit": "1784a5b"
|
||||
}
|
||||
]
|
||||
}
|
||||
+25684
-24674
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qrun-io/qqq-ui-api-client",
|
||||
"version": "1.8.0",
|
||||
"version": "1.10.0",
|
||||
"description": "Typed TypeScript client for the QQQ UI API (/qqq/v1), generated from the merged OpenAPI document (core + extensions), with OAuth2 device-flow sign-in, token refresh and a realtime (SSE) reader.",
|
||||
"keywords": [
|
||||
"qqq",
|
||||
|
||||
@@ -14,4 +14,4 @@ export type { RealtimeMessage, RealtimeStreamOptions } from './realtime.js'
|
||||
export type { paths, components, operations } from './schema.js'
|
||||
|
||||
/** The UI API version this client was generated from (info.version of the merged document). */
|
||||
export const UI_API_VERSION = '1.8.0'
|
||||
export const UI_API_VERSION = '1.10.0'
|
||||
+2851
-2297
File diff suppressed because it is too large.
Load diff
+26
-1
@@ -14,7 +14,10 @@
|
||||
* A server's own GET /qqq/v1/openapi.json is already merged and can be used directly instead.
|
||||
*
|
||||
* Rules: paths and operations are unioned (a duplicate method on one path is an error); component
|
||||
* schemas are unioned (a name defined twice must be identical, e.g. BasicErrorResponseV1); the
|
||||
* schemas are unioned (a name defined twice must be identical, e.g. BasicErrorResponseV1, or an
|
||||
* extension's copy may be an older copy of the one already merged: the same schema without the
|
||||
* properties and enum values added since, which a MINOR UI API version may add; the newer one is
|
||||
* kept and a warning printed); the
|
||||
* core document's info, servers and securitySchemes are kept; tags are unioned; every extension's
|
||||
* name, version, path prefix and operationIds are recorded in info.x-qqq-extensions.
|
||||
*
|
||||
@@ -73,6 +76,24 @@ function stable(value) {
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether `older` is `newer` as it was before additive (MINOR) changes: every key of `older` is in
|
||||
* `newer` with an older-or-equal value, `newer` may have extra entries only under `properties`, and
|
||||
* an array in `older` (an enum, `required` excepted) may lack values `newer` has.
|
||||
*/
|
||||
function isOlderCopy(newer, older, key) {
|
||||
if (Array.isArray(older) && Array.isArray(newer)) {
|
||||
const have = new Set(newer.map((v) => JSON.stringify(stable(v))))
|
||||
if (key === 'required') return older.length === newer.length && older.every((v) => have.has(JSON.stringify(stable(v))))
|
||||
return older.every((v) => have.has(JSON.stringify(stable(v))))
|
||||
}
|
||||
if (older && newer && typeof older === 'object' && typeof newer === 'object' && !Array.isArray(older) && !Array.isArray(newer)) {
|
||||
for (const k of Object.keys(older)) if (!(k in newer) || !isOlderCopy(newer[k], older[k], k)) return false
|
||||
return key === 'properties' || Object.keys(newer).every((k) => k in older)
|
||||
}
|
||||
return JSON.stringify(stable(newer)) === JSON.stringify(stable(older))
|
||||
}
|
||||
|
||||
const [coreSource, ...extensionSources] = inputs
|
||||
const merged = await load(coreSource)
|
||||
merged.paths ??= {}
|
||||
@@ -107,6 +128,10 @@ for (const source of extensionSources) {
|
||||
for (const [schemaName, schema] of Object.entries(doc.components?.schemas ?? {})) {
|
||||
const existing = merged.components.schemas[schemaName]
|
||||
if (existing && JSON.stringify(stable(existing)) !== JSON.stringify(stable(schema))) {
|
||||
if (isOlderCopy(existing, schema)) {
|
||||
console.warn(`warning: ${name} carries an older copy of schema ${schemaName} (without later additions); keeping the newer one`)
|
||||
continue
|
||||
}
|
||||
throw new Error(`schema ${schemaName} differs between the core document and ${name}`)
|
||||
}
|
||||
merged.components.schemas[schemaName] = schema
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"types": ["node"],
|
||||
"declaration": true,
|
||||
"sourceMap": true,
|
||||
"esModuleInterop": true,
|
||||
|
||||
Reference in new issue
Block a user