Compare commits
1
Commits
main
...
feat/tui-splash
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8f3a3bee4 |
No files matched your search
@@ -48,6 +48,23 @@ All notable changes to the qqq CLI and its packages. Versions follow [semver](ht
|
||||
- `scripts/print-agent.sh` prints to `~/Downloads` unless `QQQ_PRINT_OUT` says otherwise (`--dir` reports it).
|
||||
- The kernel adds `renderToPdf`, `renderValues`, `canSaveAsPdf`, `chromeValues` and content sections on text
|
||||
prints; the TUI's breadcrumbs moved to `breadcrumbs.ts`.
|
||||
- **`qqq ui` starts on a splash**: Query (the qrun.io mascot) blinking and wagging while it boots, the `qqq`
|
||||
wordmark, the version and a status line that follows the boot (reading profiles, connecting, signing in,
|
||||
loading metadata with the table count). Large Query from about 34 rows, the small one beside the
|
||||
wordmark at 80×24, plain characters under `--ascii`; any key stops the animation, not the boot; shown at
|
||||
least 600 ms. `--no-splash`, `QQQ_UI_NO_SPLASH=1` or F9 › Settings › Splash on start (`:splash on|off`,
|
||||
saved as `ui.splash`) skip it.
|
||||
- **A profile picker on start**: with several profiles and none named (`--profile`, `QQQ_PROFILE`,
|
||||
`--server`, `QQQ_SERVER`), the Servers picker opens over the splash with the last used profile selected;
|
||||
Enter connects, `n` adds, the server status fills in as the probes answer, Esc quits with a one-line hint
|
||||
about `--profile`. With no profile at all the add form opens directly; with exactly one, it connects.
|
||||
- **Connection failures stay on the splash** with the reason: Enter retries, `c` chooses another profile, `n`
|
||||
adds one, Esc quits (instead of the bare error on the sign-in screen).
|
||||
|
||||
### Changed
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
+39
-7
@@ -15,15 +15,16 @@ says `binary with ui`. The npm package `@qrun-io/qqq-cli` does not carry it. Fro
|
||||
```sh
|
||||
npm ci && npm run build # once, at the repo root (the kernel and CLI are used built)
|
||||
(cd packages/tui && bun install --frozen-lockfile) # once
|
||||
qqq ui # the default profile; or: qqq --profile sample ui
|
||||
qqq ui # the splash, then the only profile (or a picker); or: qqq --profile sample ui
|
||||
qqq ui table query person -w "lastName STARTS_WITH S" # open a screen directly (CLI grammar)
|
||||
qqq ui --theme light | --ascii | --linear | --no-mouse | --check | --no-check
|
||||
qqq ui --theme light | --ascii | --linear | --no-mouse | --no-splash | --check | --no-check
|
||||
bun run packages/tui/src/main.tsx --profile sample # the same without the CLI
|
||||
```
|
||||
|
||||
Options: `--profile|-p <name>`, `--server|-s <url>` (a profile named after the host is made), `--theme
|
||||
<name>`, `--ascii` (ASCII glyphs), `--linear` (one region, no Apps pane), `--no-mouse` / `--mouse` (the
|
||||
mouse for this run; the saved Mouse setting is on by default, see [Mouse](#mouse)),
|
||||
mouse for this run; the saved Mouse setting is on by default, see [Mouse](#mouse)), `--no-splash` /
|
||||
`--splash` (the start-up splash for this run; see [Starting](#starting)),
|
||||
`--check` (the full terminal check, then exit), `--no-check` (skip the quick check at startup). Anything
|
||||
after the options is a route in the CLI grammar.
|
||||
|
||||
@@ -31,7 +32,36 @@ Environment: `QQQ_UI_LINEAR=1`, `QQQ_UI_ASCII=1` (as the options); `QQQ_UI_SIGN_
|
||||
OAUTH2 choice); `QQQ_UI_NO_BROWSER=1` (show the sign-in address without opening a browser);
|
||||
`QQQ_PROFILE`, `QQQ_SERVER`, `QQQ_TOKEN` (a bearer token instead of the stored sign-in), `QQQ_CONFIG_DIR`,
|
||||
`QQQ_STATE_DIR`, `QQQ_CACHE_DIR`, `QQQ_KEYRING=file` (keep sign-ins in a file instead of the OS keychain),
|
||||
shared with the CLI. `QQQ_UI_NO_QUIT=1` is for the soak test only.
|
||||
shared with the CLI. `QQQ_UI_NO_SPLASH=1` (as `--no-splash`). `QQQ_UI_NO_QUIT=1` is for the soak test only.
|
||||
|
||||
## Starting
|
||||
|
||||
`qqq ui` opens on a **splash**: Query (the qrun.io mascot) blinking, his tail wagging while it works, the
|
||||
`qqq` wordmark, "Terminal client for QQQ", the version (`qqq 1.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
|
||||
|
||||
@@ -581,7 +611,8 @@ Everything below is local to the machine and shared with the CLI; nothing is sen
|
||||
1.0.0-rc.1 `note`, `lastUsedAt` and `user` recorded by the UI, and `ui.theme` for that profile only)
|
||||
and `defaultProfile`. Fields are only ever added: a 1.0.0-rc.1 file loads unchanged.
|
||||
Sign-ins themselves are in the OS keychain (or the file keyring), one per profile.
|
||||
- `ui.theme`: the theme for every profile without its own; `ui.mouse`: `false` when the mouse is off.
|
||||
- `ui.theme`: the theme for every profile without its own; `ui.mouse`: `false` when the mouse is off;
|
||||
`ui.splash`: `off` when `qqq ui` starts without the splash.
|
||||
- `ui.dates` (global, or in a profile's `ui`): date order (`YMD`, `MDY`, `DMY`), `clock` (`24h`, `12h`),
|
||||
`weekStart`, `weekNumbers`, `pickerOnEnter`, set with `:dates`. Without them the clock and the first day
|
||||
of the week follow the locale (`LC_ALL`, `LC_TIME`, `LANG`), else 24-hour and Monday; the order is ISO.
|
||||
@@ -606,7 +637,7 @@ global choice. `--ascii` replaces box drawing and symbols. Colours come only fro
|
||||
|
||||
The CLI equivalent dialog copies its command with `y` (or Enter); F9 › Settings can copy it automatically, and Shift+drag selects it with the terminal.
|
||||
|
||||
F9 › Settings also holds the mouse on/off, single-key shortcuts on/off (default: the server preference
|
||||
F9 › Settings also holds the mouse on/off, the splash on start on/off, single-key shortcuts on/off (default: the server preference
|
||||
`keyboard.singleKeyShortcuts`), the download and upload folders, the PIN and idle lock, and the terminal check.
|
||||
|
||||
## Keys that your terminal may intercept
|
||||
@@ -640,10 +671,11 @@ before any program sees it. `qqq ui --check` (and the quick check at startup) fi
|
||||
```sh
|
||||
cd packages/tui && bun test # or npm run test:tui at the root: the whole TUI against a fake server in memory
|
||||
packages/tui/scripts/smoke.sh # tmux against a running server (profile "sample"); captures kept
|
||||
# --server URL: an isolated config with two profiles, through the start-up picker
|
||||
packages/tui/scripts/mouse-smoke.sh # the mouse in tmux: SGR 1006 clicks, drags and wheel written into the pane
|
||||
packages/tui/scripts/smoke-widths.sh # fixed grid widths across pages (fake large-instance server)
|
||||
packages/tui/scripts/smoke-sql.sh [server] # SQL Runner at 80x24 and 120x36 (default the enterprise server on 8078)
|
||||
packages/tui/scripts/overflow-sweep.sh [server] # every screen and dialog at 80/100/120/200 x 24/50: no text past a border
|
||||
packages/tui/scripts/overflow-sweep.sh [server] # every screen and dialog (the splash, its picker, failure and add form too) at 80/100/120/200 x 24/50: no text past a border
|
||||
packages/tui/scripts/perf-large-table.sh # PRF-001: 10,000 rows x 40 columns, timings against a 2 s budget
|
||||
packages/tui/scripts/soak.sh [server] [minutes] # random keys for 10 min; fails on an exit or an error boundary
|
||||
```
|
||||
|
||||
@@ -65,9 +65,22 @@ shot() {
|
||||
if [[ $bad == 0 ]]; then echo "ok $name"; else fails=$((fails + 1)); fi
|
||||
}
|
||||
cmd() { keys T:: "T:$1" Enter; }
|
||||
# start_splash NAME CONFIG-JSON: a fresh TUI on its own config, no --server (the start-up splash, picker or add form)
|
||||
start_splash() {
|
||||
tmux kill-session -t "$s" 2>/dev/null || true
|
||||
mkdir -p "$cfg/$1/config"
|
||||
printf '%s\n' "$2" > "$cfg/$1/config/config.json"
|
||||
tmux new-session -d -s "$s" -x 120 -y 36 "cd '$pkg' && QQQ_CONFIG_DIR='$cfg/$1/config' QQQ_STATE_DIR='$cfg/$1/state' QQQ_KEYRING=file QQQ_CACHE_DIR='$cfg/$1/cache' bun run src/main.tsx --no-check; sleep 30"
|
||||
tmux set-option -t "$s" window-size manual > /dev/null
|
||||
}
|
||||
long="$(printf 'Long value %.0s' $(seq 1 30))"
|
||||
shots=0
|
||||
|
||||
# the start-up splash: the picker (two profiles), a failed connection (retry / choose another), the add form (no profile)
|
||||
start_splash picker "{\"profiles\":{\"sweep\":{\"server\":\"$server\",\"description\":\"the sweep server\"},\"other\":{\"server\":\"$server\"}}}"; shot 00a-splash-picker 'Connect to a server' 'Terminal client for QQQ'
|
||||
start_splash failed '{"profiles":{"down":{"server":"http://127.0.0.1:9"}}}'; shot 00b-splash-failed 'Enter retries' 'Terminal client for QQQ'
|
||||
start_splash add '{"profiles":{}}'; shot 00c-splash-add 'Add a server profile'
|
||||
keys Escape; shot 00d-splash-no-profile 'n adds a server profile'
|
||||
start; shot 01-home 'Quick actions' 'Recently viewed'
|
||||
keys T:?; shot 02-help 'Keys'
|
||||
start; keys T:/; shot 03-search 'Search'
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
# packages/tui/scripts/smoke.sh [profile] a profile of your own config
|
||||
# packages/tui/scripts/smoke.sh --server URL [--profile NAME] any server, in an isolated config
|
||||
# (MOCK or anonymous sign-in; your
|
||||
# config, keyring and cache untouched)
|
||||
# config, keyring and cache untouched;
|
||||
# two profiles, so it starts at the
|
||||
# start-up picker and connects with Enter)
|
||||
# OUT=dir WIDTH=160 HEIGHT=45
|
||||
set -euo pipefail
|
||||
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -26,11 +28,12 @@ done
|
||||
out="${OUT:-$(mktemp -d)}"
|
||||
env_prefix=""
|
||||
if [[ -n "$server" ]]; then
|
||||
# an isolated config with one profile for the server, so the TUI signs in there instead of showing the picker
|
||||
# an isolated config with two profiles: the start-up picker opens with this one (the last used) preselected
|
||||
profile="${profile:-smoke}"
|
||||
cfg="$(mktemp -d)"
|
||||
mkdir -p "$cfg/config"
|
||||
printf '{"profiles":{"%s":{"server":"%s"}},"defaultProfile":"%s"}\n' "$profile" "${server%/}" "$profile" > "$cfg/config/config.json"
|
||||
now="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
printf '{"profiles":{"%s":{"server":"%s","lastUsedAt":"%s"},"smoke-other":{"server":"%s","lastUsedAt":"2020-01-01T00:00:00Z"}},"defaultProfile":"smoke-other"}\n' "$profile" "${server%/}" "$now" "${server%/}" > "$cfg/config/config.json"
|
||||
env_prefix="QQQ_CONFIG_DIR='$cfg/config' QQQ_CACHE_DIR='$cfg/cache' QQQ_STATE_DIR='$cfg/state' QQQ_KEYRING=file"
|
||||
fi
|
||||
profile="${profile:-sample}"
|
||||
@@ -54,7 +57,13 @@ expect() { # name text...
|
||||
echo "FAIL $name (wanted: $*)"; fails=$((fails + 1))
|
||||
}
|
||||
echo "smoke: profile $profile${server:+ at $server (isolated config)}; captures in $out"
|
||||
tmux new-session -d -s "$s" -x "$w" -y "$h" "cd '$pkg' && $env_prefix bun run src/main.tsx --no-check --profile '$profile'; sleep 5"
|
||||
if [[ -n "$server" ]]; then
|
||||
tmux new-session -d -s "$s" -x "$w" -y "$h" "cd '$pkg' && $env_prefix bun run src/main.tsx --no-check; sleep 5"
|
||||
expect 00-picker 'Connect to a server' 'Enter connects' 'Esc quits' 'Terminal client for QQQ' "$profile"
|
||||
keys Enter
|
||||
else
|
||||
tmux new-session -d -s "$s" -x "$w" -y "$h" "cd '$pkg' && $env_prefix bun run src/main.tsx --no-check --profile '$profile'; sleep 5"
|
||||
fi
|
||||
expect 01-home 'Apps' 'UI API' 'Recently viewed' 'Quick actions'
|
||||
keys T:: 'T:table query person' Enter
|
||||
expect 02-grid 'records ·' 'First Name'
|
||||
|
||||
@@ -143,6 +143,19 @@ export function App({ store }: { store: AppStore }) {
|
||||
const contentWidth = w - sidebarWidth - assistantWidth
|
||||
const screen = store.screen
|
||||
const top = store.overlays[store.overlays.length - 1]
|
||||
const overlays = store.overlays.map((o, i) => <MouseLayer key={o.id} layer={i + 1}><ErrorBoundary resetKey={o.id} width={Math.min(60, w)} label="Dialog">{o.render(w, h)}</ErrorBoundary></MouseLayer>)
|
||||
if (screen?.kind === 'splash') {
|
||||
// the start-up splash has the whole terminal: no title bar, status line or key bar yet
|
||||
return (
|
||||
<MouseRoot dispatcher={mouse} press={store.pressKey}>
|
||||
<box flexDirection="column" overflow="hidden" width={w} height={h}>
|
||||
<ErrorBoundary resetKey={screen.id} width={w} label="Splash">{screen.render(w, h, !top)}</ErrorBoundary>
|
||||
{overlays}
|
||||
<MouseCapture width={w} height={h} />
|
||||
</box>
|
||||
</MouseRoot>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<MouseRoot dispatcher={mouse} press={store.pressKey}>
|
||||
<box flexDirection="column" overflow="hidden" width={w} height={h}>
|
||||
@@ -161,7 +174,7 @@ export function App({ store }: { store: AppStore }) {
|
||||
<StatusLine store={store} width={w} />
|
||||
{rows === 1 && store.shiftRowShown() ? <ShiftRow store={store} width={w} slots={store.fkeys().length} /> : <FKeyBar store={store} width={w} />}
|
||||
{rows === 2 && <ShiftRow store={store} width={w} slots={store.fkeys().length} />}
|
||||
{store.overlays.map((o, i) => <MouseLayer key={o.id} layer={i + 1}><ErrorBoundary resetKey={o.id} width={Math.min(60, w)} label="Dialog">{o.render(w, h)}</ErrorBoundary></MouseLayer>)}
|
||||
{overlays}
|
||||
{store.lock && <ErrorBoundary key="lock" resetKey="lock" width={w} label="Lock">{store.lock.render(w, h)}</ErrorBoundary>}
|
||||
<MouseCapture width={w} height={h} />
|
||||
</box>
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/**
|
||||
* What `qqq ui` does first, decided from the options, the environment and config.json before
|
||||
* anything is drawn: connect to one profile, ask which profile (several saved, none named), or add
|
||||
* the first one (none saved). Also the Splash setting (`ui.splash`: on unless off) and its timing.
|
||||
*/
|
||||
|
||||
import { readConfig, resolveProfile, writeConfig, type Config } from '@qrun-io/qqq-core'
|
||||
import type { Target } from './store'
|
||||
|
||||
/** The splash stays up at least this long (ms) so it never just flashes. */
|
||||
export const SPLASH_MIN_MS = 600
|
||||
|
||||
export type BootPlan =
|
||||
/** A profile was named (--profile, QQQ_PROFILE, --server, QQQ_SERVER) or only one exists. */
|
||||
| { kind: 'connect'; target: Target }
|
||||
/** Several profiles and none named: the Servers picker, `select` preselected (the last used). */
|
||||
| { kind: 'pick'; select?: string; note?: string }
|
||||
/** No profile at all: the add form. */
|
||||
| { kind: 'add'; note?: string }
|
||||
|
||||
/** Whether a profile was named for this run (an option or the environment). */
|
||||
export function namedProfile(args: { profile?: string; server?: string }, env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return Boolean(args.profile || args.server || env.QQQ_PROFILE || env.QQQ_SERVER)
|
||||
}
|
||||
|
||||
/** The most recently used profile (config lastUsedAt), else the default, else the first. */
|
||||
export function lastUsedProfile(config: Config): string | undefined {
|
||||
const names = Object.keys(config.profiles)
|
||||
let best: string | undefined
|
||||
let bestAt = -Infinity
|
||||
for (const n of names) {
|
||||
const at = Date.parse(config.profiles[n]!.lastUsedAt ?? '')
|
||||
if (!Number.isNaN(at) && at > bestAt) {
|
||||
best = n
|
||||
bestAt = at
|
||||
}
|
||||
}
|
||||
return best ?? (config.defaultProfile && config.profiles[config.defaultProfile] ? config.defaultProfile : names[0])
|
||||
}
|
||||
|
||||
/** Decides the first step of a run (pure: config and environment are passed in by tests). */
|
||||
export function planBoot(args: { profile?: string; server?: string }, env: NodeJS.ProcessEnv = process.env, config: Config = readConfig(env)): BootPlan {
|
||||
const names = Object.keys(config.profiles)
|
||||
if (!namedProfile(args, env) && names.length === 1) return { kind: 'connect', target: { name: names[0]!, server: config.profiles[names[0]!]!.server } }
|
||||
if (namedProfile(args, env)) {
|
||||
try {
|
||||
const r = resolveProfile({ profile: args.profile, server: args.server }, env, config)
|
||||
return { kind: 'connect', target: { name: r.name, server: r.server } }
|
||||
} catch (e) {
|
||||
// a name that does not exist: choose from what there is (or add the first)
|
||||
const note = e instanceof Error ? e.message : String(e)
|
||||
return names.length ? { kind: 'pick', select: lastUsedProfile(config), note } : { kind: 'add', note }
|
||||
}
|
||||
}
|
||||
if (!names.length) return { kind: 'add' }
|
||||
return { kind: 'pick', select: lastUsedProfile(config) }
|
||||
}
|
||||
|
||||
/** The Splash setting: on unless `ui.splash` is off (or false). */
|
||||
export function readSplash(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
try {
|
||||
const v = (readConfig(env).ui as { splash?: unknown } | undefined)?.splash
|
||||
return !(v === false || v === 'off')
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSplash(on: boolean, env: NodeJS.ProcessEnv = process.env): void {
|
||||
const config = readConfig(env)
|
||||
config.ui = { ...config.ui, splash: on ? 'on' : 'off' } as never
|
||||
writeConfig(config, env)
|
||||
}
|
||||
|
||||
/** Whether this run shows the splash: --no-splash (or QQQ_UI_NO_SPLASH=1) and the setting turn it off. */
|
||||
export function splashEnabled(args: { splash?: boolean }, env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
if (args.splash !== undefined) return args.splash
|
||||
if (env.QQQ_UI_NO_SPLASH === '1') return false
|
||||
return readSplash(env)
|
||||
}
|
||||
|
||||
/** How long to wait before leaving the splash, given when it was shown. */
|
||||
export function splashWait(shownAt: number, now: number, minMs = SPLASH_MIN_MS): number {
|
||||
return Math.max(0, shownAt + minMs - now)
|
||||
}
|
||||
|
||||
/** The one line printed when the picker is closed without choosing. */
|
||||
export const PICK_HINT = 'qqq ui: no profile chosen; --profile NAME (or QQQ_PROFILE) skips the picker'
|
||||
|
||||
/** Settings > Splash on start and `:splash on|off`: saved for the next start. */
|
||||
export function setSplashSetting(store: { flash: (text: string) => void; error: (e: unknown) => void }, on: boolean): void {
|
||||
try {
|
||||
writeSplash(on)
|
||||
} catch (e) {
|
||||
store.error(e)
|
||||
return
|
||||
}
|
||||
store.flash(on ? 'splash on (saved): the next qqq ui starts on the splash' : 'splash off (saved): the next qqq ui starts without it (--splash shows it once)')
|
||||
}
|
||||
+42
-10
@@ -5,11 +5,15 @@
|
||||
|
||||
/** Builds the store for a run: settings from options, config and environment; sign-in; the first route. */
|
||||
|
||||
import { readConfig, readTheme, resolveProfile, writeTheme, defaultSecretStore, type SecretStore } from '@qrun-io/qqq-core'
|
||||
import { probeServer, readConfig, readTheme, writeTheme, defaultSecretStore, type SecretStore } from '@qrun-io/qqq-core'
|
||||
import { PICK_HINT, SPLASH_MIN_MS, planBoot, splashEnabled, type BootPlan } from './boot-plan'
|
||||
import { installCommands } from './commands'
|
||||
import { readMouse } from './mouse/settings'
|
||||
import { readFKeyBar } from './keys/shift-bank'
|
||||
import { ProfileFormOverlay, ServersOverlay } from './overlays/servers'
|
||||
import { ServersModel } from './servers'
|
||||
import { SignInScreen, type SignInDeps } from './screens/signin'
|
||||
import { SplashScreen } from './screens/splash'
|
||||
import { Sidebar } from './screens/sidebar'
|
||||
import { AppStore, setSecretStoreFactory, type Target } from './store'
|
||||
import { DEFAULT_THEME, THEMES } from './theme'
|
||||
@@ -22,6 +26,8 @@ export interface CliArgs {
|
||||
linear: boolean
|
||||
/** --mouse / --no-mouse for this run; unset: the saved Mouse setting (on by default). */
|
||||
mouse?: boolean
|
||||
/** --splash / --no-splash for this run; unset: the Splash setting (ui.splash, on by default). */
|
||||
splash?: boolean
|
||||
check: 'startup' | 'full' | 'off'
|
||||
route: string[]
|
||||
}
|
||||
@@ -41,6 +47,8 @@ export function parseArgs(argv: string[], env: NodeJS.ProcessEnv = process.env):
|
||||
else if (a === '--linear') out.linear = true
|
||||
else if (a === '--mouse') out.mouse = true
|
||||
else if (a === '--no-mouse') out.mouse = false
|
||||
else if (a === '--splash') out.splash = true
|
||||
else if (a === '--no-splash') out.splash = false
|
||||
else if (a === '--check') out.check = 'full'
|
||||
else if (a === '--no-check') out.check = 'off'
|
||||
else out.route.push(a)
|
||||
@@ -55,19 +63,23 @@ export interface BootOptions {
|
||||
secrets?: () => SecretStore
|
||||
fetch?: typeof fetch
|
||||
timers?: boolean
|
||||
/** Shows the splash; unset: --no-splash, QQQ_UI_NO_SPLASH=1 and the Splash setting decide. */
|
||||
splash?: boolean
|
||||
/** The splash's minimum display time; unset: 600 ms with timers (a real run), else none. */
|
||||
splashMinMs?: number
|
||||
/** Prints a line once the terminal is restored (the picker closed without a choice). */
|
||||
hint?: (text: string) => void
|
||||
}
|
||||
|
||||
/** Creates the store and starts sign-in (or connects straight away with a stored sign-in). */
|
||||
/**
|
||||
* Creates the store and starts the boot: the splash (unless switched off), then the profile named
|
||||
* or the only one; with several the Servers picker, with none the add form (boot-plan.ts).
|
||||
*/
|
||||
export function boot(options: BootOptions): AppStore {
|
||||
const { args } = options
|
||||
let target: Target | undefined
|
||||
let note: string | undefined
|
||||
try {
|
||||
const r = resolveProfile({ profile: args.profile, server: args.server })
|
||||
target = { name: r.name, server: r.server }
|
||||
} catch (e) {
|
||||
note = e instanceof Error ? e.message : String(e)
|
||||
}
|
||||
const plan: BootPlan = planBoot(args)
|
||||
const target: Target | undefined = plan.kind === 'connect' ? plan.target : undefined
|
||||
let note = plan.kind === 'connect' ? undefined : plan.note
|
||||
if (args.theme) {
|
||||
if (THEMES[args.theme]) writeTheme(args.theme)
|
||||
else note = `unknown theme "${args.theme}"; themes: ${Object.keys(THEMES).join(', ')}`
|
||||
@@ -93,9 +105,29 @@ export function boot(options: BootOptions): AppStore {
|
||||
},
|
||||
}
|
||||
store.signInDeps = deps
|
||||
const exit = (hint: string) => {
|
||||
store.quit()
|
||||
if (hint) options.hint?.(hint)
|
||||
}
|
||||
if (options.splash ?? splashEnabled(args)) {
|
||||
const splash = new SplashScreen(store, { plan, deps, exit, minMs: options.splashMinMs ?? (options.timers ? SPLASH_MIN_MS : 0), animate: Boolean(options.timers) })
|
||||
store.push(splash)
|
||||
splash.start()
|
||||
return store
|
||||
}
|
||||
// --no-splash: the sign-in screen, with the same first step
|
||||
const screen = new SignInScreen(store, deps, target, note)
|
||||
store.push(screen)
|
||||
if (target) screen.choose(target)
|
||||
else {
|
||||
const model = new ServersModel({ secrets: secretsFn, probe: (server) => probeServer(server, { fetch: options.fetch }), onChange: () => store.bump() })
|
||||
if (plan.kind === 'pick') store.open(new ServersOverlay(store, (row) => screen.choose({ name: row.name, server: row.server }), model, { title: 'Connect to a server', enterLabel: 'Enter connects', escLabel: 'Esc quits', select: plan.select, onEscape: () => exit(PICK_HINT) }))
|
||||
else store.open(new ProfileFormOverlay(store, model, undefined, (name) => {
|
||||
const p = readConfig().profiles[name]
|
||||
screen.build()
|
||||
if (p) screen.choose({ name, server: p.server })
|
||||
}))
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
import { createProgram, runCli } from '@qrun-io/qqq-cli/program'
|
||||
import { setMouseSetting } from './mouse/settings'
|
||||
import { readSplash, setSplashSetting } from './boot-plan'
|
||||
import { THEMES } from './theme'
|
||||
import { metaHelp, processHelp, tableHelp, applyLiveFix, checkEnvironment, formatReport, buildFilter, readFilePreferences, startDirForSave, startDirForUpload, writeFilePreferences, hasPin, readConfig, readRecents, removePin, setPin, writeConfig, writeIdleMinutes, writeTheme, readIdleMinutes, type QueryFilter } from '@qrun-io/qqq-core'
|
||||
import { openApp, openForm, openProcess, openRecord, openTable } from './nav'
|
||||
@@ -54,7 +55,7 @@ const NOUNS: Record<string, string[]> = {
|
||||
apps: [], whoami: [], search: [], 'help-search': [],
|
||||
}
|
||||
|
||||
const TUI_COMMANDS = ['quit', 'home', 'reset', 'tags', 'audits', 'dev', 'actions', 'back', 'help', 'menu', 'goto', 'view', 'theme', 'ascii', 'linear', 'mouse', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'ask', 'dates', 'history', 'fkeys']
|
||||
const TUI_COMMANDS = ['quit', 'home', 'reset', 'tags', 'audits', 'dev', 'actions', 'back', 'help', 'menu', 'goto', 'view', 'theme', 'ascii', 'linear', 'mouse', 'splash', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'ask', 'dates', 'history', 'fkeys']
|
||||
|
||||
/** One line on each TUI command, for the `:` line's completion popup. */
|
||||
export const TUI_COMMAND_HELP: Record<string, string> = {
|
||||
@@ -357,6 +358,7 @@ export class Commands implements CommandsLike {
|
||||
case 'linear': s.settings.linear = rest[0] !== 'off'; s.bump(); return s.flash(`linear layout ${s.settings.linear ? 'on' : 'off'}`)
|
||||
case 'shortcuts': return this.setShortcuts(rest[0] !== 'off')
|
||||
case 'mouse': return setMouseSetting(s, rest[0] === undefined ? !s.settings.mouse : rest[0] !== 'off')
|
||||
case 'splash': return setSplashSetting(s, rest[0] === undefined ? !readSplash() : rest[0] !== 'off')
|
||||
case 'idle': {
|
||||
const m = Number(rest[0])
|
||||
if (!Number.isFinite(m)) return fail('idle <minutes>: 0 turns the screen saver timeout off')
|
||||
@@ -764,6 +766,8 @@ export class Commands implements CommandsLike {
|
||||
out.push({ section: 'Settings', label: `Linear layout: ${s.settings.linear ? 'on' : 'off'}`, command: `linear ${s.settings.linear ? 'off' : 'on'}`, run: () => { s.settings.linear = !s.settings.linear; s.bump() } })
|
||||
out.push({ id: 'settings:mouse', section: 'Settings', label: `Mouse: ${s.settings.mouse ? 'on' : 'off'}`, command: `mouse ${s.settings.mouse ? 'off' : 'on'}`, detail: s.settings.mouse ? 'off restores plain terminal text selection' : undefined, run: () => setMouseSetting(s, !s.settings.mouse) })
|
||||
out.push({ id: 'settings:fkeys', section: 'Settings', label: `F-key bar: ${s.settings.fkeyBar ?? 'auto'}`, command: 'fkeys one|two|auto', detail: describeFKeyBar(s.settings.fkeyBar ?? 'auto'), run: () => s.open(new ChoiceOverlay<FKeyBarMode>(s, 'F-key bar', FKEY_BAR_MODES.map((m) => ({ label: m, value: m, detail: describeFKeyBar(m) })), (m) => setFKeyBar(s, m), 'Enter chooses')) })
|
||||
const splash = readSplash()
|
||||
out.push({ id: 'settings:splash', section: 'Settings', label: `Splash on start: ${splash ? 'on' : 'off'}`, command: `splash ${splash ? 'off' : 'on'}`, detail: 'Query and the boot steps while qqq ui starts', run: () => setSplashSetting(s, !splash) })
|
||||
out.push({ section: 'Settings', label: `ASCII glyphs: ${s.settings.ascii ? 'on' : 'off'}`, command: `ascii ${s.settings.ascii ? 'off' : 'on'}`, run: () => s.setAscii(!s.settings.ascii) })
|
||||
out.push({ section: 'Settings', label: 'Screen saver / lock', command: 'lock', run: () => this.lock() })
|
||||
let idle = 0
|
||||
|
||||
@@ -30,7 +30,7 @@ function sameAction(command: string | undefined): string | undefined {
|
||||
* `:` commands the TUI does itself (theme, lock, the Servers dialog …): logged as `:…`, re-run on
|
||||
* the `:` line only. Everything else on the `:` line is a CLI command (`qqq …`).
|
||||
*/
|
||||
const TUI_ONLY = new Set(['quit', 'q', 'exit', 'home', 'reset', 'tags', 'audits', 'audit', 'dev', 'developer', 'actions', 'back', 'help', 'menu', 'goto', 'palette', 'view', 'theme', 'ascii', 'linear', 'mouse', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'dates', 'helpkeys', 'views', 'share', 'doctor', 'check', 'history'])
|
||||
const TUI_ONLY = new Set(['quit', 'q', 'exit', 'home', 'reset', 'tags', 'audits', 'audit', 'dev', 'developer', 'actions', 'back', 'help', 'menu', 'goto', 'palette', 'view', 'theme', 'ascii', 'linear', 'mouse', 'splash', 'shortcuts', 'lock', 'saver', 'pin', 'idle', 'refresh', 'filter', 'export', 'jobs', 'printjobs', 'print', 'profile', 'servers', 'server', 'signout', 'connection', 'open', 'record', 'app', 'sql', 'dates', 'helpkeys', 'views', 'share', 'doctor', 'check', 'history'])
|
||||
|
||||
let last: { action?: string; at: number; words?: string[] } = { at: 0 }
|
||||
|
||||
|
||||
@@ -77,8 +77,14 @@ renderer.on('destroy', () => {
|
||||
setTimeout(() => process.exit(0), 10)
|
||||
})
|
||||
|
||||
// a line for the normal screen once the alternate screen is gone (the picker closed without a choice)
|
||||
let exitHint: string | undefined
|
||||
process.on('exit', () => {
|
||||
if (exitHint) process.stderr.write(`${exitHint}\n`)
|
||||
})
|
||||
|
||||
// QQQ_UI_NO_QUIT=1 (the soak test): quitting from a menu is ignored, so only a crash ends the program
|
||||
const store = boot({ args, quit: process.env.QQQ_UI_NO_QUIT === '1' ? () => store.flash('quit is off (QQQ_UI_NO_QUIT)') : restore, build: process.env.QQQ_UI_BUILD ?? 'dev', timers: true })
|
||||
const store = boot({ args, quit: process.env.QQQ_UI_NO_QUIT === '1' ? () => store.flash('quit is off (QQQ_UI_NO_QUIT)') : restore, build: process.env.QQQ_UI_BUILD ?? 'dev', timers: true, hint: (text) => { exitHint = text } })
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
if (destroyed && isShutdownNoise([reason])) return
|
||||
store.error(reason)
|
||||
|
||||
@@ -33,6 +33,21 @@ function hint(tokens: KeyToken[], width: number): string {
|
||||
/** Command keys while the filter is empty; any other character starts the filter (/ starts it too). */
|
||||
const COMMAND_KEYS = new Set(['n', 'e', 'd', '*', 'r', '/'])
|
||||
|
||||
/** The dialog as the start-up profile picker (over the splash): its title, Enter and Esc, the preselected row. */
|
||||
export interface ServersOverlayOptions {
|
||||
title?: string
|
||||
/** The Enter hint (`Enter connects`). */
|
||||
enterLabel?: string
|
||||
/** The Esc hint (`Esc quits`). */
|
||||
escLabel?: string
|
||||
/** Esc with an empty filter: instead of closing. */
|
||||
onEscape?: () => void
|
||||
/** The profile the cursor starts on (else the one in use). */
|
||||
select?: string
|
||||
/** The frame's first row for a screen height (below the splash's header), and only as tall as its rows need. */
|
||||
top?: (screenHeight: number) => number
|
||||
}
|
||||
|
||||
export class ServersOverlay implements Overlay {
|
||||
readonly id = overlayId('servers')
|
||||
readonly filter = new LineEditor('')
|
||||
@@ -45,9 +60,9 @@ export class ServersOverlay implements Overlay {
|
||||
/** `/` was pressed: every character goes to the filter. */
|
||||
private filtering = false
|
||||
|
||||
constructor(private store: AppStore, private switchTo: (row: ServerRow) => void, model?: ServersModel) {
|
||||
constructor(private store: AppStore, private switchTo: (row: ServerRow) => void, model?: ServersModel, readonly options: ServersOverlayOptions = {}) {
|
||||
this.model = model ?? new ServersModel({ current: store.target?.name, secrets: store.signInDeps?.secrets, probe: (server) => probeServer(server, { fetch: store.signInDeps?.fetch }), onChange: () => store.bump() })
|
||||
const at = this.model.rows.findIndex((r) => r.isCurrent)
|
||||
const at = options.select !== undefined && this.model.rows.some((r) => r.name === options.select) ? this.model.rows.findIndex((r) => r.name === options.select) : this.model.rows.findIndex((r) => r.isCurrent)
|
||||
this.index = Math.max(0, at)
|
||||
void this.model.probeAll()
|
||||
void this.model.readSignIns()
|
||||
@@ -81,7 +96,8 @@ export class ServersOverlay implements Overlay {
|
||||
this.filter.set('')
|
||||
this.filtering = false
|
||||
this.index = 0
|
||||
} else this.store.closeOverlay(this)
|
||||
} else if (this.options.onEscape) this.options.onEscape()
|
||||
else this.store.closeOverlay(this)
|
||||
} else if (key.name === 'down') this.index = Math.min(Math.max(0, list.length - 1), this.index + 1)
|
||||
else if (key.name === 'up') this.index = Math.max(0, this.index - 1)
|
||||
else if (key.name === 'pagedown') this.index = Math.min(Math.max(0, list.length - 1), this.index + 5)
|
||||
@@ -152,7 +168,9 @@ export class ServersOverlay implements Overlay {
|
||||
const t = theme()
|
||||
const g = glyphs()
|
||||
const width = Math.max(30, Math.min(sw - 2, 120))
|
||||
const height = Math.max(10, Math.min(sh - 2, 30))
|
||||
const top = this.options.top ? this.options.top(sh) : 1
|
||||
// under the splash header: as tall as the profiles need, else up to the screen's foot
|
||||
const height = this.options.top ? Math.max(10, Math.min(sh - top - 1, 30, this.model.rows.length * 2 + 6)) : Math.max(10, Math.min(sh - 2, 30))
|
||||
const inner = width - 2
|
||||
const list = this.list()
|
||||
if (this.index >= list.length) this.index = Math.max(0, list.length - 1)
|
||||
@@ -186,12 +204,13 @@ export class ServersOverlay implements Overlay {
|
||||
const more = list.length > visibleRows ? ` ${this.scroll > 0 ? g.up : ' '}${this.scroll + visibleRows < list.length ? g.down : ' '}` : ''
|
||||
const header = ` ${pad('Name', nameW)}${pad('Short', shortW)}${pad('Server', serverW)}${pad('Status', statusW)}${pad('Last used', lastW)}`
|
||||
const commands = !this.filter.value && !this.filtering
|
||||
const enter = this.options.enterLabel ?? 'Enter switches'
|
||||
const keys = commands
|
||||
? [{ text: 'Enter switches' }, { text: 'n add' }, { text: 'e edit' }, { text: 'd remove', drop: 1 }, { text: '* default', drop: 1 }, { text: 'r probe', drop: 3 }, { text: 'type to filter', drop: 2 }, { text: 'Esc closes' }]
|
||||
: [{ text: `${list.length} of ${this.model.rows.length}` }, { text: 'Enter switches' }, { text: 'F7 add', drop: 2 }, { text: 'F4 edit', drop: 2 }, { text: 'F8 remove', drop: 3 }, { text: 'Esc clears' }]
|
||||
? [{ text: enter }, { text: 'n add' }, { text: 'e edit' }, { text: 'd remove', drop: 1 }, { text: '* default', drop: 1 }, { text: 'r probe', drop: 3 }, { text: 'type to filter', drop: 2 }, { text: this.options.escLabel ?? 'Esc closes' }]
|
||||
: [{ text: `${list.length} of ${this.model.rows.length}` }, { text: enter }, { text: 'F7 add', drop: 2 }, { text: 'F4 edit', drop: 2 }, { text: 'F8 remove', drop: 3 }, { text: 'Esc clears' }]
|
||||
const legend = `* default ${g.mark} in use`
|
||||
return (
|
||||
<Frame title="Servers (Ctrl+O)" width={width} height={height} screenWidth={sw} screenHeight={sh} top={1}>
|
||||
<Frame title={this.options.title ?? 'Servers (Ctrl+O)'} width={width} height={height} screenWidth={sw} screenHeight={sh} top={top}>
|
||||
<box paddingLeft={1}><EditorLine editor={this.filter} width={inner - 2} focused={!commands || undefined} placeholder="type to filter by name, short name, description or server · / then any letter" /></box>
|
||||
<Line text={pad(header, inner - more.length) + more} width={inner} fg={t.acc} />
|
||||
{lines}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -50,7 +50,8 @@ export interface Harness {
|
||||
/** Renders the whole TUI against the fake server and waits for the home screen. */
|
||||
export async function startTui(width = 120, height = 36, options: { metaData?: unknown; auth?: FakeAuth; secrets?: () => SecretStore; until?: 'home' | 'signin' } = {}): Promise<Harness> {
|
||||
const log: FakeLog = { requests: [] }
|
||||
const store = boot({ args: parseArgs(['--profile', 'fake']), quit: () => undefined, build: 'test', secrets: options.secrets ?? sessionSecrets, fetch: fakeFetch(log, options.metaData, options.auth) })
|
||||
// the sign-in tests drive the sign-in screen (--no-splash); the rest go through the splash
|
||||
const store = boot({ args: parseArgs(['--profile', 'fake', ...(options.until === 'signin' ? ['--no-splash'] : [])]), quit: () => undefined, build: 'test', secrets: options.secrets ?? sessionSecrets, fetch: fakeFetch(log, options.metaData, options.auth) })
|
||||
const setup = await testRender(<App store={store} />, { width, height })
|
||||
const h: Harness = { setup, store, log, frame: () => setup.captureCharFrame() }
|
||||
store.captureScreen = () => setup.captureCharFrame()
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
* Copyright 2026 QRun.IO, Inc.
|
||||
* Licensed under the Apache License, Version 2.0 (see LICENSE).
|
||||
*/
|
||||
|
||||
/** The start-up splash and profile picker: the boot plan, the picker (preselect, Enter, Esc), the add form, timing, ASCII, failures. */
|
||||
|
||||
import { afterEach, beforeAll, expect, test } from 'bun:test'
|
||||
import { testRender } from '@opentui/react/test-utils'
|
||||
import { readConfig, writeConfig, type Config } from '@qrun-io/qqq-core'
|
||||
import { App } from '../src/app'
|
||||
import { boot, parseArgs } from '../src/boot'
|
||||
import { PICK_HINT, SPLASH_MIN_MS, lastUsedProfile, planBoot, readSplash, splashEnabled, splashWait, writeSplash } from '../src/boot-plan'
|
||||
import { ProfileFormOverlay, ServersOverlay } from '../src/overlays/servers'
|
||||
import { SplashScreen, splashLayout, splashText, versionLine, type SplashLayoutInput } from '../src/screens/splash'
|
||||
import { fakeFetch, type FakeLog } from './fake-server'
|
||||
import { command, isolate, press, sessionSecrets, waitFor, type Harness } from './helpers'
|
||||
import { querySize } from '../src/saver/query-art'
|
||||
import { setAscii } from '../src/theme'
|
||||
import { cellWidth } from '../src/util/text'
|
||||
|
||||
let h: Harness | undefined
|
||||
let exits: string[] = []
|
||||
let quits = 0
|
||||
|
||||
beforeAll(() => isolate())
|
||||
afterEach(() => {
|
||||
h?.store.dispose()
|
||||
h?.setup.renderer.destroy()
|
||||
h = undefined
|
||||
exits = []
|
||||
quits = 0
|
||||
setAscii(false)
|
||||
})
|
||||
|
||||
const hour = 3_600_000
|
||||
function profiles(map: Record<string, { server: string; lastUsedAt?: string }>, defaultProfile?: string): void {
|
||||
writeConfig({ profiles: map, ...(defaultProfile ? { defaultProfile } : {}) } as Config)
|
||||
}
|
||||
|
||||
function two(): void {
|
||||
profiles({
|
||||
alpha: { server: 'http://alpha.test', lastUsedAt: new Date(Date.now() - 5 * hour).toISOString() },
|
||||
beta: { server: 'http://beta.test', lastUsedAt: new Date(Date.now() - hour).toISOString() },
|
||||
}, 'alpha')
|
||||
}
|
||||
|
||||
async function start(argv: string[], options: { width?: number; height?: number; minMs?: number; fetch?: typeof fetch; log?: FakeLog } = {}): Promise<Harness> {
|
||||
const log = options.log ?? { requests: [] }
|
||||
const store = boot({ args: parseArgs(argv, {}), quit: () => { quits++ }, build: 'test', secrets: sessionSecrets, fetch: options.fetch ?? fakeFetch(log), splashMinMs: options.minMs, hint: (t) => exits.push(t) })
|
||||
const setup = await testRender(<App store={store} />, { width: options.width ?? 120, height: options.height ?? 40 })
|
||||
const harness: Harness = { setup, store, log, frame: () => setup.captureCharFrame() }
|
||||
store.captureScreen = () => setup.captureCharFrame()
|
||||
return harness
|
||||
}
|
||||
|
||||
test('the boot plan: a named profile or the only one connects; several ask (last used first); none adds', () => {
|
||||
const at = (n: number) => new Date(Date.now() - n * hour).toISOString()
|
||||
const several: Config = { profiles: { alpha: { server: 'http://a.test', lastUsedAt: at(5) }, beta: { server: 'http://b.test', lastUsedAt: at(1) }, gamma: { server: 'http://c.test' } }, defaultProfile: 'alpha' } as Config
|
||||
expect(planBoot({}, {}, several)).toEqual({ kind: 'pick', select: 'beta' })
|
||||
expect(planBoot({ profile: 'gamma' }, {}, several)).toEqual({ kind: 'connect', target: { name: 'gamma', server: 'http://c.test' } })
|
||||
expect(planBoot({}, { QQQ_PROFILE: 'alpha' }, several)).toEqual({ kind: 'connect', target: { name: 'alpha', server: 'http://a.test' } })
|
||||
expect(planBoot({ server: 'http://b.test/x' }, {}, several)).toEqual({ kind: 'connect', target: { name: 'beta', server: 'http://b.test' } })
|
||||
expect(planBoot({}, { QQQ_SERVER: 'http://new.test' }, several).kind).toBe('connect')
|
||||
const one: Config = { profiles: { solo: { server: 'http://solo.test' } } } as Config
|
||||
expect(planBoot({}, {}, one)).toEqual({ kind: 'connect', target: { name: 'solo', server: 'http://solo.test' } })
|
||||
expect(planBoot({}, {}, { profiles: {} } as Config)).toEqual({ kind: 'add' })
|
||||
// a name that does not exist: the picker (or the add form) with why
|
||||
const typo = planBoot({ profile: 'nope' }, {}, several)
|
||||
expect(typo.kind).toBe('pick')
|
||||
expect(typo.kind === 'pick' && typo.note).toMatch(/No profile "nope"/)
|
||||
expect(planBoot({ profile: 'nope' }, {}, { profiles: {} } as Config).kind).toBe('add')
|
||||
// never used: the default, else the first
|
||||
expect(lastUsedProfile({ profiles: { a: { server: 'x' }, b: { server: 'y' } }, defaultProfile: 'b' } as Config)).toBe('b')
|
||||
expect(lastUsedProfile({ profiles: { a: { server: 'x' }, b: { server: 'y' } } } as Config)).toBe('a')
|
||||
})
|
||||
|
||||
test('the splash switch: --no-splash, --splash, QQQ_UI_NO_SPLASH and the ui.splash setting', () => {
|
||||
expect(parseArgs(['--no-splash']).splash).toBe(false)
|
||||
expect(parseArgs(['--splash']).splash).toBe(true)
|
||||
expect(parseArgs([]).splash).toBeUndefined()
|
||||
expect(splashEnabled({ splash: false })).toBe(false)
|
||||
expect(splashEnabled({}, { ...process.env, QQQ_UI_NO_SPLASH: '1' })).toBe(false)
|
||||
expect(readSplash()).toBe(true)
|
||||
writeSplash(false)
|
||||
expect(readConfig().ui).toMatchObject({ splash: 'off' })
|
||||
expect(splashEnabled({})).toBe(false)
|
||||
expect(splashEnabled({ splash: true })).toBe(true)
|
||||
writeSplash(true)
|
||||
expect(splashEnabled({})).toBe(true)
|
||||
})
|
||||
|
||||
test('the minimum display time: never under 600 ms, nothing more once it has been up that long', async () => {
|
||||
expect(SPLASH_MIN_MS).toBe(600)
|
||||
expect(splashWait(1000, 1100)).toBe(500)
|
||||
expect(splashWait(1000, 1700)).toBe(0)
|
||||
profiles({ fake: { server: 'http://fake.test' } })
|
||||
const began = Date.now()
|
||||
h = await start(['--profile', 'fake'], { minMs: 600 })
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'splash')
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
|
||||
expect(Date.now() - began).toBeGreaterThanOrEqual(600)
|
||||
})
|
||||
|
||||
test('one profile: the splash shows Query, the wordmark, the version and the boot steps, then Home', async () => {
|
||||
profiles({ fake: { server: 'http://fake.test' } })
|
||||
h = await start([], { minMs: 400 })
|
||||
const f = await waitFor(h, (x) => x.includes('Terminal client for QQQ'))
|
||||
expect(h.store.screen?.kind).toBe('splash')
|
||||
expect(f).toContain(versionLine())
|
||||
expect(f).toMatch(/qqq \d+\.\d+\.\d+ · UI API client \d+\.\d+\.\d+/)
|
||||
expect(f).toContain('█')
|
||||
// no title bar, status line or key bar under the splash
|
||||
expect(f).not.toMatch(/10\s*Quit/)
|
||||
const steps = h.store.screen as SplashScreen
|
||||
await waitFor(h, () => /loaded metadata \(\d+ tables\)/.test(steps.status))
|
||||
await waitFor(h, (x) => h!.store.screen?.kind === 'home' && x.includes('Apps'), 4000)
|
||||
expect(h.store.overlays.length).toBe(0)
|
||||
})
|
||||
|
||||
test('several profiles: the picker over the splash, the last used preselected; Enter connects to it', async () => {
|
||||
two()
|
||||
h = await start([], { width: 120, height: 40 })
|
||||
const f = await waitFor(h, (x) => x.includes('Connect to a server'))
|
||||
const picker = h.store.overlays.at(-1) as ServersOverlay
|
||||
expect(picker).toBeInstanceOf(ServersOverlay)
|
||||
expect(picker.selected()?.name).toBe('beta')
|
||||
expect(f).toContain('Enter connects')
|
||||
expect(f).toContain('Esc quits')
|
||||
// the splash header stays visible above the picker
|
||||
expect(f).toContain('Terminal client for QQQ')
|
||||
// probes fill in on their own (the picker does not wait for them)
|
||||
await waitFor(h, (x) => x.includes('UI API'))
|
||||
await press(h, () => h!.setup.mockInput.pressEnter())
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
|
||||
expect(h.store.target?.name).toBe('beta')
|
||||
})
|
||||
|
||||
test('several profiles: Esc on the picker quits with the --profile hint', async () => {
|
||||
two()
|
||||
h = await start([])
|
||||
await waitFor(h, (x) => x.includes('Connect to a server'))
|
||||
await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
expect(quits).toBe(1)
|
||||
expect(exits).toEqual([PICK_HINT])
|
||||
expect(PICK_HINT).toContain('--profile')
|
||||
})
|
||||
|
||||
test('--profile skips the picker even with several profiles', async () => {
|
||||
two()
|
||||
h = await start(['--profile', 'alpha'])
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
|
||||
expect(h.store.target?.name).toBe('alpha')
|
||||
})
|
||||
|
||||
test('no profiles: the add form opens straight away; saving connects to the new profile', async () => {
|
||||
writeConfig({ profiles: {} } as Config)
|
||||
h = await start([])
|
||||
await waitFor(h, (x) => x.includes('Add a server profile'))
|
||||
const form = h.store.overlays.at(-1) as ProfileFormOverlay
|
||||
expect(form).toBeInstanceOf(ProfileFormOverlay)
|
||||
form.editors.name.set('local')
|
||||
form.editors.server.set('http://fake.test')
|
||||
await press(h, () => form.save(), 50)
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
|
||||
expect(h.store.target).toEqual({ name: 'local', server: 'http://fake.test' })
|
||||
})
|
||||
|
||||
test('no profiles, add form closed: the splash says n adds one and Esc quits', async () => {
|
||||
writeConfig({ profiles: {} } as Config)
|
||||
h = await start([], { width: 80, height: 24 })
|
||||
await waitFor(h, (x) => x.includes('Add a server profile'))
|
||||
const f = await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
expect(f).toContain('n adds a server profile')
|
||||
await press(h, () => h!.setup.mockInput.pressKey('n'))
|
||||
expect(h.store.overlays.at(-1)).toBeInstanceOf(ProfileFormOverlay)
|
||||
await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
expect(quits).toBe(1)
|
||||
})
|
||||
|
||||
test('--no-splash: the sign-in screen as before, then Home; with several profiles the picker opens over it', async () => {
|
||||
profiles({ fake: { server: 'http://fake.test' } })
|
||||
h = await start(['--no-splash'])
|
||||
expect(h.store.routes.some((r) => r.kind === 'splash')).toBe(false)
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
|
||||
h.store.dispose()
|
||||
h.setup.renderer.destroy()
|
||||
two()
|
||||
h = await start(['--no-splash'])
|
||||
await waitFor(h, (x) => x.includes('Connect to a server'))
|
||||
expect(h.store.screen?.kind).toBe('signin')
|
||||
expect((h.store.overlays.at(-1) as ServersOverlay).selected()?.name).toBe('beta')
|
||||
})
|
||||
|
||||
test('a failure stays on the splash with retry and choose-another; c opens the picker, Esc goes back to it', async () => {
|
||||
profiles({ fake: { server: 'http://fake.test' }, other: { server: 'http://other.test' } })
|
||||
let down = true
|
||||
const ok = fakeFetch()
|
||||
const flaky = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (down) throw new Error('connection refused')
|
||||
return ok(input, init)
|
||||
}) as typeof fetch
|
||||
h = await start(['--profile', 'fake'], { width: 80, height: 24, fetch: flaky })
|
||||
let f = await waitFor(h, (x) => x.includes('Enter retries'))
|
||||
expect(f).toContain('connection refused')
|
||||
expect(f).toContain('c chooses another profile')
|
||||
await press(h, () => h!.setup.mockInput.pressKey('c'))
|
||||
f = await waitFor(h, (x) => x.includes('Connect to a server'))
|
||||
expect(f).toContain('Esc goes back')
|
||||
await press(h, () => h!.setup.mockInput.pressEscape())
|
||||
expect(quits).toBe(0)
|
||||
f = await waitFor(h, (x) => x.includes('Enter retries'))
|
||||
down = false
|
||||
await press(h, () => h!.setup.mockInput.pressEnter())
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
|
||||
})
|
||||
|
||||
test('any key stops the animation, not the boot', async () => {
|
||||
profiles({ fake: { server: 'http://fake.test' } })
|
||||
h = await start([], { minMs: 300 })
|
||||
const splash = h.store.screen as SplashScreen
|
||||
splash.animate = true
|
||||
await press(h, () => h!.setup.mockInput.pressKey('x'))
|
||||
expect(splash.animate).toBe(false)
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
|
||||
})
|
||||
|
||||
function input(ascii: boolean, anchor: 'center' | 'top' = 'center'): SplashLayoutInput {
|
||||
return { ascii, pose: { blink: false, wag: false }, status: { text: 'connecting to localhost:8078…', tone: 'status' }, extra: [], keys: '', anchor }
|
||||
}
|
||||
|
||||
test('the layout fits 80×24 (small Query beside the wordmark) and 120×40 (large Query); every line clipped', () => {
|
||||
for (const [w, hgt] of [[80, 24], [120, 40], [40, 12], [30, 8], [20, 5]] as const) {
|
||||
for (const ascii of [false, true]) {
|
||||
setAscii(ascii)
|
||||
const lines = splashLayout(w, hgt, input(ascii))
|
||||
expect(lines.length).toBeLessThanOrEqual(hgt)
|
||||
for (const l of lines) expect(cellWidth(l.map((s) => s.text).join(''))).toBeLessThanOrEqual(w)
|
||||
}
|
||||
}
|
||||
setAscii(false)
|
||||
const small = splashText(splashLayout(80, 24, input(false)))
|
||||
expect(small).toContain('Terminal client for QQQ')
|
||||
expect(small).toContain('connecting to localhost:8078…')
|
||||
const large = splashLayout(120, 40, input(false))
|
||||
expect(large.filter((l) => l.some((s) => s.tone === 'art')).length).toBe(querySize('large').height)
|
||||
const header = splashLayout(80, 24, input(false, 'top'))
|
||||
expect(splashText(header)).not.toContain('connecting')
|
||||
expect(header.length).toBeLessThanOrEqual(querySize('small').height + 2)
|
||||
})
|
||||
|
||||
test('ASCII: no braille or block characters, the wordmark in #', () => {
|
||||
setAscii(true)
|
||||
for (const [w, hgt] of [[80, 24], [120, 40]] as const) {
|
||||
const text = splashText(splashLayout(w, hgt, input(true)))
|
||||
expect(text).not.toMatch(/[⠀-⣿█]/)
|
||||
expect(text).toContain('####')
|
||||
expect(text).toContain('Terminal client for QQQ')
|
||||
expect(text).toMatch(/^[\x20-\x7e\n]*$/)
|
||||
expect(text).toContain('qqq')
|
||||
}
|
||||
})
|
||||
|
||||
test(':splash off and on save the setting (Settings > Splash on start)', async () => {
|
||||
profiles({ fake: { server: 'http://fake.test' } })
|
||||
h = await start([])
|
||||
await waitFor(h, () => h!.store.screen?.kind === 'home', 4000)
|
||||
await command(h, 'splash off')
|
||||
expect(readSplash()).toBe(false)
|
||||
expect(h.store.flashText?.text).toContain('splash off')
|
||||
await command(h, 'splash on')
|
||||
expect(readSplash()).toBe(true)
|
||||
})
|
||||
Reference in new issue
Block a user