diff --git a/.changeset/tui-clicks-long-values-set-password.md b/.changeset/tui-clicks-long-values-set-password.md new file mode 100644 index 00000000..5dca02a2 --- /dev/null +++ b/.changeset/tui-clicks-long-values-set-password.md @@ -0,0 +1,5 @@ +--- +"@noormdev/cli": minor +--- + +TUI: every actionable element answers the mouse, and the one under the pointer gets a background a step off the terminal's own (queried at startup; ANSI bright black when the terminal does not answer). Forms (click a field, toggle a checkbox, Submit/Cancel, wheel), confirm dialogs (Yes/No), every `[key] Label` hint (footers, Quick Actions, Available Actions, More; a click presses the key), earlier breadcrumb crumbs, the idle search row, and the file picker, log viewer, SQL history/clear and known-users rows are clickable. The terminal now reports pointer motion for the hover shade; set `ui.mouse: false` to turn all mouse handling off. A long value no longer wraps and pushes the action row off screen: form values, list rows, panel titles, log rows and Home rows truncate with `…`, and an open text field scrolls around its cursor. Press `x` on a password field to empty it before typing a replacement. New `noorm config set-password [value] [--stdin]` stores a rotated database password headlessly under the config's `config:write` policy. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index cf910dbd..766d5ebc 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -80,7 +80,7 @@ expect(events[0].status).toBe('failed'); ## Module mocking -Bun's `mock.module` registry is process-global and never restores, so a mocking file poisons every file loaded after it. CI works around this by splitting the suite into five separate processes. The mechanics and the reasoning are in the root `CLAUDE.md`; read it before adding `mock.module` to a new file. +Bun's `mock.module` registry is process-global and never restores, so a mocking file poisons every file loaded after it. CI works around this by splitting the suite into six separate processes. The mechanics and the reasoning are in the root `CLAUDE.md`; read it before adding `mock.module` to a new file. ## Helpers diff --git a/.claude/rules/tui-development.md b/.claude/rules/tui-development.md index 489073d3..c396128b 100644 --- a/.claude/rules/tui-development.md +++ b/.claude/rules/tui-development.md @@ -186,7 +186,7 @@ Registered: `TextInput` (while not `isDisabled`, which also covers `SearchableLi | Component | Use it? | Reason | |-----------|---------|--------| -| `TextInput` | No — use ours | `src/tui/components/forms/TextInput.tsx` is upstream's, plus the mouse-report guard and the Ctrl-chord guard. Still display-only when `isDisabled`, so the focus stack stays authoritative | +| `TextInput` | No — use ours | `src/tui/components/forms/TextInput.tsx` is upstream's, plus the mouse-report guard, the Ctrl-chord guard, and a measured single-line scroll window (`windowValue`) so a long value never wraps. Still display-only when `isDisabled`, so the focus stack stays authoritative | | `Spinner`, `Badge`, `ProgressBar`, `Alert`, `StatusMessage` | Yes | Display-only, no input handling | | `Select`, `MultiSelect`, `ConfirmInput` | No | Drive Ink's internal focus, which our stack never sees | @@ -231,7 +231,7 @@ Write no `?? 24` fallback. `useWindowSize` already falls back to the `terminal-s Use the hooks in `src/tui/hooks/useObserver.ts` for event subscriptions. They handle unsubscribe on unmount. ```tsx -import { useOnEvent, useOnceEvent, useEmit, useOnScreenPopped } from '../hooks/index.js'; +import { useOnEvent, useOnceEvent, useEmit } from '../hooks/index.js'; useOnEvent('changeset:complete', (data) => { @@ -243,11 +243,10 @@ useOnceEvent('build:complete', (data) => setFinalResult(data), []); const emitStart = useEmit('build:start'); emitStart({ schemaPath, fileCount }); - -// Reset local state when a screen is popped off the router stack -useOnScreenPopped('db/explore', () => clearExploreFilters()); ``` +State that must clear when a screen is popped belongs to something that stays mounted (`AppContextProvider` clears the explore filters on `router:popped`), not to the screen: a breadcrumb jump (`backBy`) skips the screens it passes, and a skipped screen never mounts to hear the event. + ## Feedback patterns @@ -378,6 +377,7 @@ There is no `k` on Home. Secrets belong to a config, so `k` opens them from the | `s` | Lock List | status | | `c` | Home | config | | `c` | config list | copy | +| `x` | Form, password field with a value (browse mode) | empty it | | `c` | DB list | create | **Global (every screen, via `GlobalKeyboard`; all but `Ctrl+C` inactive while typing in a field):** @@ -410,9 +410,10 @@ There is no mouse support in Ink 7.1.1 — no hook, no parsing. `src/tui/mouse.t It is **on** unless `ui.mouse` is false in `.noorm/settings.yml`. Off means inert: no escape sequence, no `useInput` registration, no `process` listener, no refs on rows. What an absent flag means is decided once, by `isMouseEnabled` in `src/core/settings/defaults.ts`; never read `settings?.ui?.mouse` directly. -- **Never write `?1000h` / `?1006h` anywhere else.** A second writer means a second thing that has to disable on exit, and a terminal left in mouse mode outlives the process — click-drag selection stays broken in every shell in that window. -- **Answer clicks with `useRowMouse`**, not by parsing in a component. It takes the same `isActive` guard the keyboard handler uses, because a click acts on whatever already has focus. -- **A handler with a catch-all character branch needs `isMouseReport(input)`.** Reports reach every `useInput` as a plain string, so `ResultTable`'s filter box and `SqlInput` would otherwise type `[<0;12;5M` into themselves. A handler that only tests named keys and exact characters needs no guard — `SelectList` has none, and the mutation harness is why. +- **Never write `?1000h` / `?1003h` / `?1006h` anywhere else.** A second writer means a second thing that has to disable on exit, and a terminal left in mouse mode outlives the process — click-drag selection stays broken in every shell in that window. +- **Answer clicks with `useRowMouse`**, not by parsing in a component. It takes the same `isActive` guard the keyboard handler uses, because a click acts on whatever already has focus. It also returns `hovered`, the row under the pointer, which is null unless `isActive`; shade it with `backgroundColor={hoverBackground()}` on the row's Box, and never on the cursor row. A motion report decodes to `kind: 'move'`, never a press, so a handler that tests `press` ignores it. Pass `matchColumn: true` when targets share a line (`Form`'s Submit/Cancel, `Confirm`'s Yes/No); list rows leave it off so a click past the end of a short label still hits the row. Pass `ignoreRepeats: true` where a double click means nothing extra (buttons, hints, crumbs): the second press otherwise lands on whatever the first brought under the pointer. +- **A handler with a catch-all character branch, or one that acts on any key, needs `isMouseReport(input)`.** Reports reach every `useInput` as a plain string, so `ResultTable`'s filter box and `SqlInput` would otherwise type `[<0;12;5M` into themselves. Motion reports arrive on every pointer move, so an "any key closes this" handler (help, the "press any key to continue" screens) would otherwise close on a mouse move. A handler that only tests named keys and exact characters needs no guard — `SelectList` has none, and the mutation harness is why. +- **Draw a `[key] Label` hint with `Hotkey`, `HotkeyLine` or `KeyTarget`** (`src/tui/components/layout/Hotkey.tsx`), never a bare `[a] Add`. A click emits the key through Ink's input emitter, so the focused component handles it exactly as the keyboard would; the screen needs no second code path. A hint naming no single key (`↑↓`, `any`, `Esc Esc`, `Ctrl+U/D`) stays plain text: `resolveHotkey` resolves a compound label only if every part is a key, because `Ctrl+U/D` must not become a bare `D`. A hint is a `Box`, so it cannot sit inside a `Text`. A hint must not stay drawn while a component that gives its key another meaning has focus (a footer `[Enter] Edit` under an open delete confirmation): draw it only in the view where the key means that. `Confirm` and `ProtectedConfirm` hold the `modal` guard (`useModal`), which makes every hint inert as a backstop, and a running screen holds `busy` (`useBusy(step === 'running')`), which stops a breadcrumb click from leaving it. The one read of an Ink internal is the `internal_eventEmitter` lookup in `Hotkey.tsx`, which `hotkey.test.tsx` pins. - **Use `TextInput` from `src/tui/components/forms/`, never from `@inkjs/ui`.** Upstream's handler ends in an unconditional `state.insert(input)`, so a click while a field is in edit mode types the report into the field — and where that field feeds a derived value, such as `ChangeAddScreen`'s change-folder name, the report reaches disk. `forms/TextInput.tsx` is upstream's component with that one guard added; the exports map publishes only the package root, so wrapping it was not possible and copying it was. `measureElement`'s `x`/`y` are live-region coordinates and SGR's are 1-based terminal coordinates; the conversion is one constant in `mouse.tsx`. It holds because the TUI renders in the alternate screen with no `` in the tree, so the frame starts at the home position. Add a `` and that constant stops being a constant. @@ -425,9 +426,11 @@ It is **on** unless `ui.mouse` is false in `.noorm/settings.yml`. Off means iner | API | What it covers | |-----|----------------| | `usePaste` | Bracketed paste delivered as a single string. `SqlInput` currently submits only the first line of a multi-line paste. | -| `useBoxMetrics` | Used once, for the height of `Form`'s status error. Still unused for measured box width/height/left/top instead of the hand-counted chrome constants at `SqlTerminalScreen.tsx:57` and `ConfigEditScreen.tsx:268`. | +| `useBoxMetrics` | Used for the height of `Form`'s status error and the width `TextInput` scrolls within. Still unused for measured box width/height/left/top instead of the hand-counted chrome constants at `SqlTerminalScreen.tsx:57` and `ConfigEditScreen.tsx:268`. | | `useAnimation`, `suspendTerminal()`, `interactive` | No current need. | -`` no longer accepts `wrap="end"` or `wrap="middle"`. Both were undocumented no-ops on 6.8.0 and were dropped from the type in 7.0.0. This codebase uses only `wrap="wrap"` and `wrap="truncate"`, so nothing changed. +`` no longer accepts `wrap="end"` or `wrap="middle"`. Both were undocumented no-ops on 6.8.0 and were dropped from the type in 7.0.0. This codebase uses `wrap="wrap"`, `wrap="truncate"`, and `wrap="truncate-start"` (`FilePicker` paths, where the end is what identifies a file), so nothing changed. + +Text drawn into a row the layout counts (list rows, form values, panel titles) takes `wrap="truncate"`; a long value otherwise wraps and the rows below it are pushed off screen. Text that carries an instruction in its tail, such as the `repair-access` hint in an error toast or the Home access warning, stays wrapped. `useFocusManager` gained `activeId` in Ink 7. It reports Ink's own focus registry, which `useFocusScope` never writes to, so it does not tell you what this codebase considers focused. The focus rules above stand unchanged. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 60785235..afac5224 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -144,7 +144,7 @@ jobs: FORCE_COLOR: 'true' run: | bun test --serial $(find tests/cli \( -name '*.test.ts' -o -name '*.test.tsx' \) \ - ! -name 'cli-logger-settings.test.ts' | sort | tr '\n' ' ') + ! -name 'cli-logger-settings.test.ts' ! -name 'mouse-rows.test.tsx' | sort | tr '\n' ' ') # Isolated: other CLI test files leave `getSettingsManager` mocked for the rest # of their process, and bun picks the file order. See "Running Tests" in CLAUDE.md. - name: Run CLI logger settings tests (isolated) @@ -152,6 +152,13 @@ jobs: CI: 'false' FORCE_COLOR: 'true' run: bun test --serial tests/cli/cli-logger-settings.test.ts + # Isolated for the same reason, from the other side: this file mocks + # `getStateManager`/`getSettingsManager` itself and would poison the group above. + - name: Run mouse rows tests (isolated) + env: + CI: 'false' + FORCE_COLOR: 'true' + run: bun test --serial tests/cli/mouse-rows.test.tsx # ca.pem is the public CA; the private keys beside it stay in the container. - name: Export the MySQL server CA for the live TLS test run: | diff --git a/CLAUDE.md b/CLAUDE.md index a4321b83..a6f56b84 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,7 @@ bun run typecheck # Type check ## Running Tests -A single `bun test` (whole suite, one process) does **not** reflect CI. CI splits the suite into five +A single `bun test` (whole suite, one process) does **not** reflect CI. CI splits the suite into six independent `bun test --serial` invocations, each in a fresh process — see `.github/workflows/ci.yml` for the split and [`docs/wiki/index.md`](docs/wiki/index.md) for the exact per-group commands. diff --git a/docs/dev/settings.md b/docs/dev/settings.md index a8c99bb4..f6da9dc0 100644 --- a/docs/dev/settings.md +++ b/docs/dev/settings.md @@ -593,7 +593,7 @@ ui: | Property | Default | Description | |----------|---------|-------------| -| `mouse` | `true` | Answer clicks and wheel notches in lists and result grids | +| `mouse` | `true` | Answer clicks, wheel notches and hover (a shade, derived from the terminal's background, on what a click would act on) in lists, forms, dialogs, hints and result grids | There is no settings-screen toggle and no accessor on `SettingsManager`. What an absent flag means is decided in exactly one place, `isMouseEnabled` in @@ -629,6 +629,8 @@ terminal mouse-tracking mode takes click-drag text selection away from the terminal unless the user holds a modifier (Option on macOS Terminal and iTerm2, Shift elsewhere). A user who hits that reports it as "text selection stopped working", which is why the help screen (`?`) names `ui.mouse: false` directly. +Hover adds a second cost: motion tracking (`?1003`) makes the terminal send one +report per cell the pointer crosses, which is noticeable over a slow SSH link. See `docs/tui.md` for what the mouse does. diff --git a/docs/guide/environments/configs.md b/docs/guide/environments/configs.md index ca1a0304..63b9a2fd 100644 --- a/docs/guide/environments/configs.md +++ b/docs/guide/environments/configs.md @@ -52,7 +52,7 @@ noorm config validate review `create` saves the stage's defaults with one-time connection overrides. To update an existing config from changed defaults, use `noorm config sync --stage staging --name review`; it previews changes and asks before saving. Neither command stores a stage link. The [CLI reference](/headless#config-create) lists flags, password rules, and overwrite authorization. -`noorm config add` and `config edit` exit 1 and point you at the TUI. For a complete config object, import JSON instead. This `staging.json` is hidden from agents: +`noorm config add` and `config edit` exit 1 and point you at the TUI. To change only a stored password, for example after a rotation, use `noorm config set-password --stdin --yes`. For a complete config object, import JSON instead. This `staging.json` is hidden from agents: ```json { diff --git a/docs/headless.md b/docs/headless.md index 01def422..e4797a33 100644 --- a/docs/headless.md +++ b/docs/headless.md @@ -214,7 +214,7 @@ Through the CLI, an `agent` channel `confirm` cell is a deny, so `--yes` never u | `db:teardown` | `db teardown` | deny | deny | confirm | | `db:destroy` | `db drop` | deny | deny | confirm | | `change:rm` | `change rm` | deny | confirm | confirm | -| `config:rm`, `config:write` | `config rm`, overwrites with `config import` or `config create`, `config sync` | deny | confirm | confirm | +| `config:rm`, `config:write` | `config rm`, `config set-password`, overwrites with `config import` or `config create`, `config sync` | deny | confirm | confirm | | `vault:read`, `secret:read` | `vault list`, `secret list`, `config export` | deny | allow | allow | | `vault:write`, `secret:write` | `vault init`, `set`, `rm`, `cp`, and `secret set`, `rm` | deny | confirm | allow | | `vault:propagate` | `vault propagate`, `ci identity enroll` | deny | confirm | confirm | @@ -402,6 +402,27 @@ noorm config repair-access staging --user admin --agent false --yes Both replacement assignments are required and must name current built-in or project roles; `--agent false` disables agent access. The user channel must explicitly pass `--yes`; agents cannot repair access even with confirmation. When the user role is still valid, its `config:write` permission applies, so an unknown agent role cannot bypass a user-role denial. Valid access cannot be edited through this command. No role is automatically substituted. +#### `config set-password [value]` + + +Replace the database password of a stored config, for rotation jobs that issue a new password and need it saved without the TUI: + +```bash +echo "$NEW_PASSWORD" | noorm config set-password prod --stdin --yes +``` + +| Flag | Effect | +|---|---| +| `[value]` | The new password. Visible in the process table and shell history, so prefer `--stdin` | +| `--stdin` | Read the password from stdin. One trailing newline is stripped | +| `--yes`, `-y` | Confirm the `config:write` policy cell. `NOORM_YES` works too | +| `--json` | Return `{ success: true, name }`. The password is never printed | + +Only `connection.password` changes. Other connection fields, access, and the config's secrets stay as they were. The command does not connect: run `noorm config validate ` afterwards to check the new password. It requires the config's `config:write` permission, so a viewer config is refused. SQLite configs are refused because SQLite has no password. An empty password is refused, so a rotation script whose variable came back empty cannot wipe the stored one. An unknown config name or a config with malformed access exits 2. A missing or empty value, a denied policy, or a missing confirmation exits 1. Every refusal leaves state unchanged. + +A stage's `locked: true` does not block this command; it only blocks deletion. + + #### `config rm ` diff --git a/docs/tui.md b/docs/tui.md index eef2ec37..75e71ffd 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -105,6 +105,7 @@ stateDiagram-v2 |------|-----|--------| | Browse | `↑` `↓`, `Tab`, `Shift+Tab` | Move between fields, wrapping through the action row | | Browse | `Enter` | Open the field for editing | +| Browse | `x` | On a password field with a value, empty it. Opening a password field starts from its current value, so this replaces it without backspacing | | Browse | `Esc` | Cancel the form | | Edit | `Enter` | Commit and return to browse | | Edit | `Esc` | Restore the value the field held when edit opened | @@ -113,11 +114,13 @@ stateDiagram-v2 A checkbox toggles in place on `Enter` or `Space`. A select expands on `Enter`, moves with `↑` `↓`, and takes the highlighted option on a second `Enter`. A red `*` marks a required field. Most forms reject an empty required field and put the cursor on it with an error. Pipeline input forms instead require presence: explicitly saving an empty string is valid. +A value longer than the row ends in `…` instead of wrapping, and an open text field scrolls to keep the cursor in view. On the config edit form, an empty password keeps the stored one, as its placeholder says: clear the field with `x` and type the replacement. + ### Mouse -On by default, in lists and result grids: +On by default. In lists and result grids: | Action | Effect | |--------|--------| @@ -125,10 +128,37 @@ On by default, in lists and result grids: | Double-click a row | Same as `Enter` on that row | | Wheel | Move the cursor one row, or scroll a document | -A click acts on whatever already has focus. Clicking an unfocused list does nothing. +In a form: + +| Action | Effect | +|--------|--------| +| Click a field | Move the cursor to it. Another field being edited is committed first | +| Double-click a text, password, or select field | Open it for editing | +| Click a checkbox | Toggle it | +| Click `[ Submit ]` or `[ Cancel ]` | Activate that button | +| Click an option in an open select | Highlight it. Double-click takes it | +| Wheel | Move between fields, or the highlight in an open select | + +In a confirm dialog, clicking `Yes (y)` confirms and clicking `No (n)` cancels. + +A `[key] Label` hint is a button: a footer hint such as `[a] Add` or `[Esc] Back`, a Quick Actions entry, a row of Available Actions, or a menu entry on More. A click presses that key, through the same path the keyboard takes, so whatever has focus answers it exactly as it would the key. A double click presses once. Hints that name no single key (`[↑↓] Scroll`, `[any] Back`) and sentences that only mention a key ("Press [a] to add one") are plain text; the Home prompts "Press [c] to ..." are the exception and are buttons. + +A hint does nothing while a confirmation holds the screen, since the key would answer the confirmation. It never types its letter into a text field that is taking input; `Esc` and `Enter` hints still work there. + +| Also clickable | Effect | +|----------------|--------| +| Earlier breadcrumb crumbs | Go back to that screen. Inactive while an overlay is open, a confirmation holds the screen, or an operation is running | +| The idle `/ Filter...` row of a searchable list | Enter search mode | +| Rows of the file picker | Click moves the cursor and enters select mode; double-click toggles the checkbox | +| Rows of the log viewer, SQL history and clear lists, known users, result grids | Click moves the cursor; double-click does what `Enter` does | + +Moving the pointer over anything a click would act on shades it, so you can see what is clickable. The shade is a step off your terminal's own background (lighter on a dark theme, darker on a light one), asked for when `noorm ui` starts, and the text keeps its color. A terminal that does not report its background gets ANSI bright black instead. The cursor row keeps its own marker and is not shaded. Nothing shades, or answers a click, in a list that does not hold focus. + ::: tip -Mouse tracking takes click-drag text selection away from the terminal. Hold Option in macOS Terminal and iTerm2, or Shift in most others. To turn the mouse off, set `ui.mouse` in `.noorm/settings.yml`; `?` prints the same hint. +Mouse tracking takes click-drag text selection away from the terminal. Hold Option in macOS Terminal and iTerm2, or Shift in most others. + +Hover makes the terminal send a report for every cell the pointer crosses, which is noticeable over a slow SSH link. To turn the mouse off, set `ui.mouse` in `.noorm/settings.yml`; `?` prints the same hint. If a crashed session leaves the terminal printing characters as the pointer moves, run `reset`. ```yaml # .noorm/settings.yml diff --git a/skills/noorm/references/cli.md b/skills/noorm/references/cli.md index 51235354..d34c698d 100644 --- a/skills/noorm/references/cli.md +++ b/skills/noorm/references/cli.md @@ -111,6 +111,16 @@ Use the [config command reference](https://noorm.dev/headless#config-create) for `config export` writes the config as JSON with its password, to stdout or `--output`. `config import ` creates a config from that JSON. File-form settings in `connection.ssl` (`caFile`, `certFile`, `keyFile`) export as their paths and the files are not read, so the files must exist at those paths on the importing machine. Inline `ca`, `cert` and `key` export as the stored PEM. Import saves a path without checking the file; `config validate ` reports an unreadable one. +### config set-password + +Replace the database password of a stored config, for rotation jobs. Only `connection.password` changes; it does not connect, so run `config validate ` afterwards. + +```bash +echo "$NEW_PASSWORD" | noorm config set-password prod --stdin --yes +``` + +Prefer `--stdin` over a positional value, which shows in the process table and shell history. It needs the config's `config:write` permission and `--yes` (or `NOORM_YES`) where the policy asks for confirmation; the agent channel and viewer configs are refused. Unknown names and malformed access exit 2; an empty value, a SQLite config, or a denial exits 1. The password is never printed, in text or `--json` output. + ### config use Set the active configuration. It connects to the config's database when it can, to register your identity; a role that cannot write registers nothing and creates no tables. On the agent channel a config with `agent: false` exits 1 with `Config "" is not accessible on the agent channel.` diff --git a/skills/noorm/references/config.md b/skills/noorm/references/config.md index 28d2aa7f..79421b44 100644 --- a/skills/noorm/references/config.md +++ b/skills/noorm/references/config.md @@ -329,6 +329,8 @@ Stage defaults fill fields below stored, environment, and inline values, in the Create stored configs with `noorm config create --stage staging --name review` or `noorm config create --all`; overwrites require literal `--force --yes` and authorization. Creation does not implicitly persist stage or environment passwords. Use `noorm config sync --stage staging --name review --json` to preview current defaults, then `--yes` to apply without a prompt. These aliases require an unprotected stage; a protected stage requires the config name to match the stage name because no stage association is stored. Sync preserves passwords and absent fields, refuses a different dialect, and requires `config:write`. Stage access assignments may name project roles and remain subject to validation and protected-stage permission ceilings. +To rotate a stored password without touching the rest of a config, use `noorm config set-password --stdin --yes`. For credentials that must be fresh on every new connection, pass a password callback to the SDK instead of storing one. + See `docs/spec/config-access-roles.md` for the full per-channel access model and MCP's `access.agent: false` invisibility. ## Minimal Settings Example diff --git a/src/cli/config/edit.ts b/src/cli/config/edit.ts index 316c066e..9cf2f353 100644 --- a/src/cli/config/edit.ts +++ b/src/cli/config/edit.ts @@ -26,7 +26,8 @@ const editCommand = defineCommand({ outputError( args, - 'Interactive only — run: noorm ui. For headless edits use: noorm config import --force --yes', + 'Interactive only — run: noorm ui. For headless edits use: noorm config import --force --yes, ' + + 'or to change only the password: noorm config set-password --stdin --yes', ); process.exit(1); diff --git a/src/cli/config/index.ts b/src/cli/config/index.ts index 577e683b..4d4043dd 100644 --- a/src/cli/config/index.ts +++ b/src/cli/config/index.ts @@ -12,6 +12,7 @@ import importCmd from './import.js'; import list from './list.js'; import repairAccess from './repair-access.js'; import rm from './rm.js'; +import setPassword from './set-password.js'; import sync from './sync.js'; import use from './use.js'; import validate from './validate.js'; @@ -23,6 +24,6 @@ export default defineCommand({ }, subCommands: { add, cp, create, edit, export: exportCmd, import: importCmd, list, - 'repair-access': repairAccess, rm, sync, use, validate, + 'repair-access': repairAccess, rm, 'set-password': setPassword, sync, use, validate, }, }); diff --git a/src/cli/config/set-password.ts b/src/cli/config/set-password.ts new file mode 100644 index 00000000..fab23c15 --- /dev/null +++ b/src/cli/config/set-password.ts @@ -0,0 +1,136 @@ +/** noorm config set-password [value]: replace a stored config's password, for rotation jobs. */ +import { attempt } from '@logosdx/utils'; +import { defineCommand } from 'citty'; + +import { initState, getStateManager } from '../../core/state/index.js'; +import { outputResult, outputError, sharedArgs, isYesMode } from '../_utils.js'; +import { readSecretValue } from '../vault/_secret-value.js'; +import { resolveSecretPolicy } from '../secret/_policy.js'; +import { EXIT } from '../_exit.js'; + +const setPasswordCommand = defineCommand({ + meta: { + name: 'set-password', + description: 'Set the database password of a stored config', + }, + args: { + name: { type: 'positional', description: 'Config name', required: true }, + value: { type: 'positional', description: 'New password (omit with --stdin)', required: false }, + stdin: { type: 'boolean', description: 'Read the password from stdin instead of argv' }, + json: sharedArgs.json, + yes: sharedArgs.yes, + }, + async run({ args }) { + + const [password, passwordErr] = await readSecretValue(args); + + if (passwordErr) { + + outputError(args, passwordErr.message); + process.exit(1); + + } + + // A rotation script whose variable came back empty must not wipe the stored password. + if (password === '') { + + outputError(args, 'The new password is empty.'); + process.exit(1); + + } + + const projectRoot = process.cwd(); + const [, initErr] = await attempt(() => initState(projectRoot)); + + if (initErr) { + + outputError(args, `Failed to load state: ${initErr.message}`); + process.exit(1); + + } + + const stateManager = getStateManager(projectRoot); + const accessError = stateManager.getConfigAccessError(args.name); + + if (accessError) { + + outputError(args, accessError); + process.exit(EXIT.USAGE); + + } + + if (!stateManager.getConfig(args.name)) { + + outputError(args, `Config "${args.name}" not found.`); + process.exit(EXIT.USAGE); + + } + + const resolved = await resolveSecretPolicy(stateManager, args.name, 'config:write', projectRoot); + + if (!resolved.ok) { + + outputError(args, resolved.error); + process.exit(1); + + } + + const { configName, check } = resolved; + const config = stateManager.getConfig(configName); + + if (!config) { + + outputError(args, `Config "${configName}" not found.`); + process.exit(EXIT.USAGE); + + } + + if (config.connection.dialect === 'sqlite') { + + outputError(args, `Config "${configName}" uses SQLite, which has no password.`); + process.exit(1); + + } + + if (check.requiresConfirmation && !isYesMode(args)) { + + outputError( + args, + `Changing the password of config "${configName}" requires confirmation (${check.confirmationPhrase}). Pass --yes to confirm.`, + ); + process.exit(1); + + } + + const [, saveErr] = await attempt(() => + stateManager.setConfig(configName, { + ...config, + connection: { ...config.connection, password: password as string }, + }), + ); + + if (saveErr) { + + outputError(args, `Failed to save config: ${saveErr.message}`); + process.exit(1); + + } + + outputResult( + args, + { success: true, name: configName }, + `Password set for config "${configName}".`, + ); + + process.exit(0); + + }, +}); + +(setPasswordCommand as typeof setPasswordCommand & { examples: string[] }).examples = [ + 'echo "$NEW_PASSWORD" | noorm config set-password prod --stdin --yes', + 'noorm config set-password staging "s3cret" --yes', + 'noorm config set-password prod --stdin --yes --json < ./new-password.txt', +]; + +export default setPasswordCommand; diff --git a/src/cli/secret/_policy.ts b/src/cli/secret/_policy.ts index 16851506..d067edef 100644 --- a/src/cli/secret/_policy.ts +++ b/src/cli/secret/_policy.ts @@ -1,11 +1,11 @@ /** - * Config resolution and policy gating for the `secret` commands. + * Config resolution and policy gating for the `secret` commands and `config set-password`. * * Config-scoped secrets are written through `StateManager`, which takes no * config object and so cannot consult `access` itself. Until that seam * carries the gate, these commands resolve the config and check it here — * which is why the check lives in one helper rather than being re-typed in - * three command files. + * each command file. */ import { checkConfigPolicy, resolveChannel } from '../../core/policy/index.js'; import type { Permission, PolicyCheck } from '../../core/policy/index.js'; diff --git a/src/cli/ui.ts b/src/cli/ui.ts index 8828fc43..af6464bc 100644 --- a/src/cli/ui.ts +++ b/src/cli/ui.ts @@ -31,10 +31,11 @@ const uiCommand = defineCommand({ }, async run() { - const [{ render }, { default: React }, { App }] = await Promise.all([ + const [{ render }, { default: React }, { App }, { detectHoverShade, setHoverBackground }] = await Promise.all([ import('ink'), import('react'), import('../tui/app.js'), + import('../tui/terminal-background.js'), ]); enableAutoLoggerInit(process.cwd(), { @@ -42,6 +43,11 @@ const uiCommand = defineCommand({ diagnostics: nullStream, }); + // Before Ink owns stdin: it would take the terminal's reply for keypresses. + const shade = await detectHoverShade(process.stdin, process.stdout); + + if (shade) setHoverBackground(shade); + const { waitUntilExit, clear, unmount } = render( React.createElement(App, { initialRoute: 'home', initialParams: {} }), { diff --git a/src/cli/vault/_secret-value.ts b/src/cli/vault/_secret-value.ts index c71645dc..d577930c 100644 --- a/src/cli/vault/_secret-value.ts +++ b/src/cli/vault/_secret-value.ts @@ -1,11 +1,11 @@ /** - * Secret value input for `vault set` and `secret set`. + * Secret value input for `vault set`, `secret set` and `config set-password`. * * A secret passed as a positional argument is visible in the process table * (`ps -ww -eo args`), in shell history, and in `set -x` CI traces. `--stdin` * is the way to set one without it appearing in any of those. * - * Lives here rather than in `cli/_utils.ts` so both secret writers share one + * Lives here rather than in `cli/_utils.ts` so the writers share one * implementation; it is a candidate to move to `_utils.ts` alongside the * other shared CLI input helpers. */ diff --git a/src/tui/app-context.tsx b/src/tui/app-context.tsx index 3b0b9956..ccb3b37c 100644 --- a/src/tui/app-context.tsx +++ b/src/tui/app-context.tsx @@ -565,6 +565,18 @@ export function AppContextProvider({ }), ); + // Here rather than in a screen: a breadcrumb jump skips the screens it passes, + // and the filters belong to this context. Only leaving explore clears them. + cleanups.push( + observer.on('router:popped', (data) => { + + const isExplore = (route: string) => route === 'db/explore' || route.startsWith('db/explore/'); + + if (isExplore(data.popped) && !isExplore(data.to)) clearExploreFilters(); + + }), + ); + cleanups.push( observer.on('config:created', () => { diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 29dd79d1..e96654d3 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -18,7 +18,7 @@ import { useState, useCallback, useEffect } from 'react'; import type { ReactElement } from 'react'; import { Box, Text, Spacer, useInput, useWindowSize } from 'ink'; -import { useFocusScope } from './focus.js'; +import { useFocusContext, useFocusScope } from './focus.js'; import type { Route, RouteParams } from './types.js'; import { RouterProvider, useRouter } from './router.js'; @@ -35,7 +35,7 @@ import { useForceMode, useSettings, } from './app-context.js'; -import { MouseProvider, useMouseTransport } from './mouse.js'; +import { hoverBackground, MouseProvider, isMouseReport, useMouseTransport, useRowMouse } from './mouse.js'; import { isMouseEnabled } from '../core/settings/defaults.js'; import { ToastProvider, ToastRenderer, LogViewerOverlay, useToast } from './components/index.js'; import { ShutdownProvider } from './shutdown.js'; @@ -58,9 +58,9 @@ function HelpScreen({ onClose }: { onClose: () => void }): ReactElement { const { enabled: mouseEnabled } = useMouseTransport(); // Any key closes help - useInput(() => { + useInput((input) => { - if (isFocused) { + if (isFocused && !isMouseReport(input)) { onClose(); @@ -139,7 +139,8 @@ function HelpScreen({ onClose }: { onClose: () => void }): ReactElement { */ function Breadcrumb(): ReactElement { - const { route, history } = useRouter(); + const { route, history, backBy } = useRouter(); + const { stack, isGuarded } = useFocusContext(); // Build breadcrumb trail const trail: string[] = history @@ -148,14 +149,41 @@ function Breadcrumb(): ReactElement { trail.push(getRouteLabel(route)); + // An overlay holds a second focus scope; `busy` and `modal` come from running screens and confirmations. + const { rowRef, hovered } = useRowMouse({ + isActive: stack.length <= 1, + matchColumn: true, + ignoreRepeats: true, + onClick: (index) => { + + if (isGuarded('busy') || isGuarded('modal')) return; + + backBy(trail.length - 1 - index); + + }, + onActivate: () => undefined, + onWheel: () => undefined, + }); + return ( - {trail.map((label, index) => ( - - {index > 0 && › } - {label} - - ))} + {trail.map((label, index) => { + + const isCurrent = index === trail.length - 1; + + return ( + + {index > 0 && › } + + {label} + + + ); + + })} ); @@ -181,13 +209,13 @@ function StatusBar(): ReactElement { return ( - + {projectName} │ - {configName} + {configName} │ {isConnected ? '●' : '○'} │ diff --git a/src/tui/components/dialogs/Confirm.tsx b/src/tui/components/dialogs/Confirm.tsx index 9ab1bcb1..41a991c7 100644 --- a/src/tui/components/dialogs/Confirm.tsx +++ b/src/tui/components/dialogs/Confirm.tsx @@ -18,7 +18,8 @@ import { Box, Text, useInput } from 'ink'; import type { ReactElement } from 'react'; -import { useFocusScope } from '../../focus.js'; +import { useFocusScope, useModal } from '../../focus.js'; +import { hoverBackground, useRowMouse } from '../../mouse.js'; import { Panel } from '../layout/Panel.js'; /** @@ -80,6 +81,8 @@ export function Confirm({ skip: hasExternalFocus, }); const isFocused = hasExternalFocus ? externalFocused : internalFocus.isFocused; + + useModal(isFocused); const [selected, setSelected] = useState<'yes' | 'no'>( defaultChoice === 'confirm' ? 'yes' : 'no', ); @@ -151,23 +154,37 @@ export function Confirm({ }); + // A click is the whole answer, so double-click does nothing extra. + const { rowRef, hovered } = useRowMouse({ + isActive: isFocused, + matchColumn: true, + ignoreRepeats: true, + onClick: (index) => (index === 0 ? onConfirm() : onCancel()), + onActivate: () => undefined, + onWheel: () => undefined, + }); + return ( {message} - - {selected === 'yes' ? '❯ ' : ' '} - Yes (y) - - - {selected === 'no' ? '❯ ' : ' '} - No (n) - + + + {selected === 'yes' ? '❯ ' : ' '} + Yes (y) + + + + + {selected === 'no' ? '❯ ' : ' '} + No (n) + + diff --git a/src/tui/components/dialogs/FilePicker.tsx b/src/tui/components/dialogs/FilePicker.tsx index 3de40f37..88562e34 100644 --- a/src/tui/components/dialogs/FilePicker.tsx +++ b/src/tui/components/dialogs/FilePicker.tsx @@ -22,8 +22,10 @@ import { TextInput } from '../forms/TextInput.js'; import type { ReactElement } from 'react'; import { useFocusScope } from '../../focus.js'; +import { hoverBackground, useRowMouse } from '../../mouse.js'; import { useViewportRows } from '../../hooks/useViewportRows.js'; import { Panel } from '../layout/Panel.js'; +import { Hotkey } from '../layout/Hotkey.js'; /** * Rows this component costs a screen beyond the shared screen reserve. @@ -342,6 +344,36 @@ export function FilePicker({ }); + // A click picks the row and moves to select mode; a double click toggles it, as Space does. + const pickRow = (index: number) => { + + setMode('select'); + setHighlightIndex(index); + + }; + + const { rowRef, hovered } = useRowMouse({ + isActive: isFocused, + onClick: pickRow, + onActivate: (index) => { + + pickRow(index); + + const file = filteredFiles[index]; + + if (file) toggleFile(file); + + }, + onWheel: (delta) => { + + const next = Math.min(Math.max(highlightIndex + delta, 0), Math.max(filteredFiles.length - 1, 0)); + + setHighlightIndex(next); + setScrollOffset((offset) => (next < offset ? next : next >= offset + visibleCount ? next - visibleCount + 1 : offset)); + + }, + }); + // Handle search input change const handleSearchChange = useCallback((value: string) => { @@ -385,12 +417,18 @@ export function FilePicker({ const isSelected = selectedSet.has(filepath); return ( - - - {isHighlighted ? '› ' : ' '} - {isSelected ? '☑ ' : '☐ '} - {filepath} - + + + + {isHighlighted ? '› ' : ' '} + {isSelected ? '☑ ' : '☐ '} + + + {filepath} ); @@ -415,10 +453,10 @@ export function FilePicker({ {/* Help */} - [Tab] switch mode - {mode === 'select' && [Space] toggle} - {mode === 'accept' && [Enter] accept} - [Esc] cancel + switch mode + {mode === 'select' && toggle} + {mode === 'accept' && accept} + cancel diff --git a/src/tui/components/dialogs/ProtectedConfirm.tsx b/src/tui/components/dialogs/ProtectedConfirm.tsx index 802492aa..aa87454e 100644 --- a/src/tui/components/dialogs/ProtectedConfirm.tsx +++ b/src/tui/components/dialogs/ProtectedConfirm.tsx @@ -21,7 +21,8 @@ import { TextInput } from '../forms/TextInput.js'; import type { ReactElement } from 'react'; -import { useFocusScope } from '../../focus.js'; +import { useFocusScope, useModal } from '../../focus.js'; +import { hoverBackground, useRowMouse } from '../../mouse.js'; import { Panel } from '../layout/Panel.js'; /** @@ -77,6 +78,8 @@ export function ProtectedConfirm({ }); const isFocused = hasExternalFocus ? externalFocused : internalFocus.isFocused; + useModal(isFocused); + const [input, setInput] = useState(''); const [error, setError] = useState(null); @@ -105,33 +108,41 @@ export function ProtectedConfirm({ }, [input, confirmPhrase, onConfirm]); - // Keyboard handling - useInput((_, key) => { + // Escape clears what was typed first, and only then cancels. + const handleEscape = () => { - if (!isFocused) return; + if (input) { - if (key.escape) { + setInput(''); + setError(null); - if (input) { + return; - setInput(''); - setError(null); + } - } - else { + onCancel(); - onCancel(); + }; - } + // Keyboard handling + useInput((_, key) => { - } + if (!isFocused) return; - if (key.return) { + if (key.escape) handleEscape(); - handleSubmit(); + if (key.return) handleSubmit(); - } + }); + // The `useModal` guard makes hint wrappers inert here, so these are click targets of their own. + const { rowRef, hovered } = useRowMouse({ + isActive: isFocused, + matchColumn: true, + ignoreRepeats: true, + onClick: (index) => (index === 0 ? handleSubmit() : handleEscape()), + onActivate: () => undefined, + onWheel: () => undefined, }); return ( @@ -169,8 +180,12 @@ export function ProtectedConfirm({ {error && {error}} - [Enter] Confirm - [Esc] Cancel + + [Enter] Confirm + + + [Esc] Cancel + diff --git a/src/tui/components/feedback/DismissableAlert.tsx b/src/tui/components/feedback/DismissableAlert.tsx index 78f9b419..6276a59a 100644 --- a/src/tui/components/feedback/DismissableAlert.tsx +++ b/src/tui/components/feedback/DismissableAlert.tsx @@ -36,6 +36,7 @@ import { updateDismissablePreference, } from '../../../core/update/global-settings.js'; import type { DismissablePreference } from '../../../core/update/types.js'; +import { Hotkey, KeyTarget } from '../layout/Hotkey.js'; // ============================================================================= // Types @@ -257,22 +258,26 @@ export function DismissableAlert({ - - {' '}[1] {confirmText}{' '} - - - {' '}[2] {laterText}{' '} - - - {' '}[3] {denyText}{' '} - + + + {' '}[1] {confirmText}{' '} + + + + + {' '}[2] {laterText}{' '} + + + + + {' '}[3] {denyText}{' '} + + {allowPersist && ( - - [p] Don't ask again: {persistChoice ? '[x]' : '[ ]'} - + {`Don't ask again: ${persistChoice ? '[x]' : '[ ]'}`} )} diff --git a/src/tui/components/forms/Form.tsx b/src/tui/components/forms/Form.tsx index 433aeeca..f728d1ca 100644 --- a/src/tui/components/forms/Form.tsx +++ b/src/tui/components/forms/Form.tsx @@ -40,6 +40,8 @@ import type { ReactElement } from 'react'; import type { DOMElement } from 'ink'; import { useFocusScope } from '../../focus.js'; +import { hoverBackground, useRowMouse } from '../../mouse.js'; +import { Hotkey } from '../layout/Hotkey.js'; /** Options an expanded select shows before it starts scrolling. */ const SELECT_VISIBLE_OPTIONS = 4; @@ -126,6 +128,17 @@ function SelectField({ }, [currentIndex]); + const chooseAt = (index: number) => { + + const picked = options[index]; + + if (!picked) return; + + onChange(picked.value); + onConfirm(); + + }; + // Handle keyboard navigation for select useInput((input, key) => { @@ -152,14 +165,7 @@ function SelectField({ // Enter - confirm selection if (key.return) { - const selected = options[highlightedIndex]; - - if (selected) { - - onChange(selected.value); - onConfirm(); - - } + chooseAt(highlightedIndex); } @@ -187,6 +193,15 @@ function SelectField({ const hasMoreAbove = startIndex > 0; const hasMoreBelow = startIndex + SELECT_VISIBLE_OPTIONS < options.length; + // Taking the option on the first press would collapse the list under the + // pointer, and the second press of a double-click would land on what moved up. + const { rowRef, hovered: hoveredOption } = useRowMouse({ + isActive, + onClick: setHighlightedIndex, + onActivate: chooseAt, + onWheel: (delta) => setHighlightedIndex((i) => Math.min(options.length - 1, Math.max(0, i + delta))), + }); + return ( {hasMoreAbove && ↑ more} @@ -196,12 +211,14 @@ function SelectField({ const actualIndex = startIndex + visibleIdx; const isHighlighted = actualIndex === highlightedIndex; const isSelected = option.value === value; + const isHovered = hoveredOption === actualIndex && !isHighlighted; return ( - + {isHighlighted ? '❯ ' : ' '} {option.label} @@ -448,6 +465,7 @@ export function Form({ const activeAction = activeIndex >= fields.length ? actions[activeIndex - fields.length] : undefined; const currentField = activeIndex < fields.length ? fields[activeIndex] : undefined; const isEditing = editingKey !== null; + const isClearable = currentField?.type === 'password' && Boolean(values[currentField.key]); // A field list that shrinks (SecretValueForm swaps fields by mode) must not // leave the cursor pointing past the end. @@ -562,9 +580,9 @@ export function Form({ }, [busy, submitting, collectErrors, fields, values, onSubmit]); - const beginEdit = useCallback(() => { + const beginEdit = useCallback((index = activeIndex) => { - const field = fields[activeIndex]; + const field = fields[index]; if (!field) return; @@ -607,9 +625,9 @@ export function Form({ }, [editingKey, editSnapshot]); - const activateStop = useCallback(() => { + const runAction = useCallback((action: FormAction) => { - if (activeAction === 'submit') { + if (action === 'submit') { handleSubmit(); @@ -617,9 +635,15 @@ export function Form({ } - if (activeAction === 'cancel') { + onCancel?.(); - onCancel?.(); + }, [handleSubmit, onCancel]); + + const activateStop = useCallback(() => { + + if (activeAction) { + + runAction(activeAction); return; @@ -627,7 +651,55 @@ export function Form({ beginEdit(); - }, [activeAction, handleSubmit, onCancel, beginEdit]); + }, [activeAction, runAction, beginEdit]); + + // Stops are fields, then buttons, matching `activeIndex`. + const clickStop = useCallback((index: number) => { + + const field = fields[index]; + + if (field && editingKey === field.key) return; + + if (isEditing) commitEdit(); + + setActiveIndex(index); + + if (!field) { + + const action = actions[index - fields.length]; + + if (action) runAction(action); + + return; + + } + + if (field.type === 'checkbox') beginEdit(index); + + }, [fields, actions, editingKey, isEditing, commitEdit, runAction, beginEdit]); + + const openStop = useCallback((index: number) => { + + const field = fields[index]; + + if (!field || field.type === 'checkbox' || editingKey === field.key) return; + + setActiveIndex(index); + beginEdit(index); + + }, [fields, editingKey, beginEdit]); + + const { rowRef, hovered } = useRowMouse({ + isActive: isFocused, + matchColumn: true, + onClick: clickStop, + onActivate: openStop, + onWheel: (delta) => { + + if (!isEditing) moveBy(delta); + + }, + }); // Note: the guard is inside the handler, not useInput's `isActive` option - // isFocused is false on the first render and `isActive` would skip @@ -743,6 +815,16 @@ export function Form({ updateValue(currentField.key, !values[currentField.key]); + return; + + } + + // Entering a password field opens on the current value, so replacing it + // otherwise means backspacing the whole thing first. + if (input.toLowerCase() === 'x' && !key.ctrl && !key.meta && currentField && isClearable) { + + updateValue(currentField.key, ''); + } }); @@ -841,9 +923,15 @@ export function Form({ } + if (isClearable) { + + return `↑↓ field ↵ edit x clear esc cancel${alt}`; + + } + return `↑↓ field ↵ edit esc cancel${alt}`; - }, [isEditing, fields, editingKey, activeAction, currentField, altSubmit]); + }, [isEditing, fields, editingKey, activeAction, currentField, isClearable, altSubmit]); return ( @@ -853,14 +941,15 @@ export function Form({ const index = startIndex + visibleIndex; const isActive = index === activeIndex && isFocused; + const isHovered = hovered === index && !isActive; const isFieldEditing = editingKey === field.key; const error = errors[field.key]; const value = values[field.key]; const starWidth = field.required ? 1 : 0; return ( - - + + *} - + {isFieldEditing && (field.type === 'text' || field.type === 'password') && ( - {displayValue(field, value) === '' && field.placeholder ? ( - {field.placeholder} - ) : ( - {displayValue(field, value)} + + {displayValue(field, value) === '' && field.placeholder ? ( + {field.placeholder} + ) : ( + {displayValue(field, value)} + )} + + + {field.hint && {' '}{field.hint}} + + {error && submitted && ( + {' '}✘ {error} )} - - {field.hint && {' '}{field.hint}} - - {error && submitted && {' '}✘ {error}} )} @@ -919,23 +1012,29 @@ export function Form({ {busy ? ( <> {busyLabel} - {onCancelBusy && [Esc] Cancel} + {onCancelBusy && Cancel} ) : ( - actions.map((action) => { + actions.map((action, actionIndex) => { const focused = activeAction === action && isFocused; + const isHovered = hovered === fields.length + actionIndex && !focused; const label = action === 'submit' ? submitLabel : 'Cancel'; return ( - - {focused ? '❯ ' : ' '}[ {label} ] - + + {focused ? '❯ ' : ' '}[ {label} ] + + ); }) diff --git a/src/tui/components/forms/TextInput.tsx b/src/tui/components/forms/TextInput.tsx index 3eabae00..e4cc5e3a 100644 --- a/src/tui/components/forms/TextInput.tsx +++ b/src/tui/components/forms/TextInput.tsx @@ -1,10 +1,12 @@ /** * Single-line text input. * - * A copy of `@inkjs/ui`'s `TextInput` (MIT) with two behavioural changes, a - * mouse report and the letter of a Ctrl chord are dropped instead of typed - * into the field, plus a `useTextEntry` call so `GlobalKeyboard` leaves its - * keystrokes alone. + * A copy of `@inkjs/ui`'s `TextInput` (MIT) with these changes: + * + * - A mouse report is dropped instead of typed into the field. + * - The letter of a Ctrl chord is dropped too. + * - A value wider than its box scrolls around the cursor instead of wrapping (`windowValue`). + * - `useTextEntry` keeps `GlobalKeyboard` from taking its keystrokes. * * **Why a copy and not a wrapper.** Upstream's handler ends in an * unconditional `state.insert(input)`, and Ink's `useInput` is subscriber-based @@ -34,10 +36,11 @@ * /> * ``` */ -import { useEffect, useMemo, useReducer } from 'react'; -import { Text, useInput } from 'ink'; +import { useEffect, useMemo, useReducer, useRef } from 'react'; +import { Box, Text, useBoxMetrics, useInput } from 'ink'; import type { ReactElement, ReactNode } from 'react'; +import type { DOMElement } from 'ink'; import { isMouseReport } from '../../mouse.js'; import { useTextEntry } from '../../focus.js'; @@ -104,6 +107,27 @@ function reducer(state: TextInputState, action: TextInputAction): TextInputState } +/** + * Cell range of the value that fits in `width` columns with the cursor in view. + * A cell is a character, plus one for the cursor past the end; two columns are + * held for the `…` markers. + * + * @example + * windowValue(100, 100, 20); // { start: 83, end: 101 } + */ +export function windowValue(length: number, cursor: number, width: number): { start: number; end: number } { + + const cells = length + 1; + + if (width <= 0 || cells <= width) return { start: 0, end: cells }; + + const body = Math.max(1, width - 2); + const start = Math.min(Math.max(0, cursor - Math.floor(body / 2)), cells - body); + + return { start, end: start + body }; + +} + /** * Props for TextInput. * @@ -155,6 +179,10 @@ export function TextInput({ useTextEntry(!isDisabled); + const boxRef = useRef(null); + const { width: boxWidth, hasMeasured } = useBoxMetrics(boxRef); + const view = windowValue(state.value.length, state.cursorOffset, hasMeasured ? boxWidth : 0); + const suggestion = useMemo(() => { if (state.value.length === 0) return undefined; @@ -237,7 +265,11 @@ export function TextInput({ }, { isActive: !isDisabled }); - return {render({ isDisabled, placeholder, state, suggestion })}; + return ( + + {render({ isDisabled, placeholder, state, suggestion, view })} + + ); } @@ -246,6 +278,7 @@ interface RenderOptions { placeholder: string; state: TextInputState; suggestion: string | undefined; + view: { start: number; end: number }; } /** @@ -256,7 +289,7 @@ interface RenderOptions { * props, so the emitted string matches upstream's while `chalk` stays out of * this package's dependencies. */ -function render({ isDisabled, placeholder, state, suggestion }: RenderOptions): ReactNode { +function render({ isDisabled, placeholder, state, suggestion, view }: RenderOptions): ReactNode { const { value, cursorOffset } = state; @@ -281,9 +314,11 @@ function render({ isDisabled, placeholder, state, suggestion }: RenderOptions): return ( <> - {value.slice(0, cursorOffset)} + {view.start > 0 && …} + {value.slice(view.start, cursorOffset)} {atCursor.length > 0 && {atCursor}} - {value.slice(cursorOffset + 1)} + {value.slice(cursorOffset + 1, view.end)} + {view.end < value.length + 1 && …} {suggestion && atCursor.length === 0 && ( <> {suggestion.slice(0, 1)} diff --git a/src/tui/components/index.ts b/src/tui/components/index.ts index 6e8c1bce..b0b074a1 100644 --- a/src/tui/components/index.ts +++ b/src/tui/components/index.ts @@ -10,8 +10,8 @@ */ // Layout -export { Panel, Divider, MissingParamPanel, NotFoundPanel } from './layout/index.js'; -export type { PanelProps, DividerProps, MissingParamPanelProps, NotFoundPanelProps } from './layout/index.js'; +export { Panel, Divider, MissingParamPanel, NotFoundPanel, Hotkey, HotkeyLine, KeyTarget, resolveHotkey } from './layout/index.js'; +export type { PanelProps, DividerProps, MissingParamPanelProps, NotFoundPanelProps, HotkeyProps, KeyTargetProps } from './layout/index.js'; // Lists export { SelectList, SearchableList, ActionList, StatusList } from './lists/index.js'; @@ -69,7 +69,6 @@ export type { export { SecretDefinitionForm, SecretDefinitionList, - SecretDefinitionListHelp, SecretValueForm, SecretValueList, SecretValueListHelp, diff --git a/src/tui/components/layout/Hotkey.tsx b/src/tui/components/layout/Hotkey.tsx new file mode 100644 index 00000000..9006083a --- /dev/null +++ b/src/tui/components/layout/Hotkey.tsx @@ -0,0 +1,195 @@ +/** + * Hotkey - a `[key] Label` hint that a click answers by pressing that key. + * + * The click emits the same string the keyboard would, through Ink's input + * emitter, so whichever component holds focus responds exactly as it would to + * the key. No screen needs a second code path for the mouse, and a hint can + * never do something the key does not. + * + * @example + * ```tsx + * + * Add + * Back + * + * ``` + */ +import { EventEmitter } from 'node:events'; + +import { Box, Text, useStdin } from 'ink'; + +import type { ReactElement, ReactNode } from 'react'; + +import { useIsGuarded, useIsTyping } from '../../focus.js'; +import { hoverBackground, useRowMouse } from '../../mouse.js'; + +const ESC = String.fromCharCode(27); + +const NAMED_KEYS: Record = { + esc: ESC, + enter: '\r', + '↵': '\r', + tab: '\t', + space: ' ', +}; + +/** + * The input a hint's key label stands for, or null when it names no single key. + * + * `Enter/Esc` takes the first alternative. Navigation hints such as `↑↓`, + * `^U/^D` and `any` name no one key, so they stay plain text. + * + * @example + * resolveHotkey('Esc'); // '\x1b' + * resolveHotkey('Enter/Esc'); // '\r' + * resolveHotkey('↑↓'); // null + */ +export function resolveHotkey(keys: string): string | null { + + if (keys === '/') return '/'; + + const resolved = keys.split('/').map((part) => { + + const name = part.trim(); + + if (/^[!-~]$/.test(name)) return name; + + if (/^Shift\+[A-Za-z]$/.test(name)) return name.slice(-1).toUpperCase(); + + return NAMED_KEYS[name.toLowerCase()] ?? null; + + }); + + // Every part has to be a key: `Ctrl+U/D` must not become a bare `D`. + return resolved.every((part) => part !== null) ? resolved[0] ?? null : null; + +} + +/** + * Ink's input emitter, which every `useInput` handler listens on. + * + * It is on the stdin context at runtime but absent from `useStdin`'s public + * type; `hotkey.test.tsx` fails if an Ink upgrade moves it. + */ +function inputEmitter(context: object): EventEmitter | null { + + if (!('internal_eventEmitter' in context)) return null; + + return context.internal_eventEmitter instanceof EventEmitter ? context.internal_eventEmitter : null; + +} + +export interface HotkeyProps { + + /** The key label inside the brackets, as the hint shows it: `Esc`, `Enter/Esc`, `a`. */ + keys: string; + + /** The action text after the brackets. */ + children: ReactNode; + + /** Dim the whole hint. Footer hints are dim; a hint in a list of actions is not. */ + dim?: boolean; + + /** Color for the bracketed key alone. */ + keyColor?: string; + + /** Color for the whole hint, such as a yellow mode banner. */ + color?: string; + +} + +/** + * A row of hints written as one string, split into one clickable Hotkey each. + * + * @example + * [Enter] Run · [d] Details · [Esc] Back + */ +export function HotkeyLine({ children, dim = true, color }: { children: string; dim?: boolean; color?: string }): ReactElement { + + const hints = [...children.matchAll(/\[([^\]]+)\]\s*([^[]*)/g)]; + + return ( + + {hints.map(([, keys = '', label = ''], index) => ( + {label.replace(/\s*·\s*$/, '').trim()} + ))} + + ); + +} + +export interface KeyTargetProps { + + /** The key label the click stands for, as `Hotkey` takes it. */ + keys: string; + + children: ReactNode; + + /** Stretch to the parent's width, so the shaded band and the click target span the row. */ + block?: boolean; + + /** The key does nothing right now (a row the keyboard also skips): no press and no shade. */ + disabled?: boolean; + +} + +/** + * Anything a click should answer by pressing a key: a row of an action list, a + * menu entry. Presses once per double click, nothing while a confirmation holds + * the screen, and no letter while a text field is taking input. + * + * @example + * Settings + */ +export function KeyTarget({ keys, children, block = false, disabled = false }: KeyTargetProps): ReactElement { + + const sequence = resolveHotkey(keys); + const emitter = inputEmitter(useStdin()); + const isTyping = useIsTyping(); + const isGuarded = useIsGuarded(); + + const isBlocked = () => sequence === null + || disabled + || isGuarded('modal') + || (/^[ -~]$/.test(sequence) && isTyping()); + + const press = () => { + + if (sequence === null || emitter === null || isBlocked()) return; + + emitter.emit('input', sequence); + + }; + + const { rowRef, hovered } = useRowMouse({ + isActive: sequence !== null, + matchColumn: true, + ignoreRepeats: true, + onClick: press, + onActivate: () => undefined, + onWheel: () => undefined, + }); + + return ( + + {children} + + ); + +} + +export function Hotkey({ keys, children, dim = true, keyColor, color }: HotkeyProps): ReactElement { + + return ( + + + {keyColor ? [{keys}] : `[${keys}]`} {children} + + + ); + +} diff --git a/src/tui/components/layout/MissingParamPanel.tsx b/src/tui/components/layout/MissingParamPanel.tsx index 0eeb0cc4..e0c5004e 100644 --- a/src/tui/components/layout/MissingParamPanel.tsx +++ b/src/tui/components/layout/MissingParamPanel.tsx @@ -14,6 +14,7 @@ import { Box, Text } from 'ink'; import type { ReactElement } from 'react'; import { Panel } from './Panel.js'; +import { Hotkey } from './Hotkey.js'; /** @@ -46,7 +47,7 @@ export function MissingParamPanel({ title, param, usage }: MissingParamPanelProp - [Esc] Back + Back ); diff --git a/src/tui/components/layout/NotFoundPanel.tsx b/src/tui/components/layout/NotFoundPanel.tsx index 4b1b6126..eced7979 100644 --- a/src/tui/components/layout/NotFoundPanel.tsx +++ b/src/tui/components/layout/NotFoundPanel.tsx @@ -14,6 +14,7 @@ import { Box, Text } from 'ink'; import type { ReactElement } from 'react'; import { Panel } from './Panel.js'; +import { Hotkey } from './Hotkey.js'; /** @@ -44,7 +45,7 @@ export function NotFoundPanel({ title, type, name }: NotFoundPanelProps): ReactE - [Esc] Back + Back ); diff --git a/src/tui/components/layout/Panel.tsx b/src/tui/components/layout/Panel.tsx index ba3e3f47..d871680d 100644 --- a/src/tui/components/layout/Panel.tsx +++ b/src/tui/components/layout/Panel.tsx @@ -77,7 +77,7 @@ export function Panel({ > {title && ( - + {title} diff --git a/src/tui/components/layout/index.ts b/src/tui/components/layout/index.ts index b2fd93b4..ed9b0af6 100644 --- a/src/tui/components/layout/index.ts +++ b/src/tui/components/layout/index.ts @@ -5,8 +5,10 @@ export { Panel } from './Panel.js'; export { Divider } from './Divider.js'; export { MissingParamPanel } from './MissingParamPanel.js'; export { NotFoundPanel } from './NotFoundPanel.js'; +export { Hotkey, HotkeyLine, KeyTarget, resolveHotkey } from './Hotkey.js'; export type { PanelProps } from './Panel.js'; export type { DividerProps } from './Divider.js'; export type { MissingParamPanelProps } from './MissingParamPanel.js'; export type { NotFoundPanelProps } from './NotFoundPanel.js'; +export type { HotkeyProps, KeyTargetProps } from './Hotkey.js'; diff --git a/src/tui/components/lists/ActionList.tsx b/src/tui/components/lists/ActionList.tsx index 4ce36b25..b1676f4a 100644 --- a/src/tui/components/lists/ActionList.tsx +++ b/src/tui/components/lists/ActionList.tsx @@ -19,6 +19,8 @@ import { Box, Text } from 'ink'; import type { ReactElement } from 'react'; +import { KeyTarget } from '../layout/Hotkey.js'; + /** * Action item. */ @@ -82,10 +84,12 @@ export function ActionList({ const labelColor = variantColors[action.variant ?? 'default'] ?? 'white'; return ( - - [{action.key}] - {action.label} - + + + [{action.key}] + {action.label} + + ); })} diff --git a/src/tui/components/lists/SearchableList.tsx b/src/tui/components/lists/SearchableList.tsx index 558b8af9..55b75e56 100644 --- a/src/tui/components/lists/SearchableList.tsx +++ b/src/tui/components/lists/SearchableList.tsx @@ -22,6 +22,7 @@ import type { ReactElement } from 'react'; import { useFocusScope } from '../../focus.js'; import { useViewportRows } from '../../hooks/useViewportRows.js'; import { SelectList } from './SelectList.js'; +import { Hotkey, KeyTarget } from '../layout/Hotkey.js'; import type { SelectListItem, SelectListProps } from './SelectList.js'; @@ -306,30 +307,34 @@ export function SearchableList({ const hasResults = filteredItems.length > 0; const isFiltering = searchTerm.trim().length > 0; - return ( - - {/* Search input row */} - - / - - {searchMode ? ( - - ) : ( - - {searchTerm || searchPlaceholder} - - )} - - {isFiltering && ( - ({filteredItems.length} matches) + const searchRow = ( + + / + + {searchMode ? ( + + ) : ( + + {searchTerm || searchPlaceholder} + )} + {isFiltering && ( + ({filteredItems.length} matches) + )} + + ); + + return ( + + {/* Search input row; a click on the idle row presses `/` */} + {searchMode || !isFocused ? searchRow : {searchRow}} {/* List or empty state */} {!hasItems ? ( @@ -351,12 +356,10 @@ export function SearchableList({ {/* Hint */} - - {searchMode - ? '[Esc] ' + (searchTerm ? 'Clear' : 'Exit search') - : '[/] Search' - } - + {searchMode + ? {searchTerm ? 'Clear' : 'Exit search'} + : Search + } ); diff --git a/src/tui/components/lists/SelectList.tsx b/src/tui/components/lists/SelectList.tsx index 25dfc73b..40681fd2 100644 --- a/src/tui/components/lists/SelectList.tsx +++ b/src/tui/components/lists/SelectList.tsx @@ -19,7 +19,7 @@ import { Box, Text, useInput } from 'ink'; import type { ReactElement, ReactNode } from 'react'; import { useFocusScope } from '../../focus.js'; -import { useRowMouse } from '../../mouse.js'; +import { hoverBackground, useRowMouse } from '../../mouse.js'; import { useOptionalRouter } from '../../router.js'; import { listMemoryKey, recallListPosition, rememberListPosition } from '../../list-memory.js'; import { useViewportRows } from '../../hooks/useViewportRows.js'; @@ -469,7 +469,7 @@ export function SelectList({ // Inert without a MouseProvider above it or with the setting off: no refs // are attached to the rows and nothing subscribes. - const { rowRef } = useRowMouse({ + const { rowRef, hovered } = useRowMouse({ isActive: isFocused && !isDisabled, onClick: setHighlightedIndex, onActivate: activateRow, @@ -516,6 +516,7 @@ export function SelectList({ const actualIndex = startIndex + visibleIndex; const isHighlighted = actualIndex === highlightedIndex; + const isHovered = hovered === actualIndex && !isHighlighted; // Number indicator (1-9, dimmed for items beyond 9) const numberIndicator = numberNav @@ -525,7 +526,12 @@ export function SelectList({ : ''; return ( - + {numberNav && ( {numberIndicator} @@ -547,6 +553,7 @@ export function SelectList({ {isHighlighted ? '❯ ' : ' '} {item.icon ? `${item.icon} ` : ''} @@ -560,7 +567,7 @@ export function SelectList({ {/* Description below (when showDescriptionBelow is true) */} {showDescriptionBelow && item.description && ( - {item.description} + {item.description} )} ); diff --git a/src/tui/components/lists/StatusList.tsx b/src/tui/components/lists/StatusList.tsx index 19439290..3eedd538 100644 --- a/src/tui/components/lists/StatusList.tsx +++ b/src/tui/components/lists/StatusList.tsx @@ -17,6 +17,8 @@ */ import { Box, Text } from 'ink'; +import { oneLine } from '../../utils/string.js'; + import type { ReactElement } from 'react'; /** @@ -86,8 +88,8 @@ export function StatusList({ items, showIcons = true, maxVisible }: StatusListPr return ( {showIcons && {config.icon}} - {item.label} - {item.detail && ({item.detail})} + {item.label} + {item.detail && ({oneLine(item.detail)})} ); diff --git a/src/tui/components/overlays/LogViewerOverlay.tsx b/src/tui/components/overlays/LogViewerOverlay.tsx index 23deb510..937f28c8 100644 --- a/src/tui/components/overlays/LogViewerOverlay.tsx +++ b/src/tui/components/overlays/LogViewerOverlay.tsx @@ -21,11 +21,14 @@ import { Box, Text, useInput } from 'ink'; import type { ReactElement } from 'react'; import { useFocusScope, useTextEntry } from '../../focus.js'; +import { hoverBackground, useRowMouse } from '../../mouse.js'; import { useAppContext } from '../../app-context.js'; import { readLogFile } from '../../../core/logger/reader.js'; import { Spinner } from '../feedback/index.js'; import type { LogEntry } from '../../../core/logger/types.js'; +import { oneLine } from '../../utils/string.js'; +import { Hotkey } from '../layout/Hotkey.js'; /** * Props for LogViewerOverlay. @@ -298,8 +301,36 @@ export function LogViewerOverlay({ onClose }: LogViewerOverlayProps): ReactEleme }); - // Calculate visible window - const startIdx = Math.max(0, highlightedIndex - Math.floor(VISIBLE_ENTRIES / 2)); + // The keyboard guard, plus the two modes that own their keys: a click must not + // reach past the search box or the detail view. + const { rowRef, hovered } = useRowMouse({ + isActive: isFocused && !searchMode && !showDetail, + onClick: setHighlightedIndex, + onActivate: (index) => { + + if (!filteredEntries[index]) return; + + setHighlightedIndex(index); + setShowDetail(true); + + }, + onWheel: (delta) => { + + setHighlightedIndex((prev) => Math.min(Math.max(prev + delta, 0), Math.max(0, filteredEntries.length - 1))); + + }, + }); + + // The window moves only when the highlight leaves it. Recentring on every + // change scrolled a clicked row away from the pointer before the second click. + const windowStart = useRef(0); + let startIdx = Math.min(windowStart.current, Math.max(0, filteredEntries.length - VISIBLE_ENTRIES)); + + if (highlightedIndex < startIdx) startIdx = highlightedIndex; + else if (highlightedIndex >= startIdx + VISIBLE_ENTRIES) startIdx = highlightedIndex - VISIBLE_ENTRIES + 1; + + windowStart.current = startIdx; + const visibleEntries = filteredEntries.slice(startIdx, startIdx + VISIBLE_ENTRIES); // Detail view @@ -321,7 +352,7 @@ export function LogViewerOverlay({ onClose }: LogViewerOverlayProps): ReactEleme gap={2} > Log Entry Detail - [Esc] Back + Back {/* Content */} @@ -339,7 +370,7 @@ export function LogViewerOverlay({ onClose }: LogViewerOverlayProps): ReactEleme borderColor="gray" paddingX={1} > - [Esc] Back to list + Back to list ); @@ -397,9 +428,15 @@ export function LogViewerOverlay({ onClose }: LogViewerOverlayProps): ReactEleme const actualIdx = startIdx + idx; const isHighlighted = actualIdx === highlightedIndex; + const isHovered = hovered === actualIdx && !isHighlighted; return ( - + {isHighlighted ? '>' : ' '} @@ -414,7 +451,7 @@ export function LogViewerOverlay({ onClose }: LogViewerOverlayProps): ReactEleme {entry.type.slice(0, 24)} - {entry.message.slice(0, 45)} + {oneLine(entry.message)} ); @@ -434,11 +471,12 @@ export function LogViewerOverlay({ onClose }: LogViewerOverlayProps): ReactEleme paddingX={1} gap={2} > - [/] Search - [g/G] Top/Bottom - [Space] {paused ? 'Resume' : 'Pause'} - [Enter] Detail - [Shift+L] Close + Search + Top + Bottom + {paused ? 'Resume' : 'Pause'} + Detail + Close ); diff --git a/src/tui/components/overlays/TextOverlay.tsx b/src/tui/components/overlays/TextOverlay.tsx index 46e550d2..b298bfa2 100644 --- a/src/tui/components/overlays/TextOverlay.tsx +++ b/src/tui/components/overlays/TextOverlay.tsx @@ -25,6 +25,7 @@ import type { ReactElement } from 'react'; import { useFocusScope } from '../../focus.js'; import { ScrollPane, rowBudget, wrapText } from '../terminal/index.js'; +import { HotkeyLine } from '../layout/Hotkey.js'; /** * Props for the text overlay. @@ -78,7 +79,7 @@ export function TextOverlay({ title, text, onClose }: TextOverlayProps): ReactEl {title} {'─'.repeat(width)} - [↑↓] Scroll [Ctrl+U/D] Page [Esc] Back + [↑↓] Scroll [Ctrl+U/D] Page [Esc] Back ); diff --git a/src/tui/components/secrets/SecretDefinitionList.tsx b/src/tui/components/secrets/SecretDefinitionList.tsx index fdbf0cc3..da247bb9 100644 --- a/src/tui/components/secrets/SecretDefinitionList.tsx +++ b/src/tui/components/secrets/SecretDefinitionList.tsx @@ -13,6 +13,7 @@ import type { StageSecret } from './types.js'; import { SelectList, type SelectListItem } from '../lists/index.js'; import { Confirm } from '../dialogs/index.js'; import { Panel } from '../layout/index.js'; +import { Hotkey, KeyTarget } from '../layout/Hotkey.js'; /** * Secret list item value. @@ -306,9 +307,11 @@ export function SecretDefinitionList({ {items.length === 0 ? ( No secrets defined. - + + Press a to add a secret definition. - + + ) : ( )} + + ); } /** - * Keyboard shortcuts help component for SecretDefinitionList. + * Drawn only in the list view: while the delete confirmation has focus, a + * clickable `[Enter] Edit` would press the confirmation's default answer. */ -export function SecretDefinitionListHelp(): ReactElement { +function SecretDefinitionListHelp(): ReactElement { return ( - [a] Add - [e] Edit - [d] Delete - [Enter] Edit - [Esc] Back + Add + Edit + Delete + Edit + Back ); diff --git a/src/tui/components/secrets/SecretValueList.tsx b/src/tui/components/secrets/SecretValueList.tsx index 3c2cb40d..541574ff 100644 --- a/src/tui/components/secrets/SecretValueList.tsx +++ b/src/tui/components/secrets/SecretValueList.tsx @@ -11,6 +11,7 @@ import type { ReactElement } from 'react'; import type { SecretValueItem, SecretValueSummary } from './types.js'; import { SelectList, type SelectListItem } from '../lists/index.js'; +import { Hotkey, KeyTarget } from '../layout/Hotkey.js'; /** * Props for SecretValueList. @@ -249,9 +250,11 @@ export function SecretValueList({ {secrets.length === 0 ? ( No secrets defined. - + + Press a to add a secret. - + + ) : ( - [a] Add - [e] Edit - [d] Delete - [Enter] Edit - [Esc] Back + Add + Edit + Delete + Edit + Back ); diff --git a/src/tui/components/secrets/index.ts b/src/tui/components/secrets/index.ts index 730aa096..8628b24e 100644 --- a/src/tui/components/secrets/index.ts +++ b/src/tui/components/secrets/index.ts @@ -10,7 +10,7 @@ export { SecretDefinitionForm } from './SecretDefinitionForm.js'; export type { SecretDefinitionFormProps } from './SecretDefinitionForm.js'; -export { SecretDefinitionList, SecretDefinitionListHelp } from './SecretDefinitionList.js'; +export { SecretDefinitionList } from './SecretDefinitionList.js'; export type { SecretDefinitionListProps } from './SecretDefinitionList.js'; // Value components (for actual secrets) diff --git a/src/tui/components/status/ConnectionStatus.tsx b/src/tui/components/status/ConnectionStatus.tsx index 52c8413f..470c655e 100644 --- a/src/tui/components/status/ConnectionStatus.tsx +++ b/src/tui/components/status/ConnectionStatus.tsx @@ -19,6 +19,7 @@ import { Badge } from '@inkjs/ui'; import type { ReactElement } from 'react'; import { isSslFileErrorMessage } from '../../../core/connection/ssl-files.js'; +import { KeyTarget } from '../layout/Hotkey.js'; /** * Connection status types. @@ -95,7 +96,7 @@ export function ConnectionStatus({ return ( {config.label} - {configName && status === 'connected' && ({configName})} + {configName && status === 'connected' && ({configName})} ); @@ -123,7 +124,7 @@ export function ConnectionStatus({ {status === 'no-database' && ( - Database does not exist yet. Press [c] to create. + Database does not exist yet. Press [c] to create. )} diff --git a/src/tui/components/terminal/ResultTable.tsx b/src/tui/components/terminal/ResultTable.tsx index 0032ceaf..c70be556 100644 --- a/src/tui/components/terminal/ResultTable.tsx +++ b/src/tui/components/terminal/ResultTable.tsx @@ -37,10 +37,11 @@ import { Box, Text, useInput, useWindowSize } from 'ink'; import v from 'voca'; import type { ReactElement } from 'react'; -import { isMouseReport, useRowMouse } from '../../mouse.js'; +import { hoverBackground, isMouseReport, useRowMouse } from '../../mouse.js'; import { useTextEntry } from '../../focus.js'; import { fitGridColumns } from './columnFit.js'; import { documentValue } from './rowDocument.js'; +import { HotkeyLine } from '../layout/Hotkey.js'; /** * Props for ResultTable component. @@ -793,7 +794,7 @@ export function ResultTable({ // filter box and the sort picker own their own keys, and a click should not // reach past whichever one is open. Inert without a MouseProvider above it // or with the setting off. - const { rowRef } = useRowMouse({ + const { rowRef, hovered } = useRowMouse({ isActive: isActive && mode === 'browse', onClick: moveCursor, onActivate: (index) => { @@ -818,8 +819,14 @@ export function ResultTable({ const renderRow = useCallback( (row: Record, index: number, isHighlighted: boolean) => { + const isHovered = hovered === index && !isHighlighted; + return ( - + {drawnColumns.map((col, colIndex) => { const colWidth = columnWidths[col] ?? col.length; @@ -843,7 +850,7 @@ export function ResultTable({ ); }, - [drawnColumns, columnWidths, rowRef], + [drawnColumns, columnWidths, rowRef, hovered], ); // Calculate total width for separator @@ -888,14 +895,13 @@ export function ResultTable({ )} {mode === 'filter' && ( - - [Tab] Column [Enter] Apply [Esc] Cancel - + [Tab] Column [Enter] Apply [Esc] Cancel )} {mode === 'sort' && ( - - [←/→] Column: {columns[sortColumnIndex]} [a] Asc [d] Desc - + + [←/→] Column: {columns[sortColumnIndex]} + [a] Asc [d] Desc + )} @@ -996,10 +1002,7 @@ export function ResultTable({ the reader pressing keys that go nowhere. */} {mode === 'browse' && isActive && ( - - [/] Filter [s] Sort [c] Clear [↑/↓] Navigate - {onSelect ? ' [↵] Open row' : ''} - + {`[/] Filter [s] Sort [c] Clear [↑/↓] Navigate${onSelect ? ' [↵] Open row' : ''}`} )} diff --git a/src/tui/components/terminal/RowViewOverlay.tsx b/src/tui/components/terminal/RowViewOverlay.tsx index fa881cc5..8b21bb14 100644 --- a/src/tui/components/terminal/RowViewOverlay.tsx +++ b/src/tui/components/terminal/RowViewOverlay.tsx @@ -44,6 +44,7 @@ import { useFocusScope } from '../../focus.js'; import { useWheelScroll } from '../../mouse.js'; import { preferredRowFormat, rememberRowFormat, renderRowDocument } from './rowDocument.js'; import { rowBudget, rowWindow, scrollTarget, wrapText } from './viewport.js'; +import { HotkeyLine } from '../layout/Hotkey.js'; /** * Props for the row view overlay. @@ -188,9 +189,7 @@ export function RowViewOverlay({ Row · {setLabel} · row {index + 1} of {rows.length} - - [←/→] Row [↑/↓] Scroll [f] {other} [Esc] Back - + {`[←/→] Row [↑/↓] Scroll [f] ${other} [Esc] Back`} {view.above > 0 && ↑ {view.above} more} {lines.slice(view.start, view.start + view.count).map((line, position) => ( {line} diff --git a/src/tui/components/terminal/SqlInput.tsx b/src/tui/components/terminal/SqlInput.tsx index 60d50125..23e995f8 100644 --- a/src/tui/components/terminal/SqlInput.tsx +++ b/src/tui/components/terminal/SqlInput.tsx @@ -13,6 +13,7 @@ import type { ReactElement } from 'react'; import { isMouseReport } from '../../mouse.js'; import { useTextEntry } from '../../focus.js'; +import { HotkeyLine } from '../layout/Hotkey.js'; /** * Props for SqlInput component. @@ -277,11 +278,11 @@ export function SqlInput({ ))} - + {editMode ? '[Shift+Tab] Exit edit [Enter] New line' : '[Shift+Tab] Edit mode [Enter] Execute [Shift+Enter] New line'} - + ); diff --git a/src/tui/focus.tsx b/src/tui/focus.tsx index 82d333fc..8726eff9 100644 --- a/src/tui/focus.tsx +++ b/src/tui/focus.tsx @@ -27,7 +27,7 @@ import { createContext, useContext, useState, useCallback, useMemo, useId, useEf import type { ReactNode, ReactElement } from 'react'; -import type { FocusEntry, FocusContextValue } from './types.js'; +import type { FocusEntry, FocusContextValue, GuardKind } from './types.js'; const FocusContext = createContext(null); @@ -60,6 +60,22 @@ export function FocusProvider({ children }: FocusProviderProps): ReactElement { const isTyping = useCallback(() => activeTextEntries.current > 0, []); + const guards = useRef>({ busy: 0, modal: 0 }); + + const isGuarded = useCallback((kind: GuardKind) => guards.current[kind] > 0, []); + + const beginGuard = useCallback((kind: GuardKind) => { + + guards.current[kind] += 1; + + return () => { + + guards.current[kind] -= 1; + + }; + + }, []); + const beginTextEntry = useCallback(() => { activeTextEntries.current += 1; @@ -145,8 +161,10 @@ export function FocusProvider({ children }: FocusProviderProps): ReactElement { stack, isTyping, beginTextEntry, + isGuarded, + beginGuard, }), - [push, pop, isActive, activeId, stack, isTyping, beginTextEntry], + [push, pop, isActive, activeId, stack, isTyping, beginTextEntry, isGuarded, beginGuard], ); return {children}; @@ -269,6 +287,75 @@ export function useTextEntry(active: boolean): void { } +function useGuard(kind: GuardKind, active: boolean): void { + + const beginGuard = useContext(FocusContext)?.beginGuard; + + useEffect(() => { + + if (!active || !beginGuard) return; + + return beginGuard(kind); + + }, [kind, active, beginGuard]); + +} + +/** + * Mark the screen busy while `active`, so a click on the breadcrumb does not + * walk away from a running operation. Keyboard Escape is the screen's own call. + * + * @example + * useBusy(step === 'running'); + */ +export function useBusy(active: boolean): void { + + useGuard('busy', active); + +} + +/** + * Mark a confirmation as holding the screen while `active`, so a click on a hint + * behind it cannot press the confirmation's keys. + * + * @example + * useModal(isFocused); + */ +export function useModal(active: boolean): void { + + useGuard('modal', active); + +} + +/** + * Whether the given guard is held right now, read at call time. + * Always false outside a FocusProvider. + * + * @example + * const isGuarded = useIsGuarded(); + * if (!isGuarded('busy')) backBy(1); + */ +export function useIsGuarded(): (kind: GuardKind) => boolean { + + return useContext(FocusContext)?.isGuarded ?? (() => false); + +} + +/** + * Whether a text field is taking keystrokes right now, read at call time. + * + * Always false outside a FocusProvider. + * + * @example + * const isTyping = useIsTyping(); + * if (!isTyping()) press('r'); + */ +export function useIsTyping(): () => boolean { + + return useContext(FocusContext)?.isTyping ?? (() => false); + +} + /** * Hook to check if a specific focus ID is active. * diff --git a/src/tui/hooks/index.ts b/src/tui/hooks/index.ts index 0ae14f22..0de2e805 100644 --- a/src/tui/hooks/index.ts +++ b/src/tui/hooks/index.ts @@ -5,7 +5,6 @@ export { useOnEvent, useOnceEvent, useEmit, - useOnScreenPopped, } from './useObserver.js'; export { diff --git a/src/tui/hooks/useObserver.ts b/src/tui/hooks/useObserver.ts index 2679bcdd..78e6e280 100644 --- a/src/tui/hooks/useObserver.ts +++ b/src/tui/hooks/useObserver.ts @@ -18,7 +18,7 @@ * emitStart({ sqlPath, fileCount }) * ``` */ -import { useCallback, useRef, useMemo, type DependencyList } from 'react'; +import { useCallback, useRef, type DependencyList } from 'react'; import { useNoormObserver } from '../observer-context.js'; import type { NoormEvents, NoormEventNames } from '../../core/observer.js'; @@ -103,56 +103,3 @@ export function useEmit( } -/** - * Run a callback when specific screens are popped from history. - * - * This is useful for cleaning up state when navigating away from a screen. - * The callback fires when `back()` is called and the popped route matches - * one of the specified screens. - * - * @param screens - Screen route(s) to watch. Can be a single string or array. - * Use prefix patterns like 'db/explore' to match all sub-routes. - * @param callback - Function to run when a matching screen is popped. - * - * @example - * ```typescript - * // Clear explore filter state when any explore screen is popped - * useOnScreenPopped('db/explore', () => { - * clearExploreFilters() - * }) - * - * // Watch multiple screens - * useOnScreenPopped(['config/edit', 'config/add'], () => { - * resetFormState() - * }) - * ``` - */ -export function useOnScreenPopped( - screens: string | string[], - callback: (poppedRoute: string, toRoute: string) => void, -): void { - - const screensArray = useMemo( - () => (Array.isArray(screens) ? screens : [screens]), - [screens], - ); - - const callbackRef = useRef(callback); - callbackRef.current = callback; - - useOnEvent('router:popped', (data) => { - - // Check if the popped route matches any of the watched screens - const matches = screensArray.some((screen) => - data.popped === screen || data.popped.startsWith(screen + '/'), - ); - - if (matches) { - - callbackRef.current(data.popped, data.to); - - } - - }, [screensArray]); - -} diff --git a/src/tui/mouse.tsx b/src/tui/mouse.tsx index 94282eb9..0211144a 100644 --- a/src/tui/mouse.tsx +++ b/src/tui/mouse.tsx @@ -7,11 +7,12 @@ * compared with it. So Ink hands over the hit-testing primitive and assumes the * events come from somewhere else. This is that somewhere else. * - * **Press and release only.** `?1000` is the weakest tracking mode a terminal - * offers, and it is all a click and a wheel notch need. `?1002` (drag) and - * `?1003` (any motion) take the mouse away from the terminal far more - * completely, and every extra report is a report some other handler has to be - * taught to ignore. + * **Motion is reported so the row under the pointer can be highlighted.** + * `?1003` (any motion) is the only mode that reports a pointer with no button + * held. Its cost is one report per cell the pointer crosses, and every one + * reaches every `useInput` handler, so a motion report is decoded as `move` + * and never as a press: handlers that act on `press` ignore it. A handler that + * accumulates characters or acts on any key must drop it with `isMouseReport`. * * **The reports arrive through `useInput`.** Ink's input parser splits each CSI * sequence into its own event and `parse-keypress` leaves an SGR report alone — @@ -25,7 +26,7 @@ * * ``` */ -import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { measureElement, useInput, useStdout } from 'ink'; import { attemptSync } from '@logosdx/utils'; @@ -35,13 +36,13 @@ import type { DOMElement } from 'ink'; const ESC = '\u001b'; /** - * Turn on press/release tracking with SGR extended coordinates. + * Turn on press, release and motion tracking with SGR extended coordinates. * * SGR (`?1006`) is not optional: the original protocol encodes a coordinate as * a single byte, so it cannot report a column past 223, and a maximised * terminal is routinely wider than that. */ -export const MOUSE_ENABLE = `${ESC}[?1000h${ESC}[?1006h`; +export const MOUSE_ENABLE = `${ESC}[?1000h${ESC}[?1003h${ESC}[?1006h`; /** * Turn tracking back off, in the reverse order it went on. @@ -50,7 +51,7 @@ export const MOUSE_ENABLE = `${ESC}[?1000h${ESC}[?1006h`; * stays in mouse mode after the process is gone, and click-drag text selection * stays broken in every shell in that window until the user resets it by hand. */ -export const MOUSE_DISABLE = `${ESC}[?1006l${ESC}[?1000l`; +export const MOUSE_DISABLE = `${ESC}[?1006l${ESC}[?1003l${ESC}[?1000l`; /** * `ESC [ < button ; column ; row M` for a press, lowercase `m` for a release. @@ -108,21 +109,20 @@ const LIVE_REGION_ORIGIN_COLUMN = 1; */ export const DOUBLE_CLICK_MS = 400; +export { hoverBackground, setHoverBackground } from './terminal-background.js'; + /** - * Which button a report came from. - * - * Wheel notches arrive even in press-only tracking, which is why they are here - * rather than behind a stronger mode. + * Which button a report came from. `none` marks a motion report. */ -export type MouseButton = 'left' | 'middle' | 'right' | 'wheel-up' | 'wheel-down'; +export type MouseButton = 'left' | 'middle' | 'right' | 'wheel-up' | 'wheel-down' | 'none'; /** * A decoded mouse report, in live-region coordinates. */ export interface MouseEvent { - /** `M` reports a press, `m` a release. A wheel notch only ever presses. */ - kind: 'press' | 'release'; + /** `M` reports a press, `m` a release. A wheel notch only ever presses. A motion report is `move`. */ + kind: 'press' | 'release' | 'move'; button: MouseButton; @@ -132,6 +132,14 @@ export interface MouseEvent { /** Zero-based column, directly comparable with `measureElement`'s `x`. */ column: number; + /** + * A left press on the same cell as the previous one, inside the + * double-click window: the second press of a double click. Decided once for + * every subscriber, because what sits under the pointer may have changed + * after the first press. + */ + repeat: boolean; + shift: boolean; /** The Alt/Option modifier — SGR calls this bit "meta". */ @@ -176,10 +184,10 @@ function decodeButton(code: number): MouseButton | null { * Is this string an SGR mouse report? * * Separate from `parseMouseReport` because the two questions differ: a handler - * that accumulates characters needs to drop anything the terminal sent as a - * mouse report, including the ones the parser declines to decode. Answering - * that with `parseMouseReport(input) !== null` would type a stray drag report - * into a filter box. + * that accumulates characters, or acts on any key, needs to drop anything the + * terminal sent as a mouse report, including the ones the parser declines to + * decode (the legacy "button released" code). Every motion report reaches every + * `useInput` handler, so a handler without this guard fires on a mouse move. * * @example * useInput((input, key) => { @@ -195,10 +203,9 @@ export function isMouseReport(input: string): boolean { /** * Decode an SGR mouse report into live-region coordinates. * - * Returns `null` for anything that is not a report this module acts on, which - * includes motion reports: press-only tracking never asks for them, so one - * arriving is a terminal volunteering more than it was told to, and treating it - * as a press would make a drag read as a click on every cell it crossed. + * Returns `null` for anything that is not a report this module acts on. + * Motion is decoded as `move` and never as a press, so a drag cannot read as a + * click on every cell it crosses. * * @example * parseMouseReport('[<0;12;5M'); // { kind: 'press', button: 'left', row: 4, column: 11, ... } @@ -210,15 +217,17 @@ export function parseMouseReport(input: string): MouseEvent | null { if (!match) return null; const code = Number(match[1]); - const button = (code & MOTION_BIT) === 0 ? decodeButton(code) : null; + const isMotion = (code & MOTION_BIT) !== 0; + const button = isMotion ? 'none' : decodeButton(code); if (button === null) return null; return { - kind: match[4] === 'M' ? 'press' : 'release', + kind: isMotion ? 'move' : match[4] === 'M' ? 'press' : 'release', button, row: Number(match[3]) - LIVE_REGION_ORIGIN_ROW, column: Number(match[2]) - LIVE_REGION_ORIGIN_COLUMN, + repeat: false, shift: (code & SHIFT_BIT) !== 0, alt: (code & ALT_BIT) !== 0, ctrl: (code & CTRL_BIT) !== 0, @@ -345,11 +354,29 @@ function MouseTracking({ handlers }: { handlers: Set }): null { }, [stdout]); + const lastLeftPress = useRef<{ row: number; column: number; at: number } | null>(null); + useInput((input) => { - const event = parseMouseReport(input); + const parsed = parseMouseReport(input); + + if (!parsed) return; + + let event = parsed; + + if (parsed.kind === 'press' && parsed.button === 'left') { + + const now = Date.now(); + const previous = lastLeftPress.current; + const repeat = previous !== null + && previous.row === parsed.row + && previous.column === parsed.column + && now - previous.at <= DOUBLE_CLICK_MS; - if (!event) return; + lastLeftPress.current = { row: parsed.row, column: parsed.column, at: now }; + event = { ...parsed, repeat }; + + } for (const handler of handlers) { @@ -523,6 +550,16 @@ export interface RowMouseOptions { /** A wheel notch: -1 for up, 1 for down. */ onWheel: (delta: -1 | 1) => void; + /** Also require the click inside the box's columns. For targets that share a line, such as buttons. */ + matchColumn?: boolean; + + /** + * Drop the second press of a double click. For targets where a double click + * means nothing extra (a button, a hint): without it, the second press lands + * on whatever the first press brought under the pointer. + */ + ignoreRepeats?: boolean; + } /** @@ -539,26 +576,29 @@ export interface RowMouse { */ rowRef: (index: number) => ((node: DOMElement | null) => void) | undefined; + /** Index of the row under the pointer, or null. Always null while `isActive` is false. */ + hovered: number | null; + } /** * Which registered row, if any, contains a given live-region row. * - * The column is deliberately ignored. A row's box is only as wide as its - * content, so requiring a horizontal hit would make a click past the end of a - * short label miss a row the reader is plainly pointing at. + * The column is ignored unless `column` is given. A list row's box is only as + * wide as its content, so requiring a horizontal hit would make a click past + * the end of a short label miss a row the reader is plainly pointing at. */ -function hitTest(nodes: Map, row: number): number | null { +function hitTest(nodes: Map, row: number, column?: number): number | null { for (const [index, node] of nodes) { const box = measureElement(node); - if (box.height > 0 && row >= box.y && row < box.y + box.height) { + if (box.height === 0 || row < box.y || row >= box.y + box.height) continue; - return index; + if (column !== undefined && (column < box.x || column >= box.x + box.width)) continue; - } + return index; } @@ -567,7 +607,8 @@ function hitTest(nodes: Map, row: number): number | null { } /** - * Click, double-click and wheel handling for a list of rows. + * Click, double-click, wheel and hover handling for a list of rows. `hovered` + * is the row under the pointer, for the caller to shade. * * Hit-tests against the rows themselves rather than against arithmetic on a * container: a `SelectList` row is one line or two depending on whether that @@ -576,23 +617,26 @@ function hitTest(nodes: Map, row: number): number | null { * * @example * ```tsx - * const { rowRef } = useRowMouse({ + * const { rowRef, hovered } = useRowMouse({ * isActive: isFocused, * onClick: setHighlightedIndex, * onActivate: selectRow, * onWheel: (delta) => step(delta), * }); * - * … + * … * ``` */ -export function useRowMouse({ isActive, onClick, onActivate, onWheel }: RowMouseOptions): RowMouse { +export function useRowMouse({ isActive, onClick, onActivate, onWheel, matchColumn = false, ignoreRepeats = false }: RowMouseOptions): RowMouse { const { enabled, subscribe } = useMouseTransport(); const nodes = useRef>(new Map()); const setters = useRef void>>(new Map()); const lastPress = useRef<{ index: number; at: number } | null>(null); + const lastSeenPress = useRef<{ index: number; at: number } | null>(null); + const [hoveredIndex, setHoveredIndex] = useState(null); + const pointer = useRef<{ row: number; column: number } | null>(null); // The handler is registered once and reads the latest props through this // ref. Listing them as effect dependencies would resubscribe on every @@ -608,8 +652,26 @@ export function useRowMouse({ isActive, onClick, onActivate, onWheel }: RowMouse const current = latest.current; + // Setting the index it already holds does not re-render, so a pointer + // moving inside one row costs a hit test and nothing else. + if (event.kind === 'move') { + + pointer.current = { row: event.row, column: event.column }; + + const over = current.isActive + ? hitTest(nodes.current, event.row, matchColumn ? event.column : undefined) + : null; + + setHoveredIndex(over); + + return; + + } + if (!current.isActive || event.kind !== 'press') return; + if (ignoreRepeats && event.repeat) return; + if (event.button === 'wheel-up') { current.onWheel(-1); @@ -628,11 +690,19 @@ export function useRowMouse({ isActive, onClick, onActivate, onWheel }: RowMouse if (event.button !== 'left') return; - const index = hitTest(nodes.current, event.row); + const index = hitTest(nodes.current, event.row, matchColumn ? event.column : undefined); if (index === null) return; const now = Date.now(); + const seen = lastSeenPress.current; + + lastSeenPress.current = { index, at: now }; + + // The second press of a double click belongs to whoever took the first. If + // the first press changed the screen, it is a stray press on something new. + if (event.repeat && !(seen !== null && seen.index === index && now - seen.at <= DOUBLE_CLICK_MS)) return; + const previous = lastPress.current; const isDouble = previous !== null && previous.index === index @@ -647,7 +717,21 @@ export function useRowMouse({ isActive, onClick, onActivate, onWheel }: RowMouse }); - }, [enabled, subscribe]); + }, [enabled, subscribe, matchColumn, ignoreRepeats]); + + // Content that scrolls under a resting pointer sends no report, so re-test the + // last known position after every render. An unchanged index does not re-render. + useEffect(() => { + + if (!enabled || pointer.current === null) return; + + setHoveredIndex( + latest.current.isActive + ? hitTest(nodes.current, pointer.current.row, matchColumn ? pointer.current.column : undefined) + : null, + ); + + }); const rowRef = useCallback( (index: number) => { @@ -675,6 +759,6 @@ export function useRowMouse({ isActive, onClick, onActivate, onWheel }: RowMouse [enabled], ); - return { enabled, rowRef }; + return { enabled, rowRef, hovered: isActive ? hoveredIndex : null }; } diff --git a/src/tui/router.tsx b/src/tui/router.tsx index c4cbcd82..0fc5ac5c 100644 --- a/src/tui/router.tsx +++ b/src/tui/router.tsx @@ -16,7 +16,7 @@ * if (canGoBack) back() * ``` */ -import { createContext, useContext, useState, useCallback, useMemo } from 'react'; +import { createContext, useContext, useState, useCallback, useMemo, useRef } from 'react'; import type { ReactNode, ReactElement } from 'react'; @@ -139,9 +139,15 @@ export function RouterProvider({ [route, params, history], ); + // A completion handler that outlives its screen holds a `back` from when that + // screen was current; running it would pop the history a second time. + const liveRoute = useRef(route); + + liveRoute.current = route; + const back = useCallback(() => { - if (history.length === 0) { + if (history.length === 0 || liveRoute.current !== route) { return; @@ -165,6 +171,31 @@ export function RouterProvider({ }, [history, route]); + const backBy = useCallback((steps: number) => { + + const count = Math.min(Math.floor(steps), history.length); + + if (count < 1) return; + + const target = history.length - count; + const destination = history[target]!; + + setHistory((prev) => prev.slice(0, target)); + setRoute(destination.route); + setParams(destination.params); + setArrivedBy('pop'); + + let popped = route; + + for (let i = history.length - 1; i >= target; i--) { + + observer.emit('router:popped', { popped, to: history[i]!.route }); + popped = history[i]!.route; + + } + + }, [history, route]); + const replace = useCallback((newRoute: Route, newParams: RouteParams = {}) => { // Replace without modifying history @@ -195,12 +226,13 @@ export function RouterProvider({ arrivedBy, navigate, back, + backBy, replace, reset, canGoBack: history.length > 0, section: getSection(route), }), - [route, params, history, arrivedBy, navigate, back, replace, reset], + [route, params, history, arrivedBy, navigate, back, backBy, replace, reset], ); return {children}; diff --git a/src/tui/screens/MoreScreen.tsx b/src/tui/screens/MoreScreen.tsx index 8d2453ee..67270d9a 100644 --- a/src/tui/screens/MoreScreen.tsx +++ b/src/tui/screens/MoreScreen.tsx @@ -11,6 +11,7 @@ import type { ScreenProps } from '../types.js'; import { useRouter } from '../router.js'; import { useFocusScope } from '../focus.js'; import { Panel } from '../components/index.js'; +import { Hotkey, KeyTarget } from '../components/layout/Hotkey.js'; /** * MoreScreen component. @@ -44,35 +45,43 @@ export function MoreScreen({ params: _params }: ScreenProps): ReactElement { - - [s] - Settings - - Edit .noorm/settings.yml - - - [v] - Vault - - Team-shared encrypted secrets - - - [i] - Identity - - Your cryptographic identity - - - [l] - Lock - - Concurrent operation locks - + + + [s] + Settings + - Edit .noorm/settings.yml + + + + + [v] + Vault + - Team-shared encrypted secrets + + + + + [i] + Identity + - Your cryptographic identity + + + + + [l] + Lock + - Concurrent operation locks + + - [s] Settings - [v] Vault - [i] Identity - [l] Lock - [Esc] Back + Settings + Vault + Identity + Lock + Back ); diff --git a/src/tui/screens/UpdateScreen.tsx b/src/tui/screens/UpdateScreen.tsx index 9a6cba59..7de6cc12 100644 --- a/src/tui/screens/UpdateScreen.tsx +++ b/src/tui/screens/UpdateScreen.tsx @@ -13,6 +13,7 @@ import { useFocusScope } from '../focus.js'; import { useRouter } from '../router.js'; import { useToast, Spinner, ProgressBar } from '../components/index.js'; import { useUpdateChecker, useUpdateProgress } from '../hooks/index.js'; +import { Hotkey, HotkeyLine } from '../components/layout/Hotkey.js'; /** * Update screen showing current version, update availability, and install action. @@ -149,7 +150,7 @@ export function UpdateScreen(_props: ScreenProps): ReactElement { Update complete! Restart noorm to use the new version. - [esc] back + back ); @@ -164,7 +165,7 @@ export function UpdateScreen(_props: ScreenProps): ReactElement { Update failed {error} - [r] retry [esc] back + [r] retry [esc] back ); @@ -178,7 +179,7 @@ export function UpdateScreen(_props: ScreenProps): ReactElement { Could not check for updates. You may be offline. - [r] retry [esc] back + [r] retry [esc] back ); @@ -193,7 +194,7 @@ export function UpdateScreen(_props: ScreenProps): ReactElement { Current version: {updateInfo.currentVersion} Already up to date. - [r] check again [esc] back + [r] check again [esc] back ); @@ -209,7 +210,7 @@ export function UpdateScreen(_props: ScreenProps): ReactElement { This is a major version update. )} - [i] install [esc] back + [i] install [esc] back ); diff --git a/src/tui/screens/change/ChangeAddScreen.tsx b/src/tui/screens/change/ChangeAddScreen.tsx index dafa53ce..74737be6 100644 --- a/src/tui/screens/change/ChangeAddScreen.tsx +++ b/src/tui/screens/change/ChangeAddScreen.tsx @@ -32,6 +32,8 @@ import { useAppContext } from '../../app-context.js'; import { Panel, Spinner, StatusMessage, TextInput } from '../../components/index.js'; import { createChange, addFile } from '../../../core/change/scaffold.js'; import { getErrorMessage, toKebabCase, resolveChangesDir } from '../../utils/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; +import { isMouseReport } from '../../mouse.js'; /** * Add steps. @@ -174,6 +176,8 @@ export function ChangeAddScreen({ params }: ScreenProps): ReactElement { if (!isFocused) return; + if (isMouseReport(input)) return; + if (step === 'input') { if (key.return) { @@ -274,8 +278,8 @@ export function ChangeAddScreen({ params }: ScreenProps): ReactElement { )} - [Enter] Create - [Esc] Cancel + Create + Cancel @@ -307,7 +311,7 @@ export function ChangeAddScreen({ params }: ScreenProps): ReactElement { Created folder structure with template files. - [e] Edit in editor + Edit in editor [any] Back to list @@ -323,7 +327,7 @@ export function ChangeAddScreen({ params }: ScreenProps): ReactElement { {error} - [r] Retry + Retry [any] Back diff --git a/src/tui/screens/change/ChangeEditScreen.tsx b/src/tui/screens/change/ChangeEditScreen.tsx index 0a6b4439..08f73a5f 100644 --- a/src/tui/screens/change/ChangeEditScreen.tsx +++ b/src/tui/screens/change/ChangeEditScreen.tsx @@ -24,6 +24,7 @@ import { Panel, Spinner, StatusMessage, MissingParamPanel } from '../../componen import { useAsyncEffect } from '../../hooks/index.js'; import { getErrorMessage, resolveChangesDir, resolveSqlDir } from '../../utils/index.js'; import { scanChanges } from '../../../core/change/parser.js'; +import { isMouseReport } from '../../mouse.js'; /** * Edit steps. @@ -110,10 +111,12 @@ export function ChangeEditScreen({ params }: ScreenProps): ReactElement { }, [activeConfig, changeName]); // Keyboard handling - useInput((_input, _key) => { + useInput((input, _key) => { if (!isFocused) return; + if (isMouseReport(input)) return; + if (step === 'complete' || step === 'error') { back(); diff --git a/src/tui/screens/change/ChangeFFScreen.tsx b/src/tui/screens/change/ChangeFFScreen.tsx index 4fc29ddd..1bafcf27 100644 --- a/src/tui/screens/change/ChangeFFScreen.tsx +++ b/src/tui/screens/change/ChangeFFScreen.tsx @@ -21,7 +21,7 @@ import type { Kysely } from 'kysely'; import { attempt } from '@logosdx/utils'; import { useRouter } from '../../router.js'; -import { useFocusScope } from '../../focus.js'; +import { useBusy, useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, @@ -43,6 +43,7 @@ import { } from '../../utils/index.js'; import { validateChangeContent } from '../../../core/change/validation.js'; import { createConnection } from '../../../core/connection/factory.js'; +import { isMouseReport } from '../../mouse.js'; /** * FF steps. @@ -70,6 +71,8 @@ export function ChangeFFScreen({ params: _params }: ScreenProps): ReactElement { const [pendingChanges, setPendingChanges] = useState([]); const [error, setError] = useState(null); + useBusy(step === 'running'); + // Load pending changes useAsyncEffect(async (isCancelled) => { @@ -201,10 +204,12 @@ export function ChangeFFScreen({ params: _params }: ScreenProps): ReactElement { }, [back]); // Keyboard handling - useInput((_input, _key) => { + useInput((input, _key) => { if (!isFocused) return; + if (isMouseReport(input)) return; + if (step === 'complete' || step === 'error') { back(); diff --git a/src/tui/screens/change/ChangeHistoryDetailScreen.tsx b/src/tui/screens/change/ChangeHistoryDetailScreen.tsx index 40d45dd0..e3813129 100644 --- a/src/tui/screens/change/ChangeHistoryDetailScreen.tsx +++ b/src/tui/screens/change/ChangeHistoryDetailScreen.tsx @@ -33,6 +33,7 @@ import { Panel, Spinner, SelectList, TextOverlay } from '../../components/index. import { useConnection, useAsyncEffect } from '../../hooks/index.js'; import { ChangeHistory } from '../../../core/change/history.js'; import { getErrorMessage, oneLine } from '../../utils/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * Rows this screen spends inside its Panel on everything that is not the list. @@ -334,8 +335,8 @@ export function ChangeHistoryDetailScreen({ params }: ScreenProps): ReactElement {/* Keyboard hints */} [↑↓] Navigate - {failureText && [Enter] Full Error} - [Esc] Back + {failureText && Full Error} + Back diff --git a/src/tui/screens/change/ChangeHistoryScreen.tsx b/src/tui/screens/change/ChangeHistoryScreen.tsx index aa22aa14..64344afa 100644 --- a/src/tui/screens/change/ChangeHistoryScreen.tsx +++ b/src/tui/screens/change/ChangeHistoryScreen.tsx @@ -34,6 +34,7 @@ import { useConnection, useAsyncEffect } from '../../hooks/index.js'; import { ChangeHistory } from '../../../core/change/history.js'; import { relativeTimeAgo } from '../../utils/date.js'; import { getErrorMessage, oneLine } from '../../utils/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * Records fetched for the list. @@ -328,9 +329,9 @@ export function ChangeHistoryScreen({ params: _params }: ScreenProps): ReactElem {/* Keyboard hints */} [↑↓] Navigate - [Enter] View Files - {failureText && [e] Full Error} - [Esc] Back + View Files + {failureText && Full Error} + Back diff --git a/src/tui/screens/change/ChangeListScreen.tsx b/src/tui/screens/change/ChangeListScreen.tsx index 42f7bd76..af2c85e0 100644 --- a/src/tui/screens/change/ChangeListScreen.tsx +++ b/src/tui/screens/change/ChangeListScreen.tsx @@ -25,6 +25,7 @@ import { Panel, Spinner } from '../../components/index.js'; import { useAsyncEffect } from '../../hooks/index.js'; import { getErrorMessage, loadChangesWithStatus, buildMergedChangeList } from '../../utils/index.js'; import { relativeTimeAgo } from '../../utils/date.js'; +import { Hotkey, KeyTarget } from '../../components/layout/Hotkey.js'; /** * Get status indicator for a change. @@ -311,7 +312,7 @@ export function ChangeListScreen({ params: _params }: ScreenProps): ReactElement {/* Change List */} {changes.length === 0 ? ( - No changes found. Press [a] to create one. + No changes found. Press [a] to create one. ) : ( @@ -384,16 +385,16 @@ export function ChangeListScreen({ params: _params }: ScreenProps): ReactElement {/* Keyboard hints */} - [a] Add - [e] Edit - [d] Delete - [r] Run - [v] Revert - [n] Next - [f] FF - [w] Rewind - [h] History - [Esc] Back + Add + Edit + Delete + Run + Revert + Next + FF + Rewind + History + Back diff --git a/src/tui/screens/change/ChangeNextScreen.tsx b/src/tui/screens/change/ChangeNextScreen.tsx index e215be4f..78f9fcae 100644 --- a/src/tui/screens/change/ChangeNextScreen.tsx +++ b/src/tui/screens/change/ChangeNextScreen.tsx @@ -22,7 +22,7 @@ import type { Kysely } from 'kysely'; import { attempt } from '@logosdx/utils'; import { useRouter } from '../../router.js'; -import { useFocusScope } from '../../focus.js'; +import { useBusy, useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, @@ -44,6 +44,8 @@ import { progressPercentage, } from '../../utils/index.js'; import { createConnection } from '../../../core/connection/factory.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; +import { isMouseReport } from '../../mouse.js'; /** * Next steps. @@ -77,6 +79,8 @@ export function ChangeNextScreen({ params }: ScreenProps): ReactElement { const [countInput, setCountInput] = useState(String(initialCount)); const [error, setError] = useState(null); + useBusy(step === 'running'); + // Load pending changes useAsyncEffect(async (isCancelled) => { @@ -215,6 +219,8 @@ export function ChangeNextScreen({ params }: ScreenProps): ReactElement { if (!isFocused) return; + if (isMouseReport(input)) return; + if (step === 'input') { if (key.return) { @@ -292,8 +298,8 @@ export function ChangeNextScreen({ params }: ScreenProps): ReactElement { - [Enter] Continue - [Esc] Cancel + Continue + Cancel diff --git a/src/tui/screens/change/ChangeRemoveScreen.tsx b/src/tui/screens/change/ChangeRemoveScreen.tsx index 206f031a..1d44d477 100644 --- a/src/tui/screens/change/ChangeRemoveScreen.tsx +++ b/src/tui/screens/change/ChangeRemoveScreen.tsx @@ -30,6 +30,7 @@ import { getErrorMessage, loadChangesWithStatus, isConfigGuarded } from '../../u import { deleteChange } from '../../../core/change/scaffold.js'; import { ChangeHistory } from '../../../core/change/history.js'; import { createConnection } from '../../../core/connection/factory.js'; +import { isMouseReport } from '../../mouse.js'; /** * Remove steps. @@ -167,10 +168,12 @@ export function ChangeRemoveScreen({ params }: ScreenProps): ReactElement { }, [back]); // Keyboard handling - useInput((_input, _key) => { + useInput((input, _key) => { if (!isFocused) return; + if (isMouseReport(input)) return; + if (step === 'complete' || step === 'error') { back(); diff --git a/src/tui/screens/change/ChangeRevertScreen.tsx b/src/tui/screens/change/ChangeRevertScreen.tsx index 055f61bd..5924f86b 100644 --- a/src/tui/screens/change/ChangeRevertScreen.tsx +++ b/src/tui/screens/change/ChangeRevertScreen.tsx @@ -22,7 +22,7 @@ import type { Kysely } from 'kysely'; import { attempt } from '@logosdx/utils'; import { useRouter } from '../../router.js'; -import { useFocusScope } from '../../focus.js'; +import { useBusy, useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, @@ -41,6 +41,7 @@ import { getErrorMessage, progressPercentage, } from '../../utils/index.js'; import { createConnection } from '../../../core/connection/factory.js'; +import { isMouseReport } from '../../mouse.js'; /** * Revert steps. @@ -71,6 +72,8 @@ export function ChangeRevertScreen({ params }: ScreenProps): ReactElement { const [result, setResult] = useState(null); const [error, setError] = useState(null); + useBusy(step === 'reverting'); + // Load change info useAsyncEffect(async (isCancelled) => { @@ -185,10 +188,12 @@ export function ChangeRevertScreen({ params }: ScreenProps): ReactElement { }, [back]); // Keyboard handling - useInput((_input, _key) => { + useInput((input, _key) => { if (!isFocused) return; + if (isMouseReport(input)) return; + if (step === 'complete' || step === 'error') { back(); diff --git a/src/tui/screens/change/ChangeRewindScreen.tsx b/src/tui/screens/change/ChangeRewindScreen.tsx index bb338f92..2b03fb6a 100644 --- a/src/tui/screens/change/ChangeRewindScreen.tsx +++ b/src/tui/screens/change/ChangeRewindScreen.tsx @@ -23,7 +23,7 @@ import type { Kysely } from 'kysely'; import { attempt } from '@logosdx/utils'; import { useRouter } from '../../router.js'; -import { useFocusScope } from '../../focus.js'; +import { useBusy, useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, @@ -45,6 +45,8 @@ import { progressPercentage, } from '../../utils/index.js'; import { createConnection } from '../../../core/connection/factory.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; +import { isMouseReport } from '../../mouse.js'; /** * Rewind steps. @@ -78,6 +80,8 @@ export function ChangeRewindScreen({ params }: ScreenProps): ReactElement { const [targetInput, setTargetInput] = useState(target); const [error, setError] = useState(null); + useBusy(step === 'running'); + // Load applied changes useAsyncEffect(async (isCancelled) => { @@ -271,6 +275,8 @@ export function ChangeRewindScreen({ params }: ScreenProps): ReactElement { if (!isFocused) return; + if (isMouseReport(input)) return; + if (step === 'input') { if (key.return) { @@ -358,8 +364,8 @@ export function ChangeRewindScreen({ params }: ScreenProps): ReactElement { - [Enter] Continue - [Esc] Cancel + Continue + Cancel diff --git a/src/tui/screens/change/ChangeRunScreen.tsx b/src/tui/screens/change/ChangeRunScreen.tsx index 3d45a23e..e6547393 100644 --- a/src/tui/screens/change/ChangeRunScreen.tsx +++ b/src/tui/screens/change/ChangeRunScreen.tsx @@ -22,7 +22,7 @@ import type { Kysely } from 'kysely'; import { attempt } from '@logosdx/utils'; import { useRouter } from '../../router.js'; -import { useFocusScope } from '../../focus.js'; +import { useBusy, useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, @@ -42,6 +42,7 @@ import { getErrorMessage, } from '../../utils/index.js'; import { validateChangeContent } from '../../../core/change/validation.js'; import { createConnection } from '../../../core/connection/factory.js'; +import { isMouseReport } from '../../mouse.js'; /** * Run steps. @@ -72,6 +73,8 @@ export function ChangeRunScreen({ params }: ScreenProps): ReactElement { const [result, setResult] = useState(null); const [error, setError] = useState(null); + useBusy(step === 'running'); + // Load change info useAsyncEffect(async (isCancelled) => { @@ -181,10 +184,12 @@ export function ChangeRunScreen({ params }: ScreenProps): ReactElement { }, [back]); // Keyboard handling - useInput((_input, _key) => { + useInput((input, _key) => { if (!isFocused) return; + if (isMouseReport(input)) return; + if (step === 'complete' || step === 'error') { back(); diff --git a/src/tui/screens/config/ConfigAddScreen.tsx b/src/tui/screens/config/ConfigAddScreen.tsx index aebccecc..1f195e33 100644 --- a/src/tui/screens/config/ConfigAddScreen.tsx +++ b/src/tui/screens/config/ConfigAddScreen.tsx @@ -27,6 +27,7 @@ import { useRouter } from '../../router.js'; import { useAppContext, useSettings } from '../../app-context.js'; import { Panel, Form, useToast } from '../../components/index.js'; import { useAbortableTask } from '../../hooks/index.js'; +import { useBusy } from '../../focus.js'; import { testConnection } from '../../../core/connection/factory.js'; import { DEFAULT_ACCESS, GUARDED_ACCESS } from '../../../core/policy/index.js'; import { validateAccessRoles } from '../../../core/config/resolver.js'; @@ -67,6 +68,8 @@ export function ConfigAddScreen({ params }: ScreenProps): ReactElement { // "nothing was saved" about a config that had just been written. const [cancellable, setCancellable] = useState(false); + useBusy(busy); + const task = useAbortableTask(); // Default access for a brand-new config: the matched stage's `protected` diff --git a/src/tui/screens/config/ConfigCopyScreen.tsx b/src/tui/screens/config/ConfigCopyScreen.tsx index a04e6007..2cfb0094 100644 --- a/src/tui/screens/config/ConfigCopyScreen.tsx +++ b/src/tui/screens/config/ConfigCopyScreen.tsx @@ -22,6 +22,7 @@ import { useAppContext } from '../../app-context.js'; import { Panel, Spinner, useToast, MissingParamPanel, NotFoundPanel, TextInput } from '../../components/index.js'; import { SettingsProvider } from '../../../core/config/resolver.js'; import { getErrorMessage, validateConfigName } from '../../utils/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * ConfigCopyScreen component. @@ -198,8 +199,8 @@ export function ConfigCopyScreen({ params }: ScreenProps): ReactElement { {validationError && {validationError}} - [Enter] Copy - [Esc] Cancel + Copy + Cancel diff --git a/src/tui/screens/config/ConfigEditScreen.tsx b/src/tui/screens/config/ConfigEditScreen.tsx index a29d0502..0c4c34a4 100644 --- a/src/tui/screens/config/ConfigEditScreen.tsx +++ b/src/tui/screens/config/ConfigEditScreen.tsx @@ -25,6 +25,7 @@ import { Panel, Form, useToast, MissingParamPanel, NotFoundPanel } from '../../c import { testConnection } from '../../../core/connection/factory.js'; import { SettingsProvider, validateAccessRoles } from '../../../core/config/resolver.js'; import { useAbortableTask } from '../../hooks/index.js'; +import { useBusy } from '../../focus.js'; import { getErrorMessage, STOPPED_WAITING_MESSAGE, @@ -67,6 +68,8 @@ export function ConfigEditScreen({ params }: ScreenProps): ReactElement { // "nothing was saved" about a config that had just been written. const [cancellable, setCancellable] = useState(false); + useBusy(busy); + const task = useAbortableTask(); // Get the config to edit diff --git a/src/tui/screens/config/ConfigExportScreen.tsx b/src/tui/screens/config/ConfigExportScreen.tsx index 54c1c569..cf615df0 100644 --- a/src/tui/screens/config/ConfigExportScreen.tsx +++ b/src/tui/screens/config/ConfigExportScreen.tsx @@ -38,6 +38,8 @@ import type { ConnectionConfig } from '../../../core/connection/types.js'; import { checkConfigPolicy, confirmationPhraseFor, getPolicyFingerprint, guarded } from '../../../core/policy/index.js'; import { SettingsProvider, effectiveConfig } from '../../../core/config/resolver.js'; import { getErrorMessage } from '../../utils/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; +import { isMouseReport } from '../../mouse.js'; /** * Export steps. @@ -242,6 +244,8 @@ export function ConfigExportScreen({ params }: ScreenProps): ReactElement { if (!isFocused) return; + if (isMouseReport(input)) return; + if (step === 'recipient-email') { if (key.return) { @@ -353,8 +357,8 @@ export function ConfigExportScreen({ params }: ScreenProps): ReactElement { - [Enter] Continue - [Esc] Cancel + Continue + Cancel diff --git a/src/tui/screens/config/ConfigImportScreen.tsx b/src/tui/screens/config/ConfigImportScreen.tsx index 5425f8a2..0bf4ab3f 100644 --- a/src/tui/screens/config/ConfigImportScreen.tsx +++ b/src/tui/screens/config/ConfigImportScreen.tsx @@ -37,6 +37,8 @@ import { resolveLegacyAccess } from '../../../core/policy/index.js'; import { parseConfig } from '../../../core/config/schema.js'; import { validateAccessRoles } from '../../../core/config/resolver.js'; import { getErrorMessage, skipsVerification } from '../../utils/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; +import { isMouseReport } from '../../mouse.js'; /** * Import steps. @@ -268,7 +270,9 @@ export function ConfigImportScreen({ params }: ScreenProps): ReactElement { // Keyboard handling for complete/error states only // Note: file-select and preview steps have their own focusable components - useInput((_input, _key) => { + useInput((input) => { + + if (isMouseReport(input)) return; back(); @@ -317,7 +321,7 @@ export function ConfigImportScreen({ params }: ScreenProps): ReactElement { No .noorm.enc files found in current directory. Use: noorm config:import <file> - [Esc] Go back + Go back diff --git a/src/tui/screens/config/ConfigListScreen.tsx b/src/tui/screens/config/ConfigListScreen.tsx index f261fbd0..67181fb1 100644 --- a/src/tui/screens/config/ConfigListScreen.tsx +++ b/src/tui/screens/config/ConfigListScreen.tsx @@ -33,6 +33,7 @@ import { SettingsProvider, effectiveConfig } from '../../../core/config/resolver import type { ConfigAccess } from '../../../core/policy/index.js'; import type { ConnectionConfig } from '../../../core/connection/types.js'; import { skipsVerification } from '../../utils/index.js'; +import { Hotkey, KeyTarget } from '../../components/layout/Hotkey.js'; /** * Config list item value. @@ -238,9 +239,11 @@ export function ConfigListScreen({ params: _params }: ScreenProps): ReactElement {configs.length === 0 ? ( No configurations found. - + + Press a to add your first config. - + + ) : ( - [a] Add - [e] Edit - [d] Delete - [c] Copy - [k] Secrets - [+] More - [Enter] Use - [Esc] Back + Add + Edit + Delete + Copy + Secrets + More + Use + Back ); diff --git a/src/tui/screens/config/ConfigMoreScreen.tsx b/src/tui/screens/config/ConfigMoreScreen.tsx index 4912757a..3e1e27b7 100644 --- a/src/tui/screens/config/ConfigMoreScreen.tsx +++ b/src/tui/screens/config/ConfigMoreScreen.tsx @@ -21,6 +21,7 @@ import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, SelectList, type SelectListItem } from '../../components/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * More action item. @@ -132,10 +133,10 @@ export function ConfigMoreScreen({ params }: ScreenProps): ReactElement { - [x] Export - [i] Import - [v] Validate - [Esc] Back + Export + Import + Validate + Back ); diff --git a/src/tui/screens/config/ConfigRemoveScreen.tsx b/src/tui/screens/config/ConfigRemoveScreen.tsx index 9c1c5b81..f84a9d6f 100644 --- a/src/tui/screens/config/ConfigRemoveScreen.tsx +++ b/src/tui/screens/config/ConfigRemoveScreen.tsx @@ -25,6 +25,7 @@ import { Panel, Confirm, ProtectedConfirm, Spinner, useToast, MissingParamPanel, import { checkConfigPolicy, confirmationPhraseFor } from '../../../core/policy/index.js'; import { canDeleteConfig, effectiveConfig, SettingsProvider, withConfigPolicy } from '../../../core/config/resolver.js'; import { getErrorMessage } from '../../utils/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * ConfigRemoveScreen component. @@ -182,7 +183,7 @@ export function ConfigRemoveScreen({ params }: ScreenProps): ReactElement { - [Enter/Esc] Back + Back ); @@ -199,7 +200,7 @@ export function ConfigRemoveScreen({ params }: ScreenProps): ReactElement { - [Enter/Esc] Back + Back ); @@ -216,7 +217,7 @@ export function ConfigRemoveScreen({ params }: ScreenProps): ReactElement { - [Enter/Esc] Back + Back ); diff --git a/src/tui/screens/config/ConfigUseScreen.tsx b/src/tui/screens/config/ConfigUseScreen.tsx index 57e0f387..2ce4a842 100644 --- a/src/tui/screens/config/ConfigUseScreen.tsx +++ b/src/tui/screens/config/ConfigUseScreen.tsx @@ -23,6 +23,7 @@ import { Panel, Spinner, StatusMessage } from '../../components/index.js'; import { syncIdentityWithConfig } from '../../../core/identity/index.js'; import { SettingsProvider, effectiveConfig } from '../../../core/config/resolver.js'; import { getErrorMessage } from '../../utils/index.js'; +import { isMouseReport } from '../../mouse.js'; /** * Use steps. @@ -117,10 +118,12 @@ export function ConfigUseScreen({ params }: ScreenProps): ReactElement { }, [stateManager, settingsManager, configName, config, isAlreadyActive, refresh]); // Keyboard handling - useInput((_input, _key) => { + useInput((input) => { if (!isFocused) return; + if (isMouseReport(input)) return; + if (step === 'complete' || step === 'error') { back(); diff --git a/src/tui/screens/config/ConfigValidateScreen.tsx b/src/tui/screens/config/ConfigValidateScreen.tsx index 4a059078..d4ca2396 100644 --- a/src/tui/screens/config/ConfigValidateScreen.tsx +++ b/src/tui/screens/config/ConfigValidateScreen.tsx @@ -25,6 +25,7 @@ import { useAppContext } from '../../app-context.js'; import { Panel, Spinner, StatusList, type StatusListItem } from '../../components/index.js'; import { validateConfigChecks } from '../../../core/config/validate.js'; import { SettingsProvider, effectiveConfig } from '../../../core/config/resolver.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * Validate steps. @@ -179,8 +180,8 @@ export function ConfigValidateScreen({ params }: ScreenProps): ReactElement { - [e] Edit - [Esc] Back + Edit + Back diff --git a/src/tui/screens/db/DbCreateScreen.tsx b/src/tui/screens/db/DbCreateScreen.tsx index cc6d2b67..96ca781b 100644 --- a/src/tui/screens/db/DbCreateScreen.tsx +++ b/src/tui/screens/db/DbCreateScreen.tsx @@ -18,12 +18,13 @@ import type { ScreenProps } from '../../types.js'; import type { DbStatus } from '../../../core/db/index.js'; import { useRouter } from '../../router.js'; -import { useFocusScope } from '../../focus.js'; +import { useBusy, useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { useToast, Panel, Spinner, SmartConfirm } from '../../components/index.js'; import { useAsyncEffect } from '../../hooks/index.js'; import { checkDbStatus, createDb } from '../../../core/db/index.js'; import { checkConfigPolicy } from '../../../core/policy/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * Screen phase state. @@ -50,6 +51,8 @@ export function DbCreateScreen({ params: _params }: ScreenProps): ReactElement { // Status info const [status, setStatus] = useState(null); + useBusy(phase === 'running'); + // Check database status useAsyncEffect(async (isCancelled) => { @@ -210,7 +213,7 @@ export function DbCreateScreen({ params: _params }: ScreenProps): ReactElement { - [Enter/Esc] Back + Back ); @@ -283,7 +286,7 @@ export function DbCreateScreen({ params: _params }: ScreenProps): ReactElement { - [Enter/Esc] Done + Done ); @@ -300,7 +303,7 @@ export function DbCreateScreen({ params: _params }: ScreenProps): ReactElement { - [Enter/Esc] Back + Back ); diff --git a/src/tui/screens/db/DbDestroyScreen.tsx b/src/tui/screens/db/DbDestroyScreen.tsx index 8593bcaf..9b6b23b6 100644 --- a/src/tui/screens/db/DbDestroyScreen.tsx +++ b/src/tui/screens/db/DbDestroyScreen.tsx @@ -19,13 +19,14 @@ import type { ScreenProps } from '../../types.js'; import type { DbStatus } from '../../../core/db/index.js'; import { useRouter } from '../../router.js'; -import { useFocusScope } from '../../focus.js'; +import { useBusy, useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { useToast, Panel, Spinner, ProtectedConfirm } from '../../components/index.js'; import { useAsyncEffect } from '../../hooks/index.js'; import { checkDbStatus, destroyDb } from '../../../core/db/index.js'; import { useConnectionContext } from '../../providers/ConnectionProvider.js'; import { checkConfigPolicy, confirmationPhraseFor } from '../../../core/policy/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * Screen phase state. @@ -53,6 +54,8 @@ export function DbDestroyScreen({ params: _params }: ScreenProps): ReactElement // Status info const [status, setStatus] = useState(null); + useBusy(phase === 'disconnecting' || phase === 'running'); + // Check current state useAsyncEffect(async (isCancelled) => { @@ -207,7 +210,7 @@ export function DbDestroyScreen({ params: _params }: ScreenProps): ReactElement - [Enter/Esc] Back + Back ); @@ -243,7 +246,7 @@ export function DbDestroyScreen({ params: _params }: ScreenProps): ReactElement - [Esc] Back + Back ); @@ -316,7 +319,7 @@ export function DbDestroyScreen({ params: _params }: ScreenProps): ReactElement - [Enter/Esc] Done + Done ); @@ -333,7 +336,7 @@ export function DbDestroyScreen({ params: _params }: ScreenProps): ReactElement - [Enter/Esc] Back + Back ); diff --git a/src/tui/screens/db/DbListScreen.tsx b/src/tui/screens/db/DbListScreen.tsx index afcf9b93..4b41cabb 100644 --- a/src/tui/screens/db/DbListScreen.tsx +++ b/src/tui/screens/db/DbListScreen.tsx @@ -30,6 +30,7 @@ import { useConnection, useAsyncEffect } from '../../hooks/index.js'; import { tablesExist } from '../../../core/version/index.js'; import { getNoormTables, noormDb } from '../../../core/shared/index.js'; import { attempt } from '@logosdx/utils'; +import { Hotkey, KeyTarget } from '../../components/layout/Hotkey.js'; /** * Database status information. @@ -250,7 +251,7 @@ export function DbListScreen({ params: _params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -322,24 +323,36 @@ export function DbListScreen({ params: _params }: ScreenProps): ReactElement { - - [c] Create - Create database and tracking tables - - - [d] Destroy - Drop all managed objects - - - [x] Explore - Browse database schema - - - [w] Wipe - Truncate table data (keep schema) - - - [t] Teardown - Drop user objects (keep noorm) - - - [r] Transfer - Copy data to another database - + + + [c] Create - Create database and tracking tables + + + + + [d] Destroy - Drop all managed objects + + + + + [x] Explore - Browse database schema + + + + + [w] Wipe - Truncate table data (keep schema) + + + + + [t] Teardown - Drop user objects (keep noorm) + + + + + [r] Transfer - Copy data to another database + + @@ -350,13 +363,13 @@ export function DbListScreen({ params: _params }: ScreenProps): ReactElement { - [c] Create - [d] Destroy - [x] Explore - [w] Wipe - [t] Teardown - [r] Transfer - [Esc] Back + Create + Destroy + Explore + Wipe + Teardown + Transfer + Back ); diff --git a/src/tui/screens/db/DbTeardownScreen.tsx b/src/tui/screens/db/DbTeardownScreen.tsx index 35baecda..f439d973 100644 --- a/src/tui/screens/db/DbTeardownScreen.tsx +++ b/src/tui/screens/db/DbTeardownScreen.tsx @@ -16,7 +16,7 @@ import type { ReactElement } from 'react'; import type { ScreenProps } from '../../types.js'; import { useRouter } from '../../router.js'; -import { useFocusScope } from '../../focus.js'; +import { useBusy, useFocusScope } from '../../focus.js'; import { useAppContext, useSettings, useGlobalModes } from '../../app-context.js'; import { useToast, Panel, Spinner, ProtectedConfirm } from '../../components/index.js'; import { previewTeardown, teardownSchema } from '../../../core/teardown/index.js'; @@ -28,6 +28,7 @@ import { attempt } from '@logosdx/utils'; import type { Kysely } from 'kysely'; import type { TeardownPreview, TeardownResult } from '../../../core/teardown/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; type Phase = 'loading' | 'blocked' | 'preview' | 'view' | 'confirm' | 'running' | 'done' | 'error'; @@ -61,6 +62,8 @@ export function DbTeardownScreen({ params: _params }: ScreenProps): ReactElement const [viewCategory, setViewCategory] = useState('tables'); const [viewScroll, setViewScroll] = useState(0); + useBusy(phase === 'running'); + // Get options from settings - memoize to prevent effect re-runs const postScript = settings?.teardown?.postScript; const preserveTables = useMemo( @@ -335,7 +338,7 @@ export function DbTeardownScreen({ params: _params }: ScreenProps): ReactElement - [Enter/Esc] Back + Back ); @@ -362,7 +365,7 @@ export function DbTeardownScreen({ params: _params }: ScreenProps): ReactElement {error} - [Enter/Esc] Back + Back ); @@ -443,10 +446,10 @@ export function DbTeardownScreen({ params: _params }: ScreenProps): ReactElement {nonEmptyCategories.length > 0 && ( - [v] View details + View details )} - [Enter/y] Continue - [Esc] Cancel + Continue + Cancel ); @@ -509,7 +512,7 @@ export function DbTeardownScreen({ params: _params }: ScreenProps): ReactElement [←/→] Category {hasMore && [↑/↓] Scroll} - [Esc] Back + Back ); @@ -609,7 +612,7 @@ export function DbTeardownScreen({ params: _params }: ScreenProps): ReactElement - [Enter/Esc] Done + Done ); diff --git a/src/tui/screens/db/DbTransferScreen.tsx b/src/tui/screens/db/DbTransferScreen.tsx index 9f880bdf..43396e06 100644 --- a/src/tui/screens/db/DbTransferScreen.tsx +++ b/src/tui/screens/db/DbTransferScreen.tsx @@ -37,7 +37,7 @@ import type { ScreenProps } from '../../types.js'; import { getErrorMessage, progressPercentage } from '../../utils/index.js'; import { useRouter } from '../../router.js'; -import { useFocusScope } from '../../focus.js'; +import { useBusy, useFocusScope } from '../../focus.js'; import { useAppContext, useGlobalModes } from '../../app-context.js'; import { useTransferProgress, useLoadGuard, useAsyncEffect } from '../../hooks/index.js'; import { @@ -66,6 +66,7 @@ import { import type { TransferOptions, TransferPlan, ConflictStrategy } from '../../../core/transfer/index.js'; import type { Config } from '../../../core/config/types.js'; import type { DtSchema } from '../../../core/dt/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; type TransferMode = 'db-to-db' | 'export' | 'import'; @@ -118,6 +119,8 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement const [importSchemas, setImportSchemas] = useState>(new Map()); const [loadingSchemas, setLoadingSchemas] = useState(false); + useBusy(phase === 'running'); + const { tryAcquire, release } = useLoadGuard(); // The write target is the destination for db-to-db, but the active config @@ -817,7 +820,7 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement {error} - [Esc] Back + Back ); @@ -845,8 +848,8 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement - [Enter] Select - [Esc] Cancel + Select + Cancel ); @@ -880,9 +883,9 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement - [Space] Toggle - [Enter] Continue - [Esc] Back + Toggle + Continue + Back ); @@ -918,8 +921,8 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement - [Enter] Continue - [Esc] Back + Continue + Back ); @@ -1000,7 +1003,7 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement No .dt, .dtz, or .dtzx files found in the project. - [Esc] Back + Back ); @@ -1082,8 +1085,8 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement - [Enter] Continue to options - [Esc] Back + Continue to options + Back ); @@ -1128,8 +1131,8 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement - [Enter] Continue - [Esc] Back + Continue + Back ); @@ -1413,7 +1416,7 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement - [Esc] Done + Done ); @@ -1527,8 +1530,8 @@ function ImportPassphraseInput({ - [Enter] Continue - [Esc] Back + Continue + Back ); @@ -1822,9 +1825,9 @@ function ExportOptionsInput({ [↑↓] Navigate - [Space] Toggle - [Enter] Continue - [Esc] Back + Toggle + Continue + Back ); diff --git a/src/tui/screens/db/DbTruncateScreen.tsx b/src/tui/screens/db/DbTruncateScreen.tsx index b4f96c88..ef1decfd 100644 --- a/src/tui/screens/db/DbTruncateScreen.tsx +++ b/src/tui/screens/db/DbTruncateScreen.tsx @@ -23,12 +23,13 @@ import { GATED_BY_CALLER, fetchList } from '../../../core/explore/operations.js' import type { ScreenProps } from '../../types.js'; import { useRouter } from '../../router.js'; -import { useFocusScope } from '../../focus.js'; +import { useBusy, useFocusScope } from '../../focus.js'; import { useAppContext, useSettings, useGlobalModes } from '../../app-context.js'; import { useToast, Panel, Spinner, ProtectedConfirm } from '../../components/index.js'; import { useConnection, useAsyncEffect } from '../../hooks/index.js'; import { getErrorMessage, withScreenConnection } from '../../utils/index.js'; import { checkConfigPolicy, confirmationPhraseFor } from '../../../core/policy/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; type Phase = 'loading' | 'blocked' | 'preview' | 'confirm' | 'running' | 'done' | 'error'; @@ -50,6 +51,8 @@ export function DbTruncateScreen({ params: _params }: ScreenProps): ReactElement const [tables, setTables] = useState([]); const [result, setResult] = useState(null); + useBusy(phase === 'running'); + // Get preserved tables from settings - memoize to prevent effect re-runs const preserveTables = useMemo( () => settings?.teardown?.preserveTables ?? [], @@ -216,7 +219,7 @@ export function DbTruncateScreen({ params: _params }: ScreenProps): ReactElement - [Enter/Esc] Back + Back ); @@ -243,7 +246,7 @@ export function DbTruncateScreen({ params: _params }: ScreenProps): ReactElement {error} - [Enter/Esc] Back + Back ); @@ -301,8 +304,8 @@ export function DbTruncateScreen({ params: _params }: ScreenProps): ReactElement - [Enter/y] Continue - [Esc] Cancel + Continue + Cancel ); @@ -364,7 +367,7 @@ export function DbTruncateScreen({ params: _params }: ScreenProps): ReactElement - [Enter/Esc] Done + Done ); diff --git a/src/tui/screens/db/DtModifyScreen.tsx b/src/tui/screens/db/DtModifyScreen.tsx index c27635cd..598515eb 100644 --- a/src/tui/screens/db/DtModifyScreen.tsx +++ b/src/tui/screens/db/DtModifyScreen.tsx @@ -22,7 +22,7 @@ import { attempt, attemptSync } from '@logosdx/utils'; import { modifyDtFile, transformSchema, buildRowProxy } from '../../../core/dt/modify.js'; import { DtReader } from '../../../core/dt/reader.js'; import { useRouter } from '../../router.js'; -import { useFocusScope } from '../../focus.js'; +import { useBusy, useFocusScope } from '../../focus.js'; import { useAsyncEffect } from '../../hooks/index.js'; import { getErrorMessage } from '../../utils/index.js'; import { @@ -36,6 +36,7 @@ import { useToast, type SelectListItem, } from '../../components/index.js'; +import { Hotkey, KeyTarget } from '../../components/layout/Hotkey.js'; // --------------------------------------------------------------------------- // Phase type @@ -87,6 +88,8 @@ export function DtModifyScreen({ params: _params }: ScreenProps): ReactElement { const { showToast } = useToast(); const [phase, setPhase] = useState('select-file'); + + useBusy(phase === 'running'); const [error, setError] = useState(null); const [selectedFile, setSelectedFile] = useState(null); const [passphrase, setPassphrase] = useState(''); @@ -819,7 +822,7 @@ export function DtModifyScreen({ params: _params }: ScreenProps): ReactElement { {error} - [Esc] Back + Back ); @@ -851,7 +854,7 @@ export function DtModifyScreen({ params: _params }: ScreenProps): ReactElement { No .dt, .dtz, or .dtzx files found in the project. - [Esc] Back + Back ); @@ -891,8 +894,8 @@ export function DtModifyScreen({ params: _params }: ScreenProps): ReactElement { - [Enter] Continue - [Esc] Back + Continue + Back ); @@ -975,8 +978,8 @@ export function DtModifyScreen({ params: _params }: ScreenProps): ReactElement { - [Enter] Modify - [Esc] Back + Modify + Back ); @@ -1107,10 +1110,10 @@ export function DtModifyScreen({ params: _params }: ScreenProps): ReactElement { - [v] {showingSchema ? 'Hide' : 'View'} schema - {recipe.length > 0 && [u] Undo last} - {recipe.length > 0 && [Enter] Done — proceed to output} - [Esc] Cancel + {showingSchema ? 'Hide' : 'View'} schema + {recipe.length > 0 && Undo last} + {recipe.length > 0 && Done — proceed to output} + Cancel ); @@ -1187,9 +1190,9 @@ export function DtModifyScreen({ params: _params }: ScreenProps): ReactElement { - [Space] Toggle - [Enter] Drop selected - [Esc] Cancel + Toggle + Drop selected + Cancel ); @@ -1260,9 +1263,9 @@ export function DtModifyScreen({ params: _params }: ScreenProps): ReactElement { - [Tab] Next field - {addFieldIndex === 3 && addName.trim() && [Enter] Add column} - [Esc] Cancel + Next field + {addFieldIndex === 3 && addName.trim() && Add column} + Cancel ); @@ -1307,7 +1310,7 @@ export function DtModifyScreen({ params: _params }: ScreenProps): ReactElement { - [Esc] Back to operations + Back to operations ); @@ -1333,8 +1336,8 @@ export function DtModifyScreen({ params: _params }: ScreenProps): ReactElement { - {renameTo.trim() && [Enter] Rename} - [Esc] Back to column list + {renameTo.trim() && Rename} + Back to column list ); @@ -1427,9 +1430,9 @@ export function DtModifyScreen({ params: _params }: ScreenProps): ReactElement { - [Space] Toggle - [Enter] Apply - [Esc] Cancel + Toggle + Apply + Cancel ); @@ -1469,8 +1472,8 @@ export function DtModifyScreen({ params: _params }: ScreenProps): ReactElement { - {filterPredicate.trim() && [Enter] Add filter} - [Esc] Cancel + {filterPredicate.trim() && Add filter} + Cancel ); @@ -1504,15 +1507,17 @@ export function DtModifyScreen({ params: _params }: ScreenProps): ReactElement { ) } - - [Space] - {overwriteOriginal ? '[x]' : '[ ]'} Overwrite original - + + + [Space] + {overwriteOriginal ? '[x]' : '[ ]'} Overwrite original + + - {outputPath.trim() && [Enter] Confirm} - [Esc] Back + {outputPath.trim() && Confirm} + Back ); @@ -1673,7 +1678,7 @@ export function DtModifyScreen({ params: _params }: ScreenProps): ReactElement { - [Esc] Done + Done ); @@ -1691,7 +1696,7 @@ export function DtModifyScreen({ params: _params }: ScreenProps): ReactElement { Phase: {phase} - [Esc] Back + Back ); diff --git a/src/tui/screens/db/SqlClearScreen.tsx b/src/tui/screens/db/SqlClearScreen.tsx index 92c97ffc..81f70b3a 100644 --- a/src/tui/screens/db/SqlClearScreen.tsx +++ b/src/tui/screens/db/SqlClearScreen.tsx @@ -18,9 +18,11 @@ import type { ScreenProps } from '../../types.js'; import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; +import { hoverBackground, useRowMouse } from '../../mouse.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Spinner, Confirm, useToast } from '../../components/index.js'; import { SqlHistoryManager } from '../../../core/sql-terminal/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * Clear option type. @@ -191,6 +193,27 @@ export function SqlClearScreen({ params: _params }: ScreenProps): ReactElement { }); + // Above the early returns, or the hook count changes with the phase. + const { rowRef, hovered } = useRowMouse({ + isActive: isFocused && phase === 'select', + onClick: setHighlightedIndex, + onActivate: (index) => { + + const option = options[index]; + + if (!option) return; + + setSelectedOption(option.key); + setPhase('confirm'); + + }, + onWheel: (delta) => { + + setHighlightedIndex((i) => Math.min(Math.max(i + delta, 0), options.length - 1)); + + }, + }); + // No config if (!activeConfigName || !projectRoot) { @@ -201,7 +224,7 @@ export function SqlClearScreen({ params: _params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -235,7 +258,7 @@ export function SqlClearScreen({ params: _params }: ScreenProps): ReactElement { - [Enter/Esc] Back + Back ); @@ -295,9 +318,15 @@ export function SqlClearScreen({ params: _params }: ScreenProps): ReactElement { {options.map((option, index) => { const isHighlighted = index === highlightedIndex; + const isHovered = hovered === index && !isHighlighted; return ( - + - [Enter] Select + Select [↑/↓] Navigate - [Esc] Cancel + Cancel ); diff --git a/src/tui/screens/db/SqlHistoryScreen.tsx b/src/tui/screens/db/SqlHistoryScreen.tsx index 871e9ef0..6a980eb6 100644 --- a/src/tui/screens/db/SqlHistoryScreen.tsx +++ b/src/tui/screens/db/SqlHistoryScreen.tsx @@ -23,6 +23,7 @@ import type { SqlHistoryEntry, SqlExecutionResult } from '../../../core/sql-term import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; +import { hoverBackground, useRowMouse } from '../../mouse.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Spinner, useToast } from '../../components/index.js'; import { ResultBrowser } from '../../components/terminal/index.js'; @@ -31,6 +32,7 @@ import { SqlHistoryManager } from '../../../core/sql-terminal/index.js'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime.js'; import v from 'voca'; +import { Hotkey } from '../../components/layout/Hotkey.js'; dayjs.extend(relativeTime); @@ -122,6 +124,33 @@ export function SqlHistoryScreen({ params: _params }: ScreenProps): ReactElement }; + /** What Enter does on an entry: open its stored result, or say why there is none. */ + const viewEntry = (entry: SqlHistoryEntry | undefined) => { + + if (entry?.resultsFile) { + + loadResult(entry); + + } + else if (entry && !entry.success) { + + showToast({ + message: entry.errorMessage ?? 'Query failed', + variant: 'error', + }); + + } + else { + + showToast({ + message: 'No results stored for this query', + variant: 'info', + }); + + } + + }; + /** Whether the open result has a grid, which decides who owns Escape. */ const hasResultGrid = Boolean(selectedResult?.columns && selectedResult.rows); @@ -204,29 +233,7 @@ export function SqlHistoryScreen({ params: _params }: ScreenProps): ReactElement // Enter: View result if (key.return) { - const entry = history[highlightedIndex]; - - if (entry?.resultsFile) { - - loadResult(entry); - - } - else if (entry && !entry.success) { - - showToast({ - message: entry.errorMessage ?? 'Query failed', - variant: 'error', - }); - - } - else { - - showToast({ - message: 'No results stored for this query', - variant: 'info', - }); - - } + viewEntry(history[highlightedIndex]); return; @@ -252,6 +259,31 @@ export function SqlHistoryScreen({ params: _params }: ScreenProps): ReactElement }); + // Inactive while a result is open: the keyboard handler hands everything to + // the grid then, and a click on a list row that is not drawn must not land. + const { rowRef, hovered } = useRowMouse({ + isActive: isFocused && !selectedResult, + onClick: setHighlightedIndex, + onActivate: (index) => { + + setHighlightedIndex(index); + viewEntry(history[index]); + + }, + onWheel: (delta) => { + + const next = Math.min(Math.max(highlightedIndex + delta, 0), Math.max(0, history.length - 1)); + + if (next === highlightedIndex) return; + + setHighlightedIndex(next); + + if (next < scrollOffset) setScrollOffset(next); + else if (next >= scrollOffset + maxVisibleRows) setScrollOffset(next - maxVisibleRows + 1); + + }, + }); + // No config if (!activeConfigName || !projectRoot) { @@ -262,7 +294,7 @@ export function SqlHistoryScreen({ params: _params }: ScreenProps): ReactElement - [Esc] Back + Back ); @@ -305,7 +337,7 @@ export function SqlHistoryScreen({ params: _params }: ScreenProps): ReactElement {!rowOpen && ( - [Esc] Close + Close )} @@ -336,7 +368,7 @@ export function SqlHistoryScreen({ params: _params }: ScreenProps): ReactElement - [Esc] Back + Back ); @@ -366,12 +398,15 @@ export function SqlHistoryScreen({ params: _params }: ScreenProps): ReactElement const actualIndex = scrollOffset + index; const isHighlighted = actualIndex === highlightedIndex; + const isHovered = hovered === actualIndex && !isHighlighted; return ( @@ -426,11 +461,11 @@ export function SqlHistoryScreen({ params: _params }: ScreenProps): ReactElement {/* Footer hints */} - [r] Re-run - [Enter] View result - [c] Clear + Re-run + View result + Clear [↑/↓] Navigate - [Esc] Back + Back ); diff --git a/src/tui/screens/db/SqlTerminalScreen.tsx b/src/tui/screens/db/SqlTerminalScreen.tsx index c278bd55..8e94d429 100644 --- a/src/tui/screens/db/SqlTerminalScreen.tsx +++ b/src/tui/screens/db/SqlTerminalScreen.tsx @@ -27,7 +27,7 @@ import type { Config } from '../../../core/config/types.js'; import type { Permission } from '../../../core/policy/index.js'; import { useRouter } from '../../router.js'; -import { useFocusScope } from '../../focus.js'; +import { useBusy, useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Spinner, useToast, ProtectedConfirm } from '../../components/index.js'; import { SqlInput, ResultBrowser } from '../../components/terminal/index.js'; @@ -41,6 +41,7 @@ import { abortMessageFor, hasServerSideCancel, } from '../../../core/sql-terminal/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * Focus areas within the terminal. @@ -102,6 +103,8 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { const dbRef = useRef | null>(null); const destroyRef = useRef<(() => Promise) | null>(null); + useBusy(isExecuting); + // One in-flight operation at a time: the initial connect, then each query. const task = useAbortableTask(); @@ -511,7 +514,7 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -528,7 +531,7 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { - [Esc] Cancel + Cancel ); @@ -548,7 +551,7 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -627,7 +630,7 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { {isExecuting && ( - [Esc] Cancel + Cancel )} @@ -656,18 +659,16 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { reach this screen at all until it closes. */} {!rowOpen && ( - {focusArea === 'input' && [h] History} + {focusArea === 'input' && History} {result?.rows && result.rows.length > 0 && ( - [Tab] {focusArea === 'input' ? 'View Results' : 'Edit Query'} + {focusArea === 'input' ? 'View Results' : 'Edit Query'} )} {/* While a query runs the spinner row carries the cancel hint, right next to the thing being cancelled. Repeating it here would be two hints for one key, and leaving the idle label would name the wrong action. */} {!isExecuting && ( - - [Esc] {focusArea === 'results' ? 'Edit Query' : query.trim() ? 'Clear' : 'Back'} - + {focusArea === 'results' ? 'Edit Query' : query.trim() ? 'Clear' : 'Back'} )} )} diff --git a/src/tui/screens/db/explore/ExploreDetailScreen.tsx b/src/tui/screens/db/explore/ExploreDetailScreen.tsx index d9ed62d3..3d67a168 100644 --- a/src/tui/screens/db/explore/ExploreDetailScreen.tsx +++ b/src/tui/screens/db/explore/ExploreDetailScreen.tsx @@ -70,6 +70,7 @@ import type { IndexSummary, ForeignKeySummary, } from '../../../../core/explore/index.js'; +import { Hotkey, HotkeyLine } from '../../../components/layout/Hotkey.js'; /** * Route to category mapping. @@ -864,7 +865,7 @@ export function ExploreDetailScreen({ params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -881,7 +882,7 @@ export function ExploreDetailScreen({ params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -914,7 +915,7 @@ export function ExploreDetailScreen({ params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -968,7 +969,7 @@ export function ExploreDetailScreen({ params }: ScreenProps): ReactElement { {detailFooterHints({ scrolls, overlay, canPeek: peekable }).map((hint) => ( - {hint} + {hint} ))} diff --git a/src/tui/screens/db/explore/ExploreListScreen.tsx b/src/tui/screens/db/explore/ExploreListScreen.tsx index 05a5ccab..594e4df4 100644 --- a/src/tui/screens/db/explore/ExploreListScreen.tsx +++ b/src/tui/screens/db/explore/ExploreListScreen.tsx @@ -41,6 +41,7 @@ import type { IndexSummary, ForeignKeySummary, } from '../../../../core/explore/index.js'; +import { Hotkey } from '../../../components/layout/Hotkey.js'; /** * Category configuration. @@ -276,7 +277,7 @@ export function ExploreListScreen({ params: _params }: ScreenProps): ReactElemen - [Esc] Back + Back ); @@ -293,7 +294,7 @@ export function ExploreListScreen({ params: _params }: ScreenProps): ReactElemen - [Esc] Back + Back ); @@ -326,7 +327,7 @@ export function ExploreListScreen({ params: _params }: ScreenProps): ReactElemen - [Esc] Back + Back ); @@ -352,8 +353,8 @@ export function ExploreListScreen({ params: _params }: ScreenProps): ReactElemen {/* Hotkeys */} - {meta.hasDetail && [Enter] View detail} - [Esc] Back + {meta.hasDetail && View detail} + Back ); diff --git a/src/tui/screens/db/explore/ExploreOverviewScreen.tsx b/src/tui/screens/db/explore/ExploreOverviewScreen.tsx index 04b3c541..0ba38e43 100644 --- a/src/tui/screens/db/explore/ExploreOverviewScreen.tsx +++ b/src/tui/screens/db/explore/ExploreOverviewScreen.tsx @@ -22,11 +22,12 @@ import type { ScreenProps, Route } from '../../../types.js'; import { useRouter } from '../../../router.js'; import { useFocusScope } from '../../../focus.js'; -import { useAppContext, useExploreFilters, useSettings } from '../../../app-context.js'; -import { useOnScreenPopped, useConnection, useAsyncEffect } from '../../../hooks/index.js'; +import { useAppContext, useSettings } from '../../../app-context.js'; +import { useConnection, useAsyncEffect } from '../../../hooks/index.js'; import { Panel, Spinner } from '../../../components/index.js'; import { fetchOverview, userGate } from '../../../../core/explore/index.js'; import { CELL_GAP, LABEL_CAP, cellWidth, fitWidths, rowBudget } from './layout.js'; +import { Hotkey, KeyTarget } from '../../../components/layout/Hotkey.js'; import type { ExploreOverview } from '../../../../core/explore/index.js'; @@ -108,23 +109,25 @@ export function CategoryList({ overview, width }: { overview: ExploreOverview | const hasItems = count > 0; return ( - - - + + + + [{cat.numberKey}] - + + + + + {cat.label} + + + + + {count} + + - - - {cat.label} - - - - - {count} - - - + ); })} @@ -143,7 +146,6 @@ export function ExploreOverviewScreen({ params: _params }: ScreenProps): ReactEl const { navigate, back } = useRouter(); const { isFocused } = useFocusScope('ExploreOverview'); const { activeConfig, activeConfigName } = useAppContext(); - const { clearFilters } = useExploreFilters(); const { settings } = useSettings(); // useWindowSize, not useStdout: stdout.columns mutates on resize without @@ -160,12 +162,6 @@ export function ExploreOverviewScreen({ params: _params }: ScreenProps): ReactEl includeNoormTables: settings?.logging?.level === 'verbose', }; - useOnScreenPopped('db/explore', () => { - - clearFilters(); - - }); - // Shared connection const { db, dialect, loading: connLoading, error: connError } = useConnection(); @@ -255,7 +251,7 @@ export function ExploreOverviewScreen({ params: _params }: ScreenProps): ReactEl - [Esc] Back + Back ); @@ -288,7 +284,7 @@ export function ExploreOverviewScreen({ params: _params }: ScreenProps): ReactEl - [Esc] Back + Back ); @@ -331,7 +327,7 @@ export function ExploreOverviewScreen({ params: _params }: ScreenProps): ReactEl {/* Hotkeys */} [1-7] Navigate - [Esc] Back + Back ); diff --git a/src/tui/screens/db/explore/FullTextOverlay.tsx b/src/tui/screens/db/explore/FullTextOverlay.tsx index 42d4d19f..38605170 100644 --- a/src/tui/screens/db/explore/FullTextOverlay.tsx +++ b/src/tui/screens/db/explore/FullTextOverlay.tsx @@ -30,6 +30,7 @@ import type { ReactElement } from 'react'; import { useFocusScope } from '../../../focus.js'; import { useWheelScroll } from '../../../mouse.js'; import { rowBudget, rowWindow, scrollTarget, wrapText } from './layout.js'; +import { Hotkey } from '../../../components/layout/Hotkey.js'; /** * Props for the full-text overlay. @@ -122,7 +123,10 @@ export function FullTextOverlay({ text, startRow, height, onClose }: FullTextOve return ( - Full text · [Esc] Close + + Full text · + Close + {view.above > 0 && ↑ {view.above} more} {lines.slice(view.start, view.start + view.count).map((line, index) => ( {line} diff --git a/src/tui/screens/db/explore/RowPeekOverlay.tsx b/src/tui/screens/db/explore/RowPeekOverlay.tsx index 3f55430c..1ab217a9 100644 --- a/src/tui/screens/db/explore/RowPeekOverlay.tsx +++ b/src/tui/screens/db/explore/RowPeekOverlay.tsx @@ -54,6 +54,7 @@ import { useFocusScope } from '../../../focus.js'; import { useAsyncEffect } from '../../../hooks/index.js'; import { Spinner } from '../../../components/index.js'; import { ResultTable, RowViewOverlay } from '../../../components/terminal/index.js'; +import { Hotkey, HotkeyLine } from '../../../components/layout/Hotkey.js'; /** * Props for the row peek overlay. @@ -274,7 +275,12 @@ export function RowPeekOverlay({ }); const qualified = `${detail.schema ? `${detail.schema}.` : ''}${detail.name}`; - const header = Rows · {qualified} · [Esc] Close; + const header = ( + + Rows · {qualified} · + Close + + ); if (error) { @@ -345,7 +351,10 @@ export function RowPeekOverlay({ for the row view would throw away the cursor and the scroll offset that Escape is supposed to come back to. */} - Rows · {qualified} · {keys} + + Rows · {qualified} · + {keys} + {label.last !== null && } {peek.mode === 'head' && ( diff --git a/src/tui/screens/debug/DebugDetailScreen.tsx b/src/tui/screens/debug/DebugDetailScreen.tsx index f6274be4..bd38c6d9 100644 --- a/src/tui/screens/debug/DebugDetailScreen.tsx +++ b/src/tui/screens/debug/DebugDetailScreen.tsx @@ -28,6 +28,7 @@ import { getTableInfo, type DebugOperations, } from '../../../core/debug/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * DebugDetailScreen component. @@ -171,7 +172,7 @@ export function DebugDetailScreen({ params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -188,7 +189,7 @@ export function DebugDetailScreen({ params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -221,7 +222,7 @@ export function DebugDetailScreen({ params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -238,7 +239,7 @@ export function DebugDetailScreen({ params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -299,8 +300,8 @@ export function DebugDetailScreen({ params }: ScreenProps): ReactElement { {/* Hotkeys */} - [d] Delete - [Esc] Back + Delete + Back ); diff --git a/src/tui/screens/debug/DebugListScreen.tsx b/src/tui/screens/debug/DebugListScreen.tsx index 049a09ef..e92c5c28 100644 --- a/src/tui/screens/debug/DebugListScreen.tsx +++ b/src/tui/screens/debug/DebugListScreen.tsx @@ -38,6 +38,7 @@ import { } from '../../../core/debug/index.js'; import type { SelectListItem } from '../../components/lists/SelectList.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * Delete confirmation state. @@ -379,7 +380,7 @@ export function DebugListScreen({ params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -396,7 +397,7 @@ export function DebugListScreen({ params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -429,7 +430,7 @@ export function DebugListScreen({ params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -490,12 +491,12 @@ export function DebugListScreen({ params }: ScreenProps): ReactElement { {/* Hotkeys */} - [s] Sort column - [S] Direction - [d] Delete - [D] Delete all - [Enter] Detail - [Esc] Back + Sort column + Direction + Delete + Delete all + Detail + Back ); diff --git a/src/tui/screens/debug/DebugOverviewScreen.tsx b/src/tui/screens/debug/DebugOverviewScreen.tsx index a2ef2f6d..dfd9dfe2 100644 --- a/src/tui/screens/debug/DebugOverviewScreen.tsx +++ b/src/tui/screens/debug/DebugOverviewScreen.tsx @@ -32,6 +32,7 @@ import { NOORM_TABLE_INFO, type TableCountResult, } from '../../../core/debug/index.js'; +import { Hotkey, KeyTarget } from '../../components/layout/Hotkey.js'; /** * DebugOverviewScreen component. @@ -137,7 +138,7 @@ export function DebugOverviewScreen({ params: _params }: ScreenProps): ReactElem - [Esc] Back + Back ); @@ -170,7 +171,7 @@ export function DebugOverviewScreen({ params: _params }: ScreenProps): ReactElem - [Esc] Back + Back ); @@ -226,26 +227,28 @@ export function DebugOverviewScreen({ params: _params }: ScreenProps): ReactElem const hasRows = count !== null && count > 0; return ( - - + + + [{index + 1}] - - - - {info.displayName} - - - {info.description} - - {failed - ? error - : ( - - {count} {count === 1 ? 'row' : 'rows'} + + + {info.displayName} - )} - + + + {info.description} + + {failed + ? error + : ( + + {count} {count === 1 ? 'row' : 'rows'} + + )} + + ); })} @@ -256,7 +259,7 @@ export function DebugOverviewScreen({ params: _params }: ScreenProps): ReactElem {/* Hotkeys */} [1-5] Select table - [Esc] Back + Back ); diff --git a/src/tui/screens/home.tsx b/src/tui/screens/home.tsx index 78cbd600..325b179d 100644 --- a/src/tui/screens/home.tsx +++ b/src/tui/screens/home.tsx @@ -40,6 +40,7 @@ import { isSslFileErrorMessage } from '../../core/connection/ssl-files.js'; import type { UnifiedHistoryRecord } from '../../core/change/types.js'; import type { LockStatus } from '../../core/lock/types.js'; import { relativeTimeAgo } from '../utils/date.js'; +import { Hotkey, HotkeyLine, KeyTarget } from '../components/layout/Hotkey.js'; /** * Status for the quick status widget. @@ -315,7 +316,7 @@ export function HomeScreen({ params: _params }: ScreenProps): ReactElement { ● Not Created - Database does not exist. Use [d] DB to create. + Database does not exist. Use [d] DB to create. ); @@ -358,7 +359,7 @@ export function HomeScreen({ params: _params }: ScreenProps): ReactElement { return ( LOCKED - by {status.lockStatus.lock.lockedBy} + by {status.lockStatus.lock.lockedBy} ); @@ -421,11 +422,13 @@ export function HomeScreen({ params: _params }: ScreenProps): ReactElement { <> No database configurations found. - Press - - [c] - - to create your first configuration. + + Press + + [c] + + to create your first configuration. + )} @@ -433,7 +436,7 @@ export function HomeScreen({ params: _params }: ScreenProps): ReactElement { - [c] Config [q] Quit + [c] Config [q] Quit ); @@ -458,17 +461,19 @@ export function HomeScreen({ params: _params }: ScreenProps): ReactElement { {accessWarning} - Press - - [c] - - to select a configuration to use. + + Press + + [c] + + to select a configuration to use. + - [c] Config [q] Quit + [c] Config [q] Quit ); @@ -497,7 +502,7 @@ export function HomeScreen({ params: _params }: ScreenProps): ReactElement { {/* Welcome / Config Summary */} Active Config: - + {activeConfigName ?? 'none'} | @@ -551,7 +556,7 @@ export function HomeScreen({ params: _params }: ScreenProps): ReactElement { {setupStatus.map((s) => ( - + ✓ {' '}{s.configName} @@ -567,15 +572,9 @@ export function HomeScreen({ params: _params }: ScreenProps): ReactElement { - - [1] Run Build - - - [2] Apply Changes (ff) - - - [3] View Lock Status - + Run Build + Apply Changes (ff) + View Lock Status @@ -632,10 +631,12 @@ export function HomeScreen({ params: _params }: ScreenProps): ReactElement { {statusIcon} {typeLabel} - {record.name} - - {relativeTimeAgo(record.executedAt)} {duration} - + {record.name} + + + {relativeTimeAgo(record.executedAt)} {duration} + + ); @@ -646,13 +647,13 @@ export function HomeScreen({ params: _params }: ScreenProps): ReactElement { {/* Navigation Hints */} - [r] Run - [p] Pipelines - [c] Config - [g] Change - [d] DB - [q] Quit - [+] More + Run + Pipelines + Config + Change + DB + Quit + More ); diff --git a/src/tui/screens/identity/IdentityEditScreen.tsx b/src/tui/screens/identity/IdentityEditScreen.tsx index 8ed2dc5e..a631f0bb 100644 --- a/src/tui/screens/identity/IdentityEditScreen.tsx +++ b/src/tui/screens/identity/IdentityEditScreen.tsx @@ -27,6 +27,7 @@ import { Form, type FormField, type FormValues } from '../../components/forms/in import { createIdentityForExistingKeys } from '../../../core/identity/index.js'; import { attempt } from '@logosdx/utils'; import { getErrorMessage } from '../../utils/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** @@ -151,7 +152,7 @@ export function IdentityEditScreen({ params: _params }: ScreenProps): ReactEleme - [Esc] Back + Back ); diff --git a/src/tui/screens/identity/IdentityExportScreen.tsx b/src/tui/screens/identity/IdentityExportScreen.tsx index 49ca6957..0adceaca 100644 --- a/src/tui/screens/identity/IdentityExportScreen.tsx +++ b/src/tui/screens/identity/IdentityExportScreen.tsx @@ -25,6 +25,7 @@ import { useAppContext } from '../../app-context.js'; import { Panel, useToast } from '../../components/index.js'; import { copyToClipboard, isClipboardAvailable } from '../../utils/index.js'; import { attemptSync } from '@logosdx/utils'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** @@ -104,7 +105,7 @@ export function IdentityExportScreen({ params: _params }: ScreenProps): ReactEle - [Esc] Back + Back ); @@ -143,13 +144,13 @@ export function IdentityExportScreen({ params: _params }: ScreenProps): ReactEle {/* Keyboard shortcuts */} {clipboardAvailable ? ( - [c] Copy to Clipboard + Copy to Clipboard ) : ( Clipboard not available )} - [Esc] Back + Back ); diff --git a/src/tui/screens/identity/IdentityInitScreen.tsx b/src/tui/screens/identity/IdentityInitScreen.tsx index 42f6917e..bc548a5b 100644 --- a/src/tui/screens/identity/IdentityInitScreen.tsx +++ b/src/tui/screens/identity/IdentityInitScreen.tsx @@ -39,6 +39,7 @@ import { } from '../../../core/identity/index.js'; import { attempt } from '@logosdx/utils'; import { getErrorMessage } from '../../utils/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; const RESTORE_HINT = 'Restore identity.key from that backup to regain access to existing state.'; @@ -232,8 +233,8 @@ export function IdentityInitScreen({ params: _params }: ScreenProps): ReactEleme - [Enter] Continue - [Esc] Cancel + Continue + Cancel ); @@ -257,7 +258,7 @@ export function IdentityInitScreen({ params: _params }: ScreenProps): ReactEleme - [Enter] Back + Back ); diff --git a/src/tui/screens/identity/IdentityOnboarding.tsx b/src/tui/screens/identity/IdentityOnboarding.tsx index bf0e4747..a6d7ee7f 100644 --- a/src/tui/screens/identity/IdentityOnboarding.tsx +++ b/src/tui/screens/identity/IdentityOnboarding.tsx @@ -24,6 +24,7 @@ import { loadPrivateKey, } from '../../../core/identity/index.js'; import { getErrorMessage } from '../../utils/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * - `checking`: reading what is on disk @@ -52,7 +53,7 @@ function PrivateKeyUnreadable({ reason }: { reason: string }): ReactElement { ✘ {reason} Your private key was left as it is. Fix the file, then open this screen again. - [Esc] Back + Back ); diff --git a/src/tui/screens/identity/IdentityScreen.tsx b/src/tui/screens/identity/IdentityScreen.tsx index 840ee3e8..e333f06e 100644 --- a/src/tui/screens/identity/IdentityScreen.tsx +++ b/src/tui/screens/identity/IdentityScreen.tsx @@ -26,6 +26,7 @@ import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel } from '../../components/index.js'; import { truncateHash } from '../../../core/identity/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** @@ -128,7 +129,7 @@ export function IdentityScreen({ params: _params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -210,11 +211,11 @@ export function IdentityScreen({ params: _params }: ScreenProps): ReactElement { {/* Keyboard shortcuts */} - [e] Edit - [x] Export Key - [r] Regenerate - [u] Known Users - [Esc] Back + Edit + Export Key + Regenerate + Known Users + Back ); diff --git a/src/tui/screens/identity/KnownUsersScreen.tsx b/src/tui/screens/identity/KnownUsersScreen.tsx index 673bb891..ed4df1ea 100644 --- a/src/tui/screens/identity/KnownUsersScreen.tsx +++ b/src/tui/screens/identity/KnownUsersScreen.tsx @@ -23,10 +23,12 @@ import type { KnownUser } from '../../../core/identity/index.js'; import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; +import { hoverBackground, useRowMouse } from '../../mouse.js'; import { useAppContext } from '../../app-context.js'; import { Panel } from '../../components/index.js'; import { truncateHash } from '../../../core/identity/index.js'; import { relativeTimeAgo } from '../../utils/date.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** @@ -135,6 +137,27 @@ export function KnownUsersScreen({ params: _params }: ScreenProps): ReactElement }); + // Above the empty-state return, or the hook count changes with it. + const { rowRef, hovered } = useRowMouse({ + isActive: isFocused, + onClick: setHighlightedIndex, + onActivate: (index) => { + + const group = userGroups[index]; + + if (!group) return; + + setHighlightedIndex(index); + setExpandedEmail((current) => (current === group.email ? null : group.email)); + + }, + onWheel: (delta) => { + + setHighlightedIndex((i) => Math.min(Math.max(i + delta, 0), Math.max(0, userGroups.length - 1))); + + }, + }); + // Empty state if (userGroups.length === 0) { @@ -150,7 +173,7 @@ export function KnownUsersScreen({ params: _params }: ScreenProps): ReactElement - [Esc] Back + Back ); @@ -164,13 +187,18 @@ export function KnownUsersScreen({ params: _params }: ScreenProps): ReactElement {userGroups.map((group, index) => { const isHighlighted = index === highlightedIndex; + const isHovered = hovered === index && !isHighlighted; const isExpanded = group.email === expandedEmail; const machineCount = group.users.length; return ( - {/* Group header */} - + {/* Group header. Only this line takes the click, so the + expanded details under it are not a target. */} + {isHighlighted ? '>' : ' '} @@ -257,9 +285,9 @@ export function KnownUsersScreen({ params: _params }: ScreenProps): ReactElement {/* Keyboard shortcuts */} - [Enter] Expand/Collapse + Expand/Collapse [Up/Down] Navigate - [Esc] Back + Back ); diff --git a/src/tui/screens/init/InitScreen.tsx b/src/tui/screens/init/InitScreen.tsx index c57f1d00..cf19055f 100644 --- a/src/tui/screens/init/InitScreen.tsx +++ b/src/tui/screens/init/InitScreen.tsx @@ -42,6 +42,7 @@ import type { PathConfig } from '../../../core/settings/types.js'; import { scaffoldDbLayout } from '../../../core/project-init.js'; import { StateManager, getStateManager } from '../../../core/state/index.js'; import { observer } from '../../../core/observer.js'; +import { KeyTarget } from '../../components/layout/Hotkey.js'; /** * Init step in the flow. @@ -481,17 +482,21 @@ export function InitScreen({ params }: ScreenProps): ReactElement { {shouldAddConfig && ( - + + Press Enter to add your first database config. - + + )} {!shouldAddConfig && ( - + + Press Enter to go to home screen. - + + )} diff --git a/src/tui/screens/init/ProjectSetup.tsx b/src/tui/screens/init/ProjectSetup.tsx index bdc1c543..470b36d2 100644 --- a/src/tui/screens/init/ProjectSetup.tsx +++ b/src/tui/screens/init/ProjectSetup.tsx @@ -13,6 +13,7 @@ import { Panel } from '../../components/layout/index.js'; import { SelectList } from '../../components/lists/index.js'; import type { SelectListItem } from '../../components/lists/index.js'; import { DEFAULT_CHANGES_PATH, DEFAULT_DB_DIR, DEFAULT_SQL_PATH, isDefaultLayout } from '../../../core/settings/defaults.js'; +import { HotkeyLine } from '../../components/layout/Hotkey.js'; /** * Props for ProjectSetup. @@ -160,7 +161,7 @@ export function ProjectSetup({ {/* Keyboard hints */} - [↑↓] navigate [Enter] select [Esc] cancel + [↑↓] navigate [Enter] select [Esc] cancel ); diff --git a/src/tui/screens/lock/LockAcquireScreen.tsx b/src/tui/screens/lock/LockAcquireScreen.tsx index a6000435..eba1ce3d 100644 --- a/src/tui/screens/lock/LockAcquireScreen.tsx +++ b/src/tui/screens/lock/LockAcquireScreen.tsx @@ -21,13 +21,14 @@ import type { Kysely } from 'kysely'; import { attempt } from '@logosdx/utils'; import { useRouter } from '../../router.js'; -import { useFocusScope } from '../../focus.js'; +import { useBusy, useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Spinner, Form, SmartConfirm, useToast } from '../../components/index.js'; import { useLockStatus } from '../../hooks/index.js'; import { createConnection } from '../../../core/connection/index.js'; import { getLockManager, LockAcquireError } from '../../../core/lock/index.js'; import { checkConfigPolicy } from '../../../core/policy/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * Screen phase state. @@ -56,6 +57,8 @@ export function LockAcquireScreen({ params: _params }: ScreenProps): ReactElemen const [error, setError] = useState(null); const [pending, setPending] = useState(null); + useBusy(phase === 'running'); + const { status: lockStatus, identityStr, @@ -248,7 +251,7 @@ export function LockAcquireScreen({ params: _params }: ScreenProps): ReactElemen - [Enter/Esc] Back + Back ); @@ -296,7 +299,7 @@ export function LockAcquireScreen({ params: _params }: ScreenProps): ReactElemen - [Enter/Esc] Back + Back ); @@ -387,7 +390,7 @@ export function LockAcquireScreen({ params: _params }: ScreenProps): ReactElemen - [Enter/Esc] Done + Done ); @@ -404,7 +407,7 @@ export function LockAcquireScreen({ params: _params }: ScreenProps): ReactElemen - [Enter/Esc] Back + Back ); diff --git a/src/tui/screens/lock/LockForceScreen.tsx b/src/tui/screens/lock/LockForceScreen.tsx index 57183a93..5752aa5f 100644 --- a/src/tui/screens/lock/LockForceScreen.tsx +++ b/src/tui/screens/lock/LockForceScreen.tsx @@ -20,7 +20,7 @@ import type { Kysely } from 'kysely'; import { attempt } from '@logosdx/utils'; import { useRouter } from '../../router.js'; -import { useFocusScope } from '../../focus.js'; +import { useBusy, useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Spinner, SmartConfirm, useToast } from '../../components/index.js'; import { useAbortableTask, useAsyncEffect } from '../../hooks/index.js'; @@ -28,6 +28,7 @@ import { createConnection, testConnection } from '../../../core/connection/index import { getLockManager } from '../../../core/lock/index.js'; import { checkConfigPolicy } from '../../../core/policy/index.js'; import { isConfigGuarded, STOPPED_WAITING_MESSAGE } from '../../utils/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * Screen phase state. @@ -53,6 +54,8 @@ export function LockForceScreen({ params: _params }: ScreenProps): ReactElement const [error, setError] = useState(null); const [lockStatus, setLockStatus] = useState(null); + useBusy(phase === 'running'); + const task = useAbortableTask(); // Check current lock status @@ -275,7 +278,7 @@ export function LockForceScreen({ params: _params }: ScreenProps): ReactElement - [Enter/Esc] Back + Back ); @@ -292,7 +295,7 @@ export function LockForceScreen({ params: _params }: ScreenProps): ReactElement - [Esc] Cancel + Cancel ); @@ -312,7 +315,7 @@ export function LockForceScreen({ params: _params }: ScreenProps): ReactElement - [Enter/Esc] Back + Back ); @@ -374,7 +377,7 @@ export function LockForceScreen({ params: _params }: ScreenProps): ReactElement - [Esc] Cancel + Cancel ); @@ -394,7 +397,7 @@ export function LockForceScreen({ params: _params }: ScreenProps): ReactElement - [Enter/Esc] Done + Done ); @@ -411,7 +414,7 @@ export function LockForceScreen({ params: _params }: ScreenProps): ReactElement - [Enter/Esc] Back + Back ); diff --git a/src/tui/screens/lock/LockListScreen.tsx b/src/tui/screens/lock/LockListScreen.tsx index c200bf92..3ec7466d 100644 --- a/src/tui/screens/lock/LockListScreen.tsx +++ b/src/tui/screens/lock/LockListScreen.tsx @@ -28,6 +28,7 @@ import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Spinner, LockStatus, useToast } from '../../components/index.js'; import { useLockStatus } from '../../hooks/index.js'; +import { Hotkey, KeyTarget } from '../../components/layout/Hotkey.js'; /** * LockListScreen component. @@ -158,7 +159,7 @@ export function LockListScreen({ params: _params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -191,7 +192,7 @@ export function LockListScreen({ params: _params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -240,29 +241,37 @@ export function LockListScreen({ params: _params }: ScreenProps): ReactElement { - - [s] Status - View detailed lock information - - - [a] Acquire - Obtain exclusive lock - - - [r] Release - Release your + + + [s] Status - View detailed lock information + + + + + [a] Acquire - Obtain exclusive lock + + + + + [r] Release - Release your lock - - - [f] Force - + + + + + [f] Force - Admin force-release - + + - [s] Status - [a] Acquire - [r] Release - [f] Force - [Esc] Back + Status + Acquire + Release + Force + Back ); diff --git a/src/tui/screens/lock/LockReleaseScreen.tsx b/src/tui/screens/lock/LockReleaseScreen.tsx index c432bc1b..3246e710 100644 --- a/src/tui/screens/lock/LockReleaseScreen.tsx +++ b/src/tui/screens/lock/LockReleaseScreen.tsx @@ -19,7 +19,7 @@ import type { Kysely } from 'kysely'; import { attempt } from '@logosdx/utils'; import { useRouter } from '../../router.js'; -import { useFocusScope } from '../../focus.js'; +import { useBusy, useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Spinner, SmartConfirm, useToast } from '../../components/index.js'; import { useLockStatus } from '../../hooks/index.js'; @@ -30,6 +30,7 @@ import { LockOwnershipError, } from '../../../core/lock/index.js'; import { checkConfigPolicy } from '../../../core/policy/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * Screen phase state. @@ -52,6 +53,8 @@ export function LockReleaseScreen({ params: _params }: ScreenProps): ReactElemen const [phase, setPhase] = useState('loading'); const [error, setError] = useState(null); + useBusy(phase === 'running'); + const { status: lockStatus, identityStr, @@ -229,7 +232,7 @@ export function LockReleaseScreen({ params: _params }: ScreenProps): ReactElemen - [Enter/Esc] Back + Back ); @@ -260,7 +263,7 @@ export function LockReleaseScreen({ params: _params }: ScreenProps): ReactElemen - [Enter/Esc] Back + Back ); @@ -286,7 +289,7 @@ export function LockReleaseScreen({ params: _params }: ScreenProps): ReactElemen - [Enter/Esc] Back + Back ); @@ -336,7 +339,7 @@ export function LockReleaseScreen({ params: _params }: ScreenProps): ReactElemen - [Enter/Esc] Done + Done ); @@ -353,7 +356,7 @@ export function LockReleaseScreen({ params: _params }: ScreenProps): ReactElemen - [Enter/Esc] Back + Back ); diff --git a/src/tui/screens/lock/LockStatusScreen.tsx b/src/tui/screens/lock/LockStatusScreen.tsx index a4611609..19fb4fcc 100644 --- a/src/tui/screens/lock/LockStatusScreen.tsx +++ b/src/tui/screens/lock/LockStatusScreen.tsx @@ -29,6 +29,7 @@ import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Spinner, LockStatus, useToast } from '../../components/index.js'; import { useLockStatus } from '../../hooks/index.js'; +import { Hotkey, KeyTarget } from '../../components/layout/Hotkey.js'; /** * Format duration for display. @@ -197,7 +198,7 @@ export function LockStatusScreen({ params: _params }: ScreenProps): ReactElement - [Esc] Back + Back ); @@ -230,7 +231,7 @@ export function LockStatusScreen({ params: _params }: ScreenProps): ReactElement - [Esc] Back + Back ); @@ -299,9 +300,11 @@ export function LockStatusScreen({ params: _params }: ScreenProps): ReactElement Database is unlocked and available. - + + Press [a] to acquire the lock. - + + )} @@ -331,15 +334,15 @@ export function LockStatusScreen({ params: _params }: ScreenProps): ReactElement )} - {!lockStatus?.isLocked && [a] Acquire} + {!lockStatus?.isLocked && Acquire} {isOurLock && ( <> - [r] Release - [x] Extend + Release + Extend )} - {lockStatus?.isLocked && [f] Force} - [Esc] Back + {lockStatus?.isLocked && Force} + Back ); diff --git a/src/tui/screens/not-found.tsx b/src/tui/screens/not-found.tsx index 7db99e53..efda7dc7 100644 --- a/src/tui/screens/not-found.tsx +++ b/src/tui/screens/not-found.tsx @@ -10,6 +10,7 @@ import type { ScreenProps } from '../types.js'; import { useRouter } from '../router.js'; import { useFocusScope } from '../focus.js'; import { useFocusedInput } from '../keyboard.js'; +import { Hotkey } from '../components/layout/Hotkey.js'; /** * Not Found screen component. @@ -63,8 +64,9 @@ export function NotFoundScreen({ params: _params }: ScreenProps): ReactElement { {/* Navigation Hints */} - - [Enter] go home {canGoBack ? '[Esc] go back' : ''} + + go home + {canGoBack && go back} ); diff --git a/src/tui/screens/pipeline/PipelineHistoryDetailScreen.tsx b/src/tui/screens/pipeline/PipelineHistoryDetailScreen.tsx index 112487a8..4254eb48 100644 --- a/src/tui/screens/pipeline/PipelineHistoryDetailScreen.tsx +++ b/src/tui/screens/pipeline/PipelineHistoryDetailScreen.tsx @@ -14,6 +14,7 @@ import type { PipelineHistoryRecord } from '../../../core/pipeline/history.js'; import { parseRunId } from '../../../core/pipeline/parse.js'; import { oneLine } from '../../utils/string.js'; import { PipelineEvidence } from './helpers.js'; +import { HotkeyLine } from '../../components/layout/Hotkey.js'; function dependencies(raw: string): number[] { @@ -132,8 +133,8 @@ export function PipelineHistoryDetailScreen({ params }: ScreenProps): ReactEleme steps={current.steps.map((step) => ({ stepNo: step.step_no, name: step.name, needs: dependencies(step.needs), status: step.status, outcome: step.transaction_outcome }))} /> } - [Enter] Step details · [d] Evidence · [i] ID lookup - [r] Refresh · [Esc] Back + [Enter] Step details · [d] Evidence · [i] ID lookup + [r] Refresh · [Esc] Back ; diff --git a/src/tui/screens/pipeline/PipelineHistoryScreen.tsx b/src/tui/screens/pipeline/PipelineHistoryScreen.tsx index eda83fc4..c7273634 100644 --- a/src/tui/screens/pipeline/PipelineHistoryScreen.tsx +++ b/src/tui/screens/pipeline/PipelineHistoryScreen.tsx @@ -14,6 +14,7 @@ import type { NoormPipelineRun } from '../../../core/shared/tables.js'; import { isPipelineSlug, parseHistoryLimit } from '../../../core/pipeline/parse.js'; import { oneLine } from '../../utils/string.js'; import { PipelineHeading } from './helpers.js'; +import { HotkeyLine } from '../../components/layout/Hotkey.js'; /** * Recorded runs for one slug on the active config. It takes a slug with no local definition, @@ -120,7 +121,7 @@ export function PipelineHistoryScreen({ params }: ScreenProps): ReactElement { } onSelect={(item) => navigate('pipeline/history/detail', { pipelineRunId: item.value.id })} isFocused={isFocused} reserveRows={3} emptyLabel="No pipeline runs in this configuration" />} - [Enter] Run · [d] Details · [h] Lookup · [i] ID · [r] Refresh · [Esc] Back + [Enter] Run · [d] Details · [h] Lookup · [i] ID · [r] Refresh · [Esc] Back ; diff --git a/src/tui/screens/pipeline/PipelineListScreen.tsx b/src/tui/screens/pipeline/PipelineListScreen.tsx index e3f39492..a4658d60 100644 --- a/src/tui/screens/pipeline/PipelineListScreen.tsx +++ b/src/tui/screens/pipeline/PipelineListScreen.tsx @@ -12,6 +12,7 @@ import { DEFAULT_PIPELINES_PATH } from '../../../core/settings/defaults.js'; import { oneLine } from '../../utils/string.js'; import { discoverPipelineDefinitions } from './helpers.js'; import type { PipelineEntry } from './helpers.js'; +import { HotkeyLine } from '../../components/layout/Hotkey.js'; /** * Entry point for pipelines in the TUI. Lists invalid definitions with their failed rule but refuses to open them, @@ -81,7 +82,7 @@ export function PipelineListScreen({ params: _params }: ScreenProps): ReactEleme } onCancel={back} isFocused={isFocused} emptyLabel="No pipelines found" reserveRows={2} />} - [Enter] Review · [h] History / slug lookup · [i] Run ID lookup · [Esc] Back + [Enter] Review · [h] History / slug lookup · [i] Run ID lookup · [Esc] Back ; diff --git a/src/tui/screens/pipeline/PipelineRunScreen.tsx b/src/tui/screens/pipeline/PipelineRunScreen.tsx index a0ee9e84..3ff69ba9 100644 --- a/src/tui/screens/pipeline/PipelineRunScreen.tsx +++ b/src/tui/screens/pipeline/PipelineRunScreen.tsx @@ -5,7 +5,7 @@ import { Box, Text, useInput } from 'ink'; import type { ScreenProps } from '../../types.js'; import { useAppContext, useGlobalModes } from '../../app-context.js'; import { useRouter } from '../../router.js'; -import { useFocusScope } from '../../focus.js'; +import { useBusy, useFocusScope } from '../../focus.js'; import { useAsyncEffect } from '../../hooks/index.js'; import { usePipelineOperations } from '../../hooks/usePipelineOperations.js'; import type { PipelineOperationTarget } from '../../hooks/usePipelineOperations.js'; @@ -20,6 +20,7 @@ import type { PipelineDefinition, PipelineDryRunResult, PipelineJsonValue, Pipel import { oneLine } from '../../utils/string.js'; import { PipelineInputEditor } from './PipelineInputEditor.js'; import { inputPresence, PipelineEvidence, PipelineHeading } from './helpers.js'; +import { Hotkey, HotkeyLine } from '../../components/layout/Hotkey.js'; type Confirmation = { target: PipelineOperationTarget; @@ -81,6 +82,9 @@ export function PipelineRunScreen({ params }: ScreenProps): ReactElement { const [invocation, setInvocation] = useState<{ configName: string; dryRun: boolean } | null>(null); const submitting = useRef(false); const mounted = useRef(true); + + useBusy(busy); + useEffect(() => { mounted.current = true; @@ -301,7 +305,7 @@ export function PipelineRunScreen({ params }: ScreenProps): ReactElement { {busy ? - {stopping ? 'Stop requested. Waiting for running SQL and transaction cleanup to finish.' : '[Esc] Request stop (running SQL must settle)'} + {stopping ? Stop requested. Waiting for running SQL and transaction cleanup to finish. : Request stop (running SQL must settle)} : result ? <> Target: {oneLine(invocation?.configName ?? '')} · {result.dryRun ? 'Dry run — no workflow SQL or run history written' : 'Execution result'} ({ stepNo: step.stepNo, name: step.name, needs: step.needs, ...('status' in step ? { status: step.status, outcome: step.transactionOutcome, error: step.error } : {}) }))} /> {!result.dryRun && result.primaryError && Error phase: {result.primaryError.phase}{result.primaryError.stepNo === undefined ? '' : ` · Step ${result.primaryError.stepNo}`}} - [Enter] Step details · [d] Evidence · [r] Review · [h] History · [Esc] Back + [Enter] Step details · [d] Evidence · [r] Review · [h] History · [Esc] Back : loading ? : definition ? <> Mode: {definition.mode} · Transaction: {definition.transaction} · Single run: {definition.singleRun ? 'protected' : 'off'} Concurrency: configured {definition.concurrency} · effective {activeConfig ? getPipelineConcurrency(definition, activeConfig.connection.dialect, activeConfig.connection.pool?.max) : 'select config'} @@ -335,12 +339,12 @@ export function PipelineRunScreen({ params }: ScreenProps): ReactElement { onSelect={(item) => setDescription(`Step ${item.value.stepNo}: ${item.value.id}\nDependencies: ${item.value.needs.join(', ') || 'none'}`)} isFocused={isFocused} reserveRows={8} />} {tab === 'inputs' && selected ? oneLine(definition.inputs[selected]?.description ?? '') : 'Steps in declaration order'} - [Enter] {tab === 'inputs' ? 'Edit' : 'Step details'} · [x] Reset · [s] Inputs/steps · [d] Details - [r] {modes.dryRun ? 'Dry run' : 'Run'} · [h] History · [Esc] Back + {`[Enter] ${tab === 'inputs' ? 'Edit' : 'Step details'} · [x] Reset · [s] Inputs/steps · [d] Details`} + {`[r] ${modes.dryRun ? 'Dry run' : 'Run'} · [h] History · [Esc] Back`} : null} {!busy && error && {oneLine(error)}} {!busy && operations.cleanupFailed && Connection cleanup failed. Execution evidence is preserved; exit status is nonzero.} - {!busy && !loading && !definition && [d] Details · [h] History · [Esc] Back} + {!busy && !loading && !definition && [d] Details · [h] History · [Esc] Back} {!busy && !result && !target && Database not ready. Browsing and input editing remain available.} ; diff --git a/src/tui/screens/run/RunBuildScreen.tsx b/src/tui/screens/run/RunBuildScreen.tsx index f6b01b08..d8c4f27b 100644 --- a/src/tui/screens/run/RunBuildScreen.tsx +++ b/src/tui/screens/run/RunBuildScreen.tsx @@ -23,7 +23,7 @@ import type { ReactElement } from 'react'; import type { ScreenProps } from '../../types.js'; import { useRouter } from '../../router.js'; -import { useFocusScope } from '../../focus.js'; +import { useBusy, useFocusScope } from '../../focus.js'; import { useSettings, useGlobalModes, useAppContext } from '../../app-context.js'; import { Panel, Spinner, SmartConfirm, StatementProgress, useToast } from '../../components/index.js'; import { useRunProgress, useAsyncEffect, useDoublePress, DOUBLE_PRESS_WINDOW_MS } from '../../hooks/index.js'; @@ -40,6 +40,7 @@ import { } from '../../utils/index.js'; import { attempt } from '@logosdx/utils'; import { DEFAULT_SQL_PATH } from '../../../core/settings/defaults.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; type Phase = 'loading' | 'confirm' | 'running' | 'complete' | 'error'; @@ -66,6 +67,8 @@ export function RunBuildScreen({ params: _params }: ScreenProps): ReactElement { const [error, setError] = useState(null); const [unmatchedInclude, setUnmatchedInclude] = useState([]); + useBusy(phase === 'running'); + // Load settings and discover files useAsyncEffect(async (isCancelled) => { @@ -242,7 +245,7 @@ export function RunBuildScreen({ params: _params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -259,7 +262,7 @@ export function RunBuildScreen({ params: _params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -292,8 +295,8 @@ export function RunBuildScreen({ params: _params }: ScreenProps): ReactElement { - [r] Retry - [Esc] Back + Retry + Back ); @@ -558,8 +561,8 @@ export function RunBuildScreen({ params: _params }: ScreenProps): ReactElement { - [r] Retry - [Esc] Back + Retry + Back ); diff --git a/src/tui/screens/run/RunDirScreen.tsx b/src/tui/screens/run/RunDirScreen.tsx index 6b1fc35f..6704d758 100644 --- a/src/tui/screens/run/RunDirScreen.tsx +++ b/src/tui/screens/run/RunDirScreen.tsx @@ -21,6 +21,7 @@ import { useRouter } from '../../router.js'; import { useSettings, useGlobalModes, useAppContext } from '../../app-context.js'; import { Panel, Spinner, Confirm, SelectList, FilePicker, KeyHandler, StatementProgress, useToast } from '../../components/index.js'; import { useRunProgress, useAsyncEffect, modeBannerRows } from '../../hooks/index.js'; +import { useBusy } from '../../focus.js'; import { buildRunContext, discoverFiles, runFiles, checkFilesStatus } from '../../../core/runner/index.js'; import type { FilesStatusResult } from '../../../core/runner/index.js'; import { getErrorMessage, resolveScreenIdentity, withScreenConnection, progressPercentage } from '../../utils/index.js'; @@ -29,6 +30,7 @@ import { attempt } from '@logosdx/utils'; import type { SelectListItem } from '../../components/index.js'; import { DEFAULT_SQL_PATH } from '../../../core/settings/defaults.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; type Phase = 'loading' | 'picker' | 'confirm' | 'checking' | 'rerun-confirm' | 'running' | 'complete' | 'error'; @@ -97,6 +99,8 @@ export function RunDirScreen({ params }: ScreenProps): ReactElement { const [filesStatus, setFilesStatus] = useState(null); const [forceRerun, setForceRerun] = useState(false); + useBusy(phase === 'running'); + // Refs for cancellation support const activeConnectionRef = useRef<{ destroy: () => Promise } | null>(null); const cancelledRef = useRef(false); @@ -438,7 +442,7 @@ export function RunDirScreen({ params }: ScreenProps): ReactElement { No active configuration selected. - [Esc] Back + Back ); @@ -477,8 +481,8 @@ export function RunDirScreen({ params }: ScreenProps): ReactElement { - [r] Retry - [Esc] Back + Retry + Back ); @@ -534,8 +538,8 @@ export function RunDirScreen({ params }: ScreenProps): ReactElement { - {hasFiles && [Enter] Select} - [Esc] Back + {hasFiles && Select} + Back ); @@ -588,7 +592,7 @@ export function RunDirScreen({ params }: ScreenProps): ReactElement { No SQL files found in this directory. - [Esc] Back + Back ); @@ -724,7 +728,7 @@ export function RunDirScreen({ params }: ScreenProps): ReactElement { - [Esc] Cancel + Cancel ); @@ -793,8 +797,8 @@ export function RunDirScreen({ params }: ScreenProps): ReactElement { - [r] Retry - [Esc] Back + Retry + Back ); diff --git a/src/tui/screens/run/RunExecScreen.tsx b/src/tui/screens/run/RunExecScreen.tsx index 94d52820..ff69ae40 100644 --- a/src/tui/screens/run/RunExecScreen.tsx +++ b/src/tui/screens/run/RunExecScreen.tsx @@ -36,6 +36,7 @@ import { useToast, } from '../../components/index.js'; import { useRunProgress, useAsyncEffect, useDoublePress, DOUBLE_PRESS_WINDOW_MS, modeBannerRows } from '../../hooks/index.js'; +import { useBusy } from '../../focus.js'; import { buildRunContext, discoverFiles, runFiles } from '../../../core/runner/index.js'; import { getErrorMessage, @@ -46,6 +47,7 @@ import { } from '../../utils/index.js'; import { attempt } from '@logosdx/utils'; import { DEFAULT_SQL_PATH } from '../../../core/settings/defaults.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; type Phase = 'loading' | 'picker' | 'confirm' | 'running' | 'complete' | 'error'; @@ -71,6 +73,8 @@ export function RunExecScreen({ params: _params }: ScreenProps): ReactElement { const [error, setError] = useState(null); const projectRoot = process.cwd(); + useBusy(phase === 'running'); + // Load files useAsyncEffect(async (isCancelled) => { @@ -244,7 +248,7 @@ export function RunExecScreen({ params: _params }: ScreenProps): ReactElement { No active configuration selected. - [Esc] Back + Back ); @@ -283,8 +287,8 @@ export function RunExecScreen({ params: _params }: ScreenProps): ReactElement { - [r] Retry - [Esc] Back + Retry + Back ); @@ -325,9 +329,9 @@ export function RunExecScreen({ params: _params }: ScreenProps): ReactElement { - [Space] Toggle - [Enter] Confirm - [Esc] Back + Toggle + Confirm + Back ); @@ -419,8 +423,8 @@ export function RunExecScreen({ params: _params }: ScreenProps): ReactElement { - [r] Retry - [Esc] Back + Retry + Back ); diff --git a/src/tui/screens/run/RunFileScreen.tsx b/src/tui/screens/run/RunFileScreen.tsx index 49fde1ae..6a685256 100644 --- a/src/tui/screens/run/RunFileScreen.tsx +++ b/src/tui/screens/run/RunFileScreen.tsx @@ -20,6 +20,7 @@ import { useRouter } from '../../router.js'; import { useSettings, useGlobalModes, useAppContext } from '../../app-context.js'; import { Panel, Spinner, Confirm, SearchableList, KeyHandler, StatementProgress, useToast } from '../../components/index.js'; import { useRunProgress, useAsyncEffect, modeBannerRows } from '../../hooks/index.js'; +import { useBusy } from '../../focus.js'; import { buildRunContext, discoverFiles, runFile, checkFilesStatus } from '../../../core/runner/index.js'; import type { FilesStatusResult } from '../../../core/runner/index.js'; import { getErrorMessage, resolveScreenIdentity, withScreenConnection } from '../../utils/index.js'; @@ -28,6 +29,7 @@ import { attempt } from '@logosdx/utils'; import type { SelectListItem } from '../../components/index.js'; import { DEFAULT_SQL_PATH } from '../../../core/settings/defaults.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; type Phase = 'loading' | 'picker' | 'confirm' | 'checking' | 'rerun-confirm' | 'running' | 'complete' | 'error'; @@ -53,6 +55,8 @@ export function RunFileScreen({ params }: ScreenProps): ReactElement { const [_fileStatus, setFileStatus] = useState(null); const [forceRerun, setForceRerun] = useState(false); + useBusy(phase === 'running'); + // Refs for cancellation support const activeConnectionRef = useRef<{ destroy: () => Promise } | null>(null); const cancelledRef = useRef(false); @@ -340,7 +344,7 @@ export function RunFileScreen({ params }: ScreenProps): ReactElement { No active configuration selected. - [Esc] Back + Back ); @@ -377,8 +381,8 @@ export function RunFileScreen({ params }: ScreenProps): ReactElement { - [r] Retry - [Esc] Back + Retry + Back ); @@ -438,11 +442,11 @@ export function RunFileScreen({ params }: ScreenProps): ReactElement { {hasFiles && ( <> - [/] Search - [Enter] Select + Search + Select )} - [Esc] Back + Back ); @@ -550,7 +554,7 @@ export function RunFileScreen({ params }: ScreenProps): ReactElement { - [Esc] Cancel + Cancel ); @@ -613,8 +617,8 @@ export function RunFileScreen({ params }: ScreenProps): ReactElement { - [r] Retry - [Esc] Back + Retry + Back ); diff --git a/src/tui/screens/run/RunInspectScreen.tsx b/src/tui/screens/run/RunInspectScreen.tsx index d00dc074..93031ee0 100644 --- a/src/tui/screens/run/RunInspectScreen.tsx +++ b/src/tui/screens/run/RunInspectScreen.tsx @@ -40,6 +40,7 @@ import type { Dialect } from '../../../core/connection/types.js'; import type { NoormDatabase } from '../../../core/shared/index.js'; import type { StateManager } from '../../../core/state/index.js'; import { DEFAULT_SQL_PATH } from '../../../core/settings/defaults.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; type Phase = 'loading' | 'picker' | 'inspecting' | 'expanded' | 'preview' | 'error'; @@ -845,7 +846,7 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { [↑↓] Scroll [^U/^D] Half - [Esc] Back + Back ); @@ -893,11 +894,11 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { {hasFiles && ( <> - [/] Search - [Enter] Select + Search + Select )} - [Esc] Back + Back ); @@ -922,10 +923,10 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { [↑↓] Scroll [^U/^D] Half - [e] Expand - [p] Preview SQL - [r] Refresh - [Esc] Back + Expand + Preview SQL + Refresh + Back ); @@ -950,7 +951,7 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { [↑↓] Scroll [^U/^D] Half - [Esc] Back to summary + Back to summary ); @@ -984,7 +985,7 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { [↑↓] Scroll [^U/^D] Half - [Esc] Back to summary + Back to summary ); diff --git a/src/tui/screens/run/RunListScreen.tsx b/src/tui/screens/run/RunListScreen.tsx index f4404907..6d48241d 100644 --- a/src/tui/screens/run/RunListScreen.tsx +++ b/src/tui/screens/run/RunListScreen.tsx @@ -31,6 +31,7 @@ import { useActiveConfig, useSettings, useGlobalModes } from '../../app-context. import { Panel, Spinner } from '../../components/index.js'; import { getEffectiveBuildPaths } from '../../../core/settings/rules.js'; import { DEFAULT_SQL_PATH } from '../../../core/settings/defaults.js'; +import { Hotkey, KeyTarget } from '../../components/layout/Hotkey.js'; /** * RunListScreen component. @@ -152,7 +153,7 @@ export function RunListScreen({ params: _params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -233,31 +234,41 @@ export function RunListScreen({ params: _params }: ScreenProps): ReactElement { - - [b] Build - Execute full schema build - - - [e] Exec - Pick files to execute - - - [f] File - Execute a single file - - - [d] Dir - Execute all files in a directory - - - [i] Inspect - View template context - + + + [b] Build - Execute full schema build + + + + + [e] Exec - Pick files to execute + + + + + [f] File - Execute a single file + + + + + [d] Dir - Execute all files in a directory + + + + + [i] Inspect - View template context + + - [b] Build - [e] Exec - [f] File - [d] Dir - [i] Inspect - [Esc] Back + Build + Exec + File + Dir + Inspect + Back ); diff --git a/src/tui/screens/secret/SecretListScreen.tsx b/src/tui/screens/secret/SecretListScreen.tsx index 54234b40..fbe63de3 100644 --- a/src/tui/screens/secret/SecretListScreen.tsx +++ b/src/tui/screens/secret/SecretListScreen.tsx @@ -36,6 +36,7 @@ import { } from '../../components/index.js'; import { maskValue } from '../../../core/logger/redact.js'; import { SecretReadGate } from './SecretReadGate.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * SecretListScreen component. @@ -230,7 +231,7 @@ function SecretListContent({ params: _params }: ScreenProps): ReactElement { - [Esc] Back + Back ); diff --git a/src/tui/screens/secret/SecretReadGate.tsx b/src/tui/screens/secret/SecretReadGate.tsx index 62f7131f..c90c73b4 100644 --- a/src/tui/screens/secret/SecretReadGate.tsx +++ b/src/tui/screens/secret/SecretReadGate.tsx @@ -7,6 +7,7 @@ import { useAppContext } from '../../app-context.js'; import { Panel, ProtectedConfirm } from '../../components/index.js'; import { useFocusScope } from '../../focus.js'; import { useRouter } from '../../router.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** Gate local secret metadata and values before a screen reads either. */ export function SecretReadGate({ children }: { children: ReactElement }): ReactElement { @@ -39,7 +40,7 @@ export function SecretReadGate({ children }: { children: ReactElement }): ReactE {check.blockedReason ?? 'Secret read is not allowed'} - [Enter/Esc] Back + Back ); diff --git a/src/tui/screens/secret/SecretRemoveScreen.tsx b/src/tui/screens/secret/SecretRemoveScreen.tsx index b4c77683..606a9ebd 100644 --- a/src/tui/screens/secret/SecretRemoveScreen.tsx +++ b/src/tui/screens/secret/SecretRemoveScreen.tsx @@ -23,6 +23,7 @@ import { useAppContext } from '../../app-context.js'; import { useVaultSecretKeys } from '../../hooks/index.js'; import { Panel, SmartConfirm, Spinner, useToast } from '../../components/index.js'; import { checkConfigPolicy, getPolicyFingerprint } from '../../../core/policy/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * SecretRemoveScreen component. @@ -171,7 +172,7 @@ export function SecretRemoveScreen({ params }: ScreenProps): ReactElement { - [Enter/Esc] Back + Back ); @@ -198,7 +199,7 @@ export function SecretRemoveScreen({ params }: ScreenProps): ReactElement { {writeCheck.blockedReason ?? 'Secret write is not allowed'} - [Enter/Esc] Back + Back ); @@ -217,7 +218,7 @@ export function SecretRemoveScreen({ params }: ScreenProps): ReactElement { - [Enter/Esc] Back + Back ); @@ -234,7 +235,7 @@ export function SecretRemoveScreen({ params }: ScreenProps): ReactElement { - [Enter/Esc] Back + Back ); @@ -263,7 +264,7 @@ export function SecretRemoveScreen({ params }: ScreenProps): ReactElement { - [Enter/Esc] Back + Back ); diff --git a/src/tui/screens/secret/SecretSetScreen.tsx b/src/tui/screens/secret/SecretSetScreen.tsx index d4883d27..a0b5aadc 100644 --- a/src/tui/screens/secret/SecretSetScreen.tsx +++ b/src/tui/screens/secret/SecretSetScreen.tsx @@ -27,6 +27,7 @@ import { useAppContext } from '../../app-context.js'; import { useVaultSecretKeys } from '../../hooks/index.js'; import { Panel, ProtectedConfirm, SecretValueForm, SmartConfirm, useToast } from '../../components/index.js'; import { checkConfigPolicy, confirmationPhraseFor, getPolicyFingerprint } from '../../../core/policy/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * Simple error screen for when no active config exists. @@ -58,9 +59,7 @@ function NoConfigError({ title, message }: { title: string; message: string }): - - [Esc] Back - + Back ); diff --git a/src/tui/screens/settings/SettingsAccessEditScreen.tsx b/src/tui/screens/settings/SettingsAccessEditScreen.tsx index 19541543..d70319fb 100644 --- a/src/tui/screens/settings/SettingsAccessEditScreen.tsx +++ b/src/tui/screens/settings/SettingsAccessEditScreen.tsx @@ -9,6 +9,7 @@ import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { useSettingsOperation } from '../../hooks/index.js'; import { attachPolicyContext, BUILTIN_ROLES, getConfigPermission, isBuiltinRole, MATRIX, USER_ALWAYS_ALLOWED, type ConfigAccess, type Permission, type PolicyCell, type RoleDefinition } from '../../../core/policy/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; const CELLS: (PolicyCell | undefined)[] = [undefined, 'allow', 'confirm', 'deny']; export function SettingsAccessEditScreen({ params }: ScreenProps): ReactElement { @@ -198,10 +199,10 @@ export function SettingsAccessEditScreen({ params }: ScreenProps): ReactElement {error && {error}} - [Enter] Cycle default/allow/confirm/deny - {!builtin && [m] Metadata} - [s] Save - [Esc] Cancel + Cycle default/allow/confirm/deny + {!builtin && Metadata} + Save + Cancel ); diff --git a/src/tui/screens/settings/SettingsAccessListScreen.tsx b/src/tui/screens/settings/SettingsAccessListScreen.tsx index c25a4870..b71cd308 100644 --- a/src/tui/screens/settings/SettingsAccessListScreen.tsx +++ b/src/tui/screens/settings/SettingsAccessListScreen.tsx @@ -10,6 +10,7 @@ import { Confirm, Panel, SelectList, useToast, type SelectListItem } from '../.. import { getErrorMessage } from '../../utils/index.js'; import { BUILTIN_ROLES, isBuiltinRole } from '../../../core/policy/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; export function SettingsAccessListScreen(_props: ScreenProps): ReactElement { @@ -159,10 +160,10 @@ export function SettingsAccessListScreen(_props: ScreenProps): ReactElement { /> - [a] Add - [Enter/e] Edit - [d] Delete/reset - [Esc] Back + Add + Edit + Delete/reset + Back ); diff --git a/src/tui/screens/settings/SettingsInitScreen.tsx b/src/tui/screens/settings/SettingsInitScreen.tsx index 52800e67..ecd62eef 100644 --- a/src/tui/screens/settings/SettingsInitScreen.tsx +++ b/src/tui/screens/settings/SettingsInitScreen.tsx @@ -22,6 +22,7 @@ import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Confirm, Spinner, useToast } from '../../components/index.js'; import { getErrorMessage } from '../../utils/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; type InitStep = | 'checking' @@ -203,7 +204,7 @@ export function SettingsInitScreen({ params }: ScreenProps): ReactElement { - [Enter/Esc] Back + Back ); diff --git a/src/tui/screens/settings/SettingsListScreen.tsx b/src/tui/screens/settings/SettingsListScreen.tsx index eec0ba8f..02ebb940 100644 --- a/src/tui/screens/settings/SettingsListScreen.tsx +++ b/src/tui/screens/settings/SettingsListScreen.tsx @@ -26,6 +26,7 @@ import { Panel, SelectList, type SelectListItem } from '../../components/index.j import { DEFAULT_CHANGES_PATH, DEFAULT_SQL_PATH } from '../../../core/settings/defaults.js'; import { BUILTIN_ROLES, isBuiltinRole } from '../../../core/policy/index.js'; import { rowBudget } from '../../components/terminal/viewport.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * Settings section item value. @@ -310,9 +311,9 @@ export function SettingsListScreen({ params: _params }: ScreenProps): ReactEleme - [Enter] Edit - [i] Init - [Esc] Back + Edit + Init + Back ); diff --git a/src/tui/screens/settings/SettingsRulesListScreen.tsx b/src/tui/screens/settings/SettingsRulesListScreen.tsx index 5dcc5ae5..dbcdd2a3 100644 --- a/src/tui/screens/settings/SettingsRulesListScreen.tsx +++ b/src/tui/screens/settings/SettingsRulesListScreen.tsx @@ -30,6 +30,7 @@ import { useToast, type SelectListItem, } from '../../components/index.js'; +import { Hotkey, KeyTarget } from '../../components/layout/Hotkey.js'; /** * Rule list item value. @@ -300,9 +301,11 @@ export function SettingsRulesListScreen({ params: _params }: ScreenProps): React {items.length === 0 ? ( No rules defined. - + + Press a to add your first rule. - + + ) : ( - [a] Add - [e] Edit - [d] Delete - [Enter] Edit - [Esc] Back + Add + Edit + Delete + Edit + Back ); diff --git a/src/tui/screens/settings/SettingsSecretRemoveScreen.tsx b/src/tui/screens/settings/SettingsSecretRemoveScreen.tsx index 3aa24dba..4900a82c 100644 --- a/src/tui/screens/settings/SettingsSecretRemoveScreen.tsx +++ b/src/tui/screens/settings/SettingsSecretRemoveScreen.tsx @@ -23,6 +23,7 @@ import { useAppContext } from '../../app-context.js'; import { Panel, Confirm, Spinner, useToast } from '../../components/index.js'; import { getErrorMessage } from '../../utils/index.js'; import { useSecretSource } from '../../hooks/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * SettingsSecretRemoveScreen component. @@ -127,7 +128,7 @@ export function SettingsSecretRemoveScreen({ params }: ScreenProps): ReactElemen - [Enter/Esc] Back + Back ); @@ -149,7 +150,7 @@ export function SettingsSecretRemoveScreen({ params }: ScreenProps): ReactElemen - [Enter/Esc] Back + Back ); @@ -175,7 +176,7 @@ export function SettingsSecretRemoveScreen({ params }: ScreenProps): ReactElemen - [Enter/Esc] Back + Back ); diff --git a/src/tui/screens/settings/SettingsSecretsListScreen.tsx b/src/tui/screens/settings/SettingsSecretsListScreen.tsx index bd688c59..214611d6 100644 --- a/src/tui/screens/settings/SettingsSecretsListScreen.tsx +++ b/src/tui/screens/settings/SettingsSecretsListScreen.tsx @@ -28,7 +28,6 @@ import { getErrorMessage } from '../../utils/index.js'; import { Panel, SecretDefinitionList, - SecretDefinitionListHelp, useToast, } from '../../components/index.js'; @@ -121,8 +120,6 @@ export function SettingsSecretsListScreen({ params: _params }: ScreenProps): Rea /> - - ); diff --git a/src/tui/screens/settings/SettingsStageSecretsListScreen.tsx b/src/tui/screens/settings/SettingsStageSecretsListScreen.tsx index 8a3dbc62..e293ea1d 100644 --- a/src/tui/screens/settings/SettingsStageSecretsListScreen.tsx +++ b/src/tui/screens/settings/SettingsStageSecretsListScreen.tsx @@ -28,9 +28,9 @@ import { getErrorMessage } from '../../utils/index.js'; import { Panel, SecretDefinitionList, - SecretDefinitionListHelp, useToast, } from '../../components/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; /** * SettingsStageSecretsListScreen component. @@ -129,7 +129,7 @@ export function SettingsStageSecretsListScreen({ params }: ScreenProps): ReactEl - [Esc] Back + Back ); @@ -146,7 +146,7 @@ export function SettingsStageSecretsListScreen({ params }: ScreenProps): ReactEl - [Esc] Back + Back ); @@ -176,8 +176,6 @@ export function SettingsStageSecretsListScreen({ params }: ScreenProps): ReactEl /> - - ); diff --git a/src/tui/screens/settings/SettingsStagesListScreen.tsx b/src/tui/screens/settings/SettingsStagesListScreen.tsx index 1fa78a71..3b4ff999 100644 --- a/src/tui/screens/settings/SettingsStagesListScreen.tsx +++ b/src/tui/screens/settings/SettingsStagesListScreen.tsx @@ -29,6 +29,7 @@ import { useToast, type SelectListItem, } from '../../components/index.js'; +import { Hotkey, KeyTarget } from '../../components/layout/Hotkey.js'; /** * Stage list item value. @@ -261,9 +262,11 @@ export function SettingsStagesListScreen({ params: _params }: ScreenProps): Reac {items.length === 0 ? ( No stages defined. - + + Press a to add your first stage. - + + ) : ( - [a] Add - [e] Edit - [k] Secrets - [d] Delete - [Enter] Edit - [Esc] Back + Add + Edit + Secrets + Delete + Edit + Back ); diff --git a/src/tui/screens/vault/VaultInitScreen.tsx b/src/tui/screens/vault/VaultInitScreen.tsx index 201a5e4b..c3e35086 100644 --- a/src/tui/screens/vault/VaultInitScreen.tsx +++ b/src/tui/screens/vault/VaultInitScreen.tsx @@ -12,13 +12,14 @@ import type { ScreenProps } from '../../types.js'; import type { NoormDatabase } from '../../../core/shared/index.js'; import { useRouter } from '../../router.js'; -import { useFocusScope } from '../../focus.js'; +import { useFocusScope, useBusy } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Spinner, SmartConfirm, useToast } from '../../components/index.js'; import { useVaultConnection } from '../../hooks/index.js'; import { checkConfigPolicy } from '../../../core/policy/index.js'; import { initializeVault, getVaultStatus } from '../../../core/vault/index.js'; import { registerIdentity } from '../../../core/identity/sync.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; type Phase = 'connecting' | 'ready' | 'initializing' | 'error'; @@ -37,6 +38,8 @@ export function VaultInitScreen({ params: _params }: ScreenProps): ReactElement const [initializationRequested, setInitializationRequested] = useState(false); const { phase: basePhase, error, connRef, setPhase: setBasePhase, setError } = useVaultConnection({ enabled: connectionApproved }); const phase = (connectionApproved ? basePhase : 'ready') as Phase; + + useBusy(phase === 'initializing'); const setPhase = setBasePhase as (p: Phase) => void; const [pendingPolicy, setPendingPolicy] = useState<'vault:write' | null>(null); const policyCheck = pendingPolicy && activeConfig ? checkConfigPolicy('user', activeConfig, pendingPolicy) : null; @@ -174,7 +177,7 @@ export function VaultInitScreen({ params: _params }: ScreenProps): ReactElement {error ?? 'Identity not available'} - [Esc] Back + Back ); @@ -203,7 +206,7 @@ export function VaultInitScreen({ params: _params }: ScreenProps): ReactElement {error} - [Esc] Back + Back ); @@ -263,8 +266,8 @@ export function VaultInitScreen({ params: _params }: ScreenProps): ReactElement - [Enter] Initialize - [Esc] Cancel + Initialize + Cancel ); diff --git a/src/tui/screens/vault/VaultRemoveScreen.tsx b/src/tui/screens/vault/VaultRemoveScreen.tsx index 109bc2bf..07793a3f 100644 --- a/src/tui/screens/vault/VaultRemoveScreen.tsx +++ b/src/tui/screens/vault/VaultRemoveScreen.tsx @@ -13,7 +13,7 @@ import type { ScreenProps } from '../../types.js'; import type { NoormDatabase } from '../../../core/shared/index.js'; import { useRouter } from '../../router.js'; -import { useFocusScope } from '../../focus.js'; +import { useFocusScope, useBusy } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Spinner, SmartConfirm, useToast } from '../../components/index.js'; import { useVaultConnection } from '../../hooks/index.js'; @@ -21,6 +21,7 @@ import { loadPrivateKey } from '../../../core/identity/storage.js'; import { checkConfigPolicy } from '../../../core/policy/index.js'; import { vaultEditConfirmation, vaultEditRefusal, VaultEditRefused, type VaultEditPermission } from './edit-policy.js'; import { getVaultKey, deleteVaultSecret, vaultSecretExists } from '../../../core/vault/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; type Phase = 'connecting' | 'ready' | 'deleting' | 'error'; @@ -39,6 +40,8 @@ export function VaultRemoveScreen({ params }: ScreenProps): ReactElement { const { phase: basePhase, error, connRef, setPhase: setBasePhase, setError } = useVaultConnection({ enabled: !denied }); const phase = basePhase as Phase; + + useBusy(phase === 'deleting'); const setPhase = setBasePhase as (p: Phase) => void; const secretKey = params.name; @@ -208,7 +211,7 @@ export function VaultRemoveScreen({ params }: ScreenProps): ReactElement { No secret key provided. - [Esc] Back + Back ); @@ -224,7 +227,7 @@ export function VaultRemoveScreen({ params }: ScreenProps): ReactElement { {error ?? 'Identity not available'} - [Esc] Back + Back ); @@ -253,7 +256,7 @@ export function VaultRemoveScreen({ params }: ScreenProps): ReactElement { {error} - [Esc] Back + Back ); @@ -320,8 +323,8 @@ export function VaultRemoveScreen({ params }: ScreenProps): ReactElement { - [y/Enter] Delete - [n/Esc] Cancel + Delete + Cancel ); diff --git a/src/tui/screens/vault/VaultScreen.tsx b/src/tui/screens/vault/VaultScreen.tsx index d48216f7..b6699b62 100644 --- a/src/tui/screens/vault/VaultScreen.tsx +++ b/src/tui/screens/vault/VaultScreen.tsx @@ -38,6 +38,7 @@ import { type VaultSecret, type PendingVaultUser, } from '../../../core/vault/index.js'; +import { Hotkey, KeyTarget } from '../../components/layout/Hotkey.js'; /** Recipients of a propagation, as `getUsersWithoutVaultAccess` returns them. */ type PropagationRecipient = PendingVaultUser; @@ -389,7 +390,7 @@ export function VaultScreen({ params: _params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -401,8 +402,8 @@ export function VaultScreen({ params: _params }: ScreenProps): ReactElement { {readCheck.blockedReason ?? 'Vault read is not allowed'} - {writeCheck?.allowed && [i] Initialize vault} - [Esc] Back + {writeCheck?.allowed && Initialize vault} + Back ); @@ -472,7 +473,7 @@ export function VaultScreen({ params: _params }: ScreenProps): ReactElement { {error} - [Esc] Back + Back ); @@ -488,12 +489,12 @@ export function VaultScreen({ params: _params }: ScreenProps): ReactElement { Vault not initialized. - Press [i] to initialize the vault for this database. + Press [i] to initialize the vault for this database. - [i] Initialize - [Esc] Back + Initialize + Back ); @@ -515,7 +516,7 @@ export function VaultScreen({ params: _params }: ScreenProps): ReactElement { - [Esc] Back + Back ); @@ -595,7 +596,7 @@ export function VaultScreen({ params: _params }: ScreenProps): ReactElement { {listItems.length === 0 ? ( No vault secrets yet. - Press [a] to add the first secret. + Press [a] to add the first secret. ) : ( - [a] Add - {listItems.length > 0 && [Enter] Select} + Add + {listItems.length > 0 && Select} {status.usersWithoutAccess > 0 && ( - {propagating ? 'Propagating...' : '[p] Propagate'} + propagating ? Propagating... : Propagate )} - [Esc] Back + Back ); diff --git a/src/tui/screens/vault/VaultSetScreen.tsx b/src/tui/screens/vault/VaultSetScreen.tsx index ed6ac384..d38f2b9b 100644 --- a/src/tui/screens/vault/VaultSetScreen.tsx +++ b/src/tui/screens/vault/VaultSetScreen.tsx @@ -14,7 +14,7 @@ import type { ScreenProps } from '../../types.js'; import type { NoormDatabase } from '../../../core/shared/index.js'; import { useRouter } from '../../router.js'; -import { useFocusScope } from '../../focus.js'; +import { useFocusScope, useBusy } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Spinner, SmartConfirm, useToast, TextInput } from '../../components/index.js'; import { useVaultConnection } from '../../hooks/index.js'; @@ -22,6 +22,7 @@ import { loadPrivateKey } from '../../../core/identity/storage.js'; import { checkConfigPolicy } from '../../../core/policy/index.js'; import { vaultEditConfirmation, vaultEditRefusal, VaultEditRefused, type VaultEditPermission } from './edit-policy.js'; import { getVaultKey, setVaultSecret } from '../../../core/vault/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; type Phase = 'connecting' | 'ready' | 'saving' | 'error'; @@ -41,6 +42,8 @@ export function VaultSetScreen({ params }: ScreenProps): ReactElement { const { phase: basePhase, error, connRef, setPhase: setBasePhase, setError } = useVaultConnection({ enabled: !denied }); const phase = basePhase as Phase; + + useBusy(phase === 'saving'); const setPhase = setBasePhase as (p: Phase) => void; const [keyValue, setKeyValue] = useState(params.name ?? ''); @@ -240,7 +243,7 @@ export function VaultSetScreen({ params }: ScreenProps): ReactElement { {error ?? 'Identity not available'} - [Esc] Back + Back ); @@ -269,7 +272,7 @@ export function VaultSetScreen({ params }: ScreenProps): ReactElement { {error} - [Esc] Back + Back ); @@ -354,9 +357,9 @@ export function VaultSetScreen({ params }: ScreenProps): ReactElement { - [Tab] Next Field - [Enter] Save - [Esc] Cancel + Next Field + Save + Cancel ); diff --git a/src/tui/screens/vault/edit-policy.tsx b/src/tui/screens/vault/edit-policy.tsx index 8e4ebc2f..46c32126 100644 --- a/src/tui/screens/vault/edit-policy.tsx +++ b/src/tui/screens/vault/edit-policy.tsx @@ -5,6 +5,7 @@ import type { ReactElement } from 'react'; import { Panel } from '../../components/index.js'; import { checkConfigPolicy } from '../../../core/policy/index.js'; import type { ConfigAccess, PolicyCheck } from '../../../core/policy/index.js'; +import { Hotkey } from '../../components/layout/Hotkey.js'; const VAULT_EDIT_PERMISSIONS = ['vault:read', 'vault:write'] as const; @@ -61,7 +62,7 @@ export function VaultEditRefused({ title, reason }: { title: string; reason?: st {reason} - [Esc] Back + Back ); diff --git a/src/tui/terminal-background.ts b/src/tui/terminal-background.ts new file mode 100644 index 00000000..569d659b --- /dev/null +++ b/src/tui/terminal-background.ts @@ -0,0 +1,156 @@ +/** + * The hover shade, derived from the terminal's own background. + * + * A fixed color cannot suit every theme, but a small step toward the foreground + * from whatever the background is reads as a hover on all of them. Terminals + * report their background when asked (OSC 11). The query runs before Ink starts + * because Ink splits the reply into separate keypresses, and a text field would + * type them. + * + * @example + * const shade = await detectHoverShade(process.stdin, process.stdout); + * if (shade) setHoverBackground(shade); + */ + +/** + * Background of whatever the pointer is over and a click would act on. + * + * ANSI bright black until `noorm ui` has asked the terminal for its own + * background; then a shade a little off it. Bright black alone is invisible on a + * theme that maps it to its background. + */ +let hoverShade = 'gray'; + +export function hoverBackground(): string { + + return hoverShade; + +} + +export function setHoverBackground(color: string): void { + + hoverShade = color; + +} + +const ESC = String.fromCharCode(27); + +const BACKGROUND_REPLY = new RegExp(`${ESC}\\]11;rgb:([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})/([0-9a-fA-F]{1,4})`); + +/** Share of the way toward white (dark theme) or black (light theme) the shade moves. */ +const LIGHTEN_BY = 0.1; +const DARKEN_BY = 0.08; + +/** One component of an `rgb:` reply, 1 to 4 hex digits wide, scaled to 0-255. */ +function scaleComponent(hex: string): number { + + return Math.round((parseInt(hex, 16) / (16 ** hex.length - 1)) * 255); + +} + +const toHex = (value: number) => Math.round(value).toString(16).padStart(2, '0'); + +/** + * The background color in a terminal's OSC 11 reply, as `#rrggbb`, or null. + * + * @example + * parseBackgroundReply('\x1b]11;rgb:1616/1a1a/2020\x1b\\'); // '#161a20' + */ +export function parseBackgroundReply(text: string): string | null { + + const match = BACKGROUND_REPLY.exec(text); + + if (!match) return null; + + const [red, green, blue] = [match[1]!, match[2]!, match[3]!].map(scaleComponent); + + return `#${toHex(red!)}${toHex(green!)}${toHex(blue!)}`; + +} + +/** + * A color a little off the background: lighter on a dark theme, darker on a light one. + * + * @example + * hoverShadeFor('#161a20'); // '#2d3136' + */ +export function hoverShadeFor(background: string): string { + + const red = parseInt(background.slice(1, 3), 16); + const green = parseInt(background.slice(3, 5), 16); + const blue = parseInt(background.slice(5, 7), 16); + const luminance = (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255; + + const shift = (channel: number) => (luminance < 0.5 + ? channel + (255 - channel) * LIGHTEN_BY + : channel * (1 - DARKEN_BY)); + + return `#${toHex(shift(red))}${toHex(shift(green))}${toHex(shift(blue))}`; + +} + +/** The parts of `process.stdin` the query uses. */ +interface RawInput { + isTTY?: boolean; + setRawMode(mode: boolean): unknown; + resume(): unknown; + pause(): unknown; + on(event: 'data', listener: (chunk: Buffer | string) => void): unknown; + off(event: 'data', listener: (chunk: Buffer | string) => void): unknown; +} + +/** The parts of `process.stdout` the query uses. */ +interface TerminalOutput { + isTTY?: boolean; + write(text: string): unknown; +} + +/** + * Ask the terminal for its background and return the hover shade for it, or null + * when it is not a terminal or does not answer in time. + * + * Reads raw stdin and hands it back as it found it, so call it before rendering. + */ +export function detectHoverShade(stdin: RawInput, stdout: TerminalOutput, timeoutMs = 120): Promise { + + if (!stdin.isTTY || !stdout.isTTY) return Promise.resolve(null); + + return new Promise((resolve) => { + + let buffer = ''; + let finished = false; + + const finish = (shade: string | null) => { + + if (finished) return; + + finished = true; + clearTimeout(giveUp); + stdin.off('data', onData); + stdin.setRawMode(false); + stdin.pause(); + resolve(shade); + + }; + + const giveUp = setTimeout(() => finish(null), timeoutMs); + + const onData = (chunk: Buffer | string) => { + + buffer += chunk.toString(); + + const background = parseBackgroundReply(buffer); + + // A short wait lets the reply's terminator arrive here and not in Ink. + if (background) setTimeout(() => finish(hoverShadeFor(background)), 15); + + }; + + stdin.setRawMode(true); + stdin.resume(); + stdin.on('data', onData); + stdout.write(`${ESC}]11;?${ESC}\\`); + + }); + +} diff --git a/src/tui/types.ts b/src/tui/types.ts index 88fea377..b1e7d48e 100644 --- a/src/tui/types.ts +++ b/src/tui/types.ts @@ -288,6 +288,12 @@ export interface RouterContextValue extends RouterState { */ back: () => void; + /** + * Go back several entries at once, as a click on an earlier breadcrumb does. + * Clamped to the history length; fewer than one step does nothing. + */ + backBy: (steps: number) => void; + /** * Replace current route without adding to history. * Useful for redirects. @@ -366,8 +372,27 @@ export interface FocusContextValue { * called. Use `useTextEntry` rather than calling this directly. */ beginTextEntry: () => () => void; + + /** + * Whether something on screen has asked not to be walked away from or + * answered from the side. Read at event time, like `isTyping`. + */ + isGuarded: (kind: GuardKind) => boolean; + + /** + * Count a guard until the returned function is called. Use `useBusy` and + * `useModal` rather than calling this directly. + */ + beginGuard: (kind: GuardKind) => () => void; } +/** + * `busy`: an operation is running, so a pointer click must not leave the screen. + * `modal`: a confirmation has focus, so a click on the screen behind it must + * not press keys the confirmation would take. + */ +export type GuardKind = 'busy' | 'modal'; + /** * Key event from Ink's useInput hook. */ diff --git a/tests/cli/app.test.tsx b/tests/cli/app.test.tsx index 61a2757f..47afc6c2 100644 --- a/tests/cli/app.test.tsx +++ b/tests/cli/app.test.tsx @@ -144,6 +144,67 @@ describe('cli: app', () => { }); + it('should go back to an earlier screen when its breadcrumb is clicked', async () => { + + const { stdin, frames, unmount } = render(); + const plain = (text: string) => text.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, 'g'), ''); + const crumbLine = () => plain(frames.filter((f) => f.includes('›')).at(-1) ?? '').split('\n').find((line) => line.includes('›')) ?? ''; + + await waitFor(() => crumbLine().includes('Home')); + // Clicks are answered once the mouse setting has loaded and tracking is on. + await waitFor(() => frames.includes(MOUSE_ENABLE)); + + const lines = plain(frames.filter((f) => f.includes('›')).at(-1) ?? '').split('\n'); + const row = lines.findIndex((line) => line.includes('›')) + 1; + const column = (lines[row - 1] ?? '').indexOf('Home') + 2; + + const firstLine = () => plain(frames.filter((f) => f.includes('Home')).at(-1) ?? '').split('\n')[0] ?? ''; + + expect(firstLine()).toContain('Configurations'); + + stdin.write(`${String.fromCharCode(27)}[<0;${column};${row}M`); + await waitFor(() => !firstLine().includes('Configurations')); + + // Only Home is left in the trail. + expect(firstLine()).toContain('Home'); + expect(firstLine()).not.toContain('›'); + + unmount(); + + }); + + it('should keep help open while the pointer moves, and still close it on a key', async () => { + + const { stdin, frames, unmount } = render(); + const motion = `${String.fromCharCode(27)}[<35;5;5M`; + const helpOpen = (frame: string) => frame.includes('go back / cancel'); + const screens = () => frames.filter((f) => f.includes('noorm') || helpOpen(f)); + + // Motion tracking is on once the mouse setting has loaded. + await waitFor(() => frames.includes(MOUSE_ENABLE)); + + stdin.write('?'); + await waitFor(() => frames.some(helpOpen)); + + const before = frames.length; + + stdin.write(motion); + stdin.write(motion); + stdin.write(motion); + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Any screen drawn after the motion must still be the help screen. + expect(frames.slice(before).filter((f) => f.includes('noorm') || helpOpen(f)).every(helpOpen)).toBe(true); + + stdin.write('x'); + await waitFor(() => !helpOpen(screens().at(-1) ?? '')); + + expect(helpOpen(screens().at(-1) ?? '')).toBe(false); + + unmount(); + + }); + /** * The help screen as it renders for a project with the given settings * file, or with none written at all. diff --git a/tests/cli/components/dialogs.test.tsx b/tests/cli/components/dialogs.test.tsx index ecd82264..19b44ab4 100644 --- a/tests/cli/components/dialogs.test.tsx +++ b/tests/cli/components/dialogs.test.tsx @@ -234,6 +234,23 @@ describe('cli: components/dialogs', () => { }); + it('should keep the checkbox and cursor visible when a path is wider than the panel', () => { + + const long = `sql/${'region_'.repeat(30)}reconciliation.sql`; + + const { lastFrame } = render( + + {}} onCancel={() => {}} /> + , + ); + + const row = (lastFrame() ?? '').split('\n').find((line) => line.includes('reconciliation')) ?? ''; + + expect(row).toContain('☑'); + expect(row).toContain('reconciliation.sql'); + + }); + it('should show file count', () => { const files = ['file1.sql', 'file2.sql', 'file3.sql']; diff --git a/tests/cli/components/form-mouse-overflow.test.tsx b/tests/cli/components/form-mouse-overflow.test.tsx new file mode 100644 index 00000000..b0d44951 --- /dev/null +++ b/tests/cli/components/form-mouse-overflow.test.tsx @@ -0,0 +1,481 @@ +/** Form: clicks, one-row long values, and `x` clearing a password (a wrapped value pushed the buttons off screen). */ +import { describe, it, expect } from 'bun:test'; +import { render } from 'ink-testing-library'; +import React from 'react'; + +import { FocusProvider } from '../../../src/tui/focus.js'; +import { MouseProvider, MOUSE_ENABLE, MOUSE_DISABLE } from '../../../src/tui/mouse.js'; +import { Form } from '../../../src/tui/components/forms/index.js'; +import { Confirm } from '../../../src/tui/components/dialogs/index.js'; +import type { FormField, FormValues } from '../../../src/tui/components/forms/index.js'; + +const ESC = String.fromCharCode(27); +const WHEEL_DOWN = 65; + +/** SGR left-press, 1-based row and column the way a terminal sends them. */ +const press = (row: number, column = 1, button = 0) => `${ESC}[<${button};${column};${row}M`; + +const KEYS = { DOWN: '\x1B[B', ENTER: '\r' }; + +const LONG = 'postgresql://some_user:averyveryverylongpasswordthatgoesonandon@db-cluster-prod-01.us-east-1.rds.amazonaws.com:5432/application_database?sslmode=verify-full'; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 50)); + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + + const deadline = Date.now() + timeoutMs; + + while (!predicate() && Date.now() < deadline) { + + await new Promise((resolve) => setTimeout(resolve, 10)); + + } + +} + +/** The frames the UI drew, without the mouse enable/disable writes. */ +const uiFrames = (frames: string[]) => frames.filter((f) => f !== MOUSE_ENABLE && f !== MOUSE_DISABLE); +const lastUi = (frames: string[]) => uiFrames(frames).at(-1) ?? ''; + +/** Color codes take string positions but no terminal columns, and CI forces color on. */ +const plain = (text: string) => text.replace(new RegExp(`${ESC}\\[[0-9;]*m`, 'g'), ''); + +/** 1-based terminal row of the line holding `needle`. */ +function rowOf(frame: string, needle: string): number { + + const index = plain(frame).split('\n').findIndex((line) => line.includes(needle)); + + if (index < 0) throw new Error(`"${needle}" is not in the frame:\n${frame}`); + + return index + 1; + +} + +/** 1-based terminal column of `needle` on its row, pointing at its second character. */ +function columnOf(frame: string, needle: string): number { + + const line = plain(frame).split('\n').find((candidate) => candidate.includes(needle)) ?? ''; + + return line.indexOf(needle) + 2; + +} + +/** Which label shares a line with the `›` marker. */ +function activeLine(frame: string): string { + + return frame.split('\n').find((line) => line.includes('›')) ?? ''; + +} + +function Harness({ children }: { children: React.ReactNode }) { + + return ( + + {children} + + ); + +} + +const FIELDS: FormField[] = [ + { key: 'name', label: 'Name', type: 'text', defaultValue: 'dev' }, + { key: 'host', label: 'Host', type: 'text', defaultValue: 'localhost' }, + { key: 'ssl', label: 'Use SSL', type: 'checkbox' }, + { key: 'password', label: 'Password', type: 'password', defaultValue: 'hunter2' }, +]; + +async function mountForm(overrides: { fields?: FormField[]; onSubmit?: (v: FormValues) => void; onCancel?: () => void } = {}) { + + const rendered = render( + +
undefined)} + onCancel={overrides.onCancel ?? (() => undefined)} + /> + , + ); + + // `›` draws only once the form has focus, and clicks are dropped before that. + await waitFor(() => lastUi(rendered.frames).includes('›')); + await tick(); + + return rendered; + +} + +describe('cli: Form mouse', () => { + + it('should move the cursor to the field that was clicked', async () => { + + const { frames, stdin, unmount } = await mountForm(); + + stdin.write(press(rowOf(lastUi(frames), 'Host'))); + await waitFor(() => activeLine(lastUi(frames)).includes('Host')); + + expect(activeLine(lastUi(frames))).toContain('Host'); + expect(lastUi(frames)).toContain('↵ edit'); + + unmount(); + + }); + + it('should open a text field for editing on a double click', async () => { + + const { frames, stdin, unmount } = await mountForm(); + const row = rowOf(lastUi(frames), 'Host'); + + stdin.write(press(row)); + await tick(); + stdin.write(press(row)); + await waitFor(() => lastUi(frames).includes('commit')); + + expect(lastUi(frames)).toContain('↵ commit'); + + unmount(); + + }); + + it('should toggle a checkbox on one click', async () => { + + const { frames, stdin, unmount } = await mountForm(); + + expect(lastUi(frames)).toContain('☐ No'); + + stdin.write(press(rowOf(lastUi(frames), 'Use SSL'))); + await waitFor(() => lastUi(frames).includes('☑ Yes')); + + expect(lastUi(frames)).toContain('☑ Yes'); + + unmount(); + + }); + + it('should submit when Submit is clicked', async () => { + + const submitted: FormValues[] = []; + let cancelled = 0; + + const { frames, stdin, unmount } = await mountForm({ + onSubmit: (values) => submitted.push(values), + onCancel: () => cancelled++, + }); + + const row = rowOf(lastUi(frames), '[ Submit ]'); + + stdin.write(press(row, columnOf(lastUi(frames), '[ Submit ]'))); + await waitFor(() => submitted.length > 0); + + expect(submitted).toHaveLength(1); + expect(submitted[0]).toMatchObject({ name: 'dev', host: 'localhost' }); + expect(cancelled).toBe(0); + + unmount(); + + }); + + it('should cancel when Cancel is clicked', async () => { + + const submitted: FormValues[] = []; + let cancelled = 0; + + const { frames, stdin, unmount } = await mountForm({ + onSubmit: (values) => submitted.push(values), + onCancel: () => cancelled++, + }); + + stdin.write(press(rowOf(lastUi(frames), '[ Cancel ]'), columnOf(lastUi(frames), '[ Cancel ]'))); + await waitFor(() => cancelled > 0); + + expect(cancelled).toBe(1); + expect(submitted).toHaveLength(0); + + unmount(); + + }); + + it('should ignore a click on the button row that lands on neither button', async () => { + + const submitted: FormValues[] = []; + let cancelled = 0; + + const { frames, stdin, unmount } = await mountForm({ + onSubmit: (values) => submitted.push(values), + onCancel: () => cancelled++, + }); + + stdin.write(press(rowOf(lastUi(frames), '[ Submit ]'), 70)); + await tick(); + + expect(submitted).toHaveLength(0); + expect(cancelled).toBe(0); + + unmount(); + + }); + + it('should move between fields on the wheel', async () => { + + const { frames, stdin, unmount } = await mountForm(); + + stdin.write(press(1, 1, WHEEL_DOWN)); + await waitFor(() => activeLine(lastUi(frames)).includes('Host')); + + expect(activeLine(lastUi(frames))).toContain('Host'); + + unmount(); + + }); + + it('should commit the field being edited when another field is clicked', async () => { + + const { frames, stdin, unmount } = await mountForm(); + + stdin.write(KEYS.ENTER); + await waitFor(() => lastUi(frames).includes('commit')); + stdin.write('x'); + await waitFor(() => lastUi(frames).includes('devx')); + + stdin.write(press(rowOf(lastUi(frames), 'Host'))); + await waitFor(() => activeLine(lastUi(frames)).includes('Host') && !lastUi(frames).includes('commit')); + + expect(activeLine(lastUi(frames))).toContain('Host'); + expect(lastUi(frames)).toContain('↵ edit'); + expect(lastUi(frames)).toContain('devx'); + + unmount(); + + }); + +}); + +describe('cli: Form open select mouse', () => { + + const selectFields: FormField[] = [ + { + key: 'role', + label: 'Role', + type: 'select', + defaultValue: 'viewer', + options: [ + { label: 'viewer', value: 'viewer' }, + { label: 'operator', value: 'operator' }, + { label: 'admin', value: 'admin' }, + ], + }, + { key: 'isTest', label: 'Test Database', type: 'checkbox' }, + ]; + + it('should take an option on a double click and leave the field below alone', async () => { + + const submitted: FormValues[] = []; + + const { frames, stdin, unmount } = await mountForm({ fields: selectFields, onSubmit: (values) => submitted.push(values) }); + + stdin.write(KEYS.ENTER); + await waitFor(() => lastUi(frames).includes('admin')); + + // The first press must not collapse the list, or the second lands on whatever moved up. + const row = rowOf(lastUi(frames), 'operator'); + + stdin.write(press(row)); + await tick(); + stdin.write(press(row)); + await waitFor(() => !lastUi(frames).includes('admin')); + + expect(lastUi(frames)).toContain('operator'); + expect(lastUi(frames)).toContain('☐ No'); + + unmount(); + + }); + + it('should only highlight an option on a single click', async () => { + + const { frames, stdin, unmount } = await mountForm({ fields: selectFields }); + + stdin.write(KEYS.ENTER); + await waitFor(() => lastUi(frames).includes('admin')); + stdin.write(press(rowOf(lastUi(frames), 'admin'))); + await waitFor(() => lastUi(frames).includes('❯ admin')); + + expect(lastUi(frames)).toContain('❯ admin'); + expect(lastUi(frames)).toContain('operator'); + + unmount(); + + }); + +}); + +describe('cli: Confirm mouse', () => { + + async function mountConfirm() { + + const answers: string[] = []; + + const rendered = render( + + answers.push('yes')} onCancel={() => answers.push('no')} /> + , + ); + + await waitFor(() => lastUi(rendered.frames).includes('❯')); + await tick(); + + return { ...rendered, answers }; + + } + + it('should confirm when Yes is clicked', async () => { + + const { frames, stdin, answers, unmount } = await mountConfirm(); + + stdin.write(press(rowOf(lastUi(frames), 'Yes (y)'), columnOf(lastUi(frames), 'Yes (y)'))); + await waitFor(() => answers.length > 0); + + expect(answers).toEqual(['yes']); + + unmount(); + + }); + + it('should cancel when No is clicked, even though Yes shares the row', async () => { + + const { frames, stdin, answers, unmount } = await mountConfirm(); + + stdin.write(press(rowOf(lastUi(frames), 'No (n)'), columnOf(lastUi(frames), 'No (n)'))); + await waitFor(() => answers.length > 0); + + expect(answers).toEqual(['no']); + + unmount(); + + }); + +}); + +describe('cli: Form long values', () => { + + const longFields: FormField[] = [ + { key: 'name', label: 'Name', type: 'text', defaultValue: 'dev' }, + { key: 'host', label: 'Host', type: 'text', defaultValue: LONG }, + { key: 'password', label: 'Password', type: 'password', defaultValue: LONG }, + { key: 'db', label: 'Database', type: 'text', defaultValue: 'app' }, + ]; + + it('should draw each field on one row so the action row stays on screen', async () => { + + const { frames, unmount } = await mountForm({ fields: longFields }); + const lines = lastUi(frames).split('\n'); + const hostRow = lines.findIndex((line) => line.includes('Host')); + const passwordRow = lines.findIndex((line) => line.includes('Password')); + const databaseRow = lines.findIndex((line) => line.includes('Database')); + + expect(passwordRow - hostRow).toBe(1); + expect(databaseRow - passwordRow).toBe(1); + expect(lastUi(frames)).toContain('[ Submit ]'); + + unmount(); + + }); + + it('should mark a cut value with an ellipsis rather than wrapping it', async () => { + + const { frames, unmount } = await mountForm({ fields: longFields }); + const hostLine = lastUi(frames).split('\n').find((line) => line.includes('Host')) ?? ''; + + expect(hostLine).toContain('…'); + expect(lastUi(frames)).not.toContain('verify-full'); + + unmount(); + + }); + + it('should keep a long value on one row while it is edited, showing the end it is typed at', async () => { + + const { frames, stdin, unmount } = await mountForm({ fields: longFields }); + + stdin.write(KEYS.DOWN); + await waitFor(() => activeLine(lastUi(frames)).includes('Host')); + stdin.write(KEYS.ENTER); + await waitFor(() => lastUi(frames).includes('commit')); + await waitFor(() => lastUi(frames).includes('verify-full')); + + const lines = lastUi(frames).split('\n'); + const hostRow = lines.findIndex((line) => line.includes('Host')); + + expect(lines[hostRow]).toContain('verify-full'); + expect(lines[hostRow + 1]).toContain('Password'); + expect(lastUi(frames)).toContain('[ Submit ]'); + + unmount(); + + }); + +}); + +describe('cli: Form clear password', () => { + + it('should empty a highlighted password field on x', async () => { + + const submitted: FormValues[] = []; + + const { frames, stdin, unmount } = await mountForm({ onSubmit: (values) => submitted.push(values) }); + + stdin.write(press(rowOf(lastUi(frames), 'Password'))); + await waitFor(() => activeLine(lastUi(frames)).includes('Password')); + + expect(lastUi(frames)).toContain('•••••••'); + expect(lastUi(frames)).toContain('x clear'); + + stdin.write('x'); + await waitFor(() => !lastUi(frames).includes('•')); + + expect(lastUi(frames)).not.toContain('•'); + expect(lastUi(frames)).not.toContain('x clear'); + + stdin.write(press(rowOf(lastUi(frames), '[ Submit ]'), columnOf(lastUi(frames), '[ Submit ]'))); + await waitFor(() => submitted.length > 0); + + expect(submitted[0]?.['password']).toBe(''); + + unmount(); + + }); + + it('should leave a text field alone on x', async () => { + + const { frames, stdin, unmount } = await mountForm(); + + expect(activeLine(lastUi(frames))).toContain('Name'); + + stdin.write('x'); + await tick(); + + expect(lastUi(frames)).toContain('dev'); + expect(lastUi(frames)).not.toContain('x clear'); + + unmount(); + + }); + + it('should type x into a password field that is open for editing', async () => { + + const { frames, stdin, unmount } = await mountForm(); + + stdin.write(press(rowOf(lastUi(frames), 'Password'))); + await waitFor(() => activeLine(lastUi(frames)).includes('Password')); + stdin.write(KEYS.ENTER); + await waitFor(() => lastUi(frames).includes('commit')); + + stdin.write('x'); + await waitFor(() => lastUi(frames).includes('hunter2x')); + + expect(lastUi(frames)).toContain('hunter2x'); + + unmount(); + + }); + +}); diff --git a/tests/cli/components/hotkey.test.tsx b/tests/cli/components/hotkey.test.tsx new file mode 100644 index 00000000..7c9513ac --- /dev/null +++ b/tests/cli/components/hotkey.test.tsx @@ -0,0 +1,516 @@ +/** Hotkey: a click on a `[key] Label` hint presses that key, through the same path the keyboard takes. */ +import { describe, it, expect } from 'bun:test'; +import { render } from 'ink-testing-library'; +import { Box, Text, useInput } from 'ink'; +import chalk from 'chalk'; +import React from 'react'; + +import { FocusProvider, useBusy, useIsGuarded } from '../../../src/tui/focus.js'; +import { MouseProvider, MOUSE_ENABLE, MOUSE_DISABLE, isMouseReport, useRowMouse } from '../../../src/tui/mouse.js'; +import { Hotkey, KeyTarget, resolveHotkey } from '../../../src/tui/components/layout/index.js'; +import { TextInput } from '../../../src/tui/components/forms/index.js'; +import { Confirm, ProtectedConfirm } from '../../../src/tui/components/dialogs/index.js'; + +const ESC = String.fromCharCode(27); + +const press = (row: number, column = 1, button = 0) => `${ESC}[<${button};${column};${row}M`; +const move = (row: number, column = 1) => press(row, column, 35); + +const tick = () => new Promise((resolve) => setTimeout(resolve, 50)); + +async function waitFor(predicate: () => boolean, timeoutMs = 2000): Promise { + + const deadline = Date.now() + timeoutMs; + + while (!predicate() && Date.now() < deadline) { + + await new Promise((resolve) => setTimeout(resolve, 10)); + + } + +} + +const uiFrames = (frames: string[]) => frames.filter((f) => f !== MOUSE_ENABLE && f !== MOUSE_DISABLE); +const lastUi = (frames: string[]) => uiFrames(frames).at(-1) ?? ''; +const plain = (text: string) => text.replace(new RegExp(`${ESC}\\[[0-9;]*m`, 'g'), ''); + +/** 1-based row and column of `needle`, pointing at its second character. */ +function locate(frame: string, needle: string): { row: number; column: number } { + + const lines = plain(frame).split('\n'); + const index = lines.findIndex((line) => line.includes(needle)); + + if (index < 0) throw new Error(`"${needle}" is not in the frame:\n${frame}`); + + return { row: index + 1, column: (lines[index] ?? '').indexOf(needle) + 2 }; + +} + +function clickOn(stdin: { write: (data: string) => void }, frame: string, needle: string): void { + + const { row, column } = locate(frame, needle); + + stdin.write(press(row, column)); + +} + +interface Pressed { + input: string; + escape: boolean; + enter: boolean; + tab: boolean; +} + +/** Stands in for whatever screen holds focus: records what reaches a `useInput` handler. */ +function Listener({ log }: { log: Pressed[] }) { + + useInput((input, key) => { + + if (isMouseReport(input)) return; + + log.push({ input, escape: key.escape, enter: key.return, tab: key.tab }); + + }); + + return null; + +} + +function Harness({ children }: { children: React.ReactNode }) { + + return ( + + {children} + + ); + +} + +async function mount(children: React.ReactNode) { + + const log: Pressed[] = []; + const rendered = render( + + + {children} + , + ); + + await waitFor(() => lastUi(rendered.frames).includes('[')); + await tick(); + + return { ...rendered, log }; + +} + +describe('cli: resolveHotkey', () => { + + it('should map named keys and single characters to the input the keyboard sends', () => { + + expect(resolveHotkey('Esc')).toBe(ESC); + expect(resolveHotkey('esc')).toBe(ESC); + expect(resolveHotkey('Enter')).toBe('\r'); + expect(resolveHotkey('Tab')).toBe('\t'); + expect(resolveHotkey('Space')).toBe(' '); + expect(resolveHotkey('r')).toBe('r'); + expect(resolveHotkey('+')).toBe('+'); + expect(resolveHotkey('/')).toBe('/'); + expect(resolveHotkey('Shift+L')).toBe('L'); + + }); + + it('should take the first alternative of a compound label', () => { + + expect(resolveHotkey('Enter/Esc')).toBe('\r'); + expect(resolveHotkey('y/Enter')).toBe('y'); + + }); + + it('should name no key for navigation hints, which stay plain text', () => { + + // `Ctrl+U/D` must not become a bare `D`, which toggles dry-run globally. + for (const label of ['↑↓', '↑/↓', '^U/^D', 'any', 'Esc Esc', 'Up/Down', 'Ctrl+U/D']) { + + expect(resolveHotkey(label)).toBeNull(); + + } + + }); + +}); + +describe('cli: Hotkey click', () => { + + it('should press the letter, so whatever holds focus answers it', async () => { + + const { frames, stdin, log, unmount } = await mount(Add); + const { row, column } = locate(lastUi(frames), '[a] Add'); + + stdin.write(press(row, column)); + await waitFor(() => log.some((entry) => entry.input === 'a')); + + expect(log.some((entry) => entry.input === 'a')).toBe(true); + + unmount(); + + }); + + it('should press Escape and Enter as the keys they are', async () => { + + const { frames, stdin, log, unmount } = await mount( + + Back + Use + , + ); + + clickOn(stdin, lastUi(frames), '[Esc] Back'); + await waitFor(() => log.some((entry) => entry.escape)); + + clickOn(stdin, lastUi(frames), '[Enter] Use'); + await waitFor(() => log.some((entry) => entry.enter)); + + expect(log.some((entry) => entry.escape)).toBe(true); + expect(log.some((entry) => entry.enter)).toBe(true); + + unmount(); + + }); + + it('should press once on a double click, so two quick clicks do not leave two screens', async () => { + + const { frames, stdin, log, unmount } = await mount(Back); + const { row, column } = locate(lastUi(frames), '[Esc] Back'); + + stdin.write(press(row, column)); + await tick(); + stdin.write(press(row, column)); + await tick(); + + expect(log.filter((entry) => entry.escape)).toHaveLength(1); + + unmount(); + + }); + + it('should do nothing for a hint that names no single key', async () => { + + const { frames, stdin, log, unmount } = await mount(Scroll); + const { row, column } = locate(lastUi(frames), '[↑↓] Scroll'); + + stdin.write(press(row, column)); + await tick(); + + expect(log).toHaveLength(0); + + unmount(); + + }); + + it('should not type a letter into a field that is taking input, but still press Escape', async () => { + + const { frames, stdin, log, unmount } = await mount( + <> + + + Retry + Back + + , + ); + + const retry = locate(lastUi(frames), '[r] Retry'); + + stdin.write(press(retry.row, retry.column)); + await tick(); + + expect(log.some((entry) => entry.input === 'r')).toBe(false); + + const back = locate(lastUi(frames), '[Esc] Back'); + + stdin.write(press(back.row, back.column)); + await waitFor(() => log.some((entry) => entry.escape)); + + expect(log.some((entry) => entry.escape)).toBe(true); + + unmount(); + + }); + + it('should shade the hint under the pointer', async () => { + + const level = chalk.level; + + chalk.level = 1; + + const { frames, stdin, unmount } = await mount( + <> + Add + Delete + , + ); + + const shaded = (name: string) => (lastUi(frames).split('\n').find((line) => line.includes(name)) ?? '').includes(`${ESC}[100m`); + const add = locate(lastUi(frames), '[a] Add'); + + expect(shaded('[a] Add')).toBe(false); + + stdin.write(move(add.row, add.column)); + await waitFor(() => shaded('[a] Add')); + + expect(shaded('[a] Add')).toBe(true); + expect(shaded('[d] Delete')).toBe(false); + + unmount(); + chalk.level = level; + + }); + + it('should render the hint text exactly as the plain footer did', async () => { + + const { frames, unmount } = await mount(Back); + + expect(plain(lastUi(frames))).toContain('[Enter/Esc] Back'); + + unmount(); + + }); + +}); + +describe('cli: Hotkey with a colored key', () => { + + it('should keep the label undimmed and the text contiguous', async () => { + + const { frames, unmount } = await mount(Acquire - Obtain lock); + + expect(plain(lastUi(frames))).toContain('[a] Acquire - Obtain lock'); + + unmount(); + + }); + +}); + +describe('cli: Hotkey guards', () => { + + it('should drop the second press of a double click when a new screen drew a hint under the pointer', async () => { + + const escapes: string[] = []; + + function Screens() { + + const [screen, setScreen] = React.useState(0); + + useInput((_input, key) => { + + if (!key.escape) return; + + escapes.push(`screen ${screen}`); + setScreen((current) => current + 1); + + }); + + // A fresh instance at the same cell each time, as a screen that went back draws. + return Back; + + } + + const { frames, stdin, unmount } = await mount(); + const { row, column } = locate(lastUi(frames), '[Esc] Back'); + + stdin.write(press(row, column)); + await tick(); + stdin.write(press(row, column)); + await tick(); + + // Two Escapes would have left two screens. + expect(escapes).toEqual(['screen 0']); + + unmount(); + + }); + + it('should ignore the second press of a double click on a target that is not the one that took the first', async () => { + + const log: string[] = []; + + function Target({ id, onFirst }: { id: string; onFirst: () => void }) { + + const { rowRef } = useRowMouse({ + isActive: true, + onClick: () => { + + log.push(id); + onFirst(); + + }, + onActivate: () => log.push(`${id}:double`), + onWheel: () => undefined, + }); + + return target; + + } + + function Swap() { + + const [n, setN] = React.useState(0); + + return setN(1)} />; + + } + + const { frames, stdin, unmount } = await mount(); + const { row, column } = locate(lastUi(frames), 'target'); + + stdin.write(press(row, column)); + await tick(); + stdin.write(press(row, column)); + await tick(); + + // The new target at the same cell is not a double click's second half. + expect(log).toEqual(['#0']); + + unmount(); + + }); + + it('should not press behind a confirmation that holds the screen', async () => { + + const answers: string[] = []; + + const { frames, stdin, unmount } = await mount( + <> + answers.push('yes')} onCancel={() => answers.push('no')} /> + Edit + , + ); + + // Enter on the confirmation would answer Yes, which is not what `[Enter] Edit` says. + clickOn(stdin, lastUi(frames), '[Enter] Edit'); + await tick(); + + expect(answers).toEqual([]); + + unmount(); + + }); + + it('should not shade a hint whose press would be dropped', async () => { + + chalk.level = 1; + + const { frames, stdin, unmount } = await mount( + <> + + History + , + ); + + const { row, column } = locate(lastUi(frames), '[h] History'); + + stdin.write(move(row, column)); + await tick(); + + expect((lastUi(frames).split('\n').find((line) => line.includes('[h] History')) ?? '')).not.toContain(`${ESC}[100m`); + + unmount(); + + }); + + it('should neither press nor shade a disabled target, like a row the keyboard also skips', async () => { + + chalk.level = 1; + + const { frames, stdin, log, unmount } = await mount( + [3] Procedures 0, + ); + + const { row, column } = locate(lastUi(frames), '[3] Procedures'); + + stdin.write(move(row, column)); + await tick(); + stdin.write(press(row, column)); + await tick(); + + expect(log.some((entry) => entry.input === '3')).toBe(false); + expect(lastUi(frames).split('\n').find((line) => line.includes('Procedures')) ?? '').not.toContain(`${ESC}[100m`); + + unmount(); + + }); + + it('should press an enabled numbered target, so a category row answers its number key', async () => { + + const { frames, stdin, log, unmount } = await mount( + [1] Tables 74, + ); + + clickOn(stdin, lastUi(frames), '[1] Tables'); + await waitFor(() => log.some((entry) => entry.input === '1')); + + expect(log.some((entry) => entry.input === '1')).toBe(true); + + unmount(); + + }); + + it('should answer a click on the protected confirmation buttons', async () => { + + const answers: string[] = []; + + const { frames, stdin, unmount } = await mount( + answers.push('confirm')} onCancel={() => answers.push('cancel')} />, + ); + + // The phrase is not typed, so Enter must not confirm. + clickOn(stdin, lastUi(frames), '[Enter] Confirm'); + await tick(); + + expect(answers).toEqual([]); + expect(plain(lastUi(frames))).toContain('Type "yes-prod" to confirm'); + + clickOn(stdin, lastUi(frames), '[Esc] Cancel'); + await waitFor(() => answers.length > 0); + + expect(answers).toEqual(['cancel']); + + unmount(); + + }); + + it('should hold the busy guard only while a component asks for it', async () => { + + function Probe({ busy }: { busy: boolean }) { + + useBusy(busy); + + const isGuarded = useIsGuarded(); + + return guarded:{String(isGuarded('busy'))}; + + } + + const first = render(); + + await waitFor(() => lastUi(first.frames).includes('guarded:')); + first.rerender(); + await tick(); + + // The guard is read at event time: a later render sees what the effect set. + expect(lastUi(first.frames)).toContain('guarded:true'); + + first.unmount(); + + const second = render(); + + await waitFor(() => lastUi(second.frames).includes('guarded:')); + + expect(lastUi(second.frames)).toContain('guarded:false'); + + second.unmount(); + + }); + +}); diff --git a/tests/cli/components/text-input.test.tsx b/tests/cli/components/text-input.test.tsx index ac412264..837e3ac4 100644 --- a/tests/cli/components/text-input.test.tsx +++ b/tests/cli/components/text-input.test.tsx @@ -2,8 +2,9 @@ * TextInput parity and mouse-report tests. * * `src/tui/components/forms/TextInput.tsx` is a copy of `@inkjs/ui`'s - * `TextInput` with two deliberate differences: a mouse report and the letter of - * a Ctrl chord are dropped rather than typed into the field. Everything else has to match, because the swap + * `TextInput` with three deliberate differences: a mouse report and the letter of + * a Ctrl chord are dropped rather than typed into the field, and a value wider + * than its box scrolls on one row. Everything else has to match, because the swap * reached 11 files and 21 call sites and none of them asked for a keyboard * change. * @@ -22,7 +23,9 @@ import React from 'react'; import { spawnSync } from 'node:child_process'; import { join } from 'node:path'; -import { TextInput } from '../../../src/tui/components/forms/TextInput.js'; +import { Box } from 'ink'; + +import { TextInput, windowValue } from '../../../src/tui/components/forms/TextInput.js'; import type { TextInputProps } from '../../../src/tui/components/forms/TextInput.js'; const ESC = String.fromCharCode(27); @@ -290,6 +293,53 @@ describe('cli: components/TextInput', () => { }); + it('should scroll a value wider than its box on one row, with the cursor in view', async () => { + + const value = `${'a'.repeat(40)}TAIL`; + const { lastFrame, unmount } = render( + + + , + ); + + await new Promise((resolve) => setTimeout(resolve, 80)); + + const frame = (lastFrame() ?? '').replace(new RegExp(`${ESC}\\[[0-9;]*m`, 'g'), ''); + + expect(frame.split('\n')).toHaveLength(1); + expect(frame.length).toBeLessThanOrEqual(20); + expect(frame.startsWith('…')).toBe(true); + expect(frame).toContain('TAIL'); + + unmount(); + + }); + + }); + + describe('windowValue', () => { + + it('should show everything when it fits or the width is unknown', () => { + + expect(windowValue(5, 5, 6)).toEqual({ start: 0, end: 6 }); + expect(windowValue(500, 500, 0)).toEqual({ start: 0, end: 501 }); + + }); + + it('should hold two columns for the markers and keep the cursor in view', () => { + + for (const cursor of [0, 50, 100]) { + + const { start, end } = windowValue(100, cursor, 20); + + expect(end - start).toBeLessThanOrEqual(18); + expect(start).toBeLessThanOrEqual(cursor); + expect(end).toBeGreaterThan(cursor); + + } + + }); + }); describe('rendering', () => { diff --git a/tests/cli/config/set-password.test.ts b/tests/cli/config/set-password.test.ts new file mode 100644 index 00000000..85268b42 --- /dev/null +++ b/tests/cli/config/set-password.test.ts @@ -0,0 +1,211 @@ +/** cli: config set-password. Subprocess against dist (the command calls process.exit); state is read back from disk. */ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import { mkdtempSync, rmSync, mkdirSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { generateKeyPair } from '../../../src/core/identity/crypto.js'; +import { StateManager } from '../../../src/core/state/index.js'; +import type { Config } from '../../../src/core/config/types.js'; +import type { ConfigAccess } from '../../../src/core/policy/index.js'; + +const CLI = join(process.cwd(), 'dist/cli/index.js'); +const CONFIG_NAME = 'prod'; +const OLD_PASSWORD = 'old-password'; +const NEW_PASSWORD = 'rotated-password-9f3'; + +describe('cli: noorm config set-password', () => { + + let tmpDir: string; + let fakeHome: string; + let privateKey: string; + let env: Record; + + beforeEach(() => { + + tmpDir = mkdtempSync(join(tmpdir(), 'noorm-set-password-')); + fakeHome = mkdtempSync(join(tmpdir(), 'noorm-set-password-home-')); + mkdirSync(join(tmpDir, '.noorm'), { recursive: true }); + writeFileSync(join(tmpDir, '.noorm', 'settings.yml'), 'paths:\n sql: ./sql\n'); + + privateKey = generateKeyPair().privateKey; + + // Inherited NOORM_* is dropped so an ambient NOORM_YES cannot change a case. + env = { + ...Object.fromEntries(Object.entries(process.env).filter( + (entry): entry is [string, string] => entry[1] !== undefined && !entry[0].startsWith('NOORM_'), + )), + HOME: fakeHome, + NOORM_IDENTITY_PRIVATE_KEY: privateKey, + NOORM_IDENTITY_NAME: 'CI Bot', + NOORM_IDENTITY_EMAIL: 'ci@example.com', + }; + + }); + + afterEach(() => { + + rmSync(tmpDir, { recursive: true, force: true }); + rmSync(fakeHome, { recursive: true, force: true }); + + }); + + async function seedConfig(name: string, access: ConfigAccess, dialect: 'postgres' | 'sqlite' = 'postgres'): Promise { + + const config: Config = { + name, + type: 'local', + isTest: false, + access, + connection: dialect === 'sqlite' + ? { dialect, database: join(tmpDir, `${name}.db`) } + : { dialect, host: 'db.example.test', port: 5432, database: 'app', user: 'app', password: OLD_PASSWORD }, + }; + + const manager = new StateManager(tmpDir, { privateKey }); + await manager.load(); + await manager.setConfig(name, config); + + } + + async function storedConfig(name: string): Promise { + + const manager = new StateManager(tmpDir, { privateKey }); + await manager.load(); + + return manager.getConfig(name); + + } + + function run(args: string[], input?: string) { + + return spawnSync('node', [CLI, 'config', 'set-password', ...args], { + cwd: tmpDir, encoding: 'utf-8', env, input, + }); + + } + + it('should store a password read from stdin and leave every other field alone', async () => { + + await seedConfig(CONFIG_NAME, { user: 'admin', agent: 'admin' }); + + const result = run([CONFIG_NAME, '--stdin', '--yes'], `${NEW_PASSWORD}\n`); + const config = await storedConfig(CONFIG_NAME); + + expect(result.status).toBe(0); + expect(config?.connection).toEqual({ + dialect: 'postgres', host: 'db.example.test', port: 5432, database: 'app', user: 'app', password: NEW_PASSWORD, + }); + expect(config?.access).toEqual({ user: 'admin', agent: 'admin' }); + + }); + + it('should store a password passed as an argument', async () => { + + await seedConfig(CONFIG_NAME, { user: 'admin', agent: 'admin' }); + + const result = run([CONFIG_NAME, NEW_PASSWORD, '--yes']); + + expect(result.status).toBe(0); + expect((await storedConfig(CONFIG_NAME))?.connection.password).toBe(NEW_PASSWORD); + + }); + + it('should never print the password, in text or JSON output', async () => { + + await seedConfig(CONFIG_NAME, { user: 'admin', agent: 'admin' }); + + const text = run([CONFIG_NAME, '--stdin', '--yes'], NEW_PASSWORD); + const json = run([CONFIG_NAME, '--stdin', '--yes', '--json'], NEW_PASSWORD); + const argv = run([CONFIG_NAME, NEW_PASSWORD, '--yes', '--json']); + + expect(`${text.stdout}${text.stderr}`).not.toContain(NEW_PASSWORD); + expect(`${json.stdout}${json.stderr}`).not.toContain(NEW_PASSWORD); + expect(`${argv.stdout}${argv.stderr}`).not.toContain(NEW_PASSWORD); + expect(JSON.parse(json.stdout)).toMatchObject({ success: true, name: CONFIG_NAME }); + + }); + + it('should refuse without --yes when the config requires confirmation, leaving the password alone', async () => { + + await seedConfig(CONFIG_NAME, { user: 'admin', agent: 'admin' }); + + const result = run([CONFIG_NAME, NEW_PASSWORD]); + + expect(result.status).toBe(1); + expect(`${result.stdout}${result.stderr}`).toContain('Pass --yes to confirm'); + expect((await storedConfig(CONFIG_NAME))?.connection.password).toBe(OLD_PASSWORD); + + }); + + it('should refuse a viewer config, leaving the password alone', async () => { + + await seedConfig(CONFIG_NAME, { user: 'viewer', agent: 'viewer' }); + + const result = run([CONFIG_NAME, NEW_PASSWORD, '--yes']); + + expect(result.status).toBe(1); + expect((await storedConfig(CONFIG_NAME))?.connection.password).toBe(OLD_PASSWORD); + + }); + + it('should name an unknown config with the usage exit code instead of reporting success', async () => { + + const result = run(['missing', NEW_PASSWORD, '--yes']); + + expect(result.status).toBe(2); + expect(`${result.stdout}${result.stderr}`).toContain('"missing" not found'); + + }); + + it('should refuse an empty password so an empty rotation variable cannot wipe the stored one', async () => { + + await seedConfig(CONFIG_NAME, { user: 'admin', agent: 'admin' }); + + const result = run([CONFIG_NAME, '', '--yes']); + const piped = run([CONFIG_NAME, '--stdin', '--yes'], '\n'); + + expect(result.status).toBe(1); + expect(piped.status).toBe(1); + expect((await storedConfig(CONFIG_NAME))?.connection.password).toBe(OLD_PASSWORD); + + }); + + it('should refuse on the agent channel even with --yes, leaving the password alone', async () => { + + await seedConfig(CONFIG_NAME, { user: 'admin', agent: 'viewer' }); + + const result = spawnSync('node', [CLI, 'config', 'set-password', CONFIG_NAME, NEW_PASSWORD, '--yes'], { + cwd: tmpDir, encoding: 'utf-8', env: { ...env, NOORM_CHANNEL: 'agent' }, + }); + + expect(result.status).toBe(1); + expect((await storedConfig(CONFIG_NAME))?.connection.password).toBe(OLD_PASSWORD); + + }); + + it('should refuse a SQLite config, which has no password', async () => { + + await seedConfig('local', { user: 'admin', agent: 'admin' }, 'sqlite'); + + const result = run(['local', NEW_PASSWORD, '--yes']); + + expect(result.status).toBe(1); + expect(`${result.stdout}${result.stderr}`).toContain('no password'); + expect((await storedConfig('local'))?.connection.password).toBeUndefined(); + + }); + + it('should refuse when neither a value nor --stdin is given', async () => { + + await seedConfig(CONFIG_NAME, { user: 'admin', agent: 'admin' }); + + const result = run([CONFIG_NAME, '--yes']); + + expect(result.status).toBe(1); + expect((await storedConfig(CONFIG_NAME))?.connection.password).toBe(OLD_PASSWORD); + + }); + +}); diff --git a/tests/cli/mouse-rows.test.tsx b/tests/cli/mouse-rows.test.tsx new file mode 100644 index 00000000..8a14f245 --- /dev/null +++ b/tests/cli/mouse-rows.test.tsx @@ -0,0 +1,640 @@ +/** + * Mouse on the lists that draw their own rows. + * + * These screens do not go through `SelectList`, so before they called + * `useRowMouse` a click and the pointer did nothing on them while the same + * gestures worked one screen over. What is pinned is that each one behaves the + * way `SelectList` does: a click moves the cursor, a second click does what + * Enter does, a wheel notch steps the cursor, the row under the pointer is + * shaded unless the cursor is on it, and a mode that owns its keys owns its + * clicks too. + * + * The suite runs under both `FORCE_COLOR=0` and `FORCE_COLOR=true`, so every + * assertion on text goes through `plain`, and shading is asserted with chalk + * pinned to a level that emits the hover background. + */ +import { describe, it, expect, vi, mock, beforeEach, afterEach } from 'bun:test'; +import { render } from 'ink-testing-library'; +import { stripVTControlCharacters } from 'node:util'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import chalk from 'chalk'; +import React from 'react'; + +import type { KnownUser } from '../../src/core/identity/index.js'; +import type { SqlExecutionResult } from '../../src/core/sql-terminal/types.js'; + +import { FocusProvider } from '../../src/tui/focus.js'; +import { MouseProvider } from '../../src/tui/mouse.js'; +import { RouterProvider } from '../../src/tui/router.js'; +import { AppContextProvider } from '../../src/tui/app-context.js'; +import { ToastProvider } from '../../src/tui/components/index.js'; +import { LogViewerOverlay } from '../../src/tui/components/overlays/LogViewerOverlay.js'; +import { SqlClearScreen } from '../../src/tui/screens/db/SqlClearScreen.js'; +import { SqlHistoryScreen } from '../../src/tui/screens/db/SqlHistoryScreen.js'; +import { KnownUsersScreen } from '../../src/tui/screens/identity/KnownUsersScreen.js'; +import { SqlHistoryManager } from '../../src/core/sql-terminal/index.js'; + +const actualCore = await import('../../src/core/index.js'); +const actualIdentity = await import('../../src/core/identity/index.js'); + +const ESC = String.fromCharCode(27); + +/** SGR press report. Column and row are 1-based, the way a terminal sends them. */ +const press = (row: number, column = 1, button = 0) => `${ESC}[<${button};${column};${row}M`; + +/** SGR motion report with no button held. */ +const move = (row: number, column = 1) => press(row, column, 35); + +const WHEEL_UP = 64; +const WHEEL_DOWN = 65; +const HOVER_BG = `${ESC}[100m`; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 50)); + +async function waitFor(predicate: () => boolean, timeoutMs = 4000): Promise { + + const deadline = Date.now() + timeoutMs; + + while (!predicate() && Date.now() < deadline) { + + await new Promise((resolve) => setTimeout(resolve, 10)); + + } + + // A silent timeout lets a test pass with the feature broken. + if (!predicate()) throw new Error(`waitFor timed out after ${timeoutMs}ms`); + +} + +/** The frame without colour, so a column or a marker reads the same under any FORCE_COLOR. */ +const plain = (frame: string | undefined) => stripVTControlCharacters(frame ?? ''); + +/** Which terminal row a piece of rendered text landed on, 1-based. */ +function terminalRowOf(frame: string, needle: string): number { + + const index = frame.split('\n').findIndex((line) => line.includes(needle)); + + if (index < 0) throw new Error(`"${needle}" is not in the frame:\n${frame}`); + + return index + 1; + +} + +const lineOf = (frame: string, needle: string) => frame.split('\n').find((line) => line.includes(needle)) ?? ''; + +function makeConfig() { + + return { + name: 'test', + type: 'local' as const, + isTest: true, + access: { user: 'admin' as const, agent: 'admin' as const }, + connection: { dialect: 'sqlite' as const, database: ':memory:' }, + }; + +} + +let knownUsers: Record = {}; +let logPath = ''; + +const createMockStateManager = () => ({ + load: vi.fn().mockResolvedValue(undefined), + getActiveConfig: vi.fn().mockReturnValue(makeConfig()), + getActiveConfigName: vi.fn().mockReturnValue('test'), + listConfigs: vi.fn().mockReturnValue([makeConfig()]), + getConfig: vi.fn().mockReturnValue(makeConfig()), + getKnownUsers: vi.fn(() => knownUsers), + setConfig: vi.fn().mockResolvedValue(undefined), + setActiveConfig: vi.fn().mockResolvedValue(undefined), + hasPrivateKey: vi.fn().mockReturnValue(true), + isLoaded: true, +}); + +const createMockSettingsManager = () => ({ + load: vi.fn().mockResolvedValue({ version: '0.1.0' }), + isLoaded: true, + settings: { version: '0.1.0', logging: { file: logPath } }, + getStages: vi.fn().mockReturnValue({}), + getStage: vi.fn().mockReturnValue(undefined), +}); + +let mockStateManager = createMockStateManager(); +let mockSettingsManager = createMockSettingsManager(); + +// `mock.module` never restores, so this file runs in its own process in CI +// (see the "Run mouse rows tests (isolated)" step), like cli-logger-settings. +mock.module('../../src/core/index.js', () => ({ + ...actualCore, + getStateManager: vi.fn(() => mockStateManager), + getSettingsManager: vi.fn(() => mockSettingsManager), +})); + +mock.module('../../src/core/identity/index.js', () => ({ + ...actualIdentity, + loadExistingIdentity: vi.fn().mockResolvedValue(null), +})); + +function Harness({ projectRoot, children }: { projectRoot: string; children: React.ReactNode }) { + + return ( + + + + + {children} + + + + + ); + +} + +describe('cli: mouse on self-drawn lists', () => { + + let tempDir: string; + let colorLevel: typeof chalk.level; + + beforeEach(async () => { + + vi.clearAllMocks(); + actualCore.observer.clear(); + + tempDir = await mkdtemp(join(tmpdir(), 'noorm-mouse-rows-')); + logPath = join(tempDir, 'noorm.log'); + knownUsers = {}; + + mockStateManager = createMockStateManager(); + mockSettingsManager = createMockSettingsManager(); + + colorLevel = chalk.level; + + }); + + afterEach(async () => { + + chalk.level = colorLevel; + actualCore.observer.clear(); + + await tick(); + await rm(tempDir, { recursive: true, force: true }); + + }); + + describe('LogViewerOverlay', () => { + + const entry = (type: string, time: string) => JSON.stringify({ time, level: 'info', type, message: `${type} message` }); + + /** The log is read newest-first, so the last line written is the top row. */ + async function overlay(types = ['alpha:evt', 'bravo:evt', 'charlie:evt']) { + + await writeFile(logPath, types.map((type, i) => entry(type, `2026-01-01T10:00:${String(i).padStart(2, '0')}.000Z`)).join('\n')); + + const view = render( + + undefined} /> + , + ); + + const frame = () => plain(view.lastFrame()); + + await waitFor(() => frame().includes(types[0]!) || frame().includes(types.at(-1)!)); + await tick(); + + return { ...view, frame }; + + } + + const cursorOn = (frame: string, type: string) => /^\s*>/.test(lineOf(frame, type).replace(/^[\s│]*/, '')); + + it('should move the cursor to the clicked entry', async () => { + + const view = await overlay(); + + expect(cursorOn(view.frame(), 'charlie:evt')).toBe(true); + + view.stdin.write(press(terminalRowOf(view.frame(), 'alpha:evt'))); + await waitFor(() => cursorOn(view.frame(), 'alpha:evt')); + + expect(cursorOn(view.frame(), 'alpha:evt')).toBe(true); + expect(cursorOn(view.frame(), 'charlie:evt')).toBe(false); + + view.unmount(); + + }); + + it('should open the detail view on a double click, as Enter does', async () => { + + const view = await overlay(); + const row = terminalRowOf(view.frame(), 'bravo:evt'); + + view.stdin.write(press(row)); + await waitFor(() => cursorOn(view.frame(), 'bravo:evt')); + + expect(view.frame()).not.toContain('Log Entry Detail'); + + view.stdin.write(press(row)); + await waitFor(() => view.frame().includes('Log Entry Detail')); + + expect(view.frame()).toContain('"type": "bravo:evt"'); + + view.unmount(); + + }); + + it('should open the clicked entry on a double click when the log is longer than the window', async () => { + + const types = Array.from({ length: 30 }, (_, i) => `evt-${String(i).padStart(2, '0')}:evt`); + const view = await overlay(types); + + // Newest first: evt-29 is the top row. A click below the middle must not scroll the list. + const row = terminalRowOf(view.frame(), 'evt-21:evt'); + + view.stdin.write(press(row)); + await waitFor(() => cursorOn(view.frame(), 'evt-21:evt')); + + expect(cursorOn(view.frame(), 'evt-21:evt')).toBe(true); + expect(terminalRowOf(view.frame(), 'evt-21:evt')).toBe(row); + + view.stdin.write(press(row)); + await waitFor(() => view.frame().includes('Log Entry Detail')); + + expect(view.frame()).toContain('"type": "evt-21:evt"'); + + view.unmount(); + + }); + + it('should step the cursor one entry per wheel notch and clamp at the ends', async () => { + + const view = await overlay(); + + view.stdin.write(press(1, 1, WHEEL_UP)); + await tick(); + + expect(cursorOn(view.frame(), 'charlie:evt')).toBe(true); + + view.stdin.write(press(1, 1, WHEEL_DOWN)); + await waitFor(() => cursorOn(view.frame(), 'bravo:evt')); + + expect(cursorOn(view.frame(), 'bravo:evt')).toBe(true); + + view.unmount(); + + }); + + it('should leave a click alone while the search box is taking keystrokes', async () => { + + const view = await overlay(); + + view.stdin.write('/'); + await waitFor(() => !view.frame().includes('type / to search')); + + view.stdin.write(press(terminalRowOf(view.frame(), 'alpha:evt'))); + await tick(); + + expect(cursorOn(view.frame(), 'charlie:evt')).toBe(true); + expect(view.frame()).not.toContain('[<'); + + view.unmount(); + + }); + + it('should shade the entry under the pointer unless the cursor is on it', async () => { + + chalk.level = 1; + + const view = await overlay(); + const raw = () => view.lastFrame() ?? ''; + + view.stdin.write(move(terminalRowOf(view.frame(), 'bravo:evt'))); + await waitFor(() => lineOf(raw(), 'bravo:evt').includes(HOVER_BG)); + + expect(lineOf(raw(), 'bravo:evt')).toContain(HOVER_BG); + expect(lineOf(raw(), 'alpha:evt')).not.toContain(HOVER_BG); + + view.stdin.write(move(terminalRowOf(view.frame(), 'charlie:evt'))); + await waitFor(() => !lineOf(raw(), 'bravo:evt').includes(HOVER_BG)); + + expect(lineOf(raw(), 'charlie:evt')).not.toContain(HOVER_BG); + + view.unmount(); + + }); + + }); + + describe('SqlClearScreen', () => { + + async function screen() { + + const view = render( + + + , + ); + + const frame = () => plain(view.lastFrame()); + + await waitFor(() => frame().includes('Clear all history')); + await tick(); + + return { ...view, frame }; + + } + + it('should move the cursor to the clicked option', async () => { + + const view = await screen(); + + expect(view.frame()).toContain('> Clear last 3 months'); + + view.stdin.write(press(terminalRowOf(view.frame(), 'Clear all history'))); + await waitFor(() => view.frame().includes('> Clear all history')); + + expect(view.frame()).toContain('> Clear all history'); + expect(view.frame()).not.toContain('> Clear last 3 months'); + + view.unmount(); + + }); + + it('should go to the confirmation for the clicked option on a double click', async () => { + + const view = await screen(); + const row = terminalRowOf(view.frame(), 'Clear all history'); + + view.stdin.write(press(row)); + await waitFor(() => view.frame().includes('> Clear all history')); + + expect(view.frame()).not.toContain('Are you sure'); + + view.stdin.write(press(row)); + await waitFor(() => view.frame().includes('Are you sure')); + + expect(view.frame()).toContain('clear ALL history'); + + view.unmount(); + + }); + + it('should step the cursor with the wheel', async () => { + + const view = await screen(); + + view.stdin.write(press(1, 1, WHEEL_DOWN)); + await waitFor(() => view.frame().includes('> Clear all history')); + + view.stdin.write(press(1, 1, WHEEL_DOWN)); + await tick(); + + expect(view.frame()).toContain('> Clear all history'); + + view.stdin.write(press(1, 1, WHEEL_UP)); + await waitFor(() => view.frame().includes('> Clear last 3 months')); + + view.unmount(); + + }); + + it('should shade the hovered option unless the cursor is on it', async () => { + + chalk.level = 1; + + const view = await screen(); + const raw = () => view.lastFrame() ?? ''; + + view.stdin.write(move(terminalRowOf(view.frame(), 'Clear all history'))); + await waitFor(() => lineOf(raw(), 'Clear all history').includes(HOVER_BG)); + + view.stdin.write(move(terminalRowOf(view.frame(), 'Clear last 3 months'))); + await waitFor(() => !lineOf(raw(), 'Clear all history').includes(HOVER_BG)); + + expect(lineOf(raw(), 'Clear last 3 months')).not.toContain(HOVER_BG); + + view.unmount(); + + }); + + }); + + describe('SqlHistoryScreen', () => { + + const result = (): SqlExecutionResult => ({ + success: true, + columns: ['id'], + rows: [{ id: 1 }], + durationMs: 3, + }); + + /** Entries are stored newest-first, so `select charlie` is the top row. */ + async function screen() { + + const manager = new SqlHistoryManager(tempDir, 'test'); + + await manager.addEntry('select alpha', result()); + await manager.addEntry('select bravo', result()); + await manager.addEntry('select charlie', result()); + + const view = render( + + + , + ); + + const frame = () => plain(view.lastFrame()); + + await waitFor(() => frame().includes('select alpha')); + await tick(); + + return { ...view, frame }; + + } + + it('should move the cursor to the clicked query', async () => { + + const view = await screen(); + + expect(view.frame()).toContain('> select charlie'); + + view.stdin.write(press(terminalRowOf(view.frame(), 'select alpha'))); + await waitFor(() => view.frame().includes('> select alpha')); + + expect(view.frame()).toContain('> select alpha'); + expect(view.frame()).not.toContain('> select charlie'); + + view.unmount(); + + }); + + it('should open the stored result on a double click, as Enter does', async () => { + + const view = await screen(); + const row = terminalRowOf(view.frame(), 'select bravo'); + + view.stdin.write(press(row)); + await waitFor(() => view.frame().includes('> select bravo')); + + view.stdin.write(press(row)); + await waitFor(() => view.frame().includes('Query Result')); + + expect(view.frame()).toContain('[/] Filter'); + + view.unmount(); + + }); + + it('should step the cursor with the wheel', async () => { + + const view = await screen(); + + view.stdin.write(press(1, 1, WHEEL_DOWN)); + await waitFor(() => view.frame().includes('> select bravo')); + + view.stdin.write(press(1, 1, WHEEL_UP)); + await waitFor(() => view.frame().includes('> select charlie')); + + view.stdin.write(press(1, 1, WHEEL_UP)); + await tick(); + + expect(view.frame()).toContain('> select charlie'); + + view.unmount(); + + }); + + it('should shade the hovered query unless the cursor is on it', async () => { + + chalk.level = 1; + + const view = await screen(); + const raw = () => view.lastFrame() ?? ''; + + view.stdin.write(move(terminalRowOf(view.frame(), 'select bravo'))); + await waitFor(() => lineOf(raw(), 'select bravo').includes(HOVER_BG)); + + view.stdin.write(move(terminalRowOf(view.frame(), 'select charlie'))); + await waitFor(() => !lineOf(raw(), 'select bravo').includes(HOVER_BG)); + + expect(lineOf(raw(), 'select charlie')).not.toContain(HOVER_BG); + + view.unmount(); + + }); + + }); + + describe('KnownUsersScreen', () => { + + const user = (email: string): KnownUser => ({ + identityHash: `hash-${email}`.padEnd(64, '0'), + email, + name: `Name ${email}`, + publicKey: 'ab'.repeat(32), + machine: 'laptop', + os: 'darwin 25', + lastSeen: '2026-01-01T10:00:00.000Z', + source: 'prod', + }); + + async function screen() { + + knownUsers = { + a: user('ann@example.com'), + b: user('bob@example.com'), + c: user('cyd@example.com'), + }; + + const view = render( + + + , + ); + + const frame = () => plain(view.lastFrame()); + + await waitFor(() => frame().includes('cyd@example.com')); + await tick(); + + return { ...view, frame }; + + } + + it('should move the cursor to the clicked user', async () => { + + const view = await screen(); + + expect(view.frame()).toContain('> [+] ann@example.com'); + + view.stdin.write(press(terminalRowOf(view.frame(), 'cyd@example.com'))); + await waitFor(() => view.frame().includes('> [+] cyd@example.com')); + + expect(view.frame()).toContain('> [+] cyd@example.com'); + expect(view.frame()).not.toContain('> [+] ann@example.com'); + + view.unmount(); + + }); + + it('should expand the clicked user on a double click, as Enter does', async () => { + + const view = await screen(); + const row = terminalRowOf(view.frame(), 'bob@example.com'); + + view.stdin.write(press(row)); + await waitFor(() => view.frame().includes('> [+] bob@example.com')); + + expect(view.frame()).not.toContain('Hash:'); + + view.stdin.write(press(row)); + await waitFor(() => view.frame().includes('[-] bob@example.com')); + + expect(view.frame()).toContain('Hash:'); + + view.unmount(); + + }); + + it('should step the cursor with the wheel', async () => { + + const view = await screen(); + + view.stdin.write(press(1, 1, WHEEL_DOWN)); + await waitFor(() => view.frame().includes('> [+] bob@example.com')); + + view.stdin.write(press(1, 1, WHEEL_UP)); + await waitFor(() => view.frame().includes('> [+] ann@example.com')); + + view.stdin.write(press(1, 1, WHEEL_UP)); + await tick(); + + expect(view.frame()).toContain('> [+] ann@example.com'); + + view.unmount(); + + }); + + it('should shade the hovered user unless the cursor is on it', async () => { + + chalk.level = 1; + + const view = await screen(); + const raw = () => view.lastFrame() ?? ''; + + view.stdin.write(move(terminalRowOf(view.frame(), 'bob@example.com'))); + await waitFor(() => lineOf(raw(), 'bob@example.com').includes(HOVER_BG)); + + view.stdin.write(move(terminalRowOf(view.frame(), 'ann@example.com'))); + await waitFor(() => !lineOf(raw(), 'bob@example.com').includes(HOVER_BG)); + + expect(lineOf(raw(), 'ann@example.com')).not.toContain(HOVER_BG); + + view.unmount(); + + }); + + }); + +}); diff --git a/tests/cli/mouse.test.tsx b/tests/cli/mouse.test.tsx index 5db13399..12ebbbe2 100644 --- a/tests/cli/mouse.test.tsx +++ b/tests/cli/mouse.test.tsx @@ -26,7 +26,8 @@ */ import { describe, it, expect, afterEach } from 'bun:test'; import { render } from 'ink-testing-library'; -import { Text } from 'ink'; +import { Box, Text } from 'ink'; +import chalk from 'chalk'; import React from 'react'; import { spawnSync } from 'node:child_process'; import { join } from 'node:path'; @@ -40,6 +41,7 @@ import { isMouseReport, installTerminalRestore, useRowMouse, + setHoverBackground, DOUBLE_CLICK_MS, } from '../../src/tui/mouse.js'; import { SelectList } from '../../src/tui/components/lists/index.js'; @@ -139,6 +141,8 @@ describe('cli: mouse transport', () => { button: 'left', row: 4, column: 11, + // Decided by the dispatcher, which sees the previous press; the parser cannot. + repeat: false, shift: false, alt: false, ctrl: false, @@ -185,10 +189,11 @@ describe('cli: mouse transport', () => { }); - it('should drop motion reports, which press-only tracking never asked for', () => { + it('should decode motion as a move and never as a press, so a drag cannot click', () => { - expect(parseMouseReport(press(1, 1, 32))).toBeNull(); - expect(parseMouseReport(press(1, 1, 32 + 2))).toBeNull(); + expect(parseMouseReport(press(3, 7, 35))).toMatchObject({ kind: 'move', button: 'none', row: 2, column: 6 }); + expect(parseMouseReport(press(1, 1, 32))).toMatchObject({ kind: 'move', button: 'none' }); + expect(parseMouseReport(press(1, 1, 32 + 2))).toMatchObject({ kind: 'move', button: 'none' }); }); @@ -204,8 +209,9 @@ describe('cli: mouse transport', () => { it('should recognise a report it refuses to decode, so it is swallowed rather than typed', () => { - expect(isMouseReport(press(1, 1, 32))).toBe(true); - expect(parseMouseReport(press(1, 1, 32))).toBeNull(); + // Legacy "some button came up" code: no button identity to act on. + expect(isMouseReport(press(1, 1, 3))).toBe(true); + expect(parseMouseReport(press(1, 1, 3))).toBeNull(); expect(isMouseReport('/')).toBe(false); expect(isMouseReport(`${ESC}[B`)).toBe(false); @@ -216,20 +222,18 @@ describe('cli: mouse transport', () => { describe('escape sequences', () => { - it('should enable press-and-release tracking with SGR coordinates and nothing stronger', () => { + it('should enable press, release and motion tracking with SGR coordinates', () => { - expect(MOUSE_ENABLE).toBe(`${ESC}[?1000h${ESC}[?1006h`); + expect(MOUSE_ENABLE).toBe(`${ESC}[?1000h${ESC}[?1003h${ESC}[?1006h`); - // ?1002 (drag) and ?1003 (motion) take the mouse away from the - // terminal far more completely than this feature needs. + // ?1002 (drag only) is redundant: ?1003 already reports every motion. expect(MOUSE_ENABLE).not.toContain('1002'); - expect(MOUSE_ENABLE).not.toContain('1003'); }); it('should disable in the reverse order it enabled', () => { - expect(MOUSE_DISABLE).toBe(`${ESC}[?1006l${ESC}[?1000l`); + expect(MOUSE_DISABLE).toBe(`${ESC}[?1006l${ESC}[?1003l${ESC}[?1000l`); }); @@ -1077,3 +1081,260 @@ afterEach(async () => { await tick(); }); + +describe('cli: mouse hover', () => { + + // Color is forced on per test; restoring here keeps a failed assertion from leaking it. + const originalChalkLevel = chalk.level; + + afterEach(() => { + + chalk.level = originalChalkLevel; + setHoverBackground('gray'); + + }); + + /** SGR motion report: button bits 3 (none) plus the motion bit, as a pointer with nothing held sends it. */ + const move = (row: number, column = 1) => press(row, column, 35); + + const HOVER_BG = `${ESC}[100m`; + + function HoverProbe({ active = true, onClick }: { active?: boolean; onClick?: () => void }) { + + const { rowRef, hovered } = useRowMouse({ + isActive: active, + onClick: onClick ?? (() => undefined), + onActivate: () => undefined, + onWheel: () => undefined, + }); + + return ( + + row-zero + row-one + hovered:{String(hovered)} + + ); + + } + + it('should report the row under the pointer and clear it when the pointer leaves', async () => { + + const { frames, stdin, unmount } = render(); + + await waitFor(() => lastUi(frames).includes('hovered:null')); + await tick(); + + stdin.write(move(terminalRowOf(lastUi(frames), 'row-one'))); + await waitFor(() => lastUi(frames).includes('hovered:1')); + + expect(lastUi(frames)).toContain('hovered:1'); + + stdin.write(move(terminalRowOf(lastUi(frames), 'row-zero'))); + await waitFor(() => lastUi(frames).includes('hovered:0')); + + expect(lastUi(frames)).toContain('hovered:0'); + + stdin.write(move(terminalRowOf(lastUi(frames), 'hovered:'))); + await waitFor(() => lastUi(frames).includes('hovered:null')); + + expect(lastUi(frames)).toContain('hovered:null'); + + unmount(); + + }); + + it('should not report a hover while the component is inactive', async () => { + + const { frames, stdin, unmount } = render(); + + await waitFor(() => lastUi(frames).includes('hovered:null')); + await tick(); + + stdin.write(move(terminalRowOf(lastUi(frames), 'row-one'))); + await tick(); + + expect(lastUi(frames)).toContain('hovered:null'); + + unmount(); + + }); + + it('should never treat motion as a click', async () => { + + let clicks = 0; + + const { frames, stdin, unmount } = render( clicks++} />); + + await waitFor(() => lastUi(frames).includes('hovered:null')); + await tick(); + + stdin.write(move(terminalRowOf(lastUi(frames), 'row-one'))); + stdin.write(press(terminalRowOf(lastUi(frames), 'row-zero'), 1, 32)); + await tick(); + + expect(clicks).toBe(0); + + unmount(); + + }); + + it('should shade a hovered list row that the cursor is not on, and nothing else', async () => { + + chalk.level = 1; + + const { frames, stdin, unmount } = render(); + + await waitFor(() => lastUi(frames).includes('alpha')); + await tick(); + + const rowOf = (name: string) => lastUi(frames).split('\n').find((line) => line.includes(name)) ?? ''; + + expect(lastUi(frames)).not.toContain(HOVER_BG); + + stdin.write(move(terminalRowOf(lastUi(frames), 'charlie'))); + await waitFor(() => rowOf('charlie').includes(HOVER_BG)); + + expect(rowOf('charlie')).toContain(HOVER_BG); + expect(rowOf('bravo')).not.toContain(HOVER_BG); + + // The cursor row keeps its own marker instead of a second highlight. + stdin.write(move(terminalRowOf(lastUi(frames), 'alpha'))); + await waitFor(() => !rowOf('charlie').includes(HOVER_BG)); + + expect(rowOf('charlie')).not.toContain(HOVER_BG); + expect(rowOf('alpha')).not.toContain(HOVER_BG); + + unmount(); + + }); + + it('should move the shade to the row that scrolls under a resting pointer', async () => { + + chalk.level = 1; + + const many = Array.from({ length: 12 }, (_, i) => ({ key: `k${i}`, label: `item-${String(i).padStart(2, '0')}`, value: i })); + + const { frames, stdin, unmount } = render(); + + await waitFor(() => lastUi(frames).includes('item-00')); + await tick(); + + const row = terminalRowOf(lastUi(frames), 'item-02'); + const lineAt = () => lastUi(frames).split('\n')[row - 1] ?? ''; + + stdin.write(move(row)); + await waitFor(() => lineAt().includes(HOVER_BG)); + + expect(lineAt()).toContain('item-02'); + expect(lineAt()).toContain(HOVER_BG); + + // The pointer does not move; four wheel notches scroll the window under it. + for (let notch = 0; notch < 4; notch++) stdin.write(press(1, 1, 65)); + + await waitFor(() => lineAt().includes('item-03')); + + expect(lineAt()).toContain('item-03'); + expect(lineAt()).toContain(HOVER_BG); + + unmount(); + + }); + + it('should shade with the color derived from the terminal when one is set, and change no text style', async () => { + + chalk.level = 3; + setHoverBackground('#2d3136'); + + const { frames, stdin, unmount } = render(); + + await waitFor(() => lastUi(frames).includes('alpha')); + await tick(); + + const rowOf = (name: string) => lastUi(frames).split('\n').find((line) => line.includes(name)) ?? ''; + const before = rowOf('charlie'); + + stdin.write(move(terminalRowOf(lastUi(frames), 'charlie'))); + await waitFor(() => rowOf('charlie').includes(`${ESC}[48;2;45;49;54m`)); + + // The shade is the only difference: the same text, on a row the background fills out to the edge. + expect(rowOf('charlie').replace(new RegExp(`${ESC}\\[(48;2;45;49;54|49)m`, 'g'), '').trimEnd()).toBe(before.trimEnd()); + + unmount(); + + }); + + it('should shade a hovered result row that the cursor is not on', async () => { + + chalk.level = 1; + + const { frames, stdin, unmount } = render( + + + , + ); + + await waitFor(() => lastUi(frames).includes('cyd')); + await tick(); + + const rowOf = (name: string) => lastUi(frames).split('\n').find((line) => line.includes(name)) ?? ''; + + stdin.write(move(terminalRowOf(lastUi(frames), 'cyd'))); + await waitFor(() => rowOf('cyd').includes(HOVER_BG)); + + expect(rowOf('cyd')).toContain(HOVER_BG); + expect(rowOf('bob')).not.toContain(HOVER_BG); + + // The cursor row is drawn inverse, so a second shade on it would hide the cursor. + stdin.write(move(terminalRowOf(lastUi(frames), 'ann'))); + await waitFor(() => !rowOf('cyd').includes(HOVER_BG)); + + expect(rowOf('cyd')).not.toContain(HOVER_BG); + expect(rowOf('ann')).not.toContain(HOVER_BG); + + unmount(); + + }); + + it('should shade a hovered form button and field row', async () => { + + chalk.level = 1; + + const { frames, stdin, unmount } = render( + + undefined} + onCancel={() => undefined} + /> + , + ); + + await waitFor(() => lastUi(frames).includes('›')); + await tick(); + + const lineOf = (text: string) => lastUi(frames).split('\n').find((line) => line.includes(text)) ?? ''; + + stdin.write(move(terminalRowOf(lastUi(frames), 'Second'))); + await waitFor(() => lineOf('Second').includes(HOVER_BG)); + + expect(lineOf('Second')).toContain(HOVER_BG); + + stdin.write(move(terminalRowOf(lastUi(frames), 'Cancel'), 18)); + await waitFor(() => lineOf('Cancel').includes(HOVER_BG)); + + // Submit and Cancel share a line: the shade has to sit after Submit, not around it. + const buttons = lineOf('Cancel'); + + expect(buttons.slice(buttons.indexOf('Submit ]'))).toContain(HOVER_BG); + expect(buttons.slice(0, buttons.indexOf('Submit'))).not.toContain(HOVER_BG); + expect(lineOf('First')).not.toContain(HOVER_BG); + + unmount(); + + }); + +}); diff --git a/tests/cli/router.test.tsx b/tests/cli/router.test.tsx index 100223d4..e6febb47 100644 --- a/tests/cli/router.test.tsx +++ b/tests/cli/router.test.tsx @@ -8,6 +8,7 @@ import { render } from 'ink-testing-library'; import React, { useEffect } from 'react'; import { Text } from 'ink'; +import { observer } from '../../src/core/index.js'; import { RouterProvider, useRouter, useRoute, useNavigation } from '../../src/tui/router.js'; import type { Route } from '../../src/tui/types.js'; @@ -317,6 +318,89 @@ describe('cli: router', () => { }); + describe('backBy', () => { + + function BackByOnMount({ steps }: { steps: number }) { + + const { backBy } = useRouter(); + + // Once, on mount: `backBy` changes with the history it just popped. + useEffect(() => { + + backBy(steps); + + }, []); + + return ; + + } + + it('should pop several entries at once and land on the one that many steps back', async () => { + + const { lastFrame } = render( + + + , + ); + + await waitFor(() => Boolean(lastFrame()?.includes('route:home'))); + + expect(lastFrame()).toContain('route:home'); + expect(lastFrame()).toContain('canGoBack:false'); + + }); + + it('should stop at the start of history instead of running past it', async () => { + + const { lastFrame } = render( + + + , + ); + + await waitFor(() => Boolean(lastFrame()?.includes('route:home'))); + + expect(lastFrame()).toContain('route:home'); + + }); + + it('should do nothing for fewer than one step', async () => { + + const { lastFrame } = render( + + + , + ); + + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(lastFrame()).toContain('route:config/add'); + + }); + + it('should announce every screen it leaves, so each can clear its state', async () => { + + const popped: string[] = []; + const listener = (data: { popped: string; to: string }) => popped.push(`${data.popped}>${data.to}`); + + observer.on('router:popped', listener); + + const { lastFrame } = render( + + + , + ); + + await waitFor(() => Boolean(lastFrame()?.includes('route:home'))); + + observer.off('router:popped', listener); + + expect(popped).toEqual(['config/add>config', 'config>home']); + + }); + + }); + describe('replace', () => { it('should replace current route without adding history', async () => { diff --git a/tests/cli/terminal-background.test.ts b/tests/cli/terminal-background.test.ts new file mode 100644 index 00000000..c4a30244 --- /dev/null +++ b/tests/cli/terminal-background.test.ts @@ -0,0 +1,184 @@ +/** The hover shade comes from the terminal's own background, asked for before Ink owns stdin. */ +import { describe, it, expect } from 'bun:test'; +import { EventEmitter } from 'node:events'; + +import { + detectHoverShade, + hoverShadeFor, + parseBackgroundReply, +} from '../../src/tui/terminal-background.js'; + +const ESC = String.fromCharCode(27); +const BEL = String.fromCharCode(7); + +describe('tui: parseBackgroundReply', () => { + + it('should read a four-digit reply, with either terminator', () => { + + expect(parseBackgroundReply(`${ESC}]11;rgb:1616/1a1a/2020${ESC}\\`)).toBe('#161a20'); + expect(parseBackgroundReply(`${ESC}]11;rgb:1616/1a1a/2020${BEL}`)).toBe('#161a20'); + + }); + + it('should scale replies that are not four digits wide', () => { + + expect(parseBackgroundReply(`${ESC}]11;rgb:ff/00/80${BEL}`)).toBe('#ff0080'); + expect(parseBackgroundReply(`${ESC}]11;rgb:f/0/f${BEL}`)).toBe('#ff00ff'); + + }); + + it('should find the reply among other bytes', () => { + + expect(parseBackgroundReply(`xx${ESC}]11;rgb:0000/0000/0000${BEL}yy`)).toBe('#000000'); + + }); + + it('should return null when there is no background reply', () => { + + expect(parseBackgroundReply('')).toBeNull(); + expect(parseBackgroundReply('hello')).toBeNull(); + expect(parseBackgroundReply(`${ESC}]10;rgb:ffff/ffff/ffff${BEL}`)).toBeNull(); + + }); + +}); + +describe('tui: hoverShadeFor', () => { + + it('should lighten a dark background a little', () => { + + expect(hoverShadeFor('#161a20')).toBe('#2d3136'); + + }); + + it('should darken a light background a little', () => { + + expect(hoverShadeFor('#ffffff')).toBe('#ebebeb'); + + }); + + it('should stay close to the background, so only the shade changes and not the look', () => { + + for (const background of ['#000000', '#161a20', '#808080', '#f2ece0', '#ffffff']) { + + const shade = hoverShadeFor(background); + + for (const offset of [1, 3, 5]) { + + const delta = Math.abs(parseInt(shade.slice(offset, offset + 2), 16) - parseInt(background.slice(offset, offset + 2), 16)); + + expect(delta).toBeGreaterThan(0); + expect(delta).toBeLessThan(40); + + } + + } + + }); + +}); + +class FakeStdin extends EventEmitter { + + isTTY = true; + rawMode: boolean[] = []; + paused = false; + + setRawMode(mode: boolean): this { + + this.rawMode.push(mode); + + return this; + + } + + resume(): this { + + this.paused = false; + + return this; + + } + + pause(): this { + + this.paused = true; + + return this; + + } + +} + +function fakeTerminal(reply?: string) { + + const stdin = new FakeStdin(); + const written: string[] = []; + + const stdout = { + isTTY: true, + write(text: string): boolean { + + written.push(text); + + if (reply !== undefined) setTimeout(() => stdin.emit('data', Buffer.from(reply)), 5); + + return true; + + }, + }; + + return { stdin, stdout, written }; + +} + +describe('tui: detectHoverShade', () => { + + it('should ask for the background and return the shade for the reply', async () => { + + const { stdin, stdout, written } = fakeTerminal(`${ESC}]11;rgb:1616/1a1a/2020${ESC}\\`); + + const shade = await detectHoverShade(stdin, stdout); + + expect(shade).toBe('#2d3136'); + expect(written).toEqual([`${ESC}]11;?${ESC}\\`]); + + }); + + it('should hand stdin back as it found it', async () => { + + const { stdin, stdout } = fakeTerminal(`${ESC}]11;rgb:1616/1a1a/2020${ESC}\\`); + + await detectHoverShade(stdin, stdout); + + expect(stdin.rawMode).toEqual([true, false]); + expect(stdin.paused).toBe(true); + expect(stdin.listenerCount('data')).toBe(0); + + }); + + it('should give up and return null when the terminal does not answer', async () => { + + const { stdin, stdout } = fakeTerminal(); + + const shade = await detectHoverShade(stdin, stdout, 30); + + expect(shade).toBeNull(); + expect(stdin.rawMode).toEqual([true, false]); + expect(stdin.listenerCount('data')).toBe(0); + + }); + + it('should not touch a stream that is not a terminal', async () => { + + const { stdin, stdout, written } = fakeTerminal(); + + stdin.isTTY = false; + + expect(await detectHoverShade(stdin, stdout)).toBeNull(); + expect(written).toEqual([]); + expect(stdin.rawMode).toEqual([]); + + }); + +});