Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b250794a7c
|
||
|
|
bf9c093bdd | ||
|
|
a4750b68ad
|
||
|
|
1c54e7fae0
|
||
|
|
bcd3468751
|
||
|
|
0414e46a60
|
||
|
|
2f50a10543
|
||
|
|
b68a609240
|
||
|
|
c9f033192e
|
||
|
|
61c645d581
|
||
|
|
9890fb1300
|
||
|
|
d0d8776838 | ||
|
|
219d2669c3
|
||
|
|
4828a73d32
|
||
|
|
2c156f3884
|
||
|
|
b40c291646
|
||
|
|
29f5b2a542
|
||
|
|
991950505d
|
||
|
|
2c3114e06b
|
||
|
|
b126064144
|
||
|
|
a7767ba76c
|
||
|
|
b42e4eb9b8
|
||
|
|
074265e4d8
|
||
|
|
2639b2cccd
|
||
|
|
943721edef | ||
|
|
fd6f7ff6b9 | ||
|
|
3a4f8a6727 | ||
|
|
e4de04e510 | ||
|
|
4f96ab0c03 | ||
|
|
9e6cad281c | ||
|
|
1d7e0af3cc | ||
|
|
650f1d33db | ||
|
|
f180a12750 | ||
|
|
924fddae25 |
No files matched your search
@@ -1,14 +0,0 @@
|
||||
version: 2.1
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
only: /.*/
|
||||
@@ -0,0 +1,17 @@
|
||||
name: build
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
jobs:
|
||||
node:
|
||||
uses: self:KofTwentyTwo/ci-workflows/.gitea/workflows/node.yaml@v1
|
||||
with:
|
||||
node-version: '22'
|
||||
# The TUI (packages/tui) is not an npm workspace and needs Bun; build and test cover the four
|
||||
# Node workspaces only, as the old CI did.
|
||||
commands: |
|
||||
npm run generate
|
||||
git diff --exit-code -- packages/ui-api-client/src/schema.ts packages/core/src/api/operations.ts
|
||||
npm run build
|
||||
npm test
|
||||
npm run lint
|
||||
@@ -0,0 +1,304 @@
|
||||
# Copyright 2026 QRun.IO, Inc.
|
||||
# Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
#
|
||||
# Release on a v<CLI version> tag (docs/release-runbook.md):
|
||||
# linux scripts/release.mjs gate; linux-x64, linux-arm64, darwin-x64 binaries (cross); draft Gitea release
|
||||
# (no musl builds: bun installs no OpenTUI musl native package on a glibc host)
|
||||
# macos native darwin-arm64 binary, built and smoke-tested on the macOS runner
|
||||
# windows native windows-x64 binary, built and smoke-tested on the Windows runner
|
||||
# publish SHA256SUMS; publish the release and check its downloads; the four npm packages to the
|
||||
# QRun-IO Gitea npm registry; for a final version pull requests to the Homebrew tap and the Scoop bucket
|
||||
name: release
|
||||
on:
|
||||
push:
|
||||
tags: ['v*']
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
env:
|
||||
NODE_VERSION: 22.23.4
|
||||
BUN_VERSION: 1.4.4
|
||||
BUN_LINUX_SHA256: e0446eb55995772eb9894bebae9a6be368d985a8b9271ee124486f9946eedd76 # bun-linux-x64-baseline.zip
|
||||
BUN_DARWIN_SHA256: c240d7f0ccb90c263a849fcdb09b7ee62630d921348ed6aafda86b9d577bc510 # bun-darwin-aarch64.zip
|
||||
BUN_WINDOWS_SHA256: 0f20ce6674d4f2dbcc8c5d59fdf99f7ba45f8c3b367e0f5781cbfa30a1c78d99 # bun-windows-x64-baseline.zip
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
runs-on: container-build
|
||||
timeout-minutes: 60
|
||||
container:
|
||||
image: node:22.23.4-bookworm
|
||||
volumes:
|
||||
- /var/lib/gitea-job-workspace:${{ github.workspace }}
|
||||
env:
|
||||
HOME: /tmp
|
||||
CI: 'true'
|
||||
outputs:
|
||||
release-id: ${{ steps.draft.outputs.id }}
|
||||
version: ${{ steps.plan.outputs.version }}
|
||||
sums: ${{ steps.draft.outputs.sums }}
|
||||
steps:
|
||||
- uses: builtin:checkout
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check the tag
|
||||
id: plan
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
version=$(node -p 'require("./packages/cli/package.json").version')
|
||||
[[ $TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ ]] || { echo "$TAG is not a release tag"; exit 1; }
|
||||
[[ $TAG == "v$version" ]] || { echo "Tag $TAG does not match packages/cli/package.json $version"; exit 1; }
|
||||
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install Bun
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p /tmp/bun && cd /tmp/bun
|
||||
curl -fsSLo bun.zip "https://github.com/oven-sh/bun/releases/download/bun-v$BUN_VERSION/bun-linux-x64-baseline.zip"
|
||||
echo "$BUN_LINUX_SHA256 bun.zip" | sha256sum -c -
|
||||
python3 -c 'import sys, zipfile; zipfile.ZipFile(sys.argv[1]).extractall(".")' bun.zip
|
||||
chmod +x bun-linux-x64-baseline/bun && echo /tmp/bun/bun-linux-x64-baseline >> "$GITHUB_PATH"
|
||||
|
||||
- name: Release gate (scripts/release.mjs)
|
||||
# npm ci, generate drift, build, readiness (final versions), test, lint, TUI tests and type check, pack dry runs
|
||||
run: node scripts/release.mjs --skip-binaries
|
||||
|
||||
- name: Linux and darwin-x64 binaries
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node scripts/build-binaries.mjs --no-build --targets linux-x64,linux-arm64,darwin-x64
|
||||
scripts/verify-binary.sh dist/qqq-linux-x64
|
||||
|
||||
- name: Draft Gitea release
|
||||
id: draft
|
||||
shell: bash
|
||||
env:
|
||||
TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
VERSION: ${{ steps.plan.outputs.version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
api="$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY"
|
||||
auth="Authorization: token $TOKEN"
|
||||
if curl -fsS -o /dev/null -H "$auth" "$api/releases/tags/$TAG" 2>/dev/null; then
|
||||
echo "A release for $TAG already exists; a version is released once"; exit 1
|
||||
fi
|
||||
if [[ -f docs/release-notes/$VERSION.md ]]; then notes=$(cat "docs/release-notes/$VERSION.md"); else
|
||||
notes=$(awk -v v="[$VERSION]" '/^## / { if (found) exit; if (index($0, "## " v) == 1) { found = 1; next } } found { print }' CHANGELOG.md)
|
||||
fi
|
||||
[[ -n ${notes//[[:space:]]/} ]] || notes="qqq $VERSION"
|
||||
body=$(NOTES="$notes" node -e 'process.stdout.write(JSON.stringify({ tag_name: process.env.TAG,
|
||||
name: `qqq ${process.env.VERSION}`, body: process.env.NOTES, draft: true, prerelease: process.env.VERSION.includes("-") }))')
|
||||
id=$(curl -fsS -X POST -H "$auth" -H 'Content-Type: application/json' -d "$body" "$api/releases" |
|
||||
node -e 'process.stdout.write(String(JSON.parse(require("fs").readFileSync(0, "utf8")).id))')
|
||||
echo "id=$id" >> "$GITHUB_OUTPUT"
|
||||
echo "sums=$(cd dist && sha256sum qqq-* | tr '\n' ';')" >> "$GITHUB_OUTPUT"
|
||||
for f in dist/qqq-*; do
|
||||
curl -fsS -o /dev/null -X POST -H "$auth" -F "attachment=@$f" "$api/releases/$id/assets?name=$(basename "$f")"
|
||||
echo "Attached $(basename "$f") to draft release $id"
|
||||
done
|
||||
|
||||
macos:
|
||||
needs: linux
|
||||
runs-on: qqq-macos-arm64
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CI: 'true'
|
||||
outputs:
|
||||
sums: ${{ steps.attach.outputs.sums }}
|
||||
steps:
|
||||
- uses: builtin:checkout
|
||||
|
||||
- name: Install Node.js and Bun
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tools="$(mktemp -d)" && cd "$tools"
|
||||
node_file="node-v$NODE_VERSION-darwin-arm64.tar.gz"
|
||||
curl -fsSLO "https://nodejs.org/dist/v$NODE_VERSION/$node_file" && curl -fsSLO "https://nodejs.org/dist/v$NODE_VERSION/SHASUMS256.txt"
|
||||
grep " $node_file\$" SHASUMS256.txt | shasum -a 256 -c -
|
||||
tar -xzf "$node_file"
|
||||
curl -fsSLo bun.zip "https://github.com/oven-sh/bun/releases/download/bun-v$BUN_VERSION/bun-darwin-aarch64.zip"
|
||||
echo "$BUN_DARWIN_SHA256 bun.zip" | shasum -a 256 -c -
|
||||
unzip -q bun.zip
|
||||
echo "$tools/node-v$NODE_VERSION-darwin-arm64/bin" >> "$GITHUB_PATH"
|
||||
echo "$tools/bun-darwin-aarch64" >> "$GITHUB_PATH"
|
||||
echo "TOOLS_DIR=$tools" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build, test, darwin-arm64 binary
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
node --version && bun --version
|
||||
npm ci
|
||||
npm run build
|
||||
npm test
|
||||
npm run lint
|
||||
node scripts/build-binaries.mjs --no-build --host
|
||||
scripts/verify-binary.sh dist/qqq-darwin-arm64
|
||||
|
||||
- name: Attach to the draft release
|
||||
id: attach
|
||||
shell: bash
|
||||
env:
|
||||
TOKEN: ${{ github.token }}
|
||||
RELEASE_ID: ${{ needs.linux.outputs.release-id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
curl -fsS -o /dev/null -X POST -H "Authorization: token $TOKEN" -F "attachment=@dist/qqq-darwin-arm64" \
|
||||
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=qqq-darwin-arm64"
|
||||
echo "Attached qqq-darwin-arm64"
|
||||
echo "sums=$(cd dist && shasum -a 256 qqq-darwin-arm64 | tr '\n' ';')" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Remove the tools
|
||||
if: always()
|
||||
shell: bash
|
||||
run: if [[ -n ${TOOLS_DIR:-} ]]; then rm -rf "$TOOLS_DIR"; fi
|
||||
|
||||
windows:
|
||||
needs: linux
|
||||
runs-on: qqq-windows-vm6218
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CI: 'true'
|
||||
outputs:
|
||||
sums: ${{ steps.attach.outputs.sums }}
|
||||
steps:
|
||||
- uses: builtin:checkout
|
||||
|
||||
- name: Install Node.js and Bun
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'; $ProgressPreference = 'SilentlyContinue'
|
||||
$tools = Join-Path ([IO.Path]::GetTempPath()) ("qqq-tools-" + [guid]::NewGuid())
|
||||
New-Item -ItemType Directory -Path $tools | Out-Null
|
||||
$nodeFile = "node-v$env:NODE_VERSION-win-x64.zip"
|
||||
Invoke-WebRequest "https://nodejs.org/dist/v$env:NODE_VERSION/$nodeFile" -OutFile "$tools\$nodeFile"
|
||||
Invoke-WebRequest "https://nodejs.org/dist/v$env:NODE_VERSION/SHASUMS256.txt" -OutFile "$tools\SHASUMS256.txt"
|
||||
$want = ((Get-Content "$tools\SHASUMS256.txt") | Where-Object { $_ -match " $([regex]::Escape($nodeFile))$" }).Split(' ')[0]
|
||||
if ((Get-FileHash "$tools\$nodeFile" -Algorithm SHA256).Hash -ne $want) { throw "$nodeFile checksum mismatch" }
|
||||
Expand-Archive "$tools\$nodeFile" -DestinationPath $tools
|
||||
Invoke-WebRequest "https://github.com/oven-sh/bun/releases/download/bun-v$env:BUN_VERSION/bun-windows-x64-baseline.zip" -OutFile "$tools\bun.zip"
|
||||
if ((Get-FileHash "$tools\bun.zip" -Algorithm SHA256).Hash -ne $env:BUN_WINDOWS_SHA256) { throw "bun.zip checksum mismatch" }
|
||||
Expand-Archive "$tools\bun.zip" -DestinationPath $tools
|
||||
Add-Content $env:GITHUB_PATH "$tools\node-v$env:NODE_VERSION-win-x64"
|
||||
Add-Content $env:GITHUB_PATH "$tools\bun-windows-x64-baseline"
|
||||
Add-Content $env:GITHUB_ENV "TOOLS_DIR=$tools"
|
||||
|
||||
- name: Build, test, windows-x64 binary
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'; $PSNativeCommandUseErrorActionPreference = $true
|
||||
node --version; bun --version
|
||||
npm ci
|
||||
npm run build
|
||||
npm test
|
||||
npm run lint
|
||||
node scripts/build-binaries.mjs --no-build --host
|
||||
pwsh -NoProfile -File scripts/verify-binary.ps1 -Binary dist\qqq-windows-x64.exe
|
||||
|
||||
- name: Attach to the draft release
|
||||
id: attach
|
||||
shell: pwsh
|
||||
env:
|
||||
TOKEN: ${{ github.token }}
|
||||
RELEASE_ID: ${{ needs.linux.outputs.release-id }}
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'; $PSNativeCommandUseErrorActionPreference = $true
|
||||
curl.exe -fsS -o NUL -X POST -H "Authorization: token $env:TOKEN" -F "attachment=@dist/qqq-windows-x64.exe" `
|
||||
"$env:GITHUB_SERVER_URL/api/v1/repos/$env:GITHUB_REPOSITORY/releases/$env:RELEASE_ID/assets?name=qqq-windows-x64.exe"
|
||||
Write-Output "Attached qqq-windows-x64.exe"
|
||||
$hash = (Get-FileHash dist\qqq-windows-x64.exe -Algorithm SHA256).Hash.ToLowerInvariant()
|
||||
Add-Content $env:GITHUB_OUTPUT "sums=$hash qqq-windows-x64.exe;"
|
||||
|
||||
- name: Remove the tools
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: if ($env:TOOLS_DIR) { Remove-Item -Recurse -Force $env:TOOLS_DIR -ErrorAction SilentlyContinue }
|
||||
|
||||
publish:
|
||||
needs: [linux, macos, windows]
|
||||
runs-on: container-build
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: node:22.23.4-bookworm
|
||||
volumes:
|
||||
- /var/lib/gitea-job-workspace:${{ github.workspace }}
|
||||
env:
|
||||
HOME: /tmp
|
||||
CI: 'true'
|
||||
TOKEN: ${{ github.token }}
|
||||
PACKAGES_TOKEN: ${{ secrets.PACKAGES_TOKEN }}
|
||||
RELEASE_ID: ${{ needs.linux.outputs.release-id }}
|
||||
VERSION: ${{ needs.linux.outputs.version }}
|
||||
steps:
|
||||
- uses: builtin:checkout
|
||||
|
||||
- name: SHA256SUMS
|
||||
shell: bash
|
||||
env:
|
||||
SUMS: ${{ needs.linux.outputs.sums }}${{ needs.macos.outputs.sums }}${{ needs.windows.outputs.sums }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git config --global --add safe.directory "$GITHUB_WORKSPACE"
|
||||
mkdir -p /tmp/assets
|
||||
tr ';' '\n' <<< "$SUMS" | sed '/^$/d' | sort -k2 > /tmp/assets/SHA256SUMS
|
||||
cat /tmp/assets/SHA256SUMS
|
||||
for want in qqq-linux-x64 qqq-linux-arm64 qqq-darwin-x64 qqq-darwin-arm64 qqq-windows-x64.exe; do
|
||||
grep -Eq "^[0-9a-f]{64} $want\$" /tmp/assets/SHA256SUMS || { echo "No checksum for $want"; exit 1; }
|
||||
done
|
||||
curl -fsS -o /dev/null -X POST -H "Authorization: token $TOKEN" -F "attachment=@/tmp/assets/SHA256SUMS" \
|
||||
"$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID/assets?name=SHA256SUMS"
|
||||
|
||||
- name: Publish the release and check the downloads
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
prerelease=false; [[ $VERSION == *-* ]] && prerelease=true
|
||||
curl -fsS -o /dev/null -X PATCH -H "Authorization: token $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d "{\"draft\":false,\"prerelease\":$prerelease}" "$GITHUB_SERVER_URL/api/v1/repos/$GITHUB_REPOSITORY/releases/$RELEASE_ID"
|
||||
# What users download (the formula, the manifest, install.sh) must match SHA256SUMS.
|
||||
cd /tmp/assets
|
||||
while read -r _ file; do
|
||||
curl -fsSL -o "$file" "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/download/v$VERSION/$file"
|
||||
done < SHA256SUMS
|
||||
sha256sum --check --strict SHA256SUMS
|
||||
echo "Released: $GITHUB_SERVER_URL/$GITHUB_REPOSITORY/releases/tag/v$VERSION"
|
||||
|
||||
- name: Publish the npm packages
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z ${PACKAGES_TOKEN:-} ]]; then echo "PACKAGES_TOKEN is empty"; exit 1; fi
|
||||
npm ci
|
||||
npm run build
|
||||
registry="$GITHUB_SERVER_URL/api/packages/$GITHUB_REPOSITORY_OWNER/npm/"
|
||||
export NPM_CONFIG_USERCONFIG=/tmp/publish.npmrc
|
||||
printf '//%s:_authToken=${PACKAGES_TOKEN}\n@qrun-io:registry=%s\n' "${registry#*://}" "$registry" > "$NPM_CONFIG_USERCONFIG"
|
||||
# In dependency order. A version already in the registry is left alone (versions are immutable),
|
||||
# so a release republishes nothing that did not change; each package's own version picks its dist-tag.
|
||||
for dir in packages/ui-api-client packages/core packages/conformance packages/cli; do
|
||||
name=$(node -p "require('./$dir/package.json').name")
|
||||
version=$(node -p "require('./$dir/package.json').version")
|
||||
if npm view "$name@$version" version --registry "$registry" > /dev/null 2>&1; then
|
||||
echo "$name@$version is already published; skipped"; continue
|
||||
fi
|
||||
tag=latest; [[ $version == *-* ]] && tag=next
|
||||
(cd "$dir" && npm publish --registry "$registry" --tag "$tag")
|
||||
echo "Published $name@$version ($tag): $(npm view "$name@$version" version --registry "$registry")"
|
||||
done
|
||||
|
||||
- name: Homebrew tap and Scoop bucket (pull requests)
|
||||
# Final versions: branch release/qqq-<version> and a pull request "qqq <version>" in QRun-IO/homebrew-tap
|
||||
# and QRun-IO/scoop-bucket (PACKAGES_TOKEN); a human merges. Prereleases, or a refused push, attach
|
||||
# qqq.rb / qqq.json to the release instead. Never fails the release.
|
||||
shell: bash
|
||||
run: bash scripts/publish-packaging.sh
|
||||
@@ -1,7 +0,0 @@
|
||||
profile: cli
|
||||
orb_version: '0.8'
|
||||
default_branch: main
|
||||
publish:
|
||||
release: true
|
||||
publish_context: qqq-maven-registry-credentials
|
||||
platforms: [macos-arm64, windows-x64]
|
||||
+106
@@ -3,6 +3,112 @@
|
||||
All notable changes to the qqq CLI and its packages. Versions follow [semver](https://semver.org);
|
||||
`@qrun-io/qqq-ui-api-client` follows the UI API version of the document it is generated from.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Changed
|
||||
|
||||
- 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.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Windows: the DPAPI keyring passes secrets to PowerShell over stdin**, not on the command line, so a token never appears in the process list or in the Windows event log, and JSON, shell syntax, Unicode and leading or trailing whitespace round-trip unchanged. Secrets are decoded with UTF-8 on both sides, the decrypted BSTR is zeroed after reading, and a PowerShell failure reports "Windows credential protection failed" instead of the subprocess arguments. Carried locally by GG Forge since cli-v1.0.0.
|
||||
- **Windows: `qqq login --browser` opens the whole authorization URL.** The opener was `cmd /c start`, which splits the URL at every `&`, so the browser received only the part before the first query parameter (`client_id`, `state` and `redirect_uri` were lost) and the loopback redirect never came. The URL now goes to PowerShell in the environment variable `QQQ_BROWSER_URL` (`Start-Process -FilePath $env:QQQ_BROWSER_URL`, a fixed encoded command, hidden window; `QQQ_POWERSHELL` picks the shell). Carried locally by GG Forge since cli-v1.0.0.
|
||||
- **`qqq ui`: Tags (t) asks the server first.** The record screen reads the record's collaboration features (`getRecordFeatures`) before opening the tag editor and says "Tags are not enabled for this table." or "Your role cannot tag this record." instead of opening an editor whose save the server would refuse. Carried locally by GG Forge since cli-v1.0.0.
|
||||
- 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
|
||||
the same). Hiding keeps the conversation, its context and the draft; Esc hides too (with a draft,
|
||||
nothing is asked). The F-key bar has `11` (reserved, blank) and `12Ask` from 120 columns (dimmed
|
||||
without the extension); narrower, the `?` help and F9 › Work say "F12 assistant". F12 is read as the
|
||||
legacy `ESC [24~` and as the kitty keyboard protocol key; README notes macOS's F12 mapping.
|
||||
- Assistant panel: `u` on a link shows its address.
|
||||
- Kernel: `parseMarkdown`, `markdownText`, `answerDocument` and `linkTargetKey` (render/markdown.ts): the
|
||||
assistant's markdown as blocks with numbered links, one per target (a filter on the primary key is
|
||||
that record; JSON and base64 filters compare by content).
|
||||
- **`qqq ui`: the Shift bank.** Shift+F1…F10 are a second row of function keys: Keys (the `?`
|
||||
reference), Servers, SQL (SQL Runner), Bulk, Metadata (refresh), Views (saved views), Copy (into a new
|
||||
record), Export, History and Assistant (the same toggle as F12 and Ctrl+G). Like the primary bar they follow the
|
||||
screen: keys that do not apply are dimmed and say why when pressed. The assignments are one table bound to
|
||||
named actions (`assistant.toggle` and the rest), so other keys can bind the same actions.
|
||||
- **The F-key bar in two rows** (primary, then the Shift row marked `⇧`) on terminals of 30 lines or more;
|
||||
`:fkeys one|two|auto` and F9 › Settings › F-key bar choose (saved as `ui.fkeyBar`). In one-row mode the
|
||||
Shift row replaces the primary row for 2 seconds after a Shift+F press. Clicks on the Shift row run its
|
||||
keys; `?` lists the bank.
|
||||
- **One decoder for modified function keys** across terminals: xterm modifier forms (tmux, iTerm2, Windows
|
||||
Terminal), the kitty keyboard protocol, the legacy `ESC [25~`…`ESC [34~` numbering (VT220, rxvt and
|
||||
the Linux console, macOS Terminal.app; `QQQ_FKEYS` overrides the detection) and rxvt's `$ ^ @`
|
||||
suffixes. Ctrl+F-keys are decoded too (no bindings yet).
|
||||
|
||||
- **Save as PDF…** is the first entry of the UI's printer picker (record `p`, grid Ctrl+P, `:print`; `:print pdf`
|
||||
saves straight away): the server renders the same record sheet, table list or page (printing
|
||||
`printingRenderToPdf`) and the PDF is saved the way downloads and exports are — the save dialog or, with
|
||||
silent downloads, the download folder, the stamped name (`Customer-Ada-Lovelace_2026-11-01_120301.pdf`), no
|
||||
silent overwrite. The CLI's `qqq print`, `qqq print list` and `qqq print text` take `--to-file [path]` (a
|
||||
folder, a file, or the download folder by default).
|
||||
- Ctrl+P on screens that are not records or grids asks **Print what?**: **Content** (the default: the screen's
|
||||
panels as a document with their titles, without navigation or borders) or **Screen** (the terminal as drawn).
|
||||
- Every printed or saved page carries the app's header (instance name, breadcrumb, server, user, time in the
|
||||
profile's date format) and footer (`qqq ui <version> · <server>` or `qqq <version> · <server>`, a grid's filter
|
||||
and sort, page n of m); the CLI sends the same.
|
||||
- `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
|
||||
|
||||
- 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 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 `| --- |`
|
||||
rows. Links show as `label ↗` (CLI: `label (qqq table get …)` or `label [n]` with the command under
|
||||
Links), never their URL-encoded `/app/...?filter=` targets; the tool results' links are listed only
|
||||
when the text does not already show the same target, so `db_performance` no longer appears three times.
|
||||
Exact duplicate paragraphs are shown once; the timing and token line stays, dimmed.
|
||||
- **Shift+F3 and the other modified F3s** (`ESC [1;2R`) no longer vanish: OpenTUI took them for cursor
|
||||
position reports. They are rewritten on the input stream into `ESC [13;2~` outside the startup
|
||||
cursor-query window.
|
||||
|
||||
## [1.0.1-rc.1] - 2026-11-08
|
||||
|
||||
Release candidate built and published by Gitea CI from the tag `v1.0.1-rc.1`, the first through the new
|
||||
release path: `@qrun-io/qqq-cli` 1.0.1-rc.1, `@qrun-io/qqq-core` 1.0.1-rc.1 and
|
||||
`@qrun-io/qqq-ui-api-client` 1.8.1-rc.1 (dist-tag `next`) with `@qrun-io/qqq-ui-api-conformance` 1.6.0 on
|
||||
the QRun-IO Gitea npm registry, and the binaries with `SHA256SUMS` on the Gitea release. It carries the
|
||||
changes listed under Unreleased above as of this tag.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Releases move to Gitea.** A `v<CLI version>` tag runs `.gitea/workflows/release.yaml`: the
|
||||
`scripts/release.mjs` gate, the Linux, macOS arm64 (native) and Windows x64 (native) binaries, the npm
|
||||
packages on `https://git.kof22.com/api/packages/QRun-IO/npm/`, the Gitea release, and for final versions
|
||||
the Homebrew tap and Scoop bucket. The `cli-v*` tags and GitHub releases are retired; `scripts/install.sh`,
|
||||
the formula and the Scoop manifest download from `https://git.kof22.com/QRun-IO/qqq-cli/releases`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Windows: the DPAPI keyring works when `qqq` is started from PowerShell 7.** The inherited `PSModulePath`
|
||||
named PowerShell 7's modules, so Windows PowerShell 5.1 could not load `Microsoft.PowerShell.Security` and every
|
||||
sign-in failed with "Windows credential protection failed". The keyring now starts PowerShell without it.
|
||||
Found by the first native Windows release lane.
|
||||
|
||||
## [1.0.0] - 2026-11-01
|
||||
|
||||
`qqq` 1.0: the command-line client and the full-screen terminal UI (`qqq ui`), shipped together as one
|
||||
|
||||
@@ -190,10 +190,14 @@ returns at once; `qqq print job status <id> --watch` follows later; `qqq print j
|
||||
job still queued). The printer given with `--printer` (or `qqq printer use`) is remembered per profile.
|
||||
`qqq print list person -w "lastName = Odd" --order -id --columns id,firstName,lastName` prints a table list
|
||||
of the whole result (up to 10,000 rows; `--page 2` for one page), and `some-command | qqq print text --title Log`
|
||||
prints text. In `qqq ui`, **Ctrl+P prints what you see** on every screen: a record sheet, a table list of
|
||||
the grid (all rows or this page), or the screen as text. Tables print only where the server enabled Print
|
||||
prints text. Add `--to-file [folder|file]` to any of them to save the PDF instead of printing (no path: the
|
||||
download folder, with the stamped name exports use). In `qqq ui`, **Ctrl+P prints what you see** on every
|
||||
screen: a record sheet, a table list of the grid (all rows or this page), or a screen's content (or the
|
||||
screen as drawn), each page with the app's header and footer; **Save as PDF…** in the printer picker saves
|
||||
it like a download. Tables print only where the server enabled Print
|
||||
for them (qbit-printing 2.1 `registerAll` enables every table). For local testing without hardware,
|
||||
`scripts/print-agent.sh` runs qqq-print-agent's virtual file printer, which writes each job to a directory.
|
||||
`scripts/print-agent.sh` runs qqq-print-agent's virtual file printer, which writes each job to `~/Downloads`
|
||||
(or `QQQ_PRINT_OUT`; `--dir` shows the folder).
|
||||
|
||||
[TRY-IT.md](TRY-IT.md) walks through every command against the sample server.
|
||||
|
||||
@@ -213,6 +217,8 @@ qqq ui --theme light # high-contrast (defau
|
||||
- **The function-key bar** at the bottom is always right for the current screen: F1 Help, F2 Search,
|
||||
F3 View, F4 Edit, F5 Refresh, F6 Filter, F7 New, F8 Delete, F9 Menu, F10 Quit. When the terminal takes
|
||||
the F-keys, Esc then a digit (or Ctrl+digit) stands for F1 to F10.
|
||||
**Shift+F1…F10** are a second bank (Keys, Servers, SQL, Bulk, Metadata, Views, Copy, Export, History,
|
||||
Assistant), shown as a second bar row from 30 lines (`:fkeys one|two|auto`).
|
||||
- **F2** is Search: records across tables (quick search), the main way into the data. **`/`** (or `.`, Ctrl+K)
|
||||
is the find-anything palette: any app, table, process, widget, report, record or command, searched as you
|
||||
type, fast on instances with thousands of tables. **Ctrl+O** switches servers, **Ctrl+P** prints what you see.
|
||||
@@ -318,8 +324,8 @@ scripts/verify-binary.sh dist/qqq-darwin-arm64 --server http://localhost:8077
|
||||
```
|
||||
|
||||
The CLI runs on Node 20 and later and uses no Bun-only API; Bun is needed only for the TUI and the
|
||||
binaries. CI is the qqq-orb `cli` profile (`.qqq-ci.yml`); a tag `v<client version>` publishes the
|
||||
packages to GitHub Packages ([`docs/ci-lanes.md`](docs/ci-lanes.md) says what it does and does not cover).
|
||||
binaries. CI is the shared Gitea `node.yaml` workflow (`.gitea/workflows/build.yaml`): build, test, lint and
|
||||
a generated-code drift check on every push; it publishes nothing ([`docs/ci-lanes.md`](docs/ci-lanes.md)).
|
||||
|
||||
## Contributing, security, license
|
||||
|
||||
|
||||
+2
-2
@@ -23,9 +23,9 @@ Everything under `docs/`:
|
||||
| [release-notes/1.0.0.md](release-notes/1.0.0.md) | the GitHub release body for `cli-v1.0.0` (the release commands are in an HTML comment at the top) |
|
||||
| [release-notes/1.0.0-rc.1.md](release-notes/1.0.0-rc.1.md) | the GitHub release body for the release candidate `cli-v1.0.0-rc.1`, kept for history |
|
||||
| [packaging/README.md](packaging/README.md) | the install paths (curl installer, Homebrew, Scoop, npm), where the formula and manifest live, platform notes (Gatekeeper, SmartScreen, musl), and how the terminal UI is bundled |
|
||||
| [packaging/homebrew-tap/](packaging/homebrew-tap/README.md) | the complete `QRun-IO/homebrew-tap` repository, ready to copy: `Formula/qqq.rb`, README, LICENSE and its qqq-orb CI files |
|
||||
| [packaging/homebrew-tap/](packaging/homebrew-tap/README.md) | the complete `QRun-IO/homebrew-tap` repository, ready to copy: `Formula/qqq.rb`, README, LICENSE (its old qqq-orb CI files were removed with CircleCI) |
|
||||
| [packaging/qqq.json](packaging/qqq.json) | the Scoop manifest, copied to `bucket/qqq.json` in `QRun-IO/scoop-bucket` |
|
||||
| [ci-lanes.md](ci-lanes.md) | what CircleCI (the qqq-orb `cli` profile) runs: the Linux verify-and-publish job and the macOS arm64 and Windows x64 lanes, what is still outside CI, and the history of the orb changes |
|
||||
| [ci-lanes.md](ci-lanes.md) | what CI runs (the shared Gitea `node.yaml` workflow), what is outside CI, and the history of the retired CircleCI orb lanes |
|
||||
|
||||
`scripts/update-packaging.mjs` fills the version and checksums of the formula and manifest from a
|
||||
release's `SHA256SUMS`.
|
||||
|
||||
+10
-21
@@ -2,30 +2,19 @@
|
||||
|
||||
## What runs today
|
||||
|
||||
`.circleci/config.yml` is the qqq-orb setup stub on the `0.8` track (it picks up every 0.8.x release by
|
||||
itself); `.qqq-ci.yml` selects the orb's `cli` profile and, since qqq-orb **0.8.33**, turns on the
|
||||
platform lanes with `platforms: [macos-arm64, windows-x64]`. The profile generates three jobs:
|
||||
CircleCI and the qqq-orb were retired on 2026-11-07. CI is the shared Gitea workflow
|
||||
[`KofTwentyTwo/ci-workflows` `node.yaml`](https://git.kof22.com/KofTwentyTwo/ci-workflows), called from
|
||||
`.gitea/workflows/build.yaml` on every push, Linux only (`node:22-bookworm`):
|
||||
|
||||
| Job | Executor | What it runs |
|
||||
|---|---|---|
|
||||
| `cli-macos-arm64` (`qqq-orb/cli_platform`) | macOS, Xcode 16.4, `m4pro.medium` | Node 20.20.2 (the `engines` floor) with nvm, `npm ci`, `npm run build && npm test && npm run lint` |
|
||||
| `cli-windows-x64` (`qqq-orb/cli_platform`) | Windows Server 2022, `windows.medium`, steps in Git Bash | the same, on Windows (keyring DPAPI path, Windows paths) |
|
||||
| `qqq-orb/cli_verify_publish` | Linux, `cimg/node:22.23` | `npm ci`; `npm run generate` then `git diff --exit-code` on `schema.ts` and `operations.ts`; build, test, lint; a Prism smoke (`GET /qqq/v1/version`, `/metaData/authentication`); on a tag `v<client version>` with `publish.release: true`, publishing (below) |
|
||||
1. `npm ci`
|
||||
2. `npm run generate`, then `git diff --exit-code` on `schema.ts` and `operations.ts` (generated-code drift)
|
||||
3. `npm run build`, `npm test`, `npm run lint`
|
||||
|
||||
On branches and pull requests the three jobs run side by side. On a release tag `cli_verify_publish`
|
||||
**requires both platform lanes**, so nothing is published unless macOS and Windows are green. Publishing
|
||||
puts all four packages on GitHub Packages (client, core, conformance, CLI, in that order) with the dist-tag
|
||||
`latest` when all four versions are final and `rc` when any of them has a prerelease part. The CircleCI
|
||||
plan must include the `m4pro.medium` and `windows.medium` resource classes.
|
||||
Not in CI: publishing (packages, binaries, Homebrew/Scoop updates are release-machine steps in
|
||||
`docs/release-runbook.md` until a release workflow is decided), the macOS arm64 and Windows x64 lanes,
|
||||
the Prism smoke, the TUI (`npm run test:tui`, `typecheck:tui`, which need Bun) and the platform binaries.
|
||||
|
||||
The orb's generator accepts this manifest: rendered with `src/scripts/generate_pipeline.py` from qqq-orb
|
||||
v0.8.33, a push to `main` gives `cli-macos-arm64`, `cli-windows-x64` and `cli_verify_publish` in
|
||||
`test_only`, and the tag `v1.8.0` gives the same in `publish_release` with `cli_verify_publish` requiring
|
||||
both lanes and bound to the `qqq-maven-registry-credentials` context.
|
||||
|
||||
Still not in CI: the TUI (`npm run test:tui`, `typecheck:tui`, which need Bun), and building and smoking
|
||||
the platform binaries (`scripts/build-binaries.mjs --host`, `scripts/verify-binary.sh` / `.ps1`); the
|
||||
lanes run the Node packages only. Those stay release-machine steps in `docs/release-runbook.md`.
|
||||
Everything below describes the retired CircleCI setup and is kept for the record.
|
||||
|
||||
### History
|
||||
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ Versions merged on 2026-10-31 (machine-readable copy: `packages/ui-api-client/op
|
||||
| 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) |
|
||||
| collaboration | 1.2.0 | `/qbit/collaboration` | 45 | qbit-collaboration `feat/per-session-presence` 0ae1e62 (local worktree `.worktrees/qbit-collab-sessions`, not yet on `develop`) | 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` 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 | |
|
||||
|
||||
@@ -36,10 +36,8 @@ checkpoint the sums here first, not there.
|
||||
## Where the formula and manifest live
|
||||
|
||||
- **`QRun-IO/homebrew-tap`** is laid out under `homebrew-tap/` here (the repository is created by copying
|
||||
that directory). Its CI is the qqq-orb `homebrew-tap` profile (`.qqq-ci.yml`, with the same setup
|
||||
`.circleci/config.yml` as `QRun-IO/homebrew-qctl`). That CI fetches and checksums every platform's
|
||||
download, but it installs the formula and runs `brew test` with the **Linux x64 binary only**; the
|
||||
macOS (and Linux arm64) binaries are not executed there. qctl stays in `QRun-IO/homebrew-qctl` until it
|
||||
that directory). Its old CI (the qqq-orb `homebrew-tap` profile on CircleCI) was retired on
|
||||
2026-11-07 and the seed no longer carries CI files; tap CI on Gitea is not decided yet. qctl stays in `QRun-IO/homebrew-qctl` until it
|
||||
moves over.
|
||||
- **`QRun-IO/scoop-bucket`** is created from
|
||||
[ScoopInstaller/BucketTemplate](https://github.com/ScoopInstaller/BucketTemplate), with `qqq.json` as
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
version: 2.1
|
||||
setup: true
|
||||
orbs:
|
||||
qqq-orb: kingsrook/qqq-orb@0.8
|
||||
workflows:
|
||||
setup:
|
||||
jobs:
|
||||
- qqq-orb/generate_pipeline:
|
||||
orb_version: '0.8'
|
||||
filters:
|
||||
branches:
|
||||
ignore: automation/qbit-rc-pending
|
||||
tags:
|
||||
ignore: /.*/
|
||||
@@ -1,5 +0,0 @@
|
||||
profile: homebrew-tap
|
||||
orb_version: '0.8'
|
||||
default_branch: main
|
||||
tap: qrun-io/tap
|
||||
formula: qqq
|
||||
@@ -2,31 +2,31 @@
|
||||
|
||||
# qqq: the QQQ command-line client and terminal UI (qqq ui) in one binary.
|
||||
# Kept in qqq-cli at docs/packaging/homebrew-tap/Formula/qqq.rb; scripts/update-packaging.mjs fills
|
||||
# version and sha256 from the SHA256SUMS of a GitHub release, then the file is copied to the tap.
|
||||
# version and sha256 from the SHA256SUMS of a Gitea release; the release workflow then copies it to the tap.
|
||||
class Qqq < Formula
|
||||
desc "Command-line client and terminal UI for QQQ applications"
|
||||
homepage "https://github.com/QRun-IO/qqq-cli"
|
||||
homepage "https://git.kof22.com/QRun-IO/qqq-cli"
|
||||
version "1.0.0"
|
||||
license "Apache-2.0"
|
||||
|
||||
on_macos do
|
||||
on_arm do
|
||||
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-darwin-arm64"
|
||||
url "https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v#{version}/qqq-darwin-arm64"
|
||||
sha256 "5017a32ac5031d9146d541381099a959e5c19bbf04c48f725045d89d6007b211"
|
||||
end
|
||||
on_intel do
|
||||
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-darwin-x64"
|
||||
url "https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v#{version}/qqq-darwin-x64"
|
||||
sha256 "eb9b1d43d24b639118d94b175c343080a39ae9ba64c24645c1792ff3b16a37f1"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
on_arm do
|
||||
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-linux-arm64"
|
||||
url "https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v#{version}/qqq-linux-arm64"
|
||||
sha256 "25f513bdda13bbe40cf536844eebc1e3a7e21cf7804024f25ebe7c42793d7ebc"
|
||||
end
|
||||
on_intel do
|
||||
url "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v#{version}/qqq-linux-x64"
|
||||
url "https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v#{version}/qqq-linux-x64"
|
||||
sha256 "804d06cb7c0012c2f185ae5d94c6bb8f9e2482a440bb3c165361f1a14c941e41"
|
||||
end
|
||||
end
|
||||
|
||||
@@ -20,9 +20,9 @@ here (`Formula/qctl.rb`) once this tap exists, with `homebrew-qctl` kept as a po
|
||||
|
||||
The formula is generated in qqq-cli: after a `cli-v<version>` GitHub release, run
|
||||
`node scripts/update-packaging.mjs <release SHA256SUMS>` there and copy
|
||||
`docs/packaging/homebrew-tap/Formula/qqq.rb` here. CI (qqq-orb `homebrew-tap` profile) fetches every
|
||||
platform's download, installs the formula and runs its test, which fails on a wrong or all-zero sum, but
|
||||
only once the pull request here is opened and its checks are read. The script refuses to write missing or
|
||||
`docs/packaging/homebrew-tap/Formula/qqq.rb` here. The tap's old CI (qqq-orb `homebrew-tap` profile,
|
||||
which installed the formula and ran its test) was retired on 2026-11-07; Gitea CI for the tap is not
|
||||
decided yet, so check the formula by hand (`brew install --build-from-source ./Formula/qqq.rb && brew test qqq`). The script refuses to write missing or
|
||||
malformed sums; still compare the sums in the diff with the release's `SHA256SUMS` before opening the PR.
|
||||
|
||||
Apache-2.0
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"description": "Command-line client and terminal UI (qqq ui) for QQQ applications, in one binary",
|
||||
"homepage": "https://github.com/QRun-IO/qqq-cli",
|
||||
"homepage": "https://git.kof22.com/QRun-IO/qqq-cli",
|
||||
"license": "Apache-2.0",
|
||||
"notes": [
|
||||
"qqq ui (the terminal UI) needs Windows Terminal or another ConPTY terminal; the legacy console is not supported.",
|
||||
@@ -9,7 +9,7 @@
|
||||
],
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v1.0.0/qqq-windows-x64.exe",
|
||||
"url": "https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v1.0.0/qqq-windows-x64.exe",
|
||||
"hash": "cf418d7842a227cee412171878228004f9bc5098a272c64b89512d1e2d06a176"
|
||||
}
|
||||
},
|
||||
@@ -20,13 +20,14 @@
|
||||
]
|
||||
],
|
||||
"checkver": {
|
||||
"github": "https://github.com/QRun-IO/qqq-cli",
|
||||
"regex": "cli-v([\\d.]+(?:-rc\\.\\d+)?)"
|
||||
"url": "https://git.kof22.com/api/v1/repos/QRun-IO/qqq-cli/releases/latest",
|
||||
"jsonpath": "$.tag_name",
|
||||
"regex": "v([\\d.]+)"
|
||||
},
|
||||
"autoupdate": {
|
||||
"architecture": {
|
||||
"64bit": {
|
||||
"url": "https://github.com/QRun-IO/qqq-cli/releases/download/cli-v$version/qqq-windows-x64.exe",
|
||||
"url": "https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v$version/qqq-windows-x64.exe",
|
||||
"hash": {
|
||||
"url": "$baseurl/SHA256SUMS"
|
||||
}
|
||||
|
||||
+95
-195
@@ -1,240 +1,140 @@
|
||||
# Release runbook: qqq 1.0.0
|
||||
# Release runbook
|
||||
|
||||
From "the branch is green" to "1.0.0 is published and installable everywhere". It is written for
|
||||
1.0.0 but holds for any release; replace the versions. Nothing in the repository tags, pushes or
|
||||
publishes by itself: `scripts/release.mjs` checks and prints, people run the commands.
|
||||
From "main is green" to "qqq X.Y.Z is published and installable everywhere". A release is one tag,
|
||||
`v<CLI version>`, on the merge commit; Gitea CI (`.gitea/workflows/release.yaml`) does the rest. Nothing
|
||||
publishes from a branch.
|
||||
|
||||
> **Since 2026-11-08** releases run on Gitea. The CircleCI/qqq-orb path (npm packages on a
|
||||
> `v<client version>` tag, binaries on a GitHub release `cli-v<version>`, hand-made tap and bucket pull
|
||||
> requests) is retired. Older releases (1.0.0 and before) stay on GitHub.
|
||||
|
||||
What ships:
|
||||
|
||||
| Artifact | Version for 1.0 | Where | Made by |
|
||||
|---|---|---|---|
|
||||
| `qqq` binaries with the terminal UI, 7 targets, plus `SHA256SUMS` | 1.0.0 | GitHub release `cli-v1.0.0` | `scripts/build-binaries.mjs` (via `release.mjs`) |
|
||||
| `@qrun-io/qqq-cli` (CLI only, Node 20+) | 1.0.0 | GitHub Packages | the orb, on tag `v1.8.0` |
|
||||
| `@qrun-io/qqq-core` | 1.0.0 | GitHub Packages | the orb (with PR #165), else by hand |
|
||||
| `@qrun-io/qqq-ui-api-client` | 1.8.0 | GitHub Packages | the orb |
|
||||
| `@qrun-io/qqq-ui-api-conformance` | 1.6.0 | GitHub Packages | the orb |
|
||||
| Artifact | Where | Made by |
|
||||
|---|---|---|
|
||||
| `qqq` binaries with the terminal UI, 5 targets (linux x64/arm64, darwin x64/arm64, windows x64), plus `SHA256SUMS` | Gitea release `v<version>` of `QRun-IO/qqq-cli` | `release.yaml`: Linux targets and darwin-x64 (cross) in the container, darwin-arm64 on the macOS runner, windows-x64 on the Windows runner |
|
||||
| `@qrun-io/qqq-ui-api-client`, `qqq-core`, `qqq-ui-api-conformance`, `qqq-cli` | QRun-IO Gitea npm registry, `https://git.kof22.com/api/packages/QRun-IO/npm/` | `release.yaml` publish job |
|
||||
| Homebrew formula `qqq` | `QRun-IO/homebrew-tap`, `Formula/qqq.rb` | pull request from `release.yaml` (final versions), merged by a human |
|
||||
| Scoop manifest `qqq` | `QRun-IO/scoop-bucket`, `bucket/qqq.json` | pull request from `release.yaml` (final versions), merged by a human |
|
||||
|
||||
**All four npm packages go final together.** The orb (PR #165) derives the dist-tag from all four
|
||||
versions: if any of client, core, conformance or CLI has a prerelease part, all four are published as
|
||||
`rc`; only when all four are final do they go out as `latest`. So 1.0.0 ships client 1.8.0 and
|
||||
conformance 1.6.0 as finals beside CLI and core 1.0.0, under the tags `v1.8.0` (orb publish) and
|
||||
`cli-v1.0.0` (GitHub release). `release.mjs` refuses a final CLI version while any of the four is still a
|
||||
prerelease (the readiness gate's "package versions" line).
|
||||
| Homebrew formula `qqq` | 1.0.0 | `QRun-IO/homebrew-tap` | `scripts/update-packaging.mjs`, then a PR |
|
||||
| Scoop manifest `qqq` | 1.0.0 | `QRun-IO/scoop-bucket` | `scripts/update-packaging.mjs`, then a PR |
|
||||
## 1. Versions
|
||||
|
||||
## Checklist: what needs James
|
||||
|
||||
Nobody else can do these; each says what happens without it.
|
||||
|
||||
- [x] **Orb changes released** in `kingsrook/qqq-orb` 0.8.33 (`docs/ci-lanes.md`): publishing
|
||||
`@qrun-io/qqq-core`, lint, the `operations.ts` drift check, the dist-tag rule above (PR #165), and
|
||||
the macOS arm64 and Windows x64 lanes, which `.qqq-ci.yml` turns on (`platforms:`) and a release tag
|
||||
requires before publishing.
|
||||
- [ ] **CircleCI plan** includes the `m4pro.medium` (macOS) and `windows.medium` resource classes. Without
|
||||
them the platform lanes cannot start, and because a release tag requires them, the publish waits;
|
||||
remove `platforms:` from `.qqq-ci.yml` to publish without them.
|
||||
- [ ] **Apple Developer ID and notarization**: a "Developer ID Application" certificate and a notary
|
||||
credential (App Store Connect API key) on the release machine. Without it the macOS binaries are
|
||||
only ad-hoc signed. Homebrew and the curl installer are not affected (they do not set the
|
||||
quarantine flag), but a binary downloaded with a browser is stopped by Gatekeeper ("Apple cannot
|
||||
check it for malicious software"). Workaround, documented in the README and the packaging notes:
|
||||
`xattr -d com.apple.quarantine ./qqq-darwin-arm64`, or Finder, right-click, Open.
|
||||
- [ ] **Windows Authenticode certificate** (the KofTwentyTwo release standard). Without it SmartScreen
|
||||
warns on first run ("Windows protected your PC", More info, Run anyway); Scoop installs still work.
|
||||
- [x] **`QRun-IO/homebrew-tap` and `QRun-IO/scoop-bucket` exist** (created from `docs/packaging/homebrew-tap/` and
|
||||
`docs/packaging/qqq.json`). After the GitHub release, run `scripts/update-packaging.mjs` on the uploaded
|
||||
`SHA256SUMS` and commit the filled `Formula/qqq.rb` and `bucket/qqq.json` to them. Without that commit
|
||||
`brew install` and `scoop install` fail their checksum (the curl installer and the download still work).
|
||||
- [ ] **CircleCI context** `qqq-maven-registry-credentials` holds a `GITHUB_TOKEN` that can write the
|
||||
four `@qrun-io` packages (the orb checks it authenticates; write access is only proven by a publish).
|
||||
- [ ] **Go for 1.0.0 final**: the 1.0.0-rc.1 feedback is accepted and no blocker is open.
|
||||
|
||||
## Not verified before 1.0
|
||||
|
||||
The platform lanes run the Node packages' build, tests and lint on macOS arm64 and Windows x64 (Node
|
||||
20), which covers the keyring code paths there, but they do not build or run the binaries.
|
||||
|
||||
- `qqq-windows-x64.exe` (CLI and UI) has never run; `scripts/verify-binary.ps1` is the smoke to run on
|
||||
a Windows machine.
|
||||
- `qqq-darwin-x64` has never run (no Intel Mac or Rosetta on the build machine).
|
||||
- Windows Terminal rendering of `qqq ui`, and the DPAPI keyring outside unit tests.
|
||||
|
||||
Verified on the build machine: darwin-arm64 (CLI, keyring, completion, `qqq ui` tmux smoke and width
|
||||
smoke against the sample server), linux-x64 glibc, linux-arm64 glibc and linux-arm64-musl in Docker
|
||||
(CLI and `qqq ui` in tmux), the npm tarballs installed on Node 20, the Homebrew formula in the
|
||||
`homebrew/brew` container, and the curl installer from a local mirror.
|
||||
|
||||
## 1. Branch green
|
||||
|
||||
- `feat/m5-cli-release` (and whatever else is in the release) is merged to `main`; the orb pipeline on
|
||||
`main` is green: `cli_verify_publish` (Linux, `cimg/node:22.23`) and the `cli-macos-arm64` and
|
||||
`cli-windows-x64` lanes.
|
||||
- Locally on `main`, clean tree: `npm ci && npm run build && npm test && npm run lint && npm run test:tui`.
|
||||
|
||||
## 2. Versions: release candidate to final
|
||||
|
||||
**For 1.0.0 this step is done** on the branch `release/1.0.0` (34bbd2a, on top of the rc.1 commit
|
||||
6517373): all four packages final, the exact workspace pins, the `## [1.0.0]` CHANGELOG entry,
|
||||
`docs/release-notes/1.0.0.md`, and the READMEs at 1.0.0. That branch (with the release docs merged into
|
||||
it) is fast-forwarded onto `main`; nothing else changes versions:
|
||||
|
||||
```sh
|
||||
git checkout main && git pull --ff-only
|
||||
git merge --ff-only release/1.0.0 # fails if main moved: rebase release/1.0.0 onto main first
|
||||
git push origin main
|
||||
```
|
||||
|
||||
For later releases, the table below is the recipe.
|
||||
|
||||
On a branch `release/1.0.0` from `main`:
|
||||
On a branch from `main`:
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `packages/cli/package.json`, `packages/cli/src/version.ts` (`CLI_VERSION`) | `1.0.0-rc.N` → `1.0.0` |
|
||||
| `packages/core/package.json` | `1.0.0-rc.N` → `1.0.0` |
|
||||
| `packages/ui-api-client/package.json` | `1.8.0-rc.N` → `1.8.0` (the client follows the UI API version) |
|
||||
| `packages/conformance/package.json` | `1.6.0-rc.N` → `1.6.0` |
|
||||
| `packages/cli/package.json`, `packages/cli/src/version.ts` (`CLI_VERSION`) | the CLI version, e.g. `1.1.0-rc.1` or `1.1.0` |
|
||||
| `packages/core/package.json`, `packages/conformance/package.json` | bump any package whose content changed |
|
||||
| `packages/ui-api-client/package.json` | follows the UI API version of `openapi/qqq-v1.json` |
|
||||
| workspace dependencies in `packages/cli` and `packages/core` | the same versions, exact |
|
||||
| `CHANGELOG.md` | a `## [1.0.0] - <date>` entry above the rc entries |
|
||||
| `README.md`, `packages/cli/README.md` | the version and `@rc` in the npm command (`@latest`, or no tag) |
|
||||
| `CHANGELOG.md` | a `## [X.Y.Z] - <date>` entry (it becomes the release notes; `docs/release-notes/X.Y.Z.md` wins if present) |
|
||||
|
||||
All four packages move to finals in the same commit; none may stay `-rc` (the orb would then publish all
|
||||
four as `rc`, and `release.mjs` refuses). Then `npm install` (refreshes `package-lock.json`),
|
||||
`npm run lint`, which fails if any version disagrees, and `npm run release -- --check`, which must say
|
||||
READY (parity 100% in both columns, every finding filed, the CHANGELOG entry, documented commands,
|
||||
package versions). Commit (`git commit -S -m "release: 1.0.0"`), merge to `main` through a pull request.
|
||||
Then `npm install` (refreshes `package-lock.json`), `npm run lint` (fails if any version disagrees) and
|
||||
`node scripts/release.mjs` (the same gate CI runs; add `--skip-binaries` to save time). A final version
|
||||
must pass the readiness gate (`npm run release -- --check`: parity, findings filed, CHANGELOG entry,
|
||||
documented commands, all four packages final); a prerelease is not gated. Merge through a pull request.
|
||||
|
||||
## 3. Release check and binaries
|
||||
Each package version is published once. The publish job skips a package whose exact version is already
|
||||
in the registry (unchanged packages ride along), so bump every package whose content changed.
|
||||
|
||||
On `main` at the release commit, clean tree:
|
||||
## 2. Tag
|
||||
|
||||
```sh
|
||||
node scripts/release.mjs # the readiness gate (final versions), npm ci, generate drift, build, test,
|
||||
# lint, TUI tests and type check, pack dry runs, the 7 binaries with the UI
|
||||
# and dist/SHA256SUMS
|
||||
scripts/verify-binary.sh dist/qqq-darwin-arm64 --server http://localhost:8077 # sample server running
|
||||
git checkout main && git pull --ff-only
|
||||
git tag -s v1.1.0 -m "qqq 1.1.0" && git push origin v1.1.0
|
||||
```
|
||||
|
||||
`release.mjs` prints the commands for steps 5 and 6 with the right versions.
|
||||
Or create the tag on the merge commit through the Gitea API (`POST /repos/QRun-IO/qqq-cli/tags`). The tag
|
||||
must equal `packages/cli/package.json` or the release fails at once. A version with a `-` (`-rc.N`) is a
|
||||
prerelease: npm dist-tag `next` for prerelease packages, a Gitea prerelease, and no tap or bucket update.
|
||||
|
||||
## 4. Sign (James's credentials)
|
||||
## 3. What CI does
|
||||
|
||||
Signing changes the files, so it comes before the checksums are final. Each script reads its
|
||||
credentials from the environment, does nothing (and says what is missing) without them, has
|
||||
`--dry-run`, and recomputes `dist/SHA256SUMS`.
|
||||
`.gitea/workflows/release.yaml`, in order; any failure stops it:
|
||||
|
||||
1. **linux** (`container-build`, `node:22.23.4-bookworm`, Bun 1.4.4 checksum-pinned):
|
||||
`scripts/release.mjs --skip-binaries` (npm ci, generate drift, build, readiness for finals, tests, lint,
|
||||
TUI tests and type check, pack dry runs), then the linux-x64, linux-arm64 and darwin-x64
|
||||
binaries (`scripts/build-binaries.mjs`), the `scripts/verify-binary.sh` smoke on linux-x64, and a
|
||||
**draft** Gitea release with those binaries. An existing release for the tag fails the run.
|
||||
2. **macos** (`qqq-macos-arm64`) and **windows** (`qqq-windows-vm6218`), in parallel: pinned Node.js
|
||||
(`SHASUMS256.txt`) and Bun (pinned sha256), `npm ci`, build, test, lint, the native binary
|
||||
(`--host`), the `verify-binary.sh` / `verify-binary.ps1` smoke, and the upload to the draft.
|
||||
3. **publish** (`container-build`): `SHA256SUMS` from the three jobs' checksums, uploaded; the release is
|
||||
published and every download is checked against `SHA256SUMS`; the four packages go to the Gitea npm
|
||||
registry with `PACKAGES_TOKEN`; `scripts/update-packaging.mjs` fills the formula and the manifest.
|
||||
For a final version `scripts/publish-packaging.sh` pushes branch `release/qqq-<version>` to
|
||||
`QRun-IO/homebrew-tap` and `QRun-IO/scoop-bucket` with `PACKAGES_TOKEN` and opens a pull request
|
||||
`qqq <version>` in each; a human checks the sums and merges. When the push or the pull request is refused
|
||||
(or for a prerelease) `qqq.rb` and `qqq.json` are attached to the release instead (step 4); the release
|
||||
never fails over this. Dry run, no writes: `DRY_RUN=1 VERSION=<version> SUMS_FILE=<SHA256SUMS> scripts/publish-packaging.sh`.
|
||||
|
||||
Watch it under Actions in `QRun-IO/qqq-cli`. The release page is
|
||||
`https://git.kof22.com/QRun-IO/qqq-cli/releases/tag/v<version>`.
|
||||
|
||||
**No musl binaries.** `build-binaries.mjs --musl` is best effort: on the glibc build container Bun does not
|
||||
install OpenTUI's musl native packages, so the release carries no `qqq-linux-*-musl`. On Alpine and other
|
||||
musl hosts use the npm package (`@qrun-io/qqq-cli`, CLI only) until a musl build lane exists.
|
||||
|
||||
## 4. Homebrew tap and Scoop bucket (when CI could not push)
|
||||
|
||||
`PACKAGES_TOKEN` is the package-scoped publisher token (user `qqq-package-publisher`); unless it is granted
|
||||
write on the tap and the bucket, CI attaches the filled files to the release instead. Their `main` branches
|
||||
should be protected so CI can only propose. For a final version, download them and open a
|
||||
pull request on each:
|
||||
|
||||
```sh
|
||||
QQQ_MACOS_SIGN_IDENTITY="Developer ID Application: <name> (<team>)" QQQ_NOTARY_PROFILE=qqq-notary \
|
||||
scripts/sign-macos.sh # codesign, hardened runtime + scripts/macos-entitlements.plist, notarytool --wait
|
||||
QQQ_WINDOWS_PFX=<cert.pfx> QQQ_WINDOWS_PFX_PASSWORD_FILE=<file> \
|
||||
scripts/sign-windows.sh # osslsigncode with an RFC 3161 timestamp
|
||||
scripts/verify-binary.sh dist/qqq-darwin-arm64 --server http://localhost:8077 # still runs after signing
|
||||
spctl --assess --type execute -vv dist/qqq-darwin-arm64 # "accepted, source=Notarized Developer ID"
|
||||
curl -fsSLO https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v1.1.0/qqq.rb
|
||||
curl -fsSLO https://git.kof22.com/QRun-IO/qqq-cli/releases/download/v1.1.0/qqq.json
|
||||
# QRun-IO/homebrew-tap: Formula/qqq.rb QRun-IO/scoop-bucket: bucket/qqq.json
|
||||
```
|
||||
|
||||
A bare executable cannot be stapled: Gatekeeper fetches the notarization ticket online the first time a
|
||||
quarantined (browser-downloaded) copy runs, and refuses offline until it can. Homebrew and the curl
|
||||
installer do not quarantine, so they work signed or not. Tried here without real credentials: an ad-hoc
|
||||
hardened-runtime signature with the entitlements (the CLI and the `qqq ui` smoke still pass), and an
|
||||
osslsigncode signature with a throwaway certificate that verifies. A real Developer ID, notarization
|
||||
and an Authenticode certificate are untried until James's credentials are used.
|
||||
Compare each sum with the release's `SHA256SUMS`. A wrong sum makes `brew install` and `scoop install`
|
||||
fail their checksum; the curl installer and direct downloads are unaffected.
|
||||
|
||||
## 5. Tags and packages
|
||||
## 5. Signing (not automated)
|
||||
|
||||
```sh
|
||||
git tag -s v1.8.0 -m "UI API client 1.8.0, qqq 1.0.0" # the orb publishes on v<client version>
|
||||
git tag -s cli-v1.0.0 -m "qqq 1.0.0" # the GitHub release with the binaries
|
||||
git push origin v1.8.0 cli-v1.0.0
|
||||
```
|
||||
The binaries are not signed by CI: there is no Apple Developer ID / notary credential or Authenticode
|
||||
certificate on the runners. Homebrew and the curl installer do not quarantine, so they work unsigned; a
|
||||
browser-downloaded macOS binary needs `xattr -d com.apple.quarantine ./qqq-darwin-arm64`, and Windows
|
||||
SmartScreen warns on first run. `scripts/sign-macos.sh` and `scripts/sign-windows.sh` remain for a manual
|
||||
signed release (they recompute `dist/SHA256SUMS`); wiring them into the native jobs needs the credentials
|
||||
as runner-side secrets (owner decision).
|
||||
|
||||
The tag `v1.8.0` makes the orb's `cli` profile verify the commit again and publish client, core,
|
||||
conformance and CLI to GitHub Packages, all as `latest` because all four are final. Watch the
|
||||
`publish_release` workflow. Only if the orb release with PR #165 is not out yet, finish by hand:
|
||||
Gitea has no equivalent of GitHub's artifact attestations; `SHA256SUMS` on the release and the signed tag
|
||||
are the provenance.
|
||||
|
||||
```sh
|
||||
npm publish -w @qrun-io/qqq-core --registry https://npm.pkg.github.com --tag latest
|
||||
for p in qqq-ui-api-client qqq-core qqq-ui-api-conformance qqq-cli; do
|
||||
npm dist-tag add "@qrun-io/$p@<its version>" latest --registry https://npm.pkg.github.com
|
||||
done
|
||||
```
|
||||
|
||||
A version can never be published twice: if the orb job fails after publishing some packages, bump the
|
||||
patch version of the rest instead of retrying the same one.
|
||||
|
||||
## 6. GitHub release
|
||||
|
||||
```sh
|
||||
gh release create cli-v1.0.0 --repo QRun-IO/qqq-cli --latest --title "qqq 1.0.0" \
|
||||
--notes-file <(sed -n '/^## \[1\.0\.0\]/,/^## \[/p' CHANGELOG.md | sed '$d') \
|
||||
dist/qqq-* dist/SHA256SUMS
|
||||
```
|
||||
|
||||
`dist/qqq-*` is the seven binaries (source maps are in `dist/sourcemaps/`, not uploaded). For a release
|
||||
candidate use `--prerelease` instead of `--latest`. The curl installer takes the newest stable `cli-v*`
|
||||
release (the channel Homebrew and Scoop follow); a release candidate is reached with `--pre` or
|
||||
`--version 1.x.y-rc.N`.
|
||||
|
||||
## 7. Homebrew tap and Scoop bucket
|
||||
|
||||
```sh
|
||||
gh release download cli-v1.0.0 --repo QRun-IO/qqq-cli -p SHA256SUMS -D /tmp/qqq-1.0.0
|
||||
node scripts/update-packaging.mjs /tmp/qqq-1.0.0/SHA256SUMS
|
||||
git diff docs/packaging # every sha256 differs from the placeholder, none is all zeros
|
||||
git commit -S -am "packaging: qqq 1.0.0" && git push # in qqq-cli, through a pull request
|
||||
```
|
||||
|
||||
The script exits 1 and writes nothing if `SHA256SUMS` lacks a target or has a malformed sum. Compare each sum
|
||||
in the diff with the release's `SHA256SUMS` by eye before committing: the tap's CI and the bucket's manifest
|
||||
tests catch a wrong sum (the download will not match) only when someone opens those pull requests and reads
|
||||
their checks.
|
||||
|
||||
Then copy `docs/packaging/homebrew-tap/Formula/qqq.rb` to `Formula/qqq.rb` in `QRun-IO/homebrew-tap`
|
||||
and `docs/packaging/qqq.json` to `bucket/qqq.json` in `QRun-IO/scoop-bucket`, a pull request on each. The tap's
|
||||
CI fetches every platform's download, installs the formula and runs its test (which insists on
|
||||
`binary with ui`). Scoop's `checkver`/`autoupdate` can take later versions from `SHA256SUMS` by itself.
|
||||
|
||||
## 8. Smoke after publishing
|
||||
## 6. Smoke after publishing
|
||||
|
||||
Against a real server (`--server`), from clean machines or containers:
|
||||
|
||||
```sh
|
||||
# curl installer (macOS, Linux)
|
||||
curl -fsSL https://raw.githubusercontent.com/QRun-IO/qqq-cli/main/scripts/install.sh | sh -s -- --version 1.0.0
|
||||
curl -fsSL https://git.kof22.com/QRun-IO/qqq-cli/raw/branch/main/scripts/install.sh | sh -s -- --version 1.1.0
|
||||
scripts/verify-binary.sh ~/.local/bin/qqq --server https://<server>
|
||||
|
||||
# Homebrew
|
||||
brew install qrun-io/tap/qqq && brew test qrun-io/tap/qqq && scripts/verify-binary.sh "$(brew --prefix)/bin/qqq"
|
||||
brew install qrun-io/tap/qqq && brew test qrun-io/tap/qqq
|
||||
|
||||
# npm, Node 20
|
||||
echo "@qrun-io:registry=https://npm.pkg.github.com" >> ~/.npmrc
|
||||
npm install -g @qrun-io/qqq-cli@1.0.0 && scripts/verify-binary.sh qqq --cli-only --server https://<server>
|
||||
|
||||
# Docker (glibc and musl)
|
||||
docker run --rm --platform linux/amd64 -v "$PWD/dist:/d:ro" -v "$PWD/scripts:/s:ro" debian:bookworm-slim bash /s/verify-binary.sh /d/qqq-linux-x64
|
||||
# npm, Node 20+
|
||||
echo "@qrun-io:registry=https://git.kof22.com/api/packages/QRun-IO/npm/" >> ~/.npmrc
|
||||
npm install -g @qrun-io/qqq-cli@1.1.0 && scripts/verify-binary.sh qqq --cli-only --server https://<server>
|
||||
```
|
||||
|
||||
```powershell
|
||||
# Windows, PowerShell
|
||||
scoop bucket add qrun https://github.com/QRun-IO/scoop-bucket; scoop install qqq
|
||||
scoop bucket add qrun https://git.kof22.com/QRun-IO/scoop-bucket; scoop install qqq
|
||||
pwsh scripts\verify-binary.ps1 -Binary (Get-Command qqq).Source -Server https://<server>
|
||||
```
|
||||
|
||||
And by hand once: download `qqq-darwin-arm64` with a browser on a Mac and run it (notarized: it runs;
|
||||
not notarized: the Gatekeeper prompt, cleared by `xattr -d com.apple.quarantine`), then `qqq ui` in
|
||||
Windows Terminal.
|
||||
|
||||
## 9. After
|
||||
|
||||
- Merge `main` back into the development branch; the next development version can stay at 1.0.0 until
|
||||
the next bump (lint keeps the versions consistent either way).
|
||||
- Announce with the release link and the install commands from the README.
|
||||
Users who added the tap or the bucket from GitHub re-add them from Gitea
|
||||
(`brew tap qrun-io/tap https://git.kof22.com/QRun-IO/homebrew-tap`).
|
||||
|
||||
## When something goes wrong
|
||||
|
||||
- **A package is broken**: `npm deprecate @qrun-io/<pkg>@<version> "<reason>"` and publish a patch; a
|
||||
version cannot be unpublished from GitHub Packages without breaking installs that pinned it.
|
||||
- **A binary is broken**: delete the asset from the GitHub release, rebuild, re-sign, upload, recompute
|
||||
`SHA256SUMS` and rerun step 7 (the formula and manifest carry the sums).
|
||||
- **The tap's CI fails on the new formula**: do not merge its pull request; `brew install` keeps
|
||||
serving the previous version.
|
||||
- **The run fails before the publish job**: nothing is public. Delete the draft release (and the tag, if
|
||||
the version must change), fix, and tag again.
|
||||
- **A package is broken**: `npm deprecate @qrun-io/<pkg>@<version> "<reason>"` against the Gitea registry
|
||||
and publish a new version; a version is never republished.
|
||||
- **A binary is broken**: delete the release, bump the version and release again; the formula and the
|
||||
manifest carry the sums, so never replace an asset in place.
|
||||
- **The tap's or bucket's check fails on the new file**: do not merge; users keep the previous version.
|
||||
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.0.1-rc.1",
|
||||
"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.0.1-rc.1",
|
||||
"@qrun-io/qqq-ui-api-client": "1.8.1-rc.1",
|
||||
"@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.0.1-rc.1",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@qrun-io/qqq-ui-api-client": "1.8.0",
|
||||
"@qrun-io/qqq-ui-api-client": "1.8.1-rc.1",
|
||||
"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.8.1-rc.1",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ the options.
|
||||
| `report`, `saved-report`, `saved-view`, `share`, `bulk-profile`, `audit`, `widget`, `script`, `job`, `prefs`, `help-search` | reports, saved reports, saved views, sharing, bulk-load mappings, audits, widgets as text, scripts, jobs, preferences, help |
|
||||
| `api <operationId>` | any UI API operation by id |
|
||||
| `extensions`, `esb`, `collab`, `health`, `alerts`, ... | one noun per extension, run when the server has it installed |
|
||||
| `assistant ask`, `assistant config` | ask the in-app assistant (qbit-assistant) a question about your data: the answer streams, then its numbers and the records and tables it refers to (`--table`/`--record` send the page as context, `-c` continues a conversation, `--json` the whole answer); `config` says whether it answers and with which model, or why not |
|
||||
| `assistant ask`, `assistant config` | ask the in-app assistant (qbit-assistant) a question about your data: the answer streams block by block, rendered (tables as sub-tables, links as `label (qqq table get …)` or `label [n]` with the command under Links, never a URL), then its numbers (`--table`/`--record` send the page as context, `-c` continues a conversation, `--json` the whole answer); `config` says whether it answers and with which model, or why not |
|
||||
| `print`, `printer` | print records, table lists and text through the printing extension, follow print jobs; printers and print agents |
|
||||
| `history` | everything you did with the CLI and the TUI, like a shell history (see below) |
|
||||
| `login`, `logout`, `profile`, `config`, `cache`, `completion`, `doctor` | sign-in, saved server profiles (`profile list|add|edit|use|remove`), settings, the metadata cache, shell completion (bash, zsh, fish, PowerShell), checks |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@qrun-io/qqq-cli",
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.1-rc.1",
|
||||
"description": "Reference command-line client for QQQ applications, built only on the QQQ UI API (/qqq/v1).",
|
||||
"keywords": [
|
||||
"qqq",
|
||||
@@ -50,7 +50,7 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npm.pkg.github.com"
|
||||
"registry": "https://git.kof22.com/api/packages/QRun-IO/npm/"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
@@ -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.0.1-rc.1",
|
||||
"@qrun-io/qqq-ui-api-client": "1.8.1-rc.1",
|
||||
"@qrun-io/qqq-ui-api-conformance": "1.6.0",
|
||||
"commander": "15.0.0"
|
||||
}
|
||||
|
||||
@@ -30,9 +30,9 @@ export interface BrowserSignInOptions {
|
||||
|
||||
/** Opens a URL with the platform's opener; failures are ignored (the URL is printed anyway). */
|
||||
export function openInBrowser(url: string): void {
|
||||
const [command, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['cmd', ['/c', 'start', '', url]] : ['xdg-open', [url]]
|
||||
const [command, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? [process.env.QQQ_POWERSHELL || 'powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from('Start-Process -FilePath $env:QQQ_BROWSER_URL', 'utf16le').toString('base64')]] : ['xdg-open', [url]]
|
||||
try {
|
||||
const child = spawn(command, args as string[], { stdio: 'ignore', detached: true })
|
||||
const child = spawn(command, args as string[], { stdio: 'ignore', detached: true, windowsHide: true, env: { ...process.env, QQQ_BROWSER_URL: url } })
|
||||
child.on('error', () => undefined)
|
||||
child.unref()
|
||||
} catch {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
|
||||
import type { Command } from 'commander'
|
||||
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
|
||||
import { assistantContextFor, describeAssistantConfig, type AssistantAnswer } from '@qrun-io/qqq-core'
|
||||
import { answerDocument, assistantContextFor, describeAssistantConfig, linkTargetKey, markdownText, parseAppHref, parseMarkdown, type AssistantAnswer, type MdInline } from '@qrun-io/qqq-core'
|
||||
import { io, type Context, UsageError } from '../context.js'
|
||||
import { positiveInt, type Json } from './shared.js'
|
||||
|
||||
@@ -22,16 +22,73 @@ export function plainAnswer(text: string): string {
|
||||
return text.replace(/\*\*(.+?)\*\*/g, '$1').replace(/__(.+?)__/g, '$1').replace(/`([^`]+)`/g, '$1').replace(/^#{1,6}\s+/gm, '')
|
||||
}
|
||||
|
||||
/** Rows for the links table: kind, label, table, id, the app path and the qqq command that opens it. */
|
||||
export function linkRows(answer: AssistantAnswer): Json[] {
|
||||
return answer.links.map((link) => ({
|
||||
kind: link.kind,
|
||||
label: link.label,
|
||||
table: link.tableName ?? '',
|
||||
id: link.recordId ?? '',
|
||||
href: link.href,
|
||||
open: link.kind === 'record' ? `qqq table get ${link.tableName} ${link.recordId}` : link.kind === 'table' ? `qqq table query ${link.tableName}` : '',
|
||||
}))
|
||||
/** The qqq command that opens a link's target (`qqq table get t 4`, `qqq table query t --filter-json '…'`), or '' for other pages. */
|
||||
export function linkCommand(href: string): string {
|
||||
const target = parseAppHref(href)
|
||||
if (!target?.tableName) return ''
|
||||
const key = linkTargetKey(href)
|
||||
const record = /^record:[^:]+:(.*)$/.exec(key)
|
||||
if (record) return `qqq table get ${target.tableName} ${shellQuote(record[1]!)}`
|
||||
if (key === `table:${target.tableName}:` || !target.filter) return `qqq table query ${target.tableName}`
|
||||
return `qqq table query ${target.tableName} --filter-json ${shellQuote(JSON.stringify(target.filter))}`
|
||||
}
|
||||
|
||||
function shellQuote(text: string): string {
|
||||
return /^[A-Za-z0-9_.:@%+=,/-]+$/.test(text) ? text : `'${text.replace(/'/g, `'\\''`)}'`
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders an answer's markdown for the terminal, block by block (so it can print while the model
|
||||
* writes): entities decoded, tables as sub-tables, each link as `label (qqq table get …)` when that
|
||||
* is short, else `label [n]` with the command listed under Links (numbered once per target).
|
||||
*/
|
||||
export class AnswerRenderer {
|
||||
readonly footnotes: Array<{ n: number; label: string; open: string }> = []
|
||||
private readonly noted = new Map<string, number>()
|
||||
private printed = 0
|
||||
|
||||
constructor(private readonly width = 100) {}
|
||||
|
||||
private note(label: string, href: string): number {
|
||||
const key = linkTargetKey(href)
|
||||
let n = this.noted.get(key)
|
||||
if (n === undefined) {
|
||||
n = this.noted.size + 1
|
||||
this.noted.set(key, n)
|
||||
this.footnotes.push({ n, label, open: linkCommand(href) || href })
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/** Text for the blocks completed since the last call (all of them when `done`), '' when none. */
|
||||
next(text: string, done: boolean): string {
|
||||
const doc = parseMarkdown(text)
|
||||
const complete = done ? doc.blocks.length : Math.max(0, doc.blocks.length - 1)
|
||||
const out: string[] = []
|
||||
for (; this.printed < complete; this.printed++) {
|
||||
const block = doc.blocks[this.printed]!
|
||||
const inTable = block.kind === 'table'
|
||||
out.push(markdownText({ blocks: [block], links: doc.links }, this.width, (part: MdInline) => {
|
||||
const open = linkCommand(part.href ?? '')
|
||||
if (!inTable && open && !open.includes('--filter-json') && open.length <= 48) return `${part.text} (${open})`
|
||||
return `${part.text} [${this.note(part.text, part.href ?? '')}]`
|
||||
}))
|
||||
}
|
||||
return out.join('\n\n')
|
||||
}
|
||||
|
||||
/** Numbers the answer's other links (tool results) the text did not show, once per target. */
|
||||
extra(answer: { text: string; links: Array<{ label: string; href: string }> }): void {
|
||||
for (const link of answerDocument(answer).extraLinks) this.note(link.label, link.href)
|
||||
}
|
||||
}
|
||||
|
||||
/** The whole answer rendered at once (tests, and output that is not streamed). */
|
||||
export function renderAnswer(answer: { text: string; links: Array<{ label: string; href: string }> }, width = 100): { text: string; links: Array<{ n: number; label: string; open: string }> } {
|
||||
const renderer = new AnswerRenderer(width)
|
||||
const text = renderer.next(answer.text, true)
|
||||
renderer.extra(answer)
|
||||
return { text, links: renderer.footnotes }
|
||||
}
|
||||
|
||||
export function registerAssistant(noun: Command, ctx: () => Context): void {
|
||||
@@ -66,38 +123,42 @@ export function registerAssistant(noun: Command, ctx: () => Context): void {
|
||||
const conversationId = options.conversation ? positiveInt(options.conversation, '--conversation') : undefined
|
||||
const assistant = await c.assistant()
|
||||
const human = c.format === 'table' || c.format === 'text'
|
||||
let streamed = false
|
||||
const renderer = new AnswerRenderer(Math.max(40, io.width() ?? 100))
|
||||
let printedAny = false
|
||||
const emit = (text: string) => {
|
||||
if (!text) return
|
||||
io.out(`${printedAny ? '\n\n' : ''}${text}`)
|
||||
printedAny = true
|
||||
}
|
||||
const answer = await assistant.ask(question, {
|
||||
conversationId,
|
||||
context: assistantContextFor({ tableName: options.table, recordId: options.record }),
|
||||
onEvent: (event) => {
|
||||
onEvent: (event, partial) => {
|
||||
if (!human) return
|
||||
if (event.type === 'text.delta' && typeof event.text === 'string') {
|
||||
io.out(plainAnswer(event.text))
|
||||
streamed = true
|
||||
} else if (event.type === 'tool.start' && typeof event.summary === 'string' && event.summary) {
|
||||
c.note(`… ${event.summary}`)
|
||||
}
|
||||
// whole blocks as the model completes them, rendered (a table prints once it is complete)
|
||||
if (event.type === 'text.delta') emit(renderer.next(partial.text, false))
|
||||
else if (event.type === 'tool.start' && typeof event.summary === 'string' && event.summary) c.note(`… ${event.summary}`)
|
||||
},
|
||||
})
|
||||
if (answer.status === 'ERROR') {
|
||||
if (streamed) io.out('\n')
|
||||
if (printedAny) io.out('\n')
|
||||
throw new UiApiError(502, { code: answer.error?.code ?? 'INTERNAL', message: answer.error?.message ?? 'The assistant could not answer.' })
|
||||
}
|
||||
if (!human) {
|
||||
c.print({ data: answer })
|
||||
return
|
||||
}
|
||||
if (!streamed) io.out(plainAnswer(answer.text))
|
||||
emit(renderer.next(answer.text, true))
|
||||
io.out('\n')
|
||||
for (const notice of answer.notices) c.note(`${notice.level}: ${notice.text}`)
|
||||
if (answer.numbers.length) {
|
||||
io.out('\n')
|
||||
c.print({ data: answer.numbers, rows: answer.numbers.map((n) => ({ number: n.label, value: n.value, href: n.href ?? '' })), columns: ['number', 'value', 'href'] })
|
||||
c.print({ data: answer.numbers, rows: answer.numbers.map((n) => ({ number: n.label, value: n.value })), columns: ['number', 'value'] })
|
||||
}
|
||||
if (answer.links.length) {
|
||||
io.out('\n')
|
||||
c.print({ data: answer.links, rows: linkRows(answer), columns: ['kind', 'label', 'table', 'id', 'open'] })
|
||||
renderer.extra(answer)
|
||||
if (renderer.footnotes.length) {
|
||||
io.out('\nLinks\n')
|
||||
c.print({ data: renderer.footnotes, rows: renderer.footnotes.map((f) => ({ n: `[${f.n}]`, label: f.label, open: f.open })), columns: ['n', 'label', 'open'] })
|
||||
}
|
||||
const seconds = answer.elapsedMs === undefined ? '' : `${(answer.elapsedMs / 1000).toFixed(1)} s, `
|
||||
c.note(`(${seconds}conversation ${answer.conversationId}; continue with: qqq assistant ask -c ${answer.conversationId} "...")`)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -22,10 +22,13 @@
|
||||
*/
|
||||
|
||||
import type { Command } from 'commander'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { FAILED_JOB_STATES, MAX_LIST_ROWS, printFilter, isFinalJobState, jobStateSummary, printProcessName, PRINT_PROCESS_PREFIX, readRememberedPrinter, resolvePrinter, whyNotPrintable, writeRememberedPrinter, type Printer, type PrintJob, type Printing } from '@qrun-io/qqq-core'
|
||||
import { existsSync, readFileSync, statSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { profileNameForServer, formatDateValue, localeFromEnv, readDateSettings, resolveDateSettings, saveDownload, type PrintChrome, type RenderedPdf, type RenderRequest, FAILED_JOB_STATES, MAX_LIST_ROWS, printFilter, isFinalJobState, jobStateSummary, printProcessName, PRINT_PROCESS_PREFIX, readRememberedPrinter, resolvePrinter, whyNotPrintable, writeRememberedPrinter, type Printer, type PrintJob, type Printing } from '@qrun-io/qqq-core'
|
||||
import { EXIT, UsageError, type Context } from '../context.js'
|
||||
import { ORDER_OPTION, countFooter, instantText, positiveInt, type Json } from './shared.js'
|
||||
import { getLogin } from '../session.js'
|
||||
import { CLI_VERSION } from '../version.js'
|
||||
import { ORDER_OPTION, countFooter, instantText, positiveInt, writeDownload, type Json } from './shared.js'
|
||||
import { resolvedFilterFrom, type QueryOptions } from './table.js'
|
||||
|
||||
const JOB_COLUMNS = ['id', 'title', 'status', 'printer', 'copies', 'created', 'message']
|
||||
@@ -45,6 +48,61 @@ function printerRows(printers: Printer[], remembered: string | undefined): Json[
|
||||
return printers.map((p) => ({ default: p.id === remembered ? '*' : '', name: p.name, device: p.device ?? '', status: p.status ?? '', approved: p.approved ? 'yes' : 'no', exposed: p.exposed ? 'yes' : 'no', formats: p.formats.join(' '), id: p.id }))
|
||||
}
|
||||
|
||||
/**
|
||||
* The app's header and footer for what the CLI prints: the instance's branding name (else the
|
||||
* profile), the title, the server (profile and host), the signed-in user, the time in the
|
||||
* profile's date format; the footer `qqq <version> · <server>`.
|
||||
*/
|
||||
async function chromeFor(c: Context, title?: string): Promise<PrintChrome> {
|
||||
const t = c.target
|
||||
let host = t.server
|
||||
try {
|
||||
host = new URL(t.server).host
|
||||
} catch {
|
||||
host = t.server
|
||||
}
|
||||
const server = t.name && t.name !== host && t.name !== profileNameForServer(t.server) ? `${t.name} (${host})` : host
|
||||
let app: string | undefined
|
||||
try {
|
||||
app = ((await c.metaData()) as unknown as { branding?: { appName?: string } }).branding?.appName
|
||||
} catch {
|
||||
app = undefined
|
||||
}
|
||||
const login = await getLogin(t).catch(() => undefined)
|
||||
const user = login && login.kind === 'session' ? String((login.user as { name?: string } | undefined)?.name ?? '') : ''
|
||||
let printedAt = new Date().toISOString()
|
||||
try {
|
||||
printedAt = formatDateValue(printedAt, 'DATE_TIME', resolveDateSettings(readDateSettings(t.name), localeFromEnv()), { withSeconds: false })
|
||||
} catch {
|
||||
// the ISO instant stays
|
||||
}
|
||||
return { app: app ?? t.name, title, server, user: user || undefined, printedAt, footerLeft: `qqq ${CLI_VERSION} · ${server}` }
|
||||
}
|
||||
|
||||
/**
|
||||
* --to-file: renders each document on the server and saves it as downloads are saved: a folder
|
||||
* given gets the stamped default names, a file name given is written as given (one document
|
||||
* only), no path saves to the download folder (`qqq config get download-dir`) with the stamped
|
||||
* name, never overwriting.
|
||||
*/
|
||||
async function saveAsPdf(c: Context, printing: Printing, requests: RenderRequest[], out: string | true, title?: string): Promise<void> {
|
||||
const chrome = await chromeFor(c, title)
|
||||
const path = out === true ? undefined : out
|
||||
const folder = path !== undefined && path !== '-' && existsSync(path) && statSync(path).isDirectory()
|
||||
if (path !== undefined && !folder && requests.length > 1) throw new UsageError('--to-file with several documents takes a folder (or no path: the download folder).')
|
||||
const rows: Json[] = []
|
||||
for (const request of requests) {
|
||||
const pdf: RenderedPdf = await printing.renderToPdf(request, new Date(), chrome)
|
||||
if (pdf.message) c.note(pdf.message)
|
||||
const written = folder ? saveDownload(join(path!, pdf.fileName), pdf.bytes, { explicit: false }) : writeDownload(path, pdf.fileName, pdf.bytes)
|
||||
if (written) {
|
||||
c.note(`Saved ${written} (${pdf.bytes.length.toLocaleString('en-US')} bytes)`)
|
||||
rows.push({ saved: written, bytes: pdf.bytes.length })
|
||||
}
|
||||
}
|
||||
if (path !== '-') c.print({ data: rows, rows, columns: ['saved', 'bytes'] })
|
||||
}
|
||||
|
||||
/** Tables the server offers Print on, from the metadata. */
|
||||
async function printableTables(c: Context): Promise<string[]> {
|
||||
const processes = ((await c.metaData() as unknown as Json).processes ?? {}) as Record<string, unknown>
|
||||
@@ -102,16 +160,18 @@ export function registerPrint(program: Command, ctx: () => Context): void {
|
||||
.option('-c, --copies <n>', 'copies of each record (1-100)', '1')
|
||||
.option('--no-wait', 'return once the jobs are queued')
|
||||
.option('--timeout <seconds>', 'how long to wait for the jobs', '120')
|
||||
.action(async (table: string, ids: string[], options: { printer?: string; copies: string; wait: boolean; timeout: string }) => {
|
||||
.option('--to-file [path]', 'save each record sheet as a PDF instead of printing: a folder, a file (one record), or the download folder')
|
||||
.action(async (table: string, ids: string[], options: { printer?: string; copies: string; wait: boolean; timeout: string; toFile?: string | true }) => {
|
||||
const c = ctx()
|
||||
const printing = await printingOf(c)
|
||||
const tables = await printableTables(c)
|
||||
if (!tables.includes(table)) throw new UsageError(`Print is not enabled for table "${table}" on this server${tables.length ? ` (tables with Print: ${tables.join(', ')})` : ''}.`)
|
||||
if (options.toFile !== undefined) return saveAsPdf(c, printing, ids.map((id) => ({ kind: 'record' as const, table, id })), options.toFile)
|
||||
const copies = positiveInt(options.copies, '--copies')
|
||||
const printer = await choosePrinter(c, printing, options.printer)
|
||||
if (options.printer) writeRememberedPrinter(c.target.name, { id: printer.id, name: printer.name })
|
||||
c.note(`Printing ${ids.length} ${table} record${ids.length === 1 ? '' : 's'} on "${printer.name}"${copies > 1 ? `, ${copies} copies each` : ''} (${printProcessName(table)})`)
|
||||
const jobIds = await printing.print({ table, ids, printer: printer.id, copies })
|
||||
const jobIds = await printing.print({ table, ids, printer: printer.id, copies, chrome: await chromeFor(c) })
|
||||
if (!jobIds.length) throw new Error(`Nothing was printed: no ${table} record ${ids.join(', ')} was found (or you may not see ${ids.length === 1 ? 'it' : 'them'}).`)
|
||||
const jobs = options.wait ? await follow(c, printing, jobIds, positiveInt(options.timeout, '--timeout')) : await printing.jobs({ ids: jobIds })
|
||||
c.print({ data: { printer: { id: printer.id, name: printer.name }, jobs }, rows: jobRows(jobs, c), columns: JOB_COLUMNS, footer: countFooter(jobs.length, 'job', [jobStateSummary(jobs), !options.wait && `follow: qqq print job status ${jobIds.join(' ')} --watch`]) })
|
||||
@@ -134,7 +194,8 @@ export function registerPrint(program: Command, ctx: () => Context): void {
|
||||
.option('--copies <n>', 'copies (1-100)', '1')
|
||||
.option('--no-wait', 'return once the job is queued')
|
||||
.option('--timeout <seconds>', 'how long to wait for the job', '300')
|
||||
.action(async (table: string, options: QueryOptions & { columns?: string; page?: string; pageSize: string; title?: string; orientation: string; paper: string; printer?: string; copies: string; wait: boolean; timeout: string }) => {
|
||||
.option('--to-file [path]', 'save the table list as a PDF instead of printing: a folder, a file, - for stdout, or the download folder')
|
||||
.action(async (table: string, options: QueryOptions & { columns?: string; page?: string; pageSize: string; title?: string; orientation: string; paper: string; printer?: string; copies: string; wait: boolean; timeout: string; toFile?: string | true }) => {
|
||||
const c = ctx()
|
||||
const printing = await printingOf(c)
|
||||
const meta = await c.tableMeta<Json>(table)
|
||||
@@ -148,11 +209,15 @@ export function registerPrint(program: Command, ctx: () => Context): void {
|
||||
const filter = await resolvedFilterFrom(c, { ...options, pageSize: '500' }, meta, columns)
|
||||
const pageSize = positiveInt(options.pageSize, '--page-size')
|
||||
const scope = options.page ? { kind: 'page' as const, skip: (positiveInt(options.page, '--page') - 1) * pageSize, limit: pageSize } : { kind: 'all' as const }
|
||||
if (options.toFile !== undefined) {
|
||||
const described = [printFilter(filter as never), options.search ? `quick "${options.search}"` : ''].filter(Boolean).join(' · ')
|
||||
return saveAsPdf(c, printing, [{ kind: 'list', list: { table, columns, filter: filter as unknown as Record<string, unknown>, scope, title: options.title ?? String(meta.label ?? table), filterDescription: described || undefined, orientation: orientation as 'AUTO', pageSize: paper as 'LETTER', primaryKeyField: String(meta.primaryKeyField ?? 'id') } }], options.toFile, String(meta.label ?? table))
|
||||
}
|
||||
const printer = await choosePrinter(c, printing, options.printer)
|
||||
if (options.printer) writeRememberedPrinter(c.target.name, { id: printer.id, name: printer.name })
|
||||
const described = [printFilter(filter as never), options.search ? `quick "${options.search}"` : ''].filter(Boolean).join(' · ')
|
||||
c.note(`Printing ${scope.kind === 'all' ? 'all rows of' : `page ${options.page} of`} ${meta.label ?? table}${described ? ` where ${described}` : ''} on "${printer.name}"`)
|
||||
const outcome = await printing.printTableList({ table, columns, filter: filter as unknown as Record<string, unknown>, scope, title: options.title ?? String(meta.label ?? table), filterDescription: described || undefined, printer: printer.id, copies: positiveInt(options.copies, '--copies'), orientation: orientation as 'AUTO', pageSize: paper as 'LETTER', primaryKeyField: String(meta.primaryKeyField ?? 'id') })
|
||||
const outcome = await printing.printTableList({ table, columns, filter: filter as unknown as Record<string, unknown>, scope, title: options.title ?? String(meta.label ?? table), filterDescription: described || undefined, printer: printer.id, copies: positiveInt(options.copies, '--copies'), orientation: orientation as 'AUTO', pageSize: paper as 'LETTER', primaryKeyField: String(meta.primaryKeyField ?? 'id'), chrome: await chromeFor(c, String(meta.label ?? table)) })
|
||||
if (outcome.message) c.note(outcome.message)
|
||||
if (!outcome.jobIds.length) throw new Error('The server queued no print job.')
|
||||
const jobs = options.wait ? await follow(c, printing, outcome.jobIds, positiveInt(options.timeout, '--timeout')) : await printing.jobs({ ids: outcome.jobIds })
|
||||
@@ -168,14 +233,16 @@ export function registerPrint(program: Command, ctx: () => Context): void {
|
||||
.option('--copies <n>', 'copies (1-100)', '1')
|
||||
.option('--no-wait', 'return once the job is queued')
|
||||
.option('--timeout <seconds>', 'how long to wait for the job', '120')
|
||||
.action(async (file: string | undefined, options: { title: string; subtitle?: string; landscape?: boolean; printer?: string; copies: string; wait: boolean; timeout: string }) => {
|
||||
.option('--to-file [path]', 'save the text page as a PDF instead of printing: a folder, a file, or the download folder')
|
||||
.action(async (file: string | undefined, options: { title: string; subtitle?: string; landscape?: boolean; printer?: string; copies: string; wait: boolean; timeout: string; toFile?: string | true }) => {
|
||||
const c = ctx()
|
||||
const printing = await printingOf(c)
|
||||
const text = readFileSync(file === undefined || file === '-' ? 0 : file, 'utf8')
|
||||
if (!text.trim()) throw new UsageError('Nothing to print: the text is empty.')
|
||||
if (options.toFile !== undefined) return saveAsPdf(c, printing, [{ kind: 'text', title: options.title, subtitle: options.subtitle, text, orientation: options.landscape ? 'LANDSCAPE' : 'PORTRAIT' }], options.toFile, options.title)
|
||||
const printer = await choosePrinter(c, printing, options.printer)
|
||||
if (options.printer) writeRememberedPrinter(c.target.name, { id: printer.id, name: printer.name })
|
||||
const outcome = await printing.printText({ title: options.title, subtitle: options.subtitle, text, printer: printer.id, copies: positiveInt(options.copies, '--copies'), orientation: options.landscape ? 'LANDSCAPE' : 'PORTRAIT' })
|
||||
const outcome = await printing.printText({ title: options.title, subtitle: options.subtitle, text, printer: printer.id, copies: positiveInt(options.copies, '--copies'), orientation: options.landscape ? 'LANDSCAPE' : 'PORTRAIT', chrome: await chromeFor(c, options.title) })
|
||||
const jobs = options.wait ? await follow(c, printing, outcome.jobIds, positiveInt(options.timeout, '--timeout')) : await printing.jobs({ ids: outcome.jobIds })
|
||||
c.print({ data: { printer: { id: printer.id, name: printer.name }, jobs }, rows: jobRows(jobs, c), columns: JOB_COLUMNS, footer: countFooter(jobs.length, 'job', [jobStateSummary(jobs)]) })
|
||||
})
|
||||
|
||||
@@ -10,35 +10,59 @@
|
||||
|
||||
import type { Command } from 'commander'
|
||||
import { UiApiError, unwrap, type UiApiClient } from '@qrun-io/qqq-ui-api-client'
|
||||
import { Assistant, Collaboration, Printing, missingQBitMessage, runProcess, type ProcessMeta, ExtensionNotInstalledError, OperationInputError, ExtensionRegistry, KNOWN_EXTENSIONS, FilterSyntaxError, OUTPUT_FORMATS, ProcessError, UnsafeFileNameError, defaultTimeZone, type DisplayOptions, applyQuery, callOperation, defaultFormat, extensionRegistry, getMetaData, getTableMetaData, render, type CallInput, type CallResult, type MetaDataDocument, type OutputFormat, type Printable } from '@qrun-io/qqq-core'
|
||||
import { Assistant, Collaboration, Printing, downloadProcessFile, missingQBitMessage, runProcess, type ProcessMeta, ExtensionNotInstalledError, OperationInputError, ExtensionRegistry, KNOWN_EXTENSIONS, FilterSyntaxError, OUTPUT_FORMATS, ProcessError, UnsafeFileNameError, defaultTimeZone, type DisplayOptions, applyQuery, callOperation, defaultFormat, extensionRegistry, getMetaData, getTableMetaData, render, type CallInput, type CallResult, type MetaDataDocument, type OutputFormat, type Printable } from '@qrun-io/qqq-core'
|
||||
import { openRealtimeStream } from '@qrun-io/qqq-ui-api-client'
|
||||
import { CLIENT_CHANNEL, authenticatedFetch, clientFor, currentToken, selectTarget, sessionCookieHeaderFor, target, type Target } from './session.js'
|
||||
|
||||
/** A write that ends the process quietly (status 0) when it throws because the reader has gone. */
|
||||
function safeWrite(stream: NodeJS.WriteStream, text: string): void {
|
||||
try {
|
||||
stream.write(text)
|
||||
} catch (error) {
|
||||
if (isBrokenPipe(error)) process.exit(0)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Where output goes. The CLI writes to the process streams; `runCli` (the TUI's `:` command line)
|
||||
* points these at a pane for the duration of one command.
|
||||
*/
|
||||
export const io = {
|
||||
out: (text: string): void => { process.stdout.write(text) },
|
||||
err: (text: string): void => { process.stderr.write(text) },
|
||||
out: (text: string): void => { safeWrite(process.stdout, text) },
|
||||
err: (text: string): void => { safeWrite(process.stderr, text) },
|
||||
isTTY: (): boolean => Boolean(process.stdout.isTTY),
|
||||
/** The terminal width table output fits nested results in; undefined when not on a terminal. */
|
||||
width: (): number | undefined => (process.stdout.isTTY ? process.stdout.columns : undefined),
|
||||
}
|
||||
|
||||
/** What a write to a pipe whose reader has gone ends with: EPIPE, or (macOS, a stream already torn down) the errors that follow it. */
|
||||
const BROKEN_PIPE_CODES = new Set(['EPIPE', 'ECONNRESET', 'ERR_STREAM_DESTROYED', 'ERR_STREAM_WRITE_AFTER_END'])
|
||||
|
||||
/** Whether an error is a write to a pipe whose reader has gone (`qqq table query | head -1`). */
|
||||
export function isBrokenPipe(error: unknown): boolean {
|
||||
return (error as NodeJS.ErrnoException | undefined)?.code === 'EPIPE'
|
||||
return BROKEN_PIPE_CODES.has(String((error as NodeJS.ErrnoException | undefined)?.code))
|
||||
}
|
||||
|
||||
/** Exits quietly with status 0 when stdout or stderr is a pipe nobody reads any more, as `yes | head` does. */
|
||||
export function exitOnBrokenPipe(streams: Array<NodeJS.WriteStream> = [process.stdout, process.stderr], exit: (code: number) => void = (code) => process.exit(code)): void {
|
||||
/**
|
||||
* A pipe nobody reads any more ends the command at once with status 0, as `yes | head` does, whatever
|
||||
* else was going on: the process exits from the stream's error event, from a write that throws, and
|
||||
* from an uncaught error that is one, so no later code can turn it into a failure.
|
||||
*/
|
||||
export function exitOnBrokenPipe(streams: Array<NodeJS.WriteStream> = [process.stdout, process.stderr], exit: (code: number) => void = (code) => process.exit(code), on: (event: 'uncaughtException', listener: (error: Error) => void) => unknown = (event, listener) => process.on(event, listener)): void {
|
||||
for (const stream of streams) {
|
||||
stream.on('error', (error) => {
|
||||
if (isBrokenPipe(error)) exit(0)
|
||||
else throw error
|
||||
})
|
||||
}
|
||||
on('uncaughtException', (error) => {
|
||||
if (isBrokenPipe(error)) exit(0)
|
||||
else {
|
||||
process.stderr.write(`${error.stack ?? error.message}\n`)
|
||||
process.exit(1)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Exit codes, as in the design doc (KofTwentyTwo ADR-0031 shape). */
|
||||
@@ -166,6 +190,7 @@ export class Context {
|
||||
const outcome = await runProcess({ baseUrl: t.server, fetch: authenticatedFetch(t), meta, values, records: recordIds.length ? { ids: recordIds } : undefined, stepTimeoutMillis: 60000 })
|
||||
return outcome.values
|
||||
},
|
||||
download: (reference) => downloadProcessFile(t.server, authenticatedFetch(t), reference),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -33,10 +33,7 @@ importLegacyCredentials().then(() => program.parseAsync()).catch((error: unknown
|
||||
return
|
||||
}
|
||||
// a stream that failed with EPIPE (an export piped into head): the reader left, nothing to report
|
||||
if (isBrokenPipe(error)) {
|
||||
process.exitCode = 0
|
||||
return
|
||||
}
|
||||
if (isBrokenPipe(error)) process.exit(0)
|
||||
const { code, text } = describeError(error, Boolean(program.opts().trace))
|
||||
process.stderr.write(text + '\n')
|
||||
process.exitCode = code
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Command, CommanderError } from 'commander'
|
||||
import { Context, EXIT, describeError, io } from './context.js'
|
||||
import { versionText } from './version.js'
|
||||
|
||||
export { buildDescription } from './version.js'
|
||||
export { buildDescription, CLI_VERSION } from './version.js'
|
||||
import { registerApi } from './commands/api.js'
|
||||
import { registerAudit } from './commands/audit.js'
|
||||
import { registerAuth } from './commands/auth.js'
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { after, before, describe, test } from 'node:test'
|
||||
import { plainAnswer } from '../commands/assistant.js'
|
||||
import { linkCommand, plainAnswer, renderAnswer } from '../commands/assistant.js'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { TEST_SOURCE } from './harness.js'
|
||||
import { runCli, sandbox } from './harness.js'
|
||||
import { StubResponse, startStub, type J, type Stub } from './stub.js'
|
||||
|
||||
@@ -59,9 +62,11 @@ describe('qqq assistant against a stub', () => {
|
||||
const r = await qqq(['-O', 'table', 'assistant', 'ask', '--table', 'customer', '--record', '5', 'how', 'many', 'customers?'])
|
||||
assert.equal(r.code, 0, r.stderr)
|
||||
assert.match(r.stdout, /^There are 42 customers\.\n/)
|
||||
assert.match(r.stdout, /Customer\s+42\s+\/app\/customer/)
|
||||
assert.match(r.stdout, /record\s+Acme \(Customer\)\s+customer\s+5\s+qqq table get customer 5/)
|
||||
assert.match(r.stdout, /table\s+Customer \(42\)\s+customer\s+qqq table query customer/)
|
||||
assert.match(r.stdout, /Customer\s+42\n/)
|
||||
assert.match(r.stdout, /Links\n/)
|
||||
assert.match(r.stdout, /\[1\]\s+Acme \(Customer\)\s+qqq table get customer 5/)
|
||||
assert.match(r.stdout, /\[2\]\s+Customer \(42\)\s+qqq table query customer/)
|
||||
assert.doesNotMatch(r.stdout, /\/app\//)
|
||||
assert.match(r.stderr, /Counting Customer/)
|
||||
assert.match(r.stderr, /conversation 12; continue with: qqq assistant ask -c 12/)
|
||||
const created = stub.requests.find((q) => q.path === '/assistant/conversations')!
|
||||
@@ -94,4 +99,24 @@ describe('qqq assistant against a stub', () => {
|
||||
test('plain answer text drops markdown markers', () => {
|
||||
assert.equal(plainAnswer('## Total\nThere are **42** `customers` and __3__ orders.'), 'Total\nThere are 42 customers and 3 orders.')
|
||||
})
|
||||
|
||||
test('the 8078 answer about unhealthy services renders as text: no entities, no URLs, a sub-table, links once', () => {
|
||||
const fixture = JSON.parse(readFileSync(join(TEST_SOURCE, '..', '..', '..', 'core', 'src', 'test', 'fixtures', 'assistant-unhealthy.json'), 'utf8')) as { text: string; links: Array<{ label: string; href: string }> }
|
||||
const { text, links } = renderAnswer(fixture, 100)
|
||||
assert.doesNotMatch(text, /&#|\/app\/|%7B|\| ---/)
|
||||
assert.match(text, /Service\s+Status\s+Since/)
|
||||
assert.match(text, /email \[1\]\s+DEGRADED/)
|
||||
assert.match(text, /- Open unhealthy components \[4\]/)
|
||||
assert.ok(text.split('\n').every((l) => [...l].length <= 100))
|
||||
assert.deepEqual(links.map((l) => [l.n, l.label, l.open.split(' --')[0]]), [
|
||||
[1, 'email', 'qqq table get health_component 4'],
|
||||
[2, 'db_performance', 'qqq table get health_component 3'],
|
||||
[3, 'esb', 'qqq table get health_component 10'],
|
||||
[4, 'Open unhealthy components', 'qqq table query health_component'],
|
||||
])
|
||||
assert.match(links[3]!.open, /--filter-json '\{"criteria":\[\{"fieldName":"status","operator":"NOT_EQUALS","values":\["UP"\]\}\],"booleanOperator":"AND"\}'/)
|
||||
assert.equal(linkCommand('/app/person/2'), 'qqq table get person 2')
|
||||
assert.equal(linkCommand('https://x'), '')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { test } from 'node:test'
|
||||
import childProcess from 'node:child_process'
|
||||
import { syncBuiltinESMExports } from 'node:module'
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { openInBrowser } from '../browser-login.js'
|
||||
|
||||
test('Windows browser opener preserves OAuth query parameters without shell interpretation', { skip: process.platform !== 'win32' }, () => {
|
||||
const original = childProcess.spawn
|
||||
const url = 'https://example.invalid/authorize?response_type=code&client_id=fixture-native&state=fake%20state&redirect_uri=http%3A%2F%2F127.0.0.1%3A4567%2Fcallback'
|
||||
let captured: unknown[] = []
|
||||
childProcess.spawn = ((...args: unknown[]) => {
|
||||
captured = args
|
||||
return Object.assign(new EventEmitter(), { unref() {} })
|
||||
}) as typeof childProcess.spawn
|
||||
syncBuiltinESMExports()
|
||||
try { openInBrowser(url) } finally { childProcess.spawn = original; syncBuiltinESMExports() }
|
||||
const [command, args, options] = captured as [string, string[], { env: NodeJS.ProcessEnv; windowsHide: boolean }]
|
||||
assert.notEqual(command, 'cmd')
|
||||
assert.equal(options.env.QQQ_BROWSER_URL, url)
|
||||
assert.equal(options.windowsHide, true)
|
||||
const script = Buffer.from(args.at(-1)!, 'base64').toString('utf16le')
|
||||
const probe = 'function Start-Process { param([string]$FilePath) [Console]::Write($FilePath) }; ' + script
|
||||
const result = childProcess.spawnSync(command, ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(probe, 'utf16le').toString('base64')], { env: options.env, encoding: 'utf8', windowsHide: true })
|
||||
assert.equal(result.status, 0, result.stderr)
|
||||
assert.equal(result.stdout, url)
|
||||
})
|
||||
@@ -20,7 +20,7 @@ describe('broken pipes', () => {
|
||||
stub = await startStub([...BASE_ROUTES,
|
||||
[/^GET \/metaData$/, () => ({ tables: { big: { name: 'big', label: 'Big' } }, processes: {}, apps: {} })],
|
||||
[/^GET \/metaData\/table\/big$/, () => ({ name: 'big', label: 'Big', primaryKeyField: 'id', capabilities: ['TABLE_QUERY'], fields: { id: { name: 'id', label: 'Id', type: 'INTEGER' }, note: { name: 'note', label: 'Note', type: 'STRING' } } })],
|
||||
[/^POST \/table\/big\/query$/, () => ({ records: Array.from({ length: 2000 }, (_, i) => ({ values: { id: i, note: 'x'.repeat(200) } })) })],
|
||||
[/^POST \/table\/big\/query$/, () => ({ records: Array.from({ length: 5000 }, (_, i) => ({ values: { id: i, note: 'x'.repeat(200) } })) })],
|
||||
])
|
||||
})
|
||||
after(async () => {
|
||||
@@ -30,23 +30,40 @@ describe('broken pipes', () => {
|
||||
|
||||
test('the helper exits 0 on EPIPE and rethrows anything else', () => {
|
||||
assert.ok(isBrokenPipe(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })))
|
||||
assert.ok(isBrokenPipe(Object.assign(new Error('destroyed'), { code: 'ERR_STREAM_DESTROYED' })))
|
||||
assert.ok(!isBrokenPipe(new Error('other')))
|
||||
const listeners: Array<(e: Error) => void> = []
|
||||
const exits: number[] = []
|
||||
exitOnBrokenPipe([{ on: (_: string, f: (e: Error) => void) => listeners.push(f) } as never], (code) => exits.push(code))
|
||||
let uncaught: ((e: Error) => void) | undefined
|
||||
exitOnBrokenPipe([{ on: (_: string, f: (e: Error) => void) => listeners.push(f) } as never], (code) => exits.push(code), (_, f) => { uncaught = f })
|
||||
listeners[0]!(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
|
||||
assert.deepEqual(exits, [0])
|
||||
uncaught!(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }))
|
||||
assert.deepEqual(exits, [0, 0])
|
||||
assert.throws(() => listeners[0]!(new Error('disk full')), /disk full/)
|
||||
})
|
||||
|
||||
test('a closed stdout ends the command quietly with status 0 and no stack trace', async () => {
|
||||
/** Runs a query whose output is far larger than a pipe buffer, closing the read end when `closeAt` says so. */
|
||||
const query = async (closeAt: 'before-any-write' | 'first-chunk') => {
|
||||
const env = { ...process.env, ...box.env, QQQ_TOKEN: 't', NO_COLOR: '1' }
|
||||
const child = spawn(process.execPath, [CLI, '--server', stub.url, 'table', 'query', 'big', '-O', 'json', '-l', '2000'], { env, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
const child = spawn(process.execPath, [CLI, '--server', stub.url, 'table', 'query', 'big', '-O', 'json', '-l', '5000'], { env, stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
let stderr = ''
|
||||
child.stderr.on('data', (d: Buffer) => (stderr += d.toString('utf8')))
|
||||
child.stdout.once('data', () => child.stdout.destroy())
|
||||
// before-any-write: the reader is gone before the child has even started, so its first write meets a closed pipe
|
||||
if (closeAt === 'before-any-write') child.stdout.destroy()
|
||||
else child.stdout.once('data', () => child.stdout.destroy())
|
||||
const code = await new Promise<number | null>((resolve) => child.on('close', resolve))
|
||||
assert.equal(code, 0)
|
||||
return { code, stderr }
|
||||
}
|
||||
|
||||
test('a reader that is gone before the first write ends the command quietly with status 0', { timeout: 60_000 }, async () => {
|
||||
const { code, stderr } = await query('before-any-write')
|
||||
assert.equal(code, 0, stderr)
|
||||
assert.doesNotMatch(stderr, /EPIPE|at .*\.js/)
|
||||
})
|
||||
|
||||
test('a reader that leaves after the first chunk ends the command quietly with status 0', { timeout: 60_000 }, async () => {
|
||||
const { code, stderr } = await query('first-chunk')
|
||||
assert.equal(code, 0, stderr)
|
||||
assert.doesNotMatch(stderr, /EPIPE|at .*\.js/)
|
||||
})
|
||||
})
|
||||
@@ -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)')
|
||||
})
|
||||
@@ -4,7 +4,7 @@ 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
|
||||
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
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
@@ -14,9 +14,11 @@ import assert from 'node:assert/strict'
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from 'node:http'
|
||||
import type { AddressInfo } from 'node:net'
|
||||
import { after, before, describe, test } from 'node:test'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { mkdtempSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { runCli, sandbox, startFakeServer, type FakeServer } from './harness.js'
|
||||
import { CLI_VERSION } from '../version.js'
|
||||
|
||||
type J = Record<string, unknown>
|
||||
|
||||
@@ -24,6 +26,7 @@ interface PrintServer {
|
||||
url: string
|
||||
runs: { process: string; values: J; recordIds?: string }[]
|
||||
lists: { process: string; values: J; recordsParam?: string }[]
|
||||
renders: J[]
|
||||
jobs: Map<string, { status: string[]; title: string; reads: number; message?: string }>
|
||||
close(): Promise<void>
|
||||
}
|
||||
@@ -50,6 +53,7 @@ const CANCEL_META = { name: 'printingCancelSelectedJobs', label: 'Cancel queued
|
||||
async function startPrintServer(): Promise<PrintServer> {
|
||||
const runs: PrintServer['runs'] = []
|
||||
const lists: PrintServer['lists'] = []
|
||||
const renders: PrintServer['renders'] = []
|
||||
const jobs: PrintServer['jobs'] = new Map()
|
||||
const state = new Map<string, { process: string; values: J; recordIds?: string }>()
|
||||
let next = 1
|
||||
@@ -69,10 +73,22 @@ async function startPrintServer(): Promise<PrintServer> {
|
||||
let m: RegExpExecArray | null
|
||||
if (path === '/version') return send(res, 200, { apiVersion: '1.9.0', features: ['metaData', 'tables', 'processes', 'version', 'extensions'], extensions: [{ name: 'printing', version: '2.1.0', pathPrefix: '/qbit/printing' }] })
|
||||
if (path === '/metaData/authentication') return send(res, 200, { type: 'MOCK' })
|
||||
if (path === '/metaData') return send(res, 200, { tables: { person: { name: 'person', label: 'Person' } }, processes: { printingPrintRecords_person: { name: 'printingPrintRecords_person', label: 'Print', tableName: 'person' }, printingCancelSelectedJobs: { name: 'printingCancelSelectedJobs', tableName: 'printing_job' }, printingPrintTableList: { name: 'printingPrintTableList' }, printingPrintText: { name: 'printingPrintText' } }, apps: {} })
|
||||
if (path === '/metaData') return send(res, 200, { tables: { person: { name: 'person', label: 'Person' } }, processes: { printingPrintRecords_person: { name: 'printingPrintRecords_person', label: 'Print', tableName: 'person' }, printingCancelSelectedJobs: { name: 'printingCancelSelectedJobs', tableName: 'printing_job' }, printingPrintTableList: { name: 'printingPrintTableList' }, printingPrintText: { name: 'printingPrintText' }, printingRenderToPdf: { name: 'printingRenderToPdf' } }, apps: {} })
|
||||
if (path === '/metaData/process/printingPrintRecords_person') return send(res, 200, PRINT_META)
|
||||
if (path === '/metaData/process/printingCancelSelectedJobs') return send(res, 200, CANCEL_META)
|
||||
if (path === '/metaData/table/person') return send(res, 200, PERSON_META)
|
||||
if (path === '/metaData/process/printingRenderToPdf') return send(res, 200, { name: 'printingRenderToPdf', label: 'Save as PDF', frontendSteps: [{ name: 'download', label: 'Download', components: [{ type: 'DOWNLOAD_FORM' }], viewFields: [{ name: 'downloadFileName', type: 'STRING', label: 'File' }] }] })
|
||||
if (path === '/processes/printingRenderToPdf/init') {
|
||||
const v = JSON.parse(field(body, 'values') ?? '{}') as J
|
||||
renders.push(v)
|
||||
const name = v.kind === 'RECORD' ? `Person-${v.recordId}` : v.kind === 'LIST' ? 'Person' : String(v.title).replace(/[^A-Za-z0-9]+/g, '-')
|
||||
return send(res, 200, { processUUID: 'render', nextStep: 'download', type: 'COMPLETE', values: { downloadFileName: `${name}.pdf`, serverFilePath: `/tmp/r-${renders.length}.pdf`, ...(v.kind === 'LIST' ? { printingMessage: 'Saved the first 10000 of 12000 rows: the limit is 10000 rows. Narrow the filter to save the rest.' } : {}) } })
|
||||
}
|
||||
if (path.startsWith('/download/')) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/pdf' })
|
||||
res.end(`%PDF-1.7 ${path}`)
|
||||
return
|
||||
}
|
||||
if ((m = /^\/metaData\/process\/(printingPrintTableList|printingPrintText)$/.exec(path))) return send(res, 200, LIST_META(m[1]!))
|
||||
if ((m = /^\/processes\/(printingPrintTableList|printingPrintText)\/init$/.exec(path))) {
|
||||
lists.push({ process: m[1]!, values: JSON.parse(field(body, 'values') ?? '{}') as J, recordsParam: field(body, 'recordsParam') })
|
||||
@@ -132,6 +148,7 @@ async function startPrintServer(): Promise<PrintServer> {
|
||||
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
|
||||
runs,
|
||||
lists,
|
||||
renders,
|
||||
jobs,
|
||||
close: () => new Promise<void>((resolve) => { server.closeAllConnections(); server.close(() => resolve()) }),
|
||||
}
|
||||
@@ -249,6 +266,11 @@ describe('qqq print and qqq printer', () => {
|
||||
assert.deepEqual(filter.criteria, [{ fieldName: 'lastName', operator: 'EQUALS', values: ['Odd'] }])
|
||||
assert.deepEqual(filter.orderBys, [{ fieldName: 'id', isAscending: false }])
|
||||
assert.equal(run.values.filterDescription, 'lastName EQUALS Odd')
|
||||
// the app header and footer
|
||||
assert.equal(String(run.values.footerLeft).replace(/:\d+$/, ':PORT'), `qqq ${CLI_VERSION} · 127.0.0.1:PORT`)
|
||||
assert.match(String(run.values.headerServer), /^127\.0\.0\.1:\d+$/)
|
||||
assert.equal(run.values.headerTitle, 'Person')
|
||||
assert.match(String(run.values.headerPrintedAt), /\d/)
|
||||
})
|
||||
|
||||
test('print list --page prints one page; default columns skip hidden and binary fields; bad options are usage errors', async () => {
|
||||
@@ -271,5 +293,40 @@ describe('qqq print and qqq printer', () => {
|
||||
const empty = await qqq(['print', 'text', '-', '--title', 'Nothing'], printServer.url, ' \n')
|
||||
assert.equal(empty.code, 2)
|
||||
})
|
||||
|
||||
test('--to-file saves record sheets instead of printing: a folder gets stamped names; no job is queued', async () => {
|
||||
const folder = mkdtempSync(join(tmpdir(), 'qqq-cli-pdf-'))
|
||||
const before = printServer.runs.length
|
||||
const result = await qqq(['print', 'person', '1', '2', '--to-file', folder])
|
||||
assert.equal(result.code, 0, result.stderr)
|
||||
const files = readdirSync(folder).sort()
|
||||
assert.equal(files.length, 2)
|
||||
assert.match(files[0]!, /^Person-1_\d{4}-\d{2}-\d{2}_\d{6}\.pdf$/)
|
||||
assert.match(readFileSync(join(folder, files[0]!), 'utf8'), /^%PDF-1\.7 \/download\/Person-1\.pdf/)
|
||||
assert.match(result.stderr, /Saved .*Person-2_\d{4}/)
|
||||
assert.equal(printServer.runs.length, before, 'no print job')
|
||||
assert.deepEqual(printServer.renders.slice(-2).map((r) => [r.kind, r.tableName, r.recordId]), [['RECORD', 'person', '1'], ['RECORD', 'person', '2']])
|
||||
assert.match(String(printServer.renders.at(-1)!.footerLeft), /^qqq \d/)
|
||||
const two = await qqq(['print', 'person', '1', '2', '--to-file', join(folder, 'one.pdf')])
|
||||
assert.equal(two.code, 2)
|
||||
assert.match(two.stderr, /takes a folder/)
|
||||
})
|
||||
|
||||
test('print list --to-file writes the named file as given and shows the limit note; without a path it goes to the download folder', async () => {
|
||||
const folder = mkdtempSync(join(tmpdir(), 'qqq-cli-pdf-'))
|
||||
const named = join(folder, 'people.pdf')
|
||||
const result = await qqq(['print', 'list', 'person', '-w', 'lastName = Odd', '--to-file', named])
|
||||
assert.equal(result.code, 0, result.stderr)
|
||||
assert.match(readFileSync(named, 'utf8'), /^%PDF/)
|
||||
assert.match(result.stderr, /Saved the first 10000 of 12000 rows/)
|
||||
const render = printServer.renders.at(-1)!
|
||||
assert.deepEqual([render.kind, render.scope, render.printingPrinterId], ['LIST', 'ALL', undefined])
|
||||
const downloads = mkdtempSync(join(tmpdir(), 'qqq-cli-dl-'))
|
||||
assert.equal((await qqq(['config', 'set', 'download-dir', downloads])).code, 0)
|
||||
const text = await qqq(['print', 'text', '-', '--title', 'Build log', '--to-file'], printServer.url, 'hello\n')
|
||||
assert.equal(text.code, 0, text.stderr)
|
||||
assert.match(readdirSync(downloads)[0]!, /^Build-log_\d{4}-\d{2}-\d{2}_\d{6}\.pdf$/)
|
||||
assert.equal((await qqq(['config', 'set', 'download-dir', ''])).code, 0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.0.1-rc.1'
|
||||
|
||||
/** 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'
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npm.pkg.github.com"
|
||||
"registry": "https://git.kof22.com/api/packages/QRun-IO/npm/"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
|
||||
@@ -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.0.1-rc.1",
|
||||
"description": "Client kernel for QQQ terminal clients: sessions, metadata, filter grammar, records, processes, jobs, render model and output formats. No terminal code.",
|
||||
"keywords": [
|
||||
"qqq",
|
||||
@@ -42,7 +42,7 @@
|
||||
"node": ">=20"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npm.pkg.github.com"
|
||||
"registry": "https://git.kof22.com/api/packages/QRun-IO/npm/"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
@@ -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.8.1-rc.1",
|
||||
"jmespath": "0.16.0",
|
||||
"yaml": "2.9.1"
|
||||
},
|
||||
|
||||
@@ -34,7 +34,7 @@ export const DOCUMENT_EXTENSIONS: DocumentExtension[] = [
|
||||
{"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":"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."},
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
|
||||
import type { CallInput, CallResult } from '../api/call.js'
|
||||
import { readConfig, writeConfig } from '../session/profiles.js'
|
||||
import { downloadReference } from '../process/screen.js'
|
||||
import { exportFileName } from '../render/files.js'
|
||||
import type { ExtensionRegistry } from './registry.js'
|
||||
|
||||
export const PRINTING = 'printing'
|
||||
@@ -33,6 +35,8 @@ export const COPIES_FIELD = 'printingCopies'
|
||||
export const JOB_IDS_VALUE = 'printingJobIds'
|
||||
export const TABLE_LIST_PROCESS = 'printingPrintTableList'
|
||||
export const TEXT_PROCESS = 'printingPrintText'
|
||||
/** Renders a record sheet, table list or text page to a PDF download (Save as PDF). */
|
||||
export const RENDER_PROCESS = 'printingRenderToPdf'
|
||||
/** The most rows the server prints in one table list. */
|
||||
export const MAX_LIST_ROWS = 10_000
|
||||
/** "All rows" is the default list choice up to this many rows (or whenever the grid is filtered). */
|
||||
@@ -95,6 +99,8 @@ export interface PrintingTransport {
|
||||
call(id: string, input: CallInput): Promise<CallResult>
|
||||
/** Runs a process to its end with these values and selected record ids; returns its final values. */
|
||||
runProcess(processName: string, options: { values: Record<string, unknown>; recordIds: string[] }): Promise<Record<string, unknown>>
|
||||
/** Fetches a process download (GET /download/{file}); needed for Save as PDF. */
|
||||
download?(reference: { fileName: string; query: Record<string, string> }): Promise<{ bytes: Uint8Array; contentType: string }>
|
||||
}
|
||||
|
||||
interface QueryRecord {
|
||||
@@ -203,6 +209,75 @@ export function canPrintLists(meta: { processes?: Record<string, unknown> } | un
|
||||
return Boolean(meta?.processes?.[TABLE_LIST_PROCESS]) && Boolean(meta?.processes?.[TEXT_PROCESS])
|
||||
}
|
||||
|
||||
/**
|
||||
* The header and footer every printed page carries (printing 0.4.1+ renderers): the app or
|
||||
* instance name, the screen title or breadcrumb, the server, the user and the printed-at time in
|
||||
* the user's date format on top; the client's footer (`qqq ui 1.0.1 · server`), a grid's filter and
|
||||
* sort, and the page number at the bottom.
|
||||
*/
|
||||
export interface PrintChrome {
|
||||
app?: string
|
||||
title?: string
|
||||
server?: string
|
||||
user?: string
|
||||
printedAt?: string
|
||||
footerLeft?: string
|
||||
footerNote?: string
|
||||
}
|
||||
|
||||
/** The process values of a chrome (blank fields left out: the server's defaults apply). */
|
||||
export function chromeValues(chrome: PrintChrome | undefined): Record<string, string> {
|
||||
if (!chrome) return {}
|
||||
const out: Record<string, string> = {}
|
||||
const put = (key: string, value: string | undefined) => {
|
||||
if (value !== undefined && value.trim()) out[key] = value.trim()
|
||||
}
|
||||
put('headerApp', chrome.app)
|
||||
put('headerTitle', chrome.title)
|
||||
put('headerServer', chrome.server)
|
||||
put('headerUser', chrome.user)
|
||||
put('headerPrintedAt', chrome.printedAt)
|
||||
put('footerLeft', chrome.footerLeft)
|
||||
put('footerNote', chrome.footerNote)
|
||||
return out
|
||||
}
|
||||
|
||||
/** One section of a screen's content: a panel's title (blank for none) and its lines. */
|
||||
export interface ContentSection {
|
||||
title: string
|
||||
lines: string[]
|
||||
}
|
||||
|
||||
/** Whether the server can render documents to a PDF download ("Save as PDF"). */
|
||||
export function canSaveAsPdf(meta: { processes?: Record<string, unknown> } | undefined): boolean {
|
||||
return Boolean(meta?.processes?.[RENDER_PROCESS])
|
||||
}
|
||||
|
||||
/** What Save as PDF renders: a record sheet, a table list, or a text page. */
|
||||
export type RenderRequest =
|
||||
| { kind: 'record'; table: string; id: string }
|
||||
| { kind: 'list'; list: Omit<TableListRequest, 'printer' | 'copies'> }
|
||||
| { kind: 'text'; title: string; subtitle?: string; text: string; sections?: ContentSection[]; orientation?: 'PORTRAIT' | 'LANDSCAPE' }
|
||||
|
||||
/** A PDF rendered for the client: the default file name (stamped), the server's name, the bytes, and a note. */
|
||||
export interface RenderedPdf {
|
||||
fileName: string
|
||||
serverName: string
|
||||
bytes: Uint8Array
|
||||
message?: string
|
||||
}
|
||||
|
||||
/** The process values of a render request. */
|
||||
export function renderValues(request: RenderRequest, chrome?: PrintChrome): Record<string, unknown> {
|
||||
const header = chromeValues(chrome)
|
||||
if (request.kind === 'record') return { kind: 'RECORD', tableName: request.table, recordId: request.id, ...header }
|
||||
if (request.kind === 'text') return { kind: 'TEXT', title: request.title, ...(request.subtitle ? { subtitle: request.subtitle } : {}), text: request.text, ...(request.sections?.length ? { sections: JSON.stringify(request.sections) } : {}), orientation: request.orientation ?? 'PORTRAIT', pageSize: 'LETTER', ...header }
|
||||
const values = tableListValues({ ...request.list, printer: '' })
|
||||
delete values[PRINTER_FIELD]
|
||||
delete values[COPIES_FIELD]
|
||||
return { kind: 'LIST', ...values, ...header }
|
||||
}
|
||||
|
||||
/** What a grid print covers. */
|
||||
export type ListScope = { kind: 'all' } | { kind: 'page'; skip: number; limit: number } | { kind: 'selected'; ids: string[] } | { kind: 'sheets'; ids: string[] }
|
||||
|
||||
@@ -308,6 +383,7 @@ export interface PrintRequest {
|
||||
/** Printer row id. */
|
||||
printer: string
|
||||
copies?: number
|
||||
chrome?: PrintChrome
|
||||
}
|
||||
|
||||
export interface TableListRequest {
|
||||
@@ -329,12 +405,16 @@ export interface TableListRequest {
|
||||
pageSize?: 'LETTER' | 'A4'
|
||||
/** The primary key field, for a selection. */
|
||||
primaryKeyField?: string
|
||||
chrome?: PrintChrome
|
||||
}
|
||||
|
||||
export interface TextRequest {
|
||||
title: string
|
||||
subtitle?: string
|
||||
text: string
|
||||
/** A screen's content as sections (printed instead of the raw text when given). */
|
||||
sections?: ContentSection[]
|
||||
chrome?: PrintChrome
|
||||
printer: string
|
||||
copies?: number
|
||||
orientation?: 'PORTRAIT' | 'LANDSCAPE'
|
||||
@@ -376,6 +456,7 @@ export function tableListValues(request: TableListRequest): Record<string, unkno
|
||||
pageSize: request.pageSize ?? 'LETTER',
|
||||
[PRINTER_FIELD]: request.printer,
|
||||
[COPIES_FIELD]: request.copies ?? 1,
|
||||
...chromeValues(request.chrome),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -438,7 +519,7 @@ export class Printing {
|
||||
if (!request.ids.length) throw new Error('nothing to print: no record ids')
|
||||
const copies = request.copies ?? 1
|
||||
if (!Number.isInteger(copies) || copies < 1 || copies > 100) throw new Error('copies must be 1 to 100')
|
||||
const values = await this.transport.runProcess(printProcessName(request.table), { values: { [PRINTER_FIELD]: request.printer, [COPIES_FIELD]: copies }, recordIds: request.ids })
|
||||
const values = await this.transport.runProcess(printProcessName(request.table), { values: { [PRINTER_FIELD]: request.printer, [COPIES_FIELD]: copies, ...chromeValues(request.chrome) }, recordIds: request.ids })
|
||||
return jobIdsOf(values)
|
||||
}
|
||||
|
||||
@@ -453,10 +534,26 @@ export class Printing {
|
||||
async printText(request: TextRequest): Promise<PrintOutcome> {
|
||||
this.require()
|
||||
if (!request.text.trim()) throw new Error('nothing to print: the text is empty')
|
||||
const values = await this.transport.runProcess(TEXT_PROCESS, { values: { title: request.title, ...(request.subtitle ? { subtitle: request.subtitle } : {}), text: request.text, orientation: request.orientation ?? 'PORTRAIT', pageSize: 'LETTER', [PRINTER_FIELD]: request.printer, [COPIES_FIELD]: request.copies ?? 1 }, recordIds: [] })
|
||||
const values = await this.transport.runProcess(TEXT_PROCESS, { values: { ...chromeValues(request.chrome), title: request.title, ...(request.subtitle ? { subtitle: request.subtitle } : {}), text: request.text, ...(request.sections?.length ? { sections: JSON.stringify(request.sections) } : {}), orientation: request.orientation ?? 'PORTRAIT', pageSize: 'LETTER', [PRINTER_FIELD]: request.printer, [COPIES_FIELD]: request.copies ?? 1 }, recordIds: [] })
|
||||
return { jobIds: jobIdsOf(values) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a record sheet, table list or text page to a PDF on the server (no printer, no job)
|
||||
* and downloads it; the default name is the server's (the document title) with the date and
|
||||
* time stamp downloads and exports use: `Customer-Ada-Lovelace_2026-11-01_120301.pdf`.
|
||||
*/
|
||||
async renderToPdf(request: RenderRequest, now: Date = new Date(), chrome?: PrintChrome): Promise<RenderedPdf> {
|
||||
this.require()
|
||||
if (!this.transport.download) throw new Error('this client cannot download process files')
|
||||
if (request.kind === 'text' && !request.text.trim()) throw new Error('nothing to save: the text is empty')
|
||||
const values = await this.transport.runProcess(RENDER_PROCESS, { values: renderValues(request, chrome), recordIds: [] })
|
||||
const reference = downloadReference(values)
|
||||
if (!reference) throw new Error('the server rendered no PDF (no download in its answer)')
|
||||
const { bytes } = await this.transport.download(reference)
|
||||
return { fileName: exportFileName(reference.fileName, 'pdf', now), serverName: reference.fileName, bytes, message: values.printingMessage === undefined ? undefined : String(values.printingMessage) }
|
||||
}
|
||||
|
||||
/** Cancels jobs still queued in QQQ (jobs a device has taken must be handled at the device). */
|
||||
async cancel(ids: string[]): Promise<void> {
|
||||
this.require()
|
||||
|
||||
@@ -48,6 +48,7 @@ export * from './render/text.js'
|
||||
export * from './extensions/registry.js'
|
||||
export * from './extensions/collaboration.js'
|
||||
export * from './extensions/assistant.js'
|
||||
export * from './render/markdown.js'
|
||||
export * from './extensions/list-columns.js'
|
||||
export * from './extensions/esb.js'
|
||||
export * from './extensions/health.js'
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The small markdown the assistant writes, parsed into blocks both heads draw: headings, paragraphs,
|
||||
* bullet and numbered lists, pipe tables, and inline **bold**, `code` and [links](/app/...). HTML
|
||||
* entities are decoded (`:` is `:`). Links are numbered in reading order, one number per target
|
||||
* (two links to the same record share it), so a client can make them Tab targets and never show a
|
||||
* URL. Exact duplicate paragraphs are collapsed (models sometimes restate a sentence word for word).
|
||||
*/
|
||||
|
||||
import { parseAppHref } from '../extensions/assistant.js'
|
||||
import { layoutTable } from './output.js'
|
||||
import { decodeEntities } from './text.js'
|
||||
|
||||
/** A run of text with one style; a link carries its number. */
|
||||
export interface MdInline {
|
||||
text: string
|
||||
style?: 'bold' | 'code' | 'link'
|
||||
href?: string
|
||||
/** For links: the index into {@link MdDocument.links}. */
|
||||
link?: number
|
||||
}
|
||||
|
||||
export type MdAlign = 'left' | 'right' | 'center'
|
||||
|
||||
export type MdBlock =
|
||||
| { kind: 'heading'; level: number; inlines: MdInline[] }
|
||||
| { kind: 'paragraph'; inlines: MdInline[] }
|
||||
| { kind: 'list'; ordered: boolean; items: MdInline[][] }
|
||||
| { kind: 'table'; headers: MdInline[][]; aligns: MdAlign[]; rows: MdInline[][][] }
|
||||
|
||||
export interface MdLink {
|
||||
label: string
|
||||
href: string
|
||||
/** What the link points at (see {@link linkTargetKey}): links with the same key are one link. */
|
||||
key: string
|
||||
}
|
||||
|
||||
export interface MdDocument {
|
||||
blocks: MdBlock[]
|
||||
/** Every link target, in reading order, once. */
|
||||
links: MdLink[]
|
||||
}
|
||||
|
||||
/** The primary key field of a table, for telling a filter on the key from a record link. */
|
||||
export type PrimaryKeyOf = (table: string) => string | undefined
|
||||
|
||||
/**
|
||||
* What a link points at, so equal targets compare equal: `record:{table}:{id}` (also for a filter
|
||||
* that is exactly "primary key EQUALS one value"), `table:{table}:{canonical filter}`, or the href.
|
||||
* Filters in JSON or base64 JSON compare by content (keys sorted, empty criteria dropped).
|
||||
*/
|
||||
export function linkTargetKey(href: string, primaryKeyOf: PrimaryKeyOf = () => 'id'): string {
|
||||
const target = parseAppHref(href)
|
||||
if (!target?.tableName) return `href:${href}`
|
||||
if (target.recordId !== undefined) return `record:${target.tableName}:${target.recordId}`
|
||||
const filter = target.filter as { criteria?: Array<{ fieldName?: string; operator?: string; values?: unknown[] }>; subFilters?: unknown[] } | undefined
|
||||
const criteria = filter?.criteria ?? []
|
||||
const pk = primaryKeyOf(target.tableName) ?? 'id'
|
||||
if (criteria.length === 1 && !filter?.subFilters?.length && criteria[0]!.fieldName === pk && criteria[0]!.operator === 'EQUALS' && criteria[0]!.values?.length === 1) {
|
||||
return `record:${target.tableName}:${String(criteria[0]!.values[0])}`
|
||||
}
|
||||
if (!criteria.length && !filter?.subFilters?.length) return `table:${target.tableName}:`
|
||||
return `table:${target.tableName}:${canonical({ criteria, subFilters: filter?.subFilters ?? [], booleanOperator: (filter as { booleanOperator?: string }).booleanOperator ?? 'AND' })}`
|
||||
}
|
||||
|
||||
function canonical(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonical).join(',')}]`
|
||||
if (value && typeof value === 'object') return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonical((value as Record<string, unknown>)[k])}`).join(',')}}`
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
/** Parses inline markdown: links, bold, code; entities decoded in text and labels. */
|
||||
function inlines(source: string, register: (label: string, href: string) => number): MdInline[] {
|
||||
const out: MdInline[] = []
|
||||
const push = (text: string, style?: MdInline['style']) => {
|
||||
if (!text) return
|
||||
const last = out[out.length - 1]
|
||||
if (!style && last && !last.style) last.text += text
|
||||
else out.push(style ? { text, style } : { text })
|
||||
}
|
||||
const pattern = /\[([^\]]+)\]\(([^)\s]+)\)|\*\*(.+?)\*\*|__(.+?)__|`([^`]+)`/g
|
||||
let at = 0
|
||||
for (const m of source.matchAll(pattern)) {
|
||||
push(decodeEntities(source.slice(at, m.index)))
|
||||
if (m[1] !== undefined) {
|
||||
const label = decodeEntities(m[1]).replace(/\*\*|__|`/g, '')
|
||||
const href = decodeEntities(m[2]!)
|
||||
out.push({ text: label, style: 'link', href, link: register(label, href) })
|
||||
} else if (m[3] !== undefined || m[4] !== undefined) push(decodeEntities(m[3] ?? m[4]!), 'bold')
|
||||
else push(decodeEntities(m[5]!), 'code')
|
||||
at = m.index + m[0].length
|
||||
}
|
||||
push(decodeEntities(source.slice(at)))
|
||||
return out
|
||||
}
|
||||
|
||||
const TABLE_RULE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/
|
||||
|
||||
function cells(line: string): string[] {
|
||||
let text = line.trim()
|
||||
if (text.startsWith('|')) text = text.slice(1)
|
||||
if (text.endsWith('|') && !text.endsWith('\\|')) text = text.slice(0, -1)
|
||||
// split on pipes that are not inside a link target or escaped
|
||||
const out: string[] = []
|
||||
let cell = ''
|
||||
let depth = 0
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const ch = text[i]!
|
||||
if (ch === '\\' && text[i + 1] === '|') { cell += '|'; i++; continue }
|
||||
if (ch === '(' && text[i - 1] === ']') depth++
|
||||
else if (ch === ')' && depth > 0) depth--
|
||||
if (ch === '|' && depth === 0) { out.push(cell.trim()); cell = '' } else cell += ch
|
||||
}
|
||||
out.push(cell.trim())
|
||||
return out
|
||||
}
|
||||
|
||||
/** Parses the assistant's markdown into blocks and numbered links. */
|
||||
export function parseMarkdown(text: string, primaryKeyOf?: PrimaryKeyOf): MdDocument {
|
||||
const links: MdLink[] = []
|
||||
const register = (label: string, href: string): number => {
|
||||
const key = linkTargetKey(href, primaryKeyOf)
|
||||
const found = links.findIndex((l) => l.key === key)
|
||||
if (found >= 0) return found
|
||||
links.push({ label, href, key })
|
||||
return links.length - 1
|
||||
}
|
||||
const blocks: MdBlock[] = []
|
||||
const seenParagraphs = new Set<string>()
|
||||
const lines = text.replace(/\r\n?/g, '\n').split('\n')
|
||||
let i = 0
|
||||
const isList = (l: string) => /^\s*([-*+]|\d+[.)])\s+/.test(l)
|
||||
const isHeading = (l: string) => /^\s{0,3}#{1,6}\s+/.test(l)
|
||||
const isTableStart = (n: number) => lines[n]?.includes('|') && TABLE_RULE.test(lines[n + 1] ?? '')
|
||||
while (i < lines.length) {
|
||||
const line = lines[i]!
|
||||
if (!line.trim()) { i++; continue }
|
||||
if (isHeading(line)) {
|
||||
const m = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/.exec(line)!
|
||||
blocks.push({ kind: 'heading', level: m[1]!.length, inlines: inlines(m[2]!, register) })
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if (isTableStart(i)) {
|
||||
const headers = cells(line).map((c) => inlines(c, register))
|
||||
const aligns: MdAlign[] = cells(lines[i + 1]!).map((c) => c.endsWith(':') ? (c.startsWith(':') ? 'center' : 'right') : 'left')
|
||||
const rows: MdInline[][][] = []
|
||||
i += 2
|
||||
while (i < lines.length && lines[i]!.trim() && lines[i]!.includes('|')) {
|
||||
rows.push(cells(lines[i]!).map((c) => inlines(c, register)))
|
||||
i++
|
||||
}
|
||||
blocks.push({ kind: 'table', headers, aligns, rows })
|
||||
continue
|
||||
}
|
||||
if (isList(line)) {
|
||||
const ordered = /^\s*\d+[.)]\s+/.test(line)
|
||||
const items: MdInline[][] = []
|
||||
while (i < lines.length && isList(lines[i]!)) {
|
||||
let item = lines[i]!.replace(/^\s*([-*+]|\d+[.)])\s+/, '')
|
||||
i++
|
||||
// continuation lines indented under the item
|
||||
while (i < lines.length && /^\s{2,}\S/.test(lines[i]!) && !isList(lines[i]!)) item += ' ' + lines[i++]!.trim()
|
||||
items.push(inlines(item, register))
|
||||
}
|
||||
blocks.push({ kind: 'list', ordered, items })
|
||||
continue
|
||||
}
|
||||
const paragraph: string[] = []
|
||||
while (i < lines.length && lines[i]!.trim() && !isHeading(lines[i]!) && !isList(lines[i]!) && !isTableStart(i)) paragraph.push(lines[i++]!.trim())
|
||||
const joined = paragraph.join(' ')
|
||||
const norm = decodeEntities(joined).replace(/\s+/g, ' ').trim()
|
||||
if (seenParagraphs.has(norm)) continue
|
||||
seenParagraphs.add(norm)
|
||||
blocks.push({ kind: 'paragraph', inlines: inlines(joined, register) })
|
||||
}
|
||||
return { blocks, links }
|
||||
}
|
||||
|
||||
/** The plain text of inlines; links as `label ↗` (or what `link` returns). */
|
||||
export function inlineText(parts: MdInline[], link: (part: MdInline) => string = (p) => `${p.text} ↗`): string {
|
||||
return parts.map((p) => (p.style === 'link' ? link(p) : p.text)).join('')
|
||||
}
|
||||
|
||||
/** Word-wraps plain text to a width (long words are cut); `indent` goes before continuation lines. */
|
||||
export function wrapPlain(text: string, width: number, first = '', rest = first): string[] {
|
||||
const out: string[] = []
|
||||
const w = Math.max(4, width)
|
||||
let line = first
|
||||
let empty = true
|
||||
for (const word of text.split(/\s+/).filter(Boolean)) {
|
||||
const prefixLen = (out.length ? rest : first).length
|
||||
if (!empty && line.length + 1 + word.length > w) {
|
||||
out.push(line)
|
||||
line = rest
|
||||
empty = true
|
||||
}
|
||||
let piece = word
|
||||
while ((empty ? line.length : line.length + 1) + piece.length > w && piece.length > w - prefixLen) {
|
||||
const room = w - line.length - (empty ? 0 : 1)
|
||||
line += (empty ? '' : ' ') + piece.slice(0, room)
|
||||
out.push(line)
|
||||
line = rest
|
||||
piece = piece.slice(room)
|
||||
empty = true
|
||||
}
|
||||
line += (empty ? '' : ' ') + piece
|
||||
empty = false
|
||||
}
|
||||
if (!empty || !out.length) out.push(line)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* The document as terminal text for a width: headings and paragraphs wrapped, lists with `-` or
|
||||
* `1.`, tables laid out like the CLI's sub-tables (columns that do not fit are left out, cells cut
|
||||
* with `…`). `link` renders a link (default `label ↗`).
|
||||
*/
|
||||
export function markdownText(doc: MdDocument, width = 100, link?: (part: MdInline) => string): string {
|
||||
const out: string[] = []
|
||||
for (const block of doc.blocks) {
|
||||
if (out.length) out.push('')
|
||||
if (block.kind === 'heading') out.push(...wrapPlain(inlineText(block.inlines, link), width))
|
||||
else if (block.kind === 'paragraph') out.push(...wrapPlain(inlineText(block.inlines, link), width))
|
||||
else if (block.kind === 'list') {
|
||||
block.items.forEach((item, n) => {
|
||||
const bullet = block.ordered ? `${n + 1}. ` : '- '
|
||||
out.push(...wrapPlain(inlineText(item, link), width, bullet, ' '.repeat(bullet.length)))
|
||||
})
|
||||
} else {
|
||||
const columns = block.headers.map((_, i) => `c${i}`)
|
||||
const rows = block.rows.map((r) => Object.fromEntries(columns.map((c, i) => [c, inlineText(r[i] ?? [], link)])))
|
||||
const { text, hidden } = layoutTable(rows, columns, block.headers.map((h) => inlineText(h, link)), { width, maxCell: Math.max(8, width) })
|
||||
out.push(...text.split('\n'))
|
||||
if (hidden.length) out.push(`(${hidden.length} more column${hidden.length === 1 ? '' : 's'} do not fit)`)
|
||||
}
|
||||
}
|
||||
return out.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* An assistant answer as a document, plus the answer's links that the text does not already show
|
||||
* (records and tables from tool results), once per target: the "→" list a client draws under the text.
|
||||
* The document's links come first, then these, so numbering them in that order is reading order.
|
||||
*/
|
||||
export function answerDocument(answer: { text: string; links: Array<{ label: string; href: string }> }, primaryKeyOf?: PrimaryKeyOf): { doc: MdDocument; extraLinks: MdLink[] } {
|
||||
const doc = parseMarkdown(answer.text, primaryKeyOf)
|
||||
const keys = new Set(doc.links.map((l) => l.key))
|
||||
const extraLinks: MdLink[] = []
|
||||
for (const link of answer.links) {
|
||||
const key = linkTargetKey(link.href, primaryKeyOf)
|
||||
if (keys.has(key)) continue
|
||||
keys.add(key)
|
||||
extraLinks.push({ label: link.label, href: link.href, key })
|
||||
}
|
||||
return { doc, extraLinks }
|
||||
}
|
||||
@@ -104,13 +104,31 @@ export class DpapiFileStore implements SecretStore {
|
||||
}
|
||||
private async ps(script: string, input: string): Promise<string> {
|
||||
const shell = process.env.QQQ_POWERSHELL ?? 'powershell'
|
||||
const { stdout } = await run(shell, ['-NoProfile', '-NonInteractive', '-Command', script.replace('$INPUT', JSON.stringify(input))])
|
||||
return stdout.trim()
|
||||
const command = "$ErrorActionPreference = 'Stop'; [Console]::InputEncoding = [Text.UTF8Encoding]::new($false); "
|
||||
+ '[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false); $qqqSecretInput = [Console]::In.ReadToEnd(); '
|
||||
+ '$qqqResult = & { ' + script.replace('$INPUT', '$qqqSecretInput') + ' }; '
|
||||
+ '[Console]::Write((ConvertTo-Json -InputObject $qqqResult -Compress))'
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const failed = () => reject(new Error('Windows credential protection failed; check PowerShell and your Windows profile'))
|
||||
// Started from PowerShell 7, the inherited PSModulePath names PowerShell 7's modules, and Windows PowerShell 5.1
|
||||
// then cannot load Microsoft.PowerShell.Security (ConvertTo-SecureString). Without it, each shell uses its own.
|
||||
const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => name.toUpperCase() !== 'PSMODULEPATH'))
|
||||
const child = execFile(shell, ['-NoProfile', '-NonInteractive', '-Command', command], { timeout: 15000, env }, (error, stdout) => {
|
||||
if (error) { failed(); return }
|
||||
try {
|
||||
const result: unknown = JSON.parse(stdout.trim())
|
||||
if (typeof result !== 'string') { failed(); return }
|
||||
resolve(result)
|
||||
} catch { failed() }
|
||||
})
|
||||
child.stdin?.on('error', failed)
|
||||
child.stdin?.end(input, 'utf8')
|
||||
})
|
||||
}
|
||||
async get(account: string): Promise<string | undefined> {
|
||||
const blob = this.read()[account]
|
||||
if (!blob) return undefined
|
||||
return this.ps('$s = ConvertTo-SecureString -String $INPUT; [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR([System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($s))', blob)
|
||||
return this.ps('$s = ConvertTo-SecureString -String $INPUT; $p = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($s); try { [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($p) } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($p); $s.Dispose() }', blob)
|
||||
}
|
||||
async set(account: string, secret: string): Promise<void> {
|
||||
const blob = await this.ps('ConvertTo-SecureString -String $INPUT -AsPlainText -Force | ConvertFrom-SecureString', secret)
|
||||
|
||||
@@ -46,6 +46,8 @@ export interface UiSettings {
|
||||
childRows?: number
|
||||
/** The terminal UI copies a CLI equivalent to the clipboard as soon as it shows it (default off). */
|
||||
copyCliCommands?: boolean
|
||||
/** The terminal UI's function-key bar: one row, two rows (with the Shift bank), or auto (two from 30 lines). */
|
||||
fkeyBar?: 'one' | 'two' | 'auto'
|
||||
/** Date entry: display order, clock, week start, picker behaviour (forms/dates.ts). */
|
||||
dates?: import('../forms/dates.js').DateSettings
|
||||
/** Keyed by screen, e.g. `table:person`, `record:person`, `app:peopleApp`. */
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
import { DpapiFileStore } from '../session/keyring.js'
|
||||
|
||||
test('Windows DPAPI preserves JSON, shell syntax, Unicode and whitespace without plaintext storage', { skip: process.platform !== 'win32' }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'qqq-dpapi-fixture-'))
|
||||
const file = join(dir, 'secrets.dpapi.json')
|
||||
const secret = ' ' + JSON.stringify({ token: 'obviously-fake-credential', syntax: '\"\'`$(){}\\', text: 'héllo 世界\nsecond line' }) + '\n '
|
||||
try {
|
||||
const store = new DpapiFileStore(file)
|
||||
await store.set('fixture', secret)
|
||||
assert.equal(readFileSync(file, 'utf8').includes('obviously-fake-credential'), false)
|
||||
assert.equal(await new DpapiFileStore(file).get('fixture'), secret)
|
||||
assert.equal(await store.delete('fixture'), true)
|
||||
assert.equal(await store.get('fixture'), undefined)
|
||||
} finally { rmSync(dir, { recursive: true, force: true }) }
|
||||
})
|
||||
|
||||
test('Windows credential failure does not expose subprocess arguments or credential material', { skip: process.platform !== 'win32' }, async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'qqq-dpapi-failure-'))
|
||||
const file = join(dir, 'secrets.dpapi.json')
|
||||
try {
|
||||
writeFileSync(file, JSON.stringify({ fixture: 'obviously-fake-invalid-dpapi-value' }))
|
||||
await assert.rejects(new DpapiFileStore(file).get('fixture'), error => {
|
||||
assert.equal(String(error), 'Error: Windows credential protection failed; check PowerShell and your Windows profile')
|
||||
return true
|
||||
})
|
||||
} finally { rmSync(dir, { recursive: true, force: true }) }
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"conversationId": 4,
|
||||
"text": "3 services are not fully healthy: one degraded (email) and two with unknown status. None are down.\n\nUnhealthy services\n\n| Service | Status | Since | Checks up | Degraded | Unknown |\n| --- | --- | --- | ---: | ---: | ---: |\n| [email](/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B4%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D) | DEGRADED | 2026-11-01 11:25 UTC | 0 | 1 | 0 |\n| [db_performance](/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B3%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D) | UNKNOWN | 2026-11-01 17:25 UTC | 2 | 0 | 1 |\n| [esb](/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B10%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D) | UNKNOWN | 2026-11-01 11:23 UTC | 1 | 0 | 1 |\n\n- [Open unhealthy components](/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22status%22%2C%22operator%22%3A%22NOT_EQUALS%22%2C%22values%22%3A%5B%22UP%22%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D)\nThree services aren't fully healthy right now:\n\n- **email** is degraded. Its only check is degraded.\n- **db_performance** is unknown. One of its three checks has no status.\n- **esb** is unknown. One of its two checks has no status.\n\nNo service is down.",
|
||||
"status": "COMPLETE",
|
||||
"notices": [],
|
||||
"numbers": [],
|
||||
"links": [
|
||||
{
|
||||
"label": "db_performance (Health Components)",
|
||||
"href": "/app/health_component/3",
|
||||
"kind": "record",
|
||||
"tableName": "health_component",
|
||||
"recordId": "3"
|
||||
},
|
||||
{
|
||||
"label": "email (Health Components)",
|
||||
"href": "/app/health_component/4",
|
||||
"kind": "record",
|
||||
"tableName": "health_component",
|
||||
"recordId": "4"
|
||||
},
|
||||
{
|
||||
"label": "esb (Health Components)",
|
||||
"href": "/app/health_component/10",
|
||||
"kind": "record",
|
||||
"tableName": "health_component",
|
||||
"recordId": "10"
|
||||
},
|
||||
{
|
||||
"label": "Health Components (3)",
|
||||
"href": "/app/health_component?filter=eyJjcml0ZXJpYSI6W3siZmllbGROYW1lIjoic3RhdHVzIiwib3BlcmF0b3IiOiJOT1RfRVFVQUxTIiwidmFsdWVzIjpbIlVQIl19XSwiYm9vbGVhbk9wZXJhdG9yIjoiQU5EIn0%3D",
|
||||
"kind": "table",
|
||||
"tableName": "health_component"
|
||||
},
|
||||
{
|
||||
"label": "email · DEGRADED",
|
||||
"href": "/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B4%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D",
|
||||
"kind": "table",
|
||||
"tableName": "health_component"
|
||||
},
|
||||
{
|
||||
"label": "db_performance · UNKNOWN",
|
||||
"href": "/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B3%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D",
|
||||
"kind": "table",
|
||||
"tableName": "health_component"
|
||||
},
|
||||
{
|
||||
"label": "esb · UNKNOWN",
|
||||
"href": "/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22id%22%2C%22operator%22%3A%22EQUALS%22%2C%22values%22%3A%5B10%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D",
|
||||
"kind": "table",
|
||||
"tableName": "health_component"
|
||||
},
|
||||
{
|
||||
"label": "Open unhealthy components",
|
||||
"href": "/app/health_component?filter=%7B%22criteria%22%3A%5B%7B%22fieldName%22%3A%22status%22%2C%22operator%22%3A%22NOT_EQUALS%22%2C%22values%22%3A%5B%22UP%22%5D%7D%5D%2C%22booleanOperator%22%3A%22AND%22%7D",
|
||||
"kind": "table",
|
||||
"tableName": "health_component"
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"name": "describe_table",
|
||||
"summary": "Described Health Components",
|
||||
"outcome": "OK"
|
||||
},
|
||||
{
|
||||
"name": "query_records",
|
||||
"summary": "Queried Health Components · 3 rows",
|
||||
"outcome": "OK"
|
||||
},
|
||||
{
|
||||
"name": "present_results",
|
||||
"summary": "Presented results",
|
||||
"outcome": "OK"
|
||||
}
|
||||
],
|
||||
"turnId": 7,
|
||||
"messageId": 14,
|
||||
"usage": {
|
||||
"inputTokens": 10,
|
||||
"outputTokens": 729,
|
||||
"costMicros": 34021
|
||||
},
|
||||
"elapsedMs": 7140
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { answerDocument, linkTargetKey, markdownText, parseMarkdown, wrapPlain } from '../render/markdown.js'
|
||||
|
||||
/** The answer to "what services are not healthy right now?" as 8078 gave it (qqq assistant ask --json). */
|
||||
const fixture = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'src', 'test', 'fixtures', 'assistant-unhealthy.json'), 'utf8')) as { text: string; links: Array<{ label: string; href: string }> }
|
||||
|
||||
test('before: the raw answer has entities, a pipe table and URL-encoded link targets', () => {
|
||||
assert.match(fixture.text, /healthy:/)
|
||||
assert.match(fixture.text, /\| --- \| --- \|/)
|
||||
assert.match(fixture.text, /\(\/app\/health_component\?filter=%7B/)
|
||||
assert.equal(fixture.links.length, 8)
|
||||
})
|
||||
|
||||
test('after: entities decoded, the table laid out, links as label ↗ and never their URL', () => {
|
||||
const { doc, extraLinks } = answerDocument(fixture)
|
||||
const text = markdownText(doc, 100)
|
||||
assert.doesNotMatch(text, /&#|\/app\/|%7B|\| ---/)
|
||||
assert.match(text, /^3 services are not fully healthy: one degraded \(email\) and two with unknown status\. None are down\./)
|
||||
assert.match(text, /Service\s+Status\s+Since\s+Checks up\s+Degraded\s+Unknown/)
|
||||
assert.match(text, /db_performance ↗\s+UNKNOWN\s+2026-11-01 17:25 UTC/)
|
||||
assert.match(text, /- Open unhealthy components ↗/)
|
||||
assert.match(text, /- email is degraded\. Its only check is degraded\./)
|
||||
assert.ok(text.split('\n').every((l) => [...l].length <= 100))
|
||||
// four targets in reading order; the tool results' eight links are all the same four (records = id filters)
|
||||
assert.deepEqual(doc.links.map((l) => l.label), ['email', 'db_performance', 'esb', 'Open unhealthy components'])
|
||||
assert.deepEqual(extraLinks, [])
|
||||
})
|
||||
|
||||
test('narrow: the table drops what does not fit and says so', () => {
|
||||
const text = markdownText(answerDocument(fixture).doc, 40)
|
||||
assert.ok(text.split('\n').every((l) => [...l].length <= 40))
|
||||
assert.match(text, /more columns? do not fit/)
|
||||
})
|
||||
|
||||
test('duplicate paragraphs collapse; lists, headings, bold and code', () => {
|
||||
const doc = parseMarkdown('## Totals\n\nThere are **42** `customers`.\n\nThere are **42** `customers`.\n\n1. one\n2. two\n')
|
||||
assert.deepEqual(doc.blocks.map((b) => b.kind), ['heading', 'paragraph', 'list'])
|
||||
assert.deepEqual((doc.blocks[1] as { inlines: Array<{ style?: string }> }).inlines.map((i) => i.style ?? ''), ['', 'bold', '', 'code', ''])
|
||||
assert.equal(markdownText(doc, 80), 'Totals\n\nThere are 42 customers.\n\n1. one\n2. two')
|
||||
})
|
||||
|
||||
test('link targets: records, id filters, JSON and base64 filters compare by content', () => {
|
||||
const json = '/app/t?filter=' + encodeURIComponent('{"booleanOperator":"AND","criteria":[{"fieldName":"s","operator":"EQUALS","values":["x"]}]}')
|
||||
const b64 = '/app/t?filter=' + encodeURIComponent(Buffer.from('{"criteria":[{"fieldName":"s","operator":"EQUALS","values":["x"]}],"booleanOperator":"AND"}').toString('base64'))
|
||||
assert.equal(linkTargetKey(json), linkTargetKey(b64))
|
||||
assert.equal(linkTargetKey('/app/t/4'), linkTargetKey('/app/t?filter=' + encodeURIComponent('{"criteria":[{"fieldName":"id","operator":"EQUALS","values":[4]}]}')))
|
||||
assert.notEqual(linkTargetKey('/app/t/4', () => 'code'), linkTargetKey('/app/t?filter=' + encodeURIComponent('{"criteria":[{"fieldName":"id","operator":"EQUALS","values":[4]}]}'), () => 'code'))
|
||||
assert.equal(linkTargetKey('/app/t'), 'table:t:')
|
||||
})
|
||||
|
||||
test('wrapping keeps the prefix and cuts long words', () => {
|
||||
assert.deepEqual(wrapPlain('alpha beta gamma', 11, '- ', ' '), ['- alpha', ' beta', ' gamma'])
|
||||
assert.deepEqual(wrapPlain('abcdefghij', 6), ['abcdef', 'ghij'])
|
||||
})
|
||||
@@ -10,7 +10,7 @@ import { join } from 'node:path'
|
||||
import { test } from 'node:test'
|
||||
import type { CallInput, CallResult } from '../api/call.js'
|
||||
import { ExtensionNotInstalledError, ExtensionRegistry, knownExtension } from '../extensions/registry.js'
|
||||
import { MAX_LIST_ROWS, Printing, canPrintLists, canPrintTo, listScopeChoices, tableListValues, isFinalJobState, jobIdsOf, jobOf, jobStateSummary, printProcessName, printerOf, readRememberedPrinter, resolvePrinter, tableHasPrint, whyNotPrintable, writeRememberedPrinter, type PrintingTransport } from '../extensions/printing.js'
|
||||
import { MAX_LIST_ROWS, Printing, canSaveAsPdf, chromeValues, renderValues, canPrintLists, canPrintTo, listScopeChoices, tableListValues, isFinalJobState, jobIdsOf, jobOf, jobStateSummary, printProcessName, printerOf, readRememberedPrinter, resolvePrinter, tableHasPrint, whyNotPrintable, writeRememberedPrinter, type PrintingTransport } from '../extensions/printing.js'
|
||||
import { readConfig, upsertProfile } from '../session/profiles.js'
|
||||
|
||||
const withPrinting = ExtensionRegistry.fromVersion({ extensions: [{ name: 'printing', version: '2.0.0' }] })
|
||||
@@ -201,3 +201,58 @@ test('grid print choices: all rows first when filtered or small, this page first
|
||||
assert.equal(canPrintLists({ processes: { printingPrintTableList: {}, printingPrintText: {} } }), true)
|
||||
assert.equal(canPrintLists({ processes: { printingPrintTableList: {} } }), false)
|
||||
})
|
||||
|
||||
test('save as PDF: render values for a record, a list (no printer) and a text page', () => {
|
||||
assert.deepEqual(renderValues({ kind: 'record', table: 'customer', id: '1' }), { kind: 'RECORD', tableName: 'customer', recordId: '1' })
|
||||
const list = renderValues({ kind: 'list', list: { table: 'person', columns: ['id'], filter: { criteria: [], orderBys: [{ fieldName: 'id', isAscending: true }] }, scope: { kind: 'all' }, title: 'Person' } })
|
||||
assert.equal(list.kind, 'LIST')
|
||||
assert.equal(list.scope, 'ALL')
|
||||
assert.equal(list.printingPrinterId, undefined)
|
||||
assert.equal(list.printingCopies, undefined)
|
||||
assert.deepEqual(renderValues({ kind: 'text', title: 'Home', text: 'x' }), { kind: 'TEXT', title: 'Home', text: 'x', orientation: 'PORTRAIT', pageSize: 'LETTER' })
|
||||
assert.equal(canSaveAsPdf({ processes: { printingRenderToPdf: {} } }), true)
|
||||
assert.equal(canSaveAsPdf({ processes: {} }), false)
|
||||
})
|
||||
|
||||
test('save as PDF: renders on the server, downloads the file and names it with the download stamp', async () => {
|
||||
const downloads: unknown[] = []
|
||||
const { runs, transport } = fake(() => ({}), async () => ({ downloadFileName: 'Customer-Ada-Lovelace.pdf', serverFilePath: '/tmp/qqq-print-1.pdf', printingMessage: 'Saved the first 10000 of 12000 rows' }))
|
||||
transport.download = async (reference) => {
|
||||
downloads.push(reference)
|
||||
return { bytes: new TextEncoder().encode('%PDF-1.7'), contentType: 'application/pdf' }
|
||||
}
|
||||
const printing = new Printing(withPrinting, transport)
|
||||
const pdf = await printing.renderToPdf({ kind: 'record', table: 'customer', id: '1' }, new Date(2026, 10, 1, 12, 3, 1))
|
||||
assert.equal(pdf.fileName, 'Customer-Ada-Lovelace_2026-11-01_120301.pdf')
|
||||
assert.equal(pdf.serverName, 'Customer-Ada-Lovelace.pdf')
|
||||
assert.equal(new TextDecoder().decode(pdf.bytes), '%PDF-1.7')
|
||||
assert.equal(pdf.message, 'Saved the first 10000 of 12000 rows')
|
||||
assert.deepEqual(runs[0], { name: 'printingRenderToPdf', values: { kind: 'RECORD', tableName: 'customer', recordId: '1' }, recordIds: [] })
|
||||
assert.deepEqual(downloads, [{ fileName: 'Customer-Ada-Lovelace.pdf', query: { filePath: '/tmp/qqq-print-1.pdf' } }])
|
||||
await assert.rejects(printing.renderToPdf({ kind: 'text', title: 't', text: ' ' }), /empty/)
|
||||
delete transport.download
|
||||
await assert.rejects(printing.renderToPdf({ kind: 'record', table: 'customer', id: '1' }), /cannot download/)
|
||||
await assert.rejects(new Printing(without, transport).renderToPdf({ kind: 'record', table: 'customer', id: '1' }), ExtensionNotInstalledError)
|
||||
const { transport: none } = fake(() => ({}), async () => ({}))
|
||||
none.download = async () => ({ bytes: new Uint8Array(), contentType: '' })
|
||||
await assert.rejects(new Printing(withPrinting, none).renderToPdf({ kind: 'record', table: 'customer', id: '1' }), /no PDF/)
|
||||
})
|
||||
|
||||
test('the app header and footer travel with every request; blank fields are left to the server', async () => {
|
||||
const chrome = { app: 'QQQ All', title: 'Sales › Customer › Ada Lovelace', server: 'local (localhost:8078)', user: 'Admin', printedAt: '2026-11-01 12:03', footerLeft: 'qqq ui 1.0.1 · local', footerNote: ' ' }
|
||||
assert.deepEqual(chromeValues(chrome), { headerApp: 'QQQ All', headerTitle: 'Sales › Customer › Ada Lovelace', headerServer: 'local (localhost:8078)', headerUser: 'Admin', headerPrintedAt: '2026-11-01 12:03', footerLeft: 'qqq ui 1.0.1 · local' })
|
||||
assert.deepEqual(chromeValues(undefined), {})
|
||||
const sections = [{ title: 'Recently viewed', lines: ['Ada Lovelace'] }]
|
||||
const text = renderValues({ kind: 'text', title: 'Home', text: 'raw', sections }, chrome)
|
||||
assert.equal(text.sections, JSON.stringify(sections))
|
||||
assert.equal(text.headerApp, 'QQQ All')
|
||||
assert.equal(renderValues({ kind: 'record', table: 'customer', id: '1' }, chrome).headerTitle, 'Sales › Customer › Ada Lovelace')
|
||||
const { runs, transport } = fake(() => ({}), async () => ({ printingJobIds: 'j1' }))
|
||||
const printing = new Printing(withPrinting, transport)
|
||||
await printing.print({ table: 'customer', ids: ['1'], printer: 'p1', chrome })
|
||||
await printing.printText({ title: 'Home', text: 'raw', sections, printer: 'p1', chrome })
|
||||
await printing.printTableList({ table: 'person', columns: ['id'], filter: {}, scope: { kind: 'all' }, printer: 'p1', chrome: { ...chrome, footerNote: 'Filter: x' } })
|
||||
assert.equal(runs[0]!.values.headerUser, 'Admin')
|
||||
assert.equal(runs[1]!.values.sections, JSON.stringify(sections))
|
||||
assert.equal(runs[2]!.values.footerNote, 'Filter: x')
|
||||
})
|
||||
@@ -18,6 +18,6 @@
|
||||
"devDependencies": {
|
||||
"@types/bun": "1.4.4",
|
||||
"@types/react": "19.3.0",
|
||||
"typescript": "5.8.3"
|
||||
"typescript": "6.0.3"
|
||||
}
|
||||
}
|
||||
+130
-27
@@ -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.0.0 · UI API client 1.8.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
|
||||
|
||||
@@ -55,6 +85,47 @@ shared with the CLI. `QQQ_UI_NO_QUIT=1` is for the soak test only.
|
||||
| `t` | tags of a record (collaboration extension) | Esc then 1..0, Ctrl+1..0 | F1..F10 when the terminal takes the F-keys |
|
||||
| Ctrl+P, `p`, PrtSc | print what you see: record sheet, table list (all rows or this page), or the screen (printing extension; else save as a file) | | |
|
||||
|
||||
### Shift bank (Shift+F1…F10)
|
||||
|
||||
A second row of ten function keys, like Norton Commander's Shift bar. Keys that do not apply on the screen
|
||||
are dimmed; pressing one says why.
|
||||
|
||||
| Key | Label | Does |
|
||||
|---|---|---|
|
||||
| Shift+F1 | Keys | the keyboard reference (as `?`) |
|
||||
| Shift+F2 | Servers | the Servers dialog (as Ctrl+O) |
|
||||
| Shift+F3 | SQL | the SQL Runner (sql-workbench extension) |
|
||||
| Shift+F4 | Bulk | bulk edit, bulk delete and processes on the grid selection (as `b`) |
|
||||
| Shift+F5 | Metadata | refresh the metadata from the server |
|
||||
| Shift+F6 | Views | saved views of the grid |
|
||||
| Shift+F7 | Copy | copy the record, or the grid row under the cursor, into a new record (as `c`) |
|
||||
| Shift+F8 | Export | export the grid to a file (as `x`) |
|
||||
| Shift+F9 | History | the History screen (as `:history`) |
|
||||
| Shift+F10 | Assistant | show or hide the Assistant panel (the same action as F12 and Ctrl+G; assistant extension) |
|
||||
|
||||
The bar shows the Shift row under the primary one (prefixed `⇧`) when the terminal has 30 lines or more.
|
||||
`:fkeys one|two|auto` or F9 › Settings › F-key bar chooses: **auto** (two rows from 30 lines), **two** rows
|
||||
always, or **one** row, where the Shift row takes the primary row's place for 2 seconds after any Shift+F
|
||||
press (a terminal cannot report Shift being pressed and released on its own). A click on the Shift row runs
|
||||
that key. The assignments are one table (`src/keys/shift-bank.ts`), each naming an action in
|
||||
`src/keys/actions.ts`.
|
||||
|
||||
What terminals send for Shift+F1…F12 is decoded in one place (`src/keys/fkeys.ts`):
|
||||
|
||||
| Terminal | Shift+F1…F12 | Notes |
|
||||
|---|---|---|
|
||||
| tmux (any outer terminal) | `ESC [1;2P` … `ESC [1;2S`, `ESC [15;2~` … `ESC [24;2~` | verified live; tmux re-encodes the keys it knows |
|
||||
| iTerm2, Windows Terminal, xterm, VTE, Konsole, Alacritty | the same xterm forms | kitty-protocol terminals (kitty, WezTerm, Ghostty, iTerm2 with CSI u) send `ESC [13;2~` for F3 and may send `ESC [57364;2u`…; both read |
|
||||
| macOS Terminal.app | Shift+F5…F12 are `ESC [25~` … `ESC [34~` | Shift+F1…F4 have no code of their own by default: add them in Settings › Profiles › Keyboard (`\033[1;2P` …) or use the F9 menu |
|
||||
| rxvt, urxvt, Linux console | Shift+F3…F10 are `ESC [25~` … `ESC [34~`, Shift+F11/F12 `ESC [23$` / `ESC [24$` | Shift+F1/F2 send the same as F11/F12 there |
|
||||
|
||||
Shift+F3 in the xterm form (`ESC [1;2R`) looks like a cursor position report, which OpenTUI drops; it and
|
||||
the legacy forms are rewritten on the input stream into the canonical `ESC [n;2~` (never in the first
|
||||
1.5 s after start or a resume, while the terminal answers OpenTUI's cursor queries). The legacy numbering is
|
||||
picked from `TERM_PROGRAM` and `TERM`; `QQQ_FKEYS=vt220|rxvt|apple|modern` overrides it (vt220: `ESC [25~`
|
||||
is Shift+F1; modern: legacy numbers are ignored, the default in tmux, iTerm2, Windows Terminal and kitty).
|
||||
Ctrl+F1…F12 are decoded too but have no bindings yet: Ctrl+F5 acts as F5.
|
||||
|
||||
Keys on one screen:
|
||||
|
||||
| Screen | Keys |
|
||||
@@ -84,11 +155,19 @@ passes it on — most do not, so Ctrl+P is the key to rely on):
|
||||
selected row**. The server prints up to 10,000 rows (more prints the first 10,000 and says so), repeats
|
||||
the header on every page, turns the page landscape when the columns need it and puts the table, filter,
|
||||
sort, row count, user and time in the header and footer;
|
||||
- any other screen (home, dashboards, the developer view, SQL results, process results, output) prints the
|
||||
**screen as text**: a monospace PDF of what is drawn, without the status line and F-key bar, sized so a
|
||||
wide terminal keeps its lines.
|
||||
- any other screen (home, dashboards, the developer view, SQL results, process results, Search, History,
|
||||
output) first asks **Print what?**: **Content** (the default) prints the screen's panels as a document —
|
||||
each panel a section under its title, no Apps sidebar, title bar, key lines or borders, wrapped to the
|
||||
page — and **Screen** prints the terminal as drawn (monospace, sized so a wide terminal keeps its lines).
|
||||
|
||||
Then the printer picker: printers that can print now (approved, exposed by a connected print agent, PDF)
|
||||
Every page carries the app's **header** (the instance's name, the screen's breadcrumb such as `People ›
|
||||
Person › Ada Lovelace`, the server, you, and the time in your date format) and **footer** (`qqq ui 1.0.1 ·
|
||||
<server>`, a grid's filter and sort, page n of m).
|
||||
|
||||
Then the printer picker, with **Save as PDF…** first: it renders the same document on the server and saves
|
||||
it the way downloads and exports are saved — the save dialog (or, with `download-mode silent`, straight to
|
||||
the download folder), the stamped name (`Customer-Ada-Lovelace_2026-11-01_120301.pdf`), never overwriting
|
||||
without asking. `:print pdf` saves without the picker. Then printers that can print now (approved, exposed by a connected print agent, PDF)
|
||||
come first, the others are listed with the reason and cannot be picked; the printer chosen is remembered
|
||||
per profile and preselected next time, marked "(last used)". `:print <printer>` skips the picker (name,
|
||||
unique prefix or id). The title bar and status line show `print 1/3 RECEIVED` while jobs run, and a flash
|
||||
@@ -350,31 +429,51 @@ help-authoring mode (F1 shows every help slot key).
|
||||
|
||||
## Assistant
|
||||
|
||||
With the `assistant` UI API extension (qbit-assistant), `Ctrl+G`, `:ask [question]` or F9 › Work ›
|
||||
Assistant opens the Assistant panel: a chat pane on the right of the screen (40 to 64 columns), or the
|
||||
whole body on terminals narrower than 110 columns. The question line is at the bottom and the
|
||||
conversation above it: each question, the answer as it is written, its numbers (counts and metrics) and
|
||||
its links. Answers are read-only and use your permissions.
|
||||
With the `assistant` UI API extension (qbit-assistant), **F12** shows and hides the Assistant panel on
|
||||
every screen (also `Ctrl+G`, `:ask [question]`, F9 › Work › Assistant, or a click on `12Ask`). It is a
|
||||
chat pane on the right of the screen (40 to 64 columns), or the whole body on terminals narrower than 110
|
||||
columns. The question line is at the bottom and the conversation above it. Answers are read-only and use
|
||||
your permissions.
|
||||
|
||||
Questions carry the screen under the panel as context, the way the Next UI's panel sends it: a record
|
||||
(`/app/{table}/{id}`) or a grid with its filter; the first line of the panel says which (`on: Person #3`).
|
||||
Hiding the panel keeps the conversation, its context and the draft on the question line; the next F12
|
||||
shows them again. When the panel is shown but the screen beside it has the focus (after opening a link),
|
||||
F12 goes back to the question line. F11 is reserved.
|
||||
|
||||
Answers are drawn from their markdown: entities decoded, headings in the accent colour, paragraphs
|
||||
wrapped to the pane, bullet and numbered lists, **bold** and `code`, and pipe tables laid out like the
|
||||
grid (columns that do not fit are named underneath, cells cut with `…`). A link shows as `label ↗`, never
|
||||
its address; each is a Tab target in reading order, once per target. Records and tables the tool results
|
||||
refer to that the text does not show are listed under `→`. Exact duplicate paragraphs are shown once;
|
||||
the timing and token line stays, dimmed.
|
||||
|
||||
Questions carry the screen the panel was shown over as context, the way the Next UI's panel sends it: a
|
||||
record (`/app/{table}/{id}`) or a grid with its filter; the first line of the panel says which
|
||||
(`on: Person #3`). While the panel is beside the screen, a record or grid you open becomes the context.
|
||||
|
||||
| Key | Does |
|
||||
|---|---|
|
||||
| Enter | ask the question; on a link, open it (a record or a grid, beside the panel; a full-width panel closes so the screen shows) |
|
||||
| Enter | ask the question; on a link, open it (a record or a grid, beside the panel; a full-width panel hides so the screen shows) |
|
||||
| Tab, Shift+Tab | move over the answer's links; typing returns to the question line |
|
||||
| u | on a link: show its address |
|
||||
| ↑ ↓ | the questions you asked on this profile (on the links: previous and next link) |
|
||||
| PgUp PgDn, wheel | scroll the conversation |
|
||||
| Ctrl+D | send without (or again with) the page context |
|
||||
| Ctrl+L | a new conversation |
|
||||
| Ctrl+X | stop the answer being written |
|
||||
| Ctrl+G | back to the screen (the panel stays); again to return |
|
||||
| Esc | close the panel (the conversation is kept) |
|
||||
| F12, Ctrl+G | hide the panel (again: show it) |
|
||||
| Esc | hide the panel (with text on the question line too: nothing is asked, the draft stays); on a link, back to the question line |
|
||||
|
||||
Without the extension the menu entry is disabled and Ctrl+G says what is missing. When the assistant is
|
||||
off for you (no model provider on the server, no permission, the kill switch) the panel shows the server's
|
||||
reason and what to configure; F5 checks again. The command line has the same: `qqq assistant ask`,
|
||||
`qqq assistant config`.
|
||||
**F12 in your terminal.** The F-key bar shows `11` (blank, reserved) and `12Ask` from 120 columns;
|
||||
narrower, the `?` help and F9 › Work say "F12 assistant". The TUI reads F12 both as the legacy sequence
|
||||
(`ESC [24~`, also with modifiers, as tmux and iTerm2 send it by default) and as the kitty keyboard
|
||||
protocol's key (57375). macOS may take F12 for Mission Control or Dashboard, or send it only with `fn`
|
||||
(System Settings › Keyboard › Keyboard Shortcuts, or "Use F1, F2, etc. keys as standard function keys");
|
||||
Ctrl+G is the fallback everywhere.
|
||||
|
||||
Without the extension the menu entry is disabled, `12Ask` is dimmed and F12 says what is missing. When
|
||||
the assistant is off for you (no model provider on the server, no permission, the kill switch) the panel
|
||||
shows the server's reason and what to configure; F5 checks again. The command line has the same:
|
||||
`qqq assistant ask`, `qqq assistant config`.
|
||||
|
||||
## SQL Runner
|
||||
|
||||
@@ -457,7 +556,7 @@ the plain sample server; one that fails shows `…`, then its last value dimmed
|
||||
|
||||
| Tile | Needs | Shows |
|
||||
|------|-------|-------|
|
||||
| Online | collaboration | people in presence across a few tables per refresh (favourites and recents first), who and on what; the signed-in user's other sessions as "you elsewhere" |
|
||||
| Online | the session store, or collaboration | who is signed in: with the session store qbit, the sessions active in the last 10 minutes and their users ("3 sessions · 2 users (you ×2, bob)"; your own sessions count); otherwise you plus the people in presence across a few tables per refresh (favourites and recents first), who and on what. It never says "nobody around" while you are signed in. A server that stores no sessions (mock auth) or merges presence per user cannot tell two terminals of one user apart: both show "1 online · you". The saver keeps the presence lease of the screen under it, so others see you still viewing it, not gone |
|
||||
| Jobs | `durableJobs` | running, queued and recently failed jobs (all users with jobs_admin, else yours) |
|
||||
| Health | health | the summary status and up / degraded / down / unknown counts |
|
||||
| Database | db-performance | live sessions, connections of the maximum, cache hit ratio, blocking chains |
|
||||
@@ -477,7 +576,7 @@ the other tiles under it as one centred stack, rotating every 8 s). In a tall na
|
||||
the tile panel under Query. The block letters say the profile's
|
||||
short name when it has 6 characters or fewer (else the profile name), with the server host in plain text
|
||||
beneath; letters, digits, `-`, `_` and `.` are drawn in block letters, anything else falls back to plain
|
||||
text. Query blinks, wags and hops every ten seconds and faces the way he drifts; the art (braille, with a
|
||||
text. Query blinks, wags and hops every ten seconds and faces the way he drifts. His art is Query's own drawing from https://www.qrun.io/query/ (one colour, indigo `#818cf8`) rasterised into braille at three sizes (42×22, 25×13 and 17×9 cells; the medium is used where there is room, from 100 columns) by `scripts/gen-query-art.mjs`; the art (braille, with a
|
||||
line-art version for `--ascii`) is in `src/saver/query-art.ts`.
|
||||
|
||||
Burn-in guard (F9 › Settings › Screen saver burn-in guard, `:saver burnin on|off`, default on): nothing
|
||||
@@ -512,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.
|
||||
@@ -537,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
|
||||
@@ -563,16 +663,19 @@ before any program sees it. `qqq ui --check` (and the quick check at startup) fi
|
||||
(System Settings › Keyboard), or hold fn.
|
||||
- Whatever the terminal does, Ctrl+F / Ctrl+B page and the F9 menu and `:` line reach every action; Esc then
|
||||
a digit (or Ctrl+digit) stands for F1..F10.
|
||||
- **Shift+F-keys** (the Shift bank): see [Shift bank](#shift-bank-shiftf1f10) for what each terminal sends;
|
||||
`QQQ_FKEYS` overrides the legacy numbering.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
cd packages/tui && bun test # or npm run test:tui at the root: the whole TUI against a fake server in memory
|
||||
packages/tui/scripts/smoke.sh # tmux against a running server (profile "sample"); captures kept
|
||||
# --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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
// Generates the screen saver's Query art (src/saver/query-art.ts: LARGE, MEDIUM, SMALL and the dot
|
||||
// boxes of the eye and the tail) from Query's own drawing: the inline SVG on https://www.qrun.io/query/
|
||||
// (viewBox 0 0 331.41 348.44; the first path is the sitting dog, the second his eye). Needs `inkscape`
|
||||
// and ImageMagick (`magick`) on the PATH; run by hand when the drawing changes:
|
||||
// node scripts/gen-query-art.mjs query-hero.svg [outdir]
|
||||
// Each size is the drawing rasterised at 8× the dot grid, averaged down to it (a box filter, so a thin
|
||||
// stroke keeps its weight as coverage) and thresholded per size (thinner strokes need a lower cut).
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const [svgPath, outDir = '.'] = process.argv.slice(2)
|
||||
if (!svgPath) throw new Error('usage: gen-query-art.mjs query-hero.svg [outdir]')
|
||||
const svg = readFileSync(svgPath, 'utf8').replaceAll('currentColor', '#ffffff')
|
||||
const paths = [...svg.matchAll(/<path[^>]*>/g)].map((m) => m[0])
|
||||
const wrap = (body) => `<svg viewBox="0 0 331.41 348.44" xmlns="http://www.w3.org/2000/svg"><rect width="331.41" height="348.44" fill="#000"/>${body}</svg>`
|
||||
const work = mkdtempSync(join(tmpdir(), 'query-art-'))
|
||||
|
||||
/** Coverage 0…1 per dot of an SVG body, on a grid of w×h dots. */
|
||||
function raster(body, w, h) {
|
||||
const file = join(work, 'in.svg')
|
||||
writeFileSync(file, wrap(body))
|
||||
const png = join(work, 'out.png')
|
||||
execFileSync('inkscape', [file, '-o', png, '-w', String(w * 8), '-h', String(h * 8)], { stdio: 'ignore' })
|
||||
const raw = execFileSync('magick', [png, '-colorspace', 'Gray', '-filter', 'Box', '-resize', `${w}x${h}!`, '-depth', '8', 'gray:-'], { maxBuffer: 1 << 24 })
|
||||
return Array.from({ length: h }, (_, y) => Array.from({ length: w }, (_, x) => raw[y * w + x] / 255))
|
||||
}
|
||||
|
||||
const DOT = [[0x01, 0x02, 0x04, 0x40], [0x08, 0x10, 0x20, 0x80]]
|
||||
function braille(bits) {
|
||||
const rows = []
|
||||
for (let cy = 0; cy < bits.length / 4; cy++) {
|
||||
let line = ''
|
||||
for (let cx = 0; cx < bits[0].length / 2; cx++) {
|
||||
let code = 0
|
||||
for (let dx = 0; dx < 2; dx++) for (let dy = 0; dy < 4; dy++) if (bits[cy * 4 + dy]?.[cx * 2 + dx]) code |= DOT[dx][dy]
|
||||
line += code ? String.fromCodePoint(0x2800 + code) : ' '
|
||||
}
|
||||
rows.push(line)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
const SIZES = { large: { w: 42, h: 22, cut: 0.42 }, medium: { w: 25, h: 13, cut: 0.28 }, small: { w: 17, h: 9, cut: 0.2 } }
|
||||
const out = {}
|
||||
for (const [name, { w, h, cut }] of Object.entries(SIZES)) {
|
||||
const dw = w * 2
|
||||
const dh = h * 4
|
||||
const bits = raster(paths[0], dw, dh).map((r) => r.map((v) => v >= cut))
|
||||
// his eye: the second path alone, any coverage at all
|
||||
const eyeCov = raster(paths[1], dw, dh)
|
||||
const eyeDots = []
|
||||
eyeCov.forEach((r, y) => r.forEach((v, x) => { if (v >= 0.12) eyeDots.push([x, y]) }))
|
||||
const eye = eyeDots.length ? [Math.min(...eyeDots.map((d) => d[0])), Math.min(...eyeDots.map((d) => d[1])), Math.max(...eyeDots.map((d) => d[0])), Math.max(...eyeDots.map((d) => d[1]))] : [0, 0, 0, 0]
|
||||
// the eye is part of the dog path's raster too: keep the eye dots drawn
|
||||
for (const [x, y] of eyeDots) bits[y][x] = true
|
||||
// the tail: the low left corner of the drawing
|
||||
const tail = [0, Math.round(dh * 0.62), Math.round(dw * 0.2), dh - 1]
|
||||
out[name] = { rows: braille(bits), eye, tail, w, h }
|
||||
}
|
||||
const ts = Object.entries(out).map(([name, o]) => `/** ${name} Query, ${o.w}×${o.h} cells, facing right (generated by scripts/gen-query-art.mjs). */\nconst ${name.toUpperCase()} = [\n${o.rows.map((r) => ` ${JSON.stringify(r)},`).join('\n')}\n]\n`).join('\n')
|
||||
const parts = `const PARTS: Record<QuerySize, Parts> = {\n${Object.entries(out).map(([n, o]) => ` ${n}: { eye: [${o.eye}], tail: [${o.tail}] },`).join('\n')}\n}\n`
|
||||
writeFileSync(join(outDir, 'query-art.generated.txt'), `${ts}\n${parts}`)
|
||||
for (const [n, o] of Object.entries(out)) console.log(`--- ${n} ${o.w}x${o.h} eye ${o.eye} tail ${o.tail}\n${o.rows.join('\n')}`)
|
||||
@@ -74,9 +74,10 @@ expect 07-record 'record 2 of'
|
||||
keys Escape
|
||||
expect 08-back 'row 2 '
|
||||
right "$((hx + 1))" "$((hy + 4))"
|
||||
expect 09-actions 'row 3 ' 'Actions'
|
||||
expect 09-actions 'Actions'
|
||||
keys Escape
|
||||
cap 09b-before-drag
|
||||
# the grid's status line can sit under the dialog (the F-key bar takes two rows from 30 lines): checked after it closes
|
||||
expect 09b-before-drag 'row 3 '
|
||||
read -r x y < <(at 09b-before-drag 'First Name')
|
||||
read -r lx _ < <(at 09b-before-drag 'Last Name')
|
||||
drag "$((lx - 1))" "$y" "$((lx + 6))"
|
||||
@@ -89,6 +90,12 @@ expect 11b-footer ' · ping ' # the flash from the resize has faded
|
||||
read -r x y < <(at 11b-footer ' · ping ')
|
||||
click 2 "$y"
|
||||
expect 12-footer-servers 'Servers'
|
||||
# the Shift row of the F-key bar (two rows from 30 lines): a click on Shift+F2 opens the same dialog
|
||||
keys Escape
|
||||
cap 12c-bar
|
||||
read -r x y < <(at 12c-bar '2Servers')
|
||||
click "$((x + 1))" "$y"
|
||||
expect 12d-shift-servers 'Servers (Ctrl+O)'
|
||||
keys Escape F9 T:mouse
|
||||
expect 12b-menu 'Mouse: on'
|
||||
read -r x y < <(at 12b-menu 'Mouse: on')
|
||||
|
||||
@@ -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'
|
||||
|
||||
+25
-32
@@ -5,11 +5,14 @@
|
||||
|
||||
import { useKeyboard, usePaste, useTerminalDimensions } from '@opentui/react'
|
||||
import { Component, useEffect, useReducer, type ReactNode } from 'react'
|
||||
import type { AppTreeNode } from './client'
|
||||
import { FKeyZone, PaneZone, StatusZone, useMouseDispatcher } from './mouse/app-zones'
|
||||
import { MouseCapture, MouseLayer, MouseRoot } from './mouse/zone'
|
||||
import { presenceText, usePresence } from './presence'
|
||||
import { ShiftRow, barRows } from './keys/shift-row'
|
||||
import type { AppStore } from './store'
|
||||
import { breadcrumbs } from './breadcrumbs'
|
||||
|
||||
export { appPath, breadcrumbs } from './breadcrumbs'
|
||||
import { glyphs, theme } from './theme'
|
||||
import { pad, trunc } from './util/text'
|
||||
|
||||
@@ -39,32 +42,6 @@ export class ErrorBoundary extends Component<{ children: ReactNode; resetKey: st
|
||||
}
|
||||
}
|
||||
|
||||
/** The apps above a table, process or app in the app tree (labels, outermost first). */
|
||||
export function appPath(nodes: AppTreeNode[], name: string, type: string, path: string[] = []): string[] | undefined {
|
||||
for (const n of nodes) {
|
||||
if (n.name === name && n.type === type) return path
|
||||
if (n.children) {
|
||||
const found = appPath(n.children, name, type, [...path, n.label])
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** The title-bar path of the top screen: its apps, the table, then the record or form. */
|
||||
export function breadcrumbs(store: AppStore): string[] {
|
||||
const top = store.screen as (import('./types').Screen & { tableName?: string; processName?: string; appName?: string }) | undefined
|
||||
if (!top) return []
|
||||
const tree = (store.meta?.appTree ?? []) as AppTreeNode[]
|
||||
const tableLabel = top.tableName ? store.meta?.tables?.[top.tableName]?.label ?? top.tableName : undefined
|
||||
if (top.kind === 'table') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), top.title()]
|
||||
if (top.kind === 'record') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), tableLabel!, top.title()]
|
||||
if (top.kind === 'form') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), top.title()]
|
||||
if (top.kind === 'process') return [...(appPath(tree, top.processName!, 'PROCESS') ?? []), top.title()]
|
||||
if (top.kind === 'app') return [...(appPath(tree, top.appName!, 'APP') ?? []), top.title()]
|
||||
return [top.title()]
|
||||
}
|
||||
|
||||
function TitleBar({ store, width }: { store: AppStore; width: number }) {
|
||||
const t = theme()
|
||||
const presence = usePresence(store.screen?.presenceScope?.())
|
||||
@@ -112,7 +89,7 @@ function StatusLine({ store, width }: { store: AppStore; width: number }) {
|
||||
}
|
||||
|
||||
/** Short labels for narrow terminals (below ~100 columns), so nothing clips at 80. */
|
||||
const SHORT: Record<string, string> = { Help: 'Help', Search: 'Search', 'Go to': 'Go', View: 'View', Edit: 'Edit', Refresh: 'Refr', Filter: 'Filt', New: 'New', Delete: 'Del', Menu: 'Menu', Quit: 'Quit', NewChild: 'Child', 'Re-run': 'Again', Open: 'Open' }
|
||||
const SHORT: Record<string, string> = { Help: 'Help', Search: 'Search', 'Go to': 'Go', View: 'View', Edit: 'Edit', Refresh: 'Refr', Filter: 'Filt', New: 'New', Delete: 'Del', Menu: 'Menu', Quit: 'Quit', NewChild: 'Child', 'Re-run': 'Again', Open: 'Open', Ask: 'Ask' }
|
||||
|
||||
function FKeyBar({ store, width }: { store: AppStore; width: number }) {
|
||||
const t = theme()
|
||||
@@ -124,7 +101,7 @@ function FKeyBar({ store, width }: { store: AppStore; width: number }) {
|
||||
<FKeyZone store={store} width={width} keys={keys.map((k) => k.n)}>
|
||||
<text flexShrink={0} wrapMode="none">
|
||||
{keys.map((k) => {
|
||||
const w = k.n === 10 ? Math.max(0, width - used) : cell
|
||||
const w = k === keys[keys.length - 1] ? Math.max(0, width - used) : cell
|
||||
used += w
|
||||
const num = String(k.n).padStart(2)
|
||||
// a key that does nothing here still shows its global label, dimmed, so the bar teaches the keymap
|
||||
@@ -156,7 +133,9 @@ export function App({ store }: { store: AppStore }) {
|
||||
const t = theme()
|
||||
const w = Math.max(20, width)
|
||||
const h = Math.max(8, height)
|
||||
const bodyHeight = h - 3
|
||||
// the F-key bar: the primary row, and the Shift row under it with two rows (keys/shift-row.tsx)
|
||||
const rows = barRows(store, h)
|
||||
const bodyHeight = h - 2 - rows
|
||||
const assistant = store.assistantLayout()
|
||||
const full = assistant?.mode === 'full'
|
||||
const sidebarWidth = store.showSidebar && !full ? (w >= 160 ? 32 : 28) : 0
|
||||
@@ -164,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}>
|
||||
@@ -180,8 +172,9 @@ export function App({ store }: { store: AppStore }) {
|
||||
{assistantWidth > 0 && store.assistant && <ErrorBoundary resetKey={store.assistant.id} width={assistantWidth} label="Assistant">{store.assistant.render(assistantWidth, bodyHeight, store.focus === 'assistant' && !top)}</ErrorBoundary>}
|
||||
</box>
|
||||
<StatusLine store={store} width={w} />
|
||||
<FKeyBar store={store} width={w} />
|
||||
{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>)}
|
||||
{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} />}
|
||||
{overlays}
|
||||
{store.lock && <ErrorBoundary key="lock" resetKey="lock" width={w} label="Lock">{store.lock.render(w, h)}</ErrorBoundary>}
|
||||
<MouseCapture width={w} height={h} />
|
||||
</box>
|
||||
|
||||
@@ -4,13 +4,16 @@
|
||||
*/
|
||||
|
||||
/**
|
||||
* The assistant panel's conversation as lines for a width: each question, the answer (wrapped,
|
||||
* markdown markers dropped), its numbers and its links. Links are numbered across the whole
|
||||
* conversation so the panel can move a cursor over them; every line fits the width it was built for.
|
||||
* The assistant panel's conversation as lines for a width: each question, the answer's markdown
|
||||
* rendered (entities decoded, headings, wrapped paragraphs, lists, **bold**, `code`, pipe tables laid
|
||||
* out like the grid and clipped with `…`), its numbers, and the links. A link shows as `label ↗`,
|
||||
* never its URL; links are numbered across the conversation in reading order, once per target (the
|
||||
* text's links, then the tool results' links the text does not show), so the panel can move a cursor
|
||||
* over them. Every line fits the width it was built for.
|
||||
*/
|
||||
|
||||
import type { AssistantAnswer, AssistantPageContext } from '@qrun-io/qqq-core'
|
||||
import { cellWidth } from '../util/text'
|
||||
import { answerDocument, fitColumns, type AssistantAnswer, type AssistantPageContext, type MdBlock, type MdInline, type MdLink, type PrimaryKeyOf } from '@qrun-io/qqq-core'
|
||||
import { cellWidth, trunc } from '../util/text'
|
||||
|
||||
/** One question and its answer (streaming until `answer.status` leaves RUNNING). */
|
||||
export interface Exchange {
|
||||
@@ -26,13 +29,22 @@ export interface Exchange {
|
||||
startedAt: number
|
||||
}
|
||||
|
||||
export type LineRole = 'question' | 'context' | 'text' | 'number' | 'link' | 'activity' | 'error' | 'meta' | 'blank'
|
||||
export type LineRole = 'question' | 'context' | 'text' | 'heading' | 'header' | 'rule' | 'number' | 'link' | 'activity' | 'error' | 'meta' | 'blank'
|
||||
|
||||
/** A run of a line with one look; `link` is the link number it opens. */
|
||||
export interface Seg {
|
||||
text: string
|
||||
style?: 'bold' | 'code' | 'link' | 'dim'
|
||||
link?: number
|
||||
}
|
||||
|
||||
export interface ChatLine {
|
||||
/** The line as plain text (what the segments add up to). */
|
||||
text: string
|
||||
role: LineRole
|
||||
/** Index into {@link chatLinks} for link lines. */
|
||||
/** The first link on the line (a click opens it). */
|
||||
link?: number
|
||||
segs?: Seg[]
|
||||
}
|
||||
|
||||
/** The answer's markdown as plain text (bold, code and heading markers dropped). */
|
||||
@@ -74,15 +86,140 @@ export function wrap(text: string, width: number): string[] {
|
||||
return out
|
||||
}
|
||||
|
||||
/** Every link of the conversation, in order (what the link cursor moves over). */
|
||||
export function chatLinks(exchanges: Exchange[]): Array<{ label: string; href: string }> {
|
||||
return exchanges.flatMap((e) => e.answer?.links ?? [])
|
||||
/** An answer's links in reading order with their offset into the conversation's numbering. */
|
||||
function answerLinks(answer: AssistantAnswer, primaryKeyOf?: PrimaryKeyOf): { blocks: MdBlock[]; text: MdLink[]; extra: MdLink[] } {
|
||||
const { doc, extraLinks } = answerDocument(answer, primaryKeyOf)
|
||||
return { blocks: doc.blocks, text: doc.links, extra: extraLinks }
|
||||
}
|
||||
|
||||
/** Every link of the conversation, in reading order, once per target per answer (what the link cursor moves over). */
|
||||
export function chatLinks(exchanges: Exchange[], primaryKeyOf?: PrimaryKeyOf): Array<{ label: string; href: string }> {
|
||||
return exchanges.flatMap((e) => {
|
||||
if (!e.answer) return []
|
||||
const { text, extra } = answerLinks(e.answer, primaryKeyOf)
|
||||
return [...text, ...extra]
|
||||
})
|
||||
}
|
||||
|
||||
function segsFrom(inlines: MdInline[], offset: number): Seg[] {
|
||||
return inlines.map((p) => p.style === 'link' ? { text: `${p.text} ↗`, style: 'link' as const, link: offset + p.link! } : { text: p.text, ...(p.style ? { style: p.style } : {}) })
|
||||
}
|
||||
|
||||
/** Wraps styled runs to a width, word by word; `first`/`rest` go before the first and later lines. */
|
||||
export function wrapSegs(segs: Seg[], width: number, role: LineRole, first = '', rest = first): ChatLine[] {
|
||||
const words: Seg[] = []
|
||||
for (const s of segs) {
|
||||
for (const part of s.text.split(/(\s+)/)) if (part) words.push({ ...s, text: part })
|
||||
}
|
||||
const lines: ChatLine[] = []
|
||||
let cur: Seg[] = [{ text: first }]
|
||||
let used = cellWidth(first)
|
||||
let content = false
|
||||
let pendingSpace: Seg | undefined
|
||||
const flush = () => {
|
||||
lines.push(line(cur, role))
|
||||
cur = [{ text: rest }]
|
||||
used = cellWidth(rest)
|
||||
content = false
|
||||
pendingSpace = undefined
|
||||
}
|
||||
for (const word of words) {
|
||||
if (/^\s+$/.test(word.text)) {
|
||||
if (content) pendingSpace = { ...word, text: ' ' }
|
||||
continue
|
||||
}
|
||||
let text = word.text
|
||||
let w = cellWidth(text)
|
||||
if (content && used + (pendingSpace ? 1 : 0) + w > width) flush()
|
||||
if (pendingSpace && content) {
|
||||
cur.push(pendingSpace)
|
||||
used += 1
|
||||
}
|
||||
pendingSpace = undefined
|
||||
while (used + w > width) {
|
||||
let cut = ''
|
||||
for (const ch of text) {
|
||||
if (used + cellWidth(cut + ch) > width) break
|
||||
cut += ch
|
||||
}
|
||||
if (!cut) break
|
||||
cur.push({ ...word, text: cut })
|
||||
flush()
|
||||
text = text.slice(cut.length)
|
||||
w = cellWidth(text)
|
||||
}
|
||||
if (text) {
|
||||
cur.push({ ...word, text })
|
||||
used += w
|
||||
content = true
|
||||
}
|
||||
}
|
||||
if (content) flush()
|
||||
return lines
|
||||
}
|
||||
|
||||
function line(segs: Seg[], role: LineRole): ChatLine {
|
||||
const merged: Seg[] = []
|
||||
for (const s of segs) {
|
||||
if (!s.text) continue
|
||||
const last = merged[merged.length - 1]
|
||||
if (last && last.style === s.style && last.link === s.link) last.text += s.text
|
||||
else merged.push({ ...s })
|
||||
}
|
||||
const link = merged.find((s) => s.link !== undefined)?.link
|
||||
return { text: merged.map((s) => s.text).join(''), role, segs: merged, ...(link !== undefined ? { link } : {}) }
|
||||
}
|
||||
|
||||
/** A pipe table laid out like the grid: columns fitted to the width (the rest left out), cells cut with `…`. */
|
||||
function tableLines(block: Extract<MdBlock, { kind: 'table' }>, width: number, offset: number): ChatLine[] {
|
||||
const cellSegs = (inl: MdInline[]) => segsFrom(inl, offset)
|
||||
const plain = (segs: Seg[]) => segs.map((s) => s.text).join('').replace(/\s+/g, ' ')
|
||||
const headers = block.headers.map((h) => plain(cellSegs(h)))
|
||||
const rows = block.rows.map((r) => block.headers.map((_, i) => cellSegs(r[i] ?? [])))
|
||||
const natural = headers.map((h, i) => Math.max(cellWidth(h), ...rows.map((r) => cellWidth(plain(r[i]!)))))
|
||||
const { widths, shown } = fitColumns(natural, width)
|
||||
const fit = (text: string, w: number, align: string) => {
|
||||
const t = trunc(text, w)
|
||||
const room = Math.max(0, w - cellWidth(t))
|
||||
return align === 'right' ? ' '.repeat(room) + t : t + ' '.repeat(room)
|
||||
}
|
||||
const out: ChatLine[] = []
|
||||
out.push(line(headers.slice(0, shown).flatMap((h, i) => [{ text: fit(h, widths[i]!, block.aligns[i] ?? 'left'), style: 'bold' as const }, { text: i < shown - 1 ? ' ' : '' }]), 'header'))
|
||||
out.push(line([{ text: widths.map((w) => '─'.repeat(w)).join(' '), style: 'dim' }], 'rule'))
|
||||
for (const r of rows) {
|
||||
const segs: Seg[] = []
|
||||
r.slice(0, shown).forEach((cell, i) => {
|
||||
const text = fit(plain(cell), widths[i]!, block.aligns[i] ?? 'left')
|
||||
const linked = cell.find((s) => s.link !== undefined)
|
||||
segs.push(linked ? { text, style: 'link', link: linked.link } : { text })
|
||||
if (i < shown - 1) segs.push({ text: ' ' })
|
||||
})
|
||||
out.push(line(segs, 'text'))
|
||||
}
|
||||
if (shown < headers.length) out.push(line([{ text: trunc(`(${headers.length - shown} more column${headers.length - shown === 1 ? '' : 's'}: ${headers.slice(shown).join(', ')})`, width), style: 'dim' }], 'meta'))
|
||||
return out.map((l) => (cellWidth(l.text) > width ? { ...l, text: trunc(l.text, width), segs: [{ text: trunc(l.text, width), style: l.segs?.[0]?.style }] } : l))
|
||||
}
|
||||
|
||||
/** An answer's markdown as lines (links numbered from `offset`). */
|
||||
export function answerLines(blocks: MdBlock[], width: number, offset: number): ChatLine[] {
|
||||
const out: ChatLine[] = []
|
||||
for (const block of blocks) {
|
||||
if (out.length) out.push({ text: '', role: 'blank' })
|
||||
if (block.kind === 'heading') out.push(...wrapSegs(segsFrom(block.inlines, offset).map((s) => ({ ...s, style: s.style ?? 'bold' })), width, 'heading'))
|
||||
else if (block.kind === 'paragraph') out.push(...wrapSegs(segsFrom(block.inlines, offset), width, 'text'))
|
||||
else if (block.kind === 'list') block.items.forEach((item, n) => {
|
||||
const bullet = block.ordered ? `${n + 1}. ` : '• '
|
||||
out.push(...wrapSegs(segsFrom(item, offset), width, 'text', bullet, ' '.repeat(cellWidth(bullet))))
|
||||
})
|
||||
else out.push(...tableLines(block, width, offset))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The conversation as lines for a width (the panel's inner width). */
|
||||
export function chatLines(exchanges: Exchange[], width: number, now = Date.now()): ChatLine[] {
|
||||
export function chatLines(exchanges: Exchange[], width: number, now = Date.now(), primaryKeyOf?: PrimaryKeyOf): ChatLine[] {
|
||||
const lines: ChatLine[] = []
|
||||
let linkIndex = 0
|
||||
let offset = 0
|
||||
const add = (text: string, role: LineRole, prefix = '', link?: number) => {
|
||||
const rest = ' '.repeat(cellWidth(prefix))
|
||||
wrap(text, width - cellWidth(prefix)).forEach((l, i) => lines.push({ text: (i === 0 ? prefix : rest) + l, role, ...(link !== undefined ? { link } : {}) }))
|
||||
@@ -93,11 +230,16 @@ export function chatLines(exchanges: Exchange[], width: number, now = Date.now()
|
||||
if (e.contextLabel) add(`on ${e.contextLabel}`, 'context', ' ')
|
||||
const answer = e.answer
|
||||
const running = !e.error && (!answer || answer.status === 'RUNNING')
|
||||
const text = plainText(answer?.text ?? '').trim()
|
||||
if (text) add(text, 'text')
|
||||
if (running && !text) add(`${e.activity ?? 'thinking'}… ${Math.max(0, Math.round((now - e.startedAt) / 1000))} s`, 'activity')
|
||||
const parsed = answer ? answerLinks(answer, primaryKeyOf) : { blocks: [], text: [], extra: [] }
|
||||
if (parsed.blocks.length) lines.push(...answerLines(parsed.blocks, width, offset))
|
||||
if (running && !parsed.blocks.length) add(`${e.activity ?? 'thinking'}… ${Math.max(0, Math.round((now - e.startedAt) / 1000))} s`, 'activity')
|
||||
for (const num of answer?.numbers ?? []) add(`${num.label}: ${num.value}`, 'number', '# ')
|
||||
for (const link of answer?.links ?? []) add(link.label, 'link', '→ ', linkIndex++)
|
||||
// the tool results' links the text does not show, once per target
|
||||
parsed.extra.forEach((link, i) => {
|
||||
const index = offset + parsed.text.length + i
|
||||
wrapSegs([{ text: `${link.label} ↗`, style: 'link', link: index }], width, 'link', '→ ', ' ').forEach((l) => lines.push(l))
|
||||
})
|
||||
offset += parsed.text.length + parsed.extra.length
|
||||
for (const notice of answer?.notices ?? []) add(notice.text, notice.level === 'error' ? 'error' : 'meta', `${notice.level}: `)
|
||||
if (e.error) add(e.error, 'error')
|
||||
else if (answer?.error) add(`${answer.error.message} (${answer.error.code})`, 'error')
|
||||
|
||||
@@ -24,8 +24,8 @@ import { wheelAsKeys } from '../mouse/keys'
|
||||
import type { AppStore } from '../store'
|
||||
import { borderProps, theme } from '../theme'
|
||||
import type { FKeyLabels } from '../types'
|
||||
import { errorText, hintLine, pad, trunc } from '../util/text'
|
||||
import { chatLines, chatLinks, contextLabel, wrap, type ChatLine, type Exchange } from './model'
|
||||
import { cellWidth, errorText, hintLine, pad, trunc } from '../util/text'
|
||||
import { chatLines, chatLinks, contextLabel, wrap, type ChatLine, type Exchange, type Seg } from './model'
|
||||
|
||||
/** Below this terminal width the panel takes the whole body instead of a right-hand pane. */
|
||||
export const SIDE_MIN_WIDTH = 110
|
||||
@@ -92,9 +92,26 @@ export class AssistantPanel {
|
||||
void this.loadConfig().then(() => this.ask(question))
|
||||
}
|
||||
|
||||
/** The page under the panel as context: a record, or a grid with its filter. */
|
||||
/** The context questions carry: the screen the panel was shown over (kept while hidden). */
|
||||
private pinned?: AssistantPageContext
|
||||
|
||||
/**
|
||||
* Takes the screen under the panel as the context: when the panel is shown, and while it is shown
|
||||
* beside the screen (a link opened from it becomes the context). A screen without one (Home)
|
||||
* keeps the context the panel had.
|
||||
*/
|
||||
capturePageContext(): void {
|
||||
const context = this.screenContext()
|
||||
if (context) this.pinned = context
|
||||
}
|
||||
|
||||
/** The context the next question carries (none after Ctrl+D). */
|
||||
pageContext(): AssistantPageContext | undefined {
|
||||
if (this.dropContext) return undefined
|
||||
return this.dropContext ? undefined : this.pinned
|
||||
}
|
||||
|
||||
/** The screen's context: a record, or a grid with its filter. */
|
||||
screenContext(): AssistantPageContext | undefined {
|
||||
const screen = this.store.screen as unknown as { kind?: string; tableName?: string; primaryKey?: string; filter?: Record<string, unknown> } | undefined
|
||||
if (screen?.kind === 'record' && screen.tableName && screen.primaryKey !== undefined) return assistantContextFor({ tableName: screen.tableName, recordId: screen.primaryKey })
|
||||
if (screen?.kind === 'table' && screen.tableName) return assistantContextFor({ tableName: screen.tableName, filter: screen.filter })
|
||||
@@ -170,9 +187,23 @@ export class AssistantPanel {
|
||||
this.store.bump()
|
||||
}
|
||||
|
||||
/** A table's primary key field from the metadata (links that filter on it are that record). */
|
||||
readonly primaryKeyOf = (table: string): string | undefined => (this.store.meta?.tables?.[table] as { primaryKeyField?: string } | undefined)?.primaryKeyField
|
||||
|
||||
/** Every link of the conversation, in reading order, once per target. */
|
||||
links(): Array<{ label: string; href: string }> {
|
||||
return chatLinks(this.exchanges, this.primaryKeyOf)
|
||||
}
|
||||
|
||||
/** `u` on a link: its address, shown (never opened). */
|
||||
showUrl(index = this.linkCursor): void {
|
||||
const link = this.links()[index]
|
||||
if (link) this.store.message(`${link.label}\n${link.href}`, 'info', 'Link address')
|
||||
}
|
||||
|
||||
/** Opens the link under the cursor in the screen beside (or, on a narrow terminal, under) the panel. */
|
||||
openLink(index = this.linkCursor): void {
|
||||
const link = chatLinks(this.exchanges)[index]
|
||||
const link = this.links()[index]
|
||||
if (!link) return
|
||||
this.linkCursor = -1
|
||||
this.store.focus = 'content'
|
||||
@@ -198,13 +229,14 @@ export class AssistantPanel {
|
||||
['Ctrl+D', 'send without / with the page context'],
|
||||
['Ctrl+L', 'new conversation'],
|
||||
['Ctrl+X', 'stop the answer'],
|
||||
['Ctrl+G', 'back to the screen (the panel stays)'],
|
||||
['Esc', 'close the panel (the conversation is kept)'],
|
||||
['u', 'on a link: show its address'],
|
||||
['F12 Ctrl+G', 'hide the panel (the conversation, context and draft are kept); again shows it'],
|
||||
['Esc', 'hide the panel (on a link: back to the question line)'],
|
||||
]
|
||||
}
|
||||
|
||||
onKey(key: Key): boolean {
|
||||
const links = chatLinks(this.exchanges).length
|
||||
const links = this.links().length
|
||||
if (key.ctrl && key.name === 'x') return this.stop(), true
|
||||
if (key.ctrl && key.name === 'l') return this.clear(), true
|
||||
if (key.ctrl && key.name === 'd') {
|
||||
@@ -214,6 +246,7 @@ export class AssistantPanel {
|
||||
}
|
||||
switch (key.name) {
|
||||
case 'escape':
|
||||
// hides the panel; the draft on the question line stays for next time
|
||||
if (this.linkCursor >= 0) this.linkCursor = -1
|
||||
else this.store.closeAssistant()
|
||||
return true
|
||||
@@ -247,6 +280,7 @@ export class AssistantPanel {
|
||||
break
|
||||
}
|
||||
if (key.name.startsWith('f') && /^f\d+$/.test(key.name)) return false
|
||||
if (this.linkCursor >= 0 && !key.ctrl && !key.meta && key.sequence === 'u') return this.showUrl(), true
|
||||
if (this.editor.handle(key)) {
|
||||
this.linkCursor = -1
|
||||
return true
|
||||
@@ -285,15 +319,16 @@ export class AssistantPanel {
|
||||
|
||||
render(width: number, height: number, focused: boolean) {
|
||||
const t = theme()
|
||||
if (this.store.assistantLayout()?.mode === 'side') this.capturePageContext()
|
||||
const inner = Math.max(10, width - 2)
|
||||
const context = this.pageContext()
|
||||
const label = this.label(context)
|
||||
const ctxLine = this.dropContext ? 'no page context (Ctrl+D adds it)' : label ? `on: ${label} (Ctrl+D drops)` : 'no page context'
|
||||
const bodyHeight = Math.max(1, height - 2 - 3)
|
||||
this.lastHeight = bodyHeight
|
||||
const all: ChatLine[] = this.exchanges.length ? chatLines(this.exchanges, inner) : this.intro().flatMap((x) => wrap(x.text, inner).map((text) => ({ ...x, text })))
|
||||
const all: ChatLine[] = this.exchanges.length ? chatLines(this.exchanges, inner, Date.now(), this.primaryKeyOf) : this.intro().flatMap((x) => wrap(x.text, inner).map((text) => ({ ...x, text })))
|
||||
if (this.pendingReveal && this.linkCursor >= 0) {
|
||||
const at = all.findIndex((l) => l.link === this.linkCursor)
|
||||
const at = all.findIndex((l) => l.segs?.some((x) => x.link === this.linkCursor) || l.link === this.linkCursor)
|
||||
if (at >= 0) {
|
||||
const bottom = all.length - this.scrollBack
|
||||
if (at < bottom - bodyHeight) this.scrollBack = all.length - at - bodyHeight
|
||||
@@ -306,27 +341,40 @@ export class AssistantPanel {
|
||||
const shown = all.slice(Math.max(0, end - bodyHeight), end)
|
||||
const color = (l: ChatLine): string => {
|
||||
switch (l.role) {
|
||||
case 'question': return t.acc
|
||||
case 'context': case 'meta': case 'activity': return t.dim
|
||||
case 'question': case 'heading': case 'header': return t.acc
|
||||
case 'context': case 'meta': case 'activity': case 'rule': return t.dim
|
||||
case 'number': return t.acc2
|
||||
case 'link': return t.ok
|
||||
case 'error': return t.err
|
||||
default: return t.fg
|
||||
}
|
||||
}
|
||||
const segColor = (l: ChatLine, seg: Seg): string => seg.style === 'link' ? t.ok : seg.style === 'code' ? t.acc2 : seg.style === 'dim' ? t.dim : color(l)
|
||||
const rows = shown.map((l, i) => {
|
||||
const selected = l.link !== undefined && l.link === this.linkCursor
|
||||
const text = <text flexShrink={0} wrapMode="none" fg={selected ? t.selFg : color(l)} bg={selected ? t.selBg : undefined}>{pad(l.text, inner)}</text>
|
||||
const used = l.segs ? l.segs.reduce((n, x) => n + cellWidth(x.text), 0) : 0
|
||||
const body = l.segs
|
||||
? (
|
||||
<text flexShrink={0} wrapMode="none">
|
||||
{l.segs.map((seg, k) => {
|
||||
const selected = seg.link !== undefined && seg.link === this.linkCursor
|
||||
const fg = selected ? t.selFg : segColor(l, seg)
|
||||
const bg = selected ? t.selBg : undefined
|
||||
return <span key={k} fg={fg} bg={bg}>{seg.style === 'bold' ? <strong>{seg.text}</strong> : seg.text}</span>
|
||||
})}
|
||||
<span>{' '.repeat(Math.max(0, inner - used))}</span>
|
||||
</text>
|
||||
)
|
||||
: <text flexShrink={0} wrapMode="none" fg={color(l)}>{pad(l.text, inner)}</text>
|
||||
return l.link === undefined
|
||||
? <box key={i} height={1} width={inner}>{text}</box>
|
||||
: <Zone key={i} onClick={() => { this.store.focus = 'assistant'; this.linkCursor = l.link!; this.openLink(l.link) }}><box height={1} width={inner}>{text}</box></Zone>
|
||||
? <box key={i} height={1} width={inner}>{body}</box>
|
||||
: <Zone key={i} onClick={() => { this.store.focus = 'assistant'; this.linkCursor = l.link!; this.openLink(l.link) }}><box height={1} width={inner}>{body}</box></Zone>
|
||||
})
|
||||
while (rows.length < bodyHeight) rows.push(<box key={`p${rows.length}`} height={1} width={inner}><text flexShrink={0} wrapMode="none">{pad('', inner)}</text></box>)
|
||||
const view = this.editor.view(Math.max(1, inner - 2))
|
||||
const ready = !this.config || this.config.enabled
|
||||
const hint = this.linkCursor >= 0
|
||||
? 'Enter opens · ↑↓ Tab links · Esc back to the question'
|
||||
: this.streaming ? 'answering… Ctrl+X stops · PgUp PgDn scroll · Esc closes' : 'Enter asks · Tab links · ↑↓ history · Ctrl+L new · Esc closes'
|
||||
? 'Enter opens · u address · ↑↓ Tab links · Esc back to the question'
|
||||
: this.streaming ? 'answering… Ctrl+X stops · PgUp PgDn scroll · F12/Esc hides' : 'Enter asks · Tab links · ↑↓ history · Ctrl+L new · F12/Esc hides'
|
||||
const title = `Assistant${this.config?.model && ready ? ` · ${this.config.model}` : ''}${this.scrollBack ? ` · ↑${this.scrollBack}` : ''}`
|
||||
return (
|
||||
<Zone
|
||||
|
||||
@@ -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)')
|
||||
}
|
||||
+44
-11
@@ -5,10 +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'
|
||||
@@ -21,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[]
|
||||
}
|
||||
@@ -40,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)
|
||||
@@ -54,25 +63,29 @@ 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(', ')}`
|
||||
}
|
||||
const theme = (args.theme && THEMES[args.theme] ? args.theme : undefined) ?? readTheme(target?.name) ?? DEFAULT_THEME
|
||||
const store = new AppStore({ settings: { theme, ascii: args.ascii, linear: args.linear, mouse: args.mouse ?? readMouse(), singleKeys: true, copyCli: readConfig().ui?.copyCliCommands === true }, quit: options.quit, build: options.build, target, timers: options.timers })
|
||||
const store = new AppStore({ settings: { theme, ascii: args.ascii, linear: args.linear, mouse: args.mouse ?? readMouse(), singleKeys: true, copyCli: readConfig().ui?.copyCliCommands === true, fkeyBar: readFKeyBar() }, quit: options.quit, build: options.build, target, timers: options.timers })
|
||||
let secrets: SecretStore | undefined
|
||||
const secretsFn = options.secrets ?? (() => (secrets ??= defaultSecretStore()))
|
||||
setSecretStoreFactory(secretsFn)
|
||||
@@ -92,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
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/** The title-bar path of a screen (apps, table, record), shared by the title bar and printed headers. */
|
||||
|
||||
import type { AppTreeNode } from './client'
|
||||
import type { AppStore } from './store'
|
||||
|
||||
/** The apps above a table, process or app in the app tree (labels, outermost first). */
|
||||
export function appPath(nodes: AppTreeNode[], name: string, type: string, path: string[] = []): string[] | undefined {
|
||||
for (const n of nodes) {
|
||||
if (n.name === name && n.type === type) return path
|
||||
if (n.children) {
|
||||
const found = appPath(n.children, name, type, [...path, n.label])
|
||||
if (found) return found
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** The title-bar path of the top screen: its apps, the table, then the record or form. */
|
||||
export function breadcrumbs(store: AppStore): string[] {
|
||||
const top = store.screen as (import('./types').Screen & { tableName?: string; processName?: string; appName?: string }) | undefined
|
||||
if (!top) return []
|
||||
const tree = (store.meta?.appTree ?? []) as AppTreeNode[]
|
||||
const tableLabel = top.tableName ? store.meta?.tables?.[top.tableName]?.label ?? top.tableName : undefined
|
||||
if (top.kind === 'table') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), top.title()]
|
||||
if (top.kind === 'record') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), tableLabel!, top.title()]
|
||||
if (top.kind === 'form') return [...(appPath(tree, top.tableName!, 'TABLE') ?? []), top.title()]
|
||||
if (top.kind === 'process') return [...(appPath(tree, top.processName!, 'PROCESS') ?? []), top.title()]
|
||||
if (top.kind === 'app') return [...(appPath(tree, top.appName!, 'APP') ?? []), top.title()]
|
||||
return [top.title()]
|
||||
}
|
||||
@@ -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'
|
||||
@@ -39,6 +40,7 @@ import { withPrintEntry } from './overlays/actions'
|
||||
import { openHistory } from './history/screen'
|
||||
import { searchLines } from './history/reverse-search'
|
||||
import { changeEvent, commandEvent, history } from './history/record'
|
||||
import { FKEY_BAR_MODES, describeFKeyBar, runShiftKey, setFKeyBar, shiftBankHelp, type FKeyBarMode } from './keys/shift-bank'
|
||||
|
||||
const NOUNS: Record<string, string[]> = {
|
||||
table: ['list', 'describe', 'query', 'count', 'get', 'create', 'update', 'delete', 'export'],
|
||||
@@ -53,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']
|
||||
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> = {
|
||||
@@ -63,7 +65,7 @@ export const TUI_COMMAND_HELP: Record<string, string> = {
|
||||
shortcuts: 'single-key shortcuts on or off', lock: 'lock the screen', pin: 'set or remove the lock PIN', idle: 'screen saver after idle minutes', refresh: 'reload this screen',
|
||||
filter: 'filter the grid (grammar)', export: 'export the grid or result', jobs: 'your jobs', printjobs: 'print jobs', print: 'print what you see (or :print <printer>)', profile: 'switch profile', servers: 'saved servers (Ctrl+O)',
|
||||
history: 'everything you did (CLI and TUI)', server: 'connect to a server URL', signout: 'sign out', connection: 'connection details', open: 'open a table', record: 'open a record by key', app: 'open an app',
|
||||
sql: 'SQL Runner (statement to run, open <notebook id>)', ask: 'the Assistant panel (Ctrl+G); with a question, asks it about this screen', saver: 'the screen saver', doctor: 'check the terminal', views: 'saved views of this table', share: 'share this record', helpkeys: 'help-authoring mode',
|
||||
sql: 'SQL Runner (statement to run, open <notebook id>)', ask: 'show or hide the Assistant panel (F12, Ctrl+G); with a question, asks it about this screen', saver: 'the screen saver', doctor: 'check the terminal', views: 'saved views of this table', share: 'share this record', helpkeys: 'help-authoring mode', fkeys: 'F-key bar: one row, two rows (with the Shift bank) or auto',
|
||||
}
|
||||
|
||||
/** CLI commands the `:` line does not offer (they make no sense inside the TUI). */
|
||||
@@ -95,7 +97,10 @@ export class Commands implements CommandsLike {
|
||||
// keys only: the screen's own key rows and shortcuts from its actions (label and key), never the actions themselves
|
||||
const panelKeys = s.focus === 'assistant' && s.assistantOpen && s.assistant ? s.assistant.helpKeys() : []
|
||||
const screenKeys: KeyRow[] = [...panelKeys, ...(screen?.helpKeys?.() ?? []), ...(screen?.menu?.() ?? []).filter((e) => e.key && !e.disabled).map((e): KeyRow => [e.key!, e.label.replace(/…$/, '').replace(/\s*\([^)]*\d[^)]*\)$/, '')])]
|
||||
s.open(new HelpOverlay(s, helpKeyGroups(screen?.kind, s.singleKeys, screenKeys), topicHelp))
|
||||
const help = new HelpOverlay(s, helpKeyGroups(screen?.kind, s.singleKeys, screenKeys), topicHelp)
|
||||
// the Shift bank (Shift+F1…F10) after the screen's help
|
||||
help.after = [{ title: 'Shift bank (Shift+F1…F10; the second row of the F-key bar)', keys: shiftBankHelp(s) }]
|
||||
s.open(help)
|
||||
// help for this screen: the kernel's help entries for the screen's roles; in help-authoring mode
|
||||
// (:helpkeys) every slot shows its key, with or without content, so authors see where help goes
|
||||
const authoring = s.helpKeys
|
||||
@@ -230,6 +235,10 @@ export class Commands implements CommandsLike {
|
||||
}
|
||||
|
||||
/** The Servers dialog: saved profiles, probed in the background; Enter switches. */
|
||||
shiftKey(n: number): void {
|
||||
runShiftKey(this.store, n)
|
||||
}
|
||||
|
||||
servers(): void {
|
||||
const s = this.store
|
||||
if (s.overlays.some((o) => o instanceof ServersOverlay)) return
|
||||
@@ -349,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')
|
||||
@@ -418,6 +428,12 @@ export class Commands implements CommandsLike {
|
||||
case 'doctor': case 'check': return this.doctor()
|
||||
case 'profile': return rest[0] ? this.switchProfile(rest[0]) : this.chooseProfile()
|
||||
case 'servers': return this.servers()
|
||||
case 'fkeys': {
|
||||
const mode = rest[0] as FKeyBarMode | undefined
|
||||
if (!mode) return s.flash(`F-key bar: ${describeFKeyBar(s.settings.fkeyBar ?? 'auto')} · :fkeys one|two|auto`)
|
||||
if (!FKEY_BAR_MODES.includes(mode)) return fail('fkeys one|two|auto')
|
||||
return setFKeyBar(s, mode)
|
||||
}
|
||||
case 'server': return rest[0] ? this.switchServer(rest[0]) : fail('server <url>')
|
||||
case 'signout': return this.signOut()
|
||||
case 'connection': return this.connection()
|
||||
@@ -426,7 +442,10 @@ export class Commands implements CommandsLike {
|
||||
case 'open': return rest[0] ? openTable(s, rest[0]) : fail('open <table>')
|
||||
case 'app': return rest[0] ? openApp(s, rest[0]) : fail('app <name>')
|
||||
case 'sql': return sqlCommand(s, line.replace(/^sql\b/, ''))
|
||||
case 'ask': return s.openAssistant(line.replace(/^ask\b/, ''))
|
||||
case 'ask': {
|
||||
const question = line.replace(/^ask\b/, '').trim()
|
||||
return question ? s.openAssistant(question) : s.toggleAssistant()
|
||||
}
|
||||
case 'report':
|
||||
if (rest[0] === 'run' && rest[1] && !rest.includes('--format') && !rest.includes('--out')) return openReport(s, rest[1])
|
||||
break
|
||||
@@ -728,7 +747,7 @@ export class Commands implements CommandsLike {
|
||||
out.push({ section: 'Work', label: 'My work', disabled: s.extensions.has('collaboration') ? undefined : 'needs the collaboration extension' })
|
||||
out.push({ section: 'Work', label: 'History', command: 'history', detail: 'everything you did, CLI and TUI', run: () => openHistory(s) })
|
||||
out.push(...sqlMenuEntries(s))
|
||||
out.push({ id: 'work:assistant', section: 'Work', label: 'Assistant', key: 'Ctrl+G', command: 'ask <question>', detail: 'ask about your data, with this screen as context', disabled: s.extensions.has('assistant') ? undefined : 'needs the assistant extension (qbit-assistant)', run: () => s.openAssistant() })
|
||||
out.push({ id: 'work:assistant', section: 'Work', label: 'Assistant', key: 'F12', command: 'ask <question>', detail: 'F12 assistant (Ctrl+G too): ask about your data, with this screen as context', disabled: s.extensions.has('assistant') ? undefined : 'needs the assistant extension (qbit-assistant)', run: () => s.openAssistant() })
|
||||
for (const e of s.extensions.all()) out.push({ section: 'Work', label: `Extension: ${e.name} ${e.version}`, detail: e.description, command: `api --list`, run: () => s.message(`${e.name} ${e.version}\n${e.description ?? ''}\nPath: ${e.pathPrefix ?? ''}\nIts panels are not drawn by this TUI yet; its operations are reachable with :api.`, 'info', 'Extension') })
|
||||
// Connection
|
||||
out.push({ section: 'Connection', label: `Server ${s.status.server}`, detail: `UI API ${s.status.apiVersion}`, command: 'connection', run: () => this.connection() })
|
||||
@@ -746,6 +765,9 @@ export class Commands implements CommandsLike {
|
||||
out.push({ section: 'Settings', label: `Single-key shortcuts: ${s.settings.singleKeys ? 'on' : 'off'}`, command: `shortcuts ${s.settings.singleKeys ? 'off' : 'on'}`, run: () => this.setShortcuts(!s.settings.singleKeys) })
|
||||
out.push({ section: 'Settings', label: `Linear layout: ${s.settings.linear ? 'on' : 'off'}`, command: `linear ${s.settings.linear ? 'off' : 'on'}`, run: () => { s.settings.linear = !s.settings.linear; s.bump() } })
|
||||
out.push({ 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
|
||||
|
||||
@@ -26,7 +26,7 @@ const ANYWHERE: KeyRow[] = [
|
||||
['F9', 'menu: areas, then their actions'],
|
||||
['Ctrl+P PrtSc', 'print what you see: a record sheet, a table list (all rows or this page), or the screen; Ctrl+P always works, Print Screen only where the terminal passes it on'],
|
||||
['Ctrl+O', 'servers: switch, add, edit'],
|
||||
['Ctrl+G', 'Assistant panel: ask about your data (this screen is the context); :ask <question>'],
|
||||
['F12 Ctrl+G', 'F12 assistant: show or hide the Assistant panel (this screen is the context); :ask <question>'],
|
||||
[':', 'command line; Tab completes, ↑↓ history, Ctrl+R searches it; :history lists all you did'],
|
||||
['F10', 'quit, reset or cancel'],
|
||||
['Ctrl+C', 'quit now'],
|
||||
|
||||
@@ -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 }
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Named actions that keys bind to: the Shift bank (shift-bank.ts) binds by name, and so can any other
|
||||
* key (F12 and Ctrl+G bind `assistant.toggle`). Each action says whether it applies on the current
|
||||
* screen; one that does not says why, and the bar dims its key.
|
||||
*/
|
||||
|
||||
import { openForm, permits } from '../nav'
|
||||
import { openHistory } from '../history/screen'
|
||||
import { openSqlRunner, sqlWorkbenchInstalled } from '../sql/entry'
|
||||
import type { AppStore } from '../store'
|
||||
import type { MenuEntry } from '../types'
|
||||
|
||||
export interface NamedAction {
|
||||
/** Why it does not apply here; undefined when it does. */
|
||||
unavailable(store: AppStore): string | undefined
|
||||
run(store: AppStore): void
|
||||
}
|
||||
|
||||
/** The parts of the table grid the actions use (screens/table.tsx). */
|
||||
interface GridLike {
|
||||
kind: string
|
||||
tableName: string
|
||||
meta?: unknown
|
||||
currentRecord?: unknown
|
||||
keyOf?(record: unknown): string | undefined
|
||||
bulkActions?(): void
|
||||
bulkEntries?(): MenuEntry[]
|
||||
tableProcesses?(): unknown[]
|
||||
savedViews?(): void
|
||||
exportDialog?(): void
|
||||
variant?: unknown
|
||||
}
|
||||
|
||||
/** The parts of the record view the actions use (screens/record.tsx). */
|
||||
interface RecordLike {
|
||||
kind: string
|
||||
tableName: string
|
||||
primaryKey?: string
|
||||
meta?: unknown
|
||||
tableVariant?: string
|
||||
}
|
||||
|
||||
function grid(store: AppStore): GridLike | undefined {
|
||||
const s = store.screen as unknown as GridLike | undefined
|
||||
return s?.kind === 'table' ? s : undefined
|
||||
}
|
||||
|
||||
function record(store: AppStore): RecordLike | undefined {
|
||||
const s = store.screen as unknown as RecordLike | undefined
|
||||
return s?.kind === 'record' ? s : undefined
|
||||
}
|
||||
|
||||
/** The record to copy: the record on screen, or the grid row under the cursor. */
|
||||
function copySource(store: AppStore): { table: string; key: string; meta: unknown; tableVariant?: string } | undefined {
|
||||
const r = record(store)
|
||||
if (r?.primaryKey !== undefined) return { table: r.tableName, key: r.primaryKey, meta: r.meta, tableVariant: r.tableVariant }
|
||||
const g = grid(store)
|
||||
const key = g?.keyOf?.(g.currentRecord)
|
||||
return g && key !== undefined ? { table: g.tableName, key, meta: g.meta } : undefined
|
||||
}
|
||||
|
||||
export const ACTIONS = {
|
||||
'help.keys': {
|
||||
unavailable: () => undefined,
|
||||
run: (s) => s.commands().help(),
|
||||
},
|
||||
servers: {
|
||||
unavailable: () => undefined,
|
||||
run: (s) => s.commands().servers(),
|
||||
},
|
||||
'sql.open': {
|
||||
unavailable: (s) => sqlWorkbenchInstalled(s) ? undefined : 'needs the sql-workbench extension',
|
||||
run: (s) => openSqlRunner(s),
|
||||
},
|
||||
'grid.bulk': {
|
||||
unavailable: (s) => {
|
||||
const g = grid(s)
|
||||
if (!g) return 'bulk actions work on a table grid'
|
||||
return (g.bulkEntries?.().length ?? 0) + (g.tableProcesses?.().length ?? 0) ? undefined : 'this table has no bulk actions or processes'
|
||||
},
|
||||
run: (s) => grid(s)?.bulkActions?.(),
|
||||
},
|
||||
'meta.refresh': {
|
||||
unavailable: (s) => s.client ? undefined : 'not connected',
|
||||
run: (s) => s.run(s.client!.metaData(true), (m) => {
|
||||
s.setMeta(m)
|
||||
s.client!.clearCaches()
|
||||
s.flash('metadata refreshed')
|
||||
}),
|
||||
},
|
||||
'grid.views': {
|
||||
unavailable: (s) => grid(s) ? undefined : 'saved views work on a table grid',
|
||||
run: (s) => grid(s)?.savedViews?.(),
|
||||
},
|
||||
'record.copy': {
|
||||
unavailable: (s) => {
|
||||
const src = copySource(s)
|
||||
if (!src) return 'copy works on a record or a grid row'
|
||||
return permits(src.meta as never, 'insert') ? undefined : 'copying needs permission to create records'
|
||||
},
|
||||
run: (s) => {
|
||||
const src = copySource(s)
|
||||
if (src) openForm(s, src.table, { mode: 'copy', primaryKey: src.key, ...(src.tableVariant ? { tableVariant: src.tableVariant } : {}) })
|
||||
},
|
||||
},
|
||||
export: {
|
||||
unavailable: (s) => grid(s) ? undefined : 'export works on a table grid',
|
||||
run: (s) => grid(s)?.exportDialog?.(),
|
||||
},
|
||||
history: {
|
||||
unavailable: () => undefined,
|
||||
run: (s) => openHistory(s),
|
||||
},
|
||||
'assistant.toggle': {
|
||||
unavailable: (s) => s.extensions.has('assistant') ? undefined : 'needs the assistant extension',
|
||||
run: (s) => s.toggleAssistant(),
|
||||
},
|
||||
} satisfies Record<string, NamedAction>
|
||||
|
||||
export type ActionName = keyof typeof ACTIONS
|
||||
|
||||
/** Runs a named action, or says why it does not apply here. */
|
||||
export function runAction(store: AppStore, name: ActionName, label: string = name): void {
|
||||
const action: NamedAction = ACTIONS[name]
|
||||
const why = action.unavailable(store)
|
||||
if (why) return store.flash(`${label}: ${why}`)
|
||||
action.run(store)
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The one decoder for function keys with modifiers (Shift+F1…F12, Ctrl+F-keys, Alt), across the
|
||||
* forms terminals send:
|
||||
*
|
||||
* - xterm modifier forms: `CSI 1;m P|Q|R|S` (F1–F4), `SS3 m P…S`, `CSI n;m ~` (F5–F12; also
|
||||
* `CSI 11..14;m ~` and kitty's `CSI 13;m ~` for F3), m = 1 + shift + 2·alt + 4·ctrl
|
||||
* (tmux, iTerm2, Windows Terminal, xterm, VTE, Konsole, Alacritty);
|
||||
* - the kitty keyboard protocol: the same forms with `:event` sub-parameters, and `CSI 57364..57375;m u`;
|
||||
* - legacy numbered forms with no modifier parameter, which mean different keys per terminal:
|
||||
* `CSI 25~ … CSI 34~` is Shift+F1…F8 with the VT220 numbering (F13 = Shift+F1), Shift+F3…F10 on
|
||||
* rxvt and the Linux console (F11 = Shift+F1), Shift+F5…F12 on macOS Terminal.app;
|
||||
* - rxvt's suffixes: `CSI n $` Shift, `CSI n ^` Ctrl, `CSI n @` Ctrl+Shift;
|
||||
* - the Linux console's `CSI [ A…E` (F1–F5).
|
||||
*
|
||||
* OpenTUI 0.8 reads the xterm and kitty forms itself, except Shift+F3 (`CSI 1;2R`) and the other
|
||||
* modified F3s: they look like a cursor position report, so OpenTUI takes them as one and drops them.
|
||||
* It reads none of the legacy or rxvt forms. `rewriteFKeyInput` turns exactly those into the
|
||||
* canonical `CSI n;m ~` form before OpenTUI sees the bytes (stdin-rewrite.ts), so every terminal
|
||||
* ends up as one OpenTUI key event (name `f3`, shift true), which `bankKey` names `shift+f3`.
|
||||
*/
|
||||
|
||||
import type { Key } from '../keys'
|
||||
|
||||
/** A function key with its modifiers. */
|
||||
export interface FKeyChord {
|
||||
/** 1..12 */
|
||||
n: number
|
||||
shift: boolean
|
||||
ctrl: boolean
|
||||
alt: boolean
|
||||
/** A kitty key-release event (nothing to do). */
|
||||
release?: boolean
|
||||
}
|
||||
|
||||
/** Which sequence family a chord was read from. */
|
||||
export type FKeyFamily = 'ss3' | 'csi-letter' | 'csi-f3-cpr' | 'csi-tilde' | 'kitty-u' | 'legacy-number' | 'rxvt-suffix' | 'linux-console'
|
||||
|
||||
/**
|
||||
* How a terminal numbers its legacy shifted F-keys (`CSI 25~` and up): `vt220` (F13 = Shift+F1),
|
||||
* `rxvt` (rxvt, urxvt, the Linux console: F11 = Shift+F1), `apple` (macOS Terminal.app: Shift+F5 is
|
||||
* `CSI 25~`), `modern` (the terminal only sends the xterm forms; legacy numbers mean nothing).
|
||||
*/
|
||||
export type LegacyProfile = 'vt220' | 'rxvt' | 'apple' | 'modern'
|
||||
|
||||
export const LEGACY_PROFILES: LegacyProfile[] = ['vt220', 'rxvt', 'apple', 'modern']
|
||||
|
||||
/** The `CSI n ~` number of F1..F12. */
|
||||
const TILDE_CODE = [0, 11, 12, 13, 14, 15, 17, 18, 19, 20, 21, 23, 24]
|
||||
const TILDE_TO_N: Record<number, number> = Object.fromEntries(TILDE_CODE.map((code, n) => [code, n]).filter(([, n]) => n > 0))
|
||||
const LETTER_TO_N: Record<string, number> = { P: 1, Q: 2, R: 3, S: 4 }
|
||||
const LINUX_TO_N: Record<string, number> = { A: 1, B: 2, C: 3, D: 4, E: 5 }
|
||||
/** kitty's private-use code points for F1..F12 (F13 and up are real keys of their own). */
|
||||
const KITTY_F1 = 57364
|
||||
|
||||
/** Legacy numbers (no modifier) that stand for Shift+F-keys, per profile. */
|
||||
const LEGACY_SHIFT: Record<LegacyProfile, Record<number, number>> = {
|
||||
vt220: { 25: 1, 26: 2, 28: 3, 29: 4, 31: 5, 32: 6, 33: 7, 34: 8 },
|
||||
rxvt: { 25: 3, 26: 4, 28: 5, 29: 6, 31: 7, 32: 8, 33: 9, 34: 10 },
|
||||
apple: { 25: 5, 26: 6, 28: 7, 29: 8, 31: 9, 32: 10, 33: 11, 34: 12 },
|
||||
modern: {},
|
||||
}
|
||||
|
||||
/** The profile for this terminal: QQQ_FKEYS overrides; otherwise from TERM_PROGRAM, TERM and friends. */
|
||||
export function legacyProfile(env: NodeJS.ProcessEnv = process.env): LegacyProfile {
|
||||
const forced = env.QQQ_FKEYS?.trim().toLowerCase()
|
||||
if (forced && (LEGACY_PROFILES as string[]).includes(forced)) return forced as LegacyProfile
|
||||
const program = env.TERM_PROGRAM ?? ''
|
||||
const term = env.TERM ?? ''
|
||||
if (program === 'Apple_Terminal') return 'apple'
|
||||
// tmux re-encodes the keys it knows in the xterm forms; Windows Terminal sends CSI 29~ for the Menu key
|
||||
if (env.TMUX || env.WT_SESSION || /^(iTerm\.app|WezTerm|ghostty|vscode|tmux)$/.test(program) || /kitty|ghostty|alacritty|foot/.test(term)) return 'modern'
|
||||
if (/^(rxvt|linux)/.test(term)) return 'rxvt'
|
||||
return 'vt220'
|
||||
}
|
||||
|
||||
function modifiers(m: number): Pick<FKeyChord, 'shift' | 'ctrl' | 'alt'> {
|
||||
const bits = Math.max(0, m - 1)
|
||||
return { shift: Boolean(bits & 1), alt: Boolean(bits & 2), ctrl: Boolean(bits & 4) }
|
||||
}
|
||||
|
||||
/** `m` or `m:event` → modifier number and whether it is a kitty release. */
|
||||
function modParam(p: string | undefined): { m: number; release: boolean } {
|
||||
if (!p) return { m: 1, release: false }
|
||||
const [m, event] = p.split(':')
|
||||
return { m: Number(m) || 1, release: event === '3' }
|
||||
}
|
||||
|
||||
function chord(n: number, m: number, release = false, alt = false): FKeyChord | undefined {
|
||||
if (!(n >= 1 && n <= 12)) return undefined
|
||||
const mods = modifiers(m)
|
||||
const out: FKeyChord = { n, ...mods, alt: mods.alt || alt }
|
||||
if (release) out.release = true
|
||||
return out
|
||||
}
|
||||
|
||||
/** Reads one whole sequence; undefined when it is not a function key. */
|
||||
export function decodeFKey(seq: string, profile: LegacyProfile = 'vt220'): { chord: FKeyChord; family: FKeyFamily } | undefined {
|
||||
// an extra ESC in front is Alt (meta) on terminals that send it that way
|
||||
let s = seq
|
||||
let alt = false
|
||||
if (s.startsWith('\x1b\x1b')) {
|
||||
alt = true
|
||||
s = s.slice(1)
|
||||
}
|
||||
let m: RegExpExecArray | null
|
||||
if ((m = /^\x1bO(\d*)([PQRS])$/.exec(s))) {
|
||||
const c = chord(LETTER_TO_N[m[2]!]!, Number(m[1]) || 1, false, alt)
|
||||
return c && { chord: c, family: 'ss3' }
|
||||
}
|
||||
if ((m = /^\x1b\[(?:1;)?(\d+(?::\d+)?)?([PQRS])$/.exec(s))) {
|
||||
const { m: mod, release } = modParam(m[1])
|
||||
const c = chord(LETTER_TO_N[m[2]!]!, mod, release, alt)
|
||||
return c && { chord: c, family: m[2] === 'R' ? 'csi-f3-cpr' : 'csi-letter' }
|
||||
}
|
||||
if ((m = /^\x1b\[(\d+)(?:;(\d+(?::\d+)?))?~$/.exec(s))) {
|
||||
const code = Number(m[1])
|
||||
const { m: mod, release } = modParam(m[2])
|
||||
if (TILDE_TO_N[code]) {
|
||||
const c = chord(TILDE_TO_N[code]!, mod, release, alt)
|
||||
return c && { chord: c, family: 'csi-tilde' }
|
||||
}
|
||||
const shifted = LEGACY_SHIFT[profile][code]
|
||||
if (shifted && !m[2]) {
|
||||
const c = chord(shifted, 2, false, alt)
|
||||
return c && { chord: c, family: 'legacy-number' }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
if ((m = /^\x1b\[(\d+)([$^@])$/.exec(s))) {
|
||||
// rxvt: $ Shift, ^ Ctrl, @ Ctrl+Shift; 25..34 are the shifted F3..F10 there
|
||||
const code = Number(m[1])
|
||||
const suffix = m[2]!
|
||||
const base = TILDE_TO_N[code] ?? LEGACY_SHIFT.rxvt[code]
|
||||
if (!base) return undefined
|
||||
const shift = suffix !== '^' || LEGACY_SHIFT.rxvt[code] !== undefined
|
||||
const ctrl = suffix !== '$'
|
||||
const c = chord(base, 1 + (shift ? 1 : 0) + (ctrl ? 4 : 0), false, alt)
|
||||
return c && { chord: c, family: 'rxvt-suffix' }
|
||||
}
|
||||
if ((m = /^\x1b\[(\d+)(?:;(\d+(?::\d+)?))?u$/.exec(s))) {
|
||||
const { m: mod, release } = modParam(m[2])
|
||||
const c = chord(Number(m[1]) - KITTY_F1 + 1, mod, release, alt)
|
||||
return c && { chord: c, family: 'kitty-u' }
|
||||
}
|
||||
if ((m = /^\x1b\[\[([A-E])$/.exec(s))) {
|
||||
const c = chord(LINUX_TO_N[m[1]!]!, 1, false, alt)
|
||||
return c && { chord: c, family: 'linux-console' }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** `shift+f2`, `ctrl+f5`, `ctrl+shift+f1`, `alt+f3`, `f4`. */
|
||||
export function chordName(c: FKeyChord): string {
|
||||
return `${c.ctrl ? 'ctrl+' : ''}${c.alt ? 'alt+' : ''}${c.shift ? 'shift+' : ''}f${c.n}`
|
||||
}
|
||||
|
||||
/** The canonical xterm form OpenTUI reads for every modifier: `CSI n;m ~`. */
|
||||
export function canonicalSequence(c: FKeyChord): string {
|
||||
const m = 1 + (c.shift ? 1 : 0) + (c.alt ? 2 : 0) + (c.ctrl ? 4 : 0)
|
||||
return `\x1b[${TILDE_CODE[c.n]}${m > 1 ? `;${m}` : ''}~`
|
||||
}
|
||||
|
||||
/** Families OpenTUI 0.8 does not read (or reads as something else); these are rewritten. */
|
||||
const NEEDS_REWRITE = new Set<FKeyFamily>(['csi-f3-cpr', 'legacy-number', 'rxvt-suffix'])
|
||||
|
||||
/**
|
||||
* Rewrites the function-key sequences OpenTUI cannot read into the canonical form, leaving every
|
||||
* other byte alone. `cpr` false leaves `CSI 1;m R` alone: during startup (and after a resume) those
|
||||
* bytes are real cursor position reports that OpenTUI's capability probe is waiting for. A chunk
|
||||
* inside a bracketed paste is never touched.
|
||||
*/
|
||||
export function rewriteFKeyInput(chunk: string, profile: LegacyProfile, options: { cpr: boolean }): string {
|
||||
if (!chunk.includes('\x1b') || chunk.includes('\x1b[200~')) return chunk
|
||||
return chunk.replace(/\x1b{1,2}\[(?:1;\d+R|\d+[~$^@])/g, (seq) => {
|
||||
if (seq.endsWith('R') && !options.cpr) return seq
|
||||
const found = decodeFKey(seq, profile)
|
||||
if (!found || !NEEDS_REWRITE.has(found.family)) return seq
|
||||
// Shift+F3 and friends only with a modifier: CSI 1;1R is always a cursor report
|
||||
if (found.family === 'csi-f3-cpr' && !found.chord.shift && !found.chord.ctrl && !found.chord.alt) return seq
|
||||
return canonicalSequence(found.chord)
|
||||
})
|
||||
}
|
||||
|
||||
/** The chord of a key event from OpenTUI (or a test), when it is a function key. */
|
||||
export function fkeyChord(key: Key, profile: LegacyProfile = 'vt220'): FKeyChord | undefined {
|
||||
const named = /^f(\d{1,2})$/.exec(key.name)
|
||||
if (named) {
|
||||
const n = Number(named[1])
|
||||
return n >= 1 && n <= 12 ? { n, shift: Boolean(key.shift), ctrl: Boolean(key.ctrl), alt: Boolean(key.meta) } : undefined
|
||||
}
|
||||
if (key.name && key.name !== 'menu') return undefined
|
||||
return key.sequence?.startsWith('\x1b') ? decodeFKey(key.sequence, profile)?.chord : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* The key the store dispatches: Shift+F1…F12 (no Ctrl, no Alt) become `shift+f1`…`shift+f12` (the
|
||||
* Shift bank); everything else is left as it is (Ctrl+F5 still acts as F5: no Ctrl bank yet).
|
||||
*/
|
||||
export function bankKey(key: Key, profile: LegacyProfile = 'vt220'): Key {
|
||||
const c = fkeyChord(key, profile)
|
||||
if (!c || c.release || !c.shift || c.ctrl || c.alt) return key
|
||||
return { name: `shift+f${c.n}`, sequence: key.sequence ?? '', shift: true }
|
||||
}
|
||||
|
||||
/** The Shift bank number of a dispatched key (`shift+f2` → 2), or undefined. */
|
||||
export function shiftNumber(key: Key): number | undefined {
|
||||
const m = /^shift\+f(\d{1,2})$/.exec(key.name)
|
||||
return m ? Number(m[1]) : undefined
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Shift bank: Shift+F1…F10, a second row of ten function keys (like Norton Commander's Shift
|
||||
* bar). The assignments are this one table; each key names an action (actions.ts), so changing a key
|
||||
* is changing a row here. Like the primary bar, a key that does not apply on the screen is dimmed
|
||||
* and pressing it says why.
|
||||
*/
|
||||
|
||||
import { readConfig, writeConfig } from '@qrun-io/qqq-core'
|
||||
import type { AppStore } from '../store'
|
||||
import { ACTIONS, runAction, type ActionName, type NamedAction } from './actions'
|
||||
|
||||
export interface ShiftKey {
|
||||
n: number
|
||||
/** The bar label (and the help's). */
|
||||
label: string
|
||||
/** At most 5 characters, for narrow terminals. */
|
||||
short: string
|
||||
action: ActionName
|
||||
/** What it does, for the `?` help. */
|
||||
help: string
|
||||
}
|
||||
|
||||
export const SHIFT_BANK: ShiftKey[] = [
|
||||
{ n: 1, label: 'Keys', short: 'Keys', action: 'help.keys', help: 'Keys: this reference (as ?)' },
|
||||
{ n: 2, label: 'Servers', short: 'Servr', action: 'servers', help: 'Servers dialog (as Ctrl+O)' },
|
||||
{ n: 3, label: 'SQL', short: 'SQL', action: 'sql.open', help: 'SQL Runner' },
|
||||
{ n: 4, label: 'Bulk', short: 'Bulk', action: 'grid.bulk', help: 'Bulk actions on the grid (as b)' },
|
||||
{ n: 5, label: 'Metadata', short: 'Meta', action: 'meta.refresh', help: 'Refresh the metadata' },
|
||||
{ n: 6, label: 'Views', short: 'Views', action: 'grid.views', help: 'Saved views of the grid' },
|
||||
{ n: 7, label: 'Copy', short: 'Copy', action: 'record.copy', help: 'Copy into a new record (as c)' },
|
||||
{ n: 8, label: 'Export', short: 'Exprt', action: 'export', help: 'Export the grid (as x)' },
|
||||
{ n: 9, label: 'History', short: 'Hist', action: 'history', help: 'History screen' },
|
||||
{ n: 10, label: 'Assistant', short: 'Asst', action: 'assistant.toggle', help: 'Assistant panel (as F12, Ctrl+G)' },
|
||||
]
|
||||
|
||||
/** The bank key for a number, if bound. */
|
||||
export function shiftKey(n: number): ShiftKey | undefined {
|
||||
return SHIFT_BANK.find((k) => k.n === n)
|
||||
}
|
||||
|
||||
/** One slot of the Shift row as drawn: a label, or null where the key does nothing here (dimmed). */
|
||||
export interface ShiftSlot {
|
||||
n: number
|
||||
label: string | null
|
||||
global: string
|
||||
short: string
|
||||
why?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* The Shift row for the current screen, `slots` keys wide (the primary row's count, so both rows line
|
||||
* up; unbound keys are blank).
|
||||
*/
|
||||
export function shiftSlots(store: AppStore, slots = 10): ShiftSlot[] {
|
||||
const out: ShiftSlot[] = []
|
||||
for (let n = 1; n <= Math.max(10, slots); n++) {
|
||||
const k = shiftKey(n)
|
||||
if (!k) {
|
||||
out.push({ n, label: null, global: '', short: '' })
|
||||
continue
|
||||
}
|
||||
const action: NamedAction = ACTIONS[k.action]
|
||||
let why: string | undefined
|
||||
try {
|
||||
why = action.unavailable(store)
|
||||
} catch {
|
||||
why = 'not available'
|
||||
}
|
||||
out.push({ n, label: why ? null : k.label, global: k.label, short: k.short, why })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Shift+Fn pressed: runs its action (or says why not) and, in one-row mode, shows the Shift row for a moment. */
|
||||
export function runShiftKey(store: AppStore, n: number): void {
|
||||
store.showShiftRow(SHIFT_ROW_MS)
|
||||
const k = shiftKey(n)
|
||||
if (!k) return store.flash(`Shift+F${n} does nothing`)
|
||||
runAction(store, k.action, `Shift+F${n} ${k.label}`)
|
||||
}
|
||||
|
||||
/** The keys for the `?` help's Shift bank group. */
|
||||
export function shiftBankHelp(store: AppStore): Array<[string, string]> {
|
||||
return shiftSlots(store).filter((s) => shiftKey(s.n)).map((s) => {
|
||||
const k = shiftKey(s.n)!
|
||||
return [`Shift+F${s.n}`, `${k.help}${s.why ? ' · not here' : ''}`]
|
||||
})
|
||||
}
|
||||
|
||||
// ---- the F-key bar setting ----
|
||||
|
||||
export type FKeyBarMode = 'one' | 'two' | 'auto'
|
||||
export const FKEY_BAR_MODES: FKeyBarMode[] = ['auto', 'one', 'two']
|
||||
/** From this height `auto` draws both rows. */
|
||||
export const TWO_ROW_MIN_HEIGHT = 30
|
||||
/** How long one-row mode shows the Shift row after a Shift+F press. */
|
||||
export const SHIFT_ROW_MS = 2000
|
||||
|
||||
export function readFKeyBar(env: NodeJS.ProcessEnv = process.env): FKeyBarMode {
|
||||
try {
|
||||
const v = readConfig(env).ui?.fkeyBar
|
||||
return v === 'one' || v === 'two' ? v : 'auto'
|
||||
} catch {
|
||||
return 'auto'
|
||||
}
|
||||
}
|
||||
|
||||
export function writeFKeyBar(mode: FKeyBarMode, env: NodeJS.ProcessEnv = process.env): void {
|
||||
const config = readConfig(env)
|
||||
config.ui = { ...config.ui, fkeyBar: mode }
|
||||
writeConfig(config, env)
|
||||
}
|
||||
|
||||
/** Settings › F-key bar and `:fkeys one|two|auto`: applies now and saves it. */
|
||||
export function setFKeyBar(store: AppStore, mode: FKeyBarMode): void {
|
||||
store.settings.fkeyBar = mode
|
||||
store.bump()
|
||||
try {
|
||||
writeFKeyBar(mode)
|
||||
} catch (e) {
|
||||
return store.error(e)
|
||||
}
|
||||
store.flash(`F-key bar: ${describeFKeyBar(mode)} (saved)`)
|
||||
}
|
||||
|
||||
export function describeFKeyBar(mode: FKeyBarMode): string {
|
||||
return mode === 'one' ? 'one row (Shift row for 2 s after Shift+F)' : mode === 'two' ? 'two rows' : `auto (two rows from ${TWO_ROW_MIN_HEIGHT} lines)`
|
||||
}
|
||||
|
||||
/** How many rows the bar takes at this terminal height. */
|
||||
export function fkeyBarRows(mode: FKeyBarMode | undefined, height: number): 1 | 2 {
|
||||
if (mode === 'two') return 2
|
||||
if (mode === 'one') return 1
|
||||
return height >= TWO_ROW_MIN_HEIGHT ? 2 : 1
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The Shift row of the F-key bar: under the primary row when the bar has two rows, or in its place
|
||||
* for a moment after a Shift+F press in one-row mode. It uses the primary row's arithmetic (mouse/hit.ts
|
||||
* fkeyAt), so the keys line up and a click hits the key drawn there; the first number carries the ⇧.
|
||||
*/
|
||||
|
||||
import type { AppStore } from '../store'
|
||||
import { fkeyAt } from '../mouse/hit'
|
||||
import { key } from '../mouse/keys'
|
||||
import { Zone } from '../mouse/zone'
|
||||
import { glyphs, theme } from '../theme'
|
||||
import { pad } from '../util/text'
|
||||
import { fkeyBarRows, shiftSlots, type ShiftSlot } from './shift-bank'
|
||||
|
||||
/** The cells of each slot, as the primary row lays them out: max(7, width / count), the last takes the rest. */
|
||||
export function slotWidths(width: number, count: number): number[] {
|
||||
const cell = Math.max(7, Math.floor(width / count))
|
||||
const out: number[] = []
|
||||
let used = 0
|
||||
for (let i = 0; i < count; i++) {
|
||||
const w = i === count - 1 ? Math.max(0, width - used) : cell
|
||||
out.push(w)
|
||||
used += w
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The text of each slot (number, then the label padded to the slot), clipped to the width. */
|
||||
export function shiftRowCells(slots: ShiftSlot[], width: number, ascii = false): Array<{ n: number; num: string; label: string; active: boolean }> {
|
||||
const widths = slotWidths(width, slots.length)
|
||||
const narrow = width < 100
|
||||
return slots.map((s, i) => {
|
||||
const w = widths[i]!
|
||||
const num = i === 0 ? `${ascii ? 'S' : '⇧'}${s.n}` : String(s.n).padStart(2)
|
||||
const base = s.label ?? s.global
|
||||
const label = narrow || base.length > w - 2 ? s.short : base
|
||||
return { n: s.n, num: num.slice(0, w), label: pad(label, Math.max(0, w - 2)).slice(0, Math.max(0, w - 2)), active: s.label !== null }
|
||||
})
|
||||
}
|
||||
|
||||
export function ShiftRow({ store, width, slots }: { store: AppStore; width: number; slots: number }) {
|
||||
const t = theme()
|
||||
const shift = shiftSlots(store, slots)
|
||||
const cells = shiftRowCells(shift, width, glyphs().border !== undefined)
|
||||
return (
|
||||
<Zone global onClick={(e) => {
|
||||
const n = fkeyAt(e.x, width, shift.map((s) => s.n))
|
||||
if (n !== undefined) store.handleKey(key(`shift+f${n}`, { shift: true }))
|
||||
}}>
|
||||
<text flexShrink={0} wrapMode="none">
|
||||
{cells.map((c) => (
|
||||
<span key={c.n}>
|
||||
<span fg={t.keyNum}>{c.num}</span>
|
||||
<span fg={c.active ? t.keyLabelFg : t.dim} bg={c.active ? t.keyLabelBg : undefined}>{c.label}</span>
|
||||
</span>
|
||||
))}
|
||||
</text>
|
||||
</Zone>
|
||||
)
|
||||
}
|
||||
|
||||
/** How many rows the bar takes now (the body is that much shorter). */
|
||||
export function barRows(store: AppStore, height: number): 1 | 2 {
|
||||
return fkeyBarRows(store.settings.fkeyBar, height)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Feeds the renderer canonical function-key sequences: every `data` chunk of the input stream goes
|
||||
* through rewriteFKeyInput before any listener (OpenTUI's stdin parser) sees it. OpenTUI's own input
|
||||
* handlers run after its parser, which already took Shift+F3 for a cursor report, so the bytes are
|
||||
* rewritten on the stream itself.
|
||||
*/
|
||||
|
||||
import { rewriteFKeyInput, type LegacyProfile } from './fkeys'
|
||||
|
||||
/** Cursor reports are expected for this long after start and after a resume (the capability probe). */
|
||||
export const CPR_QUIET_MS = 1500
|
||||
|
||||
export interface FKeyRewrite {
|
||||
/** Call after renderer.resume(): the probe runs again, so CSI 1;m R is a cursor report for a while. */
|
||||
quiet(): void
|
||||
/** Restores the stream. */
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
export function installFKeyRewrite(stream: NodeJS.EventEmitter, profile: LegacyProfile, clock: () => number = Date.now): FKeyRewrite {
|
||||
const original = stream.emit
|
||||
let quietUntil = clock() + CPR_QUIET_MS
|
||||
const patched = function (this: NodeJS.EventEmitter, event: string | symbol, ...args: unknown[]): boolean {
|
||||
if (event === 'data' && args.length) {
|
||||
const chunk = args[0]
|
||||
const text = typeof chunk === 'string' ? chunk : Buffer.isBuffer(chunk) ? chunk.toString('latin1') : undefined
|
||||
if (text !== undefined && text.includes('\x1b')) {
|
||||
const out = rewriteFKeyInput(text, profile, { cpr: clock() >= quietUntil })
|
||||
if (out !== text) args[0] = typeof chunk === 'string' ? out : Buffer.from(out, 'latin1')
|
||||
}
|
||||
}
|
||||
return original.call(this, event, ...args)
|
||||
}
|
||||
stream.emit = patched as typeof stream.emit
|
||||
return {
|
||||
quiet: () => { quietUntil = clock() + CPR_QUIET_MS },
|
||||
dispose: () => { if (stream.emit === patched) stream.emit = original },
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import { App } from './app'
|
||||
import { boot, parseArgs } from './boot'
|
||||
import { runDoctor } from './doctor'
|
||||
import { isShutdownNoise, quietShutdownNoise } from './exit'
|
||||
import { legacyProfile } from './keys/fkeys'
|
||||
import { installFKeyRewrite } from './keys/stdin-rewrite'
|
||||
|
||||
const args = parseArgs(process.argv.slice(2))
|
||||
if (args.check === 'full') {
|
||||
@@ -49,6 +51,10 @@ const renderer = await createCliRenderer({
|
||||
targetFps: 30,
|
||||
})
|
||||
|
||||
// Shift+F3 (CSI 1;2R, which OpenTUI takes for a cursor report) and the legacy and rxvt Shift/Ctrl+F
|
||||
// forms reach OpenTUI as the canonical CSI n;m ~ (keys/fkeys.ts)
|
||||
const fkeyRewrite = installFKeyRewrite(process.stdin, legacyProfile())
|
||||
|
||||
let destroyed = false
|
||||
function restore(): void {
|
||||
if (destroyed) return
|
||||
@@ -71,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)
|
||||
@@ -93,7 +105,10 @@ store.editExternal = async (text: string) => {
|
||||
spawnSync(editor, [file], { stdio: 'inherit', shell: process.platform === 'win32' })
|
||||
return readFileSync(file, 'utf8')
|
||||
} finally {
|
||||
// the resume probes the terminal again: its cursor reports are not Shift+F3
|
||||
fkeyRewrite.quiet()
|
||||
await renderer.resume()
|
||||
fkeyRewrite.quiet()
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,6 +169,9 @@ export class HelpOverlay implements Overlay {
|
||||
readonly id = overlayId('help')
|
||||
top = 0
|
||||
|
||||
/** Key groups shown after the screen's help (the Shift bank), before the mouse. */
|
||||
after: KeyGroup[] = []
|
||||
|
||||
constructor(private store: AppStore, readonly groups: KeyGroup[], readonly topicHelp: string[], readonly pointer = HELP_POINTER) {}
|
||||
|
||||
onKey(key: Key): void {
|
||||
@@ -190,6 +193,7 @@ export class HelpOverlay implements Overlay {
|
||||
body.push({ text: ' About this screen', fg: t.acc })
|
||||
for (const p of this.topicHelp) for (const l of wrap(p, inner - 4)) body.push({ text: ` ${l}` })
|
||||
}
|
||||
if (this.after.length) this.keyLines(this.after, body, half)
|
||||
if (this.store.settings.mouse) this.keyLines([MOUSE_KEYS], body, half)
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -72,6 +72,11 @@ export class SaverOverlay implements Overlay {
|
||||
features: s.version?.features ?? [],
|
||||
status: () => s.status,
|
||||
events: realtimeEvents,
|
||||
sessionTable: () => {
|
||||
// the session store qbit's table (storedSession)
|
||||
const t = Object.values(tables).find((x) => /^(stored_?)?sessions?$/i.test(x.name))
|
||||
return t && t.readPermission !== false ? t.name : undefined
|
||||
},
|
||||
favouriteTables: favourites,
|
||||
tableLabel: (name) => tables[name]?.label ?? name,
|
||||
intervalMs: () => this.model.intervalMs,
|
||||
|
||||
@@ -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()
|
||||
|
||||
+238
-20
@@ -17,12 +17,16 @@
|
||||
*/
|
||||
|
||||
import { writeFileSync } from 'node:fs'
|
||||
import { exportFileName, FAILED_JOB_STATES, MAX_LIST_ROWS, Printing, canPrintLists, isFinalJobState, listScopeChoices, readRememberedPrinter, resolvePrinter, runProcess, tableHasPrint, whyNotPrintable, writeRememberedPrinter, type ListScope, type Printer, type PrintJob, type PrintOutcome, type ProcessMeta } from '@qrun-io/qqq-core'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { canSaveAsPdf, downloadProcessFile, formatDateValue, profileNameForServer, readConfig, saveDownload, type ContentSection, type PrintChrome, type RenderRequest, exportFileName, FAILED_JOB_STATES, MAX_LIST_ROWS, Printing, canPrintLists, isFinalJobState, listScopeChoices, readRememberedPrinter, resolvePrinter, runProcess, tableHasPrint, whyNotPrintable, writeRememberedPrinter, type ListScope, type Printer, type PrintJob, type PrintOutcome, type ProcessMeta } from '@qrun-io/qqq-core'
|
||||
import { ChoiceOverlay, type Choice } from './overlays/basic'
|
||||
import type { AppStore } from './store'
|
||||
import type { Screen } from './types'
|
||||
import { errorText } from './util/text'
|
||||
import { saveFile } from './overlays/files-helpers'
|
||||
import { breadcrumbs } from './breadcrumbs'
|
||||
import { dateSettingsFor } from './controls/date-settings'
|
||||
import { CLI_VERSION } from '@qrun-io/qqq-cli/program'
|
||||
import { history } from './history/record'
|
||||
|
||||
/** The printing module of the store's server, or undefined without a client or the extension. */
|
||||
@@ -35,6 +39,7 @@ export function printingOf(store: AppStore): Printing | undefined {
|
||||
const meta = await client.processMeta(processName) as unknown as ProcessMeta
|
||||
return (await runProcess({ baseUrl: client.server, fetch: client.fetch, meta, values, records: recordIds.length ? { ids: recordIds } : undefined, stepTimeoutMillis: 60000 })).values
|
||||
},
|
||||
download: (reference) => downloadProcessFile(client.server, client.fetch, reference),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,17 +71,26 @@ export function printerChoices(printers: Printer[], rememberedId: string | undef
|
||||
return { choices, initial: remembered >= 0 ? remembered : Math.max(0, firstPrintable) }
|
||||
}
|
||||
|
||||
/** The first entry of the printer picker when the server can render to a PDF download. */
|
||||
export const SAVE_AS_PDF = 'Save as PDF…'
|
||||
|
||||
/**
|
||||
* The printer picker: printable printers first, the last used one on this profile preselected.
|
||||
* With `hint` (`:print <printer>`) the printer is chosen by name, id or unique prefix without the
|
||||
* picker. The choice is remembered for the profile, then `send` queues the print.
|
||||
* The printer picker: "Save as PDF…" first (when the server can render to a download and the
|
||||
* caller says what to render), then the printers, printable ones first, the last used one on this
|
||||
* profile preselected. With `hint` (`:print <printer>`) the printer is chosen by name, id or unique
|
||||
* prefix without the picker; `:print pdf` saves. A printer choice is remembered for the profile,
|
||||
* then `send` queues the print.
|
||||
*/
|
||||
export function pickPrinter(store: AppStore, title: string, send: (printing: Printing, printer: Printer) => void, hint?: string): void {
|
||||
export function pickPrinter(store: AppStore, title: string, send: (printing: Printing, printer: Printer) => void, hint?: string, renders?: RenderRequest[]): void {
|
||||
const printing = printingOf(store)
|
||||
if (!printing) return store.flash(`Printing needs ${store.client ? 'the printing extension' : 'a server connection'}.`, 'error')
|
||||
const canSave = Boolean(renders?.length) && canSaveAsPdf(store.meta as never)
|
||||
if (hint && /^(pdf|file)$/i.test(hint.trim())) {
|
||||
return canSave ? savePdfs(store, printing, renders!) : store.flash('This server cannot render to PDF (it needs the printingRenderToPdf process of qbit-printing).', 'error')
|
||||
}
|
||||
const profile = store.status.profile
|
||||
store.run(printing.printers(), (printers) => {
|
||||
if (!printers.length) return store.message('No printers: no print agent has exposed a printer to this server yet.', 'info', 'Print')
|
||||
if (!printers.length && !canSave) return store.message('No printers: no print agent has exposed a printer to this server yet.', 'info', 'Print')
|
||||
const go = (printer: Printer) => {
|
||||
writeRememberedPrinter(profile, { id: printer.id, name: printer.name })
|
||||
send(printing, printer)
|
||||
@@ -92,7 +106,44 @@ export function pickPrinter(store: AppStore, title: string, send: (printing: Pri
|
||||
return why ? store.flash(`"${printer.name}" cannot print now: ${why}.`, 'error') : go(printer)
|
||||
}
|
||||
const { choices, initial } = printerChoices(printers, readRememberedPrinter(profile)?.id)
|
||||
store.open(new ChoiceOverlay<Printer>(store, title, choices, go, 'printers that cannot print now are shown with the reason; the choice is remembered for this profile', initial))
|
||||
const anyPrintable = choices.some((c) => !c.disabled)
|
||||
const all: Choice<Printer | 'pdf'>[] = [
|
||||
...(canSave ? [{ label: SAVE_AS_PDF, value: 'pdf' as const, detail: 'render it and save it like a download: download folder, file dialog' }] : []),
|
||||
...choices,
|
||||
]
|
||||
const first = canSave && !anyPrintable ? 0 : initial + (canSave ? 1 : 0)
|
||||
store.open(new ChoiceOverlay<Printer | 'pdf'>(store, title, all, (choice) => (choice === 'pdf' ? savePdfs(store, printing, renders!) : go(choice)),
|
||||
'Save as PDF saves where downloads go; printers that cannot print now are shown with the reason; the printer is remembered for this profile', first))
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Save as PDF: renders each document on the server and saves it as downloads and exports are
|
||||
* saved: the save dialog (or, with silent downloads, the download folder), the stamped default
|
||||
* name (`Customer-Ada-Lovelace_2026-11-01_120301.pdf`), no silent overwrite. More than one
|
||||
* document go next to the first, each with its own stamped name.
|
||||
*/
|
||||
export function savePdfs(store: AppStore, printing: Printing, renders: RenderRequest[]): void {
|
||||
store.flash(`Rendering ${renders.length === 1 ? 'the PDF' : `${renders.length} PDFs`}…`)
|
||||
const chrome = chromeOf(store)
|
||||
store.run(printing.renderToPdf(renders[0]!, new Date(), chrome), (first) => {
|
||||
saveFile(store, renders.length === 1 ? 'Save as PDF' : `Save ${renders.length} PDFs (the others go next to this one)`, first.fileName, (path) => {
|
||||
const saved = [saveDownload(path, first.bytes, { explicit: true })]
|
||||
const notes = first.message ? [first.message] : []
|
||||
const done = () => {
|
||||
history.record(store, { kind: 'print', summary: `Saved ${saved.length === 1 ? saved[0] : `${saved.length} PDFs to ${dirname(saved[0]!)}`}` })
|
||||
store.message([`Saved ${saved.length === 1 ? saved[0] : `${saved.length} PDFs to ${dirname(saved[0]!)}`} (${first.bytes.length.toLocaleString('en-US')} bytes${saved.length > 1 ? ' for the first' : ''}).`, ...notes].join('\n'), 'info', 'Save as PDF')
|
||||
}
|
||||
const rest = renders.slice(1)
|
||||
if (!rest.length) return done()
|
||||
store.run((async () => {
|
||||
for (const request of rest) {
|
||||
const pdf = await printing.renderToPdf(request, new Date(), chrome)
|
||||
if (pdf.message) notes.push(pdf.message)
|
||||
saved.push(saveDownload(join(dirname(path), pdf.fileName), pdf.bytes, { explicit: false }))
|
||||
}
|
||||
})(), done)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -114,7 +165,7 @@ export function openPrintDialog(store: AppStore, table: string, ids: string[], w
|
||||
const unavailable = printUnavailable(store, table)
|
||||
if (!printingOf(store) || unavailable) return store.flash(`Print ${unavailable ?? 'needs a server connection'}.`, 'error')
|
||||
if (!ids.length) return store.flash('Nothing to print: select records first (Space).', 'error')
|
||||
pickPrinter(store, `Print ${what} on…`, (printing, printer) => queued(store, printing, printer, what, printing.print({ table, ids, printer: printer.id }).then((jobIds) => ({ jobIds }))), hint)
|
||||
pickPrinter(store, `Print ${what} on…`, (printing, printer) => queued(store, printing, printer, what, printing.print({ table, ids, printer: printer.id, chrome: chromeOf(store) }).then((jobIds) => ({ jobIds }))), hint, ids.map((id) => ({ kind: 'record' as const, table, id })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
@@ -161,8 +212,8 @@ export function printTargetOf(screen: Screen | undefined): PrintTarget {
|
||||
* The text of the screen as drawn (the renderer's character frame), without the status line and
|
||||
* the function-key bar, trailing spaces and trailing blank lines removed.
|
||||
*/
|
||||
export function captureScreenText(store: AppStore): string {
|
||||
const frame = store.captureScreen?.() ?? ''
|
||||
export function captureScreenText(store: AppStore, captured?: string): string {
|
||||
const frame = captured ?? store.captureScreen?.() ?? ''
|
||||
const lines = frame.replace(/\r/g, '').split('\n')
|
||||
while (lines.length && !lines[lines.length - 1]!.trim()) lines.pop()
|
||||
const body = lines.slice(0, Math.max(0, lines.length - 2)).map((l) => l.replace(/\s+$/, ''))
|
||||
@@ -182,31 +233,198 @@ export function printScreen(store: AppStore, hint?: string): void {
|
||||
if (target.kind === 'record') {
|
||||
if (tableHasPrint(store.meta as never, target.table)) return openPrintDialog(store, target.table, target.ids, target.what, hint)
|
||||
if (!canPrintLists(store.meta as never)) return saveInstead(store, target)
|
||||
return printText(store, target.what, hint)
|
||||
return askWhat(store, target.what, hint)
|
||||
}
|
||||
if (target.kind === 'text') return printText(store, target.title, hint)
|
||||
if (target.kind === 'text') return askWhat(store, target.title, hint)
|
||||
const grid = target.grid
|
||||
const choices = listScopeChoices({ total: grid.total, pageIndex: grid.pageIndex, pageSize: grid.pageSize, pageRows: grid.pageRows, selected: grid.selected, filtered: Boolean(grid.filterText), recordSheets: tableHasPrint(store.meta as never, grid.table) })
|
||||
const scoped = (scope: ListScope) => {
|
||||
if (scope.kind === 'sheets') return openPrintDialog(store, grid.table, scope.ids, `${scope.ids.length} ${grid.label} records`, hint)
|
||||
const what = scope.kind === 'all' ? `${grid.label}: ${grid.total === undefined ? 'all' : Math.min(grid.total, MAX_LIST_ROWS).toLocaleString('en-US')} rows` : scope.kind === 'page' ? `${grid.label}: page ${grid.pageIndex + 1}` : `${grid.label}: ${scope.ids.length} selected rows`
|
||||
pickPrinter(store, `Print ${what} on…`, (p, printer) => queued(store, p, printer, what, p.printTableList({
|
||||
const list = {
|
||||
table: grid.table, columns: grid.columns.map((c) => c.name), widths: grid.columns.every((c) => c.width) ? grid.columns.map((c) => c.width!) : undefined,
|
||||
filter: grid.filter, scope, title: grid.label, filterDescription: grid.filterText || undefined, printer: printer.id, primaryKeyField: grid.primaryKeyField,
|
||||
})), hint)
|
||||
filter: grid.filter, scope, title: grid.label, filterDescription: grid.filterText || undefined, primaryKeyField: grid.primaryKeyField,
|
||||
}
|
||||
pickPrinter(store, `Print ${what} on…`, (p, printer) => queued(store, p, printer, what, p.printTableList({ ...list, printer: printer.id, chrome: chromeOf(store) })), hint, [{ kind: 'list', list }])
|
||||
}
|
||||
if (grid.selectAll) return scoped({ kind: 'all' })
|
||||
store.open(new ChoiceOverlay<ListScope>(store, `Print ${grid.label}: what?`, choices.map((c) => ({ label: c.label, value: c.value, detail: c.detail })), scoped, 'the table list uses the columns, filter and sort you see; the header repeats on every page'))
|
||||
}
|
||||
|
||||
/** Prints the screen's text (monospace PDF). */
|
||||
function printText(store: AppStore, title: string, hint?: string): void {
|
||||
const text = captureScreenText(store)
|
||||
if (!text.trim()) return store.flash('There is nothing on the screen to print.', 'error')
|
||||
const wide = text.split('\n').some((l) => l.length > 110)
|
||||
pickPrinter(store, `Print this screen (${title}) on…`, (p, printer) => queued(store, p, printer, `screen ${title}`, p.printText({ title, subtitle: `qqq ui · ${store.status.server.replace(/^https?:\/\//, '')}`, text, printer: printer.id, orientation: wide ? 'LANDSCAPE' : 'PORTRAIT' })), hint)
|
||||
function printText(store: AppStore, title: string, text: string, sections?: ContentSection[], hint?: string): void {
|
||||
const wide = !sections && text.split('\n').some((l) => l.length > 110)
|
||||
const page = { title, text, sections, orientation: wide ? 'LANDSCAPE' as const : 'PORTRAIT' as const }
|
||||
const chrome = chromeOf(store)
|
||||
const what = sections ? `${title} (content)` : `screen ${title}`
|
||||
pickPrinter(store, `Print ${sections ? 'the content of' : 'this screen'} (${title}) on…`, (p, printer) => queued(store, p, printer, what, p.printText({ ...page, printer: printer.id, chrome })), hint, [{ kind: 'text', ...page }])
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// the app's header and footer, and a screen's content as sections
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The header and footer every printed page carries: the app (instance branding, else the
|
||||
* profile), the breadcrumb of the screen, the server (profile and host), the user, the time in
|
||||
* the profile's date format; the footer `qqq ui <version> · <server>` and, for grids, the filter.
|
||||
*/
|
||||
export function chromeOf(store: AppStore, footerNote?: string, now: Date = new Date()): PrintChrome {
|
||||
const branding = (store.meta as { branding?: { appName?: string } } | undefined)?.branding
|
||||
let host = store.status.server
|
||||
try {
|
||||
host = new URL(store.status.server).host
|
||||
} catch {
|
||||
host = store.status.server.replace(/^https?:\/\//, '')
|
||||
}
|
||||
const profile = store.status.profile
|
||||
const server = profile && profile !== host && profile !== profileNameForServer(store.status.server) ? `${profile} (${host})` : host
|
||||
let printedAt = now.toISOString()
|
||||
try {
|
||||
printedAt = formatDateValue(now.toISOString(), 'DATE_TIME', dateSettingsFor(store), { withSeconds: false })
|
||||
} catch {
|
||||
printedAt = now.toISOString()
|
||||
}
|
||||
return {
|
||||
app: branding?.appName ?? profileNote(profile) ?? 'QQQ',
|
||||
title: breadcrumbs(store).join(' › '),
|
||||
server,
|
||||
user: store.status.user,
|
||||
printedAt,
|
||||
footerLeft: `qqq ui ${CLI_VERSION} · ${server}`,
|
||||
footerNote,
|
||||
}
|
||||
}
|
||||
|
||||
/** The profile's description (its note in config.json), if it has one. */
|
||||
function profileNote(profile: string): string | undefined {
|
||||
try {
|
||||
return readConfig().profiles[profile]?.note || undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
const TOP_LEFT = new Set(['┌', '╭'])
|
||||
const TOP_RIGHT = new Set(['┐', '╮'])
|
||||
const BOTTOM_LEFT = new Set(['└', '╰'])
|
||||
const BOX = /[─│┌┐└┘├┤┬┴┼╭╮╰╯═║╔╗╚╝╟╢╠╣▏▕]/g
|
||||
|
||||
interface Box { top: number; left: number; bottom: number; right: number; title: string }
|
||||
|
||||
/** Boxes drawn with single lines in a character grid (their title from the top edge). */
|
||||
function boxesOf(grid: string[][]): Box[] {
|
||||
const out: Box[] = []
|
||||
for (let r = 0; r < grid.length; r++) {
|
||||
const row = grid[r]!
|
||||
for (let c = 0; c < row.length; c++) {
|
||||
if (!TOP_LEFT.has(row[c]!)) continue
|
||||
let right = -1
|
||||
for (let x = c + 1; x < row.length; x++) {
|
||||
if (TOP_RIGHT.has(row[x]!)) { right = x; break }
|
||||
if (TOP_LEFT.has(row[x]!)) break
|
||||
}
|
||||
if (right < 0) continue
|
||||
let bottom = -1
|
||||
for (let y = r + 1; y <= grid.length; y++) {
|
||||
// a panel cut off by the bottom of its region runs to the end
|
||||
if (y === grid.length) { bottom = y; break }
|
||||
const ch = grid[y]![c]
|
||||
if (ch !== undefined && BOTTOM_LEFT.has(ch)) { bottom = y; break }
|
||||
if (ch !== '│' && ch !== '║') { bottom = y; break }
|
||||
}
|
||||
if (bottom < 0) continue
|
||||
const title = row.slice(c + 1, right).join('').replace(/^[─\s]+|[─\s]+$/g, '').trim()
|
||||
out.push({ top: r, left: c, bottom, right, title })
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** Text lines without box characters, trimmed; blank and border-only lines collapse. */
|
||||
function cleanLines(lines: string[]): string[] {
|
||||
const out: string[] = []
|
||||
for (const raw of lines) {
|
||||
const line = raw.replace(BOX, ' ').replace(/\s+$/, '')
|
||||
if (!line.trim()) {
|
||||
if (out.length && out[out.length - 1] !== '') out.push('')
|
||||
continue
|
||||
}
|
||||
out.push(line)
|
||||
}
|
||||
while (out.length && out[out.length - 1] === '') out.pop()
|
||||
const indent = Math.min(...out.filter((l) => l.trim()).map((l) => l.length - l.trimStart().length))
|
||||
return Number.isFinite(indent) && indent > 0 ? out.map((l) => l.slice(indent)) : out
|
||||
}
|
||||
|
||||
/**
|
||||
* A screen's content as sections, read from the drawn frame: the content panel only (not the
|
||||
* Apps sidebar, the title bar, the status line or the key bar), each inner panel as a section
|
||||
* with its title and lines, the text around them as untitled sections, in reading order, borders
|
||||
* removed and the panel's key hint dropped.
|
||||
*/
|
||||
export function contentSections(frame: string): ContentSection[] {
|
||||
const lines = frame.replace(/\r/g, '').split('\n')
|
||||
while (lines.length && !lines[lines.length - 1]!.trim()) lines.pop()
|
||||
const body = lines.slice(1, Math.max(1, lines.length - 2))
|
||||
const grid = body.map((l) => Array.from(l))
|
||||
// the Apps sidebar ("Apps (Tab)", "Apps · type to find") is navigation, not content
|
||||
const boxes = boxesOf(grid).filter((b) => !/^Apps(\s+\(|\s+·|$)/.test(b.title))
|
||||
if (!boxes.length) {
|
||||
const text = cleanLines(body)
|
||||
return text.length ? [{ title: '', lines: text }] : []
|
||||
}
|
||||
const area = (b: Box) => (b.bottom - b.top) * (b.right - b.left)
|
||||
const panel = boxes.reduce((a, b) => (area(b) > area(a) ? b : a))
|
||||
const inside = boxes.filter((b) => b !== panel && b.top > panel.top && b.bottom <= panel.bottom && b.left >= panel.left && b.right <= panel.right)
|
||||
const children = inside.filter((b) => !inside.some((o) => o !== b && b.top > o.top && b.bottom < o.bottom && b.left > o.left && b.right < o.right))
|
||||
const sections: ContentSection[] = []
|
||||
let free: string[] = []
|
||||
const flush = () => {
|
||||
const text = cleanLines(free)
|
||||
if (text.length) sections.push({ title: '', lines: text })
|
||||
free = []
|
||||
}
|
||||
const freeRows: Array<{ row: number; text: string }> = []
|
||||
for (let r = panel.top + 1; r < panel.bottom; r++) {
|
||||
const cells = grid[r]!.slice(panel.left + 1, panel.right).map((ch, i) => (children.some((b) => r >= b.top && r <= b.bottom && panel.left + 1 + i >= b.left && panel.left + 1 + i <= b.right) ? ' ' : ch))
|
||||
freeRows.push({ row: r, text: cells.join('') })
|
||||
}
|
||||
// the panel's last line is its key hint ("a actions · … · Esc back"): not content
|
||||
const lastText = [...freeRows].reverse().find((f) => f.text.trim())
|
||||
if (lastText && /(Esc|a actions| · )/.test(lastText.text) && /(Esc|actions|Enter)/.test(lastText.text)) lastText.text = ''
|
||||
const starts = new Map<number, Box[]>()
|
||||
for (const b of children) starts.set(b.top, [...(starts.get(b.top) ?? []), b].sort((x, y) => x.left - y.left))
|
||||
for (const f of freeRows) {
|
||||
const opening = starts.get(f.row)
|
||||
if (opening) {
|
||||
flush()
|
||||
for (const b of opening) {
|
||||
const text = cleanLines(grid.slice(b.top + 1, b.bottom).map((row) => row.slice(b.left + 1, b.right).join('')))
|
||||
if (b.title || text.length) sections.push({ title: b.title, lines: text })
|
||||
}
|
||||
}
|
||||
free.push(f.text)
|
||||
}
|
||||
flush()
|
||||
return sections
|
||||
}
|
||||
|
||||
/** Ctrl+P on a screen that is not a record or a grid: its content as a document, or the screen as drawn. */
|
||||
function askWhat(store: AppStore, title: string, hint?: string): void {
|
||||
const frame = store.captureScreen?.() ?? ''
|
||||
const sections = contentSections(frame)
|
||||
const screen = captureScreenText(store, frame)
|
||||
if (!screen.trim()) return store.flash('There is nothing on the screen to print.', 'error')
|
||||
if (hint || !sections.length) return printText(store, title, screen, sections.length ? sections : undefined, hint)
|
||||
store.open(new ChoiceOverlay<'content' | 'screen'>(store, 'Print what?', [
|
||||
{ label: 'Content', value: 'content', detail: "the screen's panels as a document: no navigation, no borders" },
|
||||
{ label: 'Screen', value: 'screen', detail: 'the terminal as you see it, line for line' },
|
||||
], (what) => printText(store, title, screen, what === 'content' ? sections : undefined), 'both carry the app header and footer'))
|
||||
}
|
||||
|
||||
|
||||
|
||||
/** Without printing: the grid's export, else the screen text saved as a file. */
|
||||
function saveInstead(store: AppStore, target: PrintTarget): void {
|
||||
const screen = store.screen as { exportDialog?: () => void } | undefined
|
||||
|
||||
@@ -24,7 +24,7 @@ import { pad, trunc } from '../util/text'
|
||||
import { Canvas, type Style } from './canvas'
|
||||
import { FONT_HEIGHT, blockText, blockWidth, canDraw } from './font'
|
||||
import { sparkline, type TileState } from './model'
|
||||
import { queryFrame, queryPoseAt, querySize, type QueryPose } from './query-art'
|
||||
import { QUERY_COLOR, queryFrame, queryPoseAt, querySize, type QueryPose, type QuerySize } from './query-art'
|
||||
import { SAVER_STYLES, type SaverStyle, type SaverStyleSetting } from './settings'
|
||||
|
||||
export interface SaverTile {
|
||||
@@ -226,7 +226,7 @@ function drawPin(c: Canvas, input: SaverInput): void {
|
||||
}
|
||||
|
||||
function palette(t: ThemeTokens): string[] {
|
||||
return [t.acc, t.acc2, t.ok, t.warn, t.markFg, t.err]
|
||||
return [QUERY_COLOR, t.acc2, t.ok, t.warn, t.markFg, t.err]
|
||||
}
|
||||
|
||||
/** The block-letter logo under Query: the profile's name, with the server host beneath. */
|
||||
@@ -262,16 +262,18 @@ export interface Box {
|
||||
interface BounceSprite {
|
||||
w: number
|
||||
h: number
|
||||
size: 'large' | 'small'
|
||||
size: QuerySize
|
||||
logo: boolean
|
||||
}
|
||||
|
||||
/** Query with the name logo under him when there is room (large Query only when it all fits). */
|
||||
function bounceSprite(bodyH: number, width: number, logo: NameLogo): BounceSprite {
|
||||
const large = querySize('large')
|
||||
const medium = querySize('medium')
|
||||
const small = querySize('small')
|
||||
const extra = 2 + logo.rows.length
|
||||
if (bodyH >= large.height + extra && width >= Math.max(large.width, logo.w) + 4) return { w: Math.max(large.width, logo.w), h: large.height + extra, size: 'large', logo: true }
|
||||
if (bodyH >= medium.height + extra && width >= Math.max(medium.width, logo.w) + 4) return { w: Math.max(medium.width, logo.w), h: medium.height + extra, size: 'medium', logo: true }
|
||||
if (bodyH >= small.height + extra) return { w: Math.min(width, Math.max(small.width, logo.w)), h: small.height + extra, size: 'small', logo: true }
|
||||
return { w: small.width, h: small.height, size: 'small', logo: false }
|
||||
}
|
||||
@@ -301,7 +303,7 @@ export interface BounceLayout {
|
||||
/** The sprite is drawn (false in the panel half of the alternation). */
|
||||
showSprite: boolean
|
||||
panel?: Box
|
||||
size: 'large' | 'small'
|
||||
size: QuerySize
|
||||
showLogo: boolean
|
||||
dx: 1 | -1
|
||||
dy: 1 | -1
|
||||
@@ -354,7 +356,7 @@ export function bounceLayout(input: SaverInput): BounceLayout {
|
||||
return { ...base, sprite: box, showSprite: true }
|
||||
}
|
||||
|
||||
function drawQuery(c: Canvas, x: number, y: number, size: 'large' | 'small', pose: QueryPose, input: SaverInput, color: string): void {
|
||||
function drawQuery(c: Canvas, x: number, y: number, size: QuerySize, pose: QueryPose, input: SaverInput, color: string): void {
|
||||
c.block(x, y, queryFrame(size, pose, input.ascii), { fg: color, bold: true })
|
||||
}
|
||||
|
||||
@@ -420,13 +422,14 @@ function paintStarfield(c: Canvas, input: SaverInput): Box {
|
||||
c.put(sx, sy, chars[level]!, { fg: level >= 3 ? t.acc2 : level >= 2 ? t.fg : t.dim })
|
||||
}
|
||||
// Query drifts through left to right, bobbing
|
||||
const q = querySize('small')
|
||||
const gsize = guestSize(input.width, bodyH)
|
||||
const q = querySize(gsize)
|
||||
const step = Math.floor(input.now / FRAME_MILLIS)
|
||||
const span = input.width + q.width * 2
|
||||
const qx = (step % span) - q.width
|
||||
const qy = Math.round(1 + bodyH * 0.25 + Math.sin(input.now / 1500) * 2)
|
||||
const pose = queryPoseAt(input.now, { dx: 1, dy: Math.cos(input.now / 1500) > 0 ? 1 : -1 })
|
||||
drawQuery(c, qx, Math.max(1, qy + pose.dy), 'small', pose, input, t.acc2)
|
||||
drawQuery(c, qx, Math.max(1, qy + pose.dy), gsize, pose, input, QUERY_COLOR)
|
||||
// the tiles float in the middle
|
||||
const hud = hudSize(input, input.width - 4, bodyH - 2)
|
||||
const ox = Math.round(Math.sin(input.now / 4000) * 2)
|
||||
@@ -437,6 +440,11 @@ function paintStarfield(c: Canvas, input: SaverInput): Box {
|
||||
return { x: hx, y: hy, w: hud.w, h: hud.h }
|
||||
}
|
||||
|
||||
/** Query's size where he is a guest (not the sprite): medium when the screen is wide and tall enough for him, else small. */
|
||||
function guestSize(width: number, bodyH: number): QuerySize {
|
||||
return width >= 100 && bodyH >= 34 ? 'medium' : 'small'
|
||||
}
|
||||
|
||||
/** Whether the flip clock is mid-flip (the first 500 ms of a minute). */
|
||||
function flipping(now: number): boolean {
|
||||
return now % 60_000 < 500
|
||||
@@ -471,7 +479,8 @@ function paintFlip(c: Canvas, input: SaverInput): Box {
|
||||
const now = hhmm(input.now)
|
||||
const before = hhmm(input.now - (input.now % 60_000) - 1)
|
||||
const half = flipping(input.now) && input.now % 60_000 < 250
|
||||
const q = querySize('small')
|
||||
const qsize = guestSize(input.width, bodyH)
|
||||
const q = querySize(qsize)
|
||||
const columns = input.tiles.length > 1 && input.width >= 100 ? 2 : 1
|
||||
const wantRows = Math.ceil(input.tiles.length / columns)
|
||||
// double-size digits when the screen has room for them and the tiles
|
||||
@@ -488,7 +497,7 @@ function paintFlip(c: Canvas, input: SaverInput): Box {
|
||||
const date = trunc(new Date(input.now).toLocaleDateString('en-GB', { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric' }), input.width - 4)
|
||||
const host = trunc(`${input.server} · ${input.profile}`, input.width - 4)
|
||||
// the tiles with Query beside them, bottom-aligned
|
||||
const colW = Math.max(0, Math.min(columns === 2 ? 43 : 56, Math.floor((input.width - 6 - q.width - 3 - (columns - 1) * 3) / columns)))
|
||||
const colW = Math.max(0, Math.min(columns === 2 ? 43 : 56, Math.floor((input.width - 6 - (queryBeside ? q.width + 3 : 0) - (columns - 1) * 3) / columns)))
|
||||
const tilesW = input.tiles.length ? columns * colW + (columns - 1) * 3 : 0
|
||||
const blockW = input.tiles.length ? (queryBeside ? tilesW + 3 + q.width : Math.max(tilesW, q.width)) : q.width
|
||||
const rowsCap = Math.max(1, bodyH - (cardH + 4) - (queryBeside ? 0 : q.height + 1))
|
||||
@@ -537,12 +546,12 @@ function paintFlip(c: Canvas, input: SaverInput): Box {
|
||||
if (input.tiles.length) tileBlock(c, bx, y, input.tiles, columns, colW, tileRows, input)
|
||||
// Query peeks up beside the tiles: ears and eyes, all of him when he hops
|
||||
const pose = queryPoseAt(input.now, { dx: -1 })
|
||||
const shown = Math.min(blockH, pose.action ? q.height : 3)
|
||||
const frame = queryFrame('small', pose, input.ascii).slice(0, shown)
|
||||
const shown = Math.min(blockH, pose.action ? q.height : qsize === 'medium' ? 5 : 3)
|
||||
const frame = queryFrame(qsize, pose, input.ascii).slice(0, shown)
|
||||
const qx = input.tiles.length && queryBeside ? bx + tilesW + 3 : bx + Math.floor((blockW - q.width) / 2)
|
||||
const qy = y + blockH - shown
|
||||
c.fill(qx, qy, q.width, shown, {})
|
||||
c.block(qx, qy, frame, { fg: t.acc2, bold: true })
|
||||
c.block(qx, qy, frame, { fg: QUERY_COLOR, bold: true })
|
||||
return { x: gx, y: gy, w: groupW, h: groupH }
|
||||
}
|
||||
|
||||
@@ -562,7 +571,8 @@ function paintBig(c: Canvas, input: SaverInput): Box {
|
||||
.map((x, i) => ({ x, i }))
|
||||
.sort((a, b) => (b.x.state.value!.interest ?? 0) - (a.x.state.value!.interest ?? 0) || a.i - b.i)
|
||||
.map((e) => e.x)
|
||||
const q = querySize('small')
|
||||
const qsize = guestSize(input.width, bodyH)
|
||||
const q = querySize(qsize)
|
||||
const rotation = Math.floor(input.now / 8000)
|
||||
// compact: the stack is at most about 90 columns wide, however wide the screen
|
||||
const avail = Math.min(input.width - 4, 90)
|
||||
@@ -614,7 +624,7 @@ function paintBig(c: Canvas, input: SaverInput): Box {
|
||||
const color = tile ? (levelColor(t, tile) === t.fg ? t.acc2 : levelColor(t, tile)) : t.acc2
|
||||
const heroX = centred(heroW)
|
||||
if (!tile) {
|
||||
drawQuery(c, heroX, y, 'small', queryPoseAt(input.now), input, t.acc2)
|
||||
drawQuery(c, heroX, y, qsize, queryPoseAt(input.now), input, QUERY_COLOR)
|
||||
} else {
|
||||
const pose: QueryPose = { ...pose0, ...(asleep ? { blink: true, wag: false } : {}) }
|
||||
const numberAt = (x: number, ny: number) => {
|
||||
@@ -622,12 +632,12 @@ function paintBig(c: Canvas, input: SaverInput): Box {
|
||||
else c.put(x, ny, big, { fg: color, bold: true })
|
||||
}
|
||||
if (beside) {
|
||||
drawQuery(c, heroX, y, 'small', pose, input, t.acc2)
|
||||
drawQuery(c, heroX, y, qsize, pose, input, QUERY_COLOR)
|
||||
if (asleep) c.put(heroX + q.width - 1, y - 1, input.now % 2000 < 1000 ? 'z' : 'zZ', { fg: t.dim })
|
||||
numberAt(heroX + q.width + 3, y + Math.floor((heroH - numH) / 2))
|
||||
} else {
|
||||
numberAt(heroX + Math.floor((heroW - numW) / 2), y)
|
||||
drawQuery(c, heroX + Math.floor((heroW - q.width) / 2), y + numH + 1, 'small', pose, input, t.acc2)
|
||||
drawQuery(c, heroX + Math.floor((heroW - q.width) / 2), y + numH + 1, qsize, pose, input, QUERY_COLOR)
|
||||
if (asleep) c.put(heroX + Math.floor((heroW + q.width) / 2) - 1, y + numH, input.now % 2000 < 1000 ? 'z' : 'zZ', { fg: t.dim })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,57 +7,73 @@
|
||||
* Query, the qrun.io mascot, for the screen saver.
|
||||
*
|
||||
* Source: the sitting-dog line drawing on Query's page, https://www.qrun.io/query/ (the inline SVG,
|
||||
* viewBox 0 0 331.41 348.44, that the page's hero bounces). The large art is that drawing traced
|
||||
* into braille dots (2×4 dots per cell, 66×76 dots); the small art is hand-drawn on a 24×24 dot
|
||||
* grid after it. Each braille cell is a bitmap, so the frames are made from the art itself: blink
|
||||
* (the eye's dots cleared), tail wag (the tail's dots moved), mirrored to face the way he drifts,
|
||||
* eyes glancing up or down. The ASCII glyph set gets a plain line-art version of the same bitmap
|
||||
* (hand-drawn for the small size).
|
||||
* viewBox 0 0 331.41 348.44, in one colour: indigo-400, #818cf8). All three sizes are that drawing
|
||||
* rasterised and thresholded into braille dots (2×4 dots per cell) by scripts/gen-query-art.mjs, so
|
||||
* they share its proportions: large 42×22 cells, medium 25×13, small 17×9. Each braille cell is a
|
||||
* bitmap, so the frames are made from the art itself: blink (the eye's dots cleared), tail wag (the
|
||||
* tail's dots moved), mirrored to face the way he drifts, eyes glancing up or down. The ASCII glyph
|
||||
* set gets a plain line-art version of the same bitmap.
|
||||
*/
|
||||
|
||||
export type QuerySize = 'small' | 'large'
|
||||
/** The drawing's colour (the logo's own fill). */
|
||||
export const QUERY_COLOR = '#818cf8'
|
||||
|
||||
/** Large Query, 33×19 cells, facing right. */
|
||||
export type QuerySize = 'small' | 'medium' | 'large'
|
||||
|
||||
/** large Query, 42×22 cells, facing right (generated by scripts/gen-query-art.mjs). */
|
||||
const LARGE = [
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⡴⠞⠛⠛⠛⠛⠳⢦⣄⠀⠀⠀⠀⠀",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⡾⠋⠀⠀⠀⠀⠀⠀⢀⡀⠹⣇⠀⠀⠀⠀",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢠⡟⠀⠀⢠⡆⠀⠀⠀⠀⠸⠗⠀⠻⣤⣤⣤⣀",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠁⠀⠀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⠀⠈⢿⡀⠀⠀⠀⠀⠀⠀⠀⢀⣀⡼⠋",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠘⣧⠀⠀⠀⠀⢸⡇⠀⠀⠀⠘⣿⠛⠛⠋⠉⠀⠀",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠙⢷⣤⣀⣤⡾⠁⠀⠀⠀⠀⣿⠀⠀⠀⠀⠀⠀",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⡟⠁⠉⠀⠀⠀⠀⠀⠀⠀⣿⡀⠀⠀⠀⠀⠀",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣠⡾⠋⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢸⡇⠀⠀⠀⠀⠀",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⠞⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠀⠀⠀⠀⠀",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⠀⠀⣰⠟⠁⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢻⡆⠀⠀⠀⠀",
|
||||
"⠀⠀⠀⠀⠀⠀⠀⢀⡾⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⣿⠁⠀⠀⠀⠀",
|
||||
"⠀⣀⣀⠀⠀⠀⢀⡾⠁⠀⠀⠀⠀⣤⡴⠶⠶⠶⠶⣤⡀⠀⠀⠀⠀⠀⣰⠏⠀⠀⠀⠀⠀",
|
||||
"⠀⠙⣿⣷⠀⠀⣼⠃⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢹⣆⠀⠀⠀⢰⡏⠀⠀⠀⠀⠀⠀",
|
||||
"⠀⠀⣿⣿⠃⠀⣿⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢰⡏⠀⠀⠀⠈⣷⠀⠀⠀⠀⠀⠀",
|
||||
"⠀⢠⡿⣿⠀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⠟⠀⠀⡀⠀⠀⣿⡀⠀⠀⠀⠀⠀",
|
||||
"⠀⣼⢻⡇⠀⢸⡇⠀⠀⠀⠀⠀⠀⠀⠀⠀⢀⣴⠟⠁⠀⠀⠀⢿⡀⠀⢸⡇⠀⠀⠀⠀⠀",
|
||||
"⠀⣿⡈⠷⠶⠟⢁⠀⠀⠀⠀⠀⠀⠀⠀⠘⠛⠛⠛⠛⠛⠳⣦⡸⣧⠀⠈⠛⠳⣦⡀⠀⠀",
|
||||
"⠀⠈⠳⣦⣤⡴⠛⢧⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣤⣼⡇⠹⣦⣤⣤⣤⣬⡗⠀⠀",
|
||||
" ",
|
||||
" ⣀⣀⣄⣀⣀ ",
|
||||
" ⢀⡴⠋⠁ ⠈⠙⢢⡀ ",
|
||||
" ⣰⠋ ⢠⣦ ⣧ ",
|
||||
" ⢰⠇ ⢸⠁ ⠉ ⠈⠓⠒⠲⡆ ",
|
||||
" ⡟ ⢸⡄ ⢠⠇ ",
|
||||
" ⡇ ⢷ ⢀⣀⣀⣤⡤⠴⠋ ",
|
||||
" ⢻⡀ ⣼ ⢸ ",
|
||||
" ⣹⠓⠒⠚⠁ ⢸ ",
|
||||
" ⢀⡴⠃ ⠸⡆ ",
|
||||
" ⢀⡴⠋ ⢧ ",
|
||||
" ⢀⡴⠉ ⢸ ",
|
||||
" ⣰⠋ ⢸⠃ ",
|
||||
" ⡼⠁ ⢀⣀⣀⣀⡀ ⡼ ",
|
||||
" ⠰⣶⣄ ⢰⠃ ⠘⠉⠉ ⠈⠉⠓⣆ ⡼⠁ ",
|
||||
" ⢸⢹ ⡟ ⢸⠆ ⣷ ",
|
||||
" ⣼⣸ ⢠⡇ ⢀⡞ ⢸ ",
|
||||
" ⢠⡇⡏ ⢸⡇ ⢀⡴⠋ ⢰⡀ ⠸⡇ ",
|
||||
" ⢸ ⣇⣀⡼⠁ ⠠⣴⣯⣤⣤⣤⣄ ⣧ ⢧⣤⡀ ",
|
||||
" ⠘⢧⡀⠁⣀⢶⡀ ⠈⢳⠸⣄ ⠙⡆ ",
|
||||
" ⠉⠉⠁ ⠉⠉⠉⠉⠙⠛⠛⠛⠛⠛⠛⠛⠛⠋⠉ ⠈⠉⠉⠉⠉⠁ ",
|
||||
" ",
|
||||
]
|
||||
|
||||
/** Small Query, 12×6 cells, facing right (hand-drawn on 24×24 dots). */
|
||||
/** medium Query, 25×13 cells, facing right (generated by scripts/gen-query-art.mjs). */
|
||||
const MEDIUM = [
|
||||
" ",
|
||||
" ⢀⠔⠋⠉⠉⠑⢆ ",
|
||||
" ⢠⠃ ⡆ ⠛⠘⠤⠤⡀ ",
|
||||
" ⣾ ⢧ ⢀⣀⡼⠁ ",
|
||||
" ⠹⣄⣀⡼ ⢹⠉ ",
|
||||
" ⡰⠃⠁ ⢸⡀ ",
|
||||
" ⢀⡴⠊ ⡇ ",
|
||||
" ⢠⠎ ⡇ ",
|
||||
" ⣦⡀⢠⠇ ⠐⠚⠙⠒⢦ ⣰⠁ ",
|
||||
" ⣸⡇⢸ ⡸ ⢹ ",
|
||||
" ⣿⠁⣸ ⣠⠞⠁⠐⡆⢸⡄ ",
|
||||
" ⢧⣉⡵⣄⣀⣀⣀⣈⣉⣉⣉⣱⢱⣀⣉⣳ ",
|
||||
" ",
|
||||
]
|
||||
|
||||
/** small Query, 17×9 cells, facing right (generated by scripts/gen-query-art.mjs). */
|
||||
const SMALL = [
|
||||
'⠀⠀⠀⠀⢀⠤⠒⠒⠢⢄⠀⠀',
|
||||
'⠀⠀⠀⠀⡇⢸⠀⠁⣀⣀⡩⠕',
|
||||
'⠀⠀⠀⠀⡨⠃⠀⠀⢇⠀⠀⠀',
|
||||
'⠀⡀⢠⠊⢀⠤⢄⠀⢸⠀⠀⠀',
|
||||
'⠀⢇⡇⠀⠀⠀⡠⠃⢸⠀⠀⠀',
|
||||
'⠀⠀⠣⠤⠤⠼⠶⠶⠮⠵⠀⠀',
|
||||
]
|
||||
|
||||
/** Small Query for the ASCII glyph set, 12×6 cells, facing right. */
|
||||
const SMALL_ASCII = [
|
||||
' .--. ',
|
||||
' /( o`-.',
|
||||
" \\_) .-'",
|
||||
' _ / | ',
|
||||
' ( | _/ | ',
|
||||
" `-'--'-' ",
|
||||
" ⣀⣤⣀⡀ ",
|
||||
" ⡞⢡ ⠰⠹⣀⡀ ",
|
||||
" ⢸ ⢸⡀⢀⣀⠤⠃ ",
|
||||
" ⢈⡗⠚ ⠘⡇ ",
|
||||
" ⢀⡴⠋ ⣇ ",
|
||||
" ⢀⡀⢀⠎ ⡠⠤⣀ ⡏ ",
|
||||
" ⡷⡼ ⢨⠃⠸⡄ ",
|
||||
" ⢸⢧⢇ ⠴⠥⢜⡄⠧⡀ ",
|
||||
" ⠉⠉⠓⠒⠒⠒⠒⠊⠙⠒⠃ ",
|
||||
]
|
||||
|
||||
interface Parts {
|
||||
@@ -67,8 +83,9 @@ interface Parts {
|
||||
}
|
||||
|
||||
const PARTS: Record<QuerySize, Parts> = {
|
||||
large: { eye: [51, 7, 53, 10], tail: [2, 51, 8, 68] },
|
||||
small: { eye: [14, 4, 14, 4], tail: [2, 15, 3, 18] },
|
||||
large: { eye: [57,13,59,16], tail: [0,55,17,87] },
|
||||
medium: { eye: [34,8,35,9], tail: [0,32,10,51] },
|
||||
small: { eye: [23,5,23,6], tail: [0,22,7,35] },
|
||||
}
|
||||
|
||||
// braille dot bits by (column, row) inside a cell
|
||||
@@ -158,11 +175,11 @@ export function mirrorAscii(row: string): string {
|
||||
return [...row].reverse().map((c) => MIRROR[c] ?? c).join('')
|
||||
}
|
||||
|
||||
const BASE: Record<QuerySize, Bitmap> = { large: decodeBraille(LARGE), small: decodeBraille(SMALL) }
|
||||
const BASE: Record<QuerySize, Bitmap> = { large: decodeBraille(LARGE), medium: decodeBraille(MEDIUM), small: decodeBraille(SMALL) }
|
||||
|
||||
/** Width and height in cells of Query at a size. */
|
||||
export function querySize(size: QuerySize): { width: number; height: number } {
|
||||
return size === 'large' ? { width: 33, height: 19 } : { width: 12, height: 6 }
|
||||
return size === 'large' ? { width: 42, height: 22 } : size === 'medium' ? { width: 25, height: 13 } : { width: 17, height: 9 }
|
||||
}
|
||||
|
||||
export interface QueryPose {
|
||||
@@ -178,12 +195,6 @@ export interface QueryPose {
|
||||
|
||||
/** One frame of Query: rows of text (braille, or ASCII line art), all the same width. */
|
||||
export function queryFrame(size: QuerySize, pose: QueryPose = {}, ascii = false): string[] {
|
||||
if (ascii && size === 'small') {
|
||||
let rows = SMALL_ASCII.map((r) => (pose.blink ? r.replace('o', '-') : r))
|
||||
if (pose.wag) rows = rows.map((r, i) => (i === 3 ? ` _${r.slice(2)}` : i === 4 ? `( ${r.slice(3)}` : r))
|
||||
if (pose.facing === 'left') rows = rows.map(mirrorAscii)
|
||||
return rows
|
||||
}
|
||||
const parts = PARTS[size]
|
||||
const bits = BASE[size].map((r) => [...r])
|
||||
const [ex0, ey0, ex1, ey1] = parts.eye
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
*
|
||||
* | tile | needs | reads |
|
||||
* |---------|----------------------|----------------------------------------------------------|
|
||||
* | people | collaboration | GET presence per table, a few tables per refresh |
|
||||
* | people | session store, or collaboration | the session table (sessions active in the last 10 minutes), else GET presence per table, a few tables per refresh |
|
||||
* | jobs | durableJobs feature | GET /jobs?state=recent (scope all, else mine) |
|
||||
* | health | health | GET /health/summary |
|
||||
* | db | db-performance | GET overview + activity |
|
||||
@@ -34,6 +34,8 @@ export interface SaverContext {
|
||||
events: EventRate
|
||||
/** Tables to sample for presence, most likely first (favourites, recents, then the rest). */
|
||||
presenceTables(): string[]
|
||||
/** The server's session table (the session store qbit's), when it has one the user can read. */
|
||||
sessionTable?(): string | undefined
|
||||
/** The profile's favourite tables (count tiles). */
|
||||
favouriteTables: string[]
|
||||
tableLabel(name: string): string
|
||||
@@ -49,24 +51,81 @@ 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
|
||||
}
|
||||
|
||||
/** Everyone seen in presence lately across the sampled tables (this terminal is not: it left). */
|
||||
/** Sessions that count as online: active in the last 10 minutes. */
|
||||
export const SESSION_ACTIVE_MILLIS = 10 * 60_000
|
||||
|
||||
function userLabel(userId: string): string {
|
||||
return userId.includes(':') ? userId.slice(userId.indexOf(':') + 1) : userId
|
||||
}
|
||||
|
||||
/**
|
||||
* 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). 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
|
||||
const now = ctx.now ?? Date.now
|
||||
const names = new Map<string, string>()
|
||||
|
||||
async function sessions(): Promise<TileValue | undefined> {
|
||||
const table = ctx.sessionTable?.()
|
||||
if (!table || sessionsGone) return undefined
|
||||
const cutoff = new Date(now() - SESSION_ACTIVE_MILLIS).toISOString()
|
||||
let rows: Array<{ values: Record<string, unknown> }>
|
||||
try {
|
||||
rows = await ctx.client.query(table, {
|
||||
criteria: [{ fieldName: 'modifyDate', operator: 'GREATER_THAN', values: [cutoff] }, { fieldName: 'expiresAt', operator: 'GREATER_THAN', values: [new Date(now()).toISOString()] }],
|
||||
booleanOperator: 'AND', limit: 500,
|
||||
})
|
||||
} catch (error) {
|
||||
const status = (error as { status?: number }).status
|
||||
if (status === 403 || status === 404) sessionsGone = true
|
||||
return undefined
|
||||
}
|
||||
if (!rows.length) return undefined
|
||||
const self = ctx.status()
|
||||
const perUser = new Map<string, number>()
|
||||
for (const r of rows) {
|
||||
const id = String(r.values.userId ?? '')
|
||||
if (id) perUser.set(id, (perUser.get(id) ?? 0) + 1)
|
||||
}
|
||||
const isSelf = (id: string) => id === self.userId || (Boolean(self.user) && (names.get(id) === self.user || userLabel(id).toLowerCase() === self.user.toLowerCase()))
|
||||
const list = [...perUser.entries()].sort((a, b) => Number(isSelf(b[0])) - Number(isSelf(a[0])) || a[0].localeCompare(b[0]))
|
||||
const who = list.map(([id, n]) => `${isSelf(id) ? 'you' : names.get(id) ?? userLabel(id)}${n > 1 ? ` ×${n}` : ''}`)
|
||||
const total = rows.length
|
||||
const users = perUser.size
|
||||
return {
|
||||
value: `${total} online`,
|
||||
detail: `${total} session${total === 1 ? '' : 's'} · ${users} user${users === 1 ? '' : 's'} (${who.join(', ')})`,
|
||||
big: String(total), interest: 50 + total, level: 'ok',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'people', label: 'Online', needs: 'collaboration',
|
||||
present: () => ctx.has('collaboration'),
|
||||
id: 'people', label: 'Online', needs: 'collaboration or the session store',
|
||||
present: () => ctx.has('collaboration') || Boolean(ctx.sessionTable?.()),
|
||||
async load(): Promise<TileValue> {
|
||||
const all = ctx.presenceTables().filter((t) => !skip.has(t))
|
||||
if (!all.length) throw new SourceAbsent('no tables to watch')
|
||||
const bySession = await sessions()
|
||||
if (bySession) return bySession
|
||||
const self = ctx.status()
|
||||
const signedIn = Boolean(self.userId || self.user)
|
||||
const all = ctx.has('collaboration') ? ctx.presenceTables().filter((t) => !skip.has(t)) : []
|
||||
if (!all.length && !signedIn) throw new SourceAbsent('no tables to watch')
|
||||
const batch: string[] = []
|
||||
for (let i = 0; i < Math.min(PRESENCE_TABLES_PER_REFRESH, all.length); i++) batch.push(all[(cursor + i) % all.length]!)
|
||||
cursor = (cursor + batch.length) % Math.max(1, all.length)
|
||||
const self = ctx.status()
|
||||
let failures = 0
|
||||
await Promise.all(batch.map(async (table) => {
|
||||
try {
|
||||
@@ -74,10 +133,10 @@ export function presenceSource(ctx: SaverContext): TileSource {
|
||||
const t = now()
|
||||
for (const [id, v] of seen) if (v.table === table) seen.delete(id)
|
||||
for (const v of snap.viewers ?? []) {
|
||||
// this terminal left presence when the saver came up: the signed-in user seen here is
|
||||
// another session of theirs (a browser tab, another terminal)
|
||||
const you = v.userId === self.userId || (Boolean(self.user) && v.displayName === self.user)
|
||||
seen.set(v.userId, { name: you ? 'you elsewhere' : 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
|
||||
@@ -85,16 +144,28 @@ export function presenceSource(ctx: SaverContext): TileSource {
|
||||
else failures++
|
||||
}
|
||||
}))
|
||||
if (failures === batch.length) throw new Error('presence did not answer')
|
||||
if (batch.length && failures === batch.length && !signedIn) throw new Error('presence did not answer')
|
||||
// a person not seen again within one sweep of the list (plus a lease) has left
|
||||
const ttl = Math.ceil(all.length / PRESENCE_TABLES_PER_REFRESH) * ctx.intervalMs() + 45_000
|
||||
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 people = [...seen.values()].sort((a, b) => a.name.localeCompare(b.name))
|
||||
const detail = people.length
|
||||
? people.sort((a, b) => Number(a.name === 'you elsewhere') - Number(b.name === 'you elsewhere')).map((p) => `${p.name}${p.mode === 'EDITING' ? ' editing' : ''} · ${ctx.tableLabel(p.table)}`).join(', ')
|
||||
: 'nobody around'
|
||||
return { value: String(people.length), detail, big: String(people.length), interest: people.length ? 50 + people.length : 5 }
|
||||
// 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 {
|
||||
@@ -609,7 +611,15 @@ export class RecordScreen implements Screen {
|
||||
const collab = collaborationOf(this.store)
|
||||
if (!collab) return this.store.flash(this.store.extensions.missing('collaboration'), 'error')
|
||||
if (this.primaryKey === undefined) return
|
||||
this.store.open(new TagOverlay(this.store, collab, this.tableName, this.primaryKey, (t) => { this.tags = t; this.build() }))
|
||||
const tableName = this.tableName
|
||||
const primaryKey = this.primaryKey
|
||||
this.store.client!.call('getRecordFeatures', { path: { tableName, primaryKey: String(primaryKey) } }).then((result) => {
|
||||
if (this.tableName !== tableName || this.primaryKey !== primaryKey) return
|
||||
const features = result.data as { features?: string[]; capabilities?: Record<string, boolean> }
|
||||
if (!features?.features?.includes('TAGS')) return this.store.flash('Tags are not enabled for this table.', 'error')
|
||||
if (features.capabilities?.tag !== true) return this.store.flash('Your role cannot tag this record.', 'error')
|
||||
this.store.open(new TagOverlay(this.store, collab, tableName, primaryKey, (t) => { this.tags = t; this.build() }))
|
||||
}).catch((error) => this.store.flash(errorText(error), 'error'))
|
||||
}
|
||||
|
||||
/** Counts, follow state and the panel details of the strip and the panel (collaboration extension). */
|
||||
@@ -644,7 +654,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.0.0 · UI API client 1.8.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>
|
||||
)
|
||||
}
|
||||
+58
-10
@@ -14,6 +14,7 @@ import { openRealtimeStream } from '@qrun-io/qqq-ui-api-client'
|
||||
import { History, MetaDataIndex, addRecent, currentCredential, readIdleMinutes, type MetaDataDocument, type RecentItem } from '@qrun-io/qqq-core'
|
||||
import { TUI_CHANNEL, TuiClient, type VersionInfo } from './client'
|
||||
import { charOf, isPrintable, type Key } from './keys'
|
||||
import { bankKey, legacyProfile, shiftNumber, type LegacyProfile } from './keys/fkeys'
|
||||
import { ChoiceOverlay, ConfirmOverlay, CliCommandOverlay, MessageOverlay } from './overlays/basic'
|
||||
import { Extensions } from './extensions'
|
||||
import { PrintTracker, printScreen } from './printing'
|
||||
@@ -25,6 +26,9 @@ import { errorText } from './util/text'
|
||||
import { SQL_EXTENSION, hideRunnerApps } from './sql/apps'
|
||||
import { AssistantPanel, SIDE_MIN_WIDTH, sideWidth } from './assistant/panel'
|
||||
|
||||
/** From this width the F-key bar also has the 11 and 12 slots (12 is the Assistant). */
|
||||
export const FKEY_WIDE = 120
|
||||
|
||||
export interface Settings {
|
||||
theme: string
|
||||
ascii: boolean
|
||||
@@ -34,6 +38,8 @@ export interface Settings {
|
||||
singleKeys: boolean
|
||||
/** Copy CLI equivalents to the clipboard automatically (ui.copyCliCommands; default off). */
|
||||
copyCli?: boolean
|
||||
/** The F-key bar: one row, two rows (primary and Shift), or auto (two from 30 lines); keys/shift-bank.ts. */
|
||||
fkeyBar?: 'one' | 'two' | 'auto'
|
||||
}
|
||||
|
||||
export interface Status {
|
||||
@@ -115,6 +121,11 @@ export class AppStore {
|
||||
/** Copies text to the clipboard (set by main); returns how it was copied, or undefined when there is no clipboard. */
|
||||
copyText: (text: string) => string | undefined = () => undefined
|
||||
sidebar?: SidebarLike
|
||||
/** How this terminal numbers its legacy shifted F-keys (keys/fkeys.ts; QQQ_FKEYS overrides). */
|
||||
fkeyProfile: LegacyProfile = legacyProfile()
|
||||
/** One-row bar: the Shift row is shown instead of the primary row until this time (after a Shift+F press). */
|
||||
shiftRowUntil = 0
|
||||
private shiftRowTimer?: ReturnType<typeof setTimeout>
|
||||
private listeners = new Set<Listener>()
|
||||
private timers: Array<ReturnType<typeof setInterval>> = []
|
||||
private streamAbort?: AbortController
|
||||
@@ -198,10 +209,15 @@ export class AppStore {
|
||||
this.bump()
|
||||
}
|
||||
|
||||
/** Tells the presence source which scope is on top (called on every repaint; cheap when unchanged). */
|
||||
/**
|
||||
* Tells the presence source which scope is on top (called on every repaint; cheap when unchanged).
|
||||
* The screen saver or lock keeps the lease on the scope that was on top (the extension has no idle
|
||||
* state: the user stays as a viewer rather than vanishing from the others' screens, as a person
|
||||
* who stepped away from a record) and waking re-syncs.
|
||||
*/
|
||||
private syncPresence(): void {
|
||||
const scope = this.lock ? undefined : this.screen?.presenceScope?.()
|
||||
presenceSource().enter?.(scope)
|
||||
if (this.lock) return
|
||||
presenceSource().enter?.(this.screen?.presenceScope?.())
|
||||
}
|
||||
|
||||
/** Replaces the top screen (a form that saved becomes the record). */
|
||||
@@ -253,6 +269,7 @@ export class AppStore {
|
||||
this.assistant ??= new AssistantPanel(this)
|
||||
this.assistantOpen = true
|
||||
this.focus = 'assistant'
|
||||
this.assistant.capturePageContext()
|
||||
void this.assistant.loadConfig()
|
||||
if (question?.trim()) this.assistant.askWhenReady(question)
|
||||
this.bump()
|
||||
@@ -265,16 +282,20 @@ export class AppStore {
|
||||
this.bump()
|
||||
}
|
||||
|
||||
/** Ctrl+G: opens the panel, moves between the panel and the screen beside it, or (full width) closes it. */
|
||||
/**
|
||||
* F12 (and Ctrl+G, and `:ask` without a question): shows the panel (focus on the question line,
|
||||
* the current screen as context) or hides it. Hiding keeps the conversation, the context and the
|
||||
* draft; the next press shows them again. When the panel is shown but the screen beside it has the
|
||||
* focus, the press goes back to the question line instead.
|
||||
*/
|
||||
toggleAssistant(): void {
|
||||
if (!this.assistantOpen) return this.openAssistant()
|
||||
// shown beside a screen that has the focus (a link was opened from it): back to the question line
|
||||
if (this.focus !== 'assistant') {
|
||||
this.focus = 'assistant'
|
||||
return this.bump()
|
||||
}
|
||||
if (this.assistantLayout()?.mode === 'full') return this.closeAssistant()
|
||||
this.focus = 'content'
|
||||
this.bump()
|
||||
this.closeAssistant()
|
||||
}
|
||||
|
||||
// ---- overlays and messages ----
|
||||
@@ -350,6 +371,8 @@ export class AppStore {
|
||||
// F-key fallbacks for terminals that take the F-keys: Esc then a digit (arrives as Alt+digit) or Ctrl+digit; 0 is F10
|
||||
const digit = /^[0-9]$/.test(raw.name) ? raw.name : /^[0-9]$/.test(raw.sequence?.slice(-1) ?? '') && raw.meta ? raw.sequence.slice(-1) : undefined
|
||||
if (digit !== undefined && (raw.meta || raw.ctrl)) key = { name: `f${digit === '0' ? 10 : digit}`, sequence: '' }
|
||||
// Shift+F1…F12 (any terminal's form, keys/fkeys.ts) are the Shift bank: shift+f1…shift+f12
|
||||
key = bankKey(key, this.fkeyProfile)
|
||||
try {
|
||||
this.dispatch(key)
|
||||
} catch (e) {
|
||||
@@ -386,6 +409,8 @@ export class AppStore {
|
||||
// Ctrl+P prints what you see on every screen; Print Screen too when the terminal passes it on
|
||||
// (kitty keyboard protocol); F8 prints where the screen has no Delete for it
|
||||
if ((key.ctrl && key.name === 'p') || key.name === 'printscreen' || (key.name === 'f8' && this.fkeys().find((k) => k.n === 8)?.label === 'Print')) return printScreen(this)
|
||||
const shifted = shiftNumber(key)
|
||||
if (shifted !== undefined) return this.commands().shiftKey(shifted)
|
||||
if (key.name === 'f1') return this.commands().help()
|
||||
if (this.backslash && charOf(key) === '?') {
|
||||
this.backslash = false
|
||||
@@ -396,7 +421,8 @@ export class AppStore {
|
||||
if (key.name === 'f9') return this.commands().menu()
|
||||
if (key.ctrl && key.name === 'o') return this.commands().servers()
|
||||
if (key.name === 'f10') return this.requestQuit()
|
||||
if (key.ctrl && key.name === 'g') return this.toggleAssistant()
|
||||
// F12 shows and hides the Assistant panel (Ctrl+G where the terminal or the OS takes F12); F11 is reserved
|
||||
if (key.name === 'f12' || (key.ctrl && key.name === 'g')) return this.toggleAssistant()
|
||||
if (this.focus === 'assistant' && this.assistantOpen && this.assistant) {
|
||||
// the panel has every other key while it has focus (text goes to the question line)
|
||||
this.assistant.onKey(key)
|
||||
@@ -503,6 +529,8 @@ export class AppStore {
|
||||
dispose(): void {
|
||||
for (const t of this.timers) clearInterval(t)
|
||||
this.timers = []
|
||||
if (this.shiftRowTimer) clearTimeout(this.shiftRowTimer)
|
||||
this.shiftRowTimer = undefined
|
||||
this.clockTimer = undefined
|
||||
this.streamAbort?.abort()
|
||||
this.printTracker.dispose()
|
||||
@@ -645,16 +673,34 @@ export class AppStore {
|
||||
this.bump()
|
||||
}
|
||||
|
||||
/** The function-key bar for the current context. */
|
||||
/** One-row bar: shows the Shift row for `ms` (keys/shift-bank.ts SHIFT_ROW_MS), then the primary row again. */
|
||||
showShiftRow(ms = 2000): void {
|
||||
this.shiftRowUntil = Date.now() + ms
|
||||
if (this.shiftRowTimer) clearTimeout(this.shiftRowTimer)
|
||||
this.shiftRowTimer = setTimeout(() => {
|
||||
this.shiftRowTimer = undefined
|
||||
this.bump()
|
||||
}, ms + 10)
|
||||
;(this.shiftRowTimer as { unref?: () => void }).unref?.()
|
||||
}
|
||||
|
||||
/** Whether one-row mode shows the Shift row now. */
|
||||
shiftRowShown(): boolean {
|
||||
return Date.now() < this.shiftRowUntil
|
||||
}
|
||||
|
||||
/** The function-key bar for the current context; from 120 columns also 11 (reserved, blank) and 12 (the Assistant). */
|
||||
fkeys(): Array<{ n: number; label: string | null; global: string }> {
|
||||
const defaults: Record<number, string> = { 1: 'Help', 2: 'Search', 3: 'View', 4: 'Edit', 5: 'Refresh', 6: 'Filter', 7: 'New', 8: 'Delete', 9: 'Menu', 10: 'Quit' }
|
||||
const overrides: FKeyLabels = this.focus === 'assistant' && this.assistantOpen && this.assistant ? this.assistant.fkeys() : this.screen?.fkeys?.() ?? { 3: null, 4: null, 6: null, 7: null, 8: null }
|
||||
return Object.entries(defaults).map(([n, label]) => {
|
||||
const keys = Object.entries(defaults).map(([n, label]): { n: number; label: string | null; global: string } => {
|
||||
const k = Number(n)
|
||||
const shown = k in overrides ? overrides[k]! : label
|
||||
// F8 prints on screens that have no Delete for it
|
||||
return { n: k, label: k === 8 && shown === null ? 'Print' : shown, global: label }
|
||||
})
|
||||
if (this.width >= FKEY_WIDE) keys.push({ n: 11, label: null, global: '' }, { n: 12, label: this.extensions.has('assistant') ? 'Ask' : null, global: 'Ask' })
|
||||
return keys
|
||||
}
|
||||
|
||||
themeTokens() {
|
||||
@@ -688,6 +734,8 @@ export interface CommandsLike {
|
||||
menu(): void
|
||||
/** The Servers dialog (Ctrl+O). */
|
||||
servers(): void
|
||||
/** Shift+Fn: the Shift bank's key n (keys/shift-bank.ts). */
|
||||
shiftKey(n: number): void
|
||||
commandLine(initial?: string): void
|
||||
viewSettings(): void
|
||||
lock(): void
|
||||
|
||||
@@ -6,7 +6,10 @@
|
||||
import { afterEach, beforeAll, expect, test } from 'bun:test'
|
||||
import { testRender } from '@opentui/react/test-utils'
|
||||
import { App } from '../src/app'
|
||||
import { chatLines, contextLabel, plainText, wrap } from '../src/assistant/model'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { chatLines, chatLinks, contextLabel, plainText, wrap } from '../src/assistant/model'
|
||||
import { fkeyAt } from '../src/mouse/hit'
|
||||
import { boot, parseArgs } from '../src/boot'
|
||||
import { linkFilter } from '../src/links'
|
||||
import { fakeFetch, type FakeLog } from './fake-server'
|
||||
@@ -26,7 +29,7 @@ const TURN = sse([
|
||||
{ type: 'message.end', messageId: 2, status: 'COMPLETE' },
|
||||
])
|
||||
|
||||
interface AssistantFake { installed: boolean; config: J; turns: J[]; conversations: J[] }
|
||||
interface AssistantFake { installed: boolean; config: J; turns: J[]; conversations: J[]; stream?: string }
|
||||
|
||||
function assistantFetch(log: FakeLog, state: AssistantFake): typeof fetch {
|
||||
const base = fakeFetch(log)
|
||||
@@ -44,7 +47,7 @@ function assistantFetch(log: FakeLog, state: AssistantFake): typeof fetch {
|
||||
}
|
||||
if (/^\/assistant\/conversations\/\d+\/turns$/.test(path)) {
|
||||
state.turns.push(body ?? {})
|
||||
return new Response(TURN, { status: 200, headers: { 'content-type': 'text/event-stream' } })
|
||||
return new Response(state.stream ?? TURN, { status: 200, headers: { 'content-type': 'text/event-stream' } })
|
||||
}
|
||||
return json({ code: 'NOT_FOUND', message: path }, 404)
|
||||
}) as typeof fetch
|
||||
@@ -182,3 +185,130 @@ test('chat lines wrap to the width, number links across exchanges and drop markd
|
||||
expect(contextLabel({ tableName: 'order', recordId: 5 }, 'Order')).toBe('Order #5')
|
||||
expect(contextLabel({ tableName: 'order', filter: {} }, 'Order')).toBe('Order (filtered)')
|
||||
})
|
||||
|
||||
/** The answer to "what services are not healthy right now?" as 8078 gave it (qqq assistant ask --json). */
|
||||
const UNHEALTHY = JSON.parse(readFileSync(join(import.meta.dir, '..', '..', 'core', 'src', 'test', 'fixtures', 'assistant-unhealthy.json'), 'utf8')) as J & { text: string; links: Array<{ label: string; href: string }> }
|
||||
const unhealthyAnswer = () => ({ ...(UNHEALTHY as never as import('@qrun-io/qqq-core').AssistantAnswer), status: 'COMPLETE' as const })
|
||||
|
||||
test('before: the 8078 answer drawn as plain text had entities, a pipe table, URLs and a duplicate link list', () => {
|
||||
const before = plainText(UNHEALTHY.text)
|
||||
expect(before).toContain('healthy:')
|
||||
expect(before).toContain('| --- |')
|
||||
expect(before).toContain('/app/health_component?filter=%7B')
|
||||
expect(UNHEALTHY.links.length).toBe(8)
|
||||
})
|
||||
|
||||
for (const width of [98, 158]) {
|
||||
test(`after (${width} wide): entities decoded, a table, links as label ↗ once each, no URLs, no → list`, () => {
|
||||
const lines = chatLines([{ question: 'what services are not healthy right now?', startedAt: 0, answer: unhealthyAnswer() }], width)
|
||||
const text = lines.map((l) => l.text).join('\n')
|
||||
expect(text).not.toMatch(/&#|\/app\/|%7B|\| ---|^→/m)
|
||||
expect(text).toContain('3 services are not fully healthy: one degraded (email) and two with unknown status.')
|
||||
expect(text).toMatch(/Service\s+Status\s+Since/)
|
||||
expect(text).toMatch(/db_performance ↗\s+UNKNOWN/)
|
||||
expect(text).toContain('• Open unhealthy components ↗')
|
||||
expect(text).toContain('• email is degraded. Its only check is degraded.')
|
||||
expect(text).toContain('7.1 s · 10 in / 729 out tokens')
|
||||
expect(lines.every((l) => [...l.text].length <= width)).toBe(true)
|
||||
expect(lines.find((l) => l.role === 'meta' && l.text.includes('tokens'))).toBeTruthy()
|
||||
// four Tab targets in reading order: the three services, then the open-all link
|
||||
expect(chatLinks([{ question: 'q', startedAt: 0, answer: unhealthyAnswer() }]).map((l) => l.label)).toEqual(['email', 'db_performance', 'esb', 'Open unhealthy components'])
|
||||
const linked = lines.flatMap((l) => (l.segs ?? []).filter((x) => x.link !== undefined).map((x) => x.link))
|
||||
expect([...new Set(linked)]).toEqual([0, 1, 2, 3])
|
||||
})
|
||||
}
|
||||
|
||||
test('narrow: the table keeps the columns that fit and names the rest', () => {
|
||||
const lines = chatLines([{ question: 'q', startedAt: 0, answer: unhealthyAnswer() }], 38)
|
||||
expect(lines.every((l) => [...l.text].length <= 38)).toBe(true)
|
||||
expect(lines.map((l) => l.text).join('\n')).toMatch(/more columns?:/)
|
||||
})
|
||||
|
||||
test('duplicate paragraphs collapse; a link the text does not show is listed once under →', () => {
|
||||
const answer = { conversationId: 1, status: 'COMPLETE' as const, notices: [], numbers: [], tools: [], text: 'All good.\n\nAll good.',
|
||||
links: [{ label: 'Person (3)', href: '/app/person', kind: 'table' as const }, { label: 'People', href: '/app/person', kind: 'table' as const }] }
|
||||
const lines = chatLines([{ question: 'q', startedAt: 0, answer }], 60)
|
||||
expect(lines.filter((l) => l.text === 'All good.').length).toBe(1)
|
||||
expect(lines.filter((l) => l.text.startsWith('→ ')).map((l) => l.text)).toEqual(['→ Person (3) ↗'])
|
||||
})
|
||||
|
||||
test('F12 shows and hides the panel; hiding keeps the conversation, context and draft; Esc hides without asking', async () => {
|
||||
const state = fresh()
|
||||
h = await start(state, 120, 36)
|
||||
await command(h, 'record person 1')
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'record')
|
||||
await key('F12')
|
||||
expect(h.store.assistantOpen).toBe(true)
|
||||
expect(h.store.focus).toBe('assistant')
|
||||
await waitFor(h, (x) => x.includes('on: Person #1'))
|
||||
await type('draft question')
|
||||
await key('F12')
|
||||
expect(h.store.assistantOpen).toBe(false)
|
||||
expect(h.store.focus).toBe('content')
|
||||
// back on Home the panel keeps the record's context and the draft
|
||||
await key('ESCAPE')
|
||||
await key('F12')
|
||||
expect(h.store.assistantOpen).toBe(true)
|
||||
expect(h.store.assistant!.editor.value).toBe('draft question')
|
||||
expect(await waitFor(h, (x) => x.includes('on: Person #1'))).toBeTruthy()
|
||||
// Esc with text on the question line hides; nothing is asked; the draft stays
|
||||
await key('ESCAPE')
|
||||
expect(h.store.assistantOpen).toBe(false)
|
||||
expect(state.turns.length).toBe(0)
|
||||
expect(h.store.assistant!.editor.value).toBe('draft question')
|
||||
// Ctrl+G and :ask do the same
|
||||
await key('g', { ctrl: true })
|
||||
expect(h.store.assistantOpen).toBe(true)
|
||||
await key('g', { ctrl: true })
|
||||
expect(h.store.assistantOpen).toBe(false)
|
||||
await command(h, 'ask')
|
||||
expect(h.store.assistantOpen).toBe(true)
|
||||
})
|
||||
|
||||
test('F-key bar: 11 and 12 from 120 columns, 12Ask lit with the extension, a click on it toggles', async () => {
|
||||
h = await start(fresh(), 120, 36)
|
||||
let keys = h.store.fkeys()
|
||||
expect(keys.map((k) => k.n)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12])
|
||||
expect(keys.find((k) => k.n === 12)?.label).toBe('Ask')
|
||||
expect(keys.find((k) => k.n === 11)?.label).toBeNull()
|
||||
// the primary row (from 30 lines the Shift row is drawn under it)
|
||||
const bar = h.frame().split('\n').find((l) => l.includes(' 1Help'))!
|
||||
expect(bar).toContain('12Ask')
|
||||
expect(bar).toMatch(/11\s+12Ask/)
|
||||
expect([...bar].length).toBeLessThanOrEqual(120)
|
||||
const x = bar.indexOf('12Ask') + 2
|
||||
expect(fkeyAt(x, 120, keys.map((k) => k.n))).toBe(12)
|
||||
const y = h.frame().split('\n').findIndex((l) => l.includes('12Ask'))
|
||||
await press(h, () => h!.setup.mockMouse.click(x, y))
|
||||
expect(h.store.assistantOpen).toBe(true)
|
||||
await press(h, () => h!.setup.mockMouse.click(x, y))
|
||||
expect(h.store.assistantOpen).toBe(false)
|
||||
h.store.resize(100, 36)
|
||||
keys = h.store.fkeys()
|
||||
expect(keys.map((k) => k.n)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10])
|
||||
})
|
||||
|
||||
test('F-key bar without the extension: 12Ask is dimmed (no label), the press says what is missing', async () => {
|
||||
h = await start({ ...fresh(), installed: false }, 120, 36)
|
||||
expect(h.store.fkeys().find((k) => k.n === 12)?.label).toBeNull()
|
||||
await key('F12')
|
||||
await waitFor(h, (f) => f.includes('needs the assistant extension'))
|
||||
expect(h.store.assistantOpen).toBe(false)
|
||||
})
|
||||
|
||||
test('a markdown answer in the panel: a table and links as label ↗, Tab to a link, u shows its address', async () => {
|
||||
const state = { ...fresh(), stream: sse([
|
||||
{ type: 'message.start', turnId: 1, conversationId: 7, userMessageId: 1, messageId: 2 },
|
||||
{ type: 'text.delta', messageId: 2, text: 'Two people:\n\n| Person | Last |\n| --- | --- |\n| [Blair](/app/person/2) | Odd |\n| [Casey](/app/person/3) | Even |\n' },
|
||||
{ type: 'message.end', messageId: 2, status: 'COMPLETE' },
|
||||
]) }
|
||||
h = await start(state, 100, 30)
|
||||
await command(h, 'ask who are they')
|
||||
const f = await waitFor(h, (x) => x.includes('Blair ↗'))
|
||||
expect(f).toContain('Two people:')
|
||||
expect(f).toMatch(/Person\s+Last/)
|
||||
expect(f).not.toContain('/app/person')
|
||||
await key('TAB')
|
||||
await key('u')
|
||||
expect(await waitFor(h, (x) => x.includes('/app/person/2') && x.includes('Link address'))).toBeTruthy()
|
||||
})
|
||||
@@ -13,7 +13,7 @@ import { TuiClient } from '../src/client'
|
||||
import { RecordCollaboration } from '../src/collab'
|
||||
import type { RecordScreen } from '../src/screens/record'
|
||||
import { readViewSettings } from '@qrun-io/qqq-core'
|
||||
import { COLLABORATION, QUICK_SEARCH, collabState, fakeExtensions, resetCollab } from './fake-collab'
|
||||
import { COLLABORATION, QUICK_SEARCH, collabState, fakeExtensions, resetCollab, recordTagPolicy } from './fake-collab'
|
||||
import type { RecordViewSettingsOverlay } from '../src/overlays/record-view-settings'
|
||||
import { fakeFetch } from './fake-server'
|
||||
import { command, isolate, press, startTui, waitFor, type Harness } from './helpers'
|
||||
@@ -240,3 +240,18 @@ test('record view without the collaboration extension: no strip, no panel, the c
|
||||
expect(f).not.toContain('Collaboration')
|
||||
expect((h.store.screen as RecordScreen).list.current?.key).toStartWith('f:')
|
||||
})
|
||||
|
||||
for (const enabled of [false, true]) {
|
||||
test(`tag editor respects server capability: enabled=${enabled}, permission denied`, async () => {
|
||||
fakeExtensions.push(COLLABORATION)
|
||||
recordTagPolicy.enabled = enabled
|
||||
recordTagPolicy.allowed = false
|
||||
h = await startTui(160, 40)
|
||||
await command(h, 'table get person 1')
|
||||
await waitFor(h, (x) => x.includes('Tags (t)'))
|
||||
await key('t')
|
||||
const text = enabled ? 'Your role cannot tag this record.' : 'Tags are not enabled for this table.'
|
||||
await waitFor(h, (x) => x.includes(text))
|
||||
expect(h.store.overlays.length).toBe(0)
|
||||
})
|
||||
}
|
||||
@@ -15,6 +15,7 @@ const json = (data: unknown, status = 200) => new Response(JSON.stringify(data),
|
||||
export const fakeExtensions: J[] = []
|
||||
|
||||
export const COLLABORATION = { name: 'collaboration', version: '1.1.0', pathPrefix: '/qbit/collaboration', description: 'Record collaboration' }
|
||||
export const recordTagPolicy = { enabled: true, allowed: true }
|
||||
export const QUICK_SEARCH = { name: 'quick-search', version: '1.1.0', pathPrefix: '/qbit/quick-search', description: 'Quick search' }
|
||||
|
||||
export interface CollabState {
|
||||
@@ -37,11 +38,14 @@ export function resetCollab(): void {
|
||||
collabState.uploads = []
|
||||
collabState.shares = []
|
||||
collabState.tags = []
|
||||
recordTagPolicy.enabled = true
|
||||
recordTagPolicy.allowed = true
|
||||
}
|
||||
resetCollab()
|
||||
|
||||
export async function collabRoute(path: string, method: string, body: unknown): Promise<Response | undefined> {
|
||||
const on = (name: string) => fakeExtensions.some((e) => e.name === name)
|
||||
if (/^\/table\/person\/\d+\/features$/.test(path)) return json({ features: recordTagPolicy.enabled ? ['TAGS'] : [], capabilities: { tag: recordTagPolicy.allowed } })
|
||||
if (path === '/search' && method === 'POST' && on('quick-search')) {
|
||||
const term = String((body as J | undefined)?.searchTerm ?? '').toLowerCase()
|
||||
const hits = 'ada lovelace'.includes(term) ? [{ tableName: 'person', tableLabel: 'Person', recordId: '1', recordLabel: '1', highlights: [{ text: 'Ada Lovelace', fieldLabel: 'Name' }] }] : []
|
||||
|
||||
@@ -16,6 +16,10 @@ export const printing = {
|
||||
enabled: false,
|
||||
/** The 2.1 table-list and text prints (on with the extension unless a test turns them off). */
|
||||
lists: true,
|
||||
/** Save as PDF (printingRenderToPdf). */
|
||||
render: true,
|
||||
/** Render runs: the values each started with. */
|
||||
renders: [] as J[],
|
||||
/** Table-list and text runs: the process and the values it finished with. */
|
||||
listRuns: [] as Array<{ process: string; values: J }>,
|
||||
/** Print runs: the values and record ids each one finished with. */
|
||||
@@ -24,6 +28,8 @@ export const printing = {
|
||||
reset(): void {
|
||||
this.enabled = false
|
||||
this.lists = true
|
||||
this.render = true
|
||||
this.renders = []
|
||||
this.listRuns = []
|
||||
this.runs = []
|
||||
this.jobs.clear()
|
||||
@@ -41,6 +47,7 @@ export function printProcessesMeta(): J {
|
||||
if (!printing.enabled) return {}
|
||||
return {
|
||||
printingPrintRecords_person: { name: 'printingPrintRecords_person', label: 'Print', tableName: 'person' },
|
||||
...(printing.render ? { printingRenderToPdf: { name: 'printingRenderToPdf', label: 'Save as PDF' } } : {}),
|
||||
...(printing.lists ? { printingPrintTableList: { name: 'printingPrintTableList', label: 'Print a table list' }, printingPrintText: { name: 'printingPrintText', label: 'Print text' } } : {}),
|
||||
}
|
||||
}
|
||||
@@ -76,6 +83,16 @@ export async function printRoute(path: string, method: string, body: unknown): P
|
||||
})
|
||||
return json({ type: 'COMPLETE', processUUID: 'pr', nextStep: 'result', values: { printingJobIds: ids.join(',') } })
|
||||
}
|
||||
if (printing.render && path === '/metaData/process/printingRenderToPdf') {
|
||||
return json({ name: 'printingRenderToPdf', label: 'Save as PDF', frontendSteps: [{ name: 'download', label: 'Download', components: [{ type: 'DOWNLOAD_FORM' }], viewFields: [{ name: 'downloadFileName', label: 'File', type: 'STRING' }] }] })
|
||||
}
|
||||
if (printing.render && path === '/processes/printingRenderToPdf/init') {
|
||||
const v = values()
|
||||
printing.renders.push(v)
|
||||
const name = v.kind === 'RECORD' ? `Person-${v.recordId}` : v.kind === 'LIST' ? 'Person' : String(v.title).replace(/[^A-Za-z0-9]+/g, '-')
|
||||
return json({ type: 'COMPLETE', processUUID: 'rd', nextStep: 'download', values: { downloadFileName: `${name}.pdf`, serverFilePath: `/tmp/qqq-print-${printing.renders.length}.pdf` } })
|
||||
}
|
||||
if (printing.render && path.startsWith('/download/')) return new Response(`%PDF-1.7 ${path}`, { status: 200, headers: { 'content-type': 'application/pdf' } })
|
||||
const list = /^\/(?:metaData\/process|processes)\/(printingPrintTableList|printingPrintText)(\/.*)?$/.exec(path)
|
||||
if (list && printing.lists) {
|
||||
const name = list[1]!
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/** The function-key decoder (keys/fkeys.ts), per sequence family, and the stdin rewrite into what OpenTUI reads. */
|
||||
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { parseKeypress } from '@opentui/core'
|
||||
import { bankKey, canonicalSequence, chordName, decodeFKey, fkeyChord, legacyProfile, rewriteFKeyInput, shiftNumber, type FKeyChord, type LegacyProfile } from '../src/keys/fkeys'
|
||||
import { CPR_QUIET_MS, installFKeyRewrite } from '../src/keys/stdin-rewrite'
|
||||
|
||||
const E = '\x1b'
|
||||
const name = (seq: string, profile: LegacyProfile = 'vt220') => {
|
||||
const d = decodeFKey(seq, profile)
|
||||
return d ? chordName(d.chord) : undefined
|
||||
}
|
||||
const family = (seq: string, profile: LegacyProfile = 'vt220') => decodeFKey(seq, profile)?.family
|
||||
|
||||
/** What OpenTUI makes of a sequence after the rewrite (kitty parsing on, as the renderer runs). */
|
||||
function opentui(seq: string, profile: LegacyProfile = 'vt220'): string | undefined {
|
||||
const k = parseKeypress(rewriteFKeyInput(seq, profile, { cpr: true }), { useKittyKeyboard: true })
|
||||
if (!k?.name) return undefined
|
||||
return `${k.ctrl ? 'ctrl+' : ''}${k.meta ? 'alt+' : ''}${k.shift ? 'shift+' : ''}${k.name}`
|
||||
}
|
||||
|
||||
describe('xterm modifier forms (tmux, iTerm2, Windows Terminal, xterm, VTE)', () => {
|
||||
const shift = ['1;2P', '1;2Q', '1;2R', '1;2S', '15;2~', '17;2~', '18;2~', '19;2~', '20;2~', '21;2~', '23;2~', '24;2~']
|
||||
test('Shift+F1…F12', () => {
|
||||
shift.forEach((s, i) => expect(name(`${E}[${s}`)).toBe(`shift+f${i + 1}`))
|
||||
expect(family(`${E}[1;2P`)).toBe('csi-letter')
|
||||
expect(family(`${E}[1;2R`)).toBe('csi-f3-cpr')
|
||||
expect(family(`${E}[15;2~`)).toBe('csi-tilde')
|
||||
})
|
||||
test('SS3 with a modifier and the plain F1–F4', () => {
|
||||
expect(name(`${E}O2P`)).toBe('shift+f1')
|
||||
expect(name(`${E}O2S`)).toBe('shift+f4')
|
||||
expect(name(`${E}OQ`)).toBe('f2')
|
||||
expect(family(`${E}O2R`)).toBe('ss3')
|
||||
})
|
||||
test('CSI 11..14 forms (VT220, PuTTY) with and without modifiers', () => {
|
||||
expect(name(`${E}[11~`)).toBe('f1')
|
||||
expect(name(`${E}[12;2~`)).toBe('shift+f2')
|
||||
expect(name(`${E}[14;2~`)).toBe('shift+f4')
|
||||
})
|
||||
test('Ctrl, Alt and combinations come for free', () => {
|
||||
expect(name(`${E}[1;5P`)).toBe('ctrl+f1')
|
||||
expect(name(`${E}[15;5~`)).toBe('ctrl+f5')
|
||||
expect(name(`${E}[24;5~`)).toBe('ctrl+f12')
|
||||
expect(name(`${E}[1;6P`)).toBe('ctrl+shift+f1')
|
||||
expect(name(`${E}[20;3~`)).toBe('alt+f9')
|
||||
expect(name(`${E}${E}[1;2P`)).toBe('alt+shift+f1')
|
||||
expect(name(`${E}[1;5R`)).toBe('ctrl+f3')
|
||||
})
|
||||
test('OpenTUI reads all of them after the rewrite, Shift+F3 included', () => {
|
||||
shift.forEach((s, i) => expect(opentui(`${E}[${s}`)).toBe(`shift+f${i + 1}`))
|
||||
expect(opentui(`${E}[1;5R`)).toBe('ctrl+f3')
|
||||
// without the rewrite OpenTUI drops Shift+F3: it looks like a cursor position report
|
||||
expect(parseKeypress(`${E}[1;2R`, { useKittyKeyboard: true })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('kitty keyboard protocol', () => {
|
||||
test('F3 is CSI 13 ~; event sub-parameters; the CSI u code points', () => {
|
||||
expect(name(`${E}[13;2~`)).toBe('shift+f3')
|
||||
expect(name(`${E}[15;2:2~`)).toBe('shift+f5')
|
||||
expect(decodeFKey(`${E}[1;2:3Q`)!.chord).toEqual({ n: 2, shift: true, ctrl: false, alt: false, release: true })
|
||||
expect(name(`${E}[57364;2u`)).toBe('shift+f1')
|
||||
expect(name(`${E}[57375;2u`)).toBe('shift+f12')
|
||||
expect(family(`${E}[57365;2u`)).toBe('kitty-u')
|
||||
// F13 and up are keys of their own (macOS keyboards have them)
|
||||
expect(decodeFKey(`${E}[57376;2u`)).toBeUndefined()
|
||||
})
|
||||
test('OpenTUI reads the kitty forms itself (no rewrite)', () => {
|
||||
expect(rewriteFKeyInput(`${E}[13;2~`, 'vt220', { cpr: true })).toBe(`${E}[13;2~`)
|
||||
expect(opentui(`${E}[13;2~`)).toBe('shift+f3')
|
||||
expect(opentui(`${E}[57364;2u`)).toBe('shift+f1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('legacy numbered forms, per terminal profile', () => {
|
||||
const codes = ['25~', '26~', '28~', '29~', '31~', '32~', '33~', '34~']
|
||||
test('vt220 (F13 = Shift+F1): CSI 25~…34~ are Shift+F1…F8', () => {
|
||||
codes.forEach((c, i) => expect(name(`${E}[${c}`, 'vt220')).toBe(`shift+f${i + 1}`))
|
||||
expect(family(`${E}[25~`)).toBe('legacy-number')
|
||||
})
|
||||
test('rxvt and the Linux console (F11 = Shift+F1): Shift+F3…F10', () => {
|
||||
codes.forEach((c, i) => expect(name(`${E}[${c}`, 'rxvt')).toBe(`shift+f${i + 3}`))
|
||||
// CSI 23~ / 24~ stay F11 / F12 everywhere (rxvt's Shift+F1 / F2 cannot be told apart from them)
|
||||
expect(name(`${E}[23~`, 'rxvt')).toBe('f11')
|
||||
})
|
||||
test('macOS Terminal.app: Shift+F5…F12', () => {
|
||||
codes.forEach((c, i) => expect(name(`${E}[${c}`, 'apple')).toBe(`shift+f${i + 5}`))
|
||||
})
|
||||
test('modern terminals: legacy numbers mean nothing (Windows Terminal sends CSI 29~ for the Menu key)', () => {
|
||||
for (const c of codes) expect(decodeFKey(`${E}[${c}`, 'modern')).toBeUndefined()
|
||||
expect(rewriteFKeyInput(`${E}[29~`, 'modern', { cpr: true })).toBe(`${E}[29~`)
|
||||
})
|
||||
test('the rewrite gives OpenTUI the canonical form', () => {
|
||||
expect(opentui(`${E}[25~`, 'vt220')).toBe('shift+f1')
|
||||
expect(opentui(`${E}[26~`, 'vt220')).toBe('shift+f2')
|
||||
expect(opentui(`${E}[33~`, 'apple')).toBe('shift+f11')
|
||||
expect(opentui(`${E}[34~`, 'rxvt')).toBe('shift+f10')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rxvt suffixes and the Linux console', () => {
|
||||
test('$ Shift, ^ Ctrl, @ Ctrl+Shift', () => {
|
||||
expect(name(`${E}[23$`)).toBe('shift+f11')
|
||||
expect(name(`${E}[24$`)).toBe('shift+f12')
|
||||
expect(name(`${E}[11^`)).toBe('ctrl+f1')
|
||||
expect(name(`${E}[15^`)).toBe('ctrl+f5')
|
||||
expect(name(`${E}[24^`)).toBe('ctrl+f12')
|
||||
expect(name(`${E}[25^`)).toBe('ctrl+shift+f3')
|
||||
expect(name(`${E}[23@`)).toBe('ctrl+shift+f11')
|
||||
expect(family(`${E}[15^`)).toBe('rxvt-suffix')
|
||||
expect(opentui(`${E}[23$`)).toBe('shift+f11')
|
||||
expect(opentui(`${E}[15^`)).toBe('ctrl+f5')
|
||||
})
|
||||
test('editing keys with the same suffixes are not function keys', () => {
|
||||
expect(decodeFKey(`${E}[2$`)).toBeUndefined()
|
||||
expect(decodeFKey(`${E}[5^`)).toBeUndefined()
|
||||
expect(rewriteFKeyInput(`${E}[3^`, 'rxvt', { cpr: true })).toBe(`${E}[3^`)
|
||||
})
|
||||
test('Linux console CSI [ A…E are F1…F5', () => {
|
||||
expect(name(`${E}[[A`)).toBe('f1')
|
||||
expect(name(`${E}[[E`)).toBe('f5')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the rewrite', () => {
|
||||
test('leaves cursor reports alone during the probe, and CSI 1;1R always', () => {
|
||||
expect(rewriteFKeyInput(`${E}[1;2R`, 'vt220', { cpr: false })).toBe(`${E}[1;2R`)
|
||||
expect(rewriteFKeyInput(`${E}[1;1R`, 'vt220', { cpr: true })).toBe(`${E}[1;1R`)
|
||||
expect(rewriteFKeyInput(`${E}[12;40R`, 'vt220', { cpr: true })).toBe(`${E}[12;40R`)
|
||||
expect(rewriteFKeyInput(`${E}[1;2R`, 'vt220', { cpr: true })).toBe(`${E}[13;2~`)
|
||||
})
|
||||
test('rewrites inside a chunk, keeps text and other sequences, never touches a paste', () => {
|
||||
expect(rewriteFKeyInput(`ab${E}[25~${E}[A${E}[1;2R`, 'vt220', { cpr: true })).toBe(`ab${E}[11;2~${E}[A${E}[13;2~`)
|
||||
expect(rewriteFKeyInput(`${E}[200~${E}[25~${E}[201~`, 'vt220', { cpr: true })).toBe(`${E}[200~${E}[25~${E}[201~`)
|
||||
expect(rewriteFKeyInput('plain', 'vt220', { cpr: true })).toBe('plain')
|
||||
})
|
||||
test('canonical sequences', () => {
|
||||
const c = (n: number, mods: Partial<FKeyChord> = {}): FKeyChord => ({ n, shift: false, ctrl: false, alt: false, ...mods })
|
||||
expect(canonicalSequence(c(1, { shift: true }))).toBe(`${E}[11;2~`)
|
||||
expect(canonicalSequence(c(3, { ctrl: true }))).toBe(`${E}[13;5~`)
|
||||
expect(canonicalSequence(c(12))).toBe(`${E}[24~`)
|
||||
})
|
||||
test('on the stream: Buffer chunks before any listener; quiet after a resume; dispose restores', () => {
|
||||
const stream = new EventEmitter()
|
||||
const seen: string[] = []
|
||||
stream.on('data', (d: Buffer | string) => seen.push(Buffer.isBuffer(d) ? d.toString('latin1') : d))
|
||||
let now = 1000
|
||||
const r = installFKeyRewrite(stream, 'vt220', () => now)
|
||||
stream.emit('data', Buffer.from(`${E}[1;2R`))
|
||||
stream.emit('data', Buffer.from(`${E}[25~`))
|
||||
now += CPR_QUIET_MS
|
||||
stream.emit('data', Buffer.from(`${E}[1;2R`))
|
||||
r.quiet()
|
||||
stream.emit('data', `${E}[1;2R`)
|
||||
stream.emit('data', Buffer.from('é', 'utf8'))
|
||||
r.dispose()
|
||||
stream.emit('data', `${E}[25~`)
|
||||
expect(seen).toEqual([`${E}[1;2R`, `${E}[11;2~`, `${E}[13;2~`, `${E}[1;2R`, Buffer.from('é', 'utf8').toString('latin1'), `${E}[25~`])
|
||||
})
|
||||
})
|
||||
|
||||
describe('key events and the profile', () => {
|
||||
test('fkeyChord and bankKey: Shift (only) becomes the Shift bank; Ctrl+F keeps acting as F', () => {
|
||||
expect(bankKey({ name: 'f2', sequence: `${E}[1;2Q`, shift: true }).name).toBe('shift+f2')
|
||||
expect(bankKey({ name: 'f2', sequence: `${E}OQ` }).name).toBe('f2')
|
||||
expect(bankKey({ name: 'f5', sequence: '', ctrl: true }).name).toBe('f5')
|
||||
expect(bankKey({ name: 'f1', sequence: '', ctrl: true, shift: true }).name).toBe('f1')
|
||||
expect(bankKey({ name: 'f9', sequence: '', meta: true, shift: true }).name).toBe('f9')
|
||||
// a sequence OpenTUI left unnamed is decoded from its bytes
|
||||
expect(bankKey({ name: '', sequence: `${E}[26~` }, 'vt220').name).toBe('shift+f2')
|
||||
expect(bankKey({ name: 'menu', sequence: `${E}[29~` }, 'modern').name).toBe('menu')
|
||||
expect(fkeyChord({ name: 'f13', sequence: '' })).toBeUndefined()
|
||||
expect(fkeyChord({ name: 'x', sequence: 'x' })).toBeUndefined()
|
||||
expect(shiftNumber({ name: 'shift+f10', sequence: '' })).toBe(10)
|
||||
expect(shiftNumber({ name: 'f10', sequence: '' })).toBeUndefined()
|
||||
})
|
||||
test('legacyProfile from the environment', () => {
|
||||
expect(legacyProfile({ TERM_PROGRAM: 'Apple_Terminal', TERM: 'xterm-256color' })).toBe('apple')
|
||||
expect(legacyProfile({ TERM_PROGRAM: 'iTerm.app', TERM: 'xterm-256color' })).toBe('modern')
|
||||
expect(legacyProfile({ WT_SESSION: 'x', TERM: 'xterm-256color' })).toBe('modern')
|
||||
expect(legacyProfile({ TMUX: '/tmp/x', TERM: 'tmux-256color', TERM_PROGRAM: 'tmux' })).toBe('modern')
|
||||
expect(legacyProfile({ TERM: 'rxvt-unicode-256color' })).toBe('rxvt')
|
||||
expect(legacyProfile({ TERM: 'linux' })).toBe('rxvt')
|
||||
expect(legacyProfile({ TERM: 'xterm' })).toBe('vt220')
|
||||
expect(legacyProfile({ TERM: 'xterm', QQQ_FKEYS: 'apple' })).toBe('apple')
|
||||
expect(legacyProfile({ TERM: 'xterm', QQQ_FKEYS: 'bogus' })).toBe('vt220')
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
|
||||
@@ -101,8 +101,9 @@ test('F-key bar: a click presses the key; the footer connection opens the menu a
|
||||
expect(h.store.overlays.length).toBe(1)
|
||||
await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
expect(h.store.overlays.length).toBe(0)
|
||||
// the footer is the row above the F-key bar; its first cells are the server and profile: the Servers dialog
|
||||
await press(h, () => mouse().click(2, 30 - 2))
|
||||
// the footer is the row above the F-key bar (two rows from 30 lines: primary, then Shift); its
|
||||
// first cells are the server and profile: the Servers dialog
|
||||
await press(h, () => mouse().click(2, 30 - 3))
|
||||
await waitFor(h, () => h!.store.overlays.length === 1)
|
||||
expect(h.store.overlays[0]!.constructor.name).toBe('ServersOverlay')
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -5,8 +5,11 @@
|
||||
|
||||
import { afterEach, beforeAll, beforeEach, expect, test } from 'bun:test'
|
||||
import { KeyCodes } from '@opentui/core/testing'
|
||||
import { printerOf, readRememberedPrinter, writeRememberedPrinter } from '@qrun-io/qqq-core'
|
||||
import { captureScreenText, printTargetOf, printerChoices } from '../src/printing'
|
||||
import { printerOf, readRememberedPrinter, writeFilePreferences, writeRememberedPrinter } from '@qrun-io/qqq-core'
|
||||
import { mkdtempSync, readFileSync, readdirSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { captureScreenText, contentSections, printTargetOf, printerChoices } from '../src/printing'
|
||||
import { printing } from './fake-print'
|
||||
import { command, isolate, press, startTui, waitFor, type Harness } from './helpers'
|
||||
|
||||
@@ -14,6 +17,7 @@ let h: Harness | undefined
|
||||
beforeAll(() => isolate())
|
||||
beforeEach(() => {
|
||||
printing.reset()
|
||||
writeFilePreferences({ downloadMode: undefined, downloadDir: undefined })
|
||||
writeRememberedPrinter('fake', undefined)
|
||||
})
|
||||
afterEach(() => {
|
||||
@@ -72,7 +76,7 @@ test('record: p opens the printer picker, Enter prints, the job is followed in t
|
||||
await press(h, () => h!.setup.mockInput.typeText('Virtual PDF'))
|
||||
await enter()
|
||||
await waitFor(h, () => printing.runs.length === 1)
|
||||
expect(printing.runs[0]).toEqual({ values: { printingPrinterId: 'p1', printingCopies: 1 }, recordIds: '2' })
|
||||
expect(printing.runs[0]).toMatchObject({ values: { printingPrinterId: 'p1', printingCopies: 1 }, recordIds: '2' })
|
||||
f = await waitFor(h, (x) => x.includes('Queued a print job for Person First2 Odd on Virtual PDF printer'))
|
||||
expect(h.store.status.print).toBe('print 0/1 QUEUED')
|
||||
await poll()
|
||||
@@ -102,7 +106,7 @@ test('grid: Space selects records, p, "a record sheet for each selected row" pri
|
||||
expect(f).toContain('Back office laser (last used)')
|
||||
await enter()
|
||||
await waitFor(h, () => printing.runs.length === 1)
|
||||
expect(printing.runs[0]).toEqual({ values: { printingPrinterId: 'p3', printingCopies: 1 }, recordIds: '1,2' })
|
||||
expect(printing.runs[0]).toMatchObject({ values: { printingPrinterId: 'p3', printingCopies: 1 }, recordIds: '1,2' })
|
||||
await waitFor(h, () => h!.store.status.print === 'print 0/2 QUEUED')
|
||||
await poll()
|
||||
await poll()
|
||||
@@ -206,20 +210,88 @@ test('Ctrl+P on a record prints its record sheet; F8 says Print where there is n
|
||||
expect(f).toContain('8Print')
|
||||
expect(printTargetOf(h.store.screen)).toEqual({ kind: 'text', title: h.store.screen!.title() })
|
||||
await press(h, () => h!.setup.mockInput.pressKey(KeyCodes.F8))
|
||||
f = await waitFor(h, (x) => x.includes('Print this screen'))
|
||||
f = await waitFor(h, (x) => x.includes('Print what?'))
|
||||
// Content first (the default), Screen second
|
||||
expect(f.indexOf('Content')).toBeLessThan(f.indexOf('Screen '))
|
||||
await enter()
|
||||
f = await waitFor(h, (x) => x.includes('Print the content of'))
|
||||
await enter()
|
||||
await waitFor(h, () => printing.listRuns.at(-1)?.values.printingPrinterId !== undefined)
|
||||
const run = printing.listRuns.at(-1)!
|
||||
expect(run.process).toBe('printingPrintText')
|
||||
const sections = JSON.parse(String(run.values.sections)) as Array<{ title: string; lines: string[] }>
|
||||
expect(sections.length).toBeGreaterThan(0)
|
||||
const content = JSON.stringify(sections)
|
||||
expect(content).not.toContain('Apps (Tab)')
|
||||
expect(content).not.toContain('│')
|
||||
expect(content).not.toContain('1Help')
|
||||
// the app header and footer
|
||||
expect(run.values.headerApp).toBe('Fake')
|
||||
expect(run.values.headerTitle).toBe(h.store.screen!.title())
|
||||
expect(String(run.values.headerServer)).toContain('fake.test')
|
||||
expect(run.values.headerUser).toBe(h.store.status.user)
|
||||
expect(String(run.values.headerPrintedAt)).toMatch(/\d/)
|
||||
expect(String(run.values.footerLeft)).toMatch(/^qqq ui \d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)? · .*fake\.test/)
|
||||
})
|
||||
|
||||
test('Print what? → Screen prints the terminal as drawn; a record print carries the breadcrumb', async () => {
|
||||
printing.enabled = true
|
||||
h = await startTui(120, 36)
|
||||
await ctrlP()
|
||||
await waitFor(h, (x) => x.includes('Print what?'))
|
||||
await arrow('down')
|
||||
await enter()
|
||||
await waitFor(h, (x) => x.includes('Print this screen'))
|
||||
await enter()
|
||||
await waitFor(h, () => printing.listRuns.at(-1)?.values.printingPrinterId !== undefined)
|
||||
const run = printing.listRuns.at(-1)!
|
||||
expect(run.values.sections).toBeUndefined()
|
||||
expect(String(run.values.text)).toContain('Apps')
|
||||
expect(String(run.values.text)).not.toContain('1Help')
|
||||
await command(h, 'table get person 2')
|
||||
await waitFor(h, (f) => f.includes('First2 Odd'))
|
||||
await ctrlP()
|
||||
await waitFor(h, (x) => x.includes('Print Person First2 Odd on…'))
|
||||
await press(h, () => h!.setup.mockInput.typeText('Virtual PDF'))
|
||||
await enter()
|
||||
await waitFor(h, () => printing.runs.length === 1)
|
||||
expect(printing.runs[0]!.values.headerTitle).toBe('People › Person › First2 Odd')
|
||||
})
|
||||
|
||||
test('content sections: the content panel only, inner panels as titled sections in reading order, no borders, no key hint', () => {
|
||||
const frame = [
|
||||
' Fake ─ Home Tester ',
|
||||
'┌─ Apps (Tab) ─┐┌─ Home ───────────────────────────────────────────┐',
|
||||
'│▸ People ││Good morning, Tester │',
|
||||
'│ ││┌─ Recently viewed ─┐┌─ Quick actions ───────────┐ │',
|
||||
'│ │││ Ada Lovelace ││ ▷ Simple Sleep run │ │',
|
||||
'│ │││ Blair Sample ││ + New Person create │ │',
|
||||
'│ ││└───────────────────┘└───────────────────────────┘ │',
|
||||
'│ ││ Applications (2) │',
|
||||
'│ ││ a actions · Tab panels · Esc back │',
|
||||
'└──────────────┘└───────────────────────────────────────────────────┘',
|
||||
' fake.test · fake · mock session · jobs n/a ',
|
||||
' 1Help 2Search 8Print ',
|
||||
].join('\n')
|
||||
const sections = contentSections(frame)
|
||||
expect(sections).toEqual([
|
||||
{ title: '', lines: ['Good morning, Tester'] },
|
||||
{ title: 'Recently viewed', lines: ['Ada Lovelace', 'Blair Sample'] },
|
||||
{ title: 'Quick actions', lines: ['▷ Simple Sleep run', '+ New Person create'] },
|
||||
{ title: '', lines: ['Applications (2)'] },
|
||||
])
|
||||
expect(contentSections('')).toEqual([])
|
||||
// a panel cut off by the bottom of the content panel still becomes a section
|
||||
const clipped = [' t', '┌─ Main ──────────────┐', '│┌─ Apps grid ───────┐│', '││ Alerts Cluster ││', '││ 3 tables ││', ' status', ' keys'].join('\n')
|
||||
expect(contentSections(clipped)).toEqual([{ title: 'Apps grid', lines: ['Alerts Cluster', '3 tables'] }])
|
||||
})
|
||||
|
||||
|
||||
test('Print Screen (when the terminal passes it on) and :print <printer> print without the picker', async () => {
|
||||
printing.enabled = true
|
||||
h = await startTui(120, 36)
|
||||
await press(h, () => h!.store.handleKey({ name: 'printscreen', sequence: '' } as never))
|
||||
await waitFor(h, (x) => x.includes('Print this screen'))
|
||||
await waitFor(h, (x) => x.includes('Print what?'))
|
||||
await esc()
|
||||
await command(h, 'print back office')
|
||||
await waitFor(h, () => printing.listRuns.length === 1 && printing.listRuns[0]!.values.printingPrinterId === 'p3')
|
||||
@@ -232,3 +304,67 @@ test('the screen capture drops the status line and the function-key bar and trim
|
||||
expect(captureScreenText(store)).toBe(' Title bar\n body line')
|
||||
expect(captureScreenText({} as never)).toBe('')
|
||||
})
|
||||
|
||||
test('Save as PDF is the first picker entry: a record renders on the server and saves like a download (silent mode: the download folder, stamped name)', async () => {
|
||||
printing.enabled = true
|
||||
const folder = mkdtempSync(join(tmpdir(), 'qqq-tui-pdf-'))
|
||||
writeFilePreferences({ downloadMode: 'silent', downloadDir: folder })
|
||||
h = await startTui(120, 36)
|
||||
await command(h, 'table get person 2')
|
||||
await waitFor(h, (f) => f.includes('First2 Odd'))
|
||||
await ctrlP()
|
||||
const f = await waitFor(h, (x) => x.includes('Print Person First2 Odd on…'))
|
||||
const picker = f.split('\n').filter((l) => l.includes('║'))
|
||||
expect(picker.findIndex((l) => l.includes('Save as PDF…'))).toBeLessThan(picker.findIndex((l) => l.includes('Back office laser')))
|
||||
await press(h, () => h!.setup.mockInput.typeText('Save as PDF'))
|
||||
await enter()
|
||||
await waitFor(h, (x) => x.includes('Saved '))
|
||||
expect(printing.renders).toMatchObject([{ kind: 'RECORD', tableName: 'person', recordId: '2' }])
|
||||
expect(printing.runs).toEqual([])
|
||||
const files = readdirSync(folder)
|
||||
expect(files.length).toBe(1)
|
||||
expect(files[0]).toMatch(/^Person-2_\d{4}-\d{2}-\d{2}_\d{6}\.pdf$/)
|
||||
expect(readFileSync(join(folder, files[0]!), 'utf8')).toStartWith('%PDF-1.7 /download/Person-2.pdf')
|
||||
})
|
||||
|
||||
test('Save as PDF on a grid saves the table list; with the dialog (default mode) the save dialog offers the stamped name', async () => {
|
||||
printing.enabled = true
|
||||
h = await startTui(140, 40)
|
||||
await command(h, 'table query person -w "lastName = Odd"')
|
||||
await waitFor(h, (f) => f.includes('First2 '))
|
||||
await ctrlP()
|
||||
await waitFor(h, (x) => x.includes('Print Person: what?'))
|
||||
await enter()
|
||||
await waitFor(h, (x) => x.includes('Print Person: 15 rows on…'))
|
||||
await press(h, () => h!.setup.mockInput.typeText('Save as PDF'))
|
||||
await enter()
|
||||
const f = await waitFor(h, (x) => x.includes('Save as PDF') && /Person_\d{4}-\d{2}-\d{2}_\d{6}\.pdf/.test(x))
|
||||
expect(f).toContain('Name')
|
||||
const render = printing.renders[0]!
|
||||
expect(render.kind).toBe('LIST')
|
||||
expect(render.scope).toBe('ALL')
|
||||
expect(JSON.parse(String(render.filterJSON)).criteria[0]).toMatchObject({ fieldName: 'lastName', values: ['Odd'] })
|
||||
expect(render.printingPrinterId).toBeUndefined()
|
||||
})
|
||||
|
||||
test(':print pdf saves the screen as a PDF without the picker; a server without the render process shows no Save as PDF entry', async () => {
|
||||
printing.enabled = true
|
||||
const folder = mkdtempSync(join(tmpdir(), 'qqq-tui-pdf-'))
|
||||
writeFilePreferences({ downloadMode: 'silent', downloadDir: folder })
|
||||
h = await startTui(120, 36)
|
||||
await command(h, 'print pdf')
|
||||
await waitFor(h, (x) => x.includes('Saved '))
|
||||
expect(printing.renders[0]!.kind).toBe('TEXT')
|
||||
expect(String(printing.renders[0]!.text)).toContain('Apps')
|
||||
expect(readdirSync(folder)[0]).toMatch(/_\d{4}-\d{2}-\d{2}_\d{6}\.pdf$/)
|
||||
await esc()
|
||||
printing.render = false
|
||||
// F10 › Reset refreshes the metadata: the render process is gone
|
||||
await command(h, 'reset')
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'home')
|
||||
await command(h, 'table get person 2')
|
||||
await waitFor(h, (x) => x.includes('First2 Odd'))
|
||||
await ctrlP()
|
||||
const f = await waitFor(h, (x) => x.includes('Print Person First2 Odd on…'))
|
||||
expect(f).not.toContain('Save as PDF…')
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
||||
import { removePin, setPin } from '@qrun-io/qqq-core'
|
||||
import { UiApiError } from '@qrun-io/qqq-ui-api-client'
|
||||
import { SaverOverlay } from '../src/overlays/saver'
|
||||
import { setPresenceSource } from '../src/presence'
|
||||
import { FONT_HEIGHT, blockText, blockWidth, canDraw } from '../src/saver/font'
|
||||
import { ALTERNATE_MILLIS, AUTO_STYLE_MILLIS, FRAME_MILLIS, SHIFT_MILLIS, bounce, bounceLayout, isDimmed, logoName, nextFrameDelay, paintSaver, saverFrame, styleAt, type SaverInput, type SaverTile } from '../src/saver/layout'
|
||||
import { EventRate, MAX_BACKOFF_MS, TileModel, isAbsent, sparkline, type TileSource, type TileValue } from '../src/saver/model'
|
||||
@@ -126,17 +127,69 @@ describe('sources', () => {
|
||||
const s = presenceSource(ctx({ now: () => t, data: (_id, input) => ({ viewers: viewers[(input as { query: { table: string } }).query.table] ?? [] }) }))
|
||||
const v = await s.load()
|
||||
expect(v.value).toBe('3')
|
||||
expect(v.detail).toMatch(/^Bob · PERSON, Carol editing · ORDER, you elsewhere · PERSON$/)
|
||||
expect(v.detail).toBe('3 online · you, Bob · PERSON, Carol editing · ORDER')
|
||||
viewers.person = []
|
||||
viewers.order = []
|
||||
t = 120_000
|
||||
expect((await s.load()).value).toBe('0')
|
||||
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 s = presenceSource(ctx({ data: () => { throw new UiApiError(404, {}) } }))
|
||||
await s.load()
|
||||
await expect(s.load()).rejects.toThrow('no tables')
|
||||
const refuse = () => { throw new UiApiError(404, {}) }
|
||||
// signed out: nothing to show once every table refuses
|
||||
const out = presenceSource(ctx({ status: () => ({ apiVersion: '1', pingMs: 1, stream: 'connected', lastEvent: '', user: '' }), data: refuse }))
|
||||
await out.load()
|
||||
await expect(out.load()).rejects.toThrow('no tables')
|
||||
// signed in: never "nobody around", this terminal is online
|
||||
const me = presenceSource(ctx({ data: refuse }))
|
||||
await me.load()
|
||||
const v = await me.load()
|
||||
expect(v.value).toBe('1')
|
||||
expect(v.detail).toBe('1 online · you')
|
||||
})
|
||||
|
||||
test('online by session: sessions active in the last 10 minutes, own sessions counted, others by user', async () => {
|
||||
const queries: unknown[] = []
|
||||
const rows = [{ userId: 'core:me' }, { userId: 'core:me' }, { userId: 'core:bob' }]
|
||||
const client = { query: async (_t: string, f: unknown) => { queries.push(f); return rows.map((values) => ({ values })) }, data: async () => ({}), count: async () => 0 }
|
||||
const mk = (over: Partial<SaverContext> & { data?: (id: string, input: unknown) => unknown } = {}) => ctx({ client: client as never, sessionTable: () => 'storedSession', now: () => 1_000_000_000_000, ...over })
|
||||
const v = await presenceSource(mk()).load()
|
||||
expect(v.value).toBe('3 online')
|
||||
expect(v.detail).toBe('3 sessions · 2 users (you ×2, bob)')
|
||||
expect(v.big).toBe('3')
|
||||
const f = queries[0] as { criteria: Array<{ fieldName: string; operator: string; values: string[] }> }
|
||||
expect(f.criteria[0]).toMatchObject({ fieldName: 'modifyDate', operator: 'GREATER_THAN', values: [new Date(1_000_000_000_000 - 600_000).toISOString()] })
|
||||
// the owner's case: the same user twice
|
||||
rows.splice(2, 1)
|
||||
expect((await presenceSource(mk()).load()).detail).toBe('2 sessions · 1 user (you ×2)')
|
||||
// an empty session table (a mock-auth server stores none) falls back to presence and still counts you
|
||||
rows.length = 0
|
||||
const fb = await presenceSource(mk({ data: () => ({ viewers: [] }) })).load()
|
||||
expect(fb.value).toBe('1')
|
||||
expect(fb.detail).toBe('1 online · you')
|
||||
// no session table, no collaboration: the tile hides
|
||||
expect(presenceSource(ctx({ has: () => false })).present()).toBe(false)
|
||||
expect(presenceSource(ctx({ has: () => false, sessionTable: () => 'storedSession' })).present()).toBe(true)
|
||||
// a session table the user may not read is skipped for good
|
||||
const denied = { ...client, query: async () => { throw new UiApiError(403, {}) } }
|
||||
const d = presenceSource(mk({ client: denied as never, data: () => ({ viewers: [] }) }))
|
||||
expect((await d.load()).value).toBe('1')
|
||||
})
|
||||
|
||||
test('jobs: counts by status, falls back to my jobs on 403', async () => {
|
||||
@@ -170,7 +223,7 @@ describe('sources', () => {
|
||||
|
||||
describe('Query', () => {
|
||||
test('both sizes have their documented size; braille round-trips; mirrored and blinking frames differ', () => {
|
||||
for (const size of ['small', 'large'] as const) {
|
||||
for (const size of ['small', 'medium', 'large'] as const) {
|
||||
const { width, height } = querySize(size)
|
||||
const frame = queryFrame(size)
|
||||
expect(frame.length).toBe(height)
|
||||
@@ -187,6 +240,49 @@ describe('Query', () => {
|
||||
expect(mirrorAscii("(/`")).toBe("'\\)")
|
||||
})
|
||||
|
||||
test('every size keeps the logo\'s silhouette: floppy ear left of the head and snout, tail curled at the low left, sitting body, front legs and the ground line at the bottom', () => {
|
||||
expect(querySize('large')).toEqual({ width: 42, height: 22 })
|
||||
expect(querySize('medium')).toEqual({ width: 25, height: 13 })
|
||||
expect(querySize('small')).toEqual({ width: 17, height: 9 })
|
||||
for (const size of ['large', 'medium', 'small'] as const) {
|
||||
const rows = queryFrame(size)
|
||||
const bits = decodeBraille(rows)
|
||||
const H = bits.length
|
||||
const W = bits[0]!.length
|
||||
const dots = (y0: number, y1: number, x0 = 0, x1 = W) => {
|
||||
const out: Array<[number, number]> = []
|
||||
for (let y = Math.floor(y0 * H); y < Math.ceil(y1 * H); y++) for (let x = Math.floor(x0 * W); x < Math.ceil(x1 * W); x++) if (bits[y]?.[x]) out.push([x, y])
|
||||
return out
|
||||
}
|
||||
const minX = (d: Array<[number, number]>) => Math.min(...d.map((p) => p[0]))
|
||||
const maxX = (d: Array<[number, number]>) => Math.max(...d.map((p) => p[0]))
|
||||
// the picture is the logo's: dots across the whole height and most of the width, nothing past the cells
|
||||
expect(dots(0, 1).length).toBeGreaterThan(H)
|
||||
expect(rows.length).toBe(querySize(size).height)
|
||||
// head and ear: the top third; the ear (its left edge) is well left of the snout (the right edge), the snout is the rightmost thing up there
|
||||
const head = dots(0, 0.34)
|
||||
expect(head.length).toBeGreaterThan(5)
|
||||
expect(maxX(head)).toBeGreaterThan(W * 0.7)
|
||||
expect(minX(head)).toBeLessThan(W * 0.55)
|
||||
expect(maxX(head) - minX(head)).toBeGreaterThan(W * 0.3)
|
||||
// the top of the head sits above the middle of the back, right of the tail
|
||||
expect(minX(head)).toBeGreaterThan(W * 0.2)
|
||||
// the tail: ink in the low-left corner, left of where the body's back reaches the ground
|
||||
const tail = dots(0.6, 1, 0, 0.22)
|
||||
expect(tail.length).toBeGreaterThan(4)
|
||||
// the sitting body: the chest edge at the right in the middle rows and the haunch at the left
|
||||
const middle = dots(0.4, 0.75)
|
||||
expect(maxX(middle)).toBeGreaterThan(W * 0.6)
|
||||
expect(minX(middle)).toBeLessThan(W * 0.5)
|
||||
// front legs and the ground line: the bottom row of cells spans most of the width
|
||||
const ground = dots(0.84, 1)
|
||||
expect(maxX(ground) - minX(ground)).toBeGreaterThan(W * 0.6)
|
||||
expect(maxX(ground)).toBeGreaterThan(W * 0.7)
|
||||
// the eye is inside the head, right of the ear's left edge
|
||||
expect(queryFrame(size, { blink: true })).not.toEqual(rows)
|
||||
}
|
||||
})
|
||||
|
||||
test('poses: blinks, acts every ten seconds, faces the way he drifts', () => {
|
||||
expect(queryPoseAt(0).blink).toBe(true)
|
||||
expect(queryPoseAt(1000).blink).toBe(false)
|
||||
@@ -520,7 +616,7 @@ describe('burn-in guard', () => {
|
||||
const cy = (minY + maxY) / 2
|
||||
for (const r of spans) {
|
||||
expect(Math.abs((r.from + r.to) / 2 - cx)).toBeLessThanOrEqual(width * 0.4)
|
||||
expect(Math.abs(r.y - cy)).toBeLessThanOrEqual(height * 0.4 + 1)
|
||||
expect(Math.abs(r.y - cy)).toBeLessThanOrEqual(height * 0.4 + 2)
|
||||
}
|
||||
{
|
||||
// the group as a whole stays near the middle of the screen, fully on it
|
||||
@@ -688,6 +784,31 @@ describe('in the TUI', () => {
|
||||
await command(h, 'saver dim 10')
|
||||
})
|
||||
|
||||
test('the saver keeps the presence lease of the screen under it (others see an idle viewer, not a gone one); waking resyncs', async () => {
|
||||
const calls: Array<string | undefined> = []
|
||||
const none = { count: 0, people: [] }
|
||||
setPresenceSource({ get: () => none, subscribe: () => () => undefined, enter: (scope) => { calls.push(scope) } })
|
||||
try {
|
||||
await command(h, 'table query person')
|
||||
await waitFor(h, (f) => f.includes('30 records'))
|
||||
expect(calls.at(-1)).toBe('table:person')
|
||||
calls.length = 0
|
||||
await command(h, 'saver flip')
|
||||
await waitFor(h, (f) => f.includes('preview: flip'))
|
||||
// never left (the same scope may be announced again; the source ignores a repeat)
|
||||
expect(calls.every((c) => c === 'table:person')).toBe(true)
|
||||
await press(h, () => h.setup.mockInput.typeText('x'))
|
||||
expect(h.store.lock).toBeUndefined()
|
||||
h.store.bump()
|
||||
await waitFor(h, (f) => f.includes('30 records'))
|
||||
expect(calls.includes(undefined)).toBe(false)
|
||||
expect(calls.every((c) => c === 'table:person')).toBe(true)
|
||||
} finally {
|
||||
setPresenceSource(undefined)
|
||||
h.store.back()
|
||||
}
|
||||
})
|
||||
|
||||
test('F9 → Settings lists the saver style, refresh and preview', () => {
|
||||
const labels = (h.store.commands() as unknown as { entries(f: string): Array<{ label: string }> }).entries('').map((e) => e.label)
|
||||
expect(labels.some((l) => l.startsWith('Screen saver style:'))).toBe(true)
|
||||
|
||||
Loaded 100 of 113 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user