diff --git a/.changeset/connection-timeouts.md b/.changeset/connection-timeouts.md new file mode 100644 index 00000000..f1d8107c --- /dev/null +++ b/.changeset/connection-timeouts.md @@ -0,0 +1,9 @@ +--- +"@noormdev/sdk": minor +--- + +## Connection + +### Added + +* `feat(db):` Connections now carry a connect timeout, so an unreachable host fails instead of hanging forever. Defaults to 15s; set `connection.connectTimeoutMs` per config to raise it for a database that resumes from an auto-paused state. diff --git a/.changeset/tui-ink7-and-row-inspection.md b/.changeset/tui-ink7-and-row-inspection.md new file mode 100644 index 00000000..c55f6b99 --- /dev/null +++ b/.changeset/tui-ink7-and-row-inspection.md @@ -0,0 +1,24 @@ +--- +"@noormdev/cli": minor +--- + +## TUI + +### Added + +* `feat(tui):` The interactive UI now draws in the alternate screen at full terminal height, and restores the terminal on exit instead of leaving itself in scrollback. +* `feat(tui):` Forms are a two-column layout with browse and edit modes. Arrow keys move between fields on every field type including selects, `Enter` opens a field and commits it, `Esc` reverts it, and submit is a `[ Save ]` row you navigate to. +* `feat(tui):` Lists size themselves to the terminal instead of a fixed row count, and keep their cursor when you leave a screen and come back. +* `feat(explore):` `r` on a table shows its first and last rows, ordered by primary key. `Enter` opens a row as YAML or JSON, `f` switches format, and the arrow keys walk between rows. +* `feat(explore):` The detail screen scrolls, and `v` shows a value the column grid had to truncate. +* `feat(sql):` Wide result grids drop whole columns behind a `… N more columns` marker rather than squeezing every column past legibility. `Enter` opens a row in full. +* `feat(tui):` Mouse support: click to move the cursor, double-click to activate, wheel to scroll. Set `ui.mouse: false` in `.noorm/settings.yml` to restore click-drag text selection. +* `feat(db):` `Esc` cancels a connection test or query that is hanging. On PostgreSQL and MySQL the server is asked to stop the query; on SQL Server and SQLite the client stops waiting and says so. + +### Fixed + +* `fix(sql):` Backspace in the results filter did nothing, because the key reports as `delete` on the previous Ink release. +* `fix(tui):` Screens sized from the terminal never recomputed on resize. +* `fix(explore):` Column, index and parameter lists re-flowed per row, so the type column landed on a different offset on nearly every row. +* `fix(explore):` "Total Objects" counted categories the screen does not list, so it exceeded the rows a reader could see. +* `fix(tui):` The help screen and log viewer drew past the bottom of the window, putting their first lines out of reach. diff --git a/.claude/rules/documentation.md b/.claude/rules/documentation.md index 8265ee79..8e57c69c 100644 --- a/.claude/rules/documentation.md +++ b/.claude/rules/documentation.md @@ -1,45 +1,33 @@ --- -paths: docs/**/*.md +paths: + - "docs/**/*.md" --- -# Documentation Rules +# Documentation rules -## Three-Pillar Structure +## Voice is assigned per file, not globally -Build each section with Memory, Reasoning, and Example woven together. The pillars are invisible scaffolding. +The `## Documentation surfaces` table in the root `CLAUDE.md` assigns every doc path a voice: `atomic-writing` or `terse-technical`. That table is the authority, and `/documentation` reads it to route authoring. Look the file up there before writing, and follow the `atomic-writing` skill when it says so. -| Pillar | Purpose | Expression | -|--------|---------|------------| -| Memory | The what | Concepts, definitions, data structures | -| Reasoning | The why | Motivation, trade-offs, design decisions | -| Example | The how | Code samples, workflows, practical demonstrations | +Do not impose one house tone across `docs/`. A CLI reference and a getting-started guide are labeled differently on purpose. -## Style - -Start with the problem the reader wants to solve. Explain through analogy before technical detail. - -Use short sentences mixed with longer explanations. Show "why" before "how". Code follows explanation. - -Create visual hierarchy through headers, code blocks, and tables. +## Claims +Every claim has to be one you can point at. -## Tone +Do not invent a position to argue against. No "some people say", no "you may have been told", no opponent who does not hold the view. State the finding and let it stand without a foil. -Conversational but precise. Guide the reader from familiar concepts to new ones. +`docs/guide/changes/overview.md:15` currently breaks this ("Some argue that you *are* moving data..."). Fix it when you next touch that file, and do not copy the shape. +Measurements carry their conditions: the tool, the scale, the run. Never combine numbers from different runs or builds into one comparison. If the conditions differ, re-measure or say which figure came from where. -## Claims +Mark inference as inference. An extrapolation from a mechanism reads as a measured result unless the text says otherwise. -Every claim has to be one you can point at. -Do not invent a position to argue against. No "some people say", no "you may have been told", no -opponent who does not hold the view. State the finding and let it stand without a foil. +## Structure -Measurements carry their conditions — the tool, the scale, the run. Never combine numbers from -different runs or builds into one comparison. If the conditions differ, re-measure or say which -figure came from where. +Lead with the problem the reader wants to solve, then the mechanism. Code follows the explanation that motivates it. -Mark inference as inference. An extrapolation from a mechanism reads as a measured result unless -the text says otherwise. +Prefer a table, tree, or diagram wherever the content has a shape. Reserve prose for reasoning and motivation, which is the one thing a table cannot carry. diff --git a/.claude/rules/testing.md b/.claude/rules/testing.md index 47a000ea..0ad8a49b 100644 --- a/.claude/rules/testing.md +++ b/.claude/rules/testing.md @@ -1,58 +1,95 @@ --- -paths: tests/**/*.{ts,tsx} +paths: + - "tests/**/*.{ts,tsx}" --- -# Testing Rules +# Testing rules -## Naming +Runner is `bun:test`. `bun run test` is `bun test --serial`, and `bunfig.toml` pins `concurrency = 1`, `timeout = 30000`, and `preload = ["./tests/preload.ts"]`. All 315 test files import from `'bun:test'`; nothing here uses vitest or jest. -Use `describe('module: feature', () => {})` format. Group by module, then by feature. -```ts -describe('runner: executeFile', () => { +## Before running anything - it('should skip unchanged files', async () => { +Integration tests need live databases on non-default ports (postgres `15432`, mysql `13306`, mssql `11433`): - // ... +```bash +docker compose -f docker-compose.test.yml up -d +``` - }); +`skipIfNoContainer(dialect)` (`tests/utils/db.ts:892`) **throws, it does not skip**, despite the name. 40 files call it in `beforeAll`. Without the containers up you get a wall of failures that read like real regressions. Check the containers before you believe a red suite. - it('should emit error event on failure', async () => { - // ... +## Database safety - }); +The suite runs `TRUNCATE`, `DROP`, and teardown. `assertTestDatabase` (`tests/utils/db.ts:122`) refuses to connect unless the resolved database name is `:memory:` or contains `test` as a `_`/`-` delimited word, and `createTestConnection` calls it on every connection. It exists so a stray `.env` or leaked CI secret cannot aim a destructive suite at a real database. -}); -``` +Never bypass it by building a Kysely instance by hand. Route test connections through `createTestConnection`. -## Coverage +## Naming -Test all paths: success, error, edge cases. Verify observer events are emitted with correct data. +`describe('module: feature', ...)` — 414 of 420 top-level describes follow this. Group by module, then by feature. Prefix `it` descriptions with "should". ```ts -it('should emit error event on failure', async () => { +describe('runner: executeFile', () => { - const events: any[] = []; - observer.on('file:after', (data) => events.push(data)); + it('should skip unchanged files', async () => { - const [_, err] = await attempt(() => executeFile(badFile, configName)); + // ... - expect(err).toBeInstanceOf(Error); - expect(events[0].status).toBe('failed'); + }); }); ``` -## Error Handling +## Errors -Use `attempt` for operations that may fail. Assert both the error and any side effects. +Use `attempt`/`attemptSync` and assert on the tuple. Assert the error *and* the side effects that should or should not have happened. ```ts const [result, err] = await attempt(() => executeFile(badPath)); + expect(err).toBeInstanceOf(InvalidFileError); expect(result).toBeUndefined(); ``` + +The no-`try/catch` rule in `typescript.md` covers `tests/**` too, and 17 blocks currently violate it. Do not add more. + + +## Observer events + +Modules that emit events should have their emissions asserted, not just their return values. This applies to the ~20 files testing event-emitting modules (`core/change/executor`, `core/transfer/*`, `core/lifecycle/*`, and similar), not to the suite at large. + +```ts +const events: ChangeEvent[] = []; +observer.on('file:after', (data) => events.push(data)); + +const [, err] = await attempt(() => executeFile(badFile, configName)); + +expect(err).toBeInstanceOf(Error); +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. + + +## Helpers + +`tests/utils/db.ts` is the shared database harness: + +| Export | Purpose | +|--------|---------| +| `createTestConnection` | guarded connection; use instead of building Kysely directly | +| `assertTestDatabase`, `NotATestDatabaseError` | the naming-convention safety guard | +| `TEST_CONNECTIONS`, `makeTestConfig` | per-dialect connection details and a filled-in `Config` | +| `deployTestSchema`, `seedTestData`, `resetTestData`, `teardownTestSchema` | fixture lifecycle | +| `isContainerRunning`, `skipIfNoContainer` | container preflight | + +SQL fixtures live in `tests/fixtures/sql//`. `tests/integration/cli/setup.ts` spawns the built CLI for headless tests. + +`tests/global-setup.ts` and `tests/global-teardown.ts` are **dead code**, left over from vitest. Nothing wires them: `bunfig.toml` sets only `preload`, and there is no vitest config in the repo. Editing them changes nothing. Real preload work belongs in `tests/preload.ts`, which pins `NOORM_CHANNEL` and strips agent-harness markers so policy-gated tests behave the same locally and in CI. diff --git a/.claude/rules/tui-development.md b/.claude/rules/tui-development.md index 0ebba92b..06e9879f 100644 --- a/.claude/rules/tui-development.md +++ b/.claude/rules/tui-development.md @@ -1,13 +1,21 @@ --- -paths: src/tui/**/*.{ts,tsx}, tests/tui/**/*.{ts,tsx} +paths: + - "src/tui/**/*.{ts,tsx}" + - "tests/cli/**/*.{ts,tsx}" --- -# TUI Development Rules +# TUI development rules -## Focus System +Stack: `ink@7.1.1`, `@inkjs/ui@2.0.0`, `react@19.2.4`. Rules below are correct for those versions and verified against the installed build. Where Ink 6.8.0 behaved differently the difference is noted inline, because much of this codebase was written against it. -Use `useFocusScope` from `src/tui/focus.tsx` for keyboard input. @inkjs/ui's focus system (`useFocus`, `useFocusManager`) does not communicate with ours - mixing them causes lost input. + +## Focus + + +### Use `useFocusScope`, never Ink's focus hooks + +`useFocusScope` from `src/tui/focus.tsx` is the only focus system in this codebase (83 files). Ink's `useFocus` and `useFocusManager` maintain a separate stack that ours never reads, so mixing them drops input on the floor. ```tsx const { isFocused } = useFocusScope('my-component'); @@ -20,78 +28,236 @@ useInput((input, key) => { }); ``` -Check `isFocused` inside the handler, not via `{ isActive }` option. The option prevents handler registration when false, and `isFocused` is false on initial render before useEffect runs. +### Check `isFocused` inside the handler, not the `isActive` option -## @inkjs/ui Components +Ink's `useInput` accepts `{ isActive }`, and passing `isFocused` to it breaks input. -Build custom interactive components using `useFocusScope` + `useInput`. @inkjs/ui's `Select`, `MultiSelect`, `ConfirmInput` use ink's internal focus, incompatible with our stack. +`useFocusScope` pushes onto the focus stack inside a `useEffect` (`src/tui/focus.tsx:208`), so `isFocused` is `false` during the first render. Ink's `useInput` returns early from both of its effects when `isActive === false` (`use-input.js:29` and `:38`), skipping handler registration *and* `setRawMode(true)`. The handler never recovers once the effect has been skipped. -Use `TextInput` (with `isDisabled`), `Spinner`, `Badge`, `ProgressBar`. These are display-only or properly controlled. +Guarding inside the handler registers it once and lets it no-op until focus arrives. +```tsx +// Correct +useInput((input, key) => { + + if (!isFocused) return; -## Keyboard Handling +}); -Guard all `useInput` handlers with `isFocused` check. Ink's useInput is subscriber-based - all handlers receive every keystroke. +// Broken: never registers, because isFocused is false on the first render +useInput(handler, { isActive: isFocused }); +``` -Skip arrow keys in parent when child handles them (e.g., select fields). Otherwise parent intercepts before child can respond. +`useFocusedInput(isFocused, handler)` in `src/tui/keyboard.tsx:249` wraps this correctly. Prefer it. -## Screen Focus Ownership +### One focus owner per screen -When a screen's primary content is a Form or other focusable component, do NOT create a competing focus scope at the screen level. Let the child component own focus. +A screen whose content is a `Form` (or any other focusable component) must not open its own scope. Two scopes on one screen means the child never reaches the top of the stack. -**Bad - competing scopes:** ```tsx -function MyScreen(): ReactElement { +// Bad: competes with Form +const { isFocused } = useFocusScope('MyScreen'); +return
; - const { isFocused } = useFocusScope('MyScreen'); // ❌ Competes with Form +// Good: Form owns focus +return ; +``` - return ; +For a screen with several states, split into components that each own their focus: -} +```tsx +if (!activeConfig) return ; // its own useFocusScope +return ; // Form's own focusLabel +``` + + +### Reusable components take focus from the parent via `skip` + +A component that is sometimes standalone and sometimes embedded accepts an optional `isFocused` prop and passes `skip` to `useFocusScope`. `skip: true` suppresses the stack push and forces `isFocused` to `false`, so the parent's value is the only one in play. + +```tsx +const hasExternalFocus = externalFocused !== undefined; +const internalFocus = useFocusScope({ + label: focusLabel ?? 'SelectList', + skip: hasExternalFocus, +}); +const isFocused = hasExternalFocus ? externalFocused : internalFocus.isFocused; ``` -**Good - Form owns focus:** +Used by `SelectList`, `SearchableList`, `Confirm`, and `ProtectedConfirm`. Follow it for any new focusable component meant to nest. + + +## Keyboard + + +### Every `useInput` handler needs an `isFocused` guard + +Ink's `useInput` is subscriber-based: every registered handler receives every keystroke regardless of focus. The guard is what makes focus mean anything. + + +### Backspace sets `key.backspace`, Delete sets `key.delete` + +The physical Backspace key sends `0x7f`, which `parse-keypress.js:433` names `backspace`. Ctrl+H (`0x08`) does too, at `:428`. `key.delete` is the real Delete key alone. Verified against the installed build: + +| Input | `key.backspace` | `key.delete` | +|-------|-----------------|--------------| +| Backspace (`0x7f`) | `true` | `false` | +| Ctrl+H (`0x08`) | `true` | `false` | +| Delete (`\x1b[3~`) | `false` | `true` | + +Guard on `key.backspace` alone: + ```tsx -function MyScreen(): ReactElement { +if (key.backspace) { - // No useFocusScope here - Form handles it - return ; + // erase one char } ``` -For screens with multiple states (error state vs form), use separate components with their own focus: +`ResultTable.tsx:471` does this, and `tests/cli/components/terminal.test.tsx` pins it through the filter box. + +`SqlInput.tsx:172` and `LogViewerOverlay.tsx:197` still read `key.backspace || key.delete`. Ink 6.8.0 required that, because it reported `0x7f` as `delete` and left `key.backspace` true only for Ctrl+H. On 7.1.1 the extra clause only makes the Delete key erase backwards as well. Harmless where it sits, but do not copy it into new code. + + +### `key.meta` means Alt, not Escape + +Ink 6.8.0 set `key.meta` on a plain Escape as well as on Alt combinations, so `!key.meta` doubled as an Escape filter. Ink 7 reserves it for real modifier combinations; a plain Escape now arrives with `escape: true, meta: false`. Verified against the installed build. + +The two character-input filters that use it, `ResultTable.tsx:480` and `LogViewerOverlay.tsx:208`, both sit behind a `key.escape` early return in the same handler (`:452` and `:165`), and Ink strips the escape byte so `input` is empty anyway. They kept working. Do not rely on `!key.meta` to exclude Escape. Test `key.escape` and return. + + +### A parent must not consume arrow keys its child owns + +When the focused child handles its own arrow navigation, the parent has to opt out or it intercepts first. Deciding that per *field type* is the trap: `Form` used to skip arrow handling whenever the active field was a select, so the only way out of a select was Tab, and a user pressing Down got option-cycling instead of the next field. + +Split it by *mode* instead, and let the child mount only in the mode it owns. `Form.tsx:606` is the reference: ```tsx -function MyScreen(): ReactElement { +if (isEditing) { - if (!activeConfig) { + // Everything else belongs to the field: TextInput's own handler for + // text/password, SelectField's for select. + if (key.escape) { - return ; // Has its own useFocusScope + revertEdit(); + + return; + + } + + if (key.return) { + + commitEdit(); } - return ; // Form has its own focusLabel + return; + +} + +if (key.downArrow) { + + moveBy(1); + + return; } ``` +`SelectField` is rendered only while its field is in edit mode, so its `useInput` is not even registered in browse mode. Ownership is decided by what exists, not by a type check the parent has to keep in sync. + + +### Global keys + +`GlobalKeyboard` (`src/tui/keyboard.tsx:114`) owns Ctrl+C, Shift+L, Shift+Q, `?`, `D`, and `F`. It deliberately does **not** handle Esc: each screen handles its own, because a global handler fires alongside the screen handler and pops history twice. + +`?`, `D`, and `F` only fire when `stack.length <= 1`, so they stay inert while a text input is focused. + + +## @inkjs/ui components + + +| Component | Use it? | Reason | +|-----------|---------|--------| +| `TextInput` | No — use ours | `src/tui/components/forms/TextInput.tsx` is upstream's, plus the mouse-report guard. 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 | + +Build interactive replacements from `useFocusScope` + `useInput`. `SelectList` (`src/tui/components/lists/SelectList.tsx`) and `Confirm` (`src/tui/components/dialogs/Confirm.tsx`) already exist for the two common cases. + +`forms/TextInput.tsx` is pinned to upstream *differentially*, not by hand: `tests/cli/components/text-input.test.tsx` drives the same keystroke script through both and compares the `onChange`/`onSubmit` logs, and a child process at `FORCE_COLOR=1` compares the rendered frames byte for byte. If `@inkjs/ui` is upgraded, that file is what tells you whether the copy still matches. + + +## Terminal size comes from `useWindowSize` + +`useWindowSize()` returns `{ columns, rows }` as plain numbers and re-renders the component on resize, so anything derived from it is current by construction. + +```tsx +const { rows: terminalHeight } = useWindowSize(); + +const maxRows = useMemo(() => Math.floor((terminalHeight - 9) * 0.75), [terminalHeight]); +``` + +Never size a component from `useStdout().stdout.rows`. Ink's resize handler (`ink.js:279`) calls `calculateLayout()` and `onRender()`, which re-lay-out and re-paint the *existing* React output without a state update, so the component never re-executes. A probe against the installed build confirms it: emitting `resize` left a `useStdout` component's execution count at 1 while a `useWindowSize` component went from 1 to 2. + +Consequences for any component that reads `stdout.rows`: + +- A value read during render is frozen at mount and only refreshes when something *else* re-renders the component. +- A `useMemo` keyed on `[stdout.rows]` never recomputes on resize, because the dependency is only compared when the component re-executes. + +Reserve `useStdout()` for `write()`. + +Two screens size themselves this way. Copy either: + +| Location | Shape | +|----------|-------| +| `src/tui/screens/db/SqlTerminalScreen.tsx:51,55-63` | `useMemo(..., [terminalHeight])` sizing the result table | +| `src/tui/screens/config/ConfigEditScreen.tsx:49,268` | `formHeight` derived in the render body | + +In a screen with early returns, the hook has to sit above them, or the hook count changes between renders once the async load resolves. `ConfigEditScreen` is the worked example and `tests/cli/screens/config/ConfigEditScreen.test.tsx:132` guards it. + +Write no `?? 24` fallback. `useWindowSize` already falls back to the `terminal-size` probe and then to 80x24 when the stream reports nothing. + + +## Observer hooks + +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'; + +useOnEvent('changeset:complete', (data) => { + + setResults((prev) => [...prev, data]); + +}, []); + +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()); +``` + -## UI Patterns +## Feedback patterns -Use toasts for success/error feedback instead of dead-end confirmation screens. Show toast and use `back()` to pop history: +Report success and failure with a toast plus `back()`, not a dead-end confirmation screen. `back()` pops history, so the breadcrumb gains no duplicate entry. ```tsx const { showToast } = useToast(); const { back } = useRouter(); showToast({ message: 'Config saved', variant: 'success' }); -back(); // Pops history stack, avoids duplicate breadcrumb entries +back(); ``` -Use Form's `busy` and `statusError` props for inline progress and error display. Keeps user on form to fix errors: +Keep failures on the form with `busy` and `statusError` so the user can correct the input in place. ```tsx ` to work, parent needs explicit width: +Wrap anything focusable in `FocusProvider`, wait after render before writing to stdin, and call `unmount()` to release stdin handlers. The wait is required because the focus stack initializes in a `useEffect`, so input sent on the same tick lands before any handler is registered. ```tsx - - Left - - Right - -``` +const { stdin, lastFrame, unmount } = render(); -For fixed-position elements (like toast), use fixed width to reserve space and prevent layout shift: +await new Promise((r) => setTimeout(r, 50)); +stdin.write('\x1B[B'); +await new Promise((r) => setTimeout(r, 50)); -```tsx - - - +unmount(); ``` +| Key | Sequence | +|-----|----------| +| Up | `\x1B[A` | +| Down | `\x1B[B` | +| Enter | `\r` | +| Escape | `\x1B` | +| Backspace | `\x7F` | +| Delete | `\x1B[3~` | -## Observer Hooks +A multi-character `stdin.write('abc')` arrives as one `input` string, not three events. -Use hooks from `src/tui/hooks/useObserver.ts` for event subscriptions. These handle cleanup automatically. +Ink 7 writes a cleared frame as it unmounts, so a render-time throw shows up in `frames` but never in `lastFrame()`. Assert against `frames.join('')` when testing an error path. `tests/cli/focus.test.tsx:116` is the reference. -```tsx -import { useOnEvent, useOnceEvent, useEmit } from '../hooks/index.js'; -// Subscribe to events - cleanup on unmount -useOnEvent('changeset:complete', (data) => { +### Fixed sleeps are the suite's weak point - setResults(prev => [...prev, data]); +A fixed `setTimeout` says "probably long enough", and a number of TUI tests bet on one. Under machine load that bet loses. Measured on the CI CLI group, same machine, same commit: two `ink@7.1.1` runs took 169.4s and 155.0s and each failed a *different* small set of tests (`cli: router > navigate`, then `cli: DismissableAlert`); both failures inspected were the wait expiring before the frame arrived, not a behavior change. A third `ink@7.1.1` run on an idle machine took 111.9s and passed clean, and two `ink@6.8.0` runs took 109.0s and 109.8s and also passed clean. -}, []); +Read that carefully: the failures track wall-clock pressure, not the Ink version. Do not file these as an Ink 7 regression. The fragile thing is the fixed sleep. -// One-time subscription -useOnceEvent('build:complete', (data) => setFinalResult(data), []); +Prefer polling until the condition holds over sleeping a guessed duration: -// Emit events via memoized callback -const emitStart = useEmit('build:start'); -emitStart({ schemaPath, fileCount }); -``` +```tsx +const waitFor = async (predicate: () => boolean, timeoutMs = 2000) => { + const deadline = Date.now() + timeoutMs; -## Testing + while (!predicate() && Date.now() < deadline) { -Wait after render before sending input. Focus stack initializes in useEffect. Call `unmount()` to clean up stdin handlers. + await new Promise((r) => setTimeout(r, 10)); -```tsx -render(); -await new Promise(r => setTimeout(r, 50)); -stdin.write('\x1b[B'); // Down: \x1b[B, Up: \x1b[A, Enter: \r, Esc: \x1b -await new Promise(r => setTimeout(r, 50)); -unmount(); + } + +}; + +await waitFor(() => Boolean(lastFrame()?.includes('route:config'))); ``` +The existing fixed-sleep tests have not been converted. Convert one when you touch it. -## Keyboard Shortcuts -Consistent hotkey conventions across all screens: +## Keyboard shortcuts + +Hotkey registry. Verify against the screen source before changing any of it. + +**Home** (`src/tui/screens/home.tsx:248`): -**Home navigation** (`src/tui/screens/home.tsx`): | Key | Action | |-----|--------| | `r` | run | @@ -179,30 +346,73 @@ Consistent hotkey conventions across all screens: | `1` / `2` / `3` | quick actions: run build, change ff, lock status | | `q` | quit | -There is no `k` on Home — secrets belong to a config, so `k` opens them from -the config list. +There is no `k` on Home. Secrets belong to a config, so `k` opens them from the config list. **Common actions (sub-screens):** -| Key | Action | Mnemonic | -|-----|--------|----------| + +| Key | Action | Note | +|-----|--------|------| | `a` | add | | | `e` | edit | | | `d` | delete | | -| `k` | secrets | **k**eys (from the config list) | +| `k` | secrets | **k**eys, from the config list | | `+` | more | export / import / validate live here, not on the list | | `Enter` | use/activate | selecting a config activates it | **Context-dependent keys:** -- `[i]` = identity on Home, import on the config More screen -- `[x]` = export on the config More screen, extend in Lock Status -- `[s]` = settings on Home, status in Lock List -- `[c]` = config on Home, copy on the config list, create on the DB screen -**Global shortcuts (available everywhere):** +| Key | Screen | Action | +|-----|--------|--------| +| `i` | Home | identity | +| `i` | config More | import | +| `x` | config More | export | +| `x` | Identity | export | +| `x` | Lock Status | extend | +| `x` | DB list | explore | +| `s` | Home | settings | +| `s` | Lock List | status | +| `c` | Home | config | +| `c` | config list | copy | +| `c` | DB list | create | + +**Global (every screen, via `GlobalKeyboard`):** + | Key | Action | |-----|--------| -| `Shift+L` | Toggle log viewer overlay | -| `Shift+Q` | Open the SQL terminal | -| `?` | Show help | +| `Ctrl+C` | graceful exit | +| `Shift+L` | toggle log viewer overlay | +| `Shift+Q` | open the SQL terminal | +| `?` | show help | +| `D` | toggle dry-run mode | +| `F` | toggle force mode | + +Pass `numberNav` to `SelectList` for 1-9 quick selection in lists. + + +## Mouse + +There is no mouse support in Ink 7.1.1 — no hook, no parsing. `src/tui/mouse.tsx` is the whole transport: it writes the tracking escape sequences, parses SGR reports off `useInput`, restores the terminal on every exit path, and hit-tests rows with `measureElement`. + +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. +- **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. + + +## Ink 7 APIs still unused + +`alternateScreen` is in use (`src/cli/ui.ts:54`) and so is `measureElement` (`src/tui/mouse.tsx`). What is left: + +| 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` | 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. -Use `numberNav` prop on `SelectList` for 1-9 quick selection in lists. +`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/.claude/rules/typescript.md b/.claude/rules/typescript.md index a19701d9..8a9f89ef 100644 --- a/.claude/rules/typescript.md +++ b/.claude/rules/typescript.md @@ -1,24 +1,24 @@ --- -paths: "**/*.{js,jsx,ts,tsx}" +paths: + - "**/*.{js,jsx,ts,tsx}" --- -# TypeScript Standards +# TypeScript standards -## Function Structure (MANDATORY) +General TypeScript judgment (`const` over `let`, no `as`, no `any`, inference over annotation, short functions) is covered by the global TypeScript style rules and is not repeated here. This file carries only what is specific to noorm. -Every function body should be organized into up to four logical sections, in this order: + +## Function structure + +Organize every function body into up to four sections, in this order, separated by a blank line: 1. **Declaration** — local variables, destructuring, constants 2. **Validation** — input guards, early throws -3. **Business logic** — the actual work +3. **Business logic** — the work 4. **Commit** — final side effects and the return value -Use `attempt(...)` only when this function does something with the error — translate it, recover, emit, etc. If you'd just re-throw or re-return it unchanged, don't wrap — let it propagate naturally. - -### The mental model (for thinking about your code) - -The block markers below are **authoring aids only**. They show the four sections so you can reason about where code belongs. **Do NOT copy these `// === ... ===` comments into actual source files.** They are not a code artifact — they are a guide you read, then delete. +No banner comments marking the sections. If a section needs one to be understandable, the function is too long; split it. ```typescript /** @@ -31,120 +31,57 @@ The block markers below are **authoring aids only**. They show the four sections */ async function modifyUserEmail(userID: UUID, newEmail: EmailAddress) { - // === Declaration block === const now = new Date(); - // === Validation block === if (!isValidEmail(newEmail)) { throw new InvalidEmailError(newEmail); - } - - // === Business logic block === - // attempt() here because we translate both "fetch failed" and "user missing" - // into a single UserNotFoundError for the caller. - const [user, err] = await attempt(() => fetchUser(userID)); - - if (err || !user) { - - throw new UserNotFoundError(userID); - } - - user.email = newEmail; - user.updatedAt = now; - - // === Commit block === - // No attempt() — saveUser's error is already meaningful; let it propagate. - return saveUser(user); -} -``` - -### What the code should actually look like - -Separate sections with a blank line. No banner comments. Errors propagate unless the function does something with them. - -```typescript -/** - * Updates user email address after validation. - * - * Prevents invalid emails and ensures user exists before update. - * - * @example - * const user = await modifyUserEmail(userID, newEmail); - */ -async function modifyUserEmail(userID: UUID, newEmail: EmailAddress) { - - const now = new Date(); - - if (!isValidEmail(newEmail)) { - throw new InvalidEmailError(newEmail); } + // attempt() here because we collapse "fetch failed" and "user missing" + // into one UserNotFoundError for the caller. const [user, err] = await attempt(() => fetchUser(userID)); if (err || !user) { throw new UserNotFoundError(userID); + } user.email = newEmail; user.updatedAt = now; + // No attempt() here: saveUser's error is already meaningful, so let it propagate. return saveUser(user); -} -``` - -If a section needs a banner comment to be understandable, the function is probably too long — split it. - -### When to use `attempt` - -Use `attempt` whenever you need to *inspect* the error before deciding what to do. Common cases: - -- **Ignore it** — a non-critical cleanup step fails, you don't care. -- **Ignore only specific kinds** — swallow a network timeout, re-throw everything else. -- **Translate it** — collapse several underlying failures into one domain error. -- **Recover** — fall back to a default, retry, read from cache. -- **Observe it** — emit an event, log context, then either continue or re-throw. -```typescript -// Ignore a specific error class, re-throw the rest. -const [res, err] = await attempt(() => fetchRemote(url)); -if (err && !(err instanceof NetworkError)) throw err; - -// Translate + observe. -const [user, err] = await attempt(() => modifyUserEmail(userID, newEmail)); -if (err) { - - observer.emit('user:update-failed', { userID, error: err }); - return; } ``` -If you're going to re-throw the error unchanged in every case, skip `attempt` and let it propagate directly. + +## Error handling -## Error Handling (ZERO TOLERANCE) +### Never bind a catch parameter -- **NEVER use try-catch** - This is a critical violation. The zero tolerance targets try-catch specifically, not throwing or `attempt`/`attemptSync` - see Function Structure above for when to wrap deliberately vs. let errors propagate. -- **Mandated `@logosdx/utils` utilities**: `attempt`/`attemptSync` (the convention actually in use - 553 call sites across 175 files) and `retry` (used at `src/core/connection/factory.ts:93`). Use `attempt`/`attemptSync` per the Function Structure guidance above - only when the function does something with the error. -- **Available in `@logosdx/utils` but not currently used**: `batch`, `circuitBreaker`, `debounce`, `throttle`, `memo`/`memoize`, `rateLimit`, `withTimeout`, `FetchEngine`. Reach for them if a real need arises; they are not mandated because nothing in `src/` imports them today. -- `ObserverEngine` is `@logosdx/observer`, not `@logosdx/utils`. +`catch (err)` appears **zero** times in `src/`. Use `attempt`/`attemptSync` instead; the error tuple is the convention (600+ call sites across 187 files). ```typescript -// CORRECT - attempt() used deliberately: observes the error, emits, then stops +// Correct: attempt() observes the error, emits, then stops const [result, err] = await attempt(() => db.execute(sql)); + if (err) { observer.emit('error', { source: 'executor', error: err }); return; + } -// ALSO CORRECT - nothing to add by wrapping; let the error propagate +// Also correct: nothing to add by wrapping, so let it propagate return db.execute(sql); -// WRONG - Never do this +// Wrong try { const result = await db.execute(sql); } @@ -153,123 +90,71 @@ catch (err) { } ``` +`try { } finally { }` for cleanup binds no error and swallows nothing. It is permitted and used at 4 sites, including `src/core/lock/manager.ts:358` and `src/sdk/context.ts:570`. -## Import Organization - -```typescript -// Built-ins first -import { readFile } from 'fs/promises'; - -// External libraries (alphabetical by org) -import { - attempt, - attemptSync, - FetchEngine, -} from '@logosdx/utils'; -import Joi from 'joi'; - -// Local imports (by depth, deepest first) -import * as utils from '../../../utils/index'; -import * as controllers from '../../controllers/index'; -import * as misc from '../misc'; -``` +Four bare `catch { }` blocks remain in TUI code (`RunFileScreen.tsx:273`, `RunDirScreen.tsx:336`, `LogViewerOverlay.tsx:78`, `SecretValueForm.tsx:154`). They are the "may fail, do not care" case, which `attemptSync` expresses without a `try`. Convert them when you touch the surrounding code; do not add more. +This rule also reaches `tests/**` through this file's glob, where 17 genuine `try/catch` blocks currently violate it (`tests/core/config/schema.test.ts:203` and 10 other files). Use `attempt` plus `expect(err)` there instead. -## Code Style (ESLint-Enforced) -These patterns are enforced by ESLint: +### Wrap with `attempt` only when the function acts on the error -```typescript -// 4-space indentation -function example() { - - const value = 'test'; -} +If every branch re-throws the error unchanged, skip `attempt` and let it propagate. Reach for it when you need to inspect the error first: -// Single quotes, semicolons always -const name = 'value'; - -// Stroustrup brace style (else on new line) -if (condition) { - - // logic -} -else { - - // other logic -} +| Intent | Example | +|--------|---------| +| Ignore it | a non-critical cleanup step fails | +| Ignore specific kinds | swallow a network timeout, re-throw everything else | +| Translate it | collapse several failures into one domain error | +| Recover | fall back to a default, retry, read from cache | +| Observe it | emit an event or log context, then continue or re-throw | -// Padded blocks - newline after opening brace, before closing -function doSomething() { - - const x = 1; - - return x; - -} - -for (const item of items) { - - process(item); - -} - -// Trailing comma on multiline -const config = { - name: 'test', - value: 42, -}; - -// Object curly spacing -const { name, value } = config; - -// Max line length 150 +```typescript +// Ignore a specific error class, re-throw the rest. +const [res, err] = await attempt(() => fetchRemote(url)); +if (err && !(err instanceof NetworkError)) throw err; ``` -## Utilities - -Core utilities from `@logosdx/utils`: +## Shared utilities actually in use -```typescript -// Error tuples - Go-style [result, err] -const [data, err] = await attempt(() => db.query()); -const [parsed, parseErr] = attemptSync(() => JSON.parse(str)); - -// Retry with backoff -const fn = retry(asyncFn, { retries: 3, delay: 1000, backoff: 2 }); +| Symbol | Package | Where | +|--------|---------|-------| +| `attempt` / `attemptSync` | `@logosdx/utils` | everywhere; the error-handling convention | +| `retry` | `@logosdx/utils` | `src/core/connection/factory.ts:118`, `src/core/update/updater.ts:227` | +| `runWithTimeout` | `@logosdx/utils` | `src/core/connection/manager.ts:217`, `src/core/lifecycle/manager.ts:343` | +| `ObserverEngine` / `ObserverRelay` | `@logosdx/observer` | `src/core/observer.ts:19`, `src/core/worker-bridge/bridge.ts:2` | -// Batch with concurrency -await batch(fn, { items, concurrency: 3, failureMode: 'abort' | 'continue' }); +`@logosdx/utils` also exports `batch`, `circuitBreaker`, `debounce`, `throttle`, `memoize`, `rateLimit`, `withTimeout`, `clone`, `equals`, `reach`, `Deferred`, and `assert`. Nothing in `src/` imports any of them. Reach for one if a real need arises, and read its signature from the package types rather than assuming it. -// Timeout enforcement -const fn = withTimeout(asyncFn, { timeout: 5000 }); +`FetchEngine` is not in `@logosdx/utils`; it lives in `@logosdx/fetch`. There is no `memo` export, only `memoize` and `memoizeSync`. -// Debounce/throttle for UI -const fn = debounce(handler, { delay: 300, maxWait: 1000 }); -const fn = throttle(handler, { delay: 16 }); -// Deep operations -const copy = clone(obj); // handles circular refs -const same = equals(a, b); // deep comparison -const val = reach(obj, 'a.b.c'); // safe nested access +## Formatting -// Async control -const d = new Deferred(); // external resolve/reject -d.resolve(value); +ESLint enforces all of the following (`eslint.config.js`), so `bun run lint` is the authority. Listed here to get it right the first time: -// Memoization with LRU -const fn = memoize(asyncFn, { ttl: 60000, maxSize: 100 }); -fn.cache.clear(); +| Rule | Setting | +|------|---------| +| `indent` | 4 spaces | +| `quotes` | single, `avoidEscape` | +| `semi` | always | +| `brace-style` | stroustrup (`else` on a new line) | +| `padded-blocks` | always (blank line after `{`, before `}`) | +| `padding-line-between-statements` | blank line before every `return` | +| `comma-dangle` | always on multiline | +| `object-curly-spacing` | always | +| `array-bracket-spacing` | never | +| `max-len` | 150, strings and URLs exempt | +| `unused-imports/no-unused-vars` | `^_` prefix exempts a binding | +| `no-multiple-empty-lines` | max 2 | -// Assertions -assert(condition, 'message', CustomError); -``` +Import order is **not** enforced and not consistently followed in `src/`. Match the file you are editing. -## Class Patterns +## Classes -Use private fields with `#` prefix. +Use `#` private fields. 30 files do; zero use the TypeScript `private` keyword on a field, because `private` is erased at compile time while `#` is enforced at runtime. ```typescript export class StateManager { @@ -288,13 +173,14 @@ export class StateManager { } ``` +`protected override` on a method is the one exception, and only when overriding a third-party base class member that `#` cannot express (`src/core/worker-bridge/bridge.ts:61`). + + +## JSDoc -## JSDoc Requirements +Every exported function and class needs a JSDoc block. Explain WHY, not what or how, and include a usage example. -- All functions and classes MUST have JSDoc -- Explain WHY, not what or how -- Include usage examples -- Comment ambiguous validation logic +Current coverage: 99.5% of exported functions, 81% of exported classes. The gap is concentrated in `src/sdk/namespaces/*.ts`, which is public SDK surface; add the block when you touch one. ```typescript /** diff --git a/CLAUDE.md b/CLAUDE.md index a50231ab..2b67db0e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,10 +84,10 @@ Path-specific rules are in `.claude/rules/`: | File | Applies To | Covers | |------|------------|--------| -| `typescript.md` | `**/*.{js,jsx,ts,tsx}` | 4-block function structure, error handling (no try-catch), imports, code style | -| `tui-development.md` | `src/tui/**`, `tests/tui/**` | Focus system, UI patterns, Ink layout, observer hooks | -| `testing.md` | `tests/**/*.{ts,tsx}` | Test naming, coverage, error assertions | -| `documentation.md` | `docs/**/*.md` | Three-pillar structure, style, tone | +| `typescript.md` | `**/*.{js,jsx,ts,tsx}` | 4-block function structure, error handling (no try-catch), classes, JSDoc | +| `tui-development.md` | `src/tui/**`, `tests/cli/**` | Focus system, keyboard handling, non-reactive terminal size, observer hooks | +| `testing.md` | `tests/**/*.{ts,tsx}` | Docker preflight, database safety, naming, error assertions | +| `documentation.md` | `docs/**/*.md` | Per-file voice assignment, claims discipline | ## Help System @@ -120,7 +120,6 @@ Use Kysely as the SQL translator. Write database operations once, Kysely handles For setup wizards where the target database may not exist yet, use `testConnection(config, { testServerOnly: true })`. This connects to the dialect's system database (postgres→`postgres`, mssql→`master`, mysql→no database) to verify credentials without requiring the target database. -<<<<<<< HEAD ## Documentation surfaces | Path | Covers | Voice | @@ -201,8 +200,7 @@ For setup wizards where the target database may not exist yet, use `testConnecti | `docs/reference/sdk.md` | SDK API reference, withSchema, impersonation, routines | terse-technical | | `packages/sdk/README.md` | npm SDK readme, install, usage, schema scoping | terse-technical | -======= ->>>>>>> origin/master + ## Project signals (auto-loaded) diff --git a/bun.lockb b/bun.lockb index 3eee0052..d232cbde 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/dev/config.md b/docs/dev/config.md index 2013d3b2..34cca819 100644 --- a/docs/dev/config.md +++ b/docs/dev/config.md @@ -55,6 +55,7 @@ interface Config { ssl?: boolean | SSLConfig pool?: { min?: number, max?: number } // Defaults to { min: 0, max: 10 } tlsServerName?: string // MSSQL only — cert hostname when host is an IP + connectTimeoutMs?: number // Per attempt, defaults to 15000 } identity?: string // Override audit identity @@ -65,6 +66,13 @@ File locations (`paths.sql`, `paths.changes`) are **not** part of `Config` — they live in `settings.yml` and are read through `SettingsManager.getPaths()`. See [Settings](./settings.md). +`connectTimeoutMs` bounds one connection attempt, and is passed to whichever +option the dialect's driver exposes for it — `connectionTimeoutMillis` on `pg`, +`connectTimeout` on `mysql2` and `tedious`. It exists because `pg` defaults to +no limit, so an unreachable host waited forever. Raise it when the link is slow +but working; a serverless database resuming from auto-pause is the case that +needs it. + ## Environment Variables diff --git a/docs/dev/ink-cheatsheet.md b/docs/dev/ink-cheatsheet.md index b2a3529b..e87c66b4 100644 --- a/docs/dev/ink-cheatsheet.md +++ b/docs/dev/ink-cheatsheet.md @@ -3,13 +3,26 @@ A reference for building CLI applications with Ink (React for the terminal). -This page documents the upstream Ink API, not noorm's own TUI. Where the two diverge, noorm's rules win—see `.claude/rules/tui-development.md`. The divergences are called out inline below. Installed here: `ink@^6.8.0`, `react@^19.2.4`, `@inkjs/ui@^2.0.0`. +This page documents the upstream Ink API, not noorm's own TUI. Where the two diverge, noorm's rules win—see `.claude/rules/tui-development.md`. The divergences are called out inline below. Installed here: `ink@^7.1.1`, `react@^19.2.4`, `@inkjs/ui@^2.0.0`. + +The body describes Ink 7.1.1 (released 2026-07-16), which is the installed version. Everything here is available to you. Two markers record *when* a thing arrived, which is what you need when reading noorm code written against Ink 6: + +| Marker | Meaning | +|--------|---------| +| **[Ink 7]** | Arrived in Ink 7.x. Ink 6.8.0 had no such export. | +| **[Changed in 7]** | Existed in Ink 6.8.0 as well, but behaved differently there. See [Migrating from Ink 6 to Ink 7](#migrating-from-ink-6-to-ink-7). | + +Find every one of them with `rg '\[Ink 7\]|\[Changed in 7\]' docs/dev/ink-cheatsheet.md`. + +noorm went from 6.8.0 to 7.1.1 with no peer bumps. Ink 7 requires Node >=22 and React >=19.2; `engines.node >= 22.13` and `react@^19.2.4` already met both. [Migrating from Ink 6 to Ink 7](#migrating-from-ink-6-to-ink-7) walks each breaking change and what it cost here. ## Table of Contents - [Installation](#installation) +- [What Ink 7 Changes for noorm](#what-ink-7-changes-for-noorm) +- [Migrating from Ink 6 to Ink 7](#migrating-from-ink-6-to-ink-7) - [Core Concepts](#core-concepts) - [Components](#components) - [Hooks](#hooks) @@ -38,6 +51,224 @@ bun add @inkjs/ui This repo is Bun-managed (`bun.lockb`, `bunfig.toml`, `engines.bun >= 1.2`). Running `npm install` here creates a conflicting lockfile. +## What Ink 7 Changes for noorm + + +The upgrade closed two live bugs. Three more Ink 7 capabilities cover things this codebase still hand-rolls or lives without. + +Fixed by the upgrade: + +| noorm bug | What fixed it | Where | +|-----------|---------------|-------| +| Terminal resize never re-rendered | `useStdout` replaced by [`useWindowSize`](#usewindowsize) | `src/tui/screens/db/SqlTerminalScreen.tsx:51,55-63`
`src/tui/screens/config/ConfigEditScreen.tsx:49,262` | +| Filter-mode Backspace did nothing | The version bump alone; the existing guard became correct | `src/tui/components/terminal/ResultTable.tsx:471` | + +Still open: + +| noorm problem | Where it lives today | Ink 7 answer | +|---------------|----------------------|--------------| +| Multi-line paste submits a partial query | `src/tui/components/terminal/SqlInput.tsx:78,139-157` | [`usePaste`](#usepaste) | +| Exiting the TUI leaves its frames in scrollback | `src/cli/ui.ts:45`
`src/cli/sql/repl.ts:108` | [`alternateScreen`](#render-options) | +| Chrome heights are hand-counted constants | `src/tui/screens/db/SqlTerminalScreen.tsx:57`
`src/tui/screens/config/ConfigEditScreen.tsx:262` | [`useBoxMetrics`](#useboxmetrics), [`measureElement`](#measureelement) | + + +### Resize is reactive now + +Both screens used to read the stream from `useStdout()` and size themselves from `stdout.rows`: + +```tsx +const { stdout } = useStdout(); + +const maxResultRows = useMemo(() => { + + const terminalHeight = stdout.rows ?? 24; + // ... arithmetic + +}, [stdout.rows]); +``` + +Node updates `stdout.rows` when the terminal resizes, but nothing asks React to re-render. A dependency array is compared only during a render pass, so with no render there is no comparison, and `maxResultRows` held its stale value until some unrelated state change happened to re-render the screen. `ConfigEditScreen` had the same defect in a different shape: it read `stdout.rows` in the render body, which does pick up a new value on any render, but a resize by itself still produced no render. + +`useWindowSize()` subscribes to the resize event and re-renders the component, so the value is current by construction. `src/tui/screens/db/SqlTerminalScreen.tsx:51,55-63`: + +```tsx +const { rows: terminalHeight } = useWindowSize(); // [Ink 7] + +const maxResultRows = useMemo(() => { + + const uiChrome = 9; + const availableHeight = terminalHeight - uiChrome; + const maxRows = Math.floor(availableHeight * 0.75); + + return Math.max(5, Math.min(maxRows, 30)); + +}, [terminalHeight]); +``` + +`src/tui/screens/config/ConfigEditScreen.tsx:49,262` does the same for `formHeight`. Its hook has to stay above the screen's early returns, because those returns run before the config finishes loading and a hook called after them changes the hook count between renders. + +The `?? 24` fallback both sites carried is gone: `useWindowSize` returns `columns` and `rows` as plain numbers, falling back to the `terminal-size` probe and then to 80x24 when the stream reports nothing. + + +### Pasted SQL submits before it is complete + +Nothing in `src/` enables bracketed paste, and `src/tui/components/terminal/SqlInput.tsx:78` reads keys through `useInput`. Without bracketed paste the terminal gives Ink no way to distinguish pasted text from typed text, so a newline inside a paste is indistinguishable from pressing Enter. `SqlInput.tsx:139-157` routes Enter like this: + +```tsx +if (key.return) { + + if (key.shift || editMode) { /* insert a newline */ } + else if (value.trim()) { onSubmit(value); } + + return; + +} +``` + +Outside edit mode, the first newline in a pasted multi-line statement calls `onSubmit(value)` with only the first line. + +`usePaste` turns bracketed paste mode on while the hook is mounted and delivers the paste as one string. Ink routes paste and keypresses on separate channels, so pasted content never reaches the `useInput` handler while `usePaste` is active: + +```tsx +usePaste((text) => { // [Ink 7] + + const before = value.slice(0, cursor); + const after = value.slice(cursor); + + updateValue(before + text + after, cursor + text.length); + +}); +``` + + +### The TUI has no alternate screen + +Both entry points render onto the primary screen: `src/cli/ui.ts:45` and `src/cli/sql/repl.ts:108`, each with `{ exitOnCtrlC: false, patchConsole: true }`. On `app:exit` they call `clear()` then `unmount()`, which erases the last frame but leaves earlier output in the user's scrollback. + +`alternateScreen: true` renders into the terminal's alternate buffer, the mechanism vim and less use, and restores the previous terminal contents on exit. + +```tsx +render(, { + exitOnCtrlC: false, + patchConsole: true, + alternateScreen: true, // [Ink 7] +}); +``` + +::: warning Two constraints before adopting it +Scrollback is unavailable while the alternate screen is active, which is standard terminal behavior but changes how the log viewer overlay feels. Ink also treats alternate-screen teardown output as disposable: frames, hook writes, and `console.*` output produced after unmount begins are not replayed onto the restored screen. Anything the user must still see after exit has to be written after the Ink instance is gone. +::: + + +### Chrome heights are hand-counted + +Neither `measureElement` nor `useBoxMetrics` is used anywhere in `src/`. Both height budgets are maintained by hand and drift whenever the chrome changes: + +- `SqlTerminalScreen.tsx:57` — `const uiChrome = 9;`, with a comment enumerating header (2), panel border (2), status bar (1), separator (1), footer (2), help (1). +- `ConfigEditScreen.tsx:262` — `Math.max(terminalHeight - 6, 10)`, reserving panel border (2), title (2), padding (2). + +`useBoxMetrics(ref)` reports a real box's measured `width`, `height`, `left`, and `top`, and re-reports on layout change, so the number comes from the rendered tree instead of a comment. Ink 7.1.1's `measureElement` also returns `x` and `y` now, not just `width` and `height`. + + +### Filter-mode Backspace works now + +`src/tui/components/terminal/ResultTable.tsx:471` checks only `key.backspace`: + +```tsx +if (key.backspace) { + + setFilter((f) => ({ ...f, term: f.term.slice(0, -1) })); + + return; + +} +``` + +Most terminals send byte `0x7F` for the Backspace key. Ink 6.8.0 reported that as `key.delete`, so this branch never fired and the filter term could not be edited. Ink 7 reports it as `key.backspace`, so the bump alone made the existing code correct — no edit to this file. `tests/cli/components/terminal.test.tsx` pins the behavior: it fails on 6.8.0 and passes on 7.1.1. + +The other two Backspace handlers, `SqlInput.tsx:172` and `LogViewerOverlay.tsx:197`, check `key.backspace || key.delete`. That was required on 6.8.0 and is now merely harmless: it makes the real Delete key erase backwards too. New code should test `key.backspace` alone. + + +## Migrating from Ink 6 to Ink 7 + + +Ink 7.0.0 has four breaking changes. All four cost noorm nothing; this is the record of why. + +| Change | Outcome here | +|--------|--------------| +| Requires Node >=22 | Already met — `engines.node >= 22.13` | +| Requires React >=19.2 (Ink uses `useEffectEvent` internally) | Already met — `react@^19.2.4`, `@types/react@^19.2.14` | +| Backspace sets `key.backspace`, not `key.delete` | One site fixed itself (`ResultTable.tsx:471`), two were already safe | +| `key.meta` is no longer `true` on plain Escape | No effect — see below | + + +### `key.backspace` vs `key.delete` + +Most terminals send the same byte for Backspace as for Delete, and Ink 6 misreported it. Ink 7 separates them. + +```tsx +// Before (Ink 6) — the physical Backspace key arrived as key.delete +useInput((input, key) => { + if (key.delete) { /* fired for physical Backspace */ } +}); + +// After (Ink 7) +useInput((input, key) => { + if (key.backspace) { /* physical Backspace (0x7F) */ } + if (key.delete) { /* the real Delete key, e.g. Fn+Backspace */ } +}); +``` + +`SqlInput.tsx:172` and `LogViewerOverlay.tsx:197` check both flags, which is why they survived the upgrade untouched. Now that 7.1.1 is the installed version, new code should test `key.backspace` for Backspace and leave `key.delete` to the Delete key. + + +### `key.meta` on plain Escape + +Ink 6 set `key.meta` to `true` for a plain Escape as well as for Alt/Meta combinations. Ink 7 reserves `key.meta` for actual modifier combinations. + +```tsx +// Before (Ink 6) — key.meta was true for Escape AND for Alt+key +useInput((input, key) => { + if (key.meta) { /* also fired on plain Escape */ } +}); + +// After (Ink 7) — test key.escape for Escape +useInput((input, key) => { + if (key.escape) { /* plain Escape */ } + if (key.meta) { /* Alt/Meta combinations only */ } +}); +``` + +noorm has two `!key.meta` guards on character input, `ResultTable.tsx:480` and `LogViewerOverlay.tsx:208`, both testing `input && !key.ctrl && !key.meta`. Neither depends on the old behavior: Ink strips the leading escape byte before it reaches the handler, so a plain Escape arrives with `input` empty and the `input &&` test already rejects it. The `!key.meta` clause keeps doing its intended job of filtering Alt combinations. + + +### One change the release notes omit + +Ink 6.8.0's `textWrap` type accepted two values the 6.8.0 readme never documented: `wrap="end"` and `wrap="middle"`. Neither did anything. Ink 6's `wrapText` matches only the `wrap` branch and values starting with `truncate`, so both fell through and returned the text unmodified. Running 6.8.0's `wrapText('hello world', 5, mode)` gives `"hello world"` for `end` and `middle`, against `"hell…"` for `truncate-end`. + +Ink 7.0.0 removed both from the type. The release notes do not mention it. + +If you find one in a codebase, deleting the prop preserves what the screen renders today. Rewriting it to `truncate-end` or `truncate-middle` does not: it replaces a no-op with real truncation. noorm has neither, using only `wrap="wrap"` and `wrap="truncate"`. + + +### Fixes carried by the 7.0.x and 7.1.x line + +Upgrading picks these up along with the new API. + +| Version | Fix | Bearing on noorm | +|---------|-----|------------------| +| 7.0.0 | Wide characters (emoji, CJK) no longer split on overlapping writes; CJK text no longer truncates past `` width | Result grids render arbitrary column data, so this is the highest-value fix here | +| 7.0.0 | `useInput` no longer crashes on unmapped key codes | Every screen registers a `useInput` handler | +| 7.0.0 | Incremental rendering handles a trailing newline | Only with `incrementalRendering: true`, which noorm does not set | +| 7.0.1 | `disableFocus()` is respected when handling Escape; `useApp` exit typing restored | Ink's own focus registry, which noorm does not use | +| 7.0.2 | Raw-mode disable is deferred, preventing a process hang on component swap | Both entry points unmount and then `process.exit(0)` | +| 7.0.0, 7.0.3, 7.1.1 | Four `` correctness fixes | None. `` is unused in `src/` | +| 7.0.3 | `useBoxMetrics` accepts refs whose initial value is `null` | Applies to the standard `useRef(null)` form | +| 7.0.4 | One shared resize listener via `emitLayoutListeners` instead of one per hook | Matters once `useWindowSize` is used on several screens | +| 7.0.5 | Incomplete stack frames handled in the error overview | Error display only | +| 7.0.6 | Stale frames on Windows when output exactly fills the terminal | Windows only | + + ## Core Concepts @@ -73,6 +304,41 @@ instance.rerender(); // Re-render with new component await instance.waitUntilExit(); // Wait for app to exit ``` +The second argument may also be a plain `WriteStream` instead of an options object. + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `stdout` | `WriteStream` | `process.stdout` | Output stream | +| `stdin` | `ReadStream` | `process.stdin` | Input stream | +| `stderr` | `WriteStream` | `process.stderr` | Error stream | +| `exitOnCtrlC` | `boolean` | `true` | Listen for Ctrl+C and exit | +| `patchConsole` | `boolean` | `true` | Keep `console.*` output from mixing into Ink's | +| `debug` | `boolean` | `false` | Write each update as separate output instead of replacing | +| `maxFps` | `number` | `30` | Ceiling on render updates per second | +| `incrementalRendering` | `boolean` | `false` | Redraw only changed lines | +| `concurrent` | `boolean` | `false` | React concurrent mode: Suspense, `useTransition`, `useDeferredValue` | +| `onRender` | `(metrics) => void` | — | Runs after each committed frame with `{ renderTime }` | +| `isScreenReaderEnabled` | `boolean` | `INK_SCREEN_READER === 'true'` | Screen reader support | +| `kittyKeyboard` | `KittyKeyboardOptions` | — | Kitty protocol config: `{ mode, flags }` | +| `interactive` | `boolean` | auto | **[Ink 7]** Override interactive-mode detection | +| `alternateScreen` | `boolean` | `false` | **[Ink 7]** Render into the terminal's alternate screen buffer | + +`interactive` defaults to `true`, or `false` when running in CI or when `stdout.isTTY` is falsy. Non-interactive mode disables ANSI erase sequences, cursor manipulation, synchronized output, resize handling, and Kitty auto-detection, writing only the final frame at unmount. `alternateScreen` is ignored whenever the session is non-interactive. + +Reusing one `stdout` across several `render()` calls without unmounting is unsupported. Call `unmount()` first, or use `cleanup()`. + + +#### Instance Methods + +| Method | Description | +|--------|-------------| +| `rerender(node)` | Replace the root node or update its props | +| `unmount()` | Unmount the app | +| `clear()` | Clear the output | +| `waitUntilExit()` | Promise settling when the app unmounts; resolves with the value passed to `exit(value)`, rejects with the error passed to `exit(error)` | +| `waitUntilRenderFlush()` | **[Ink 7]** Promise settling once pending output is flushed to stdout | +| `cleanup()` | **[Changed in 7]** Drop the internal instance for this stdout so the next `render()` builds a fresh one. Ink 7 also unmounts the current app first, leaving no terminal state such as the alternate screen behind; 6.8.0 only removes the registry entry and never unmounts | + ### Exit Programmatically @@ -132,17 +398,27 @@ Layout props only. `borderStyle`, `borderColor`, the individual `borderTop`/`bor | `flexDirection` | `row` \| `column` \| `row-reverse` \| `column-reverse` | Direction of flex items | | `flexGrow` | `number` | Grow factor | | `flexShrink` | `number` | Shrink factor | +| `flexBasis` | `number` \| `string` | Initial size before free space is distributed | | `flexWrap` | `wrap` \| `nowrap` \| `wrap-reverse` | Wrap behavior | -| `justifyContent` | `flex-start` \| `flex-end` \| `center` \| `space-between` \| `space-around` | Main axis alignment | -| `alignItems` | `flex-start` \| `flex-end` \| `center` \| `stretch` | Cross axis alignment | -| `alignSelf` | `flex-start` \| `flex-end` \| `center` \| `auto` | Self alignment | +| `justifyContent` | `flex-start` \| `flex-end` \| `center` \| `space-between` \| `space-around` \| `space-evenly` | Main axis alignment | +| `alignItems` | `flex-start` \| `flex-end` \| `center` \| `stretch` \| `baseline` | Cross axis alignment. `baseline` is **[Ink 7]** | +| `alignSelf` | `flex-start` \| `flex-end` \| `center` \| `auto` \| `stretch` \| `baseline` | Self alignment. `stretch` and `baseline` are **[Ink 7]** | +| `alignContent` | `flex-start` \| `flex-end` \| `center` \| `stretch` \| `space-between` \| `space-around` \| `space-evenly` | **[Ink 7]** Cross axis alignment across wrapped lines | | `gap` | `number` | Gap between children | | `rowGap` | `number` | Gap between rows | | `columnGap` | `number` | Gap between columns | | `width` | `number` \| `string` | Width (number or percentage) | | `height` | `number` \| `string` | Height (number or percentage) | -| `minWidth` | `number` | Minimum width | -| `minHeight` | `number` | Minimum height | +| `minWidth` | `number` \| `string` | Minimum width. Percentages unsupported | +| `minHeight` | `number` \| `string` | Minimum height (rows or percentage) | +| `maxWidth` | `number` \| `string` | **[Ink 7]** Maximum width. Percentages unsupported | +| `maxHeight` | `number` \| `string` | **[Ink 7]** Maximum height (rows or percentage) | +| `aspectRatio` | `number` | **[Ink 7]** Width/height ratio. Needs at least one size constraint so Ink can derive the other dimension | +| `position` | `relative` \| `absolute` \| `static` | Positioning mode, default `relative`. `static` is **[Ink 7]** and ignores the offsets below | +| `top` / `right` / `bottom` / `left` | `number` \| `string` | **[Ink 7]** Offsets for positioned elements | +| `display` | `flex` \| `none` | `none` hides the element | +| `overflow` | `visible` \| `hidden` | Overflow in both directions, default `visible` | +| `overflowX` / `overflowY` | `visible` \| `hidden` | Overflow per axis | | `padding` | `number` | Padding all sides | | `paddingX` | `number` | Horizontal padding | | `paddingY` | `number` | Vertical padding | @@ -194,9 +470,28 @@ Layout props only. `borderStyle`, `borderColor`, the individual `borderTop`/`bor }}> Custom + +// Border background, independent of the box background + + Border painted on blue + ``` +#### Border Props Reference + +| Prop | Type | Description | +|------|------|-------------| +| `borderStyle` | `keyof Boxes` \| `BoxStyle` | Named style or a custom character set. No border when unset | +| `borderTop` / `borderBottom` / `borderLeft` / `borderRight` | `boolean` | Per-side visibility, each defaulting to `true` | +| `borderColor` | `string` | Shorthand for all four per-side colors | +| `borderTopColor` / `borderBottomColor` / `borderLeftColor` / `borderRightColor` | `string` | Per-side color | +| `borderDimColor` | `boolean` | Shorthand for all four per-side dim flags, default `false` | +| `borderTopDimColor` / `borderBottomDimColor` / `borderLeftDimColor` / `borderRightDimColor` | `boolean` | Per-side dim | +| `borderBackgroundColor` | `string` | **[Ink 7]** Shorthand for all four per-side border backgrounds | +| `borderTopBackgroundColor` / `borderBottomBackgroundColor` / `borderLeftBackgroundColor` / `borderRightBackgroundColor` | `string` | **[Ink 7]** Per-side border background | + + #### Background Colors ```tsx @@ -234,8 +529,12 @@ import { Text } from "ink"; // Text wrapping Long text will be truncated... +Truncates at the start... Truncates in the middle... Truncates at the end... + +// [Ink 7] Fill every line to the full column width, breaking words as needed +Long text broken mid-word to fill each line... ``` @@ -251,7 +550,11 @@ import { Text } from "ink"; | `strikethrough` | `boolean` | Strikethrough text | | `dimColor` | `boolean` | Dimmed color | | `inverse` | `boolean` | Inverse colors | -| `wrap` | `wrap` \| `truncate` \| `truncate-middle` \| `truncate-end` | Wrap behavior | +| `wrap` | `wrap` \| `hard` \| `truncate` \| `truncate-start` \| `truncate-middle` \| `truncate-end` | Wrap behavior, default `wrap`. `hard` is **[Ink 7]** | + +::: warning Two dead wrap values were removed in Ink 7 +Ink 6.8.0's type accepted `wrap="end"` and `wrap="middle"`, both undocumented and both no-ops that returned the text unchanged. Ink 7.0.0 dropped them from the type. Delete the prop to keep current rendering; swapping in `truncate-end` or `truncate-middle` starts truncating text that was never truncated before. See [One change the release notes omit](#one-change-the-release-notes-omit). noorm uses neither. +::: ### Newline @@ -381,7 +684,8 @@ function App() { } if (key.meta) { - // Alt/Option key held + // [Changed in 7] Alt/Option combinations only. + // Ink 6 also set this to true on a plain Escape. } if (key.shift) { @@ -389,15 +693,21 @@ function App() { } // Other special keys - if (key.backspace) { } - if (key.delete) { } + if (key.backspace) { } // [Changed in 7] the physical Backspace key (0x7F) + if (key.delete) { } // [Changed in 7] the real Delete key, e.g. Fn+Backspace if (key.tab) { } if (key.pageUp) { } if (key.pageDown) { } + if (key.home) { } + if (key.end) { } }); } ``` +::: warning Backspace and Escape changed in Ink 7 +On Ink 6.8.0 the Backspace key sets `key.delete`, and a plain Escape sets both `key.escape` and `key.meta`. Both are fixed in Ink 7. Checking `key.backspace || key.delete` works on either version. See [Migrating from Ink 6 to Ink 7](#migrating-from-ink-6-to-ink-7). +::: + #### Key Object Reference @@ -411,12 +721,23 @@ function App() { | `escape` | `boolean` | Escape pressed | | `ctrl` | `boolean` | Ctrl held | | `shift` | `boolean` | Shift held | -| `meta` | `boolean` | Alt/Option held | +| `meta` | `boolean` | Alt/Option held. **[Changed in 7]** no longer `true` on a plain Escape | | `tab` | `boolean` | Tab pressed | -| `backspace` | `boolean` | Backspace pressed | -| `delete` | `boolean` | Delete pressed | +| `backspace` | `boolean` | Backspace pressed. **[Changed in 7]** Ink 6 reported this key as `delete` | +| `delete` | `boolean` | Delete pressed. **[Changed in 7]** now the real Delete key only | | `pageUp` | `boolean` | Page Up pressed | | `pageDown` | `boolean` | Page Down pressed | +| `home` | `boolean` | Home pressed | +| `end` | `boolean` | End pressed | +| `super` | `boolean` | Cmd/Win held. Kitty protocol only | +| `hyper` | `boolean` | Hyper held. Kitty protocol only | +| `capsLock` | `boolean` | Caps Lock active. Kitty protocol only | +| `numLock` | `boolean` | Num Lock active. Kitty protocol only | +| `eventType` | `'press' \| 'repeat' \| 'release'` | Key event type. Kitty protocol only | + +::: tip Kitty protocol detection widened in Ink 7 +**[Changed in 7]** In `auto` mode Ink now queries every terminal for Kitty keyboard protocol support instead of consulting a hardcoded allowlist, so the `super`, `hyper`, `capsLock`, `numLock`, and `eventType` fields populate in more terminals than they did on 6.8.0. Configure it with the `kittyKeyboard` render option. +::: #### Conditional Input @@ -429,6 +750,23 @@ useInput( ); ``` +::: danger noorm guards inside the handler instead +The "Focus System" section of `.claude/rules/tui-development.md` requires the opposite of the upstream pattern above. In `src/tui/`, check `isFocused` inside the handler body, not through the `isActive` option: + +```tsx +const { isFocused } = useFocusScope('my-component'); + +useInput((input, key) => { + + if (!isFocused) return; + // handle input + +}); +``` + +`isActive: false` prevents the handler from registering at all, and `isFocused` is false on the first render because noorm's focus stack initializes in a `useEffect`. Registering unconditionally and returning early is what keeps the component reachable. Ink 7 does not change this; the divergence stands. +::: + ### useFocus @@ -477,6 +815,7 @@ function App() { focus, // Focus specific ID enableFocus, // Enable focus system disableFocus, // Disable focus system + activeId, // [Ink 7] ID of the focused component, or undefined } = useFocusManager(); useInput((input, key) => { @@ -508,6 +847,37 @@ function App() { } ``` +`exit(value)` resolves `waitUntilExit()` with `value`; `exit(error)` rejects it. + +Ink 7 adds two more members to the same object. + +```tsx +const { exit, waitUntilRenderFlush, suspendTerminal } = useApp(); + +// [Ink 7] Wait for the pending frame to reach stdout +await waitUntilRenderFlush(); +``` + + +#### suspendTerminal + +**[Ink 7]** Added in 7.1.0. Hands the terminal to a child process such as `$EDITOR`, `less`, or `fzf`, then restores Ink's terminal state and forces a full redraw. The callback form restores the terminal even when the callback throws. + +```tsx +const { suspendTerminal } = useApp(); + +// Callback form - preferred +await suspendTerminal(async () => { + await runEditor(); +}); + +// Handle form - resume yourself, or let `await using` do it on scope exit +await using suspension = await suspendTerminal(); +await runEditor(); +``` + +Called without a callback it returns a `TerminalSuspension`: `{ resume(), [Symbol.asyncDispose]() }`. + ### useStdin @@ -544,6 +914,12 @@ function App() { } ``` +::: danger `stdout.rows` does not trigger re-renders +Node mutates `stdout.rows` and `stdout.columns` when the terminal resizes, but that mutation asks React for nothing. A component sized from `stdout.rows` keeps its old layout until an unrelated state change re-renders it, and a `[stdout.rows]` dependency array is never even compared in the meantime. + +Use [`useWindowSize`](#usewindowsize) **[Ink 7]** for dimensions. Reserve `useStdout` for `write()`. The two noorm screens that had this bug were converted on the 7.1.1 upgrade: see [Resize is reactive now](#resize-is-reactive-now). +::: + ### useStderr @@ -560,6 +936,114 @@ function App() { ``` +### usePaste + +**[Ink 7]** Handle clipboard pastes as a single string. + +```tsx +import { useInput, usePaste } from "ink"; + +function Editor() { + useInput((input, key) => { + // Typed characters and key events only, never pasted text + if (key.return) { /* submit */ } + }); + + usePaste((text) => { + // The whole pasted string, newlines included + insert(text); + }); +} + +// Disable when another component should own pastes +usePaste(handler, { isActive: isFocused }); +``` + +| Parameter | Type | Description | +|-----------|------|-------------| +| `handler` | `(text: string) => void` | Called once per paste with the full string | +| `options.isActive` | `boolean` | Enable or disable the handler, default `true` | + +While the hook is mounted, Ink turns on bracketed paste mode (`\x1b[?2004h`), so the terminal frames pasted text and Ink stops guessing. `usePaste` and `useInput` compose in the same component because they run on separate channels: with `usePaste` active, paste content never reaches `useInput`. + + +### useWindowSize + +**[Ink 7]** Terminal dimensions that re-render on resize. + +```tsx +import { useWindowSize, Box, Text } from "ink"; + +function App() { + const { columns, rows } = useWindowSize(); + + return ( + + {columns}x{rows} + + ); +} +``` + +Returns `{ columns, rows }` and re-renders the component whenever the terminal resizes. This is the correct source for terminal dimensions; `useStdout().stdout.rows` is not reactive. + + +### useBoxMetrics + +**[Ink 7]** Track a box's measured layout, updating as the layout changes. + +```tsx +import { useRef } from "react"; +import { Box, Text, useBoxMetrics } from "ink"; + +function Example() { + const ref = useRef(null); + const { width, height, left, top, hasMeasured } = useBoxMetrics(ref); + + return ( + + + {hasMeasured ? `${width}x${height} at ${left},${top}` : "Measuring..."} + + + ); +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `width` / `height` | `number` | Measured size | +| `left` / `top` | `number` | Offset from the parent's edges | +| `hasMeasured` | `boolean` | Whether the tracked element was measured in the latest layout pass | + +Positions are relative to the parent. The hook returns zeros before the first layout pass and whenever the ref is detached, which is what `hasMeasured` distinguishes from a genuine zero. It re-runs on terminal resize, sibling and content changes, and position changes, so unlike [`measureElement`](#measureelement) it needs no effect to stay current. + + +### useAnimation + +**[Ink 7]** Drive frame-based animation without your own timer. + +```tsx +import { Text, useAnimation } from "ink"; + +function Spinner() { + const { frame } = useAnimation({ interval: 80 }); + const characters = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + + return {characters[frame % characters.length]}; +} +``` + +| Field | Type | Description | +|-------|------|-------------| +| `frame` | `number` | Counter incrementing by 1 each interval. Use for indexed sequences | +| `time` | `number` | Milliseconds since the animation started or last reset. Use for continuous math | +| `delta` | `number` | Milliseconds since the previous rendered tick, accounting for throttled renders. Use for velocity-driven motion | +| `reset` | `() => void` | Reset `frame`, `time`, and `delta` to `0` and restart timing | + +Options are `interval` (default `100` ms) and `isActive` (default `true`). Setting `isActive` back to `true` after pausing resets all values to `0`. Every `useAnimation` in the tree shares one internal timer, so several animated components collapse into a single render cycle. + + ### measureElement Measure rendered element dimensions. @@ -574,7 +1058,8 @@ function App() { useEffect(() => { if (ref.current) { - const { width, height } = measureElement(ref.current); + // [Changed in 7] 7.1.1 also returns x and y + const { x, y, width, height } = measureElement(ref.current); setDimensions({ width, height }); } }, []); @@ -587,6 +1072,15 @@ function App() { } ``` +| Field | Type | Description | +|-------|------|-------------| +| `width` / `height` | `number` | Measured size | +| `x` / `y` | `number` | **[Ink 7]** 0-based column and row within the live layout region, added in 7.1.1 | + +`x` and `y` are layout-tree coordinates, accumulated by walking up each ancestor's offset. They are not terminal viewport coordinates, so comparing them against mouse events means converting through the live region's viewport position. That holds in alternate-screen mode too, whenever output such as `` content sits above the live region. + +`measureElement` returns zeros when called during render, before layout runs. Call it from `useEffect`, `useLayoutEffect`, an input handler, or a timer, and pass the changing content as a dependency so it re-measures. [`useBoxMetrics`](#useboxmetrics) does that bookkeeping for you. + ## Ink UI Components @@ -1384,12 +1878,14 @@ useEffect(() => { ### Focus Management -Only one component should handle input at a time. Use `isActive` option: +Only one component should handle input at a time. Upstream does that with the `isActive` option: ```tsx useInput(handler, { isActive: isFocused }); ``` +In noorm, guard inside the handler instead. See [Conditional Input](#conditional-input). + ### Testing diff --git a/docs/dev/ink-testing-library-cheatsheet.md b/docs/dev/ink-testing-library-cheatsheet.md index 1efd2cba..5b871971 100644 --- a/docs/dev/ink-testing-library-cheatsheet.md +++ b/docs/dev/ink-testing-library-cheatsheet.md @@ -380,9 +380,13 @@ describe("NavigableMenu", () => { | Delete | `\x1B[3~` | | Ctrl+C | `\x03` | +On `ink@7.1.1` (installed here) `\x7F` sets `key.backspace` and `\x1B[3~` sets `key.delete`. Ink 6.8.0 reported *both* as `key.delete`, so a component guarding on `key.backspace` never fired for `\x7F` and the backspace example below could not have passed. Verified by feeding each sequence through `useInput` on the installed build. + ### Testing Character Input +Every write needs an `await` around it. Ink registers the `useInput` handler in a `useEffect`, so input written on the render tick lands before any handler exists, and React needs a tick after the keystroke to flush the new frame into `lastFrame()`. Without the waits both assertions below read `"Text:"`. + ```tsx function TextCollector() { @@ -407,33 +411,40 @@ function TextCollector() { return Text: {text}; } +const tick = () => new Promise((r) => setTimeout(r, 50)); + describe("TextCollector", () => { - it("should collect typed characters", () => { + it("should collect typed characters", async () => { const { stdin, lastFrame } = render(); - stdin.write("h"); - stdin.write("e"); - stdin.write("l"); - stdin.write("l"); - stdin.write("o"); + await tick(); + stdin.write("hello"); + await tick(); expect(lastFrame()).toBe("Text: hello"); }); - it("should handle backspace", () => { + it("should handle backspace", async () => { const { stdin, lastFrame } = render(); + await tick(); stdin.write("hello"); + await tick(); stdin.write("\x7F"); // Backspace + await tick(); expect(lastFrame()).toBe("Text: hell"); }); }); ``` +A multi-character `stdin.write("hello")` arrives as one `input` string, not five events. `tests/cli/components/terminal.test.tsx` is the live version of this pattern. + +A fixed `tick()` is the smallest thing that works, not the most robust. For anything slower than a keystroke, poll until the condition holds instead of sleeping a guessed duration. `.claude/rules/tui-development.md` carries the helper and the measurements behind that advice. + ## Testing Async Components diff --git a/docs/dev/settings.md b/docs/dev/settings.md index 5c7497c7..4257f9ca 100644 --- a/docs/dev/settings.md +++ b/docs/dev/settings.md @@ -102,6 +102,10 @@ teardown: preserveTables: - AppSettings postScript: sql/teardown/cleanup.sql + +# Terminal UI behavior +ui: + mouse: false # default: true ``` @@ -526,6 +530,56 @@ Use cases: - **postScript** - Re-seed essential data, reset sequences, or run cleanup SQL after teardown +## UI Configuration + +Affects `noorm ui` only; headless commands ignore this section. + +```yaml +ui: + mouse: false +``` + +| Property | Default | Description | +|----------|---------|-------------| +| `mouse` | `true` | Answer clicks and wheel notches in lists 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 +`src/core/settings/defaults.ts`, and every reader goes through it: + +```typescript +export const DEFAULT_UI_MOUSE = true + +export function isMouseEnabled(settings) { + return settings?.ui?.mouse ?? DEFAULT_UI_MOUSE +} +``` + +`UiConfigSchema` takes the same constant for `mouse`'s zod default, so `ui: {}` +and no `ui` section at all mean the same thing. Only a written `false` disables +it. + +The TUI adds one condition on top, and it is about timing rather than meaning: + +```typescript +const { settings } = useSettings() + + +``` + +`null` is *not loaded yet*, not *absent*. Without that check, a project that +wrote `mouse: false` would spend every startup with tracking on and its text +selection broken, for a setting it explicitly wrote. So the enable sequence goes +out once the managers have finished loading rather than at `render()`. + +On by default because the feature is worth having, but it is a trade: any +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. +See `docs/tui.md` for what the mouse does. + + ## SettingsManager API The manager handles loading, saving, and accessing settings. diff --git a/docs/guide/database/terminal.md b/docs/guide/database/terminal.md index e31e5f70..e9e3aae8 100644 --- a/docs/guide/database/terminal.md +++ b/docs/guide/database/terminal.md @@ -110,6 +110,32 @@ OK (42ms) ``` +## Stopping a Query That Will Not Come Back + +`Escape` while a query is running gives the terminal back. What that does to the +database depends on the dialect, and the terminal says which one happened: + +| Dialect | On `Escape` | What you see | +|---------|-------------|--------------| +| PostgreSQL | `pg_cancel_backend` on a second connection | `Cancelled. The server was asked to stop the query.` | +| MySQL | `KILL QUERY` on a second connection | `Cancelled. The server was asked to stop the query.` | +| SQL Server | nothing reaches the server | `Stopped waiting. The query may still be running on the server.` | +| SQLite | nothing reaches the server | `Stopped waiting. The query may still be running on the server.` | + +The difference is not cosmetic. On the bottom two rows the query keeps running +to completion, holding locks and burning CPU, and the only thing that changed is +that noorm stopped listening for the answer. Kill it from the server side if it +matters. + +SQL Server is a limitation of the driver layer rather than of the database: +tedious exposes a per-request cancel, but Kysely's MSSQL dialect owns the +request object and never hands it out. SQLite has no second connection to +interrupt the first from. + +`Escape` also works while the terminal is still connecting, which is what a +network problem looks like before a query ever runs. + + ## Result Formatting Results display in a formatted table with column alignment: @@ -199,7 +225,7 @@ There are two ways to write across several lines. `Shift+Enter` inserts a newlin | `Tab` | Insert four spaces, or move between the query and the results table once a query has returned rows | | `Up` / `Down` | Navigate command history when the input is empty, otherwise move the cursor | | `Left` / `Right` | Move the cursor | -| `Escape` | Clear the input; on an empty input, leave the terminal | +| `Escape` | Stop a running query or a connect in progress; otherwise clear the input, and on an empty input leave the terminal | | `h` | Open the history viewer (empty input only) | diff --git a/docs/public/image/build-and-change.gif b/docs/public/image/build-and-change.gif index e81950c5..724d9caa 100644 Binary files a/docs/public/image/build-and-change.gif and b/docs/public/image/build-and-change.gif differ diff --git a/docs/public/image/tui-rows.gif b/docs/public/image/tui-rows.gif new file mode 100644 index 00000000..a3157130 Binary files /dev/null and b/docs/public/image/tui-rows.gif differ diff --git a/docs/public/image/tui.gif b/docs/public/image/tui.gif index 370afa75..60b5606e 100644 Binary files a/docs/public/image/tui.gif and b/docs/public/image/tui.gif differ diff --git a/docs/public/image/tui/change-history.png b/docs/public/image/tui/change-history.png index 1091c609..c1d4f9f9 100644 Binary files a/docs/public/image/tui/change-history.png and b/docs/public/image/tui/change-history.png differ diff --git a/docs/public/image/tui/changes-list.png b/docs/public/image/tui/changes-list.png index cd0d5630..c9d23153 100644 Binary files a/docs/public/image/tui/changes-list.png and b/docs/public/image/tui/changes-list.png differ diff --git a/docs/public/image/tui/config-add.png b/docs/public/image/tui/config-add.png index 9e6cfeb0..aedb4d9d 100644 Binary files a/docs/public/image/tui/config-add.png and b/docs/public/image/tui/config-add.png differ diff --git a/docs/public/image/tui/config-list.png b/docs/public/image/tui/config-list.png index b6762c69..eaf8d093 100644 Binary files a/docs/public/image/tui/config-list.png and b/docs/public/image/tui/config-list.png differ diff --git a/docs/public/image/tui/database-menu.png b/docs/public/image/tui/database-menu.png index 06282e20..ee8671b2 100644 Binary files a/docs/public/image/tui/database-menu.png and b/docs/public/image/tui/database-menu.png differ diff --git a/docs/public/image/tui/explore-overview.png b/docs/public/image/tui/explore-overview.png index 04ec7053..2a797068 100644 Binary files a/docs/public/image/tui/explore-overview.png and b/docs/public/image/tui/explore-overview.png differ diff --git a/docs/public/image/tui/explore-row-peek.png b/docs/public/image/tui/explore-row-peek.png new file mode 100644 index 00000000..91c9ec5a Binary files /dev/null and b/docs/public/image/tui/explore-row-peek.png differ diff --git a/docs/public/image/tui/explore-row-view.png b/docs/public/image/tui/explore-row-view.png new file mode 100644 index 00000000..35a57be2 Binary files /dev/null and b/docs/public/image/tui/explore-row-view.png differ diff --git a/docs/public/image/tui/explore-table-detail.png b/docs/public/image/tui/explore-table-detail.png index 4b304b0f..ad1e77be 100644 Binary files a/docs/public/image/tui/explore-table-detail.png and b/docs/public/image/tui/explore-table-detail.png differ diff --git a/docs/public/image/tui/explore-tables.png b/docs/public/image/tui/explore-tables.png index a6b58b4e..7fd3a53c 100644 Binary files a/docs/public/image/tui/explore-tables.png and b/docs/public/image/tui/explore-tables.png differ diff --git a/docs/public/image/tui/home.png b/docs/public/image/tui/home.png index f80db4c5..b1291141 100644 Binary files a/docs/public/image/tui/home.png and b/docs/public/image/tui/home.png differ diff --git a/docs/public/image/tui/identity.png b/docs/public/image/tui/identity.png index df35e95c..625c5f9e 100644 Binary files a/docs/public/image/tui/identity.png and b/docs/public/image/tui/identity.png differ diff --git a/docs/public/image/tui/lock.png b/docs/public/image/tui/lock.png index 37666fd2..ad1b6e45 100644 Binary files a/docs/public/image/tui/lock.png and b/docs/public/image/tui/lock.png differ diff --git a/docs/public/image/tui/log-viewer.png b/docs/public/image/tui/log-viewer.png index c9ea2f4b..7fc0feee 100644 Binary files a/docs/public/image/tui/log-viewer.png and b/docs/public/image/tui/log-viewer.png differ diff --git a/docs/public/image/tui/more-menu.png b/docs/public/image/tui/more-menu.png index 0a356844..8f81ede0 100644 Binary files a/docs/public/image/tui/more-menu.png and b/docs/public/image/tui/more-menu.png differ diff --git a/docs/public/image/tui/run-menu.png b/docs/public/image/tui/run-menu.png index fdb4a790..f8384921 100644 Binary files a/docs/public/image/tui/run-menu.png and b/docs/public/image/tui/run-menu.png differ diff --git a/docs/public/image/tui/secrets.png b/docs/public/image/tui/secrets.png index 7a164860..c816b22f 100644 Binary files a/docs/public/image/tui/secrets.png and b/docs/public/image/tui/secrets.png differ diff --git a/docs/public/image/tui/settings.png b/docs/public/image/tui/settings.png index 87cd5282..b184f4fa 100644 Binary files a/docs/public/image/tui/settings.png and b/docs/public/image/tui/settings.png differ diff --git a/docs/public/image/tui/sql-terminal.png b/docs/public/image/tui/sql-terminal.png index f55b9092..aad46a79 100644 Binary files a/docs/public/image/tui/sql-terminal.png and b/docs/public/image/tui/sql-terminal.png differ diff --git a/docs/public/image/tui/vault.png b/docs/public/image/tui/vault.png index 43276307..16fcf271 100644 Binary files a/docs/public/image/tui/vault.png and b/docs/public/image/tui/vault.png differ diff --git a/docs/tapes/03-tui.tape b/docs/tapes/03-tui.tape index fcf2f090..6550f038 100644 --- a/docs/tapes/03-tui.tape +++ b/docs/tapes/03-tui.tape @@ -4,17 +4,22 @@ # # Three things this tape has to respect that the CLI tapes do not: # -# 1. Size is the composition, not a crop. Ink lays out against the terminal -# it is handed and does not reflow afterwards. The add-config form is the -# tallest screen (10 fields rendered at once, not as steps) and sets the -# height for the whole recording. +# 1. Size is the whole frame, not a crop. The TUI renders into the alternate +# screen and draws to the full terminal height, so the canvas below is the +# shell: breadcrumb pinned to the top, status bar pinned to the bottom, and +# whatever the screen needs in between. A taller canvas is not more room +# for content, it is more empty room. # 2. Keys are not text. Single-key hotkeys need a Sleep after each one; Ink # repaints on its own schedule and a key sent mid-repaint is dropped. -# 3. In the form, Enter on a text field SUBMITS THE WHOLE FORM — it does not -# advance. Only Tab and the arrow keys move between fields. Enter is -# correct on a select (it confirms the option and advances) and on the -# final checkbox (it submits). Getting this wrong silently creates a -# half-filled config instead of failing loudly. +# 3. The form has two modes, and Enter is the switch between them. In browse +# mode ↑/↓ move between fields — every field type, selects included — and +# Enter opens the active field. In edit mode the field owns the keyboard, +# and Enter commits back to browse. Typing without opening the field first +# does not error: the characters land on the browse handler, which ignores +# them, and the recording shows an empty form being submitted. +# +# Submit is not a key any more. Past the last field the cursor lands on the +# [ Create Config ] / [ Cancel ] action row, and Enter there is what saves. # # Screen keys, from src/tui: # home [r] Run [c] Config [g] Change [d] DB [q] Quit @@ -27,8 +32,8 @@ Source theme.tape Output ../public/image/tui.gif -Set Width 1200 # ~111 cols -Set Height 1160 # ~49 lines, sized to the add-config form +Set Width 1200 # 111 cols +Set Height 700 # 32 lines — measured with `tput lines`, not derived Set FontSize 15 Set TypingSpeed 90ms @@ -57,62 +62,94 @@ Sleep 2s Type "a" Sleep 2500ms -# Config Name — Tab, never Enter. +# Config Name. Enter opens the field, Enter commits it, ↓ moves on. That is +# the loop for every field below; only the select and the checkbox differ. +Enter +Sleep 800ms Type "dev" Sleep 800ms -Tab -Sleep 1s +Enter +Sleep 800ms +Down +Sleep 800ms -# Database Type — PostgreSQL is already highlighted; Enter confirms + advances. +# Database Type. A select collapses to its current value until it is opened, +# so Enter expands the four dialects and a second Enter takes the highlighted +# one. PostgreSQL is already the value, so this is a confirmation, not a pick. Enter -Sleep 1200ms +Sleep 1500ms +Enter +Sleep 1000ms -# Host — "localhost" is prefilled, so Tab straight past it. -Tab -Sleep 800ms +# Host is prefilled with `localhost`, so pass over it. +Down +Sleep 700ms +Down +Sleep 700ms -# Port — the 5432 shown is a placeholder, not a value; the test container is +# Port. The 5432 shown is the placeholder, not a value; the test container is # on 15432 and leaving this blank yields a config that cannot connect. +Enter +Sleep 500ms Type "15432" Sleep 800ms -Tab +Enter +Sleep 700ms +Down Sleep 600ms +Enter +Sleep 500ms Type "noorm_demo" Sleep 800ms -Tab +Enter +Sleep 700ms +Down Sleep 600ms +Enter +Sleep 500ms Type "noorm_test" Sleep 800ms -Tab +Enter +Sleep 700ms +Down Sleep 600ms +Enter +Sleep 500ms Type "noorm_test" Sleep 800ms -Tab -Sleep 1s - -# User Role — Admin at the terminal. Enter -Sleep 1200ms +Sleep 900ms -# Agent Role — already Viewer: DEFAULT_ACCESS is { user: 'admin', agent: -# 'viewer' }, so an agent gets read-only against this config out of the box. -# That access split is the point, so just confirm it. +# User Role, then Agent Role. Both already hold their defaults — +# DEFAULT_ACCESS is { user: 'admin', agent: 'viewer' }, so an agent gets +# read-only against this config out of the box. That access split is the +# point, so ↓ past them rather than opening either. # -# Do not "helpfully" arrow to Viewer here. This tape used to press Up twice to -# get there from Admin, which was correct until the default changed; the same -# two presses now wrap round to Admin and hand an agent full access while the -# recording still claims read-only. Selects follow the default, not a position. -Enter -Sleep 1200ms +# Do not "helpfully" open the Agent Role select and arrow to Viewer. An +# earlier tape pressed Up twice to reach it from Admin, which was correct +# until the default changed; the same two presses then wrapped round to Admin +# and handed an agent full access while the recording still claimed +# read-only. Selects follow the default, not a position. +Down +Sleep 700ms +Down +Sleep 700ms +Down +Sleep 900ms -# Test Database — Space toggles, Enter submits the form. +# Test Database. A checkbox has nothing to type into, so Space (or Enter) +# toggles it in place. Space -Sleep 1s +Sleep 1200ms + +# Past the last field is the action row. This is where the form is submitted. +Down +Sleep 1500ms Enter -Sleep 4s +Sleep 5s Escape Sleep 2s @@ -177,7 +214,8 @@ Sleep 5s # [1] Tables, then down to `task` — the payoff table. Its detail shows both the # inherited compound key (user_id + created_at + task_index, no surrogate id) # and the `priority` column the fast-forward just added, which is the whole -# walkthrough in one screen. +# walkthrough in one screen. Its footer advertises [r] Rows; that is +# 05-rows.tape. Type "1" Sleep 3s Down diff --git a/docs/tapes/04-screenshots.tape b/docs/tapes/04-screenshots.tape index 7f5e4bca..05a8bf20 100644 --- a/docs/tapes/04-screenshots.tape +++ b/docs/tapes/04-screenshots.tape @@ -6,30 +6,39 @@ # screens (the [c] Create label was wrong in both for a while). # # Run `./shots.sh` rather than calling vhs directly: it renders this tape and -# then crops each PNG down to its own content, since one tape has one canvas -# but the screens vary from ~14 to ~49 lines tall. +# then trims each PNG back to its content. +# +# On sizing: the TUI draws into the alternate screen at full terminal height, +# so the canvas is the frame every screen gets, not a bound the tall ones push +# against. Lists and forms window themselves into whatever they are given — +# a taller canvas shows a few more rows and a lot more empty panel, and a +# shorter one starts hiding fields behind a `↓ N more`. 32 lines holds the +# add-config form's ten fields with an expanded select and still leaves the +# explorer's table detail room to breathe. +# +# `seeded` rather than `built`, because the row peek has nothing to show +# against empty tables. # # Screen keys, from src/tui: # home [r] Run [c] Config [g] Change [d] DB [+] More [q] Quit # config [a] Add # change [f] FF [h] History # db [c] Create [x] Explore +# detail [v] Full text [r] Rows # global Shift+L log viewer, Shift+Q SQL terminal, ? help Source theme.tape Output ../../tmp/screenshots-throwaway.gif -# Tall enough for the add-config form, the tallest screen in the app. Every -# other shot gets cropped back down by shots.sh. -Set Width 1200 -Set Height 1160 +Set Width 1200 # 111 cols +Set Height 700 # 32 lines — measured with `tput lines`, not derived Set FontSize 15 Set TypingSpeed 90ms Hide Type "source ./env-scrub.sh" Enter -Type "./sandbox.sh built" Enter +Type "./sandbox.sh seeded" Enter Wait+Screen@180s /sandbox ready/ Type "export HOME=/tmp/noorm-demo/home" Enter Type "cd /tmp/noorm-demo/project" Enter @@ -109,6 +118,25 @@ Enter Sleep 4s Screenshot shots/explore-table-detail.png Sleep 2s + +# Rows, then one row as a document. Two sets appear only because the seeded +# table holds more rows than the viewport reads at either end. +Type "r" +Sleep 4s +Screenshot shots/explore-row-peek.png +Sleep 2s + +Down +Sleep 800ms +Enter +Sleep 3s +Screenshot shots/explore-row-view.png +Sleep 2s +Escape +Sleep 1500ms +Escape +Sleep 1500ms + Escape Sleep 1500ms Escape @@ -126,10 +154,14 @@ Sleep 2s Escape Sleep 2s +# The dwell after this Screenshot is 4s, not the 2s everywhere else. The PNG is +# flushed asynchronously and the very next keypress here closes the overlay, so +# a short dwell writes the *closed* screen under this filename — silently, with +# a plausible-looking file. Type "L" -Sleep 3s +Sleep 4s Screenshot shots/log-viewer.png -Sleep 2s +Sleep 4s Type "L" Sleep 2s Escape diff --git a/docs/tapes/05-rows.tape b/docs/tapes/05-rows.tape new file mode 100644 index 00000000..6e2e26b9 --- /dev/null +++ b/docs/tapes/05-rows.tape @@ -0,0 +1,108 @@ +# Reading rows from the schema explorer: peek at both ends of a table, then +# open one row as a document. +# +# Separate from 03-tui.tape rather than appended to it, for two reasons: +# +# 1. It needs data. 03 records the `project` sandbox and creates everything on +# camera, so its tables are empty by construction — a peek there shows "All +# 0 rows". This tape uses the `seeded` mode, which loads demo-project/ +# seed.sql after the build. +# 2. 03 is already a six-step walkthrough. Appending a seventh step to a GIF +# that loops buys reach for the one feature at the cost of every feature +# before it, because nobody watches to the end of a two-minute loop. +# +# The table truncates `title` to fit six columns across; that is not a defect +# to work around, it is the reason the row view exists, and the tape shows the +# two in sequence. +# +# Screen keys, from src/tui: +# db [x] Explore +# detail [v] Full text [r] Rows +# peek [↑↓] Row [Tab] Set [↵] Open row +# row [←→] Row [↑↓] Scroll [f] YAML/JSON + +Source theme.tape + +Output ../public/image/tui-rows.gif + +Set Width 1200 # 111 cols +Set Height 700 # 32 lines — measured with `tput lines`, not derived +Set FontSize 15 +Set TypingSpeed 90ms + +Hide +# Strip coding-agent env vars so the recording shows a plain terminal and not +# whoever's shell happened to render it. See env-scrub.sh. This one is load +# bearing beyond cosmetics: a detected harness puts the CLI on the `agent` +# channel, whose default role is `viewer`, and sandbox.sh's `run build` is +# refused outright. +Type "source ./env-scrub.sh" Enter +Type "./sandbox.sh seeded" Enter +Wait+Screen@180s /sandbox ready/ +Type "export HOME=/tmp/noorm-demo/home" Enter +Type "cd /tmp/noorm-demo/project" Enter +Type "export PATH=/tmp/noorm-demo/bin:$PATH" Enter +Type "export PS1='$ '" Enter +Type "clear" Enter +Show + +Sleep 1s +Type "noorm ui" Sleep 500ms Enter +Sleep 4s + +# ── Down to the task table ──────────────────────────────────────────────── +Type "d" +Sleep 2s +Type "x" +Sleep 3500ms +Type "1" +Sleep 2500ms +Down +Sleep 700ms +Down +Sleep 1200ms +Enter +Sleep 3500ms + +# ── Peek at both ends ───────────────────────────────────────────────────── +# `r`, not `p`: `r` already means re-run, rename and transfer elsewhere, and +# this key is screen-local. The read is gated on sql:read rather than explore, +# because reading rows through a schema-only permission would be a hole. +# +# Two sets appear only when the table holds more rows than the viewport can +# read at both ends; a short table collapses to one "All N rows" table. +Type "r" +Sleep 4s + +Down +Sleep 1200ms + +# ── One row as a document ───────────────────────────────────────────────── +# Enter opens the highlighted row with nothing truncated. `f` swaps YAML for +# JSON and back, and the choice sticks for the rest of the session. +Enter +Sleep 3500ms +Type "f" +Sleep 3500ms + +# ←/→ walk the rows the table was showing — filtered and sorted, so the viewer +# moves through what the reader can see rather than through what was fetched. +Right +Sleep 2500ms +Left +Sleep 1500ms +Left +Sleep 2500ms + +# ── Back out to the other end of the table ──────────────────────────────── +Escape +Sleep 2500ms +Tab +Sleep 2500ms +Enter +Sleep 3500ms + +# Hold the final screen. PlaybackSpeed 2 halves playback, so this 10s of tape +# reads as ~5s in the GIF — long enough to read the last row before the loop +# restarts. +Sleep 10s diff --git a/docs/tapes/README.md b/docs/tapes/README.md index 4ad0d460..4b523c5b 100644 --- a/docs/tapes/README.md +++ b/docs/tapes/README.md @@ -10,6 +10,7 @@ so re-recording after a CLI change is one command instead of a fresh take. | `02-build-and-change.tape` | `build-and-change.gif` | `run build`, `change list`, `change ff`, `change history` | | `03-tui.tape` | `tui.gif` | `noorm ui` — create a config, create the database, build, fast-forward changes, history, explorer | | `04-screenshots.tape` | `../public/image/tui/*.png` | Stills of every TUI screen, for `tui.md`. Run via `./shots.sh` | +| `05-rows.tape` | `tui-rows.gif` | Peek at both ends of a table, then open one row as a document | ## Prerequisites @@ -30,17 +31,24 @@ cd docs/tapes vhs 01-install.tape vhs 02-build-and-change.tape vhs 03-tui.tape -./shots.sh # 04-screenshots.tape + per-image cropping +./shots.sh # 04-screenshots.tape + per-image trimming +vhs 05-rows.tape ``` Each tape builds its own sandbox first, so they can run in any order and none of them depends on a previous one. -`shots.sh` is a wrapper, not an alternative: `04-screenshots.tape` produces -stills at one canvas size (tall enough for the add-config form, the tallest -screen in the app), and the script crops each one back down to its own content -and re-pads it. Running the tape directly leaves every short screen sitting on -a slab of empty terminal. +`shots.sh` is a wrapper, not an alternative: it renders `04-screenshots.tape`, +then trims each PNG to its own widest line and re-pads it in the brand +background so the twenty stills share one margin. + +**The canvas is the frame, not a bound.** The TUI renders into the alternate +screen and draws to the full terminal height, so a screen shorter than the +canvas gets empty panel rather than a shorter image, and `shots.sh` cannot trim +that away: the status bar reaches the bottom edge on every screen. Size the +canvas to the tallest screen a tape visits and no taller. 32 lines holds the +add-config form's ten fields with a select expanded, which is the floor all +three TUI tapes are set to. ## The sandbox @@ -68,7 +76,14 @@ Three things this buys: Modes: `fresh` (nothing set up), `project` (identity and a project, but no config and no database — the TUI walkthrough creates both on camera), -`bootstrapped` (identity + config), `built` (also applies the schema). +`bootstrapped` (identity + config), `built` (also applies the schema), `seeded` +(also loads `demo-project/seed.sql`, which is what `05-rows.tape` peeks at). + +Every mode except `fresh` and `project` runs `noorm run build`, which is +refused outright when a coding-agent environment variable is set: the CLI reads +that as the `agent` channel, whose default role is `viewer`. Source +`env-scrub.sh` first, as every tape does, or run `sandbox.sh` from a plain +shell. `sandbox.sh` drops the `noorm_demo` database on the test Postgres container each run, so recordings never inherit objects from a previous take — that @@ -88,6 +103,12 @@ The SQL files describe the schema **as it exists today**, including `priority`. The changes exist for databases built before that column landed, and are written idempotently (`ADD COLUMN IF NOT EXISTS`) so they are safe on both. +`demo-project/seed.sql` sits beside `sql/` rather than inside it, so `run build` +never picks it up and stays a 4-file build in every recording that narrates the +count. Only `sandbox.sh seeded` loads it, piped straight to psql. Its 24 tasks +are what make the row peek show a first set and a last set instead of collapsing +to one `All N rows` table. + To record against the full example instead: ```bash @@ -116,6 +137,17 @@ Height = lines * 22.4 + 64 ``` Re-measure if you change the font or size — do not scale the numbers by eye. +The three TUI tapes override the theme's size with `Set FontSize 15`, where the +same probe reports: + +| `Set Width` / `Set Height` | `tput cols` / `tput lines` | +|---|---| +| 1200 / 700 | 111 / 32 | +| 1200 / 1160 | 111 / 57 | + +Cell height does not divide evenly out of those two points, so read the number +you need off a probe rather than off a constant. A probe is a two-line tape: +`Type "tput lines > size.txt" Enter`, then `Sleep 2s`. ## Notes on VHS @@ -161,9 +193,19 @@ Driving the TUI adds three more, all of which fail *silently* — the recording looks plausible while the database stays empty, so verify against the database rather than against the GIF: -- **Enter on a text field submits the whole form.** It does not advance. Only - Tab and the arrows move between fields. Enter is correct on a select (confirm - + advance) and on the final checkbox (submit). +- **A form has a browse mode and an edit mode, and Enter switches between + them.** Browse is where the tape starts: `↑`/`↓` and Tab move between fields, + every field type included, and Enter opens the active one. Inside a field, + Enter commits and Esc puts back the value it held on entry. So the loop for a + text field is Enter, type, Enter, Down — typing straight into a browsing form + sends the characters to a handler that ignores them, and the recording shows + an empty form. + + Submit is not a key. Past the last field the cursor lands on the + `[ Create Config ]` / `[ Cancel ]` row, and Enter there is what saves. + + A checkbox toggles in place on Space or Enter, and a select expands on Enter + and takes the highlighted option on the next one. - **Run Build and Change FF each gate on a confirm** ("Run 4 SQL files on dev?"). Miss the `y` and Escape simply cancels the operation. - **Navigation is two levels deep**: Home → list → action. One Escape returns @@ -173,8 +215,15 @@ rather than against the GIF: ## Known rough edges -Both are in the CLI, not the tapes: +All three are in the app, not the tapes: +- **The `?` help screen and the Shift+L log overlay overflow the alternate + screen.** `AppShell` claims `height={terminalHeight}` and both overlays render + as siblings after it (`src/tui/app.tsx:384-388`), so the frame is the window + plus the overlay and the top of it scrolls out of reach. `log-viewer.png` was + re-shot with that visible, because it is what the app draws today. Re-run + `./shots.sh` once the overlays take over the frame again, as their own + comments say they mean to. - `run build` at the default log level prints one `file:after` line per file with the absolute path repeated in a `filepath=` field. It is too verbose to record on a real schema. diff --git a/docs/tapes/demo-project/seed.sql b/docs/tapes/demo-project/seed.sql new file mode 100644 index 00000000..0cd20aa2 --- /dev/null +++ b/docs/tapes/demo-project/seed.sql @@ -0,0 +1,49 @@ +-- Rows for the recordings that show data rather than schema (05-rows.tape). +-- +-- Deliberately NOT under sql/: `noorm run build` globs that directory, and a +-- seed file there would turn the 4-file schema every other tape narrates into +-- a 5-file one. sandbox.sh pipes this straight to psql instead, so only the +-- `seeded` mode ever sees it. +-- +-- 24 tasks is chosen, not arbitrary. The row peek shows First N and Last N as +-- two tables only when the table holds more rows than it can read at both ends; +-- a handful of rows collapses to a single "All N rows" table and the recording +-- stops demonstrating the thing it exists to demonstrate. + +INSERT INTO app_user (email, created_at) VALUES + ('ada@example.com', TIMESTAMPTZ '2026-01-04 09:15:00+00'), + ('grace@example.com', TIMESTAMPTZ '2026-01-11 16:40:00+00'), + ('alan@example.com', TIMESTAMPTZ '2026-02-02 08:05:00+00'); + +INSERT INTO project (user_id, created_at, name) VALUES + (1, TIMESTAMPTZ '2026-01-04 09:30:00+00', 'Billing rewrite'), + (2, TIMESTAMPTZ '2026-01-12 10:00:00+00', 'Search relevance'), + (3, TIMESTAMPTZ '2026-02-02 08:20:00+00', 'Onboarding funnel'); + +INSERT INTO task (user_id, created_at, task_index, title, done, priority) VALUES + (1, TIMESTAMPTZ '2026-01-04 09:30:00+00', 1, 'Model invoices with an inherited key', true, 1), + (1, TIMESTAMPTZ '2026-01-04 09:30:00+00', 2, 'Backfill legacy invoice numbers', true, 2), + (1, TIMESTAMPTZ '2026-01-04 09:30:00+00', 3, 'Split tax lines onto their own table', true, 2), + (1, TIMESTAMPTZ '2026-01-04 09:30:00+00', 4, 'Reconcile refunds against payments', false, 1), + (1, TIMESTAMPTZ '2026-01-04 09:30:00+00', 5, 'Retire the surrogate invoice_id', false, 3), + (1, TIMESTAMPTZ '2026-01-04 09:30:00+00', 6, 'Add a dunning schedule', false, 4), + (1, TIMESTAMPTZ '2026-01-04 09:30:00+00', 7, 'Export monthly revenue to the warehouse', false, 3), + (1, TIMESTAMPTZ '2026-01-04 09:30:00+00', 8, 'Document the currency rounding rule', false, 5), + + (2, TIMESTAMPTZ '2026-01-12 10:00:00+00', 1, 'Tokenise product titles', true, 2), + (2, TIMESTAMPTZ '2026-01-12 10:00:00+00', 2, 'Weight recent purchases higher', true, 1), + (2, TIMESTAMPTZ '2026-01-12 10:00:00+00', 3, 'Drop the stopword list', true, 4), + (2, TIMESTAMPTZ '2026-01-12 10:00:00+00', 4, 'Measure click-through per query', false, 2), + (2, TIMESTAMPTZ '2026-01-12 10:00:00+00', 5, 'Cache the top thousand queries', false, 3), + (2, TIMESTAMPTZ '2026-01-12 10:00:00+00', 6, 'Handle plural and singular forms', false, 3), + (2, TIMESTAMPTZ '2026-01-12 10:00:00+00', 7, 'Rank exact matches above fuzzy ones', false, 2), + (2, TIMESTAMPTZ '2026-01-12 10:00:00+00', 8, 'Log every empty result set', false, 5), + + (3, TIMESTAMPTZ '2026-02-02 08:20:00+00', 1, 'Cut the signup form to three fields', true, 1), + (3, TIMESTAMPTZ '2026-02-02 08:20:00+00', 2, 'Verify email before first login', true, 1), + (3, TIMESTAMPTZ '2026-02-02 08:20:00+00', 3, 'Send a welcome message on day one', true, 3), + (3, TIMESTAMPTZ '2026-02-02 08:20:00+00', 4, 'Track drop-off between step two and three', false, 2), + (3, TIMESTAMPTZ '2026-02-02 08:20:00+00', 5, 'Offer a sample project on first run', false, 2), + (3, TIMESTAMPTZ '2026-02-02 08:20:00+00', 6, 'Remind dormant accounts after a week', false, 4), + (3, TIMESTAMPTZ '2026-02-02 08:20:00+00', 7, 'Translate the checklist into Spanish', false, 4), + (3, TIMESTAMPTZ '2026-02-02 08:20:00+00', 8, 'Retire the old onboarding modal', false, 5); diff --git a/docs/tapes/sandbox.sh b/docs/tapes/sandbox.sh index 912249d9..14e69245 100755 --- a/docs/tapes/sandbox.sh +++ b/docs/tapes/sandbox.sh @@ -12,11 +12,15 @@ # Usage: # ./sandbox.sh # fresh project, no identity, no config # ./sandbox.sh bootstrapped # identity + config + schema already applied +# ./sandbox.sh seeded # built, plus demo rows for the row-peek tape # set -euo pipefail DEMO_ROOT="${NOORM_DEMO_ROOT:-/tmp/noorm-demo}" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +# Absolute: the mode blocks below cd into the sandbox, so a path relative to +# this script stops resolving once they have. +TAPES_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" MODE="${1:-fresh}" PG_CONTAINER="noorm-test-postgres" @@ -120,7 +124,7 @@ if [ "$MODE" = "project" ]; then node "$NOORM_BIN" init --yes >/dev/null fi -if [ "$MODE" = "bootstrapped" ] || [ "$MODE" = "built" ]; then +if [ "$MODE" = "bootstrapped" ] || [ "$MODE" = "built" ] || [ "$MODE" = "seeded" ]; then export HOME="$DEMO_ROOT/home" cd "$DEMO_ROOT/project" @@ -145,9 +149,20 @@ fi # `built` also applies the schema, so the TUI opens on a real database instead # of reporting "empty database" on its home screen. Changes stay pending — that # is the state worth showing. -if [ "$MODE" = "built" ]; then +if [ "$MODE" = "built" ] || [ "$MODE" = "seeded" ]; then node "$NOORM_BIN" run build >/dev/null 2>&1 fi +# `seeded` adds rows on top. The schema tapes only ever show structure, so an +# empty database is the honest state for them; the row peek has nothing to peek +# at without this. Piped straight to psql rather than added under sql/, which +# would make `run build` report five files instead of the four every other tape +# narrates. +if [ "$MODE" = "seeded" ]; then + + docker exec -i "$PG_CONTAINER" psql -q -v ON_ERROR_STOP=1 -U "$PG_USER" -d "$PG_DB" \ + < "$TAPES_DIR/demo-project/seed.sql" >/dev/null +fi + echo "sandbox ready at $DEMO_ROOT ($MODE)" diff --git a/docs/tapes/shots.sh b/docs/tapes/shots.sh index fcb0a673..1241324b 100755 --- a/docs/tapes/shots.sh +++ b/docs/tapes/shots.sh @@ -1,11 +1,16 @@ #!/usr/bin/env bash # -# Renders 04-screenshots.tape and crops each still down to its own content. +# Renders 04-screenshots.tape and trims each still back to its content. # -# One tape has one canvas, but the TUI screens run from ~14 to ~49 lines. The -# canvas is sized for the tallest (the add-config form), so every other shot -# comes out with a slab of empty terminal below it. This trims that back off -# and re-adds even padding, so each image is sized to what it actually shows. +# What "content" means changed when the TUI moved into the alternate screen. +# It draws to the full terminal height now — breadcrumb at the top, status bar +# pinned to the bottom — so every screen reaches both edges and the vertical +# trim only takes off the padding VHS drew. The horizontal trim still does real +# work, cutting each image to its own widest line, and the re-added border +# gives all of them the same margin in the brand background. +# +# So: do not size the canvas in 04-screenshots.tape expecting this to crop the +# slack back off. It cannot. The canvas is the frame. # # Output lands in ../public/image/tui/. # diff --git a/docs/tui.md b/docs/tui.md index ceec51a4..aa00744a 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -9,7 +9,8 @@ noorm ui ![A full pass through the TUI: adding a config, creating the database, building the schema, fast-forwarding changes, and browsing the result in the explorer](/image/tui.gif) -Everything in noorm is accessible through keyboard shortcuts. No mouse needed. +Everything in noorm is accessible through keyboard shortcuts. The mouse works +too, in lists and grids — see [Mouse](#mouse) if you would rather it did not. The TUI is a dedicated subcommand — every other `noorm` command runs as a non-interactive CLI. Running `noorm` on its own prints the command list (citty's `--help`) instead of opening the wizard, so the entry into the TUI is always explicit. See the [CLI Reference](/headless) for the headless surface. @@ -112,6 +113,89 @@ apart. Numbered selection is enabled per list — if a list renders numbers down its left edge (Settings and the schema explorer do), the digits work there. +Every list sizes itself to the window, so a taller terminal shows more rows +rather than the same fixed count with the rest behind a scroll marker. A list +also keeps its cursor when you open an item and come back, so walking a long +list one entry at a time does not restart at the top. + + +### Form Navigation + +Forms have two modes and `Enter` switches between them. + +| Mode | Key | Effect | +|------|-----|--------| +| Browse | `↑` `↓` | Move between fields, every field type included | +| Browse | `Tab` / `Shift+Tab` | Same, forwards and backwards | +| Browse | `Enter` | Open the active field for editing | +| Browse | `Escape` | Cancel the form | +| Edit | `Enter` | Commit and return to browse | +| Edit | `Escape` | Restore the value the field held when edit opened | +| Edit | `Tab` | Commit and move to the next field | + +Enter is the mode switch, so submitting is a place you navigate to rather than +a key you press: + +```mermaid +stateDiagram-v2 + [*] --> Browse + Browse --> Edit: Enter on a text or select field + Edit --> Browse: Enter commits + Edit --> Browse: Esc reverts + Browse --> Actions: Down past the last field + Actions --> Browse: Down wraps to the first field + Actions --> [*]: Enter on the submit button +``` + +Past the last field the cursor lands on the action row, where `←` `→` move +between the buttons and `Enter` activates one. The submit button carries the +screen's own label, `[ Create Config ]` on the add-config form. + +Two field types never open into edit mode: + +- 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`. Collapsed, it shows its current value on one line + like every other field. + +A red `*` after a label marks a required field. Submitting with one empty puts +the cursor on it and shows the error beside the value. + + +### Mouse + +On by default, in lists and result grids only: + +| Action | Effect | +|--------|--------| +| Click a row | Move the cursor to it | +| Double-click a row | Same as `Enter` on that row | +| Wheel up / down | Move the cursor one row | + +A click acts on whatever already has focus. Clicking a list that is not focused +does nothing — it does not move focus there, so the keyboard stays where you +left it. + + +#### Text selection stopped working + +That is this feature, and it is the one thing it costs you. Any terminal +mouse-tracking mode hands the mouse to the application, so click-drag selection +needs a modifier held down: Option in macOS Terminal and iTerm2, Shift in most +others. + +If you would rather have plain selection back, turn the mouse off in +`.noorm/settings.yml`: + +```yaml +ui: + mouse: false +``` + +`?` inside the TUI prints the same line, so you do not have to remember which +file it lives in. Nothing else changes: every screen is fully keyboard-driven +either way. + ### Global Shortcuts @@ -124,6 +208,35 @@ left edge (Settings and the schema explorer do), the digits work there. | `Ctrl+C` | Quit | +### Cancelling a Database Operation + +A screen waiting on a database says so, and says that `Escape` will stop it: + +``` +Testing connection... [Esc] Cancel +``` + +That appears while a config is being tested (add and edit), while the +force-release screen checks lock status, and while the SQL terminal connects or +runs a query. `Escape` returns the screen to a usable state; nothing is saved. + +Two things are worth knowing about what "cancel" means here: + +- **The client always stops waiting. The server usually does not stop working.** + A cancelled query keeps running on PostgreSQL and MySQL only until noorm's + cancel request reaches it, and on SQL Server and SQLite it is never told at + all. The message names which happened, and + [the SQL terminal guide](./guide/database/terminal.md) has the per-dialect + table. +- **A connect that answers after you cancelled is closed, not adopted.** The + screen keeps the state you cancelled into, and the connection is destroyed + rather than left half-open. + +A connection attempt also ends on its own after 15 seconds, with or without a +keypress. Raise `connection.connectTimeoutMs` for a link that is slow but +working. + + ## Screen Reference @@ -186,6 +299,63 @@ Select a table to see its full schema: ![Table detail: columns, indexes, and foreign keys](/image/tui/explore-table-detail.png) +A detail longer than the window scrolls rather than running off the bottom: + +| Key | Action | +|-----|--------| +| `↑` `↓` | Scroll one line | +| `Ctrl+U` / `Ctrl+D` | Half a page | +| `PageUp` / `PageDown` | A full page. On macOS these are `fn ↑` and `fn ↓`, which is what the footer says there | +| `Home` / `End` | Top, bottom | +| `v` | Redraw the same rows with nothing truncated | +| `r` | Read rows from the object | + +The footer lists only the keys the current screen answers to, so a detail that +fits shows no scroll hints and a view shows no `[r] Rows`. + + +### Reading Rows + +The explorer describes structure. `r` on a detail screen reads the rows +themselves, without leaving the explorer for the SQL terminal: + +![Peeking at both ends of a table, then opening one row as a document](/image/tui-rows.gif) + +The peek reads both ends of the table rather than a page from the top, so the +last rows by primary key are as reachable as the first: + +![The row peek: first rows and last rows, side by side](/image/tui/explore-row-peek.png) + +| Key | Action | +|-----|--------| +| `↑` `↓` | Move the cursor within a set | +| `Tab` | Swap between the first set and the last | +| `Enter` | Open the highlighted row as a document | +| `/` `s` `c` | Filter, sort, clear, on the focused set | +| `Escape` | Close the peek | + +Three things decide what comes back: + +- **Reading rows needs `sql:read`**, not the `explore` permission the rest of + these screens use. A config an agent may inspect the schema of is not one it + may read data from. See [Configs](/guide/environments/configs#access-roles). +- **The tail needs a primary key.** With one, the last rows ride the index. + Without one, only the first rows appear. +- **A short table is one set.** When both ends meet, the peek says + `All N rows` and draws a single table. + +The table fits as many whole columns as the terminal holds and marks the rest +`… N more columns`. `Enter` on a row is what shows them all: + +![One row as a YAML document, with the keys to walk to the next](/image/tui/explore-row-view.png) + +| Key | Action | +|-----|--------| +| `←` `→` | Previous row, next row, in the order the table was showing | +| `↑` `↓` | Scroll a document taller than the window | +| `f` | Swap YAML for JSON. The choice holds for the rest of the session | +| `Escape` | Back to the peek, on the row you were reading | + ### SQL Terminal @@ -193,9 +363,11 @@ Press `Shift+Q` anywhere to open the SQL terminal against the active config: ![The built-in SQL terminal](/image/tui/sql-terminal.png) -- Tab completion for table/column names -- Query history with up/down arrows -- Results cached for review +- `↑` `↓` walk your query history, and `h` opens it as a list +- A result wider than the terminal keeps whole columns and marks the remainder + `… N more columns`, rather than cramming every column into a few characters +- `Enter` on a result row opens it as a document, the same YAML or JSON view the + row peek uses, which is how you read the columns the grid left out ### Log Viewer @@ -204,9 +376,9 @@ Press `Shift+L` anywhere to toggle the log overlay: ![The log viewer overlay, opened with Shift+L](/image/tui/log-viewer.png) -The overlay sits on top of whatever screen you were on, so you can watch events -while an operation runs. `[/]` searches, `[Space]` pauses the live tail, and -`[Enter]` opens a single entry in full. +The overlay tails events as they happen, so you can watch an operation run. +`[/]` searches, `[g]` and `[G]` jump to the top and bottom, `[Space]` pauses the +tail, `[Enter]` opens a single entry in full, and `Shift+L` again closes it. ### More Options diff --git a/package.json b/package.json index 1db6be5b..38836a22 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "csv-parse": "^6.1.0", "dayjs": "^1.11.20", "eta": "^4.5.1", - "ink": "^6.8.0", + "ink": "^7.1.1", "json5": "^2.2.3", "kysely": "^0.28.12", "react": "^19.2.4", diff --git a/src/cli/ui.ts b/src/cli/ui.ts index 6436501c..7e0a392a 100644 --- a/src/cli/ui.ts +++ b/src/cli/ui.ts @@ -47,6 +47,11 @@ const uiCommand = defineCommand({ { exitOnCtrlC: false, patchConsole: true, + // Without this the TUI draws into normal scrollback, so every + // repaint appends and anything taller than the window scrolls + // out of reach. The alternate buffer also restores whatever the + // terminal was showing before `noorm ui` on exit. + alternateScreen: true, }, ); diff --git a/src/core/config/schema.ts b/src/core/config/schema.ts index 5febe43a..380e3724 100644 --- a/src/core/config/schema.ts +++ b/src/core/config/schema.ts @@ -133,6 +133,7 @@ export const ConnectionSchema = z ssl: SSLSchema.optional(), pool: PoolSchema.optional(), tlsServerName: z.string().optional(), + connectTimeoutMs: z.number().int().positive().optional(), }) .refine((conn) => conn.dialect === 'sqlite' || conn.host, { message: 'Host is required for non-SQLite databases', @@ -181,6 +182,7 @@ const PartialConnectionSchema = z.object({ ssl: SSLSchema.optional(), pool: PoolSchema.optional(), tlsServerName: z.string().optional(), + connectTimeoutMs: z.number().int().positive().optional(), }); /** diff --git a/src/core/connection/defaults.ts b/src/core/connection/defaults.ts index deb14b10..3277c7df 100644 --- a/src/core/connection/defaults.ts +++ b/src/core/connection/defaults.ts @@ -16,6 +16,43 @@ export const DEFAULT_PORTS: Record = { mssql: 1433, }; +/** + * Milliseconds a connection attempt may spend before the driver gives up. + * + * Postgres ships with no limit at all — `pg`'s `connectionTimeoutMillis` + * defaults to `0` — which is why an unreachable host used to wait forever + * instead of erroring. 15s is tedious's own default, so mssql keeps the + * behaviour it already had, and it clears the slowest legitimate case by a + * wide margin: a warm TLS handshake plus login is sub-second even across + * continents. The one case it does not clear is a serverless database resuming + * from auto-pause, which is what `connection.connectTimeoutMs` is for. + */ +export const DEFAULT_CONNECT_TIMEOUT_MS = 15_000; + +/** + * Resolve the connect timeout for a connection config. + * + * A non-positive override is treated as absent rather than as "no timeout": + * every driver here reads `0` as infinite, and silently reinstating the + * forever-hang is the exact bug this default exists to close. + * + * @example + * const pool = new Pool({ connectionTimeoutMillis: connectTimeoutFor(config) }); + */ +export function connectTimeoutFor(config: { connectTimeoutMs?: number }): number { + + const configured = config.connectTimeoutMs; + + if (typeof configured === 'number' && Number.isFinite(configured) && configured > 0) { + + return configured; + + } + + return DEFAULT_CONNECT_TIMEOUT_MS; + +} + /** * Port number validation. Shared by `core/config` and `core/settings` so the * bound has one source of truth. diff --git a/src/core/connection/dialects/mssql.ts b/src/core/connection/dialects/mssql.ts index dc55edfe..5e9d1cfe 100644 --- a/src/core/connection/dialects/mssql.ts +++ b/src/core/connection/dialects/mssql.ts @@ -14,7 +14,7 @@ import { Kysely, MssqlDialect, sql } from 'kysely'; import type { ConnectionConfiguration } from 'tedious'; import type { ConnectionConfig, ConnectionResult } from '../types.js'; -import { DEFAULT_PORTS } from '../defaults.js'; +import { DEFAULT_PORTS, connectTimeoutFor } from '../defaults.js'; import { MssqlLimitPlugin } from './mssql-limit-plugin.js'; /** @@ -145,6 +145,12 @@ export function buildTediousOptions( trustServerCertificate: !config.ssl, encrypt: true, serverName: resolveTlsServerName(config), + // Matches tedious's own default, so mssql keeps the behaviour it + // had while becoming overridable alongside the other dialects. + // `requestTimeout` is deliberately left alone: a long-running + // query is legitimate, and Escape is the answer for that, not a + // deadline nobody asked for. + connectTimeout: connectTimeoutFor(config), }, }; diff --git a/src/core/connection/dialects/mysql.ts b/src/core/connection/dialects/mysql.ts index 8291facc..14aa47a9 100644 --- a/src/core/connection/dialects/mysql.ts +++ b/src/core/connection/dialects/mysql.ts @@ -6,7 +6,7 @@ */ import { Kysely, MysqlDialect } from 'kysely'; import type { ConnectionConfig, ConnectionResult } from '../types.js'; -import { DEFAULT_PORTS } from '../defaults.js'; +import { DEFAULT_PORTS, connectTimeoutFor } from '../defaults.js'; /** * Create a MySQL connection. @@ -36,6 +36,9 @@ export async function createMysqlConnection(config: ConnectionConfig): Promise({ diff --git a/src/core/connection/dialects/postgres.ts b/src/core/connection/dialects/postgres.ts index 281c64d8..449df587 100644 --- a/src/core/connection/dialects/postgres.ts +++ b/src/core/connection/dialects/postgres.ts @@ -6,7 +6,7 @@ */ import { Kysely, PostgresDialect } from 'kysely'; import type { ConnectionConfig, ConnectionResult } from '../types.js'; -import { DEFAULT_PORTS } from '../defaults.js'; +import { DEFAULT_PORTS, connectTimeoutFor } from '../defaults.js'; /** * Create a PostgreSQL connection. @@ -39,6 +39,9 @@ export async function createPostgresConnection( min: config.pool?.min ?? 0, max: config.pool?.max ?? 10, ssl: config.ssl, + // pg's own default is 0, meaning wait forever: an unreachable host + // never comes back without this. + connectionTimeoutMillis: connectTimeoutFor(config), }); const db = new Kysely({ diff --git a/src/core/connection/factory.ts b/src/core/connection/factory.ts index 134dc125..b5086a96 100644 --- a/src/core/connection/factory.ts +++ b/src/core/connection/factory.ts @@ -8,10 +8,12 @@ import { accessSync, constants as fsConstants, existsSync } from 'node:fs'; import { dirname, resolve } from 'node:path'; import { sql } from 'kysely'; -import { retry, attempt, attemptSync } from '@logosdx/utils'; +import { retry, attempt, attemptSync, runWithTimeout, isTimeoutError } from '@logosdx/utils'; import type { ConnectionConfig, ConnectionResult, Dialect } from './types.js'; import { observer } from '../observer.js'; +import { OperationAbortedError, raceAbort, throwIfAborted } from '../shared/abort.js'; import { getConnectionManager } from './manager.js'; +import { connectTimeoutFor } from './defaults.js'; type DialectFactory = (config: ConnectionConfig) => ConnectionResult | Promise; @@ -87,32 +89,72 @@ export interface ConnectionRetryOptions { } /** - * Create a database connection with retry logic. + * Milliseconds a `destroy()` on an abandoned connection may take before it is + * left to the garbage collector. * - * Automatically retries on transient connection failures (ECONNREFUSED, ETIMEDOUT). - * Does not retry authentication failures or missing drivers. + * Closing a pool whose socket is already dead can hang exactly the way opening + * it did, which is why `ConnectionManager.closeAll` wraps its own destroys the + * same way. Without the bound, the cleanup for a hang would be a second hang. + */ +const DISCARD_TIMEOUT_MS = 5_000; + +/** + * Milliseconds the liveness probe is allowed on top of the driver's own + * connect timeout. + * + * The two deadlines would otherwise be identical and race, and the driver's + * loss is the bad outcome: only it can tear down its socket, and only it knows + * enough to say "Connection terminated due to connection timeout" instead of a + * generic one. The grace lets the driver report first and leaves the probe as + * the backstop for the case no driver option covers — a socket that opened and + * then went silent. + */ +const PROBE_GRACE_MS = 2_000; + +/** + * Close a connection whose caller has stopped waiting for it. + * + * The window between "the user pressed Escape" and "the driver finally + * connected" leaves a live pool with no reference to it anywhere. This is the + * only thing that reclaims it. * * @example - * ```typescript - * const conn = await createConnection({ - * dialect: 'postgres', - * host: 'localhost', - * database: 'myapp', - * user: 'postgres', - * password: 'secret', - * }) + * // raceAbort hands the late arrival here rather than dropping it + * return raceAbort(openConnection(config), signal, discardConnection); + */ +export async function discardConnection( + conn: Pick, + timeoutMs: number = DISCARD_TIMEOUT_MS, +): Promise { + + const [, err] = await attempt(() => + runWithTimeout(() => conn.destroy(), { timeout: timeoutMs, throws: true }), + ); + + if (err) { + + observer.emit('error', { source: 'connection', error: err }); + + } + +} + +/** + * Open a connection, retrying transient failures. * - * await sql`SELECT 1`.execute(conn.db) - * await conn.destroy() - * ``` + * Split out from `createConnection` so the abort race wraps the whole thing: + * the caller stops waiting at the boundary, while this keeps running to the + * point where its result can be handed back for cleanup. */ -export async function createConnection( +async function openConnection( config: ConnectionConfig, - configName: string = '__default__', - retryOptions: ConnectionRetryOptions = {}, + configName: string, + retryOptions: ConnectionRetryOptions, + signal?: AbortSignal, ): Promise { const { retries = 3, delay = 1000, backoff = 2 } = retryOptions; + const connectTimeout = connectTimeoutFor(config); const [conn, err] = await attempt(() => retry( @@ -139,8 +181,43 @@ export async function createConnection( const conn = await createFn!(config); - // Test connection with simple query - await sql`SELECT 1`.execute(conn.db); + // The driver can hand back a live pool after the caller gave + // up. Nothing else holds it at this point, so close it here. + if (signal?.aborted) { + + void discardConnection(conn); + + throw new OperationAbortedError(); + + } + + // A socket that opened and then went quiet is what a blackholed + // network looks like from here, and no driver connect timeout + // covers it — the connect already succeeded. + const [, probeErr] = await attempt(() => + runWithTimeout(() => sql`SELECT 1`.execute(conn.db), { + timeout: connectTimeout + PROBE_GRACE_MS, + throws: true, + }), + ); + + if (probeErr) { + + void discardConnection(conn); + + // "Function timed out" is what the wrapper says, and it + // would be the whole of what the user sees. + if (isTimeoutError(probeErr)) { + + throw new Error( + `Database did not respond within ${connectTimeout + PROBE_GRACE_MS}ms`, + ); + + } + + throw probeErr; + + } return conn; @@ -150,8 +227,13 @@ export async function createConnection( delay, backoff, jitterFactor: 0.1, + signal, shouldRetry: (err) => { + // Retrying something the caller walked away from would + // hold the pool open for another two rounds of backoff. + if (err instanceof OperationAbortedError) return false; + const msg = err.message.toLowerCase(); // Don't retry auth/config failures @@ -218,6 +300,48 @@ export async function createConnection( } +/** + * Create a database connection with retry logic. + * + * Automatically retries on transient connection failures (ECONNREFUSED, ETIMEDOUT). + * Does not retry authentication failures or missing drivers. + * + * Pass `signal` to be able to stop waiting. Aborting rejects with + * `OperationAbortedError` right away; the driver is not obliged to notice, so + * a connection that opens afterwards is closed rather than left half-open. + * Omit it and nothing about the call changes. + * + * @example + * ```typescript + * const conn = await createConnection({ + * dialect: 'postgres', + * host: 'localhost', + * database: 'myapp', + * user: 'postgres', + * password: 'secret', + * }) + * + * await sql`SELECT 1`.execute(conn.db) + * await conn.destroy() + * ``` + */ +export async function createConnection( + config: ConnectionConfig, + configName: string = '__default__', + retryOptions: ConnectionRetryOptions = {}, + signal?: AbortSignal, +): Promise { + + throwIfAborted(signal); + + return raceAbort( + openConnection(config, configName, retryOptions, signal), + signal, + discardConnection, + ); + +} + /** * Default system databases by dialect. * Used for testing server connectivity without requiring the target database to exist. @@ -238,6 +362,9 @@ const SYSTEM_DATABASES: Record = { * @param options - Test options * @param options.testServerOnly - If true, connects to system database instead of target. * Useful when the target database doesn't exist yet. + * @param options.signal - Abort to stop waiting. The result comes back with + * `aborted: true` so a caller can say so honestly + * instead of reporting a database error. * * @example * ```typescript @@ -254,8 +381,8 @@ const SYSTEM_DATABASES: Record = { */ export async function testConnection( config: ConnectionConfig, - options: { testServerOnly?: boolean } = {}, -): Promise<{ ok: boolean; error?: string }> { + options: { testServerOnly?: boolean; signal?: AbortSignal } = {}, +): Promise<{ ok: boolean; error?: string; aborted?: boolean }> { let testConfig = config; @@ -296,10 +423,18 @@ export async function testConnection( } - const [conn, err] = await attempt(() => createConnection(testConfig, '__test__')); + const [conn, err] = await attempt(() => + createConnection(testConfig, '__test__', {}, options.signal), + ); if (err) { + if (err instanceof OperationAbortedError) { + + return { ok: false, error: err.message, aborted: true }; + + } + return { ok: false, error: err.message }; } diff --git a/src/core/connection/index.ts b/src/core/connection/index.ts index 69a07b3f..29d47352 100644 --- a/src/core/connection/index.ts +++ b/src/core/connection/index.ts @@ -3,8 +3,8 @@ * * Provides database connection creation and management. */ -export { createConnection, testConnection } from './factory.js'; +export { createConnection, testConnection, discardConnection } from './factory.js'; export type { ConnectionRetryOptions } from './factory.js'; export { getConnectionManager, resetConnectionManager } from './manager.js'; -export { DEFAULT_PORTS, PortSchema } from './defaults.js'; +export { DEFAULT_PORTS, PortSchema, DEFAULT_CONNECT_TIMEOUT_MS, connectTimeoutFor } from './defaults.js'; export * from './types.js'; diff --git a/src/core/connection/types.ts b/src/core/connection/types.ts index bb1fba96..1eaf4fd9 100644 --- a/src/core/connection/types.ts +++ b/src/core/connection/types.ts @@ -54,6 +54,16 @@ export interface ConnectionConfig { max?: number; }; + /** + * Milliseconds a single connection attempt may spend before the driver + * gives up. Defaults to `DEFAULT_CONNECT_TIMEOUT_MS`. + * + * Raise it for a link that is slow but working — a serverless database + * resuming from auto-pause is the case that motivates it, since the resume + * can outlast any timeout tuned for a handshake. + */ + connectTimeoutMs?: number; + // SSL ssl?: | boolean diff --git a/src/core/explore/index.ts b/src/core/explore/index.ts index 6e9a869c..20e6c331 100644 --- a/src/core/explore/index.ts +++ b/src/core/explore/index.ts @@ -19,16 +19,20 @@ * ``` */ export { + DEFAULT_PEEK_ROWS, fetchOverview, fetchList, fetchDetail, + fetchRowPeek, formatSummaryDescription, } from './operations.js'; -export type { DetailCategory, ExploreOptions } from './operations.js'; +export type { DetailCategory, ExploreOptions, RowPeek, RowPeekGate } from './operations.js'; export { getExploreOperations } from './dialects/index.js'; +export { applyRowLimit, MAX_PEEK_ROWS, peekQuery, readPeekRows } from './peek.js'; + export type { ExploreCategory, ExploreOverview, @@ -49,4 +53,5 @@ export type { DialectExploreOperations, ExploreSummary, ExploreDetail, + RowPeekQuery, } from './types.js'; diff --git a/src/core/explore/operations.ts b/src/core/explore/operations.ts index a27ebce3..b1750dbb 100644 --- a/src/core/explore/operations.ts +++ b/src/core/explore/operations.ts @@ -8,7 +8,9 @@ import { attempt } from '@logosdx/utils'; import type { Kysely } from 'kysely'; import type { Dialect } from '../connection/types.js'; +import type { Channel, ConfigAccess } from '../policy/index.js'; import type { + ColumnDetail, ExploreCategory, ExploreOverview, TableSummary, @@ -29,7 +31,9 @@ import type { TriggerDetail, } from './types.js'; import { getExploreOperations } from './dialects/index.js'; +import { readPeekRows } from './peek.js'; import { observer } from '../observer.js'; +import { assertPolicy } from '../policy/index.js'; /** * Options for explore operations. @@ -320,6 +324,194 @@ export async function fetchDetail( } +/** + * Rows a peek reads per set unless the caller sizes it itself. + */ +export const DEFAULT_PEEK_ROWS = 10; + +/** + * Policy inputs the row peek is checked against. + * + * Mandatory, and shaped like `SqlPolicyGate`, for the same reason: this is the + * one explore operation that returns user data rather than catalog metadata, so + * it is gated on `sql:read` where the rest of the module needs only `explore`. + * A schema-only permission must not become a way to read rows. + */ +export interface RowPeekGate { + + /** Config name, which is what the policy message names. */ + configName: string; + + /** The config's per-channel access roles. */ + access: ConfigAccess; + + /** Who is driving — see `resolveChannel`. */ + channel: Channel; + +} + +/** + * Both ends of a table, or the one set that is honest for it. + * + * `mode` is what the caller renders from, because "first N and last N" is only + * one of three truthful answers: + * + * | mode | what came back | why | + * |------|----------------|-----| + * | `whole` | every row the table has, in `first` | it holds fewer than two pages | + * | `ends` | `first` and `last`, disjoint | it holds more | + * | `head` | `first` only, and there may be more | no primary key to order a tail by | + */ +export interface RowPeek { + + /** Which of the three shapes above this is. */ + mode: 'whole' | 'ends' | 'head'; + + /** Column names in ordinal order, so both sets draw the same grid. */ + columns: string[]; + + /** Primary-key columns the read was ordered by. Empty in `head` mode. */ + keyColumns: string[]; + + /** The head of the table, or the whole of it in `whole` mode. */ + first: Record[]; + + /** The tail, in ascending order. Empty except in `ends` mode. */ + last: Record[]; + +} + +/** + * A row's key as one comparable string. + * + * `String` per column before encoding, rather than stringifying the row object: + * a driver may hand the same key back as a number in one result and a string in + * another, and a Date or a Buffer has no JSON form worth comparing. Within one + * table a key column holds one type, so a per-column `String` is enough to tell + * two rows apart, and a primary key is unique so there are no ties to break. + * + * The parts are then encoded as a JSON array rather than joined by a + * separator, because any separator a value could itself contain would make + * `('a|b', 'c')` and `('a', 'b|c')` the same key. + */ +function keyOf(row: Record, keyColumns: string[]): string { + + return JSON.stringify(keyColumns.map((column) => String(row[column]))); + +} + +/** + * Column names in ordinal order. + */ +function namesInOrder(columns: ColumnDetail[]): string[] { + + return [...columns] + .sort((a, b) => a.ordinalPosition - b.ordinalPosition) + .map((column) => column.name); + +} + +/** + * Read the first and last rows of a table. + * + * Ordered by the primary key, which is the only order a relational table + * actually has: `ORDER BY pk ASC` and `ORDER BY pk DESC` both ride the primary + * key index, so reading the tail costs the same as reading the head no matter + * how large the table is. Without a primary key there is no tail to read — the + * alternative would be a full scan and a sort of an unbounded table to answer a + * question the user asked casually — so the result comes back in `head` mode + * and says so. + * + * The second query is skipped whenever the first one already answered: a page + * that came back short is the whole table. When both run and their keys + * intersect, the two sets are merged rather than drawn twice, because a table + * holding fewer than two pages would otherwise show the same rows under both + * headings with nothing to distinguish that from a table that really has them + * at both ends. + * + * @param db - Kysely database instance + * @param dialect - Database dialect + * @param detail - The table, as `fetchDetail` returned it + * @param gate - Policy inputs; the read is refused unless they allow `sql:read` + * @param limit - Rows per set + * @returns Both ends of the table, or the single set that is honest for it + * + * @throws Error carrying the policy's blockedReason when `gate` denies. + * + * @example + * ```typescript + * const peek = await fetchRowPeek(db, 'postgres', detail, { + * configName: 'local', + * access: config.access, + * channel: 'user', + * }); + * + * if (peek.mode === 'head') console.log('no primary key; no tail'); + * ``` + */ +export async function fetchRowPeek( + db: Kysely, + dialect: Dialect, + detail: TableDetail, + gate: RowPeekGate, + limit: number = DEFAULT_PEEK_ROWS, +): Promise { + + const columns = namesInOrder(detail.columns); + const keyColumns = namesInOrder(detail.columns.filter((column) => column.isPrimaryKey)); + const base = { table: detail.name, schema: detail.schema, keyColumns, limit }; + + assertPolicy(gate.channel, { name: gate.configName, access: gate.access }, 'sql:read'); + + const [first, headErr] = await attempt(() => readPeekRows(db, dialect, { ...base, direction: 'asc' })); + + if (headErr) { + + observer.emit('error', { source: 'explore', error: headErr }); + throw headErr; + + } + + // A short page is the whole table, whether or not it has a key: there is + // nothing left for a tail query to find. + if (first.length < limit) { + + return { mode: 'whole', columns, keyColumns, first, last: [] }; + + } + + if (keyColumns.length === 0) { + + return { mode: 'head', columns, keyColumns, first, last: [] }; + + } + + const [tail, tailErr] = await attempt(() => readPeekRows(db, dialect, { ...base, direction: 'desc' })); + + if (tailErr) { + + observer.emit('error', { source: 'explore', error: tailErr }); + throw tailErr; + + } + + const last = [...tail].reverse(); + const headKeys = new Set(first.map((row) => keyOf(row, keyColumns))); + const beyondHead = last.filter((row) => !headKeys.has(keyOf(row, keyColumns))); + + if (beyondHead.length === last.length) { + + return { mode: 'ends', columns, keyColumns, first, last }; + + } + + // The ends met. Both sets are already ascending and the overlap is what + // joins them, so appending the part the head did not cover reconstructs the + // table in order. + return { mode: 'whole', columns, keyColumns, first: [...first, ...beyondHead], last: [] }; + +} + /** * Format a summary description for list display. * diff --git a/src/core/explore/peek.ts b/src/core/explore/peek.ts new file mode 100644 index 00000000..33d7e5a1 --- /dev/null +++ b/src/core/explore/peek.ts @@ -0,0 +1,196 @@ +/** + * The row-peek read: `SELECT * FROM ORDER BY `, one page of it. + * + * Built with Kysely's query builder rather than a `sql` template, which is a + * deliberate departure from the rest of this module. Everything else here + * queries a catalog whose shape differs per vendor, so each dialect writes its + * own statement. This reads a user table, where the statement is the same + * everywhere and only the row cap differs — so it is one builder with one + * branch rather than four near-identical methods. + * + * Two things the builder buys, and one it does not: + * + * - **Identifier quoting, per dialect, for free.** Postgres, SQLite and MSSQL + * wrap in `"` and escape an embedded one by doubling it; MySQL uses backticks + * the same way. The compiler on the connection does that, so nothing here + * concatenates a name into SQL. Schema and table names come from the + * database's own catalog, but a table named `we"ird` is still the difference + * between an identifier and an aborted statement. + * - **A bound parameter for the page size**, on the three dialects that take + * one. `.top()` inlines its argument instead, which is why the value is + * clamped to an integer before it gets there. + * - **No portability for the row cap at all.** Kysely emits whichever method + * was called, verbatim, on every dialect, and neither method throws when used + * on the wrong one: `.limit()` compiles to `limit @1` on SQL Server and + * `.top()` compiles to `top(10)` on the other three. Each is valid on a + * disjoint set, so the branch below is mandatory and the failure it prevents + * is invisible until a server sees the statement. `tests/core/explore/ + * peek.test.ts` pins the exact string per dialect for exactly that reason. + * + * On the last point, `MssqlLimitPlugin` (`core/connection/dialects/ + * mssql-limit-plugin.ts`) already rewrites `LimitNode` to `TopNode` on every + * connection noorm builds, so `.limit()` alone would in fact work there today. + * The branch is kept anyway, and the two do not fight: the plugin declines a + * query that already carries a `top`. It is kept because this function takes + * any `Kysely` instance — an SDK caller's own, or a test harness's — and only + * connections that came through `createConnection` carry the plugin. Emitting + * the clause the dialect accepts is correct with the plugin and without it; + * relying on the plugin is correct only with it. + * + * Names are passed as `sql.id()` expressions rather than the plain strings + * `selectFrom` also accepts, because Kysely parses a string table reference and + * splits it on `.`: `selectFrom('we.ird')` compiles to `"we"."ird"`, silently + * reading a different table. `sql.id('we.ird')` stays one identifier. + * + * @example + * ```typescript + * const rows = await readPeekRows(db, 'mssql', { + * table: 'users', + * schema: 'dbo', + * keyColumns: ['id'], + * direction: 'desc', + * limit: 10, + * }); + * // select top(10) * from "dbo"."users" as "peek" order by "id" desc + * ``` + */ +import { sql } from 'kysely'; + +import type { Kysely, SelectQueryBuilder } from 'kysely'; +import type { Dialect } from '../connection/types.js'; +import type { RowPeekQuery } from './types.js'; + +/** + * Most rows a single peek will read, however many the caller asks for. + * + * A peek is a look at both ends of a table, not an export. The ceiling exists + * so a caller that sizes its page from something unbounded cannot turn one + * keystroke into a full-table read. + */ +export const MAX_PEEK_ROWS = 500; + +/** + * Which row-cap clause each dialect accepts. + * + * A `Record` rather than a conditional, so a fifth dialect added to + * the `Dialect` union fails to compile here instead of falling through to + * whichever branch happened to be the default — and the wrong branch is not a + * type error or a thrown exception anywhere else, only a rejected statement at + * runtime. + */ +const LIMIT_STYLE: Record = { + postgres: 'limit', + mysql: 'limit', + sqlite: 'limit', + mssql: 'top', +}; + +/** + * Alias every peek carries, so the `FROM` clause is a table expression rather + * than a bare identifier reference. + */ +const PEEK_ALIAS = 'peek'; + +/** + * Cap a result set the way this dialect can express it. + * + * The entire portability question, in one function, so a future simplification + * to a bare `.limit()` has a test to fail rather than a runtime error to + * produce. `.top()` is the mssql form rather than `OFFSET/FETCH` because it + * compiles correctly with *and* without an `ORDER BY`, which is what lets the + * primary-key path and the no-key path share one shape — T-SQL requires an + * `ORDER BY` before `OFFSET/FETCH`, and Kysely will emit it without one and + * leave SQL Server to object. + * + * @example + * applyRowLimit(query, 'mssql', 10); // select top(10) * from ... + * applyRowLimit(query, 'postgres', 10); // select * from ... limit $1 + */ +export function applyRowLimit( + query: SelectQueryBuilder, + dialect: Dialect, + limit: number, +): SelectQueryBuilder { + + return LIMIT_STYLE[dialect] === 'top' ? query.top(limit) : query.limit(limit); + +} + +/** + * Bound the page size and force it to a whole number. + * + * `.top()` inlines its argument into the SQL rather than binding it, so the + * value has to be provably an integer before it gets there. + */ +function clampLimit(limit: number): number { + + if (!Number.isFinite(limit)) return 1; + + return Math.max(1, Math.min(MAX_PEEK_ROWS, Math.trunc(limit))); + +} + +/** + * The select the peek runs, unexecuted. + * + * Separate from the execution so a test can assert the compiled SQL for every + * dialect without a database, which is the only cheap guard against the + * `limit`/`top` split. + * + * @example + * peekQuery(db, 'mssql', request).compile().sql; + * // 'select top(10) * from "dbo"."users" as "peek" order by "id" asc' + */ +export function peekQuery( + db: Kysely, + dialect: Dialect, + query: RowPeekQuery, +) { + + const target = query.schema + ? sql.id(query.schema, query.table) + : sql.id(query.table); + + let selected = db.selectFrom(target.as(PEEK_ALIAS)).selectAll(); + + // No key means no order to ask for. Inventing one over an arbitrary column + // would cost a full scan and a sort, on a table of unbounded size, to + // answer a question the reader asked in passing. + for (const column of query.keyColumns) { + + selected = selected.orderBy(sql.id(column), query.direction); + + } + + return applyRowLimit(selected, dialect, clampLimit(query.limit)); + +} + +/** + * Read one page of a table's rows. + * + * @param db - Kysely database instance + * @param dialect - Database dialect, which decides how the page is capped + * @param query - Table, key columns, direction and page size + * @returns The rows, in the order the database returned them + * + * @example + * ```typescript + * const head = await readPeekRows(db, 'postgres', { + * table: 'users', + * schema: 'public', + * keyColumns: ['id'], + * direction: 'asc', + * limit: 10, + * }); + * ``` + */ +export async function readPeekRows( + db: Kysely, + dialect: Dialect, + query: RowPeekQuery, +): Promise[]> { + + return peekQuery(db, dialect, query).execute(); + +} diff --git a/src/core/explore/types.ts b/src/core/explore/types.ts index b51e45a1..9fd5f080 100644 --- a/src/core/explore/types.ts +++ b/src/core/explore/types.ts @@ -292,6 +292,44 @@ export interface TriggerDetail { } +// ----------------------------------------------------------------------------- +// Row peek +// ----------------------------------------------------------------------------- + +/** + * One page of a table's rows, as `readPeekRows` is asked for it. + * + * The key columns arrive already resolved rather than being looked up here: + * `fetchDetail` has already reported which columns are the primary key, and a + * second catalog query per peek would be paying twice for the same answer. + * + * Not part of `DialectExploreOperations`: the statement is the same on every + * dialect and only its row cap differs, so it lives in one builder with one + * branch rather than four near-identical methods. + */ +export interface RowPeekQuery { + + /** Table to read from, unquoted, exactly as the catalog reported it. */ + table: string; + + /** Schema qualifying `table`, where the dialect has one. */ + schema?: string; + + /** + * Primary-key columns, in the order they should be sorted by. Empty reads + * in whatever order the storage engine hands rows back, which is the only + * thing a table without a primary key can offer. + */ + keyColumns: string[]; + + /** `desc` reads the tail; the caller re-reverses it for display. */ + direction: 'asc' | 'desc'; + + /** Rows to read. Clamped by the dialect before it reaches the SQL. */ + limit: number; + +} + // ----------------------------------------------------------------------------- // Dialect operations interface // ----------------------------------------------------------------------------- diff --git a/src/core/index.ts b/src/core/index.ts index 80bcae64..f8c2d6e3 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -29,11 +29,16 @@ export type { Config, ConfigInput } from './config/types.js'; export { createConnection, testConnection, + discardConnection, getConnectionManager, resetConnectionManager, + DEFAULT_CONNECT_TIMEOUT_MS, } from './connection/index.js'; export type { Dialect, ConnectionConfig, ConnectionResult } from './connection/index.js'; +// Cancellation +export { OperationAbortedError } from './shared/abort.js'; + // Settings export { SettingsManager, @@ -336,7 +341,7 @@ export type { } from './change/index.js'; // SQL Terminal -export { SqlHistoryManager, executeRawSql } from './sql-terminal/index.js'; +export { SqlHistoryManager, executeRawSql, hasServerSideCancel, abortMessageFor } from './sql-terminal/index.js'; export type { SqlHistoryEntry, SqlExecutionResult, diff --git a/src/core/settings/defaults.ts b/src/core/settings/defaults.ts index 196f5b33..26ab36cb 100644 --- a/src/core/settings/defaults.ts +++ b/src/core/settings/defaults.ts @@ -48,6 +48,32 @@ export const DEFAULT_LOGGING_CONFIG: LoggingConfig = { maxFiles: 5, }; +/** + * Whether the TUI answers mouse reports when nothing says otherwise. + * + * The single place "absent means on" is decided. The schema reads it for the + * `ui: {}` case, `isMouseEnabled` reads it for the no-section case, and the TUI + * reads neither directly — so the two cannot drift apart into a state where + * writing an empty `ui:` block changes behaviour. + */ +export const DEFAULT_UI_MOUSE = true; + +/** + * Whether the TUI should answer mouse reports for these settings. + * + * A plain `settings?.ui?.mouse === true` read makes an absent section mean off, + * which is the opposite of the default now, and a plain `!== false` read + * scatters the decision across every caller. This is the one reader. + * + * @example + * + */ +export function isMouseEnabled(settings: Pick | null | undefined): boolean { + + return settings?.ui?.mouse ?? DEFAULT_UI_MOUSE; + +} + /** * Complete default settings. * diff --git a/src/core/settings/index.ts b/src/core/settings/index.ts index a49bc430..8a004d9b 100644 --- a/src/core/settings/index.ts +++ b/src/core/settings/index.ts @@ -22,6 +22,7 @@ export type { StrictConfig, LoggingConfig, TeardownConfig, + UiConfig, Settings, RuleEvaluationResult, RulesEvaluationResult, @@ -49,6 +50,7 @@ export type { PathConfigSchemaType, StrictConfigSchemaType, LoggingConfigSchemaType, + UiConfigSchemaType, } from './schema.js'; // Defaults @@ -58,9 +60,11 @@ export { DEFAULT_PATH_CONFIG, DEFAULT_STRICT_CONFIG, DEFAULT_LOGGING_CONFIG, + DEFAULT_UI_MOUSE, SETTINGS_FILE_PATH, SETTINGS_DIR_PATH, createDefaultSettings, + isMouseEnabled, } from './defaults.js'; // Rule Evaluation diff --git a/src/core/settings/schema.ts b/src/core/settings/schema.ts index 6282229a..6b47672a 100644 --- a/src/core/settings/schema.ts +++ b/src/core/settings/schema.ts @@ -7,6 +7,7 @@ import { z } from 'zod'; import { PortSchema } from '../connection/defaults.js'; +import { DEFAULT_UI_MOUSE } from './defaults.js'; // ───────────────────────────────────────────────────────────── // Base Schemas @@ -168,6 +169,22 @@ export const TeardownConfigSchema = z.object({ postScript: z.string().optional(), }); +// ───────────────────────────────────────────────────────────── +// UI Config Schema +// ───────────────────────────────────────────────────────────── + +/** + * Terminal UI behaviour. + * + * `mouse` takes `DEFAULT_UI_MOUSE` here as well as being optional on the + * section, so `ui: {}` in settings.yml means the same thing as no `ui` section + * at all. Absent-section behaviour is `isMouseEnabled`'s job, off the same + * constant. + */ +export const UiConfigSchema = z.object({ + mouse: z.boolean().default(DEFAULT_UI_MOUSE), +}); + // Main Settings Schema // ───────────────────────────────────────────────────────────── @@ -183,6 +200,7 @@ export const SettingsSchema = z.object({ logging: LoggingConfigSchema.optional(), secrets: z.array(StageSecretSchema).optional(), teardown: TeardownConfigSchema.optional(), + ui: UiConfigSchema.optional(), }); // ───────────────────────────────────────────────────────────── @@ -199,6 +217,7 @@ export type BuildConfigSchemaType = z.infer; export type PathConfigSchemaType = z.infer; export type StrictConfigSchemaType = z.infer; export type LoggingConfigSchemaType = z.infer; +export type UiConfigSchemaType = z.infer; // ───────────────────────────────────────────────────────────── // Validation Error diff --git a/src/core/settings/types.ts b/src/core/settings/types.ts index 374acc7f..763f6a61 100644 --- a/src/core/settings/types.ts +++ b/src/core/settings/types.ts @@ -235,6 +235,34 @@ export interface TeardownConfig { } +/** + * Terminal UI configuration. + * + * Only affects `noorm ui`; headless commands ignore it. + * + * @example + * ```yaml + * ui: + * mouse: false + * ``` + */ +export interface UiConfig { + + /** + * Answer mouse clicks and wheel notches in lists and result grids. + * + * Absent means on — `isMouseEnabled` in `./defaults.js` is where that is + * decided, so read it rather than this field. Written `false` is the only + * thing that turns tracking off, and it is worth writing: enabling any + * 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). The symptom a user reports is "text selection stopped + * working", which does not point at noorm on its own. + */ + mouse?: boolean; + +} + /** * Complete settings configuration. * @@ -266,6 +294,9 @@ export interface Settings { /** Database teardown/reset configuration */ teardown?: TeardownConfig; + /** Terminal UI behaviour */ + ui?: UiConfig; + } /** diff --git a/src/core/shared/abort.ts b/src/core/shared/abort.ts new file mode 100644 index 00000000..3997371a --- /dev/null +++ b/src/core/shared/abort.ts @@ -0,0 +1,121 @@ +/** + * Cancellation primitives for work that can outlive the caller's interest in it. + * + * A database driver sitting on a dead socket has nothing to react to, so + * nothing here makes it stop. What it does is let the caller stop waiting + * without losing the handle: the abandoned promise still settles, and its + * value is routed to a salvage callback so whoever owns the resource can close + * it rather than leak it. + */ + +/** + * Raised when a caller stopped waiting for an operation. + * + * Deliberately not a failure of the operation: the work may well still be + * running. Callers test for it so they can word the outcome honestly instead + * of reporting a database error that never happened. + * + * @example + * const [conn, err] = await attempt(() => createConnection(config, name, {}, signal)); + * + * if (err instanceof OperationAbortedError) return { ok: false, aborted: true }; + */ +export class OperationAbortedError extends Error { + + override readonly name = 'OperationAbortedError' as const; + + constructor(message = 'Stopped waiting for the database') { + + super(message); + + } + +} + +/** + * Throw immediately when `signal` has already been aborted. + * + * Guards the entry of an operation, so an abort that landed before the call + * still prevents the work instead of starting something nobody wants. + * + * @example + * throwIfAborted(signal); + * const conn = await openConnection(config); + */ +export function throwIfAborted(signal?: AbortSignal, message?: string): void { + + if (signal?.aborted) { + + throw new OperationAbortedError(message); + + } + +} + +/** + * Settle with `work`, or reject as soon as `signal` aborts — whichever is first. + * + * The abandoned `work` keeps running, because a promise cannot be un-started. + * `onAbandoned` is the only place its eventual value can be reclaimed: for a + * connection that means closing a pool nothing else holds a reference to. A + * late rejection is swallowed, since the one listener has already gone and an + * unhandled rejection would take the process with it. + * + * @example + * // The caller gets control back on abort; a connection that opens later is + * // still closed rather than left half-open. + * return raceAbort(openConnection(config), signal, discardConnection); + */ +export function raceAbort( + work: Promise, + signal?: AbortSignal, + onAbandoned?: (value: T) => void, +): Promise { + + if (!signal) return work; + + // Seeded from `aborted`, and attached before the guard below throws: the + // argument promise already exists by the time this function runs, so it + // needs a handler on every path out of here. + let abandoned = signal.aborted; + + void work.then( + (value) => { + + if (abandoned) onAbandoned?.(value); + + }, + () => undefined, + ); + + throwIfAborted(signal); + + return new Promise((resolve, reject) => { + + const onAbort = () => { + + abandoned = true; + reject(new OperationAbortedError()); + + }; + + signal.addEventListener('abort', onAbort, { once: true }); + + void work.then( + (value) => { + + signal.removeEventListener('abort', onAbort); + resolve(value); + + }, + (error) => { + + signal.removeEventListener('abort', onAbort); + reject(error); + + }, + ); + + }); + +} diff --git a/src/core/shared/index.ts b/src/core/shared/index.ts index db1c04f6..fafd72b1 100644 --- a/src/core/shared/index.ts +++ b/src/core/shared/index.ts @@ -8,6 +8,9 @@ // Errors export { getSqlErrorMessage } from './errors.js'; +// Cancellation +export { OperationAbortedError, throwIfAborted, raceAbort } from './abort.js'; + // Files export { filterFilesByPaths, findUnmatchedIncludePatterns, findUnmatchedExcludePatterns } from './files.js'; diff --git a/src/core/sql-terminal/executor.ts b/src/core/sql-terminal/executor.ts index f8ce37a9..5c5830ba 100644 --- a/src/core/sql-terminal/executor.ts +++ b/src/core/sql-terminal/executor.ts @@ -5,10 +5,11 @@ */ import { sql } from 'kysely'; import { attempt } from '@logosdx/utils'; -import type { Kysely } from 'kysely'; +import type { Kysely, QueryResult } from 'kysely'; import { observer } from '../observer.js'; import { assertPolicy, classifyStatements } from '../policy/index.js'; +import { OperationAbortedError, raceAbort } from '../shared/abort.js'; import type { Channel, ConfigAccess, Permission, SqlClass } from '../policy/index.js'; import type { Dialect } from '../connection/types.js'; import type { SqlExecutionResult } from './types.js'; @@ -20,6 +21,223 @@ const CLASS_PERMISSION: Record = { ddl: 'sql:ddl', }; +/** + * How a dialect is told to stop a query that is already running. + * + * `sessionId` reads back the server's own identifier for the connection the + * query will run on; `cancel` is the statement that stops it, issued from a + * *different* connection because the first one is busy. + */ +interface ServerCancel { + sessionId: string; + cancel: (db: Kysely, sessionId: number) => Promise; +} + +/** + * Dialects where aborting sends the server a cancel rather than only stopping + * the client from listening. + * + * Absent by design: + * - **mssql**: tedious exposes `request.cancel()`, but Kysely's `MssqlDialect` + * owns the `Request` object and never hands it out, so there is nothing to + * call it on from here. + * - **sqlite**: in-process and single-connection; there is no second + * connection from which to interrupt the first. + */ +const SERVER_CANCEL: Partial> = { + postgres: { + sessionId: 'select pg_backend_pid() as id', + cancel: (db, sessionId) => sql`select pg_cancel_backend(${sessionId})`.execute(db), + }, + mysql: { + // KILL cannot be prepared, so the id is interpolated. It comes from + // connection_id() and is checked to be a positive integer before it + // gets here, never from user input. + sessionId: 'select connection_id() as id', + cancel: (db, sessionId) => sql.raw(`kill query ${sessionId}`).execute(db), + }, +}; + +/** + * Whether aborting a query on this dialect actually stops work on the server. + * + * Callers use it to word the outcome: "cancelled" is only true where this + * returns true, and "stopped waiting" is the honest phrasing everywhere else. + * + * @example + * const message = hasServerSideCancel(dialect) + * ? 'Cancelled. The server was asked to stop the query.' + * : 'Stopped waiting. The query may still be running on the server.'; + */ +export function hasServerSideCancel(dialect: Dialect): boolean { + + return SERVER_CANCEL[dialect] !== undefined; + +} + +/** Message for an abort that reached the server. */ +const SERVER_CANCEL_MESSAGE = 'Cancelled. The server was asked to stop the query.'; + +/** Message for an abort that only stopped the client waiting. */ +const STOPPED_WAITING_MESSAGE = 'Stopped waiting. The query may still be running on the server.'; + +/** + * The honest one-line outcome of aborting a query on `dialect`. + * + * Lives next to the strategy table so the UI cannot drift into claiming a + * cancellation the dialect never had. A screen that reports an abort before + * the executor returns uses this rather than wording its own. + * + * @example + * setResult({ success: false, errorMessage: abortMessageFor('mssql'), durationMs: 0 }); + */ +export function abortMessageFor(dialect: Dialect): string { + + return hasServerSideCancel(dialect) ? SERVER_CANCEL_MESSAGE : STOPPED_WAITING_MESSAGE; + +} + +/** + * Options for a raw SQL execution. + */ +export interface ExecuteSqlOptions { + /** Abort to stop waiting for the query. */ + signal?: AbortSignal; + + /** + * Dialect of `db`. Only used to decide whether a cancel can be sent to the + * server; without it an abort degrades to stopping the client. + */ + dialect?: Dialect; +} + +/** + * Whether a cancel was armed for the query, and so whether an abort reached the + * server or only stopped this process waiting. + * + * Mutable because the answer is only known part-way through the execution, and + * the caller needs it after the abort has already handed control back. + */ +interface CancelArming { + armed: boolean; +} + +/** + * Read the server's session identifier out of a `sessionId` probe result. + * + * Returns undefined for anything that is not a positive integer, which is what + * keeps the mysql `KILL` interpolation safe: the id is interpolated into a + * statement that cannot be prepared, so this is the only thing standing between + * a driver returning something unexpected and that string. + * + * Exported for its own tests. It is a validator, and the value it rejects never + * occurs on a healthy connection, so nothing else can exercise it. + * + * @example + * readSessionId([{ id: '4711' }]) // => 4711 + * readSessionId([{ id: '4711; drop table users' }]) // => undefined + */ +export function readSessionId(rows: readonly { id?: unknown }[]): number | undefined { + + const raw = rows[0]?.id; + const id = Number(raw); + + if (!Number.isInteger(id) || id <= 0) return undefined; + + return id; + +} + +/** + * Run `query` on one pinned connection, asking the server to kill it on abort. + * + * Pinning is what makes the cancel land on the right session: read the id from + * the pool and the query is free to run on a different connection, so the kill + * would hit an idle one. The cancel itself goes through `db` — the pool — for + * the same reason the pinned connection cannot send it: it is busy with the + * query being cancelled. + */ +async function runWithServerCancel( + db: Kysely, + query: string, + strategy: ServerCancel, + signal: AbortSignal, + arming: CancelArming, +): Promise> { + + return db.connection().execute(async (pinned) => { + + const probe = await sql.raw<{ id?: unknown }>(strategy.sessionId).execute(pinned); + const sessionId = readSessionId(probe.rows); + + if (sessionId === undefined) { + + return sql.raw(query).execute(pinned); + + } + + const onAbort = () => { + + // Fire and forget: the caller has already been handed back control + // by raceAbort, and a cancel that cannot be delivered leaves the + // outcome exactly where it would have been without one. + void attempt(() => strategy.cancel(db, sessionId)); + + }; + + // Set before the listener rather than inside it: `once` guarantees the + // listener runs on abort, and reading a flag set by one abort listener + // from another one's continuation would depend on dispatch order. + arming.armed = true; + + signal.addEventListener('abort', onAbort, { once: true }); + + // finally, not attempt(): the listener has to come off whether the + // query finished, failed, or was killed out from under us. + try { + + return await sql.raw(query).execute(pinned); + + } + finally { + + signal.removeEventListener('abort', onAbort); + + } + + }); + +} + +/** + * Execute `query`, honouring `options.signal`. + * + * Everything about *how* an abort is handled lives here; the caller above only + * has to know that an `OperationAbortedError` came back. + */ +function runQuery( + db: Kysely, + query: string, + options: ExecuteSqlOptions, + arming: CancelArming, +): Promise> { + + const { signal, dialect } = options; + + if (!signal) return sql.raw(query).execute(db); + + const strategy = dialect ? SERVER_CANCEL[dialect] : undefined; + + if (!strategy) { + + return raceAbort(sql.raw(query).execute(db), signal); + + } + + return raceAbort(runWithServerCancel(db, query, strategy, signal, arming), signal); + +} + /** * Policy inputs for the ad-hoc SQL gate. Every production ad-hoc SQL surface * (RPC `sql` command, CLI `sql`, TUI SQL terminal) passes this: it is the @@ -47,27 +265,39 @@ export interface SqlPolicyGate { * @param db - Kysely database instance * @param query - Raw SQL query to execute * @param configName - Config name for event context + * @param options - Cancellation inputs; omit them and nothing changes * @returns Execution result with columns, rows, and metadata */ export async function executeRawSqlUnchecked( db: Kysely, query: string, configName: string, + options: ExecuteSqlOptions = {}, ): Promise { const start = performance.now(); observer.emit('sql-terminal:execute:before', { query, configName }); - const [result, err] = await attempt(() => - sql.raw(query).execute(db), - ); + const arming: CancelArming = { armed: false }; + + const [result, err] = await attempt(() => runQuery(db, query, options, arming)); const durationMs = performance.now() - start; if (err) { - const errorMessage = err instanceof Error ? err.message : String(err); + const wasAborted = err instanceof OperationAbortedError; + + // `arming`, not the dialect's capability: a session-id probe that came + // back with nothing leaves a postgres query with no cancel behind it, + // and reporting one anyway is the overclaim this whole distinction + // exists to avoid. + const serverWasAsked = wasAborted && arming.armed; + + const errorMessage = wasAborted + ? (serverWasAsked ? SERVER_CANCEL_MESSAGE : STOPPED_WAITING_MESSAGE) + : (err instanceof Error ? err.message : String(err)); observer.emit('sql-terminal:execute:after', { query, @@ -77,6 +307,17 @@ export async function executeRawSqlUnchecked( error: errorMessage, }); + if (wasAborted) { + + return { + success: false, + errorMessage, + durationMs, + aborted: serverWasAsked ? 'server-cancel-requested' : 'stopped-waiting', + }; + + } + return { success: false, errorMessage, @@ -127,6 +368,8 @@ export async function executeRawSqlUnchecked( * @param query - Raw SQL query to execute * @param configName - Config name for event context * @param gate - Policy inputs the query is classified and checked against + * @param signal - Abort to stop waiting. Whether that also stops the server + * depends on the dialect; the result says which happened. * @returns Execution result with columns, rows, and metadata * * @throws Error carrying the policy's blockedReason when `gate` denies. @@ -153,12 +396,13 @@ export async function executeRawSql( query: string, configName: string, gate: SqlPolicyGate, + signal?: AbortSignal, ): Promise { const statementClass = classifyStatements(query, gate.dialect); assertPolicy(gate.channel, { name: configName, access: gate.access }, CLASS_PERMISSION[statementClass]); - return executeRawSqlUnchecked(db, query, configName); + return executeRawSqlUnchecked(db, query, configName, { signal, dialect: gate.dialect }); } diff --git a/src/core/sql-terminal/index.ts b/src/core/sql-terminal/index.ts index 1f13e591..23e6e1ea 100644 --- a/src/core/sql-terminal/index.ts +++ b/src/core/sql-terminal/index.ts @@ -13,5 +13,5 @@ export * from './history.js'; * symbol ends up one autocomplete away from a production call site. The * tests that legitimately need it import `./executor.js` directly. */ -export { executeRawSql } from './executor.js'; -export type { SqlPolicyGate } from './executor.js'; +export { executeRawSql, hasServerSideCancel, abortMessageFor } from './executor.js'; +export type { SqlPolicyGate, ExecuteSqlOptions } from './executor.js'; diff --git a/src/core/sql-terminal/types.ts b/src/core/sql-terminal/types.ts index ffe4c183..4a959be1 100644 --- a/src/core/sql-terminal/types.ts +++ b/src/core/sql-terminal/types.ts @@ -64,6 +64,18 @@ export interface SqlExecutionResult { /** Execution duration in milliseconds */ durationMs: number; + /** + * How an execution ended when the caller stopped waiting. Absent when the + * query ran to completion, whether it succeeded or failed. + * + * `server-cancel-requested` means a cancel was sent to the database on a + * second connection, so the query is being stopped there. `stopped-waiting` + * means only the client let go: the query may well still be running and + * holding resources. The distinction exists so the UI can say which one + * happened rather than calling both of them "cancelled". + */ + aborted?: 'server-cancel-requested' | 'stopped-waiting'; + } /** diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 0c8323e7..29dd79d1 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -16,7 +16,7 @@ */ import { useState, useCallback, useEffect } from 'react'; import type { ReactElement } from 'react'; -import { Box, Text, Spacer, useInput } from 'ink'; +import { Box, Text, Spacer, useInput, useWindowSize } from 'ink'; import { useFocusScope } from './focus.js'; @@ -33,7 +33,10 @@ import { useGlobalModes, useDryRunMode, useForceMode, + useSettings, } from './app-context.js'; +import { MouseProvider, useMouseTransport } from './mouse.js'; +import { isMouseEnabled } from '../core/settings/defaults.js'; import { ToastProvider, ToastRenderer, LogViewerOverlay, useToast } from './components/index.js'; import { ShutdownProvider } from './shutdown.js'; import { ConnectionProvider, useConnectionContext } from './providers/ConnectionProvider.js'; @@ -50,6 +53,10 @@ function HelpScreen({ onClose }: { onClose: () => void }): ReactElement { const globalModes = useGlobalModes(); const { isFocused } = useFocusScope('HelpScreen'); + // Read from the transport rather than from settings, so the line describes + // what the terminal is actually in rather than what the file asked for. + const { enabled: mouseEnabled } = useMouseTransport(); + // Any key closes help useInput(() => { @@ -105,6 +112,23 @@ function HelpScreen({ onClose }: { onClose: () => void }): ReactElement { + + {/* + The escape hatch for the mouse. It is on unless a project turns + it off, and turning it on takes click-drag text selection away + from the terminal — a symptom a user reports as "text selection + stopped working", which points at their terminal rather than at + noorm. Naming the setting here is what closes that gap. + */} + + Mouse {mouseEnabled ? 'on' : 'off'}. + + ui.mouse: {mouseEnabled ? 'false' : 'true'} + + + {' '}in .noorm/settings.yml {mouseEnabled ? 'restores text selection' : 'enables clicks'} + + ); @@ -209,6 +233,7 @@ function AppShell(): ReactElement { const { navigate } = useRouter(); const { showToast } = useToast(); const { updateInfo, installing } = useUpdateChecker(); + const { rows: terminalHeight } = useWindowSize(); // Show toast when update available (non-major updates) useEffect(() => { @@ -318,7 +343,13 @@ function AppShell(): ReactElement { onOpenSqlTerminal={handleOpenSqlTerminal} onDebugMode={handleDebugMode} > - + {/* height, not minHeight: the shell owns the alternate screen, so it + claims the full window instead of growing to fit its content. */} + {/* Header */} - {/* Help Screen - Full screen takeover */} + {/* Hidden rather than unmounted: the shell claims the whole window, + so an overlay drawn beside it would push the top out of reach. + Unmounting instead would take the screen's state with it — the + half-filled form you pressed ? from. */} {showHelp && } - {/* Log Viewer - Full screen takeover */} {showLogViewer && } ); } +/** + * Turns the mouse transport on unless settings say not to. + * + * Separate from `MouseProvider` because the transport must not depend on the + * app context: it has to be testable on its own, and the enable sequence has to + * wait for the setting rather than for `render()`. + * + * `settings` is null while the managers load, and that is *unknown*, not + * *absent* — absent is a loaded settings object with no `ui` section, which + * `isMouseEnabled` reads as on. Waiting matters now that the default is on: a + * project that wrote `ui: { mouse: false }` would otherwise spend every startup + * with tracking enabled and its text selection broken, for a setting it + * explicitly wrote. So the flag reads false first and flips once, which is + * exactly when the escape sequence should go out. + */ +function MouseFromSettings({ children }: { children: ReactElement }): ReactElement { + + const { settings } = useSettings(); + + return ( + + {children} + + ); + +} + /** * Props for the App component. */ @@ -401,15 +461,17 @@ export function App({ - - - - - - - - - + + + + + + + + + + + diff --git a/src/tui/components/dialogs/FilePicker.tsx b/src/tui/components/dialogs/FilePicker.tsx index 95d7da2e..3de40f37 100644 --- a/src/tui/components/dialogs/FilePicker.tsx +++ b/src/tui/components/dialogs/FilePicker.tsx @@ -17,13 +17,24 @@ */ import { useState, useCallback, useMemo } from 'react'; import { Box, Text, useInput } from 'ink'; -import { TextInput } from '@inkjs/ui'; +import { TextInput } from '../forms/TextInput.js'; import type { ReactElement } from 'react'; import { useFocusScope } from '../../focus.js'; +import { useViewportRows } from '../../hooks/useViewportRows.js'; import { Panel } from '../layout/Panel.js'; +/** + * Rows this component costs a screen beyond the shared screen reserve. + * + * It draws its own search row, status row, and help row with a gap either side + * of the list, which is six rows — but it also brings its own Panel and its own + * help line, so the two rows the shared reserve holds back for a hotkey footer + * are not spent twice. + */ +const PICKER_CHROME_ROWS = 4; + /** * File picker modes. */ @@ -45,9 +56,18 @@ export interface FilePickerProps { /** Callback when cancelled */ onCancel: () => void; - /** Maximum visible files in the list */ + /** + * Maximum visible files in the list. Defaults to what the terminal has room + * for once this component's own chrome is accounted for. + */ visibleCount?: number; + /** + * Rows of the screen that belong to something other than this picker, such + * as a dry-run banner stacked above it. Only the parent knows these. + */ + reserveRows?: number; + /** Focus scope label */ focusLabel?: string; } @@ -88,10 +108,14 @@ export function FilePicker({ selected: initialSelected = [], onSelect, onCancel, - visibleCount = 8, + visibleCount: pinnedVisibleCount, + reserveRows = 0, focusLabel = 'FilePicker', }: FilePickerProps): ReactElement { + const availableRows = useViewportRows(PICKER_CHROME_ROWS + reserveRows); + const visibleCount = pinnedVisibleCount ?? availableRows; + const { isFocused } = useFocusScope(focusLabel); // State diff --git a/src/tui/components/dialogs/ProtectedConfirm.tsx b/src/tui/components/dialogs/ProtectedConfirm.tsx index aeb3d63f..802492aa 100644 --- a/src/tui/components/dialogs/ProtectedConfirm.tsx +++ b/src/tui/components/dialogs/ProtectedConfirm.tsx @@ -17,7 +17,7 @@ */ import { useState, useCallback, useRef } from 'react'; import { Box, Text, useInput } from 'ink'; -import { TextInput } from '@inkjs/ui'; +import { TextInput } from '../forms/TextInput.js'; import type { ReactElement } from 'react'; diff --git a/src/tui/components/forms/Form.tsx b/src/tui/components/forms/Form.tsx index 4906f9da..66a2096e 100644 --- a/src/tui/components/forms/Form.tsx +++ b/src/tui/components/forms/Form.tsx @@ -1,10 +1,22 @@ /** - * Form component - multi-field form with validation. + * Form component - multi-field form with a browse/edit navigation model. * - * Orchestrates multiple input fields with keyboard navigation: - * - ↑/↓ navigate between fields (Tab also advances) - * - Enter submits the form (or selects option in select fields) - * - Esc clears current field or cancels if empty + * Layout is two aligned columns: a label gutter sized from the longest label, + * then the value. One row per field, no spacer rows, and a select collapses to + * its current value until it is being edited. A 10-field config form fits on a + * short terminal instead of running off the bottom. + * + * Navigation has two modes, because a single mode cannot serve both "move + * around the form" and "change this value" with the same arrow keys: + * + * - Browse (default): ↑/↓ and Tab move between fields on EVERY field type, + * Enter opens the active field for editing, Esc cancels the form. Past the + * last field the cursor lands on the action row, where Enter submits. + * - Edit: the field owns input. Enter commits and returns to browse, Esc puts + * back the value the field had when edit mode opened. + * + * Enter is therefore the mode switch, not the submit key - submission lives on + * the action row so "down, then enter" is the only model to learn. * * @example * ```tsx @@ -20,14 +32,42 @@ * /> * ``` */ -import { useState, useCallback, useMemo, useId, useEffect, useRef } from 'react'; -import { Box, Text, useInput } from 'ink'; -import { TextInput } from '@inkjs/ui'; +import { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import { Box, Text, useInput, useWindowSize } from 'ink'; +import { TextInput } from './TextInput.js'; import type { ReactElement } from 'react'; import { useFocusScope } from '../../focus.js'; +/** Options an expanded select shows before it starts scrolling. */ +const SELECT_VISIBLE_OPTIONS = 4; + +/** Widest label gutter. Anything longer truncates instead of moving the value column. */ +const LABEL_GUTTER_MAX = 22; + +/** Columns the `›` active marker occupies. */ +const MARKER_WIDTH = 2; + +/** Columns between the label gutter and the value column. */ +const LABEL_VALUE_GAP = 2; + +/** + * Rows the form spends on chrome rather than fields: two scroll indicators, the + * spacer above the action row, the action row, and the hint row. + */ +const FORM_CHROME_ROWS = 5; + +/** + * Rows the app shell and its Panel claim before a Form sees the terminal: + * breadcrumb header (2), status bar (2), panel border (2), title + spacer (2), + * vertical padding (2). Only used when a consumer does not pass `height`. + */ +const SCREEN_CHROME_ROWS = 10; + +/** Floor for the derived budget, so a tiny terminal still renders something usable. */ +const MIN_FORM_ROWS = 8; + /** * Form field types. */ @@ -55,8 +95,8 @@ interface SelectFieldProps { /** * Inline SelectField component with proper keyboard handling. * - * Manages its own highlighted index and keyboard navigation. - * Enter confirms selection and moves to next field. + * Only mounted while its field is in edit mode, so it owns up/down/enter + * without ever competing with the Form's own field navigation. */ function SelectField({ options, @@ -124,19 +164,17 @@ function SelectField({ }); - // Calculate visible window (show 4 options max) - const visibleCount = 4; const startIndex = useMemo(() => { - if (options.length <= visibleCount) return 0; + if (options.length <= SELECT_VISIBLE_OPTIONS) return 0; - const halfVisible = Math.floor(visibleCount / 2); + const halfVisible = Math.floor(SELECT_VISIBLE_OPTIONS / 2); let start = highlightedIndex - halfVisible; if (start < 0) start = 0; - if (start > options.length - visibleCount) { + if (start > options.length - SELECT_VISIBLE_OPTIONS) { - start = options.length - visibleCount; + start = options.length - SELECT_VISIBLE_OPTIONS; } @@ -144,13 +182,13 @@ function SelectField({ }, [highlightedIndex, options.length]); - const visibleOptions = options.slice(startIndex, startIndex + visibleCount); + const visibleOptions = options.slice(startIndex, startIndex + SELECT_VISIBLE_OPTIONS); const hasMoreAbove = startIndex > 0; - const hasMoreBelow = startIndex + visibleCount < options.length; + const hasMoreBelow = startIndex + SELECT_VISIBLE_OPTIONS < options.length; return ( - {hasMoreAbove && ↑ more} + {hasMoreAbove && ↑ more} {visibleOptions.map((option, visibleIdx) => { @@ -173,7 +211,7 @@ function SelectField({ })} - {hasMoreBelow && ↓ more} + {hasMoreBelow && ↓ more} ); @@ -186,7 +224,7 @@ export interface FormField { /** Unique field identifier */ key: string; - /** Display label */ + /** Display label. Truncated in the gutter if longer than the cap. */ label: string; /** Field type */ @@ -204,6 +242,12 @@ export interface FormField { /** Placeholder text for text/password */ placeholder?: string; + /** + * Short qualifier rendered dim after the value, e.g. `(locked)`. + * Keeps the label short enough to survive the gutter cap. + */ + hint?: string; + /** Custom validation function */ validate?: (value: string | boolean) => string | undefined; } @@ -228,7 +272,7 @@ export interface FormProps { /** Callback when form is submitted with valid values */ onSubmit: (values: FormValues) => void; - /** Callback when form is cancelled */ + /** Callback when form is cancelled. Omit it and the Cancel button is not rendered. */ onCancel?: () => void; /** Submit button label */ @@ -243,14 +287,80 @@ export interface FormProps { /** Busy label to show while busy */ busyLabel?: string; + /** + * Called when Escape is pressed while `busy`. Supplying it also advertises + * the hatch next to the busy label — a busy state that can be cancelled + * and does not say so is one nobody tries. + * + * Omit it and Escape keeps falling through to `onCancel`. + */ + onCancelBusy?: () => void; + /** Error message to show in toolbar (right side) */ statusError?: string; + + /** + * Total rows the form may occupy, including its action and hint rows. + * Pass it when the screen knows its own chrome; otherwise the form derives + * a budget from the terminal height. + */ + height?: number; +} + +/** + * Which action stop the cursor is on once it moves past the last field. + */ +type FormAction = 'submit' | 'cancel'; + +/** + * Truncate a label to the gutter width, marking the cut with an ellipsis. + */ +function truncateLabel(label: string, max: number): string { + + if (max <= 0) return ''; + + if (label.length <= max) return label; + + return `${label.slice(0, max - 1)}…`; + +} + +/** + * Browse-mode text for a field's value, so a select costs one row like + * everything else until it is opened. + */ +function displayValue(field: FormField, value: string | boolean | undefined): string { + + if (field.type === 'checkbox') { + + return value ? '☑ Yes' : '☐ No'; + + } + + const text = typeof value === 'string' ? value : ''; + + if (field.type === 'password') { + + return '•'.repeat(text.length); + + } + + if (field.type === 'select') { + + const option = field.options?.find((opt) => opt.value === text); + + return option?.label ?? text; + + } + + return text; + } /** * Form component. * - * A multi-field form with keyboard navigation and validation. + * A multi-field form with browse/edit keyboard navigation and validation. * Pushes to the focus stack on mount. */ export function Form({ @@ -261,14 +371,20 @@ export function Form({ focusLabel = 'Form', busy = false, busyLabel = 'Working...', + onCancelBusy, statusError, + height, }: FormProps): ReactElement { const { isFocused } = useFocusScope(focusLabel); - const _formId = useId(); - // Form state + // useWindowSize, not useStdout: stdout.rows mutates on resize without telling + // React, which would freeze the derived budget at mount size. + const { rows: terminalRows } = useWindowSize(); + const [activeIndex, setActiveIndex] = useState(0); + const [editingKey, setEditingKey] = useState(null); + const [editSnapshot, setEditSnapshot] = useState(''); const [values, setValues] = useState(() => { const initial: FormValues = {}; @@ -300,31 +416,38 @@ export function Form({ const [submitted, setSubmitted] = useState(false); const [submitting, setSubmitting] = useState(false); - // Get current field - const currentField = fields[activeIndex]; + const actions: FormAction[] = useMemo( + () => (onCancel ? ['submit', 'cancel'] : ['submit']), + [onCancel], + ); + + const stopCount = fields.length + actions.length; + const activeAction = activeIndex >= fields.length ? actions[activeIndex - fields.length] : undefined; + const currentField = activeIndex < fields.length ? fields[activeIndex] : undefined; + const isEditing = editingKey !== null; - // Navigate fields - const nextField = useCallback(() => { + // A field list that shrinks (SecretValueForm swaps fields by mode) must not + // leave the cursor pointing past the end. + useEffect(() => { - setActiveIndex((i) => (i + 1) % fields.length); + setActiveIndex((i) => (i >= stopCount ? Math.max(0, stopCount - 1) : i)); - }, [fields.length]); + }, [stopCount]); - const prevField = useCallback(() => { + const moveBy = useCallback((delta: number) => { - setActiveIndex((i) => (i - 1 + fields.length) % fields.length); + setActiveIndex((i) => (i + delta + stopCount) % stopCount); - }, [fields.length]); + }, [stopCount]); - // Update field value - stable reference (no dependencies) const updateValue = useCallback((key: string, value: string | boolean) => { setValues((prev) => ({ ...prev, [key]: value })); - // Clear error when value changes setErrors((prev) => { - if (!prev[key]) return prev; // No change needed + if (!prev[key]) return prev; + const next = { ...prev }; delete next[key]; @@ -334,7 +457,9 @@ export function Form({ }, []); - // Create stable onChange handlers for each field (memoized by field key) + // TextInput reports changes from a useEffect keyed on the onChange identity, + // so an inline arrow would re-fire the last change on every render - loud + // enough to overwrite an Esc revert with the value it just discarded. const onChangeHandlers = useRef void>>({}); const getOnChangeHandler = useCallback( @@ -352,35 +477,29 @@ export function Form({ [updateValue], ); - // Validate all fields - const validateAll = useCallback((): boolean => { + const collectErrors = useCallback((): FormErrors => { - const newErrors: FormErrors = {}; + const found: FormErrors = {}; for (const field of fields) { const value = values[field.key]; - // Required check - if (field.required) { + if (field.required && (value === '' || value === undefined)) { - if (value === '' || value === undefined) { + found[field.key] = 'Required'; - newErrors[field.key] = 'Required'; - continue; - - } + continue; } - // Custom validation if (field.validate) { const error = field.validate(value ?? ''); if (error) { - newErrors[field.key] = error; + found[field.key] = error; } @@ -388,140 +507,208 @@ export function Form({ } - setErrors(newErrors); - - return Object.keys(newErrors).length === 0; + return found; }, [fields, values]); - // Handle submit const handleSubmit = useCallback(() => { - // Prevent double-submit if (busy || submitting) return; + const found = collectErrors(); + const firstInvalid = fields.findIndex((field) => found[field.key]); + setSubmitted(true); setSubmitting(true); + setErrors(found); - if (validateAll()) { + if (firstInvalid === -1) { onSubmit(values); - // Reset submitting - parent's `busy` prop guards against double-submit - // during async operations - setSubmitting(false); - } else { - // Validation failed, allow retry - setSubmitting(false); + // Land on the offending field; it may be outside the current window. + setActiveIndex(firstInvalid); } - }, [busy, submitting, validateAll, values, onSubmit]); + // Parent's `busy` prop guards double-submit across the async work. + setSubmitting(false); + + }, [busy, submitting, collectErrors, fields, values, onSubmit]); - // Handle escape - clear field or cancel - const handleEscape = useCallback(() => { + const beginEdit = useCallback(() => { const field = fields[activeIndex]; if (!field) return; - const value = values[field.key]; + // A checkbox has nothing to type into, so Enter just flips it. + if (field.type === 'checkbox') { - // Check if field has content to clear - const hasContent = - field.type === 'checkbox' ? value === true : typeof value === 'string' && value !== ''; + updateValue(field.key, !values[field.key]); - if (hasContent) { - - // Clear the field - updateValue(field.key, field.type === 'checkbox' ? false : ''); + return; } - else { - // Field is empty, cancel form - onCancel?.(); + if (field.type === 'select' && !field.options?.length) return; + + setEditSnapshot(values[field.key] ?? ''); + setEditingKey(field.key); + + }, [activeIndex, fields, values, updateValue]); + + const commitEdit = useCallback((finalValue?: string) => { + + if (editingKey !== null && finalValue !== undefined) { + + updateValue(editingKey, finalValue); } - }, [activeIndex, fields, values, updateValue, onCancel]); + setEditingKey(null); - // Keyboard handling for navigation - // Note: Select fields handle their own up/down/enter - // Note: Text/password fields handle Enter via TextInput's onSubmit - useInput((input, key) => { + }, [editingKey, updateValue]); - if (!isFocused) return; + const revertEdit = useCallback(() => { + + if (editingKey === null) return; + + const key = editingKey; + + setValues((prev) => ({ ...prev, [key]: editSnapshot })); + setEditingKey(null); + + }, [editingKey, editSnapshot]); - const fieldType = currentField?.type; - const isSelectField = fieldType === 'select'; - const isTextInput = fieldType === 'text' || fieldType === 'password'; + const activateStop = useCallback(() => { - // Shift+Tab - moves to previous field - if (key.tab && key.shift) { + if (activeAction === 'submit') { - prevField(); + handleSubmit(); return; } - // Tab - moves to next field - if (key.tab) { + if (activeAction === 'cancel') { - nextField(); + onCancel?.(); return; } - // Arrow keys - only handle if NOT on a select field - // (select fields handle their own arrow navigation) - if (!isSelectField) { + beginEdit(); + + }, [activeAction, handleSubmit, onCancel, beginEdit]); + + // Note: the guard is inside the handler, not useInput's `isActive` option - + // isFocused is false on the first render and `isActive` would skip + // registration permanently. + useInput((input, key) => { + + if (!isFocused) return; + + if (isEditing) { - if (key.downArrow) { + // Everything else belongs to the field: TextInput's own handler for + // text/password, SelectField's for select. + if (key.escape) { - nextField(); + revertEdit(); return; } - if (key.upArrow) { + if (key.tab) { - prevField(); + commitEdit(); + moveBy(key.shift ? -1 : 1); return; } + if (key.return) { + + commitEdit(); + + } + + return; + } - // Enter - submit form - // Skip for select (uses Enter to select option) - // Skip for text/password (TextInput handles via onSubmit) - if (key.return && !isSelectField && !isTextInput) { + if (key.tab) { - handleSubmit(); + moveBy(key.shift ? -1 : 1); + + return; + + } + + if (key.downArrow) { + + moveBy(1); + + return; + + } + + if (key.upArrow) { + + moveBy(-1); + + return; + + } + + if (activeAction && (key.leftArrow || key.rightArrow)) { + + setActiveIndex((i) => { + + const next = key.leftArrow ? i - 1 : i + 1; + + return Math.min(stopCount - 1, Math.max(fields.length, next)); + + }); return; } - // Escape if (key.escape) { - handleEscape(); + // While busy, Escape belongs to the operation in flight: leaving + // the screen would abandon it rather than stop it. + if (busy && onCancelBusy) { + + onCancelBusy(); + + return; + + } + + onCancel?.(); + + return; + + } + + if (key.return) { + + activateStop(); return; } - // Space toggles checkbox - if (fieldType === 'checkbox' && input === ' ' && currentField) { + if (input === ' ' && currentField?.type === 'checkbox') { updateValue(currentField.key, !values[currentField.key]); @@ -529,84 +716,192 @@ export function Form({ }); + const gutterWidth = useMemo(() => { + + let widest = 0; + + for (const field of fields) { + + const width = field.label.length + (field.required ? 1 : 0); + + if (width > widest) widest = width; + + } + + return Math.min(widest, LABEL_GUTTER_MAX); + + }, [fields]); + + const labelColumnWidth = MARKER_WIDTH + gutterWidth + LABEL_VALUE_GAP; + + // An expanded select eats rows the field list would otherwise get. + const expandedExtraRows = useMemo(() => { + + const field = fields.find((candidate) => candidate.key === editingKey); + + if (field?.type !== 'select' || !field.options) return 0; + + const listed = Math.min(field.options.length, SELECT_VISIBLE_OPTIONS); + const indicators = field.options.length > SELECT_VISIBLE_OPTIONS ? 2 : 0; + + return listed + indicators - 1; + + }, [fields, editingKey]); + + const budget = height ?? Math.max(terminalRows - SCREEN_CHROME_ROWS, MIN_FORM_ROWS); + const visibleCount = Math.max(1, budget - FORM_CHROME_ROWS - expandedExtraRows); + + // Same windowing as SelectList: centre the focused row, clamp to the ends. + const startIndex = useMemo(() => { + + if (fields.length <= visibleCount) return 0; + + const windowFocus = Math.min(activeIndex, fields.length - 1); + const halfVisible = Math.floor(visibleCount / 2); + let start = windowFocus - halfVisible; + + if (start < 0) start = 0; + if (start > fields.length - visibleCount) { + + start = fields.length - visibleCount; + + } + + return start; + + }, [activeIndex, fields.length, visibleCount]); + + const visibleFields = fields.slice(startIndex, startIndex + visibleCount); + const hasMoreAbove = startIndex > 0; + const hasMoreBelow = startIndex + visibleCount < fields.length; + + const hintText = useMemo(() => { + + if (isEditing) { + + const field = fields.find((candidate) => candidate.key === editingKey); + + return field?.type === 'select' + ? '↑↓ option ↵ commit esc revert' + : '↵ commit esc revert tab commit + next'; + + } + + if (activeAction) { + + return '↑↓ move ←→ button ↵ activate esc cancel'; + + } + + if (currentField?.type === 'checkbox') { + + return '↑↓ field ↵/space toggle esc cancel'; + + } + + return '↑↓ field ↵ edit esc cancel'; + + }, [isEditing, fields, editingKey, activeAction, currentField]); + return ( - - {fields.map((field, index) => { + + {hasMoreAbove && {' '}↑ {startIndex} more} + + {visibleFields.map((field, visibleIndex) => { + const index = startIndex + visibleIndex; const isActive = index === activeIndex && isFocused; + const isFieldEditing = editingKey === field.key; const error = errors[field.key]; const value = values[field.key]; + const starWidth = field.required ? 1 : 0; return ( - - - + + + {isActive ? '› ' : ' '} - {field.label} - {field.required && *} + {truncateLabel(field.label, gutterWidth - starWidth)} + {field.required && *} - - {field.type === 'text' && ( + + {isFieldEditing && (field.type === 'text' || field.type === 'password') && ( )} - {field.type === 'password' && ( - - )} - - {field.type === 'select' && field.options && ( + {isFieldEditing && field.type === 'select' && field.options && ( )} - {field.type === 'checkbox' && ( - - {value ? '☑' : '☐'} {value ? 'Yes' : 'No'} - + {!isFieldEditing && ( + + {displayValue(field, value) === '' && field.placeholder ? ( + {field.placeholder} + ) : ( + {displayValue(field, value)} + )} + + {field.hint && {' '}{field.hint}} + + {error && submitted && {' '}✘ {error}} + )} - - {error && submitted && ( - - {error} - - )} ); })} - - - {busy ? ( + {hasMoreBelow && ( + {' '}↓ {fields.length - startIndex - visibleCount} more + )} + + + {busy ? ( + <> {busyLabel} - ) : ( - <> - [Enter] {submitLabel} - [Esc] Cancel - [↑↓] Navigate - - )} - + {onCancelBusy && [Esc] Cancel} + + ) : ( + actions.map((action) => { + + const focused = activeAction === action && isFocused; + const label = action === 'submit' ? submitLabel : 'Cancel'; + + return ( + + {focused ? '❯ ' : ' '}[ {label} ] + + ); + + }) + )} + + + + {' '}{hintText} {statusError && ✘ {statusError}} diff --git a/src/tui/components/forms/TextInput.tsx b/src/tui/components/forms/TextInput.tsx new file mode 100644 index 00000000..6a48c9e9 --- /dev/null +++ b/src/tui/components/forms/TextInput.tsx @@ -0,0 +1,291 @@ +/** + * Single-line text input. + * + * A copy of `@inkjs/ui`'s `TextInput` (MIT) with exactly one behavioural + * change: a mouse report is dropped instead of typed into the field. + * + * **Why a copy and not a wrapper.** Upstream's handler ends in an + * unconditional `state.insert(input)`, and Ink's `useInput` is subscriber-based + * — every registered handler receives every keystroke and there is no + * `stopPropagation`. So a mouse report reaches that `insert` no matter what + * else consumes it first, and the guard has to live inside the handler. + * Composing one from outside would need `useTextInputState` and `useTextInput`, + * and `@inkjs/ui`'s `exports` map publishes only the package root, so neither + * hook is importable. Owning the file is the only seam there is. + * + * The leak this closes is not cosmetic. `ChangeAddScreen` slugifies its + * description into the change folder name and then creates that folder, so a + * click landed while typing produced `2026-08-17-0-20-11m-0-20-11m` as a real + * directory. Both terminators leak — a press ends in `M`, a release in `m` — + * and a single gesture emits several, so the damage compounds within one click. + * + * Everything else is upstream's behaviour on purpose, including the parts that + * would be written differently in new code. See the comments on the handler. + * + * @example + * ```tsx + * + * ``` + */ +import { useEffect, useMemo, useReducer } from 'react'; +import { Text, useInput } from 'ink'; + +import type { ReactElement, ReactNode } from 'react'; + +import { isMouseReport } from '../../mouse.js'; + +interface TextInputState { + + /** + * The value before the last edit. + * + * Only exists so the change effect can tell an edit from a re-render. It is + * what makes `onChange` fire once per keystroke rather than once per frame. + */ + previousValue: string; + + value: string; + + cursorOffset: number; + +} + +type TextInputAction = + | { type: 'move-cursor-left' } + | { type: 'move-cursor-right' } + | { type: 'insert'; text: string } + | { type: 'delete' }; + +function reducer(state: TextInputState, action: TextInputAction): TextInputState { + + switch (action.type) { + + case 'move-cursor-left': + + return { ...state, cursorOffset: Math.max(0, state.cursorOffset - 1) }; + + case 'move-cursor-right': + + return { ...state, cursorOffset: Math.min(state.value.length, state.cursorOffset + 1) }; + + case 'insert': + + return { + ...state, + previousValue: state.value, + value: state.value.slice(0, state.cursorOffset) + + action.text + + state.value.slice(state.cursorOffset), + cursorOffset: state.cursorOffset + action.text.length, + }; + + case 'delete': { + + const nextOffset = Math.max(0, state.cursorOffset - 1); + + return { + ...state, + previousValue: state.value, + value: state.value.slice(0, nextOffset) + state.value.slice(nextOffset + 1), + cursorOffset: nextOffset, + }; + + } + + } + +} + +/** + * Props for TextInput. + * + * Identical to `@inkjs/ui`'s `TextInputProps`, so a call site swaps the import + * and nothing else. + */ +export interface TextInputProps { + + /** When disabled, user input is ignored and the value renders without a cursor. */ + readonly isDisabled?: boolean; + + /** Text to display when the input is empty. */ + readonly placeholder?: string; + + /** Starting value. The input is uncontrolled; changes are reported, not accepted back. */ + readonly defaultValue?: string; + + /** Candidates to autocomplete the value with. */ + readonly suggestions?: string[]; + + /** Called with the new value on every edit. */ + readonly onChange?: (value: string) => void; + + /** Called with the value when Enter is pressed. */ + readonly onSubmit?: (value: string) => void; + +} + +/** + * Text input that ignores mouse reports. + * + * @example + * + */ +export function TextInput({ + isDisabled = false, + defaultValue = '', + placeholder = '', + suggestions, + onChange, + onSubmit, +}: TextInputProps): ReactElement { + + const [state, dispatch] = useReducer(reducer, { + previousValue: defaultValue, + value: defaultValue, + cursorOffset: defaultValue.length, + }); + + const suggestion = useMemo(() => { + + if (state.value.length === 0) return undefined; + + return suggestions + ?.find((candidate) => candidate.startsWith(state.value)) + ?.replace(state.value, ''); + + }, [state.value, suggestions]); + + // Keyed on the `onChange` identity, so an inline arrow re-fires the last + // change on every render. `Form.tsx` caches a handler per field because of + // it; that contract is upstream's and is kept deliberately. + useEffect(() => { + + if (state.value !== state.previousValue) onChange?.(state.value); + + }, [state.previousValue, state.value, onChange]); + + // `isActive` rather than a guard inside the handler: `isDisabled` is a prop, + // not a focus-stack value, so the effect re-runs when it flips and the + // handler registers then. Upstream's wiring, and the reason a disabled input + // is display-only. + useInput((input, key) => { + + // The whole point of this file. A mouse report reaches every useInput + // handler as a plain string, and the insert at the bottom of this + // handler would type `[<0;12;5M` into the field. + if (isMouseReport(input)) return; + + if (key.upArrow || key.downArrow || (key.ctrl && input === 'c') || key.tab || (key.shift && key.tab)) { + + return; + + } + + if (key.return) { + + if (suggestion) { + + dispatch({ type: 'insert', text: suggestion }); + onSubmit?.(state.value + suggestion); + + return; + + } + + onSubmit?.(state.value); + + return; + + } + + if (key.leftArrow) { + + dispatch({ type: 'move-cursor-left' }); + + } + else if (key.rightArrow) { + + dispatch({ type: 'move-cursor-right' }); + + } + // `key.delete` alongside `key.backspace` makes the Delete key erase + // backwards, which `tui-development.md` tells new code not to do. Kept + // because it is what all 21 call sites have always done, and changing + // it here would be a keyboard change smuggled in with a mouse fix. + else if (key.backspace || key.delete) { + + dispatch({ type: 'delete' }); + + } + else { + + dispatch({ type: 'insert', text: input }); + + } + + }, { isActive: !isDisabled }); + + return {render({ isDisabled, placeholder, state, suggestion })}; + +} + +interface RenderOptions { + isDisabled: boolean; + placeholder: string; + state: TextInputState; + suggestion: string | undefined; +} + +/** + * The value as it is drawn, cursor included. + * + * Built from nested `` rather than `chalk` calls: Ink's `Text` applies + * `chalk.inverse` and `chalk.dim` itself for the `inverse` and `dimColor` + * 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 { + + const { value, cursorOffset } = state; + + if (value.length === 0) { + + if (isDisabled) return placeholder ? {placeholder} : ''; + + if (placeholder.length === 0) return ; + + return ( + <> + {placeholder.slice(0, 1)} + {placeholder.slice(1)} + + ); + + } + + if (isDisabled) return value; + + const atCursor = value.slice(cursorOffset, cursorOffset + 1); + + return ( + <> + {value.slice(0, cursorOffset)} + {atCursor.length > 0 && {atCursor}} + {value.slice(cursorOffset + 1)} + {suggestion && atCursor.length === 0 && ( + <> + {suggestion.slice(0, 1)} + {suggestion.slice(1)} + + )} + {suggestion && atCursor.length > 0 && {suggestion}} + {!suggestion && atCursor.length === 0 && } + + ); + +} diff --git a/src/tui/components/forms/index.ts b/src/tui/components/forms/index.ts index 5c882581..b2119585 100644 --- a/src/tui/components/forms/index.ts +++ b/src/tui/components/forms/index.ts @@ -1,11 +1,14 @@ /** * Form components. * - * TextInput from @inkjs/ui is display-only when isDisabled, so it's compatible. + * TextInput is ours rather than @inkjs/ui's, because upstream's handler types + * a mouse report into the field. Display-only when isDisabled, same as before. * Select from @inkjs/ui uses ink's internal focus - don't use it. */ export { Form } from './Form.js'; -export { TextInput } from '@inkjs/ui'; +export { TextInput } from './TextInput.js'; + +export type { TextInputProps } from './TextInput.js'; export type { FormProps, diff --git a/src/tui/components/lists/SearchableList.tsx b/src/tui/components/lists/SearchableList.tsx index 8844fc57..9e0f94f8 100644 --- a/src/tui/components/lists/SearchableList.tsx +++ b/src/tui/components/lists/SearchableList.tsx @@ -15,15 +15,22 @@ */ import { useState, useMemo, useCallback, useRef, useEffect } from 'react'; import { Box, Text, useInput } from 'ink'; -import { TextInput } from '@inkjs/ui'; +import { TextInput } from '../forms/TextInput.js'; import type { ReactElement } from 'react'; import { useFocusScope } from '../../focus.js'; +import { useViewportRows } from '../../hooks/useViewportRows.js'; import { SelectList } from './SelectList.js'; import type { SelectListItem } from './SelectList.js'; +/** + * Rows this component draws around the list it wraps: the search row, the hint + * row, and the two gaps the surrounding column puts either side of the list. + */ +const SEARCH_CHROME_ROWS = 4; + /** * Filter state for SearchableList (used for persistence). */ @@ -58,9 +65,19 @@ export interface SearchableListProps { /** Label shown when no items match search */ noResultsLabel?: string; - /** Number of visible items before scrolling */ + /** + * Items visible before scrolling. Defaults to what the terminal has room + * for once this component's own search and hint rows are accounted for. + */ visibleCount?: number; + /** + * Rows of the screen that belong to something other than this list — an + * intro paragraph above it, a banner over the Panel. Only the parent knows + * these, and only they stop a terminal-sized list from overrunning them. + */ + reserveRows?: number; + /** Focus scope label for keyboard handling */ focusLabel?: string; @@ -97,7 +114,8 @@ export function SearchableList({ searchPlaceholder = 'Search...', emptyLabel = 'No items', noResultsLabel = 'No matches', - visibleCount = 8, + visibleCount: pinnedVisibleCount, + reserveRows = 0, focusLabel, isFocused: externalFocused, numberNav = false, @@ -107,6 +125,11 @@ export function SearchableList({ onCancel, }: SearchableListProps): ReactElement { + // The wrapped SelectList is handed a resolved number, so it never + // double-counts this component's search and hint rows. + const availableRows = useViewportRows(SEARCH_CHROME_ROWS + reserveRows); + const visibleCount = pinnedVisibleCount ?? availableRows; + // Focus management const hasExternalFocus = externalFocused !== undefined; const internalFocus = useFocusScope({ diff --git a/src/tui/components/lists/SelectList.tsx b/src/tui/components/lists/SelectList.tsx index 488b4fc1..5a139a88 100644 --- a/src/tui/components/lists/SelectList.tsx +++ b/src/tui/components/lists/SelectList.tsx @@ -19,6 +19,10 @@ import { Box, Text, useInput } from 'ink'; import type { ReactElement } from 'react'; import { useFocusScope } from '../../focus.js'; +import { useRowMouse } from '../../mouse.js'; +import { useOptionalRouter } from '../../router.js'; +import { listMemoryKey, recallListPosition, rememberListPosition } from '../../list-memory.js'; +import { useViewportRows } from '../../hooks/useViewportRows.js'; /** * Item in a SelectList. @@ -59,9 +63,20 @@ export interface SelectListProps { /** Label shown when no items */ emptyLabel?: string; - /** Number of visible items before scrolling */ + /** + * Items visible before scrolling. Defaults to what the terminal has room + * for, assuming the list owns the screen. Pass a number only to pin a list + * that must not grow. + */ visibleCount?: number; + /** + * Rows of the screen that belong to something other than this list — an + * intro paragraph above it, a banner over the Panel. Only the parent knows + * these, and only they stop a terminal-sized list from overrunning them. + */ + reserveRows?: number; + /** Focus scope label for keyboard handling. If not provided, uses parent's focus. */ focusLabel?: string; @@ -110,7 +125,8 @@ export function SelectList({ onSelect, onHighlight, emptyLabel = 'No items', - visibleCount = 5, + visibleCount: pinnedVisibleCount, + reserveRows = 0, focusLabel, isFocused: externalFocused, defaultValue, @@ -123,6 +139,15 @@ export function SelectList({ onCancel, }: SelectListProps): ReactElement { + // Unconditional so the hook count is stable whether or not the caller + // pinned a count; the pin wins when it is there. `visibleCount` counts + // items while the budget counts rows, and those differ once a description + // is drawn on its own line - halving assumes every item carries one, which + // under-fills a mixed list rather than running it off the bottom. + const availableRows = useViewportRows(reserveRows); + const rowsPerItem = showDescriptionBelow ? 2 : 1; + const visibleCount = pinnedVisibleCount ?? Math.max(1, Math.floor(availableRows / rowsPerItem)); + // Use external focus if provided, otherwise manage own focus scope const hasExternalFocus = externalFocused !== undefined; const internalFocus = useFocusScope({ @@ -180,6 +205,51 @@ export function SelectList({ }, [enabledItems.length, highlightedIndex]); + // Where this list's cursor is remembered. Derived from the router rather + // than from a prop, so every list screen gets the behaviour without opting + // in and the next one cannot forget to. `focusLabel` separates the lists on + // the two routes that render more than one (`db/transfer`, `db/dt-modify`), + // both of which already label theirs. Rendered without a router - a bare + // list in a test - there is no key and nothing is remembered. + const router = useOptionalRouter(); + const memoryKey = router ? listMemoryKey(router.route, router.params, focusLabel) : null; + const arrivedBy = router?.arrivedBy; + + // Restore once, and only once there is something to match against: a screen + // that fetches its rows mounts this list empty, and `useState` above has + // already read its initial value by the time the rows land. + const hasRestoredRef = useRef(false); + + useEffect(() => { + + if (hasRestoredRef.current || enabledItems.length === 0) return; + + hasRestoredRef.current = true; + + // Only on the way back. Restoring on every mount reads well for a + // browsing list but it also rewinds the wizards, where each phase swaps + // one list for another under a single route and a step is meant to open + // on its own first row - `DbTransferScreen` opens its options on the + // truncate toggle, and its test says so. + if (arrivedBy !== 'pop') return; + + // An explicit defaultValue is the caller stating where to start, which + // outranks where the user happened to leave the cursor last time. + if (memoryKey === null || defaultValue !== undefined) return; + + const remembered = recallListPosition(memoryKey); + + if (remembered === undefined) return; + + const index = enabledItems.findIndex((item) => item.key === remembered); + + // A miss means the row was deleted while we were away. Leaving the + // cursor at the top is the honest answer; an index would have restored + // onto whichever row slid into the empty slot. + if (index >= 0) setHighlightedIndex(index); + + }, [enabledItems, memoryKey, defaultValue, arrivedBy]); + // Calculate visible window for scrolling const startIndex = useMemo(() => { @@ -211,13 +281,21 @@ export function SelectList({ const item = enabledItems[highlightedIndex]; - if (item && onHighlight) { + if (!item) return; + + if (memoryKey !== null) { + + rememberListPosition(memoryKey, item.key); + + } + + if (onHighlight) { onHighlight(item); } - }, [highlightedIndex, enabledItems, onHighlight]); + }, [highlightedIndex, enabledItems, onHighlight, memoryKey]); // Handle keyboard navigation // Note: We don't use isActive option because it prevents handler registration @@ -226,6 +304,14 @@ export function SelectList({ if (!isFocused || isDisabled) return; + // No guard against mouse reports here, deliberately. Every branch below + // tests a named key or an exact character, and a report matches none of + // them — `numberNav` runs it through parseInt and gets NaN. A guard was + // written and then removed once the mutation harness showed that taking + // it out changed nothing. The handlers that do need one are the ones + // with a catch-all character branch: `ResultTable`'s filter box and + // `SqlInput`. + // Escape - cancel/back if (key.escape) { @@ -333,6 +419,48 @@ export function SelectList({ }); + /** + * What Enter would do to one row. + * + * In multi-select Enter submits the whole list, which is not something a + * click on a single row asked for. Space is the row-scoped key there, so + * that is the one a double click borrows. + */ + const activateRow = (index: number) => { + + const item = enabledItemsRef.current[index]; + + if (!item) return; + + if (multiSelect) onToggleRef.current?.(item); + else onSelectRef.current?.(item); + + }; + + // Inert without a MouseProvider above it or with the setting off: no refs + // are attached to the rows and nothing subscribes. + const { rowRef } = useRowMouse({ + isActive: isFocused && !isDisabled, + onClick: setHighlightedIndex, + onActivate: activateRow, + onWheel: (delta) => { + + setHighlightedIndex((current) => { + + const last = enabledItemsRef.current.length - 1; + + if (last < 0) return current; + + // Clamped, not wrapped. The arrows wrap, but a wheel that + // jumps from the last row back to the first reads as a glitch + // rather than as navigation. + return Math.min(Math.max(current + delta, 0), last); + + }); + + }, + }); + // Empty state if (enabledItems.length === 0) { @@ -367,7 +495,7 @@ export function SelectList({ : ''; return ( - + {numberNav && ( {numberIndicator} diff --git a/src/tui/components/terminal/ResultBrowser.tsx b/src/tui/components/terminal/ResultBrowser.tsx new file mode 100644 index 00000000..db0a8d48 --- /dev/null +++ b/src/tui/components/terminal/ResultBrowser.tsx @@ -0,0 +1,157 @@ +/** + * ResultBrowser - a result grid you can step into. + * + * `ResultTable` draws a result and `RowViewOverlay` draws one row of it; this + * is the wiring between them, and it exists because both SQL screens need + * exactly the same wiring. The explore peek composes the two itself, because it + * has two grids sharing one viewer and a cursor per grid; a screen with one + * grid does not, and would otherwise copy this five times. + * + * Two things it is responsible for: + * + * - **The cursor is here, not in the table.** `←`/`→` in the viewer move it and + * the table has to follow, so that Escape lands the reader back on the row + * they were reading rather than on the row they opened. + * - **The grid is hidden, not unmounted, while a row is open.** Unmounting it + * would throw away the filter, the sort and the scroll offset that Escape is + * supposed to come back to. + * + * Nothing here claims Escape. The viewer pushes its own focus scope, which + * makes the screen underneath unfocused, and the table stops answering keys + * while a row is open; each level gives up its own key without taking the level + * below with it. + * + * @example + * + */ +import { useEffect, useRef, useState } from 'react'; +import { Box } from 'ink'; + +import type { ReactElement } from 'react'; + +import { ResultTable } from './ResultTable.js'; +import { RowViewOverlay } from './RowViewOverlay.js'; + +/** + * Props for the result browser. + */ +export interface ResultBrowserProps { + + /** Column names, in the order the query returned them. */ + columns: string[]; + + /** Row data as array of objects. */ + rows: Record[]; + + /** Maximum visible rows before the grid scrolls. */ + maxVisibleRows?: number; + + /** Lines the row viewer may draw, its header included. */ + height: number; + + /** Whether the browser has input. */ + active?: boolean; + + /** What the viewer calls this list, above the row counter. */ + label?: string; + + /** Auto-sort by date or ID column on load. Default: true, as the grid's. */ + autoSort?: boolean; + + /** Called when Escape leaves the grid, which is only ever from browse mode. */ + onEscape?: () => void; + + /** + * Told whether a row is open, so a screen footer outside this component can + * stop advertising keys the viewer has taken over. + * + * Called from the three places that change it rather than from an effect: a + * caller passing an inline arrow hands over a new function every render, and + * an effect listing it would fire on every render for a value that did not + * change. + */ + onRowOpenChange?: (open: boolean) => void; + +} + +/** + * ResultBrowser component. + */ +export function ResultBrowser({ + columns, + rows, + maxVisibleRows, + height, + active = true, + label = 'Results', + autoSort, + onEscape, + onRowOpenChange, +}: ResultBrowserProps): ReactElement { + + const [cursor, setCursor] = useState(0); + + // The rows as the grid was displaying them — filtered and sorted, which is + // what `←`/`→` have to walk so the viewer moves through what the reader can + // see rather than through what the query returned. + const [subject, setSubject] = useState[] | null>(null); + + // Held in a ref so the reset effect below does not have to list it: a + // caller passing an inline arrow renews it on every render, and an effect + // that listed it would reset the cursor on every render. + const notifyRef = useRef(onRowOpenChange); + notifyRef.current = onRowOpenChange; + + // A new result is a new list, so a cursor carried over from the last one + // would point at a row that is not there and an open viewer would be + // showing a row from the previous query. + useEffect(() => { + + setCursor(0); + setSubject(null); + notifyRef.current?.(false); + + }, [rows]); + + return ( + + + { + + setCursor(index); + setSubject(list); + onRowOpenChange?.(true); + + }} + {...(onEscape ? { onEscape } : {})} + /> + + {subject !== null && ( + { + + setSubject(null); + onRowOpenChange?.(false); + + }} + /> + )} + + ); + +} diff --git a/src/tui/components/terminal/ResultTable.tsx b/src/tui/components/terminal/ResultTable.tsx index bce28adf..b98d247a 100644 --- a/src/tui/components/terminal/ResultTable.tsx +++ b/src/tui/components/terminal/ResultTable.tsx @@ -4,12 +4,26 @@ * Interactive table renderer with client-side filtering and sorting. * * **Features:** - * - Auto-calculated column widths + * - Auto-calculated column widths, chopped to what the row can hold * - Truncation for long values * - Scroll support for large result sets * - Filter by all columns or specific column * - Sort ascending/descending by any column * + * **A wide result is chopped, not crammed.** Ink's `width` is a flex basis and + * flex items shrink by default, so a row wider than the terminal does not + * overflow — every cell loses columns at once, headers wrap onto a second line + * and double each row's height, and values break mid-value. `fitGridColumns` + * therefore keeps the leading columns that fit at a readable width and reports + * the rest as a `… N more columns` marker. The filter and the sort picker still + * see every column, because a column being off the right edge is a drawing + * decision and not a reason it should stop being searchable. + * + * Cells are formatted through `documentValue`, the same normalizer the row + * document viewer uses, so a `bytea` reads `` rather than + * `{"type":"Buffer","data":[0,…` and a `Date` reads as a bare timestamp rather + * than a quoted one. + * * @example * ```tsx * * ``` */ -import { useState, useMemo, useCallback, useEffect } from 'react'; -import { Box, Text, useInput } from 'ink'; +import { useState, useMemo, useCallback, useEffect, useRef } from 'react'; +import { Box, Text, useInput, useWindowSize } from 'ink'; import v from 'voca'; import type { ReactElement } from 'react'; +import { isMouseReport, useRowMouse } from '../../mouse.js'; +import { fitGridColumns } from './columnFit.js'; +import { documentValue } from './rowDocument.js'; + /** * Props for ResultTable component. */ @@ -49,6 +67,37 @@ export interface ResultTableProps { /** Auto-sort by date column (desc) or ID (desc) on load. Default: true */ autoSort?: boolean; + /** + * Cursor position, zero-based, into the rows as displayed. + * + * Supplying it makes the cursor controlled: the table draws this row and + * reports where the arrows would move it instead of moving it itself. Left + * out, the table keeps its own cursor, which is what the SQL terminal does. + */ + highlightedRow?: number; + + /** Where the arrows moved the cursor, so a controlled parent can follow. */ + onHighlightChange?: (index: number) => void; + + /** + * Enter on the cursor's row, in browse mode only, so the filter box keeps + * its own Enter and the sort picker keeps its own. + * + * `rows` is the list as displayed — filtered and sorted — because a caller + * that opens the picked row usually wants to step to the next one, and the + * next one is the next in what the reader is looking at rather than the + * next in what was handed in. + */ + onSelect?: (row: Record, index: number, rows: Record[]) => void; + + /** + * Tab in browse mode only, so the filter box keeps its column cycling. + * + * Named for the key rather than for an intent: this table has no idea what + * is next to it, and the caller decides what leaving means. + */ + onTab?: () => void; + } /** @@ -244,14 +293,34 @@ function detectNumericIdColumn( /** * Format a cell value for display. + * + * Routed through `documentValue` rather than `JSON.stringify` because the + * drivers hand back real JavaScript and disagree per dialect about what: a + * binary column arrives as a `Buffer` on three of them and a `Uint8Array` on + * `bun:sqlite`, and either one stringifies to `{"type":"Buffer","data":[0,…`, + * which is the wrapper rather than the value and which the width allocator will + * happily spend a whole column on. `documentValue` was measured against all four + * drivers; it also survives the two values that make `JSON.stringify` throw + * outright, a `bigint` and MySQL's zero date. + * + * `undefined` stays blank and `null` stays `NULL`, which is the one place this + * departs from the document: a grid has a column heading to say what the blank + * is under, and a document has only the value. + * + * @example + * formatCellValue(Buffer.of(0, 255)); // '' */ function formatCellValue(value: unknown): string { - if (value === null) return 'NULL'; if (value === undefined) return ''; - if (typeof value === 'object') return JSON.stringify(value); - return String(value); + const documented = documentValue(value); + + if (documented === null) return 'NULL'; + + if (typeof documented === 'object') return JSON.stringify(documented); + + return String(documented); } @@ -266,10 +335,20 @@ export function ResultTable({ active = true, onEscape, autoSort = true, + highlightedRow: controlledRow, + onHighlightChange, + onSelect, + onTab, }: ResultTableProps): ReactElement { const isActive = active; + const isControlled = controlledRow !== undefined; + + // useWindowSize rather than a prop: how many columns fit is the terminal's + // business, and this hook is what re-runs the fit when it resizes. + const { columns: terminalColumns } = useWindowSize(); + // Compute initial sort based on data const initialSort = useMemo((): SortState | null => { @@ -304,9 +383,29 @@ export function ResultTable({ const [filter, setFilter] = useState({ term: '', column: null }); const [sort, setSort] = useState(initialSort); const [scrollOffset, setScrollOffset] = useState(0); - const [highlightedRow, setHighlightedRow] = useState(0); + const [internalRow, setInternalRow] = useState(0); const [sortColumnIndex, setSortColumnIndex] = useState(0); + const highlightedRow = isControlled ? controlledRow : internalRow; + + // One mover for both cursors, so a controlled parent and an uncontrolled + // table cannot drift into two different ideas of where the cursor is. + const moveCursor = (next: number) => { + + if (!isControlled) setInternalRow(next); + + onHighlightChange?.(next); + + }; + + // Held in a ref for the reset effect below. A parent that passes an inline + // arrow - which is every parent - hands over a new function on every + // render, so an effect that listed the mover in its dependencies would + // re-run on every render and snap the cursor back to the top row on each + // one, which looks exactly like the arrows not working. + const moveCursorRef = useRef(moveCursor); + moveCursorRef.current = moveCursor; + // Update sort when data changes (new query) useEffect(() => { @@ -314,32 +413,35 @@ export function ResultTable({ }, [initialSort]); - // Calculate column widths - const columnWidths = useMemo(() => { + // Which columns are drawn and how wide each one is. + // + // The width a column wants comes from its own content, so a table of short + // values keeps its density; the fit then decides how many of those columns + // the row can actually hold and drops the rest off the right edge. + const fit = useMemo(() => { - const widths: Record = {}; + const desired: Record = {}; for (const col of columns) { - // Start with header length - let maxWidth = col.length; + let want = col.length; - // Check all row values for (const row of rows) { - const value = formatCellValue(row[col]); - maxWidth = Math.max(maxWidth, value.length); + want = Math.max(want, formatCellValue(row[col]).length); } - // Cap at max width - widths[col] = Math.min(maxWidth, maxColumnWidth); + desired[col] = Math.min(want, maxColumnWidth); } - return widths; + return fitGridColumns(columns, desired, terminalColumns); + + }, [columns, rows, maxColumnWidth, terminalColumns]); - }, [columns, rows, maxColumnWidth]); + const drawnColumns = fit.columns; + const columnWidths = fit.widths; // Apply filter const filteredRows = useMemo(() => { @@ -410,15 +512,45 @@ export function ResultTable({ useEffect(() => { setScrollOffset(0); - setHighlightedRow(0); + moveCursorRef.current(0); }, [filter.term, filter.column]); + // Keep the cursor in view when something outside moved it. + // + // The arrow handlers below scroll as they move, so for an uncontrolled + // table this never fires. A controlled parent can set the cursor to + // anywhere — the row view's ←/→ do exactly that — and the window has to + // follow it or the reader escapes back to a table scrolled somewhere else. + useEffect(() => { + + if (highlightedRow < scrollOffset) { + + setScrollOffset(highlightedRow); + + return; + + } + + if (highlightedRow >= scrollOffset + maxVisibleRows) { + + setScrollOffset(highlightedRow - maxVisibleRows + 1); + + } + + }, [highlightedRow, scrollOffset, maxVisibleRows]); + // Handle keyboard input useInput((input, key) => { if (!isActive) return; + // A mouse report reaches every useInput handler as a plain string, and + // the filter box below would happily type `[<0;12;5M` into itself. + // Dropped for the whole handler so a click can only ever do what the + // mouse handler decides. + if (isMouseReport(input)) return; + // Mode-specific handling if (mode === 'filter') { @@ -557,7 +689,7 @@ export function ResultTable({ if (highlightedRow > 0) { - setHighlightedRow((r) => r - 1); + moveCursor(highlightedRow - 1); // Scroll if needed if (highlightedRow - 1 < scrollOffset) { @@ -576,7 +708,7 @@ export function ResultTable({ if (highlightedRow < sortedRows.length - 1) { - setHighlightedRow((r) => r + 1); + moveCursor(highlightedRow + 1); // Scroll if needed if (highlightedRow + 1 >= scrollOffset + maxVisibleRows) { @@ -591,6 +723,29 @@ export function ResultTable({ } + // Enter: hand the cursor's row up. Only reached in browse mode, which + // is what leaves the filter box's Enter and the sort picker's Enter + // alone - both return above without falling through to here. + if (key.return && onSelect) { + + const picked = sortedRows[highlightedRow]; + + if (picked) onSelect(picked, highlightedRow, sortedRows); + + return; + + } + + // Tab: likewise. Guarded on the callback so a table without one behaves + // exactly as it did before, rather than swallowing the key. + if (key.tab && onTab) { + + onTab(); + + return; + + } + // /: Enter filter mode if (input === '/') { @@ -631,13 +786,38 @@ export function ResultTable({ }); + // Mouse. Gated on browse mode for the same reason Enter and Tab are: the + // 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({ + isActive: isActive && mode === 'browse', + onClick: moveCursor, + onActivate: (index) => { + + const picked = sortedRows[index]; + + if (picked && onSelect) onSelect(picked, index, sortedRows); + + }, + onWheel: (delta) => { + + if (sortedRows.length === 0) return; + + const next = Math.min(Math.max(highlightedRow + delta, 0), sortedRows.length - 1); + + if (next !== highlightedRow) moveCursor(next); + + }, + }); + // Render a table row const renderRow = useCallback( (row: Record, index: number, isHighlighted: boolean) => { return ( - - {columns.map((col, colIndex) => { + + {drawnColumns.map((col, colIndex) => { const colWidth = columnWidths[col] ?? col.length; const value = v.truncate(formatCellValue(row[col]), colWidth, '\u2026'); @@ -660,7 +840,7 @@ export function ResultTable({ ); }, - [columns, columnWidths], + [drawnColumns, columnWidths, rowRef], ); // Calculate total width for separator @@ -668,18 +848,18 @@ export function ResultTable({ let width = 0; - for (const col of columns) { + for (const col of drawnColumns) { width += columnWidths[col] ?? col.length; } // Add separators - width += (columns.length - 1) * 3; // ' | ' + width += (drawnColumns.length - 1) * 3; // ' | ' return width; - }, [columns, columnWidths]); + }, [drawnColumns, columnWidths]); const hasFilter = filter.term.length > 0; const hasSort = sort !== null; @@ -727,12 +907,22 @@ export function ResultTable({ <> {/* Header */} - {columns.map((col, index) => { + {drawnColumns.map((col, index) => { const colWidth = columnWidths[col] ?? col.length; - const paddedCol = col.padEnd(colWidth); const isSortColumn = sort?.column === col; + // A header wider than its column used to be written + // out in full: Ink wrapped it onto a second line, + // which silently doubled the height of every row on + // screen and left the grid looking shredded. Cells + // have always truncated; headers now do too, and the + // sort arrow is charged to the same width instead of + // being added past it, which used to shift every + // column after it by one. + const labelWidth = Math.max(1, isSortColumn ? colWidth - 1 : colWidth); + const paddedCol = v.truncate(col, labelWidth, '\u2026').padEnd(labelWidth); + return ( {index > 0 && | } @@ -762,7 +952,11 @@ export function ResultTable({ {visibleRows.map((row, index) => { const actualIndex = scrollOffset + index; - const isHighlighted = actualIndex === highlightedRow; + + // Gated on `isActive`: a cursor is a claim that the + // arrows and Enter land here, and on a screen holding + // two tables two cursors make that claim twice. + const isHighlighted = isActive && actualIndex === highlightedRow; return renderRow(row, actualIndex, isHighlighted); @@ -776,22 +970,32 @@ export function ResultTable({ )} + )} - {/* Footer */} + {/* Footer. The columns the fit dropped are reported here rather + than on a line of their own: a caller sizing itself has to know + how tall a table is before it lays one out, and a notice that + appears only sometimes makes that a guess. This line is already + in every caller's arithmetic. */} - + {sortedRows.length} row{sortedRows.length !== 1 ? 's' : ''} {isFiltered && ` (filtered from ${rows.length})`} + {fit.hidden > 0 && ` · … ${fit.hidden} more column${fit.hidden === 1 ? '' : 's'}`} + {fit.hidden > 0 && onSelect && isActive && ' — [↵] on a row shows them all'} - {/* Help */} - {mode === 'browse' && ( + {/* Help. Gated on `isActive`: these keys belong to whichever table + currently has input, and an inactive one advertising them sends + the reader pressing keys that go nowhere. */} + {mode === 'browse' && isActive && ( [/] 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 new file mode 100644 index 00000000..ece7bb95 --- /dev/null +++ b/src/tui/components/terminal/RowViewOverlay.tsx @@ -0,0 +1,194 @@ +/** + * RowViewOverlay - one row of a result grid, every column of it, in JSON or + * YAML. + * + * `ResultTable` has to fit a table across a terminal, so it truncates every + * cell and, on a wide table, drops the columns that will not fit at a readable + * width. That is a reasonable trade only if there is a way to the whole row, + * and this is it: the cursor's row as a key/value document, one field per line, + * with nothing cut and nothing hidden. + * + * It sits beside `ResultTable` rather than beside either of its callers: the + * explore peek and the SQL screens both draw the same grid and both owe the + * reader the same way out of it, and two copies of this would drift. + * + * Four decisions worth stating: + * + * - **The index is controlled.** The caller owns which row is selected, because + * the same number drives the cursor in the table underneath. Moving here and + * moving there have to be the same move, or Escape lands the reader on a + * different row than the one they were reading. + * - **`←` and `→` stop at the ends of the list.** They do not wrap, and in the + * peek's `ends` mode they do not cross from the first set into the last. Both + * sets are slices with an unknown number of rows between them, so a `→` that + * slid across the gap would draw two non-adjacent rows as neighbours. + * Crossing is Escape, then Tab, which is a deliberate act. + * - **`↑` and `↓` scroll the document**, using the same `scrollTarget` the + * detail viewport and the full-text overlay use, so a forty-column row is + * reachable by the keys the reader already learned elsewhere. + * - **The format is remembered for the session**, not for the component. See + * `rowDocument.ts` for where and why. + * + * @example + * + */ +import { useEffect, useMemo, useState } from 'react'; +import { Box, Text, useInput, useWindowSize } from 'ink'; + +import type { ReactElement } from 'react'; + +import type { RowFormat } from './rowDocument.js'; + +import { useFocusScope } from '../../focus.js'; +import { preferredRowFormat, rememberRowFormat, renderRowDocument } from './rowDocument.js'; +import { rowBudget, rowWindow, scrollTarget, wrapText } from './viewport.js'; + +/** + * Props for the row view overlay. + */ +export interface RowViewOverlayProps { + + /** The list the cursor is in, in the order the grid drew it. */ + rows: Record[]; + + /** Which row of `rows` is on screen. Owned by the caller. */ + index: number; + + /** Column names in ordinal order, so the document reads like the grid did. */ + columns: string[]; + + /** What the list is called, e.g. `First 10 by id` or `Results`. */ + setLabel: string; + + /** Lines the overlay may draw, its header included. */ + height: number; + + /** Told when `←`/`→` move the cursor, so the table underneath follows. */ + onMove: (index: number) => void; + + /** Called when the reader dismisses the overlay. */ + onClose: () => void; + +} + +/** The position line and the key line. Both truncate, so both are one row. */ +const HEADER_ROWS = 2; + +/** + * RowViewOverlay component. + */ +export function RowViewOverlay({ + rows, + index, + columns, + setLabel, + height, + onMove, + onClose, +}: RowViewOverlayProps): ReactElement { + + const { isFocused } = useFocusScope('RowView'); + + // useWindowSize rather than a prop: the wrap width is the terminal's, and + // this is the one place that has to recompute when the terminal resizes. + const { columns: terminalColumns } = useWindowSize(); + + const [format, setFormat] = useState(preferredRowFormat); + const [offset, setOffset] = useState(0); + + const width = rowBudget(terminalColumns); + const row = rows[index]; + + const lines = useMemo(() => { + + if (!row) return ['(no row)']; + + return renderRowDocument(row, columns, format) + .split('\n') + .flatMap((line) => wrapText(line, width)); + + }, [row, columns, format, width]); + + // A new row is a new document, so an offset carried over from the last one + // would open it part-way down for no reason the reader can see. + useEffect(() => setOffset(0), [index]); + + const budget = Math.max(1, height - HEADER_ROWS); + const view = rowWindow(lines.length, offset, budget); + const maxOffset = lines.length - view.count; + + const move = (next: number) => { + + if (next < 0 || next > rows.length - 1 || next === index) return; + + onMove(next); + + }; + + useInput((input, key) => { + + if (!isFocused) return; + + if (key.escape) { + + onClose(); + + return; + + } + + if (key.leftArrow) { + + move(index - 1); + + return; + + } + + if (key.rightArrow) { + + move(index + 1); + + return; + + } + + // Ink reports Ctrl+F as `input === 'f'` with `key.ctrl` set, so the + // modifier has to be excluded or a forward-page attempt toggles format. + if (input === 'f' && !key.ctrl && !key.meta) { + + const next: RowFormat = format === 'yaml' ? 'json' : 'yaml'; + + rememberRowFormat(next); + setFormat(next); + + return; + + } + + const target = scrollTarget(input, key, view, maxOffset); + + if (target !== null) setOffset(Math.min(Math.max(target, 0), maxOffset)); + + }); + + const other = format === 'yaml' ? 'JSON' : 'YAML'; + + return ( + + + Row · {setLabel} · row {index + 1} of {rows.length} + + + [←/→] Row [↑/↓] Scroll [f] {other} [Esc] Back + + {view.above > 0 && ↑ {view.above} more} + {lines.slice(view.start, view.start + view.count).map((line, position) => ( + {line} + ))} + {view.below > 0 && ↓ {view.below} more} + + ); + +} diff --git a/src/tui/components/terminal/SqlInput.tsx b/src/tui/components/terminal/SqlInput.tsx index 689f277a..d71008dc 100644 --- a/src/tui/components/terminal/SqlInput.tsx +++ b/src/tui/components/terminal/SqlInput.tsx @@ -11,6 +11,8 @@ import { useState, useRef, useEffect } from 'react'; import { Box, Text, useInput } from 'ink'; import type { ReactElement } from 'react'; +import { isMouseReport } from '../../mouse.js'; + /** * Props for SqlInput component. */ @@ -79,6 +81,11 @@ export function SqlInput({ if (!isActive) return; + // A mouse report reaches every useInput handler as a plain string, and + // the character branch below would type `[<0;12;5M` into the query. + // Nothing here answers a click, so the whole handler drops them. + if (isMouseReport(input)) return; + // Shift+Tab: Toggle edit mode if (key.shift && key.tab) { diff --git a/src/tui/components/terminal/columnFit.ts b/src/tui/components/terminal/columnFit.ts new file mode 100644 index 00000000..b08e93c5 --- /dev/null +++ b/src/tui/components/terminal/columnFit.ts @@ -0,0 +1,185 @@ +/** + * Which columns a result grid draws, and how wide. + * + * Ink's `width` is a flex basis and flex items shrink by default, so a row of + * cells wider than the terminal does not overflow — it squeezes. Every cell + * loses columns at once, headers wrap onto a second line and silently double + * the height of every row, and values break mid-value: `select * from ai_usage` + * produced a grid whose headers read `ai_u`/`sage` and whose ids read + * `1283`/`2`. Fifteen columns shown uselessly is worth less than three shown + * properly, so the fit drops columns rather than shrinking past readable. + * + * Dropping is only acceptable because the dropped columns stay reachable: + * `ResultTable` marks how many it left off, and `Enter` on a row opens + * `RowViewOverlay`, which shows every column of it. + * + * @example + * const fit = fitGridColumns(columns, desiredWidths, terminalColumns); + * fit.columns.map((col) => fit.widths[col]); + */ +import { rowBudget } from './viewport.js'; + +/** Widest a grid column grows before it truncates. */ +export const PEEK_COLUMN_CAP = 24; + +/** + * Narrowest a column may be and still be worth a column of the row. + * + * Sixteen, chosen from what a reader can actually do with the result rather + * than from what fits: + * + * - `2024-03-01` is whole, and `2024-03-01 12:…` is enough of a timestamp to + * order rows by eye. + * - A UUID reads `ee3d58b5-be2b-4…`, which distinguishes one row from another. + * At six it reads `ee3d5…`, which distinguishes nothing and still costs a + * column of the row. + * - The identifiers that turn up as headers on a wide table — `provider_name`, + * `sales_order_no`, `idempotency_key` — fit whole, so the reader can still + * tell which column they are looking at. + * + * Below this a column is not narrow, it is absent while still charging rent, + * which is why the fit drops columns rather than shrinking past it. It is a + * floor on what a column may be *shrunk* to, never a floor on what a column + * *asks* for: an `id` column holding single digits is drawn three wide. + */ +const MIN_READABLE_COLUMN = 16; + +/** Columns between two cells, as `ResultTable` draws them: ` | `. */ +const CELL_SEPARATOR = 3; + +/** + * Which columns the grid draws, and how wide, when nothing is known about the + * values. + */ +export interface PeekColumnFit { + + /** The leading columns that fit, in their original order. */ + columns: string[]; + + /** How many were left off the right edge. */ + hidden: number; + + /** Width every drawn column gets. */ + width: number; + +} + +/** + * Fit columns across the row by dropping them, not by squeezing them. + * + * The worst case, and the width ceiling: every column is assumed to want at + * least `MIN_READABLE_COLUMN`, so this is how many columns fit when none of + * them can be drawn short. `fitGridColumns` takes the `width` from here as its + * per-column cap and then shows more columns than this when the values are + * narrow enough to allow it. + * + * One column is always drawn, however narrow the terminal: an empty grid is + * strictly worse than a cramped one, and `rowBudget` already floors the width + * at something a terminal can hold. + * + * @example + * fitPeekColumns(fifteenColumns, 76); // { columns: 3, hidden: 12, width: 22 } + */ +export function fitPeekColumns(columns: string[], terminalColumns: number): PeekColumnFit { + + const budget = rowBudget(terminalColumns); + + if (columns.length === 0) return { columns: [], hidden: 0, width: PEEK_COLUMN_CAP }; + + let shown = 1; + + while (shown < columns.length) { + + const next = shown + 1; + const needed = next * MIN_READABLE_COLUMN + (next - 1) * CELL_SEPARATOR; + + if (needed > budget) break; + + shown = next; + + } + + // Whatever the drops bought goes into width, up to the cap: a row with room + // to spare should show three whole values rather than three narrow ones. + const room = budget - CELL_SEPARATOR * (shown - 1); + const width = Math.max( + MIN_READABLE_COLUMN, + Math.min(PEEK_COLUMN_CAP, Math.floor(room / shown)), + ); + + return { columns: columns.slice(0, shown), hidden: columns.length - shown, width }; + +} + +/** + * Which columns the grid draws, and how wide each one is. + */ +export interface GridColumnFit { + + /** The leading columns that fit, in their original order. */ + columns: string[]; + + /** How many were left off the right edge. */ + hidden: number; + + /** Width per drawn column, keyed by column name. */ + widths: Record; + +} + +/** + * Fit columns to the row, spending only what each one's values actually need. + * + * `fitPeekColumns` answers the same question knowing nothing about the values, + * which is the only answer available before a query runs. A grid does know: it + * has every row in hand, so an `id` column of single digits costs three columns + * rather than sixteen, and the row has that much more left over for the columns + * after it. What it inherits from `fitPeekColumns` is the ceiling — no column is + * drawn wider than the peek fit would have allowed — which is what keeps a + * single `text` column from eating the whole row. + * + * The first column is drawn even when it does not fit, clamped to the budget, + * for the same reason `fitPeekColumns` always keeps one. + * + * @example + * fitGridColumns(['id', 'note'], { id: 2, note: 40 }, 100); + * // { columns: ['id', 'note'], hidden: 0, widths: { id: 2, note: 24 } } + */ +export function fitGridColumns( + columns: string[], + desired: Record, + terminalColumns: number, +): GridColumnFit { + + const budget = rowBudget(terminalColumns); + const cap = fitPeekColumns(columns, terminalColumns).width; + const widths: Record = {}; + + let used = 0; + let shown = 0; + + for (const column of columns) { + + const want = Math.max(1, Math.min(desired[column] ?? column.length, cap)); + + if (shown === 0) { + + widths[column] = Math.min(want, budget); + used = widths[column]!; + shown = 1; + + continue; + + } + + if (used + CELL_SEPARATOR + want > budget) break; + + widths[column] = want; + used += CELL_SEPARATOR + want; + shown += 1; + + } + + return { columns: columns.slice(0, shown), hidden: columns.length - shown, widths }; + +} diff --git a/src/tui/components/terminal/index.ts b/src/tui/components/terminal/index.ts index 527821a5..05cd92fe 100644 --- a/src/tui/components/terminal/index.ts +++ b/src/tui/components/terminal/index.ts @@ -1,7 +1,8 @@ /** * Terminal components. * - * Components for the SQL terminal interface. + * Components for the SQL terminal interface, and the result grid the explore + * screens share with it. */ export { SqlInput } from './SqlInput.js'; @@ -9,3 +10,26 @@ export type { SqlInputProps } from './SqlInput.js'; export { ResultTable } from './ResultTable.js'; export type { ResultTableProps } from './ResultTable.js'; + +export { ResultBrowser } from './ResultBrowser.js'; +export type { ResultBrowserProps } from './ResultBrowser.js'; + +export { RowViewOverlay } from './RowViewOverlay.js'; +export type { RowViewOverlayProps } from './RowViewOverlay.js'; + +export { fitGridColumns, fitPeekColumns, PEEK_COLUMN_CAP } from './columnFit.js'; +export type { GridColumnFit, PeekColumnFit } from './columnFit.js'; + +export { + DEFAULT_ROW_FORMAT, + describeBinary, + documentRow, + documentValue, + preferredRowFormat, + rememberRowFormat, + renderRowDocument, +} from './rowDocument.js'; +export type { RowFormat } from './rowDocument.js'; + +export { halfPage, rowBudget, rowWindow, scrollTarget, wrapText } from './viewport.js'; +export type { RowWindow } from './viewport.js'; diff --git a/src/tui/components/terminal/rowDocument.ts b/src/tui/components/terminal/rowDocument.ts new file mode 100644 index 00000000..af75ccab --- /dev/null +++ b/src/tui/components/terminal/rowDocument.ts @@ -0,0 +1,270 @@ +/** + * One database row as a readable key/value document. + * + * The grid `ResultTable` draws answers "what is in this result"; this answers + * "what is in this row", which is a different question with a different failure + * mode. A grid truncates and the reader sees that it truncated. A serializer + * that meets a value it did not expect either throws or prints a lie, and both + * look like a bug in the table rather than in the formatter. + * + * `ResultTable` formats its cells through `documentValue` for the same reason, + * so a `bytea` column reads `` in the grid and in the + * document rather than `{"type":"Buffer","data":[0,…` in one and the summary in + * the other. + * + * Every rule below came from asking the four drivers what they actually return, + * not from reasoning about SQL types. What came back, per dialect: + * + * | value | postgres | mysql | mssql | sqlite | + * |-------|----------|-------|-------|--------| + * | `NULL` | `null` | `null` | `null` | `null` | + * | date / timestamp | `Date` | `Date` | `Date` | `string` | + * | binary | `Buffer` | `Buffer` | `Buffer` | `Uint8Array` | + * | `bigint` | `string` | `number`, lossy | `string` | `number`, lossy | + * | `decimal` | `string` | `string` | `number` | `number` | + * | boolean | `boolean` | `number` 0/1 | `boolean` | `number` 0/1 | + * | `json` / `jsonb` | parsed object | parsed object | — | `string` | + * + * Which produces four rules that are not obvious from the type list: + * + * - **`Buffer` is not the only binary.** `bun:sqlite` hands back a plain + * `Uint8Array`, so `Buffer.isBuffer` is `false` for exactly the value that + * most needs summarizing, and the check has to be on the view rather than on + * the subclass. Left alone, either one serializes to + * `{"type":"Buffer","data":[0,255,16]}` — the wrapper, not the value. + * - **`bigint` is a crash, not a formatting choice.** `JSON.stringify` throws a + * `TypeError` on one. No noorm connection produces a `bigint` today, but + * `readPeekRows` accepts any `Kysely` instance and every one of these drivers + * has an option that turns large integers into `BigInt`. A crash in a viewer + * is not worth the wager. + * - **`Date` is not always valid.** MySQL's zero date (`0000-00-00`) arrives as + * an `Invalid Date`, and `toISOString()` throws on it. + * - **`null` has to survive.** It is one of three things that print alike if + * anything coerces to a string on the way out: `null`, the string `"null"`, + * and `''`. JSON keeps them apart by quoting, and so does YAML — `yaml` + * quotes a string that would otherwise parse as a null. + * + * @example + * renderRowDocument({ id: 1n, blob: Buffer.of(255) }, ['id', 'blob'], 'yaml'); + * // id: "1" + * // blob: + */ +import YAML from 'yaml'; + +/** + * How a row is written out. + * + * Both, because a reader wants different things from them: YAML to read, JSON + * to paste into something else. + */ +export type RowFormat = 'json' | 'yaml'; + +/** + * What a reader gets before they choose. + * + * YAML, because the ask is a key/value document one field per line and that is + * what YAML is — JSON spends its first and last line on braces and every line + * in between on quotes and a trailing comma, which is the right trade only when + * something other than a person is going to parse it. + */ +export const DEFAULT_ROW_FORMAT: RowFormat = 'yaml'; + +/** Bytes a binary summary shows before it gives up and says how many there are. */ +const BINARY_PREVIEW_BYTES = 12; + +/** + * The format the reader last chose, for as long as the process lives. + * + * Module state rather than React state because every level that could hold it + * is torn down between two rows: the row view unmounts on Escape, the peek + * unmounts on Escape, and the detail screen unmounts on navigating to another + * table. A reader who prefers JSON would be re-pressing `f` on every row. + * + * Deliberately not persisted. `AppContext` already carries session-scoped + * explore state (`exploreFilters`) and is where this belongs the day it needs + * to outlive the process or be visible to another screen; until then a context + * field would force every test that renders the overlay to wrap it in a + * provider for a single boolean. + */ +let sessionFormat: RowFormat = DEFAULT_ROW_FORMAT; + +/** + * The format the next row view opens in. + * + * @example + * const [format, setFormat] = useState(preferredRowFormat); + */ +export function preferredRowFormat(): RowFormat { + + return sessionFormat; + +} + +/** + * Remember a format for the rest of the session. + * + * @example + * rememberRowFormat('json'); + */ +export function rememberRowFormat(format: RowFormat): void { + + sessionFormat = format; + +} + +/** + * Whether this is a binary value from any of the four drivers. + * + * `Buffer.isBuffer` is not enough: `bun:sqlite` returns `Uint8Array`, and a + * driver is free to return any typed array view over the bytes. + */ +function isBinary(value: object): value is ArrayBufferView { + + return ArrayBuffer.isView(value); + +} + +/** + * A binary value as something a person can read. + * + * Names the length first because that is the part a reader can act on, and + * shows a hex preview because it is often enough to recognise what the column + * holds — a PNG header, a UUID, a zero fill. + * + * @example + * describeBinary(Buffer.from([0, 255, 16])); // '' + */ +export function describeBinary(view: ArrayBufferView): string { + + const bytes = new Uint8Array(view.buffer, view.byteOffset, view.byteLength); + const unit = bytes.length === 1 ? 'byte' : 'bytes'; + + if (bytes.length === 0) return ''; + + const preview = Buffer.from(bytes.subarray(0, BINARY_PREVIEW_BYTES)).toString('hex'); + const elided = bytes.length > BINARY_PREVIEW_BYTES ? '…' : ''; + + return ``; + +} + +/** + * One driver value as something both serializers can carry. + * + * Recursive, because a `jsonb` column arrives already parsed and may hold any + * of the above at any depth — a `Buffer` cannot appear inside one, but an + * unexpected shape is exactly what this function exists to survive. + * + * @example + * documentValue(new Date('2024-03-01T00:00:00Z')); // '2024-03-01T00:00:00.000Z' + * documentValue(9007199254740993n); // '9007199254740993' + */ +export function documentValue(value: unknown, seen: WeakSet = new WeakSet()): unknown { + + if (value === null || value === undefined) return null; + + // Before the object branch: a bigint is a primitive, and the only value + // here that makes JSON.stringify throw rather than mis-render. + if (typeof value === 'bigint') return value.toString(); + + if (typeof value !== 'object') { + + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + + return value; + + } + + return String(value); + + } + + if (value instanceof Date) { + + return Number.isNaN(value.getTime()) ? '' : value.toISOString(); + + } + + if (isBinary(value)) return describeBinary(value); + + // A row from a driver cannot be circular, but this walks whatever came back + // rather than what should have, and a cycle is an infinite loop rather than + // a bad render. + if (seen.has(value)) return ''; + + seen.add(value); + + if (Array.isArray(value)) return value.map((entry) => documentValue(entry, seen)); + + const out: Record = {}; + + for (const [key, entry] of Object.entries(value)) { + + out[key] = documentValue(entry, seen); + + } + + return out; + +} + +/** + * A whole row, ordered by the column list rather than by the object's keys. + * + * The column list is the table's ordinal order, which is the order the reader + * just saw in the grid. Any key the list does not mention is appended rather + * than dropped: the list comes from the catalog and the row comes from the + * driver, and a reader looking for a value should never lose it to a + * disagreement between the two. + * + * @example + * documentRow({ b: 2, a: 1 }, ['a', 'b']); // { a: 1, b: 2 } + */ +export function documentRow(row: Record, columns: string[]): Record { + + const document: Record = {}; + const seen = new WeakSet(); + + for (const column of columns) { + + document[column] = documentValue(row[column], seen); + + } + + for (const key of Object.keys(row)) { + + if (key in document) continue; + + document[key] = documentValue(row[key], seen); + + } + + return document; + +} + +/** + * The row as text, ready to be wrapped and windowed. + * + * `lineWidth: 0` turns off YAML's folding. Left on, `yaml` breaks a value + * longer than 80 columns across lines of its own choosing, which makes a row's + * height depend on its content and puts the viewport's line arithmetic out by + * however much it folded. Wrapping is the viewport's job and it knows the + * terminal width; this only has to produce one line per field. + * + * @example + * renderRowDocument(row, peek.columns, 'yaml').split('\n'); + */ +export function renderRowDocument( + row: Record, + columns: string[], + format: RowFormat, +): string { + + const document = documentRow(row, columns); + + if (format === 'json') return JSON.stringify(document, null, 2); + + return YAML.stringify(document, { lineWidth: 0 }).trimEnd(); + +} diff --git a/src/tui/components/terminal/viewport.ts b/src/tui/components/terminal/viewport.ts new file mode 100644 index 00000000..313378a7 --- /dev/null +++ b/src/tui/components/terminal/viewport.ts @@ -0,0 +1,199 @@ +/** + * Viewport arithmetic: how wide a row may be, how much of a list fits, and + * where a keypress moves the fold. + * + * Ink has no scroll offset, and a `` left to wrap itself occupies however + * many rows the terminal decides. Anything that counts rows therefore has to + * flatten its content to one element per visual line and slice that list — the + * explore detail screen, the full-text overlay, and the row document viewer all + * do, which is why the arithmetic lives here rather than in any one of them. + * + * It sits under `components/terminal` because that is the shared layer both the + * SQL screens and the explore screens already depend on. `screens/db/explore/ + * layout.ts` re-exports every symbol here, so the explore call sites read the + * same as they always did. + * + * @example + * const view = rowWindow(lines.length, offset, height - HEADER_ROWS); + * lines.slice(view.start, view.start + view.count); + */ +import type { Key } from 'ink'; + +/** Columns a Panel spends on its border and horizontal padding. */ +const PANEL_CHROME_COLUMNS = 4; + +/** Narrowest row worth planning for. Below it the terminal is unusable anyway. */ +const MIN_ROW_COLUMNS = 24; + +/** + * Rows the two scroll indicators claim once the content overflows. + * + * Held back as a pair rather than per-indicator so the viewport keeps one + * height for the whole scroll, instead of growing a row at each end. + */ +const INDICATOR_ROWS = 2; + +/** + * Columns a row inside a Panel actually gets. + * + * @example + * const budget = rowBudget(useWindowSize().columns); + */ +export function rowBudget(terminalColumns: number): number { + + return Math.max(terminalColumns - PANEL_CHROME_COLUMNS, MIN_ROW_COLUMNS); + +} + +/** + * The slice of a row list that is on screen, and what is off it either way. + */ +export interface RowWindow { + + /** Index of the first row drawn. */ + start: number; + + /** Rows drawn. */ + count: number; + + /** Rows scrolled off the top. */ + above: number; + + /** Rows still below the fold. */ + below: number; + +} + +/** + * Window a row list to a height budget. + * + * The offset is clamped here rather than trusted, so a resize or a smaller + * object cannot strand the viewport past the end of the content: whatever + * offset the caller is holding, what it renders is always in range. + * + * @example + * const view = rowWindow(rows.length, offset, viewportRows(terminalRows)); + * rows.slice(view.start, view.start + view.count); + */ +export function rowWindow(total: number, offset: number, budget: number): RowWindow { + + if (total <= budget) { + + return { start: 0, count: total, above: 0, below: 0 }; + + } + + const count = Math.max(1, budget - INDICATOR_ROWS); + const start = Math.min(Math.max(offset, 0), total - count); + + return { start, count, above: start, below: total - start - count }; + +} + +/** + * Rows a half-page key moves, never less than one. + * + * @example + * halfPage(10); // 5 + */ +export function halfPage(count: number): number { + + return Math.max(1, Math.floor(count / 2)); + +} + +/** + * Where a keypress moves a viewport, or `null` when the key is not one of ours. + * + * Shared by the detail viewport, the full-text overlay and the row document + * viewer so the three answer to the same keys, and so the reasoning below sits + * in one place instead of three. + * + * Ctrl+U / Ctrl+D are the advertised paging keys. Ctrl reaches the application + * on every terminal and platform, needs no fn-key contortion, and matches vim. + * PageUp/PageDown stay bound behind them: they do work on a Mac, via fn+↑ and + * fn+↓, which is why the footer names the chord rather than the key cap. + * + * @example + * const target = scrollTarget(input, key, view, maxOffset); + * if (target !== null) scrollTo(target); + */ +export function scrollTarget(input: string, key: Key, view: RowWindow, maxOffset: number): number | null { + + // ⌘+↑↓ mirror PageUp/PageDown, and are checked ahead of the plain arrows + // because the chord sets `upArrow`/`downArrow` too. + // + // Do not add a footer hint for this. `key.super` is only ever set under the + // kitty keyboard protocol - Ink's own `use-input.d.ts` says so, and + // Terminal.app and iTerm2 bind ⌘ combinations to their own actions and + // never forward them - so on the terminal most readers are using, the + // chord does nothing. A hint for a key that silently fails is worse than + // no hint. It is bound because it costs nothing where it happens to work. + if (key.super && key.upArrow) return view.start - view.count; + + if (key.super && key.downArrow) return view.start + view.count; + + if (key.upArrow) return view.start - 1; + + if (key.downArrow) return view.start + 1; + + if (key.ctrl && input === 'u') return view.start - halfPage(view.count); + + if (key.ctrl && input === 'd') return view.start + halfPage(view.count); + + if (key.pageUp) return view.start - view.count; + + if (key.pageDown) return view.start + view.count; + + if (key.home) return 0; + + if (key.end) return maxOffset; + + return null; + +} + +/** + * Break text into lines that each fit a width. + * + * A viewport counts rows, so anything it draws has to have a line count known + * before Ink lays it out. A `` left to wrap itself does not: it occupies + * however many rows the terminal decides, and the window arithmetic is wrong by + * that much. Wrapping here keeps every line the caller hands over exactly one + * row tall. + * + * @example + * wrapText('create view v as select 1', 12); // ['create view', 'v as select', '1'] + */ +export function wrapText(text: string, width: number): string[] { + + const limit = Math.max(1, width); + const lines: string[] = []; + + for (const paragraph of text.split('\n')) { + + let remainder = paragraph; + let broke = false; + + while (remainder.length > limit) { + + const space = remainder.lastIndexOf(' ', limit); + const cut = space > 0 ? space : limit; + + lines.push(remainder.slice(0, cut)); + remainder = remainder.slice(space > 0 ? cut + 1 : cut); + broke = true; + + } + + if (remainder.length > 0 || !broke) { + + lines.push(remainder); + + } + + } + + return lines; + +} diff --git a/src/tui/hooks/index.ts b/src/tui/hooks/index.ts index 32a6d5d1..5bb1a852 100644 --- a/src/tui/hooks/index.ts +++ b/src/tui/hooks/index.ts @@ -58,6 +58,16 @@ export { export { useAsyncEffect } from './useAsyncEffect.js'; +export { useAbortableTask, type AbortableTask } from './useAbortableTask.js'; + +export { + useViewportRows, + viewportRows, + modeBannerRows, + SCREEN_CHROME_ROWS, + MIN_VIEWPORT_ROWS, +} from './useViewportRows.js'; + export { useSettingsOperation, type UseSettingsOperationOptions, diff --git a/src/tui/hooks/useAbortableTask.ts b/src/tui/hooks/useAbortableTask.ts new file mode 100644 index 00000000..1abf66ff --- /dev/null +++ b/src/tui/hooks/useAbortableTask.ts @@ -0,0 +1,112 @@ +/** + * One cancellable database operation per screen. + * + * The screen owns its busy state as it always did; this owns the question of + * whether a result that just came back still belongs on screen. A hung connect + * or query can resolve long after the user pressed Escape and moved on, and + * writing that result is how a cancelled screen silently un-cancels itself. + * + * @example + * ```tsx + * const task = useAbortableTask(); + * + * const submit = async () => { + * + * const controller = task.start(); + * setBusy(true); + * + * const result = await testConnection(config, { signal: controller.signal }); + * + * if (!task.isCurrent(controller)) return; + * + * setBusy(false); + * + * }; + * + * // Escape while busy + * if (task.cancel()) { + * + * setBusy(false); + * setError('Stopped waiting for the database.'); + * + * } + * ``` + */ +import { useCallback, useEffect, useRef } from 'react'; + +/** + * Handle for the screen's single in-flight operation. + */ +export interface AbortableTask { + + /** + * Begin an operation, aborting whatever was in flight before it. + * The returned controller is the operation's identity as well as its + * cancel handle. + */ + start: () => AbortController; + + /** + * Abort the live operation. Returns false when there was nothing to abort, + * which is what lets a screen fall through to its normal Escape behaviour. + */ + cancel: () => boolean; + + /** + * Whether `controller` is still the operation the screen is showing. + * + * False once it has been cancelled or replaced. Every `await` in an + * operation needs this check afterwards: a driver is free to ignore an + * abort and answer anyway, and that answer must not reach the screen. + */ + isCurrent: (controller: AbortController) => boolean; + +} + +/** + * Track one cancellable operation, and reject results that outlived it. + */ +export function useAbortableTask(): AbortableTask { + + const activeRef = useRef(null); + + const start = useCallback(() => { + + activeRef.current?.abort(); + + const controller = new AbortController(); + activeRef.current = controller; + + return controller; + + }, []); + + const cancel = useCallback(() => { + + const controller = activeRef.current; + + if (!controller || controller.signal.aborted) return false; + + controller.abort(); + + return true; + + }, []); + + const isCurrent = useCallback( + (controller: AbortController) => activeRef.current === controller && !controller.signal.aborted, + [], + ); + + // Unmounting is a cancellation too: without this the operation keeps its + // connection open and its continuation would set state on a dead component. + useEffect(() => () => { + + activeRef.current?.abort(); + activeRef.current = null; + + }, []); + + return { start, cancel, isCurrent }; + +} diff --git a/src/tui/hooks/useViewportRows.ts b/src/tui/hooks/useViewportRows.ts new file mode 100644 index 00000000..d9513052 --- /dev/null +++ b/src/tui/hooks/useViewportRows.ts @@ -0,0 +1,121 @@ +/** + * Row budgets for anything that windows itself down the page. + * + * Ink has no scroll offset, so a list that renders more rows than the terminal + * holds does not clip — it pushes the footer and the status bar off the bottom. + * Every list therefore has to know how many rows it may draw, and until now + * each one carried a hardcoded guess: a 60-row terminal showed the same eight + * configs as a 24-row one, and the rest of the list was unreachable. + * + * The budget is the terminal minus the chrome around the list. The chrome + * splits in two, and so does the accounting: + * + * - What the *screen* costs — the app shell, one titled Panel, the hotkey + * footer under it — is the same on nearly every screen, so it lives here as + * `SCREEN_CHROME_ROWS` and no caller counts it. + * - What the *list component* costs — a search row, a status line — is known + * only to that component, so each one adds its own before calling this. + * - What a *screen* puts beside its list — an intro paragraph, a dry-run + * banner — is known only to that screen, so it passes `reserveRows`. + * + * @example + * // The list owns the screen: nothing to count. + * const rows = useViewportRows(); + * + * // Two lines of explanation sit above the list, inside the same Panel. + * const rows = useViewportRows(2); + */ +import { useWindowSize } from 'ink'; + +import type { GlobalModes } from '../app-context.js'; + +/** Rows the app shell spends: the breadcrumb and its rule, the status bar and its rule. */ +const SHELL_CHROME_ROWS = 4; + +/** Rows a titled Panel spends: two borders, two padding rows, the title and its spacer. */ +const PANEL_CHROME_ROWS = 6; + +/** Rows the hotkey footer spends: the gap above it, and the line itself. */ +const FOOTER_CHROME_ROWS = 2; + +/** + * Rows a screen spends before drawing any content of its own. + * + * The shape this assumes is the one nearly every screen in `src/tui/screens` + * has: the app shell, a single titled Panel holding the content, and a row of + * hotkey hints under the Panel. Verified empirically against a 30-row shell. + * + * `ConfigEditScreen` reserves 10 for the same shell and Panel because it has no + * footer — the Form draws its own hints inside the Panel. + */ +export const SCREEN_CHROME_ROWS = SHELL_CHROME_ROWS + PANEL_CHROME_ROWS + FOOTER_CHROME_ROWS; + +/** + * Shortest viewport worth rendering, however little the terminal offers. + * + * Below this a list carries no more information than a single line would, and + * the honest failure is a cramped list rather than a negative budget. + */ +export const MIN_VIEWPORT_ROWS = 5; + +/** + * Rows a viewport gets once the chrome around it has taken its share. + * + * Pure so it can be unit-tested and so a component that already holds the + * terminal height does not have to call the hook a second time. + * + * @example + * viewportRows(40); // 28 + * viewportRows(40, 4); // 24 — four rows of the screen belong to something else + * viewportRows(8); // 5 — the floor, not a negative budget + */ +export function viewportRows(terminalRows: number, reserveRows = 0): number { + + return Math.max(terminalRows - SCREEN_CHROME_ROWS - reserveRows, MIN_VIEWPORT_ROWS); + +} + +/** + * Rows the calling component may draw, recomputed whenever the terminal resizes. + * + * `useWindowSize` rather than `useStdout`: `stdout.rows` mutates on resize + * without telling React, so anything derived from it stays frozen at mount + * size. Call it above any early return, or the hook count changes across an + * async load boundary. + * + * @example + * const rows = useViewportRows(bannerRows); + * + */ +export function useViewportRows(reserveRows = 0): number { + + const { rows: terminalRows } = useWindowSize(); + + return viewportRows(terminalRows, reserveRows); + +} + +/** + * Rows the DRY RUN / FORCE banner claims above a screen's Panel. + * + * The three run screens stack it in the same gapped column as their Panel, so + * an active mode costs a line per mode plus the gap under the block. Shared + * because getting it wrong is invisible until someone toggles a mode and the + * bottom of the list slides under the status bar. + * + * @example + * const rows = useViewportRows(2 + modeBannerRows(globalModes)); + */ +export function modeBannerRows(modes: GlobalModes): number { + + const lines = (modes.dryRun ? 1 : 0) + (modes.force ? 1 : 0); + + if (lines === 0) { + + return 0; + + } + + return lines + 1; + +} diff --git a/src/tui/list-memory.ts b/src/tui/list-memory.ts new file mode 100644 index 00000000..4e972520 --- /dev/null +++ b/src/tui/list-memory.ts @@ -0,0 +1,118 @@ +/** + * Where the cursor was, per list, for as long as the process lives. + * + * A screen unmounts when you navigate off it, so the highlighted row goes with + * its component state and the next visit starts at the top. This is the store + * that survives that unmount. + * + * Module state rather than a field on `AppContext`, for the same reason + * `rowDocument.ts` keeps the preferred row format here: `SelectList` is the + * only reader, it is rendered bare in tests that have no provider above it, and + * a context field would force every one of them to grow a wrapper for a string. + * Tests reset it with `clearListMemory()`. + * + * Positions are keyed by item key, never by index. Delete the row the cursor + * was on and an index restores onto whatever slid into that slot; a key that no + * longer matches anything simply misses, and the caller falls back to the top. + */ +import type { Route, RouteParams } from './types.js'; + +/** + * Distinct lists remembered before the oldest is dropped. + * + * The key includes route params, so a session that walks fifty tables leaves + * fifty entries behind. The cap keeps that bounded without anyone having to + * think about it; a hundred list positions is far more than a session revisits + * and costs a few kilobytes. + */ +const MAX_REMEMBERED_LISTS = 100; + +/** + * Insertion order is the eviction order, which `rememberListPosition` keeps + * current by deleting before it sets. + */ +const positions = new Map(); + +/** + * Render route params to a stable string. + * + * Sorted, because two call sites can write the same params in a different + * order and they are the same screen to a user. + */ +function serializeParams(params: RouteParams | undefined): string { + + if (!params) return ''; + + return Object.entries(params) + .filter(([, value]) => value !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, value]) => `${name}=${String(value)}`) + .join('&'); + +} + +/** + * Build the slot a list's cursor is remembered in. + * + * Params are part of it because `secret?name=dev` and `secret?name=prod` are + * different lists to a user. `listId` separates the lists on a route that + * renders more than one - `db/transfer` and `db/dt-modify` are the two, and + * both already label theirs. + * + * @example + * const key = listMemoryKey('db/transfer', {}, 'DbTransferDestSelect'); + */ +export function listMemoryKey(route: Route, params?: RouteParams, listId?: string): string { + + return `${route}|${serializeParams(params)}|${listId ?? ''}`; + +} + +/** + * Remember which item the cursor is on. + * + * @example + * rememberListPosition(key, item.key); + */ +export function rememberListPosition(key: string, itemKey: string): void { + + // Delete first so a rewrite moves the entry to the young end of the map + // and a list the user keeps coming back to is never the one evicted. + positions.delete(key); + positions.set(key, itemKey); + + if (positions.size <= MAX_REMEMBERED_LISTS) return; + + const oldest = positions.keys().next().value; + + if (oldest !== undefined) positions.delete(oldest); + +} + +/** + * The item the cursor was last on, if this list has been visited. + * + * @example + * const remembered = recallListPosition(key); + */ +export function recallListPosition(key: string): string | undefined { + + return positions.get(key); + +} + +/** + * Forget every remembered position. + * + * Called when the router resets, because the positions belong to the history + * stack a reset discards. Tests use it to keep module state from leaking + * between cases. + * + * @example + * beforeEach(() => clearListMemory()); + */ +export function clearListMemory(): void { + + positions.clear(); + +} diff --git a/src/tui/mouse.tsx b/src/tui/mouse.tsx new file mode 100644 index 00000000..f88e7b12 --- /dev/null +++ b/src/tui/mouse.tsx @@ -0,0 +1,614 @@ +/** + * Mouse transport for the TUI. + * + * Ink 7.1.1 ships no mouse support at all — no hook, no parsing. The only + * mention of a mouse in the whole build is a JSDoc note on `measureElement` + * saying that event coordinates have to be converted before they can be + * 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. + * + * **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 — + * it comes out as an unnamed key whose `input` is the raw sequence with the + * escape byte stripped. No patching, no second stdin listener. + * + * @example + * ```tsx + * + * + * + * ``` + */ +import { createContext, useCallback, useContext, useEffect, useMemo, useRef } from 'react'; +import { measureElement, useInput, useStdout } from 'ink'; +import { attemptSync } from '@logosdx/utils'; + +import type { ReactElement, ReactNode } from 'react'; +import type { DOMElement } from 'ink'; + +const ESC = '\u001b'; + +/** + * Turn on press/release 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`; + +/** + * Turn tracking back off, in the reverse order it went on. + * + * Leaving this unwritten is the worst failure this module has: the terminal + * 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`; + +/** + * `ESC [ < button ; column ; row M` for a press, lowercase `m` for a release. + * + * The leading escape byte is stripped by `reportBody` rather than matched here: + * a control character inside a regular expression is a lint error, and spelling + * it as a unicode escape does not change what it is. + */ +const SGR_REPORT = /^\[<(\d+);(\d+);(\d+)([Mm])$/; + +/** + * The report without its escape byte. + * + * Ink strips it before `useInput` sees the sequence, but anything holding the + * bytes a terminal actually sent still carries it. + */ +function reportBody(input: string): string { + + return input.startsWith(ESC) ? input.slice(1) : input; + +} + +const BUTTON_MASK = 0b11; +const SHIFT_BIT = 4; +const ALT_BIT = 8; +const CTRL_BIT = 16; +const MOTION_BIT = 32; +const WHEEL_BIT = 64; + +/** + * Where the live layout region starts on the terminal, 1-based. + * + * `measureElement` reports positions inside the live region; SGR reports them + * against the terminal. The two line up at the origin because the TUI renders + * in the alternate screen at full terminal height with no `` anywhere + * in the tree, so Ink writes the frame from the home position. Verified by + * capturing what Ink writes: the frame follows `ESC[2J ESC[3J ESC[H`, and an + * element `measureElement` puts at `y: 2` lands on the third line written. + * + * A `` block above the live region would push this down. There is none; + * if one ever appears, this is the constant that has to stop being a constant. + */ +const LIVE_REGION_ORIGIN_ROW = 1; +const LIVE_REGION_ORIGIN_COLUMN = 1; + +/** + * How long after a press a second press on the same row counts as a double. + * + * The protocol has no double-click event, so the window is ours to pick. 400ms + * sits inside the range desktop environments use for the same job (GNOME + * defaults to 400, Windows and macOS to 500), which is what a user's hands are + * already calibrated to. Shorter windows lose real double-clicks: every report + * crosses the terminal's input path, and over SSH that adds latency to an + * interval the user did not change. + */ +export const DOUBLE_CLICK_MS = 400; + +/** + * 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. + */ +export type MouseButton = 'left' | 'middle' | 'right' | 'wheel-up' | 'wheel-down'; + +/** + * 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'; + + button: MouseButton; + + /** Zero-based row, directly comparable with `measureElement`'s `y`. */ + row: number; + + /** Zero-based column, directly comparable with `measureElement`'s `x`. */ + column: number; + + shift: boolean; + + /** The Alt/Option modifier — SGR calls this bit "meta". */ + alt: boolean; + + ctrl: boolean; + +} + +/** + * Called with every decoded report while tracking is on. + */ +export type MouseHandler = (event: MouseEvent) => void; + +function decodeButton(code: number): MouseButton | null { + + if ((code & WHEEL_BIT) !== 0) { + + return (code & 1) === 0 ? 'wheel-up' : 'wheel-down'; + + } + + switch (code & BUTTON_MASK) { + + case 0: + return 'left'; + case 1: + return 'middle'; + case 2: + return 'right'; + default: + // 3 is the legacy protocol's "some button came up" code, which carries + // no button identity. SGR reports the real button on release instead, + // so seeing it here means the report is not one we can act on. + return 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. + * + * @example + * useInput((input, key) => { + * if (isMouseReport(input)) return; + * }); + */ +export function isMouseReport(input: string): boolean { + + return SGR_REPORT.test(reportBody(input)); + +} + +/** + * 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. + * + * @example + * parseMouseReport('[<0;12;5M'); // { kind: 'press', button: 'left', row: 4, column: 11, ... } + */ +export function parseMouseReport(input: string): MouseEvent | null { + + const match = SGR_REPORT.exec(reportBody(input)); + + if (!match) return null; + + const code = Number(match[1]); + const button = (code & MOTION_BIT) === 0 ? decodeButton(code) : null; + + if (button === null) return null; + + return { + kind: match[4] === 'M' ? 'press' : 'release', + button, + row: Number(match[3]) - LIVE_REGION_ORIGIN_ROW, + column: Number(match[2]) - LIVE_REGION_ORIGIN_COLUMN, + shift: (code & SHIFT_BIT) !== 0, + alt: (code & ALT_BIT) !== 0, + ctrl: (code & CTRL_BIT) !== 0, + }; + +} + +const SHUTDOWN_SIGNALS: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP']; + +/** + * Run `restore` on every process path that can still run code. + * + * `process.on('exit')` covers more than it looks like: measured on this + * runtime, it fires for `process.exit()`, an uncaught exception, an unhandled + * rejection, and a natural end. It does **not** fire when a signal kills a + * process that has no listener for it, which is the one gap the signal handlers + * below fill. + * + * Those handlers restore and then get out of the way. Registering a signal + * listener suppresses Node's default termination, and the TUI's lifecycle + * manager already registers its own for all three, so normally this only adds a + * write to a path that was going to exit anyway. When ours is the last listener + * standing it removes itself and re-raises, so the process dies exactly as it + * would have without this module. + * + * @example + * const uninstall = installTerminalRestore(() => stdout.write(MOUSE_DISABLE)); + */ +export function installTerminalRestore(restore: () => void): () => void { + + const onExit = () => { + + restore(); + + }; + + const onSignal = (signal: NodeJS.Signals) => { + + restore(); + + if (process.listenerCount(signal) === 1) { + + process.removeListener(signal, onSignal); + process.kill(process.pid, signal); + + } + + }; + + process.on('exit', onExit); + + for (const signal of SHUTDOWN_SIGNALS) { + + process.on(signal, onSignal); + + } + + return () => { + + process.removeListener('exit', onExit); + + for (const signal of SHUTDOWN_SIGNALS) { + + process.removeListener(signal, onSignal); + + } + + }; + +} + +interface MouseTransport { + + /** Whether tracking is on. Rows only need refs and hit tests when it is. */ + enabled: boolean; + + subscribe: (handler: MouseHandler) => () => void; + +} + +/** + * What a component sees with no provider above it — a bare `SelectList` in a + * test, or the whole app before the mouse setting has loaded. + */ +const MOUSE_OFF: MouseTransport = { + enabled: false, + subscribe: () => () => undefined, +}; + +const MouseContext = createContext(MOUSE_OFF); + +/** + * Enables tracking, parses reports, and fans them out to subscribers. + * + * Split out from the provider so that with the flag off there is no `useInput` + * registration, no `setRawMode` call, and no process listener — nothing to + * measure rather than nothing to see. + */ +function MouseTracking({ handlers }: { handlers: Set }): null { + + const { stdout } = useStdout(); + + useEffect(() => { + + // attemptSync, not a bare write: the stream can already be gone by the + // time an exit handler runs, and a throw there would replace a tidy + // shutdown with a crash. + const restore = () => { + + attemptSync(() => stdout.write(MOUSE_DISABLE)); + + }; + + stdout.write(MOUSE_ENABLE); + + const uninstall = installTerminalRestore(restore); + + return () => { + + uninstall(); + restore(); + + }; + + }, [stdout]); + + useInput((input) => { + + const event = parseMouseReport(input); + + if (!event) return; + + for (const handler of handlers) { + + handler(event); + + } + + }); + + return null; + +} + +/** + * Props for MouseProvider. + */ +export interface MouseProviderProps { + + /** + * Whether mouse tracking is on. False is inert: nothing is written to the + * terminal, nothing listens to stdin, and no process handler is registered. + */ + enabled: boolean; + + children: ReactNode; + +} + +/** + * Makes mouse reports available to the components below it. + * + * Takes `enabled` rather than reading settings itself, so the module stays + * testable without an app context and so the caller decides when the setting is + * known. It flips false to true once settings load, and the enable sequence + * goes out then rather than at render time. + * + * @example + * ```tsx + * + * + * + * ``` + */ +export function MouseProvider({ enabled, children }: MouseProviderProps): ReactElement { + + const handlers = useRef>(new Set()); + + const subscribe = useCallback((handler: MouseHandler) => { + + handlers.current.add(handler); + + return () => { + + handlers.current.delete(handler); + + }; + + }, []); + + const value = useMemo( + () => (enabled ? { enabled, subscribe } : MOUSE_OFF), + [enabled, subscribe], + ); + + return ( + + {enabled && } + {children} + + ); + +} + +/** + * The mouse transport, or an inert stand-in when there is no provider. + * + * @example + * const { enabled } = useMouseTransport(); + */ +export function useMouseTransport(): MouseTransport { + + return useContext(MouseContext); + +} + +/** + * Options for useRowMouse. + */ +export interface RowMouseOptions { + + /** + * Whether the component owning these rows currently has input. + * + * A click acts on whatever already has focus and does nothing anywhere + * else, so this is the same guard the component's `useInput` handler uses. + */ + isActive: boolean; + + /** A single click landed on this row. */ + onClick: (index: number) => void; + + /** A second click landed on the same row inside the double-click window. */ + onActivate: (index: number) => void; + + /** A wheel notch: -1 for up, 1 for down. */ + onWheel: (delta: -1 | 1) => void; + +} + +/** + * Result of useRowMouse. + */ +export interface RowMouse { + + /** True while tracking is on. */ + enabled: boolean; + + /** + * Ref for the box drawing row `index`, or `undefined` when the mouse is + * off so React skips ref handling entirely. + */ + rowRef: (index: number) => ((node: DOMElement | null) => void) | undefined; + +} + +/** + * 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. + */ +function hitTest(nodes: Map, row: 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) { + + return index; + + } + + } + + return null; + +} + +/** + * Click, double-click and wheel handling for a list of rows. + * + * 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 + * item has a description, and deriving the boundary would mean keeping a second + * copy of the render's layout rules in step with the first. + * + * @example + * ```tsx + * const { rowRef } = useRowMouse({ + * isActive: isFocused, + * onClick: setHighlightedIndex, + * onActivate: selectRow, + * onWheel: (delta) => step(delta), + * }); + * + * + * ``` + */ +export function useRowMouse({ isActive, onClick, onActivate, onWheel }: 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); + + // The handler is registered once and reads the latest props through this + // ref. Listing them as effect dependencies would resubscribe on every + // render, since every caller passes inline arrows. + const latest = useRef({ isActive, onClick, onActivate, onWheel }); + latest.current = { isActive, onClick, onActivate, onWheel }; + + useEffect(() => { + + if (!enabled) return; + + return subscribe((event) => { + + const current = latest.current; + + if (!current.isActive || event.kind !== 'press') return; + + if (event.button === 'wheel-up') { + + current.onWheel(-1); + + return; + + } + + if (event.button === 'wheel-down') { + + current.onWheel(1); + + return; + + } + + if (event.button !== 'left') return; + + const index = hitTest(nodes.current, event.row); + + if (index === null) return; + + const now = Date.now(); + const previous = lastPress.current; + const isDouble = previous !== null + && previous.index === index + && now - previous.at <= DOUBLE_CLICK_MS; + + // Clearing on a double is what keeps a third click from activating + // again: a triple click is a double followed by a fresh single. + lastPress.current = isDouble ? null : { index, at: now }; + + if (isDouble) current.onActivate(index); + else current.onClick(index); + + }); + + }, [enabled, subscribe]); + + const rowRef = useCallback( + (index: number) => { + + if (!enabled) return undefined; + + const existing = setters.current.get(index); + + if (existing) return existing; + + // Cached per index so React sees the same ref across renders and + // does not detach and reattach every row on every frame. + const setter = (node: DOMElement | null) => { + + if (node) nodes.current.set(index, node); + else nodes.current.delete(index); + + }; + + setters.current.set(index, setter); + + return setter; + + }, + [enabled], + ); + + return { enabled, rowRef }; + +} diff --git a/src/tui/router.tsx b/src/tui/router.tsx index 5df6204e..c4cbcd82 100644 --- a/src/tui/router.tsx +++ b/src/tui/router.tsx @@ -25,9 +25,11 @@ import { type RouteParams, type HistoryEntry, type RouterContextValue, + type NavigationKind, getSection, getParentRoute, } from './types.js'; +import { clearListMemory } from './list-memory.js'; import { observer } from '../core/index.js'; /** @@ -104,6 +106,7 @@ export function RouterProvider({ const [history, setHistory] = useState(() => buildAncestorHistory(initialRoute), ); + const [arrivedBy, setArrivedBy] = useState('initial'); const navigate = useCallback( (newRoute: Route, newParams: RouteParams = {}) => { @@ -123,6 +126,7 @@ export function RouterProvider({ // Navigate to new route setRoute(newRoute); setParams(newParams); + setArrivedBy('push'); // Emit event for listeners observer.emit('router:navigated', { @@ -151,6 +155,7 @@ export function RouterProvider({ setHistory((prev) => prev.slice(0, -1)); setRoute(previous.route); setParams(previous.params); + setArrivedBy('pop'); // Emit event for listeners (e.g., to clear state when leaving a screen) observer.emit('router:popped', { @@ -165,14 +170,20 @@ export function RouterProvider({ // Replace without modifying history setRoute(newRoute); setParams(newParams); + setArrivedBy('replace'); }, []); const reset = useCallback(() => { + // The remembered list cursors are a companion to the history stack this + // throws away, so they go with it. + clearListMemory(); + setHistory([]); setRoute('home'); setParams({}); + setArrivedBy('initial'); }, []); @@ -181,6 +192,7 @@ export function RouterProvider({ route, params, history, + arrivedBy, navigate, back, replace, @@ -188,7 +200,7 @@ export function RouterProvider({ canGoBack: history.length > 0, section: getSection(route), }), - [route, params, history, navigate, back, replace, reset], + [route, params, history, arrivedBy, navigate, back, replace, reset], ); return {children}; @@ -225,6 +237,24 @@ export function useRouter(): RouterContextValue { } +/** + * Router access for a component that may be rendered without one. + * + * `useRouter` throws off-provider, which is the right contract for a screen but + * the wrong one for a shared component that is also rendered bare in tests and + * that only wants the route as a cache key. Returns `null` instead, so the + * caller can degrade rather than crash. + * + * @example + * const router = useOptionalRouter(); + * const key = router ? listMemoryKey(router.route, router.params) : null; + */ +export function useOptionalRouter(): RouterContextValue | null { + + return useContext(RouterContext); + +} + /** * Hook to get just the current route and params. * diff --git a/src/tui/screens/change/ChangeAddScreen.tsx b/src/tui/screens/change/ChangeAddScreen.tsx index 15144f76..dafa53ce 100644 --- a/src/tui/screens/change/ChangeAddScreen.tsx +++ b/src/tui/screens/change/ChangeAddScreen.tsx @@ -21,7 +21,6 @@ import { useState, useCallback } from 'react'; import { existsSync } from 'fs'; import { join } from 'path'; import { Box, Text, useInput } from 'ink'; -import { TextInput } from '@inkjs/ui'; import type { ReactElement } from 'react'; import type { ScreenProps } from '../../types.js'; @@ -30,7 +29,7 @@ import { attempt } from '@logosdx/utils'; import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; -import { Panel, Spinner, StatusMessage } from '../../components/index.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'; diff --git a/src/tui/screens/change/ChangeNextScreen.tsx b/src/tui/screens/change/ChangeNextScreen.tsx index de40996d..044fcbd6 100644 --- a/src/tui/screens/change/ChangeNextScreen.tsx +++ b/src/tui/screens/change/ChangeNextScreen.tsx @@ -12,7 +12,7 @@ */ import { useState, useCallback, useMemo } from 'react'; import { Box, Text, useInput } from 'ink'; -import { TextInput, ProgressBar } from '@inkjs/ui'; +import { ProgressBar } from '@inkjs/ui'; import type { ReactElement } from 'react'; import type { ScreenProps } from '../../types.js'; @@ -30,6 +30,7 @@ import { StatusMessage, SmartConfirm, StatusList, + TextInput, } from '../../components/index.js'; import { checkConfigPolicy } from '../../../core/policy/index.js'; import { useChangeProgress, useAsyncEffect } from '../../hooks/index.js'; diff --git a/src/tui/screens/change/ChangeRewindScreen.tsx b/src/tui/screens/change/ChangeRewindScreen.tsx index 80fc260a..990f1aa0 100644 --- a/src/tui/screens/change/ChangeRewindScreen.tsx +++ b/src/tui/screens/change/ChangeRewindScreen.tsx @@ -13,7 +13,7 @@ */ import { useState, useCallback } from 'react'; import { Box, Text, useInput } from 'ink'; -import { TextInput, ProgressBar } from '@inkjs/ui'; +import { ProgressBar } from '@inkjs/ui'; import type { ReactElement } from 'react'; import { isNumericString, type ScreenProps } from '../../types.js'; @@ -31,6 +31,7 @@ import { StatusMessage, SmartConfirm, StatusList, + TextInput, } from '../../components/index.js'; import { checkConfigPolicy } from '../../../core/policy/index.js'; import { useChangeProgress, useAsyncEffect } from '../../hooks/index.js'; diff --git a/src/tui/screens/config/ConfigAddScreen.tsx b/src/tui/screens/config/ConfigAddScreen.tsx index 51ff8e7f..158e6c87 100644 --- a/src/tui/screens/config/ConfigAddScreen.tsx +++ b/src/tui/screens/config/ConfigAddScreen.tsx @@ -26,10 +26,12 @@ import type { Dialect } from '../../../core/connection/types.js'; 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 { testConnection } from '../../../core/connection/factory.js'; import { DEFAULT_ACCESS, GUARDED_ACCESS } from '../../../core/policy/index.js'; import { getErrorMessage, + STOPPED_WAITING_MESSAGE, validateConfigName, validatePort, buildConnectionConfig, @@ -55,6 +57,13 @@ export function ConfigAddScreen({ params }: ScreenProps): ReactElement { const [busyLabel, setBusyLabel] = useState('Testing connection...'); const [connectionError, setConnectionError] = useState(null); + // Only the connection test can be cancelled. The save that follows is a + // local write, and offering a hatch over it would let the screen report + // "nothing was saved" about a config that had just been written. + const [cancellable, setCancellable] = useState(false); + + const task = useAbortableTask(); + // Default access for a brand-new config: the matched stage's `protected` // flag (guarded when true) if the caller navigated with a known stage // name, otherwise the unrestricted-by-the-author default — which still @@ -120,21 +129,24 @@ export function ConfigAddScreen({ params }: ScreenProps): ReactElement { }, { key: 'userRole', - label: 'User Role (CLI/TUI access)', + label: 'User Role', + hint: '(CLI/TUI access)', type: 'select', options: USER_ROLE_OPTIONS, defaultValue: defaultAccess.user, }, { key: 'agentRole', - label: 'Agent Role (MCP/CLI access)', + label: 'Agent Role', + hint: '(MCP/CLI access)', type: 'select', options: AGENT_ROLE_OPTIONS, defaultValue: defaultAccess.agent === false ? 'off' : defaultAccess.agent, }, { key: 'isTest', - label: 'Test Database (skipped in production builds)', + label: 'Test Database', + hint: '(skipped in production builds)', type: 'checkbox', defaultValue: false, }, @@ -158,11 +170,21 @@ export function ConfigAddScreen({ params }: ScreenProps): ReactElement { const connectionConfig = buildConnectionConfig(values, dialect); // Test connection first (server only - database may not exist yet) + const controller = task.start(); + setBusy(true); setBusyLabel('Testing connection...'); setConnectionError(null); + setCancellable(true); + + const result = await testConnection(connectionConfig, { + testServerOnly: true, + signal: controller.signal, + }); - const result = await testConnection(connectionConfig, { testServerOnly: true }); + // Cancelled or superseded: whoever did that already owns the + // screen, and a driver that answered anyway must not undo it. + if (!task.isCurrent(controller)) return; if (!result.ok) { @@ -186,6 +208,7 @@ export function ConfigAddScreen({ params }: ScreenProps): ReactElement { // Save config setBusyLabel('Saving configuration...'); + setCancellable(false); const [_, err] = await attempt(async () => { @@ -202,6 +225,8 @@ export function ConfigAddScreen({ params }: ScreenProps): ReactElement { }); + if (!task.isCurrent(controller)) return; + if (err) { setConnectionError(getErrorMessage(err)); @@ -230,9 +255,20 @@ export function ConfigAddScreen({ params }: ScreenProps): ReactElement { } }, - [stateManager, configs, refresh, showToast, back, navigate, fromInit], + [stateManager, configs, refresh, showToast, back, navigate, fromInit, task], ); + // Escape while busy stops the operation instead of walking away from it. + const handleCancelBusy = useCallback(() => { + + if (!task.cancel()) return; + + setBusy(false); + setCancellable(false); + setConnectionError(STOPPED_WAITING_MESSAGE); + + }, [task]); + // Handle cancel const handleCancel = useCallback(() => { @@ -260,6 +296,7 @@ export function ConfigAddScreen({ params }: ScreenProps): ReactElement { focusLabel="ConfigAddForm" busy={busy} busyLabel={busyLabel} + onCancelBusy={cancellable ? handleCancelBusy : undefined} statusError={connectionError ?? undefined} /> diff --git a/src/tui/screens/config/ConfigCopyScreen.tsx b/src/tui/screens/config/ConfigCopyScreen.tsx index 8abdcf00..3897a2c5 100644 --- a/src/tui/screens/config/ConfigCopyScreen.tsx +++ b/src/tui/screens/config/ConfigCopyScreen.tsx @@ -11,7 +11,6 @@ */ import { useState, useCallback, useMemo } from 'react'; import { Box, Text, useInput } from 'ink'; -import { TextInput } from '@inkjs/ui'; import { attempt } from '@logosdx/utils'; import type { ReactElement } from 'react'; @@ -20,7 +19,7 @@ import type { ScreenProps } from '../../types.js'; import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; -import { Panel, Spinner, useToast, MissingParamPanel, NotFoundPanel } from '../../components/index.js'; +import { Panel, Spinner, useToast, MissingParamPanel, NotFoundPanel, TextInput } from '../../components/index.js'; import { getErrorMessage, validateConfigName } from '../../utils/index.js'; /** diff --git a/src/tui/screens/config/ConfigEditScreen.tsx b/src/tui/screens/config/ConfigEditScreen.tsx index 6d784b57..c4f1511a 100644 --- a/src/tui/screens/config/ConfigEditScreen.tsx +++ b/src/tui/screens/config/ConfigEditScreen.tsx @@ -11,7 +11,7 @@ * ``` */ import { useState, useCallback, useMemo } from 'react'; -import { Box, useStdout } from 'ink'; +import { useWindowSize } from 'ink'; import { attempt } from '@logosdx/utils'; import type { ReactElement } from 'react'; @@ -24,8 +24,10 @@ import { useAppContext } from '../../app-context.js'; import { Panel, Form, useToast, MissingParamPanel, NotFoundPanel } from '../../components/index.js'; import { testConnection } from '../../../core/connection/factory.js'; import { SettingsProvider } from '../../../core/config/resolver.js'; +import { useAbortableTask } from '../../hooks/index.js'; import { getErrorMessage, + STOPPED_WAITING_MESSAGE, validateConfigName, validatePort, buildConnectionConfig, @@ -42,7 +44,11 @@ export function ConfigEditScreen({ params }: ScreenProps): ReactElement { const { back } = useRouter(); const { stateManager, settingsManager, refresh } = useAppContext(); const { showToast } = useToast(); - const { stdout } = useStdout(); + // useWindowSize, not useStdout: stdout.rows mutates on resize without asking + // React for anything, so formHeight below would stay frozen at mount size. + // Must stay above the early returns, or the hook count changes across the + // async config-load boundary. + const { rows: terminalHeight } = useWindowSize(); const configName = params.name; @@ -50,6 +56,13 @@ export function ConfigEditScreen({ params }: ScreenProps): ReactElement { const [busyLabel, setBusyLabel] = useState('Testing connection...'); const [connectionError, setConnectionError] = useState(null); + // Only the connection test can be cancelled. The save that follows is a + // local write, and offering a hatch over it would let the screen report + // "nothing was saved" about a config that had just been written. + const [cancellable, setCancellable] = useState(false); + + const task = useAbortableTask(); + // Get the config to edit const config = useMemo(() => { @@ -87,8 +100,9 @@ export function ConfigEditScreen({ params }: ScreenProps): ReactElement { }, { key: 'dialect', - label: 'Database Type (cannot be changed)', + label: 'Database Type', type: 'text', + hint: '(locked)', defaultValue: config.connection.dialect, // Read-only - we'll skip this in submit }, @@ -127,14 +141,16 @@ export function ConfigEditScreen({ params }: ScreenProps): ReactElement { }, { key: 'userRole', - label: 'User Role (CLI/TUI access)', + label: 'User Role', + hint: '(CLI/TUI access)', type: 'select', options: USER_ROLE_OPTIONS, defaultValue: access.user, }, { key: 'agentRole', - label: 'Agent Role (MCP/CLI access)', + label: 'Agent Role', + hint: '(MCP/CLI access)', type: 'select', options: AGENT_ROLE_OPTIONS, defaultValue: access.agent === false ? 'off' : access.agent, @@ -170,11 +186,21 @@ export function ConfigEditScreen({ params }: ScreenProps): ReactElement { }); // Test connection first + const controller = task.start(); + setBusy(true); setBusyLabel('Testing connection...'); setConnectionError(null); + setCancellable(true); - const result = await testConnection(connectionConfig, { testServerOnly: true }); + const result = await testConnection(connectionConfig, { + testServerOnly: true, + signal: controller.signal, + }); + + // Cancelled or superseded: whoever did that already owns the + // screen, and a driver that answered anyway must not undo it. + if (!task.isCurrent(controller)) return; if (!result.ok) { @@ -198,6 +224,7 @@ export function ConfigEditScreen({ params }: ScreenProps): ReactElement { // Save config setBusyLabel('Saving changes...'); + setCancellable(false); const [_, err] = await attempt(async () => { @@ -213,6 +240,8 @@ export function ConfigEditScreen({ params }: ScreenProps): ReactElement { }); + if (!task.isCurrent(controller)) return; + if (err) { setConnectionError(getErrorMessage(err)); @@ -230,9 +259,20 @@ export function ConfigEditScreen({ params }: ScreenProps): ReactElement { back(); }, - [stateManager, config, configName, settingsProvider, refresh, showToast, back], + [stateManager, config, configName, settingsProvider, refresh, showToast, back, task], ); + // Escape while busy stops the operation instead of walking away from it. + const handleCancelBusy = useCallback(() => { + + if (!task.cancel()) return; + + setBusy(false); + setCancellable(false); + setConnectionError(STOPPED_WAITING_MESSAGE); + + }, [task]); + // Handle cancel const handleCancel = useCallback(() => { @@ -254,25 +294,26 @@ export function ConfigEditScreen({ params }: ScreenProps): ReactElement { } - const terminalHeight = stdout.rows ?? 24; - - // Reserve space for Panel border (2), title (2), padding (2) - const formHeight = Math.max(terminalHeight - 6, 10); + // App shell header (2) + status bar (2) + panel border (2) + title and its + // spacer (2) + vertical padding (2). The old reserve of 6 ignored the shell, + // which is what pushed the last fields under an overflow-hidden fold; the + // Form windows itself to this budget now, so no clipping container is needed. + const formHeight = Math.max(terminalHeight - 10, 8); return ( - - - + ); diff --git a/src/tui/screens/config/ConfigExportScreen.tsx b/src/tui/screens/config/ConfigExportScreen.tsx index d6227dd9..caeec34f 100644 --- a/src/tui/screens/config/ConfigExportScreen.tsx +++ b/src/tui/screens/config/ConfigExportScreen.tsx @@ -12,7 +12,6 @@ */ import { useState, useCallback, useMemo } from 'react'; import { Box, Text, useInput } from 'ink'; -import { TextInput } from '@inkjs/ui'; import { writeFileSync } from 'fs'; import { join } from 'path'; import { attempt } from '@logosdx/utils'; @@ -28,6 +27,7 @@ import { Spinner, StatusMessage, SelectList, + TextInput, type SelectListItem, } from '../../components/index.js'; import { encryptForRecipient } from '../../../core/identity/crypto.js'; diff --git a/src/tui/screens/config/ConfigListScreen.tsx b/src/tui/screens/config/ConfigListScreen.tsx index 21178af8..17aac278 100644 --- a/src/tui/screens/config/ConfigListScreen.tsx +++ b/src/tui/screens/config/ConfigListScreen.tsx @@ -214,7 +214,6 @@ export function ConfigListScreen({ params: _params }: ScreenProps): ReactElement onSelect={handleSelect} onHighlight={handleHighlight} isFocused={isFocused} - visibleCount={8} /> )} diff --git a/src/tui/screens/db/DbTransferScreen.tsx b/src/tui/screens/db/DbTransferScreen.tsx index 7dd31598..a0643d06 100644 --- a/src/tui/screens/db/DbTransferScreen.tsx +++ b/src/tui/screens/db/DbTransferScreen.tsx @@ -29,7 +29,7 @@ */ import { useState, useEffect, useCallback, useMemo } from 'react'; import { Box, Text, useInput } from 'ink'; -import { ProgressBar, TextInput } from '@inkjs/ui'; +import { ProgressBar } from '@inkjs/ui'; import { attempt } from '@logosdx/utils'; import type { ReactElement } from 'react'; @@ -48,6 +48,7 @@ import { Confirm, SmartConfirm, FilePicker, + TextInput, type SelectListItem, } from '../../components/index.js'; @@ -815,15 +816,13 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement Source: {activeConfigName} Select destination or action: - - - + @@ -850,17 +849,15 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement Space to toggle, Enter to continue ({selectAllTables ? 'all' : selectedTables.size} selected) - - setPhase('select-dest')} - visibleCount={12} - /> - + setPhase('select-dest')} + reserveRows={4} + /> @@ -886,13 +883,17 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement DRY RUN MODE - the transfer will be validated, not executed )} + {/* Pinned rather than terminal-derived: the menu is a fixed + set of entries - one truncate toggle and the four conflict + strategies - so it always fits and a taller terminal has + nothing more to reveal. */} setPhase('select-tables')} - visibleCount={5} + visibleCount={optionItems.length} /> @@ -994,7 +995,6 @@ export function DbTransferScreen({ params: _params }: ScreenProps): ReactElement selected={importFiles} onSelect={handleImportFilesSelect} onCancel={() => setPhase('select-dest')} - visibleCount={12} /> ); diff --git a/src/tui/screens/db/DtModifyScreen.tsx b/src/tui/screens/db/DtModifyScreen.tsx index ed773781..c27635cd 100644 --- a/src/tui/screens/db/DtModifyScreen.tsx +++ b/src/tui/screens/db/DtModifyScreen.tsx @@ -864,7 +864,6 @@ export function DtModifyScreen({ params: _params }: ScreenProps): ReactElement { files={availableDtFiles} onSelect={handleFileSelect} onCancel={back} - visibleCount={12} /> ); @@ -1183,7 +1182,7 @@ export function DtModifyScreen({ params: _params }: ScreenProps): ReactElement { }} focusLabel="DtModifyDropList" - visibleCount={10} + reserveRows={2} /> @@ -1303,7 +1302,7 @@ export function DtModifyScreen({ params: _params }: ScreenProps): ReactElement { onSelect={handleRenameSelect} onCancel={() => setPhase('operations')} focusLabel="DtModifyRenameList" - visibleCount={10} + reserveRows={2} /> @@ -1423,7 +1422,7 @@ export function DtModifyScreen({ params: _params }: ScreenProps): ReactElement { }} focusLabel="DtModifyAlterList" - visibleCount={10} + reserveRows={2} /> diff --git a/src/tui/screens/db/SqlHistoryScreen.tsx b/src/tui/screens/db/SqlHistoryScreen.tsx index e49ca00b..871e9ef0 100644 --- a/src/tui/screens/db/SqlHistoryScreen.tsx +++ b/src/tui/screens/db/SqlHistoryScreen.tsx @@ -15,7 +15,7 @@ * ``` */ import { useState, useEffect, useMemo } from 'react'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text, useInput, useWindowSize } from 'ink'; import type { ReactElement } from 'react'; import type { ScreenProps } from '../../types.js'; @@ -25,7 +25,7 @@ import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Spinner, useToast } from '../../components/index.js'; -import { ResultTable } from '../../components/terminal/index.js'; +import { ResultBrowser } from '../../components/terminal/index.js'; import { SqlHistoryManager } from '../../../core/sql-terminal/index.js'; import dayjs from 'dayjs'; @@ -55,6 +55,9 @@ export function SqlHistoryScreen({ params: _params }: ScreenProps): ReactElement const { isFocused } = useFocusScope('SqlHistory'); const { activeConfigName, projectRoot } = useAppContext(); const { showToast } = useToast(); + // Above the early returns, or the hook count changes between renders once + // the history load resolves. + const { rows: terminalHeight } = useWindowSize(); // State const [history, setHistory] = useState([]); @@ -64,6 +67,10 @@ export function SqlHistoryScreen({ params: _params }: ScreenProps): ReactElement const [selectedResult, setSelectedResult] = useState(null); const [loadingResult, setLoadingResult] = useState(false); + // Whether the open result has a row up as a document, so the footer below + // stops offering an Escape the viewer has taken over. + const [rowOpen, setRowOpen] = useState(false); + const maxVisibleRows = 10; // Load history @@ -115,15 +122,22 @@ export function SqlHistoryScreen({ params: _params }: ScreenProps): ReactElement }; + /** Whether the open result has a grid, which decides who owns Escape. */ + const hasResultGrid = Boolean(selectedResult?.columns && selectedResult.rows); + // Keyboard shortcuts useInput((input, key) => { if (!isFocused) return; - // Viewing result - Escape to close + // Viewing a result. The grid owns Escape by mode — cancel filter, leave + // sort, then close — and Ink hands every keystroke to every registered + // handler, so claiming it here as well would close the whole result view + // on a keystroke the reader meant for the filter box. Escape is claimed + // here only for a stored result with no grid to hand it to. if (selectedResult) { - if (key.escape) { + if (key.escape && !hasResultGrid) { setSelectedResult(null); @@ -275,19 +289,25 @@ export function SqlHistoryScreen({ params: _params }: ScreenProps): ReactElement {selectedResult.columns && selectedResult.rows ? ( - setSelectedResult(null)} + onRowOpenChange={setRowOpen} /> ) : ( No data )} - - [Esc] Close - + {!rowOpen && ( + + [Esc] Close + + )} ); diff --git a/src/tui/screens/db/SqlTerminalScreen.tsx b/src/tui/screens/db/SqlTerminalScreen.tsx index 2ae16861..b05821dd 100644 --- a/src/tui/screens/db/SqlTerminalScreen.tsx +++ b/src/tui/screens/db/SqlTerminalScreen.tsx @@ -16,7 +16,7 @@ * ``` */ import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; -import { Box, Text, useInput, useStdout } from 'ink'; +import { Box, Text, useInput, useWindowSize } from 'ink'; import { attempt } from '@logosdx/utils'; import type { ReactElement } from 'react'; @@ -28,9 +28,15 @@ import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Spinner, useToast } from '../../components/index.js'; -import { SqlInput, ResultTable } from '../../components/terminal/index.js'; +import { SqlInput, ResultBrowser } from '../../components/terminal/index.js'; +import { useAbortableTask } from '../../hooks/index.js'; import { createConnection, testConnection } from '../../../core/connection/index.js'; -import { SqlHistoryManager, executeRawSql } from '../../../core/sql-terminal/index.js'; +import { + SqlHistoryManager, + executeRawSql, + abortMessageFor, + hasServerSideCancel, +} from '../../../core/sql-terminal/index.js'; /** * Focus areas within the terminal. @@ -46,20 +52,26 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { const { isFocused } = useFocusScope('SqlTerminal'); const { activeConfig, activeConfigName, projectRoot, setHelpKeyEnabled } = useAppContext(); const { showToast } = useToast(); - const { stdout } = useStdout(); + // useWindowSize, not useStdout: stdout.rows mutates on resize without asking + // React for anything, so a memo keyed on it never recomputes. + const { rows: terminalHeight } = useWindowSize(); // Calculate max visible rows as 75% of terminal height, accounting for UI chrome // UI chrome: header (2), panel border (2), status bar (1), separator (1), footer (2), help (1) = ~9 lines const maxResultRows = useMemo(() => { - const terminalHeight = stdout.rows ?? 24; const uiChrome = 9; const availableHeight = terminalHeight - uiChrome; const maxRows = Math.floor(availableHeight * 0.75); return Math.max(5, Math.min(maxRows, 30)); - }, [stdout.rows]); + }, [terminalHeight]); + + // Lines the row document viewer gets. It replaces the grid rather than + // sitting beside it, so it is spending the same chrome and is not capped at + // thirty: a row with sixty columns should use whatever the terminal has. + const rowViewRows = useMemo(() => Math.max(5, terminalHeight - 9), [terminalHeight]); // State const [query, setQuery] = useState(''); @@ -69,6 +81,11 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { const [connectionError, setConnectionError] = useState(null); const [focusArea, setFocusArea] = useState('input'); + // Whether the result browser has a row open as a document. The footer below + // is outside that component, so without this it would keep offering Tab and + // Escape while the viewer owns both. + const [rowOpen, setRowOpen] = useState(false); + // History state const [history, setHistory] = useState([]); const [historyIndex, setHistoryIndex] = useState(-1); @@ -78,6 +95,9 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { const dbRef = useRef | null>(null); const destroyRef = useRef<(() => Promise) | null>(null); + // One in-flight operation at a time: the initial connect, then each query. + const task = useAbortableTask(); + // Load query from params if re-running from history useEffect(() => { @@ -116,6 +136,7 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { } let cancelled = false; + const controller = task.start(); const initialize = async () => { @@ -123,11 +144,13 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { setConnectionError(null); // Test connection - const testResult = await testConnection(activeConfig.connection); + const testResult = await testConnection(activeConfig.connection, { + signal: controller.signal, + }); if (!testResult.ok) { - if (!cancelled) { + if (!cancelled && task.isCurrent(controller)) { setConnectionError(testResult.error ?? 'Connection failed'); setIsConnecting(false); @@ -140,12 +163,12 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { // Create connection const [conn, connErr] = await attempt(() => - createConnection(activeConfig.connection, activeConfigName), + createConnection(activeConfig.connection, activeConfigName, {}, controller.signal), ); if (connErr || !conn) { - if (!cancelled) { + if (!cancelled && task.isCurrent(controller)) { setConnectionError(connErr?.message ?? 'Failed to connect'); setIsConnecting(false); @@ -156,7 +179,7 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { } - if (cancelled) { + if (cancelled || !task.isCurrent(controller)) { await conn.destroy(); @@ -206,6 +229,8 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { if (!dbRef.current || !historyManagerRef.current || !activeConfigName || !activeConfig) return; + const controller = task.start(); + setIsExecuting(true); setResult(null); @@ -214,9 +239,15 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { access: activeConfig.access, channel: 'user', dialect: activeConfig.connection.dialect, - }), + }, controller.signal), ); + // The cancel handler already reported the outcome. A driver that + // answered anyway must not overwrite it, or the screen silently + // un-cancels itself — and the query would land in history as if the + // user had waited for it. + if (!task.isCurrent(controller)) return; + const execResult: SqlExecutionResult = gateErr ? { success: false, errorMessage: gateErr.message, durationMs: 0 } : rawResult!; @@ -257,7 +288,32 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { } - }, [activeConfigName, activeConfig, showToast]); + }, [activeConfigName, activeConfig, showToast, task]); + + // Escape while an operation is in flight. Returns false when there was + // nothing to cancel, so the caller can fall through to its normal Escape. + const cancelInFlight = useCallback(() => { + + if (!task.cancel()) return false; + + const dialect = activeConfig?.connection.dialect; + + if (dialect) { + + setResult({ + success: false, + errorMessage: abortMessageFor(dialect), + durationMs: 0, + aborted: hasServerSideCancel(dialect) ? 'server-cancel-requested' : 'stopped-waiting', + }); + + } + + setIsExecuting(false); + + return true; + + }, [task, activeConfig]); // Navigate history const handleHistoryNavigate = useCallback((direction: 'up' | 'down') => { @@ -318,7 +374,33 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { // Keyboard shortcuts useInput((input, key) => { - if (!isFocused || isExecuting) return; + if (!isFocused) return; + + // A query in flight owns Escape: leaving the screen would abandon it + // rather than stop it, and every other key belongs to a screen that is + // not accepting input yet. + if (isExecuting) { + + if (key.escape) cancelInFlight(); + + return; + + } + + // Connecting: Escape stops the connect on the way out, so the driver + // is not left opening a pool for a screen that no longer exists. + if (isConnecting) { + + if (key.escape) { + + task.cancel(); + back(); + + } + + return; + + } // Tab: Switch focus between input and results if (key.tab && !key.shift && result?.rows && result.rows.length > 0) { @@ -393,6 +475,10 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { + + + [Esc] Cancel + ); @@ -441,12 +527,15 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { paddingX={1} paddingY={1} > - setFocusArea('input')} + onRowOpenChange={setRowOpen} /> )} @@ -471,8 +560,9 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { {/* Executing indicator */} {isExecuting && ( - + + [Esc] Cancel )} @@ -496,14 +586,26 @@ export function SqlTerminalScreen({ params }: ScreenProps): ReactElement { )} - {/* Footer hints */} - - {focusArea === 'input' && [h] History} - {result?.rows && result.rows.length > 0 && ( - [Tab] {focusArea === 'input' ? 'View Results' : 'Edit Query'} - )} - [Esc] {focusArea === 'results' ? 'Edit Query' : query.trim() ? 'Clear' : 'Back'} - + {/* Footer hints. Silent while a row is open as a document: that + overlay names its own keys and owns Escape, and Tab does not + reach this screen at all until it closes. */} + {!rowOpen && ( + + {focusArea === 'input' && [h] History} + {result?.rows && result.rows.length > 0 && ( + [Tab] {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'} + + )} + + )} ); diff --git a/src/tui/screens/db/explore/ExploreDetailScreen.tsx b/src/tui/screens/db/explore/ExploreDetailScreen.tsx index 312583ef..a387b49d 100644 --- a/src/tui/screens/db/explore/ExploreDetailScreen.tsx +++ b/src/tui/screens/db/explore/ExploreDetailScreen.tsx @@ -4,7 +4,19 @@ * Shows full details for tables, views, procedures, functions, and types. * Displays columns, indexes, foreign keys, parameters, etc. * + * A detail view does not return a tree; it returns a flat list of rows, one + * element per visual line, and `ScrollView` draws the slice of that list the + * terminal has room for. Ink exposes no scroll offset, so the alternative was + * to window five nested section trees independently — five implementations of + * the same arithmetic, none of them assertable. + * * Keyboard shortcuts: + * - ↑/↓: Scroll one row + * - Ctrl+U/Ctrl+D: Scroll half a viewport + * - PageUp/PageDown (fn+↑/fn+↓ on a Mac), ⌘+↑/⌘+↓: Scroll one viewport + * - Home/End: Jump to either end + * - v: Open the full-text overlay, where nothing is truncated + * - p: Peek at the table's first and last rows (tables only) * - Esc: Go back * * @example @@ -13,7 +25,7 @@ * ``` */ import { useState } from 'react'; -import { Box, Text, useInput } from 'ink'; +import { Box, Text, useInput, useWindowSize } from 'ink'; import { attempt } from '@logosdx/utils'; import type { ReactElement } from 'react'; @@ -26,6 +38,24 @@ import { useAppContext } from '../../../app-context.js'; import { Panel, Spinner } from '../../../components/index.js'; import { useConnection, useAsyncEffect } from '../../../hooks/index.js'; import { fetchDetail } from '../../../../core/explore/index.js'; +import { + CELL_GAP, + MARKER_WIDTH, + IDENTIFIER_CAP, + DATA_TYPE_CAP, + cellWidth, + detailFooterHints, + fitWidths, + rowBudget, + rowWindow, + scrollTarget, + viewportRows, + wrapText, +} from './layout.js'; +import { FullTextOverlay } from './FullTextOverlay.js'; +import { RowPeekOverlay } from './RowPeekOverlay.js'; + +import type { DetailOverlay, DetailRow } from './layout.js'; import type { DetailCategory } from '../../../../core/explore/index.js'; import type { @@ -57,303 +87,652 @@ const ROUTE_TO_CATEGORY: Record = { type AnyDetail = TableDetail | ViewDetail | ProcedureDetail | FunctionDetail | TypeDetail; /** - * Column list component. + * The trailing cell of a column row: nullability, then the default if there + * is one. + */ +function constraintText(col: ColumnDetail): string { + + const nullability = col.isNullable ? 'NULL' : 'NOT NULL'; + + return col.defaultValue ? `${nullability} DEFAULT ${col.defaultValue}` : nullability; + +} + +/** + * The cells of a row as one string, spaced the way the row Box gaps them. + * + * This is the untruncated copy of the line. It is not padded to the cell widths, + * because padding is what the element does to keep the columns aligned and the + * overlay's whole job is to stop paying for that. + */ +function joinCells(cells: string[]): string { + + return cells.join(' '.repeat(CELL_GAP)); + +} + +/** + * A blank line between two sections. + * + * The sections used to be `gap={1}` on a column Box. A gap is invisible to the + * row count, so it becomes a row of its own here. */ -function ColumnList({ columns }: { columns: ColumnDetail[] }): ReactElement { +function spacerRow(key: string): DetailRow { + + return { text: '', element: }; + +} + +/** + * A section's underlined title. + */ +function headingRow(key: string, text: string): DetailRow { + + return { text, element: {text} }; + +} + +/** + * One row per column. + * + * Cell widths come from the longest entry in this table, not from a guess, so + * the type and constraint columns land on one offset for every row no matter + * which rows carry a default. + * + * @example + * const rows = columnRows(detail.columns, rowBudget(terminalColumns)); + */ +export function columnRows(columns: ColumnDetail[], width: number): DetailRow[] { if (columns.length === 0) { - return No columns; + return [{ text: 'No columns', element: No columns }]; } - return ( - - {columns.map((col) => ( - - - - {col.isPrimaryKey ? '* ' : ' '} - {col.name} + const [nameWidth, typeWidth, constraintWidth] = fitWidths( + [ + MARKER_WIDTH + cellWidth(columns.map((col) => col.name), IDENTIFIER_CAP), + cellWidth(columns.map((col) => col.dataType), DATA_TYPE_CAP), + cellWidth(columns.map(constraintText)), + ], + width, + ); + + return columns.map((col) => { + + const name = `${col.isPrimaryKey ? '* ' : ' '}${col.name}`; + + return { + text: joinCells([name, col.dataType, constraintText(col)]), + element: ( + + + + {name} - - {col.dataType} + + {col.dataType} + + + {constraintText(col)} - - {col.isNullable ? 'NULL' : 'NOT NULL'} - {col.defaultValue ? ` DEFAULT ${col.defaultValue}` : ''} - - ))} - - ); + ), + }; + + }); } /** - * Parameter list component. + * One row per parameter. + * + * Same content-derived widths as the column rows, so a procedure with one + * `timestamp with time zone` parameter does not stagger the rest. + * + * @example + * const rows = parameterRows(detail.parameters, rowBudget(terminalColumns)); */ -function ParameterList({ parameters }: { parameters: ParameterDetail[] }): ReactElement { +export function parameterRows(parameters: ParameterDetail[], width: number): DetailRow[] { if (parameters.length === 0) { - return No parameters; + return [{ text: 'No parameters', element: No parameters }]; } - return ( - - {parameters.map((param) => ( - - - {param.name} - - - {param.dataType} - - {param.mode} - - ))} - + const [nameWidth, typeWidth, modeWidth] = fitWidths( + [ + cellWidth(parameters.map((param) => param.name), IDENTIFIER_CAP), + cellWidth(parameters.map((param) => param.dataType), DATA_TYPE_CAP), + cellWidth(parameters.map((param) => param.mode)), + ], + width, ); + return parameters.map((param) => ({ + text: joinCells([param.name, param.dataType, param.mode]), + element: ( + + + {param.name} + + + {param.dataType} + + + {param.mode} + + + ), + })); + } /** - * Index list component. + * The trailing cell of an index row: the indexed columns, then UNIQUE when + * that is not already implied by the primary-key marker. */ -function IndexList({ indexes }: { indexes: IndexSummary[] }): ReactElement { +function indexColumnsText(idx: IndexSummary): string { + + const columns = `(${idx.columns.join(', ')})`; + + return idx.isUnique && !idx.isPrimary ? `${columns} UNIQUE` : columns; + +} + +/** + * One row per index. + * + * Index names run long and unevenly, so the name cell is capped and truncated + * rather than allowed to push the column list off the right edge. + * + * @example + * const rows = indexRows(detail.indexes, rowBudget(terminalColumns)); + */ +export function indexRows(indexes: IndexSummary[], width: number): DetailRow[] { if (indexes.length === 0) { - return No indexes; + return [{ text: 'No indexes', element: No indexes }]; } - return ( - - {indexes.map((idx) => ( - - - - {idx.isPrimary ? '* ' : ' '} - {idx.name} + const [nameWidth, columnsWidth] = fitWidths( + [ + MARKER_WIDTH + cellWidth(indexes.map((idx) => idx.name), IDENTIFIER_CAP), + cellWidth(indexes.map(indexColumnsText)), + ], + width, + ); + + return indexes.map((idx) => { + + const name = `${idx.isPrimary ? '* ' : ' '}${idx.name}`; + + return { + text: joinCells([name, indexColumnsText(idx)]), + element: ( + + + + {name} - - ({idx.columns.join(', ')}) - {idx.isUnique && !idx.isPrimary ? ' UNIQUE' : ''} - + + {indexColumnsText(idx)} + - ))} - - ); + ), + }; + + }); } /** - * Foreign key list component. + * Two rows per foreign key, not two columns: a constraint name plus both sides + * of the reference never fits one line. Both rows truncate so a long reference + * degrades instead of reflowing under the next key's name. + * + * @example + * const rows = foreignKeyRows(detail.foreignKeys, rowBudget(terminalColumns)); */ -function ForeignKeyList({ foreignKeys }: { foreignKeys: ForeignKeySummary[] }): ReactElement { +export function foreignKeyRows(foreignKeys: ForeignKeySummary[], width: number): DetailRow[] { if (foreignKeys.length === 0) { - return No foreign keys; + return [{ text: 'No foreign keys', element: No foreign keys }]; } - return ( - - {foreignKeys.map((fk) => ( - - {fk.name} - - {' '}({fk.columns.join(', ')}) → {fk.referencedTable}({fk.referencedColumns.join(', ')}) - - - ))} - - ); + return foreignKeys.flatMap((fk) => { + + const reference = ` (${fk.columns.join(', ')}) → ${fk.referencedTable}(${fk.referencedColumns.join(', ')})`; + + return [ + { + text: fk.name, + element: ( + + {fk.name} + + ), + }, + { + text: reference, + element: ( + + {reference} + + ), + }, + ]; + + }); } /** - * Table detail view. + * Qualified object name, with whatever the view shows beside it. */ -function TableDetailView({ detail }: { detail: TableDetail }): ReactElement { +function titleRow(detail: AnyDetail, trailing?: string): DetailRow { - return ( - - {/* Header */} - - {detail.schema ? `${detail.schema}.` : ''}{detail.name} - {detail.rowCountEstimate !== undefined && ( - ~{detail.rowCountEstimate.toLocaleString()} rows - )} - + const qualified = `${detail.schema ? `${detail.schema}.` : ''}${detail.name}`; - {/* Columns */} - - Columns ({detail.columns.length}) - + return { + text: trailing ? joinCells([qualified, trailing]) : qualified, + element: ( + + {qualified} + {trailing !== undefined && {trailing}} + ), + }; - {/* Indexes */} - {detail.indexes.length > 0 && ( - - Indexes ({detail.indexes.length}) - - - )} +} - {/* Foreign Keys */} - {detail.foreignKeys.length > 0 && ( - - Foreign Keys ({detail.foreignKeys.length}) - - - )} - - ); +/** + * The definition dump, one row per line it will occupy. + * + * The 500-character cut and its ASCII marker are left as they were; only the + * line breaking is new, because a `` that wraps itself has a height the + * viewport cannot count. + */ +function definitionRows(definition: string, width: number): DetailRow[] { + + const shown = `${definition.slice(0, 500)}${definition.length > 500 ? '...' : ''}`; + + return wrapText(shown, width).map((line, index) => ({ + text: line, + element: {line}, + })); } /** - * View detail view. + * Flatten sections into one row list, a blank line between each. + * + * Replaces the `gap={1}` the section Boxes used to carry, which the viewport + * had no way to account for. */ -function ViewDetailView({ detail }: { detail: ViewDetail }): ReactElement { +function joinSections(sections: DetailRow[][]): DetailRow[] { - return ( - - {/* Header */} - - {detail.schema ? `${detail.schema}.` : ''}{detail.name} - {detail.isUpdatable ? 'UPDATABLE' : 'READ-ONLY'} - + return sections.flatMap((section, index) => ( + index === 0 ? section : [spacerRow(`gap:${index}`), ...section] + )); - {/* Columns */} - - Columns ({detail.columns.length}) - - +} - {/* Definition */} - {detail.definition && ( - - Definition - {detail.definition.slice(0, 500)}{detail.definition.length > 500 ? '...' : ''} - - )} - - ); +/** + * Table detail as rows: identity, columns, then indexes and foreign keys when + * the table has any. + * + * @example + * const rows = tableDetailRows(detail, rowBudget(terminalColumns)); + */ +export function tableDetailRows(detail: TableDetail, width: number): DetailRow[] { + + const sections: DetailRow[][] = [ + [titleRow( + detail, + detail.rowCountEstimate === undefined + ? undefined + : `~${detail.rowCountEstimate.toLocaleString()} rows`, + )], + [headingRow('h:columns', `Columns (${detail.columns.length})`), ...columnRows(detail.columns, width)], + ]; + + if (detail.indexes.length > 0) { + + sections.push([ + headingRow('h:indexes', `Indexes (${detail.indexes.length})`), + ...indexRows(detail.indexes, width), + ]); + + } + + if (detail.foreignKeys.length > 0) { + + sections.push([ + headingRow('h:fks', `Foreign Keys (${detail.foreignKeys.length})`), + ...foreignKeyRows(detail.foreignKeys, width), + ]); + + } + + return joinSections(sections); } /** - * Procedure detail view. + * View detail as rows: identity, columns, then the definition when there is one. + * + * @example + * const rows = viewDetailRows(detail, rowBudget(terminalColumns)); */ -function ProcedureDetailView({ detail }: { detail: ProcedureDetail }): ReactElement { +export function viewDetailRows(detail: ViewDetail, width: number): DetailRow[] { - return ( - - {/* Header */} - {detail.schema ? `${detail.schema}.` : ''}{detail.name} + const sections: DetailRow[][] = [ + [titleRow(detail, detail.isUpdatable ? 'UPDATABLE' : 'READ-ONLY')], + [headingRow('h:columns', `Columns (${detail.columns.length})`), ...columnRows(detail.columns, width)], + ]; - {/* Parameters */} - - Parameters ({detail.parameters.length}) - - + if (detail.definition) { - {/* Definition */} - {detail.definition && ( - - Definition - {detail.definition.slice(0, 500)}{detail.definition.length > 500 ? '...' : ''} - - )} - - ); + sections.push([headingRow('h:definition', 'Definition'), ...definitionRows(detail.definition, width)]); + + } + + return joinSections(sections); } /** - * Function detail view. + * Procedure detail as rows: identity, parameters, then the definition. + * + * @example + * const rows = procedureDetailRows(detail, rowBudget(terminalColumns)); */ -function FunctionDetailView({ detail }: { detail: FunctionDetail }): ReactElement { +export function procedureDetailRows(detail: ProcedureDetail, width: number): DetailRow[] { - return ( - - {/* Header */} - - {detail.schema ? `${detail.schema}.` : ''}{detail.name} - → {detail.returnType} - + const sections: DetailRow[][] = [ + [titleRow(detail)], + [ + headingRow('h:parameters', `Parameters (${detail.parameters.length})`), + ...parameterRows(detail.parameters, width), + ], + ]; - {/* Parameters */} - - Parameters ({detail.parameters.length}) - - + if (detail.definition) { - {/* Definition */} - {detail.definition && ( - - Definition - {detail.definition.slice(0, 500)}{detail.definition.length > 500 ? '...' : ''} - - )} - - ); + sections.push([headingRow('h:definition', 'Definition'), ...definitionRows(detail.definition, width)]); + + } + + return joinSections(sections); } /** - * Type detail view. + * Function detail as rows: identity and return type, parameters, definition. + * + * @example + * const rows = functionDetailRows(detail, rowBudget(terminalColumns)); */ -function TypeDetailView({ detail }: { detail: TypeDetail }): ReactElement { +export function functionDetailRows(detail: FunctionDetail, width: number): DetailRow[] { - return ( - - {/* Header */} - - {detail.schema ? `${detail.schema}.` : ''}{detail.name} - {detail.kind.toUpperCase()} - + const sections: DetailRow[][] = [ + [titleRow(detail, `→ ${detail.returnType}`)], + [ + headingRow('h:parameters', `Parameters (${detail.parameters.length})`), + ...parameterRows(detail.parameters, width), + ], + ]; - {/* Enum values */} - {detail.kind === 'enum' && detail.values && ( - - Values ({detail.values.length}) - - {detail.values.map((value, i) => ( - {value} - ))} - - - )} + if (detail.definition) { - {/* Composite attributes */} - {detail.kind === 'composite' && detail.attributes && ( - - Attributes ({detail.attributes.length}) - - - )} + sections.push([headingRow('h:definition', 'Definition'), ...definitionRows(detail.definition, width)]); - {/* Domain base type */} - {detail.kind === 'domain' && detail.baseType && ( - - Base Type - {detail.baseType} - - )} + } + + return joinSections(sections); + +} + +/** + * Type detail as rows. Which section follows the header depends on the kind: + * enum values, composite attributes, or a domain's base type. + * + * @example + * const rows = typeDetailRows(detail, rowBudget(terminalColumns)); + */ +export function typeDetailRows(detail: TypeDetail, width: number): DetailRow[] { + + const sections: DetailRow[][] = [ + [titleRow(detail, detail.kind.toUpperCase())], + ]; + + if (detail.kind === 'enum' && detail.values) { + + sections.push([ + headingRow('h:values', `Values (${detail.values.length})`), + ...detail.values.map((value, index) => ({ + text: ` ${value}`, + element: {value}, + })), + ]); + + } + + if (detail.kind === 'composite' && detail.attributes) { + + sections.push([ + headingRow('h:attributes', `Attributes (${detail.attributes.length})`), + ...columnRows(detail.attributes, width), + ]); + + } + + if (detail.kind === 'domain' && detail.baseType) { + + sections.push([ + headingRow('h:baseType', 'Base Type'), + { text: ` ${detail.baseType}`, element: {detail.baseType} }, + ]); + + } + + return joinSections(sections); + +} + +/** + * Props for the detail viewport. + */ +export interface ScrollViewProps { + + /** One row per visual line, each carrying its own untruncated text. */ + rows: DetailRow[]; + + /** Lines the viewport may draw, indicators included. */ + height: number; + + /** Focus comes from the screen; this component opens no scope of its own. */ + isFocused: boolean; + + /** + * Told whenever an overlay opens or closes, so the screen can swap the + * footer hints. The overlay's state lives here rather than on the screen + * because the viewport is what has to survive it: keeping this component + * mounted is what preserves the scroll offset across a dismissal. + */ + onOverlayChange?: (overlay: DetailOverlay) => void; + + /** + * Draws the row peek, when the object has rows to peek at. Absent is what + * suppresses `p`, so a view or a procedure never offers a key that would + * open an empty overlay. + */ + renderPeek?: (close: () => void) => ReactElement; + +} + +/** + * A vertical viewport over a flat row list. + * + * Takes `height` as a prop rather than reading `useWindowSize` itself so the + * screen stays the one place that accounts for chrome, and so a test can pin a + * viewport without a terminal to measure. + * + * `v` swaps the viewport for `FullTextOverlay`, which draws the same rows with + * nothing truncated; `p` swaps it for the row peek, when the caller supplied + * one. Both swaps happen here rather than on the screen so this component stays + * mounted through them and its scroll offset survives Escape. + * + * @example + * + */ +export function ScrollView({ + rows, + height, + isFocused, + onOverlayChange, + renderPeek, +}: ScrollViewProps): ReactElement { + + const [offset, setOffset] = useState(0); + const [overlay, setOverlay] = useState('none'); + + const view = rowWindow(rows.length, offset, height); + const maxOffset = rows.length - view.count; + + // Every move rebases on `view.start`, not on `offset`: the window clamps + // what it draws, so a stale offset left by a resize or a smaller object + // cannot send the next keypress somewhere the viewport never was. + const scrollTo = (next: number) => setOffset(Math.min(Math.max(next, 0), maxOffset)); + + const open = (next: DetailOverlay) => { + + setOverlay(next); + onOverlayChange?.(next); + + }; + + useInput((input, key) => { + + // The overlay as well as `isFocused`: an overlay pushes a focus scope, + // so the screen's `isFocused` does go false in the app, but this + // component is also rendered directly by tests that pin it true. + // Whoever owns the keys, it is not this handler while an overlay is up. + if (!isFocused || overlay !== 'none') return; + + // Ink reports Ctrl+V as `input === 'v'` with `key.ctrl` set, so the + // modifier has to be excluded or a paste attempt opens the overlay. + if (input === 'v' && !key.ctrl && !key.meta) { + + open('fullText'); + + return; + + } + + // `r` for rows, matching the hint. Screen-local, as `r` already means + // re-run, rename and transfer on three other screens. + if (input === 'r' && !key.ctrl && !key.meta && renderPeek) { + + open('peek'); + + return; + + } + + const target = scrollTarget(input, key, view, maxOffset); + + if (target !== null) scrollTo(target); + + }); + + if (overlay === 'fullText') { + + return ( + row.text)} + startRow={view.start} + height={height} + onClose={() => open('none')} + /> + ); + + } + + if (overlay === 'peek' && renderPeek) { + + return renderPeek(() => open('none')); + + } + + return ( + + {view.above > 0 && ↑ {view.above} more} + {rows.slice(view.start, view.start + view.count).map((row) => row.element)} + {view.below > 0 && ↓ {view.below} more} ); } +/** + * Whether this detail is a table, by shape rather than by assertion. + * + * `TableDetail` is the only variant carrying both an index list and a foreign + * key list, so the pair narrows the union without a cast. The peek needs a real + * `TableDetail` — it reads the column list for the primary key — and trusting + * the route alone would hand it whatever `fetchDetail` happened to return. + * + * @example + * if (isTableDetail(detail)) peek(detail.columns); + */ +function isTableDetail(detail: AnyDetail): detail is TableDetail { + + return 'indexes' in detail && 'foreignKeys' in detail; + +} + +/** + * Rows for whichever kind of object the route asked for. + * + * The route is what decides, not the object's shape: `fetchDetail` was called + * with this category and returns the matching detail for it. + */ +function buildDetailRows(category: DetailCategory | undefined, detail: AnyDetail, width: number): DetailRow[] { + + switch (category) { + + case 'tables': + return tableDetailRows(detail as TableDetail, width); + + case 'views': + return viewDetailRows(detail as ViewDetail, width); + + case 'procedures': + return procedureDetailRows(detail as ProcedureDetail, width); + + case 'functions': + return functionDetailRows(detail as FunctionDetail, width); + + case 'types': + return typeDetailRows(detail as TypeDetail, width); + + default: + return [{ text: 'Unknown category', element: Unknown category }]; + + } + +} + /** * ExploreDetailScreen component. * @@ -363,19 +742,38 @@ export function ExploreDetailScreen({ params }: ScreenProps): ReactElement { const { back, route } = useRouter(); const { isFocused } = useFocusScope('ExploreDetail'); - const { activeConfig, activeConfigName: _activeConfigName } = useAppContext(); + const { activeConfig, activeConfigName } = useAppContext(); + + // useWindowSize, not useStdout: stdout.columns and .rows mutate on resize + // without telling React, so anything derived from them would freeze at mount + // size. Above the early returns, or the hook count changes once the load + // resolves. + const { columns: terminalColumns, rows: terminalRows } = useWindowSize(); const [detail, setDetail] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + // Mirrors the viewport's overlay state, which the footer is the only thing + // here that needs. Above the early returns, like every other hook. + const [overlay, setOverlay] = useState('none'); + // Get category from route const category = ROUTE_TO_CATEGORY[route]; const name = params.name; const schema = params.schema; - // Shared connection - const { db, dialect, loading: connLoading, error: connError } = useConnection(); + // Shared connection. + // + // `ConnectionProvider` labels it `Kysely` — by its own cast + // at `ConnectionProvider.tsx:192`, over a connection `createConnection` + // returned untyped — so the vault and lock screens can name noorm's own + // tables. Nothing here reads one, and Kysely treats the two instantiations + // as mutually unassignable, so the untyped view is recovered once and both + // core calls take it. This replaces the per-call cast `fetchDetail` used to + // carry rather than adding a second one. + const { db: typedDb, dialect, loading: connLoading, error: connError } = useConnection(); + const db = typedDb as Kysely | null; // Load detail when connection is ready useAsyncEffect(async (isCancelled) => { @@ -393,7 +791,7 @@ export function ExploreDetailScreen({ params }: ScreenProps): ReactElement { const [result, err] = await attempt(async () => { - return await fetchDetail(db as Kysely, dialect, category, name, schema); + return await fetchDetail(db, dialect, category, name, schema); }); @@ -515,41 +913,63 @@ export function ExploreDetailScreen({ params }: ScreenProps): ReactElement { } - // Render detail based on category - const renderDetail = () => { + // Columns a detail row gets once the Panel has taken its border and padding. + const rowWidth = rowBudget(terminalColumns); - switch (category) { - - case 'tables': - return ; - - case 'views': - return ; - - case 'procedures': - return ; + // Rows the viewport gets once the shell, the Panel, and the footer have + // taken theirs. + const height = viewportRows(terminalRows); - case 'functions': - return ; + const detailRows = buildDetailRows(category, detail, rowWidth); - case 'types': - return ; + // Advertise the scroll keys only when there is something to scroll, so a + // detail that fits reads exactly as it did before. + const scrolls = detailRows.length > height; - default: - return Unknown category; - - } - - }; + // Only a table has rows to peek at, and only a live connection can read + // them. Anything missing suppresses the key rather than opening an overlay + // that can only report why it is empty. + const peekable = category === 'tables' + && isTableDetail(detail) + && db !== null + && dialect !== null + && activeConfigName !== null; return ( - {renderDetail()} + ( + + ) + : undefined} + /> - [Esc] Back + {detailFooterHints({ scrolls, overlay, canPeek: peekable }).map((hint) => ( + {hint} + ))} ); diff --git a/src/tui/screens/db/explore/ExploreListScreen.tsx b/src/tui/screens/db/explore/ExploreListScreen.tsx index f3312f30..2b2f4b80 100644 --- a/src/tui/screens/db/explore/ExploreListScreen.tsx +++ b/src/tui/screens/db/explore/ExploreListScreen.tsx @@ -320,7 +320,6 @@ export function ExploreListScreen({ params: _params }: ScreenProps): ReactElemen searchPlaceholder={`Filter ${meta.title.toLowerCase()}...`} emptyLabel={`No ${meta.title.toLowerCase()} found`} noResultsLabel="No matches" - visibleCount={10} isFocused={isFocused} numberNav initialSearchTerm={filterState?.searchTerm} diff --git a/src/tui/screens/db/explore/ExploreOverviewScreen.tsx b/src/tui/screens/db/explore/ExploreOverviewScreen.tsx index a149a7de..34ab241a 100644 --- a/src/tui/screens/db/explore/ExploreOverviewScreen.tsx +++ b/src/tui/screens/db/explore/ExploreOverviewScreen.tsx @@ -12,8 +12,8 @@ * noorm db # Then press 'x' to explore * ``` */ -import { useState } from 'react'; -import { Box, Text, useInput } from 'ink'; +import { useState, useMemo } from 'react'; +import { Box, Text, useInput, useWindowSize } from 'ink'; import { attempt } from '@logosdx/utils'; import type { ReactElement } from 'react'; @@ -26,6 +26,7 @@ import { useAppContext, useExploreFilters, useSettings } from '../../../app-cont import { useOnScreenPopped, useConnection, useAsyncEffect } from '../../../hooks/index.js'; import { Panel, Spinner } from '../../../components/index.js'; import { fetchOverview } from '../../../../core/explore/index.js'; +import { CELL_GAP, LABEL_CAP, cellWidth, fitWidths, rowBudget } from './layout.js'; import type { ExploreOverview, ExploreOptions } from '../../../../core/explore/index.js'; @@ -55,6 +56,83 @@ const CATEGORIES: CategoryConfig[] = [ { key: 'foreignKeys', label: 'Foreign Keys', route: 'db/explore/fks', hotkey: 'k', numberKey: '7' }, ]; +/** + * Sums the categories this screen actually lists. + * + * `ExploreOverview` also carries `triggers`, `locks` and `connections`. None + * has a row here, and locks and connections are runtime state rather than + * schema objects, so counting them left the total silently exceeding the rows + * a reader could see. + * + * @example + * countBrowsableObjects({ tables: 42, views: 7, ...rest }); // 49 + rest + */ +export function countBrowsableObjects(overview: ExploreOverview): number { + + return CATEGORIES.reduce((sum, cat) => sum + (overview[cat.key] ?? 0), 0); + +} + +/** + * Labels of the config summary above the category list. Their gutter is + * derived here so the three values start on one offset instead of each + * landing wherever its own label ended. + */ +const SUMMARY_LABELS = ['Config:', 'Database:', 'Total Objects:']; + +const SUMMARY_GUTTER = cellWidth(SUMMARY_LABELS, LABEL_CAP); + +/** + * Category list component. + * + * Hotkey, label, and count are sized from the categories themselves rather + * than a hardcoded width, and none of the three shrinks, so every count sits + * on the same offset. + */ +export function CategoryList({ overview, width }: { overview: ExploreOverview | null; width: number }): ReactElement { + + const [hotkeyWidth, labelWidth, countWidth] = useMemo(() => fitWidths( + [ + cellWidth(CATEGORIES.map((cat) => `[${cat.numberKey}]`)), + cellWidth(CATEGORIES.map((cat) => cat.label), LABEL_CAP), + cellWidth(CATEGORIES.map((cat) => String(overview?.[cat.key] ?? 0))), + ], + width, + ), [overview, width]); + + return ( + + {CATEGORIES.map((cat) => { + + const count = overview?.[cat.key] ?? 0; + const hasItems = count > 0; + + return ( + + + + [{cat.numberKey}] + + + + + {cat.label} + + + + + {count} + + + + ); + + })} + + ); + +} + /** * ExploreOverviewScreen component. * @@ -68,6 +146,11 @@ export function ExploreOverviewScreen({ params: _params }: ScreenProps): ReactEl const { clearFilters } = useExploreFilters(); const { settings } = useSettings(); + // useWindowSize, not useStdout: stdout.columns mutates on resize without + // telling React, so widths derived from it would freeze at mount size. + // Above the early returns, or the hook count changes once the load resolves. + const { columns: terminalColumns } = useWindowSize(); + const [overview, setOverview] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -213,57 +296,36 @@ export function ExploreOverviewScreen({ params: _params }: ScreenProps): ReactEl } - // Calculate total objects - const totalObjects = overview - ? Object.values(overview).reduce((sum, count) => sum + count, 0) - : 0; + const totalObjects = overview ? countBrowsableObjects(overview) : 0; return ( {/* Config info */} - - Config: + + + Config: + {activeConfigName} ({activeConfig.connection.dialect}) - - Database: + + + Database: + {activeConfig.connection.database} - - Total Objects: + + + Total Objects: + {totalObjects} - {/* Category list */} - - {CATEGORIES.map((cat) => { - - const count = overview?.[cat.key] ?? 0; - const hasItems = count > 0; - - return ( - - - [{cat.numberKey}] - - - - {cat.label} - - - - {count} - - - ); - - })} - + diff --git a/src/tui/screens/db/explore/FullTextOverlay.tsx b/src/tui/screens/db/explore/FullTextOverlay.tsx new file mode 100644 index 00000000..65845f2a --- /dev/null +++ b/src/tui/screens/db/explore/FullTextOverlay.tsx @@ -0,0 +1,128 @@ +/** + * FullTextOverlay - the detail viewport with nothing truncated. + * + * The explore rows align because every cell is sized once per section and + * truncated at its edge, which is what makes a column of types readable and + * also what puts a long Postgres default out of reach. This is the way back to + * it: the same rows, in the same order, wrapped to the terminal instead of cut, + * scrolled with the same keys, and dismissed with Escape. + * + * It shows every row rather than one, because the viewport has no row cursor to + * ask - arrow keys move the window, not a selection - and because a reader who + * cannot see a value usually cannot see the two beside it either. It opens on + * whichever row was at the top of the viewport, so dismissing it lands the + * reader back where they started. + * + * Focus follows the overlay pattern `LogViewerOverlay` established: its own + * `useFocusScope`, its own `useInput` guarded on `isFocused`, and Escape as the + * only way out. It lives here rather than in `components/overlays/` because it + * is built from this screen's layout module, and a shared component reaching + * back into a screen is the wrong direction. + * + * @example + * row.text)} startRow={view.start} height={height} onClose={close} /> + */ +import { useMemo, useState } from 'react'; +import { Box, Text, useInput, useWindowSize } from 'ink'; + +import type { ReactElement } from 'react'; + +import { useFocusScope } from '../../../focus.js'; +import { rowBudget, rowWindow, scrollTarget, wrapText } from './layout.js'; + +/** + * Props for the full-text overlay. + */ +export interface FullTextOverlayProps { + + /** Untruncated text, one entry per row the viewport draws. */ + text: string[]; + + /** Row the viewport had at its top, so the overlay opens where the reader was. */ + startRow: number; + + /** Lines the overlay may draw, its header included. */ + height: number; + + /** Called when the reader dismisses the overlay. */ + onClose: () => void; + +} + +/** The header line, which is also how a reader knows which view they are in. */ +const HEADER_ROWS = 1; + +/** + * Wrapped lines, plus where each row's first line landed. + * + * The index is what lets the overlay open on the row the viewport had at its + * top: a row is one line up there and however many the wrap needs down here, so + * the two offsets are not the same number. + */ +function layoutLines(text: string[], width: number): { lines: string[]; rowStarts: number[] } { + + const lines: string[] = []; + const rowStarts: number[] = []; + + for (const row of text) { + + rowStarts.push(lines.length); + lines.push(...wrapText(row, width)); + + } + + return { lines, rowStarts }; + +} + +/** + * FullTextOverlay component. + */ +export function FullTextOverlay({ text, startRow, height, onClose }: FullTextOverlayProps): ReactElement { + + const { isFocused } = useFocusScope('ExploreFullText'); + + // useWindowSize rather than a prop: the wrap width is the terminal's, and + // this is the one place that has to recompute when the terminal resizes. + const { columns } = useWindowSize(); + + const width = rowBudget(columns); + + const { lines, rowStarts } = useMemo(() => layoutLines(text, width), [text, width]); + + const [offset, setOffset] = useState(() => rowStarts[startRow] ?? 0); + + const budget = Math.max(1, height - HEADER_ROWS); + const view = rowWindow(lines.length, offset, budget); + const maxOffset = lines.length - view.count; + + useInput((input, key) => { + + if (!isFocused) return; + + if (key.escape) { + + onClose(); + + return; + + } + + const target = scrollTarget(input, key, view, maxOffset); + + if (target !== null) setOffset(Math.min(Math.max(target, 0), maxOffset)); + + }); + + return ( + + Full text · [Esc] Close + {view.above > 0 && ↑ {view.above} more} + {lines.slice(view.start, view.start + view.count).map((line, index) => ( + {line} + ))} + {view.below > 0 && ↓ {view.below} more} + + ); + +} diff --git a/src/tui/screens/db/explore/RowPeekOverlay.tsx b/src/tui/screens/db/explore/RowPeekOverlay.tsx new file mode 100644 index 00000000..1871aaf9 --- /dev/null +++ b/src/tui/screens/db/explore/RowPeekOverlay.tsx @@ -0,0 +1,371 @@ +/** + * RowPeekOverlay - the first and last rows of a table, on demand. + * + * The detail screen describes a table's shape and never its contents, so the + * only way to see what is actually in one was to leave for the SQL terminal and + * write the query by hand. This is that query, bound to a key. + * + * Three things about it are deliberate: + * + * - **It reads nothing until asked.** Opening a table's detail must not fetch + * rows, so the query fires when this component mounts, which is when the + * reader pressed `p`. + * - **It is gated on `sql:read`, not `explore`.** Every other explore screen + * reads catalog metadata; this one reads user data. `fetchRowPeek` refuses + * before it queries, and a refusal lands in the error state below rather than + * as an unhandled throw. + * - **It draws with `ResultTable`.** Column widths, chopping a wide table down + * to what the row can hold, truncation and windowing are already solved + * there, and a second table renderer would have to solve them again and + * drift. This component hands over every column the table has and lets the + * grid decide which of them fit. + * + * Exactly one table is live at a time. Two cursors on screen would be a claim + * that Enter lands in both places, so the unfocused set draws none, advertises + * none of its keys, and ignores input; `Tab` moves focus between them. + * + * **Escape is not this component's key while a table is up.** `ResultTable` + * already owns Escape by mode — it cancels a filter, then it leaves sort mode, + * and only in browse mode does it call `onEscape`. Ink delivers every keystroke + * to every registered handler, so a handler here that also closed on Escape + * would close the peek out from under a reader who was only cancelling a + * filter. The focused table gets `onEscape={onClose}` instead, and this + * component claims Escape only while there is no table to hand it to, which is + * the loading and error states. + * + * Focus follows the same pattern `FullTextOverlay` uses: its own + * `useFocusScope`, its own `useInput` guarded on `isFocused`. + * + * @example + * + */ +import { useState } from 'react'; +import { Box, Text, useInput } from 'ink'; +import { attempt } from '@logosdx/utils'; + +import type { ReactElement } from 'react'; +import type { Kysely } from 'kysely'; + +import type { Dialect } from '../../../../core/connection/types.js'; +import type { RowPeek, RowPeekGate, TableDetail } from '../../../../core/explore/index.js'; + +import { fetchRowPeek } from '../../../../core/explore/index.js'; +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'; + +/** + * Props for the row peek overlay. + */ +export interface RowPeekOverlayProps { + + /** Connection the rows are read over. */ + db: Kysely; + + /** Dialect, which decides how the page is limited and quoted. */ + dialect: Dialect; + + /** The table, as the detail screen already fetched it. */ + detail: TableDetail; + + /** Config name, access roles and channel the read is checked against. */ + gate: RowPeekGate; + + /** Lines the overlay may draw, its header included. */ + height: number; + + /** Called when the reader dismisses the overlay. */ + onClose: () => void; + +} + +/** + * The header line, which names the table and the keys the peek answers to. + * + * One line, and truncated rather than wrapped, so the row budget below stays + * arithmetic rather than a guess about how the terminal will re-flow it. + */ +const HEADER_ROWS = 1; + +/** The line naming a set, above its table. */ +const LABEL_ROWS = 1; + +/** + * Rows one `ResultTable` spends on everything that is not a row: its status + * line and the blank under it, the header, the rule, and the count line with + * the blank above it. Counted rather than measured because the page size has to + * be decided before the first query, and `measureElement` can only answer after + * a render. + */ +const TABLE_CHROME_ROWS = 5; + +/** Rows a set costs before it holds anything. */ +const SET_CHROME_ROWS = LABEL_ROWS + TABLE_CHROME_ROWS; + +/** Most rows either set shows, however tall the terminal is. */ +const MAX_SET_ROWS = 10; + +/** + * Rows one set may draw, given how many sets share the viewport. + * + * @example + * setRows(28, 2); // 8 — two labelled tables and a header in 28 lines + */ +export function setRows(height: number, sets: number): number { + + return Math.max(1, Math.floor((height - HEADER_ROWS - sets * SET_CHROME_ROWS) / sets)); + +} + +/** + * How many rows to read per set. + * + * Derived from the terminal rather than fixed at ten, because two sets of ten + * plus their chrome need 33 lines and most terminals are shorter than that: + * reading rows the viewport cannot draw would leave them behind a scroll + * indicator with no key to reach them. Budgeted for two sets, which is the + * worst case — a single-set result has room to spare. + * + * @example + * peekPageSize(48); // 10 — the cap, not the space + * peekPageSize(30); // 4 + */ +export function peekPageSize(height: number): number { + + return Math.min(MAX_SET_ROWS, setRows(height, 2)); + +} + +/** + * What each set is called, given what came back. + * + * `whole` earns "All", because the reader is looking at the table rather than + * an end of it, and saying "first" over a complete table invites the question + * of what was left out. + */ +function headings(peek: RowPeek): { first: string; last: string | null } { + + const by = peek.keyColumns.length > 0 ? ` by ${peek.keyColumns.join(', ')}` : ''; + + if (peek.mode === 'ends') { + + return { + first: `First ${peek.first.length}${by}`, + last: `Last ${peek.last.length}${by}`, + }; + + } + + if (peek.mode === 'head') { + + return { first: `First ${peek.first.length}`, last: null }; + + } + + return { first: `All ${peek.first.length} row${peek.first.length === 1 ? '' : 's'}${by}`, last: null }; + +} + +/** Which of the two sets a key or a cursor belongs to. */ +type PeekSetName = 'first' | 'last'; + +/** + * One labelled set. + * + * `autoSort` is off: `ResultTable` otherwise re-sorts by whichever column looks + * like a date or an id, which would silently replace the primary-key order the + * query was built to guarantee and make "first" and "last" mean nothing. + * + * The cursor is controlled from above rather than left to the table, because + * the row view moves it too: `←`/`→` in there have to move the same cursor the + * arrows move here, or escaping out lands on a row the reader was not reading. + */ +function PeekSet({ label, columns, rows, maxRows, active, cursor, onCursor, onSelect, onTab, onEscape }: { + label: string; + columns: string[]; + rows: Record[]; + maxRows: number; + active: boolean; + cursor: number; + onCursor: (index: number) => void; + onSelect: (row: Record, index: number, list: Record[]) => void; + onTab?: () => void; + onEscape: () => void; +}): ReactElement { + + return ( + + {label} + + + ); + +} + +/** + * RowPeekOverlay component. + */ +export function RowPeekOverlay({ + db, + dialect, + detail, + gate, + height, + onClose, +}: RowPeekOverlayProps): ReactElement { + + const { isFocused } = useFocusScope('ExploreRowPeek'); + + const [peek, setPeek] = useState(null); + const [error, setError] = useState(null); + const [focusedSet, setFocusedSet] = useState('first'); + const [cursors, setCursors] = useState>({ first: 0, last: 0 }); + + // The set whose row is open, and the rows as that table was displaying them + // — filtered and sorted, which is what ←/→ have to walk so the viewer moves + // through what the reader can see rather than through what was fetched. + const [subject, setSubject] = useState<{ set: PeekSetName; rows: Record[] } | null>(null); + + // The page size is fixed at mount rather than tracked: it decides what was + // read, and a resize cannot retroactively change that. The row budget + // below does track the terminal, so a shrink windows what is already here. + const [pageSize] = useState(() => peekPageSize(height)); + + useAsyncEffect(async (isCancelled) => { + + const [result, err] = await attempt(() => fetchRowPeek(db, dialect, detail, gate, pageSize)); + + if (isCancelled()) return; + + if (err || !result) { + + setError(err?.message ?? 'No rows returned'); + + return; + + } + + setPeek(result); + + }, []); + + // Escape only while there is no table to hand it to. Once one is up it owns + // the key by mode — cancel filter, leave sort, then close — and a second + // handler here would fire alongside it and close the peek on a keystroke + // the reader meant for the filter box. + useInput((_input, key) => { + + if (!isFocused || peek !== null) return; + + if (key.escape) onClose(); + + }); + + const qualified = `${detail.schema ? `${detail.schema}.` : ''}${detail.name}`; + const header = Rows · {qualified} · [Esc] Close; + + if (error) { + + return ( + + {header} + Could not read rows + {error} + + ); + + } + + if (!peek) { + + return ( + + {header} + + + ); + + } + + const label = headings(peek); + const sets = label.last === null ? 1 : 2; + const maxRows = setRows(height, sets); + + const setRowsFor = (set: PeekSetName) => (set === 'first' ? peek.first : peek.last); + const setLabelFor = (set: PeekSetName) => (set === 'first' ? label.first : label.last ?? ''); + + const moveCursor = (set: PeekSetName, index: number) => setCursors( + (current) => (current[set] === index ? current : { ...current, [set]: index }), + ); + + const swap = () => setFocusedSet((current) => (current === 'first' ? 'last' : 'first')); + + // `[↵] Open` is not named here. `ResultTable` advertises it on whichever + // table is focused, because it is the table's key, and naming it twice on + // one screen reads as two different things to press. + const keys = [ + '[↑↓] Row', + ...(sets > 1 ? ['[Tab] Set'] : []), + '[Esc] Close', + ].join(' '); + + const setProps = (set: PeekSetName) => ({ + label: setLabelFor(set), + columns: peek.columns, + rows: setRowsFor(set), + maxRows, + active: subject === null && focusedSet === set, + cursor: cursors[set], + onCursor: (index: number) => moveCursor(set, index), + onSelect: (_row: Record, index: number, list: Record[]) => { + + moveCursor(set, index); + setSubject({ set, rows: list }); + + }, + ...(sets > 1 ? { onTab: swap } : {}), + onEscape: onClose, + }); + + return ( + + {/* `display` rather than a swap: unmounting the tables to make room + for the row view would throw away the cursor and the scroll + offset that Escape is supposed to come back to. */} + + Rows · {qualified} · {keys} + + {label.last !== null && } + {peek.mode === 'head' && ( + + No primary key, so there is no last set — rows are in storage order. + + )} + + {subject !== null && ( + moveCursor(subject.set, index)} + onClose={() => setSubject(null)} + /> + )} + + ); + +} diff --git a/src/tui/screens/db/explore/layout.ts b/src/tui/screens/db/explore/layout.ts new file mode 100644 index 00000000..bc390a05 --- /dev/null +++ b/src/tui/screens/db/explore/layout.ts @@ -0,0 +1,278 @@ +/** + * Layout planning for the explore screens: cell widths across, row windows + * down. + * + * Ink's `width` is a flex basis and flex items shrink by default, so a row of + * fixed-width cells silently re-flows the moment a later cell is long: the + * column list squeezed name and type on every row that carried a DEFAULT + * expression and left them full width on every row that did not, so the + * columns wandered down the screen. Cells are therefore sized once per + * section from the content that section actually holds, rendered with + * `flexShrink={0}`, and truncated rather than wrapped. + * + * Same idiom as the Form label gutter: derive from content, cap it, truncate + * past the cap. + * + * Down the page the constraint is the opposite one. Ink has no scroll offset + * and the only way to fake one on a nested tree is a negative margin, which + * fights Yoga. So the detail screen flattens itself to one element per visual + * line and `rowWindow` slices that list to what the terminal can draw. + * + * Truncating across is what makes the columns line up, and it is also what puts + * a long value out of reach, so every row carries its untruncated `text` + * alongside the element that draws it. One builder produces both, because two + * builders would drift. + * + * The keymap and the footer hints live here too. Both are width decisions: the + * footer is one wrapping line and the keys it can afford to name are bounded by + * how many columns the terminal has. + * + * @example + * const names = columns.map((col) => col.name); + * const [nameWidth, typeWidth] = fitWidths( + * [cellWidth(names, IDENTIFIER_CAP), cellWidth(types, DATA_TYPE_CAP)], + * rowBudget(terminalColumns), + * ); + */ +import type { ReactElement } from 'react'; + +/** Columns between two cells in an explore row. */ +export const CELL_GAP = 2; + +/** Columns the `* ` primary-key marker occupies ahead of an identifier. */ +export const MARKER_WIDTH = 2; + +/** + * Widest an identifier cell grows before it truncates. Fits the 30-character + * identifier Postgres and MySQL allow in practice without letting a generated + * 63-character name push every other column off the screen. + */ +export const IDENTIFIER_CAP = 32; + +/** Widest a data-type cell grows. `timestamp with time zone` is exactly 24. */ +export const DATA_TYPE_CAP = 24; + +/** Widest a fixed label cell grows, e.g. the overview's category names. */ +export const LABEL_CAP = 16; + +/** No cell shrinks below this; past it the text carries no information. */ +export const MIN_CELL_COLUMNS = 6; + +/** + * Rows the detail viewport gets once the chrome has taken its share. + * + * Re-exported rather than reimplemented: the detail screen is the same shape + * every list screen has - shell, one titled Panel, a hotkey footer - so it + * spends the same rows on chrome, and one copy of that accounting is enough. + * Import it from here alongside `rowWindow`, which is the only thing it feeds. + */ +export { viewportRows } from '../../../hooks/useViewportRows.js'; + +/** + * Row and viewport arithmetic, re-exported from where the SQL screens can also + * reach it. + * + * These moved to `components/terminal/viewport.js` when the row document viewer + * was promoted out of this directory: the viewer needs the same wrap width and + * the same scroll keys, and a component may not import from a screen. Every + * explore call site still imports them from here, because from an explore + * screen's point of view nothing about them changed. + */ +export { + halfPage, + rowBudget, + rowWindow, + scrollTarget, + wrapText, +} from '../../../components/terminal/viewport.js'; +export type { RowWindow } from '../../../components/terminal/viewport.js'; + +/** + * Truncate to a width, marking the cut so a reader knows the value continues. + * + * Ink's `wrap="truncate"` does this for rendered text; this is for the places + * that need the truncated string itself. + * + * @example + * truncateCell('information_schema', 8); // 'informa…' + */ +export function truncateCell(text: string, max: number): string { + + if (max <= 0) return ''; + + if (text.length <= max) return text; + + return `${text.slice(0, max - 1)}…`; + +} + +/** + * Width a cell wants: the longest entry it has to hold, capped. + * + * Derived per section rather than hardcoded, so a table of short names does + * not pay for the one table that has long ones. Omit the cap for a trailing + * cell, which `fitWidths` bounds by whatever the terminal has left. + * + * @example + * cellWidth(['id', 'created_at'], IDENTIFIER_CAP); // 10 + */ +export function cellWidth(cells: string[], cap: number = Number.POSITIVE_INFINITY): number { + + let widest = 0; + + for (const cell of cells) { + + if (cell.length > widest) widest = cell.length; + + } + + return Math.min(widest, cap); + +} + +/** + * One visual line of a detail view. + * + * `element` is the line as the viewport draws it: cells sized to the section + * and truncated at the terminal's edge. `text` is the same line with nothing + * cut, which is the only copy of a value the reader can still get to once the + * element has clipped it. + * + * @example + * const [row] = columnRows([jobid], rowBudget(100)); + * row.text; // "* jobid bigint NOT NULL DEFAULT nextval('cron.jobid_seq'::regclass)" + */ +export interface DetailRow { + + /** The line with nothing truncated. */ + text: string; + + /** The line as the viewport draws it. */ + element: ReactElement; + +} + +/** + * What to call the full-page keys on this platform. + * + * No Mac keyboard has a key labelled PgUp, so naming one sends a reader looking + * for something that is not there. The keys themselves work everywhere - on a + * Mac fn+↑ and fn+↓ send the same escape sequences - so only the label changes. + * + * Takes the platform rather than reading `process.platform` at the call site so + * both branches are reachable from one machine. + * + * @example + * pageKeyLabel('darwin'); // 'fn ↑↓' + * pageKeyLabel('linux'); // 'PgUp/PgDn' + */ +export function pageKeyLabel(platform: NodeJS.Platform = process.platform): string { + + return platform === 'darwin' ? 'fn ↑↓' : 'PgUp/PgDn'; + +} + +/** + * What is drawn in the viewport's place, if anything. + * + * Three states rather than an `expanded` flag, because the two overlays answer + * to different keys: the full-text view scrolls and the row peek does not, so a + * footer that could only say "an overlay is open" would advertise scroll keys + * during a peek that ignores them. + */ +export type DetailOverlay = 'none' | 'fullText' | 'peek'; + +/** + * What the detail screen's footer says, in order. + * + * The footer is a single `flexWrap` line, so every hint added is a column the + * next one does not have. The Home/End hint was dropped to pay for the two that + * arrived: Home and End stay bound, but on a Mac they are fn+← and fn+→, so the + * hint had the same defect the paging hint did, and jumping to either end is + * the least-reached of the four movements. `[r] Rows` is named that way for the + * same reason — `Peek rows` is five columns the line cannot spare. + * + * @example + * detailFooterHints({ scrolls: true, overlay: 'none', canPeek: true, platform: 'darwin' }); + * // ['[↑↓] Scroll', '[^U/^D] Half', '[fn ↑↓] Page', '[v] Full text', '[r] Rows', '[Esc] Back'] + */ +export function detailFooterHints(options: { + scrolls: boolean; + overlay: DetailOverlay; + canPeek?: boolean; + platform?: NodeJS.Platform; +}): string[] { + + const { scrolls, overlay, canPeek, platform } = options; + + if (overlay === 'fullText') { + + return ['[↑↓] Scroll', '[^U/^D] Half', '[Esc] Close']; + + } + + // The peek draws its own tables and does not scroll, so Escape is the only + // key it owns. + if (overlay === 'peek') { + + return ['[Esc] Close']; + + } + + // A detail that fits has nothing to scroll, and reads exactly as it did + // before any of this. It can still hold a value too wide for the row. + const movement = scrolls + ? ['[↑↓] Scroll', '[^U/^D] Half', `[${pageKeyLabel(platform)}] Page`] + : []; + + return [...movement, '[v] Full text', ...(canPeek ? ['[r] Rows'] : []), '[Esc] Back']; + +} + +/** + * Fit a row's cells into the terminal budget. + * + * Allocates left to right, holding back enough room for every later cell, so a + * narrow terminal loses detail from the trailing cell rather than reflowing the + * row into a wrapped mess. Left to right because the identifier is what a + * reader scans by; the qualifiers after it are the affordable loss. + * + * The floor only ever shrinks a cell, never widens one: a cell that wants three + * columns gets three. + * + * Overloaded per row shape so callers can destructure without every width + * widening to `number | undefined`. + * + * @example + * fitWidths([32, 7, 52], rowBudget(100)); // [32, 7, 52] + * fitWidths([32, 7, 52], rowBudget(50)); // [30, 6, 6] + */ +export function fitWidths(desired: [number, number], budget: number): [number, number]; +export function fitWidths(desired: [number, number, number], budget: number): [number, number, number]; +export function fitWidths(desired: number[], budget: number): number[] { + + const widths: number[] = []; + + let remaining = budget - CELL_GAP * Math.max(0, desired.length - 1); + + for (const [index, want] of desired.entries()) { + + let reserved = 0; + + for (const later of desired.slice(index + 1)) { + + reserved += Math.min(later, MIN_CELL_COLUMNS); + + } + + const room = Math.max(MIN_CELL_COLUMNS, remaining - reserved); + const width = Math.min(want, room); + + widths.push(width); + remaining -= width; + + } + + return widths; + +} diff --git a/src/tui/screens/debug/DebugListScreen.tsx b/src/tui/screens/debug/DebugListScreen.tsx index 8f48d269..049a09ef 100644 --- a/src/tui/screens/debug/DebugListScreen.tsx +++ b/src/tui/screens/debug/DebugListScreen.tsx @@ -483,7 +483,7 @@ export function DebugListScreen({ params }: ScreenProps): ReactElement { noResultsLabel="No matching rows" isFocused={isFocused} numberNav - visibleCount={10} + reserveRows={2} /> diff --git a/src/tui/screens/lock/LockForceScreen.tsx b/src/tui/screens/lock/LockForceScreen.tsx index 83457256..97a66ba2 100644 --- a/src/tui/screens/lock/LockForceScreen.tsx +++ b/src/tui/screens/lock/LockForceScreen.tsx @@ -23,11 +23,11 @@ import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; import { Panel, Spinner, SmartConfirm, useToast } from '../../components/index.js'; -import { useAsyncEffect } from '../../hooks/index.js'; +import { useAbortableTask, useAsyncEffect } from '../../hooks/index.js'; import { createConnection, testConnection } from '../../../core/connection/index.js'; import { getLockManager } from '../../../core/lock/index.js'; import { confirmationPhraseFor } from '../../../core/policy/index.js'; -import { isConfigGuarded } from '../../utils/index.js'; +import { isConfigGuarded, STOPPED_WAITING_MESSAGE } from '../../utils/index.js'; /** * Screen phase state. @@ -53,6 +53,8 @@ export function LockForceScreen({ params: _params }: ScreenProps): ReactElement const [error, setError] = useState(null); const [lockStatus, setLockStatus] = useState(null); + const task = useAbortableTask(); + // Check current lock status useAsyncEffect(async (isCancelled) => { @@ -65,17 +67,24 @@ export function LockForceScreen({ params: _params }: ScreenProps): ReactElement } - // Test connection - const testResult = await testConnection(activeConfig.connection); + // The first pass runs before the app context has resolved a config and + // latches the error above. Clearing it here matters most exactly when + // the connect below hangs: without it the screen sits on "No active + // configuration" instead of the spinner the hatch is attached to. + setPhase('loading'); + setError(null); - if (!testResult.ok) { + const controller = task.start(); - if (!isCancelled()) { + // Test connection + const testResult = await testConnection(activeConfig.connection, { signal: controller.signal }); - setError(`Cannot connect to database: ${testResult.error}`); - setPhase('error'); + if (!task.isCurrent(controller) || isCancelled()) return; - } + if (!testResult.ok) { + + setError(`Cannot connect to database: ${testResult.error}`); + setPhase('error'); return; @@ -87,6 +96,8 @@ export function LockForceScreen({ params: _params }: ScreenProps): ReactElement const conn = await createConnection( activeConfig.connection, activeConfigName ?? undefined, + {}, + controller.signal, ); const db = conn.db as Kysely; const lockManager = getLockManager(); @@ -99,7 +110,9 @@ export function LockForceScreen({ params: _params }: ScreenProps): ReactElement }); - if (isCancelled()) return; + // A driver is free to answer after the abort; that answer must not + // reinstate a screen the user already stopped. + if (!task.isCurrent(controller) || isCancelled()) return; if (err) { @@ -130,6 +143,8 @@ export function LockForceScreen({ params: _params }: ScreenProps): ReactElement if (!activeConfig || !activeConfigName) return; + const controller = task.start(); + setPhase('running'); const [, err] = await attempt(async () => { @@ -137,6 +152,8 @@ export function LockForceScreen({ params: _params }: ScreenProps): ReactElement const conn = await createConnection( activeConfig.connection, activeConfigName ?? undefined, + {}, + controller.signal, ); const db = conn.db as Kysely; const lockManager = getLockManager(); @@ -147,6 +164,8 @@ export function LockForceScreen({ params: _params }: ScreenProps): ReactElement }); + if (!task.isCurrent(controller)) return; + if (err) { setError(err.message); @@ -158,7 +177,18 @@ export function LockForceScreen({ params: _params }: ScreenProps): ReactElement setPhase('done'); - }, [activeConfig, activeConfigName]); + }, [activeConfig, activeConfigName, task]); + + // Escape while a connect is in flight stops it rather than leaving it + // running behind a spinner nobody can dismiss. + const handleCancelBusy = useCallback(() => { + + if (!task.cancel()) return; + + setError(STOPPED_WAITING_MESSAGE); + setPhase('error'); + + }, [task]); // Handle cancel const handleCancel = useCallback(() => { @@ -180,6 +210,14 @@ export function LockForceScreen({ params: _params }: ScreenProps): ReactElement if (!isFocused) return; + if ((phase === 'loading' || phase === 'running') && key.escape) { + + handleCancelBusy(); + + return; + + } + if (phase === 'done' || phase === 'error' || phase === 'no-lock') { if (key.return || key.escape) { @@ -222,9 +260,15 @@ export function LockForceScreen({ params: _params }: ScreenProps): ReactElement if (phase === 'loading') { return ( - - - + + + + + + + [Esc] Cancel + + ); } @@ -298,9 +342,15 @@ export function LockForceScreen({ params: _params }: ScreenProps): ReactElement if (phase === 'running') { return ( - - - + + + + + + + [Esc] Cancel + + ); } diff --git a/src/tui/screens/run/RunDirScreen.tsx b/src/tui/screens/run/RunDirScreen.tsx index fd8b6377..ff4ba830 100644 --- a/src/tui/screens/run/RunDirScreen.tsx +++ b/src/tui/screens/run/RunDirScreen.tsx @@ -20,7 +20,7 @@ import type { ScreenProps } from '../../types.js'; import { useRouter } from '../../router.js'; import { useSettings, useGlobalModes, useAppContext } from '../../app-context.js'; import { Panel, Spinner, Confirm, SelectList, FilePicker, KeyHandler, useToast } from '../../components/index.js'; -import { useRunProgress, useAsyncEffect } from '../../hooks/index.js'; +import { useRunProgress, useAsyncEffect, modeBannerRows } from '../../hooks/index.js'; import { discoverFiles, runFiles, checkFilesStatus } from '../../../core/runner/index.js'; import type { FilesStatusResult } from '../../../core/runner/index.js'; import { getErrorMessage, resolveScreenIdentity, buildRunContext, withScreenConnection } from '../../utils/index.js'; @@ -502,16 +502,14 @@ export function RunDirScreen({ params }: ScreenProps): ReactElement { Select a directory to execute all SQL files within - - - + ) : ( <> @@ -608,7 +606,7 @@ export function RunDirScreen({ params }: ScreenProps): ReactElement { selected={relativeFiles} onSelect={handleFileSelect} onCancel={() => setPhase('picker')} - visibleCount={10} + reserveRows={modeBannerRows(globalModes)} /> ); diff --git a/src/tui/screens/run/RunExecScreen.tsx b/src/tui/screens/run/RunExecScreen.tsx index 7989005c..73942359 100644 --- a/src/tui/screens/run/RunExecScreen.tsx +++ b/src/tui/screens/run/RunExecScreen.tsx @@ -26,7 +26,7 @@ import type { ScreenProps } from '../../types.js'; import { useRouter } from '../../router.js'; import { useSettings, useGlobalModes, useAppContext } from '../../app-context.js'; import { Panel, Spinner, SelectList, type SelectListItem, Confirm, KeyHandler, useToast } from '../../components/index.js'; -import { useRunProgress, useAsyncEffect } from '../../hooks/index.js'; +import { useRunProgress, useAsyncEffect, modeBannerRows } from '../../hooks/index.js'; import { discoverFiles, runFiles } from '../../../core/runner/index.js'; import { getErrorMessage, resolveScreenIdentity, buildRunContext, withScreenConnection } from '../../utils/index.js'; import { attempt } from '@logosdx/utils'; @@ -266,17 +266,15 @@ export function RunExecScreen({ params: _params }: ScreenProps): ReactElement { Space to toggle, Enter to confirm ({selectedFiles.size} selected) - - - + diff --git a/src/tui/screens/run/RunFileScreen.tsx b/src/tui/screens/run/RunFileScreen.tsx index a48ef6b3..4ac9b24f 100644 --- a/src/tui/screens/run/RunFileScreen.tsx +++ b/src/tui/screens/run/RunFileScreen.tsx @@ -19,7 +19,7 @@ import type { ScreenProps } from '../../types.js'; import { useRouter } from '../../router.js'; import { useSettings, useGlobalModes, useAppContext } from '../../app-context.js'; import { Panel, Spinner, Confirm, SearchableList, KeyHandler, useToast } from '../../components/index.js'; -import { useRunProgress, useAsyncEffect } from '../../hooks/index.js'; +import { useRunProgress, useAsyncEffect, modeBannerRows } from '../../hooks/index.js'; import { discoverFiles, runFile, checkFilesStatus } from '../../../core/runner/index.js'; import type { FilesStatusResult } from '../../../core/runner/index.js'; import { getErrorMessage, resolveScreenIdentity, buildRunContext, withScreenConnection } from '../../utils/index.js'; @@ -403,17 +403,15 @@ export function RunFileScreen({ params }: ScreenProps): ReactElement { Select a SQL file to execute - - - + ) : ( <> diff --git a/src/tui/screens/run/RunInspectScreen.tsx b/src/tui/screens/run/RunInspectScreen.tsx index 07da9820..36eef206 100644 --- a/src/tui/screens/run/RunInspectScreen.tsx +++ b/src/tui/screens/run/RunInspectScreen.tsx @@ -596,17 +596,15 @@ export function RunInspectScreen({ params }: ScreenProps): ReactElement { Select a template file to inspect - - - + ) : ( <> diff --git a/src/tui/screens/settings/SettingsBuildScreen.tsx b/src/tui/screens/settings/SettingsBuildScreen.tsx index 7e3b55ff..4b04d034 100644 --- a/src/tui/screens/settings/SettingsBuildScreen.tsx +++ b/src/tui/screens/settings/SettingsBuildScreen.tsx @@ -85,14 +85,16 @@ export function SettingsBuildScreen({ params: _params }: ScreenProps): ReactElem () => [ { key: 'include', - label: 'Include Paths (comma-separated)', + label: 'Include Paths', + hint: '(comma-separated)', type: 'text', defaultValue: formatPathList(build.include), placeholder: 'tables, views, functions', }, { key: 'exclude', - label: 'Exclude Paths (comma-separated)', + label: 'Exclude Paths', + hint: '(comma-separated)', type: 'text', defaultValue: formatPathList(build.exclude), placeholder: 'archive, experiments', diff --git a/src/tui/screens/settings/SettingsListScreen.tsx b/src/tui/screens/settings/SettingsListScreen.tsx index b57efa50..7089cd93 100644 --- a/src/tui/screens/settings/SettingsListScreen.tsx +++ b/src/tui/screens/settings/SettingsListScreen.tsx @@ -257,7 +257,6 @@ export function SettingsListScreen({ params: _params }: ScreenProps): ReactEleme onSelect={handleSelect} onHighlight={handleHighlight} isFocused={isFocused} - visibleCount={8} numberNav /> diff --git a/src/tui/screens/settings/SettingsRuleEditScreen.tsx b/src/tui/screens/settings/SettingsRuleEditScreen.tsx index 93a3194a..bf577285 100644 --- a/src/tui/screens/settings/SettingsRuleEditScreen.tsx +++ b/src/tui/screens/settings/SettingsRuleEditScreen.tsx @@ -152,14 +152,16 @@ export function SettingsRuleEditScreen({ params }: ScreenProps): ReactElement { }, { key: 'include', - label: 'Include Paths (comma-separated)', + label: 'Include Paths', + hint: '(comma-separated)', type: 'text', defaultValue: formatPathList(existingRule?.include), placeholder: 'sql/seeds, sql/fixtures', }, { key: 'exclude', - label: 'Exclude Paths (comma-separated)', + label: 'Exclude Paths', + hint: '(comma-separated)', type: 'text', defaultValue: formatPathList(existingRule?.exclude), placeholder: 'sql/dangerous, sql/archive', diff --git a/src/tui/screens/settings/SettingsRulesListScreen.tsx b/src/tui/screens/settings/SettingsRulesListScreen.tsx index 9ed64b9c..ad4b844c 100644 --- a/src/tui/screens/settings/SettingsRulesListScreen.tsx +++ b/src/tui/screens/settings/SettingsRulesListScreen.tsx @@ -280,7 +280,7 @@ export function SettingsRulesListScreen({ params: _params }: ScreenProps): React onSelect={handleSelect} onHighlight={handleHighlight} isFocused={isFocused} - visibleCount={8} + reserveRows={3} /> )} diff --git a/src/tui/screens/settings/SettingsStageEditScreen.tsx b/src/tui/screens/settings/SettingsStageEditScreen.tsx index 6c6e7bf9..b1e5a719 100644 --- a/src/tui/screens/settings/SettingsStageEditScreen.tsx +++ b/src/tui/screens/settings/SettingsStageEditScreen.tsx @@ -110,7 +110,8 @@ export function SettingsStageEditScreen({ params }: ScreenProps): ReactElement { }, { key: 'locked', - label: 'Locked (prevent config deletion)', + label: 'Locked', + hint: '(prevents config deletion)', type: 'checkbox', defaultValue: existingStage?.locked ?? false, }, @@ -164,7 +165,8 @@ export function SettingsStageEditScreen({ params }: ScreenProps): ReactElement { }, { key: 'protected', - label: 'Default: Protected (enforce)', + label: 'Default: Protected', + hint: '(enforce)', type: 'checkbox', defaultValue: defaults.protected ?? false, }, diff --git a/src/tui/screens/settings/SettingsStagesListScreen.tsx b/src/tui/screens/settings/SettingsStagesListScreen.tsx index b8ca1c1f..1fa78a71 100644 --- a/src/tui/screens/settings/SettingsStagesListScreen.tsx +++ b/src/tui/screens/settings/SettingsStagesListScreen.tsx @@ -271,7 +271,7 @@ export function SettingsStagesListScreen({ params: _params }: ScreenProps): Reac onSelect={handleSelect} onHighlight={handleHighlight} isFocused={isFocused} - visibleCount={8} + reserveRows={3} /> )} diff --git a/src/tui/screens/settings/SettingsStrictScreen.tsx b/src/tui/screens/settings/SettingsStrictScreen.tsx index 08189b1d..5d22770a 100644 --- a/src/tui/screens/settings/SettingsStrictScreen.tsx +++ b/src/tui/screens/settings/SettingsStrictScreen.tsx @@ -78,7 +78,8 @@ export function SettingsStrictScreen({ params: _params }: ScreenProps): ReactEle }, { key: 'stages', - label: 'Required Stages (comma-separated)', + label: 'Required Stages', + hint: '(comma-separated)', type: 'text', defaultValue: formatStageList(strict.stages), placeholder: 'dev, staging, prod', diff --git a/src/tui/screens/vault/VaultScreen.tsx b/src/tui/screens/vault/VaultScreen.tsx index e00d1bcd..4e50534d 100644 --- a/src/tui/screens/vault/VaultScreen.tsx +++ b/src/tui/screens/vault/VaultScreen.tsx @@ -506,6 +506,7 @@ export function VaultScreen({ params: _params }: ScreenProps): ReactElement { items={listItems} onSelect={(item) => handleEdit(item.value)} isFocused={isFocused} + reserveRows={2} showDescriptionBelow /> )} diff --git a/src/tui/screens/vault/VaultSetScreen.tsx b/src/tui/screens/vault/VaultSetScreen.tsx index 720d4d55..709c2d40 100644 --- a/src/tui/screens/vault/VaultSetScreen.tsx +++ b/src/tui/screens/vault/VaultSetScreen.tsx @@ -6,7 +6,6 @@ */ import { useState, useCallback } from 'react'; import { Box, Text, useInput } from 'ink'; -import { TextInput } from '@inkjs/ui'; import { attempt } from '@logosdx/utils'; import type { Kysely } from 'kysely'; @@ -17,7 +16,7 @@ import type { NoormDatabase } from '../../../core/shared/index.js'; import { useRouter } from '../../router.js'; import { useFocusScope } from '../../focus.js'; import { useAppContext } from '../../app-context.js'; -import { Panel, Spinner, useToast } from '../../components/index.js'; +import { Panel, Spinner, useToast, TextInput } from '../../components/index.js'; import { useVaultConnection } from '../../hooks/index.js'; import { loadPrivateKey } from '../../../core/identity/storage.js'; import { getVaultKey, setVaultSecret } from '../../../core/vault/index.js'; diff --git a/src/tui/types.ts b/src/tui/types.ts index 9f003e0f..db0d3768 100644 --- a/src/tui/types.ts +++ b/src/tui/types.ts @@ -238,8 +238,23 @@ export interface RouterState { /** Navigation history stack */ history: HistoryEntry[]; + + /** + * How the current route was reached. + * + * A screen cannot tell a fresh visit from a return trip on its own, because + * it is unmounted and rebuilt either way. This is what separates them, and + * it is why a list restores its cursor after `back()` but opens at the top + * when you walk into it. + */ + arrivedBy: NavigationKind; } +/** + * How the router landed on the current route. + */ +export type NavigationKind = 'initial' | 'push' | 'pop' | 'replace'; + /** * Router context value exposed to components. */ diff --git a/src/tui/utils/connection.ts b/src/tui/utils/connection.ts index 4267c4a1..f679af4b 100644 --- a/src/tui/utils/connection.ts +++ b/src/tui/utils/connection.ts @@ -19,6 +19,16 @@ import type { ConnectionConfig, ConnectionResult } from '../../core/connection/t import type { NoormDatabase } from '../../core/shared/index.js'; import { createConnection, testConnection } from '../../core/connection/index.js'; +/** + * What the user is told after cancelling a connect. + * + * "Stopped waiting" rather than "cancelled" because that is all that happened: + * the driver may still be holding a half-open socket, and nothing was asked of + * the server. Cleanup runs regardless — this is about the wording, not the + * behaviour. + */ +export const STOPPED_WAITING_MESSAGE = 'Stopped waiting for the database. Nothing was saved.'; + /** * Execute a callback with a managed database connection. * diff --git a/src/tui/utils/index.ts b/src/tui/utils/index.ts index 4cc8cae6..0dd2b9f7 100644 --- a/src/tui/utils/index.ts +++ b/src/tui/utils/index.ts @@ -7,7 +7,7 @@ export { resolveChangesDir, resolveSqlDir } from './paths.js'; export { resolveScreenIdentity } from './identity.js'; export { createChangeManager, type CreateChangeManagerOptions } from './change-context.js'; export { buildRunContext, type BuildRunContextOptions } from './run-context.js'; -export { withScreenConnection } from './connection.js'; +export { withScreenConnection, STOPPED_WAITING_MESSAGE } from './connection.js'; export { loadChangesWithStatus, buildPendingChangeList, diff --git a/tests/cli/app-context.test.tsx b/tests/cli/app-context.test.tsx index c84f0232..c8ae34d3 100644 --- a/tests/cli/app-context.test.tsx +++ b/tests/cli/app-context.test.tsx @@ -251,8 +251,11 @@ describe('cli: app-context', () => { }); - const { lastFrame, unmount } = render(); - const output = lastFrame() ?? ''; + // Ink 7 paints its error overview and then writes a cleared frame + // as it unmounts, so the message is in `frames` but never in + // `lastFrame()`. Search every frame. + const { frames, unmount } = render(); + const output = frames.join(''); // Error may appear in rendered output or in console.error const hasErrorInOutput = output.includes('useAppContext must be used within an AppContextProvider'); diff --git a/tests/cli/app.test.tsx b/tests/cli/app.test.tsx index 7b4d216d..61a2757f 100644 --- a/tests/cli/app.test.tsx +++ b/tests/cli/app.test.tsx @@ -6,9 +6,14 @@ import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; import { render } from 'ink-testing-library'; import React from 'react'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; import { App } from '../../src/tui/app.js'; import { resetLifecycleManager } from '../../src/core/lifecycle/manager.js'; +import { resetSettingsManager } from '../../src/core/settings/index.js'; +import { resetStateManager } from '../../src/core/state/index.js'; +import { MOUSE_ENABLE, MOUSE_DISABLE } from '../../src/tui/mouse.js'; import type { Route } from '../../src/tui/types.js'; // ANSI escape sequences @@ -17,6 +22,18 @@ const KEYS = { CTRL_C: '\x03', }; +async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise { + + const deadline = Date.now() + timeoutMs; + + while (!predicate() && Date.now() < deadline) { + + await new Promise((resolve) => setTimeout(resolve, 10)); + + } + +} + describe('cli: app', () => { // Reset lifecycle manager between tests to prevent state conflicts @@ -127,6 +144,82 @@ describe('cli: app', () => { }); + /** + * The help screen as it renders for a project with the given settings + * file, or with none written at all. + * + * Reads the help out of `frames` rather than `lastFrame()`: the mouse + * transport writes its escape sequences through the same stream, so the + * last write is often a sequence rather than a screen. + */ + async function helpScreenFor(yaml: string | null): Promise { + + const root = mkdtempSync(join(process.cwd(), 'tmp', 'noorm-mouse-help-')); + + try { + + if (yaml !== null) { + + mkdirSync(join(root, '.noorm'), { recursive: true }); + writeFileSync(join(root, '.noorm', 'settings.yml'), yaml); + + } + + resetSettingsManager(); + resetStateManager(); + + const { stdin, frames, unmount } = render(); + + await waitFor(() => frames.some((f) => f.includes('Home'))); + + // The line reports the transport's state, so it is only + // meaningful once the settings load has settled it. + await new Promise((resolve) => setTimeout(resolve, 300)); + + stdin.write('?'); + + await waitFor(() => frames.some((f) => f.includes('go back / cancel'))); + + unmount(); + + return frames.filter((f) => f.includes('go back / cancel')).at(-1) ?? ''; + + } + finally { + + resetSettingsManager(); + resetStateManager(); + rmSync(root, { recursive: true, force: true }); + + } + + } + + it('should tell the help screen reader how to turn the mouse off', async () => { + + // Discoverability now cuts the other way. With the mouse on by + // default, the user who has to find the flag is the one who dislikes + // it, and their symptom is "text selection stopped working" — which + // points at their terminal, not at noorm. `?` is where that gets + // answered. + const help = await helpScreenFor(null); + + expect(help).toContain('Mouse on.'); + expect(help).toContain('ui.mouse: false'); + expect(help).toContain('restores text selection'); + + }); + + it('should tell the help screen reader how to turn the mouse back on', async () => { + + const help = await helpScreenFor('ui:\n mouse: false\n'); + + expect(help).toContain('Mouse off.'); + expect(help).toContain('ui.mouse: true'); + expect(help).toContain('enables clicks'); + + }); + it('should exit on Ctrl+C', async () => { const { stdin, unmount } = render(); @@ -214,6 +307,115 @@ describe('cli: app', () => { }); + it('should turn the mouse on when settings.yml has no ui section at all', async () => { + + // The default. `ui.mouse` absent means on, so a project that never + // writes the section gets the mouse. + const root = mkdtempSync(join(process.cwd(), 'tmp', 'noorm-mouse-app-')); + + try { + + resetSettingsManager(); + resetStateManager(); + + const { frames, unmount } = render(); + + await waitFor(() => frames.includes(MOUSE_ENABLE)); + + expect(frames).toContain(MOUSE_ENABLE); + + unmount(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(frames).toContain(MOUSE_DISABLE); + + } + finally { + + resetSettingsManager(); + resetStateManager(); + rmSync(root, { recursive: true, force: true }); + + } + + }); + + it('should leave the mouse off when settings.yml writes ui.mouse: false', async () => { + + // The escape hatch, and the reason the flag still exists. Written + // false has to survive a default that says otherwise. + const root = mkdtempSync(join(process.cwd(), 'tmp', 'noorm-mouse-app-')); + + try { + + mkdirSync(join(root, '.noorm'), { recursive: true }); + writeFileSync(join(root, '.noorm', 'settings.yml'), 'ui:\n mouse: false\n'); + + resetSettingsManager(); + resetStateManager(); + + const { frames, unmount } = render(); + + await waitFor(() => frames.some((f) => f.includes('Home'))); + + // Past the point where the settings load resolves, which is the + // only moment the flag could have flipped on. + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(frames).not.toContain(MOUSE_ENABLE); + + unmount(); + + expect(frames).not.toContain(MOUSE_DISABLE); + + } + finally { + + resetSettingsManager(); + resetStateManager(); + rmSync(root, { recursive: true, force: true }); + + } + + }); + + it('should turn the mouse on when settings.yml asks for it explicitly', async () => { + + const root = mkdtempSync(join(process.cwd(), 'tmp', 'noorm-mouse-app-')); + + try { + + mkdirSync(join(root, '.noorm'), { recursive: true }); + writeFileSync(join(root, '.noorm', 'settings.yml'), 'ui:\n mouse: true\n'); + + resetSettingsManager(); + resetStateManager(); + + const { frames, unmount } = render(); + + // The flag is not known at render() time — the managers load + // asynchronously — so this also pins that the enable sequence + // waits for the setting rather than for the first frame. + await waitFor(() => frames.includes(MOUSE_ENABLE)); + + expect(frames).toContain(MOUSE_ENABLE); + + unmount(); + await new Promise((resolve) => setTimeout(resolve, 100)); + + expect(frames).toContain(MOUSE_DISABLE); + + } + finally { + + resetSettingsManager(); + resetStateManager(); + rmSync(root, { recursive: true, force: true }); + + } + + }); + }); describe('screen rendering', () => { diff --git a/tests/cli/components/form-navigation.test.tsx b/tests/cli/components/form-navigation.test.tsx index 93b09732..7e9b458a 100644 --- a/tests/cli/components/form-navigation.test.tsx +++ b/tests/cli/components/form-navigation.test.tsx @@ -1,7 +1,17 @@ /** * Form navigation tests. * - * Tests Tab and Shift+Tab keyboard navigation between form fields. + * Encodes the browse/edit navigation contract: + * + * - Browse mode is the default. Arrows move BETWEEN fields on every field type, + * including select. Before this contract, the Form deliberately handed arrows + * to the active select so the user could only leave it with Tab - the bug this + * suite exists to keep dead. + * - Enter is the mode switch, not the submit key. Submitting is a navigable + * action row after the last field, so "down then enter" is the only model a + * user has to learn. + * - Esc in edit mode restores the value the field had when edit mode opened, so + * a mistyped edit is always recoverable without cancelling the whole form. */ import { describe, it, expect } from 'bun:test'; import { render } from 'ink-testing-library'; @@ -14,13 +24,31 @@ import type { FormField } from '../../../src/tui/components/forms/index.js'; const KEYS = { TAB: '\t', SHIFT_TAB: '\x1b[Z', - DOWN: '\x1b[B', - UP: '\x1b[A', + DOWN: '\x1B[B', + UP: '\x1B[A', + LEFT: '\x1B[D', + RIGHT: '\x1B[C', + ENTER: '\r', + ESC: '\x1B', + SPACE: ' ', }; /** - * Wrapper with focus provider for components that need focus. + * Poll until the predicate holds instead of sleeping a guessed duration. + * Fixed sleeps are the known flake source in this suite. */ +async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise { + + const deadline = Date.now() + timeoutMs; + + while (!predicate() && Date.now() < deadline) { + + await new Promise((r) => setTimeout(r, 5)); + + } + +} + function TestWrapper({ children }: { children: React.ReactNode }) { return {children}; @@ -28,22 +56,18 @@ function TestWrapper({ children }: { children: React.ReactNode }) { } /** - * Helper to find which field has the active indicator. - * Returns the label of the field with '›' before it. + * The marker `›` sits at the start of the active row, so the active label is + * whichever label shares a line with it. */ function getActiveField(frame: string, fieldLabels: string[]): string | null { - const lines = frame.split('\n'); + for (const line of frame.split('\n')) { - for (const label of fieldLabels) { + if (!line.includes('›')) continue; - for (const line of lines) { + for (const label of fieldLabels) { - if (line.includes('›') && line.includes(label)) { - - return label; - - } + if (line.includes(label)) return label; } @@ -62,113 +86,569 @@ describe('cli: components/form-navigation', () => { ]; const labels = ['Name', 'Host', 'Port']; - it('should start with first field active', { retry: 2 }, async () => { + describe('browse mode', () => { - const { lastFrame, unmount } = render( - - {}} /> - , - ); + it('should start on the first field in browse mode', async () => { - await new Promise((r) => setTimeout(r, 150)); + const { lastFrame, unmount } = render( + + {}} /> + , + ); - expect(getActiveField(lastFrame() ?? '', labels)).toBe('Name'); - unmount(); + await waitFor(() => getActiveField(lastFrame() ?? '', labels) === 'Name'); - }); + expect(getActiveField(lastFrame() ?? '', labels)).toBe('Name'); + expect(lastFrame()).toContain('↵ edit'); - it('should move to next field on Tab', async () => { + unmount(); - const { lastFrame, stdin, unmount } = render( - - {}} /> - , - ); + }); - await new Promise((r) => setTimeout(r, 150)); + it('should move to the next field on Down', async () => { - stdin.write(KEYS.TAB); - await new Promise((r) => setTimeout(r, 150)); + const { lastFrame, stdin, unmount } = render( + + {}} /> + , + ); - expect(getActiveField(lastFrame() ?? '', labels)).toBe('Host'); - unmount(); + await waitFor(() => getActiveField(lastFrame() ?? '', labels) === 'Name'); + stdin.write(KEYS.DOWN); + await waitFor(() => getActiveField(lastFrame() ?? '', labels) === 'Host'); - }); + expect(getActiveField(lastFrame() ?? '', labels)).toBe('Host'); + + unmount(); + + }); + + it('should move to the previous field on Up', async () => { + + const { lastFrame, stdin, unmount } = render( + + {}} /> + , + ); + + await waitFor(() => getActiveField(lastFrame() ?? '', labels) === 'Name'); + stdin.write(KEYS.DOWN); + await waitFor(() => getActiveField(lastFrame() ?? '', labels) === 'Host'); + stdin.write(KEYS.UP); + await waitFor(() => getActiveField(lastFrame() ?? '', labels) === 'Name'); + + expect(getActiveField(lastFrame() ?? '', labels)).toBe('Name'); + + unmount(); + + }); + + it('should move BETWEEN fields on Down when the active field is a select', async () => { + + // The reported bug: Down used to be handed to the select so it moved + // between options and the user had to reach for Tab to leave the field. + const selectFields: FormField[] = [ + { + key: 'role', + label: 'Role', + type: 'select', + options: [ + { label: 'Admin', value: 'admin' }, + { label: 'Operator', value: 'operator' }, + ], + defaultValue: 'admin', + }, + { key: 'host', label: 'Host', type: 'text' }, + ]; + const selectLabels = ['Role', 'Host']; + + const { lastFrame, stdin, unmount } = render( + + {}} /> + , + ); + + await waitFor(() => getActiveField(lastFrame() ?? '', selectLabels) === 'Role'); + stdin.write(KEYS.DOWN); + await waitFor(() => getActiveField(lastFrame() ?? '', selectLabels) === 'Host'); + + expect(getActiveField(lastFrame() ?? '', selectLabels)).toBe('Host'); + + unmount(); + + }); + + it('should keep Tab and Shift+Tab moving between fields', async () => { + + const { lastFrame, stdin, unmount } = render( + + {}} /> + , + ); + + await waitFor(() => getActiveField(lastFrame() ?? '', labels) === 'Name'); + + stdin.write(KEYS.TAB); + await waitFor(() => getActiveField(lastFrame() ?? '', labels) === 'Host'); + expect(getActiveField(lastFrame() ?? '', labels)).toBe('Host'); + + stdin.write(KEYS.TAB); + await waitFor(() => getActiveField(lastFrame() ?? '', labels) === 'Port'); + expect(getActiveField(lastFrame() ?? '', labels)).toBe('Port'); + + stdin.write(KEYS.SHIFT_TAB); + await waitFor(() => getActiveField(lastFrame() ?? '', labels) === 'Host'); + expect(getActiveField(lastFrame() ?? '', labels)).toBe('Host'); + + unmount(); + + }); - it('should move to previous field on Shift+Tab', async () => { + it('should cancel the form on Esc', async () => { - const { lastFrame, stdin, unmount } = render( - - {}} /> - , - ); + let cancelled = false; - await new Promise((r) => setTimeout(r, 150)); + const { lastFrame, stdin, unmount } = render( + + {}} + onCancel={() => { - // Move to second field - stdin.write(KEYS.TAB); - await new Promise((r) => setTimeout(r, 150)); - expect(getActiveField(lastFrame() ?? '', labels)).toBe('Host'); + cancelled = true; - // Shift+Tab back to first - stdin.write(KEYS.SHIFT_TAB); - await new Promise((r) => setTimeout(r, 150)); + }} + /> + , + ); - expect(getActiveField(lastFrame() ?? '', labels)).toBe('Name'); - unmount(); + await waitFor(() => getActiveField(lastFrame() ?? '', labels) === 'Name'); + stdin.write(KEYS.ESC); + await waitFor(() => cancelled); + + expect(cancelled).toBe(true); + + unmount(); + + }); + + it('should NOT submit when Enter is pressed on a field', async () => { + + // Enter is the mode switch now. Submitting from a field would make the + // action row unreachable-by-accident and resurrect the old model. + let submitted = false; + + const { lastFrame, stdin, unmount } = render( + + { + + submitted = true; + + }} + /> + , + ); + + await waitFor(() => getActiveField(lastFrame() ?? '', labels) === 'Name'); + stdin.write(KEYS.ENTER); + await waitFor(() => Boolean(lastFrame()?.includes('↵ commit'))); + + expect(lastFrame()).toContain('↵ commit'); + expect(submitted).toBe(false); + + unmount(); + + }); }); - it('should navigate forward through all fields with Tab', async () => { + describe('action row', () => { + + it('should land on the submit button after the last field', async () => { + + const { lastFrame, stdin, unmount } = render( + + {}} submitLabel="Save" onCancel={() => {}} /> + , + ); + + await waitFor(() => getActiveField(lastFrame() ?? '', labels) === 'Name'); + + stdin.write(KEYS.DOWN); + await waitFor(() => getActiveField(lastFrame() ?? '', labels) === 'Host'); + stdin.write(KEYS.DOWN); + await waitFor(() => getActiveField(lastFrame() ?? '', labels) === 'Port'); + stdin.write(KEYS.DOWN); + await waitFor(() => Boolean(lastFrame()?.includes('❯ [ Save ]'))); + + expect(lastFrame()).toContain('❯ [ Save ]'); + expect(getActiveField(lastFrame() ?? '', labels)).toBeNull(); + + unmount(); + + }); + + it('should submit the collected values on Enter over the submit button', async () => { + + let received: Record | null = null; + + const { lastFrame, stdin, unmount } = render( + + { + + received = values; + + }} + submitLabel="Save" + /> + , + ); + + await waitFor(() => getActiveField(lastFrame() ?? '', ['Host']) === 'Host'); + stdin.write(KEYS.DOWN); + await waitFor(() => Boolean(lastFrame()?.includes('❯ [ Save ]'))); + stdin.write(KEYS.ENTER); + await waitFor(() => received !== null); + + expect(received).toEqual({ host: 'localhost' }); + + unmount(); + + }); + + it('should cancel on Enter over the cancel button', async () => { + + let cancelled = false; + let submitted = false; + + const { lastFrame, stdin, unmount } = render( + + { + + submitted = true; + + }} + onCancel={() => { + + cancelled = true; + + }} + /> + , + ); + + await waitFor(() => getActiveField(lastFrame() ?? '', ['Host']) === 'Host'); + stdin.write(KEYS.DOWN); + await waitFor(() => Boolean(lastFrame()?.includes('❯ [ Submit ]'))); + stdin.write(KEYS.RIGHT); + await waitFor(() => Boolean(lastFrame()?.includes('❯ [ Cancel ]'))); + stdin.write(KEYS.ENTER); + await waitFor(() => cancelled); - const { lastFrame, stdin, unmount } = render( - - {}} /> - , - ); + expect(cancelled).toBe(true); + expect(submitted).toBe(false); - await new Promise((r) => setTimeout(r, 150)); - expect(getActiveField(lastFrame() ?? '', labels)).toBe('Name'); + unmount(); - stdin.write(KEYS.TAB); - await new Promise((r) => setTimeout(r, 150)); - expect(getActiveField(lastFrame() ?? '', labels)).toBe('Host'); + }); - stdin.write(KEYS.TAB); - await new Promise((r) => setTimeout(r, 150)); - expect(getActiveField(lastFrame() ?? '', labels)).toBe('Port'); + it('should move between the action buttons with Left and Right', async () => { - unmount(); + const { lastFrame, stdin, unmount } = render( + + {}} + onCancel={() => {}} + /> + , + ); + + await waitFor(() => getActiveField(lastFrame() ?? '', ['Host']) === 'Host'); + stdin.write(KEYS.DOWN); + await waitFor(() => Boolean(lastFrame()?.includes('❯ [ Submit ]'))); + + stdin.write(KEYS.RIGHT); + await waitFor(() => Boolean(lastFrame()?.includes('❯ [ Cancel ]'))); + expect(lastFrame()).toContain('❯ [ Cancel ]'); + + stdin.write(KEYS.LEFT); + await waitFor(() => Boolean(lastFrame()?.includes('❯ [ Submit ]'))); + expect(lastFrame()).toContain('❯ [ Submit ]'); + + unmount(); + + }); + + it('should move focus to the first invalid field when validation fails', async () => { + + let submitted = false; + + const { lastFrame, stdin, unmount } = render( + + { + + submitted = true; + + }} + /> + , + ); + + await waitFor(() => getActiveField(lastFrame() ?? '', ['Host', 'Database']) === 'Host'); + stdin.write(KEYS.DOWN); + await waitFor(() => getActiveField(lastFrame() ?? '', ['Host', 'Database']) === 'Database'); + stdin.write(KEYS.DOWN); + await waitFor(() => Boolean(lastFrame()?.includes('❯ [ Submit ]'))); + stdin.write(KEYS.ENTER); + await waitFor(() => Boolean(lastFrame()?.includes('Required'))); + + expect(submitted).toBe(false); + expect(lastFrame()).toContain('Required'); + expect(getActiveField(lastFrame() ?? '', ['Host', 'Database'])).toBe('Database'); + + unmount(); + + }); }); - it('should navigate backward through fields with Shift+Tab', async () => { + describe('edit mode', () => { + + it('should type into a text field only after Enter opens edit mode', async () => { + + const { lastFrame, stdin, unmount } = render( + + {}} /> + , + ); + + await waitFor(() => getActiveField(lastFrame() ?? '', ['Host']) === 'Host'); + + // Browse mode swallows the keystroke - the field does not own input yet. + stdin.write('xyz'); + await waitFor(() => false, 60); + expect(lastFrame()).not.toContain('xyz'); + + stdin.write(KEYS.ENTER); + await waitFor(() => Boolean(lastFrame()?.includes('↵ commit'))); + + stdin.write('abc'); + await waitFor(() => Boolean(lastFrame()?.includes('abc'))); + + expect(lastFrame()).toContain('abc'); + + unmount(); + + }); + + it('should commit the edit on Enter and return to browse mode', async () => { + + let received: Record | null = null; + + const { lastFrame, stdin, unmount } = render( + + { + + received = values; + + }} + /> + , + ); + + await waitFor(() => getActiveField(lastFrame() ?? '', ['Host']) === 'Host'); + stdin.write(KEYS.ENTER); + await waitFor(() => Boolean(lastFrame()?.includes('↵ commit'))); + stdin.write('db1'); + await waitFor(() => Boolean(lastFrame()?.includes('db1'))); + stdin.write(KEYS.ENTER); + await waitFor(() => Boolean(lastFrame()?.includes('↵ edit'))); + + expect(lastFrame()).toContain('↵ edit'); + expect(lastFrame()).toContain('db1'); + + // The committed value survives into submission. + stdin.write(KEYS.DOWN); + await waitFor(() => Boolean(lastFrame()?.includes('❯ [ Submit ]'))); + stdin.write(KEYS.ENTER); + await waitFor(() => received !== null); + + expect(received).toEqual({ host: 'db1' }); + + unmount(); + + }); + + it('should revert to the pre-edit value on Esc', async () => { + + let received: Record | null = null; + + const { lastFrame, stdin, unmount } = render( + + { + + received = values; + + }} + /> + , + ); + + await waitFor(() => getActiveField(lastFrame() ?? '', ['Host']) === 'Host'); + stdin.write(KEYS.ENTER); + await waitFor(() => Boolean(lastFrame()?.includes('↵ commit'))); + stdin.write('XX'); + await waitFor(() => Boolean(lastFrame()?.includes('localhostXX'))); + + stdin.write(KEYS.ESC); + await waitFor(() => Boolean(lastFrame()?.includes('↵ edit'))); + + expect(lastFrame()).toContain('localhost'); + expect(lastFrame()).not.toContain('localhostXX'); + + stdin.write(KEYS.DOWN); + await waitFor(() => Boolean(lastFrame()?.includes('❯ [ Submit ]'))); + stdin.write(KEYS.ENTER); + await waitFor(() => received !== null); + + expect(received).toEqual({ host: 'localhost' }); + + unmount(); + + }); + + it('should NOT cancel the form when Esc leaves edit mode', async () => { + + let cancelled = false; + + const { lastFrame, stdin, unmount } = render( + + {}} + onCancel={() => { + + cancelled = true; + + }} + /> + , + ); + + await waitFor(() => getActiveField(lastFrame() ?? '', ['Host']) === 'Host'); + stdin.write(KEYS.ENTER); + await waitFor(() => Boolean(lastFrame()?.includes('↵ commit'))); + stdin.write(KEYS.ESC); + await waitFor(() => Boolean(lastFrame()?.includes('↵ edit'))); + + expect(cancelled).toBe(false); + + unmount(); + + }); + + it('should expand a select only in edit mode and move between options with arrows', async () => { + + let received: Record | null = null; + + const selectFields: FormField[] = [ + { + key: 'role', + label: 'Role', + type: 'select', + options: [ + { label: 'Admin', value: 'admin' }, + { label: 'Operator', value: 'operator' }, + ], + defaultValue: 'admin', + }, + ]; + + const { lastFrame, stdin, unmount } = render( + + { + + received = values; + + }} + /> + , + ); + + await waitFor(() => getActiveField(lastFrame() ?? '', ['Role']) === 'Role'); + + // Collapsed: only the current option is on screen. + expect(lastFrame()).toContain('Admin'); + expect(lastFrame()).not.toContain('Operator'); + + stdin.write(KEYS.ENTER); + await waitFor(() => Boolean(lastFrame()?.includes('Operator'))); + expect(lastFrame()).toContain('Operator'); + + stdin.write(KEYS.DOWN); + await waitFor(() => Boolean(lastFrame()?.includes('❯ Operator'))); + stdin.write(KEYS.ENTER); + await waitFor(() => Boolean(lastFrame()?.includes('↵ edit'))); + + // Collapsed again, now showing the newly chosen option. + expect(lastFrame()).not.toContain('Admin'); + + stdin.write(KEYS.DOWN); + await waitFor(() => Boolean(lastFrame()?.includes('❯ [ Submit ]'))); + stdin.write(KEYS.ENTER); + await waitFor(() => received !== null); + + expect(received).toEqual({ role: 'operator' }); + + unmount(); + + }); + + it('should toggle a checkbox in place with Enter and with Space, never opening edit mode', async () => { - const { lastFrame, stdin, unmount } = render( - - {}} /> - , - ); + const { lastFrame, stdin, unmount } = render( + + {}} + /> + , + ); - await new Promise((r) => setTimeout(r, 150)); + // Wait for the active marker, not the box: the box renders before + // the focus stack settles and the handler is still a no-op then. + await waitFor(() => getActiveField(lastFrame() ?? '', ['Test Database']) === 'Test Database'); - // Navigate to last field - stdin.write(KEYS.TAB); - await new Promise((r) => setTimeout(r, 150)); - stdin.write(KEYS.TAB); - await new Promise((r) => setTimeout(r, 150)); - expect(getActiveField(lastFrame() ?? '', labels)).toBe('Port'); + stdin.write(KEYS.ENTER); + await waitFor(() => Boolean(lastFrame()?.includes('☑'))); + expect(lastFrame()).toContain('☑'); + expect(lastFrame()).toContain('↵/space toggle'); - // Navigate backward - stdin.write(KEYS.SHIFT_TAB); - await new Promise((r) => setTimeout(r, 150)); - expect(getActiveField(lastFrame() ?? '', labels)).toBe('Host'); + stdin.write(KEYS.SPACE); + await waitFor(() => Boolean(lastFrame()?.includes('☐'))); + expect(lastFrame()).toContain('☐'); - stdin.write(KEYS.SHIFT_TAB); - await new Promise((r) => setTimeout(r, 150)); - expect(getActiveField(lastFrame() ?? '', labels)).toBe('Name'); + unmount(); - unmount(); + }); }); diff --git a/tests/cli/components/forms.test.tsx b/tests/cli/components/forms.test.tsx index 03416105..85fc934f 100644 --- a/tests/cli/components/forms.test.tsx +++ b/tests/cli/components/forms.test.tsx @@ -1,7 +1,17 @@ /** - * Form components tests. + * Form layout tests. * - * Tests Form component with various field types. + * Encodes the aligned two-column contract: + * + * - Label gutter then value column, one row per field, no blank row between + * them. The old one-field-per-two-rows layout with a `gap={1}` spacer burned + * ~20 rows on a 10-field config form and pushed fields past the fold. + * - The gutter is sized from the longest label and capped, so a verbose label + * truncates instead of shoving the value column sideways for every other row. + * - A select shows only its current value until it is being edited. That + * collapse is where most of the height saving comes from. + * - The form windows itself to a row budget and says so, so no field is ever + * rendered off the bottom with no way to reach it. */ import { describe, it, expect } from 'bun:test'; import { render } from 'ink-testing-library'; @@ -11,24 +21,129 @@ import { FocusProvider } from '../../../src/tui/focus.js'; import { Form } from '../../../src/tui/components/forms/index.js'; import type { FormField } from '../../../src/tui/components/forms/index.js'; +const KEYS = { + DOWN: '\x1B[B', +}; + +// eslint-disable-next-line no-control-regex -- matching the ANSI SGR escape is the point +const ANSI_PATTERN = /\u001B\[[0-9;]*m/g; + +/** Column assertions have to run against the text, not the styling. */ +function strip(frame: string | undefined): string { + + return (frame ?? '').replace(ANSI_PATTERN, ''); + +} + /** - * Wrapper with focus provider for components that need focus. + * Poll until the predicate holds instead of sleeping a guessed duration. */ +async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise { + + const deadline = Date.now() + timeoutMs; + + while (!predicate() && Date.now() < deadline) { + + await new Promise((r) => setTimeout(r, 5)); + + } + +} + +/** + * Write Escape until it is observed, rather than once and hopefully. + */ +async function pressUntil( + stdin: { write: (data: string) => void }, + predicate: () => boolean, + timeoutMs = 2000, +): Promise { + + const deadline = Date.now() + timeoutMs; + + while (!predicate() && Date.now() < deadline) { + + stdin.write('\x1B'); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + } + +} + function TestWrapper({ children }: { children: React.ReactNode }) { return {children}; } +function lineWith(frame: string, needle: string): string { + + return frame.split('\n').find((line) => line.includes(needle)) ?? ''; + +} + +function lineIndexOf(frame: string, needle: string): number { + + return frame.split('\n').findIndex((line) => line.includes(needle)); + +} + describe('cli: components/forms', () => { - describe('Form', () => { + describe('two-column layout', () => { + + it('should render each field as one row with the value column aligned', () => { + + const fields: FormField[] = [ + { key: 'host', label: 'Host', type: 'text', defaultValue: 'localhost' }, + { key: 'port', label: 'Port', type: 'text', defaultValue: '5432' }, + { key: 'database', label: 'Database', type: 'text', defaultValue: 'appdb' }, + ]; + + const { lastFrame } = render( + + {}} /> + , + ); + + const frame = strip(lastFrame()); + + const hostColumn = lineWith(frame, 'Host').indexOf('localhost'); + const portColumn = lineWith(frame, 'Port').indexOf('5432'); + const databaseColumn = lineWith(frame, 'Database').indexOf('appdb'); + + expect(hostColumn).toBeGreaterThan(0); + expect(portColumn).toBe(hostColumn); + expect(databaseColumn).toBe(hostColumn); + + }); + + it('should not leave a blank row between fields', () => { + + const fields: FormField[] = [ + { key: 'host', label: 'Host', type: 'text', defaultValue: 'localhost' }, + { key: 'port', label: 'Port', type: 'text', defaultValue: '5432' }, + ]; - it('should render field labels', () => { + const { lastFrame } = render( + + {}} /> + , + ); + const frame = strip(lastFrame()); + + expect(lineIndexOf(frame, 'Port') - lineIndexOf(frame, 'Host')).toBe(1); + + }); + + it('should truncate an over-long label rather than widen the value column', () => { + + const longLabel = 'Test Database (skipped in production builds)'; const fields: FormField[] = [ - { key: 'name', label: 'Name', type: 'text' }, - { key: 'host', label: 'Host', type: 'text' }, + { key: 'host', label: 'Host', type: 'text', defaultValue: 'localhost' }, + { key: 'isTest', label: longLabel, type: 'checkbox' }, ]; const { lastFrame } = render( @@ -37,15 +152,42 @@ describe('cli: components/forms', () => { , ); - expect(lastFrame()).toContain('Name'); - expect(lastFrame()).toContain('Host'); + const frame = strip(lastFrame()); + + expect(frame).not.toContain(longLabel); + expect(frame).toContain('…'); + + const hostColumn = lineWith(frame, 'Host').indexOf('localhost'); + const checkboxColumn = lineWith(frame, 'Test Database').indexOf('☐'); + + expect(checkboxColumn).toBe(hostColumn); }); - it('should show required indicator', () => { + it('should show the required marker', () => { + + const fields: FormField[] = [{ key: 'name', label: 'Name', type: 'text', required: true }]; + + const { lastFrame } = render( + + {}} /> + , + ); + + expect(strip(lastFrame())).toContain('Name*'); + + }); + + it('should render a hint after the value', () => { const fields: FormField[] = [ - { key: 'name', label: 'Name', type: 'text', required: true }, + { + key: 'dialect', + label: 'Database Type', + type: 'text', + defaultValue: 'postgres', + hint: '(locked)', + }, ]; const { lastFrame } = render( @@ -54,13 +196,19 @@ describe('cli: components/forms', () => { , ); - expect(lastFrame()).toContain('*'); + const line = lineWith(strip(lastFrame()), 'Database Type'); + + expect(line.indexOf('postgres')).toBeGreaterThan(0); + expect(line.indexOf('(locked)')).toBeGreaterThan(line.indexOf('postgres')); }); - it('should render checkbox fields', () => { + it('should render a checkbox value inline', () => { - const fields: FormField[] = [{ key: 'ssl', label: 'Use SSL', type: 'checkbox' }]; + const fields: FormField[] = [ + { key: 'ssl', label: 'Use SSL', type: 'checkbox' }, + { key: 'tls', label: 'Use TLS', type: 'checkbox', defaultValue: true }, + ]; const { lastFrame } = render( @@ -68,15 +216,17 @@ describe('cli: components/forms', () => { , ); - expect(lastFrame()).toContain('Use SSL'); - expect(lastFrame()).toContain('☐'); // Unchecked + const frame = strip(lastFrame()); + + expect(lineWith(frame, 'Use SSL')).toContain('☐ No'); + expect(lineWith(frame, 'Use TLS')).toContain('☑ Yes'); }); - it('should render checkbox with default value', () => { + it('should mask a password value in browse mode', () => { const fields: FormField[] = [ - { key: 'ssl', label: 'Use SSL', type: 'checkbox', defaultValue: true }, + { key: 'password', label: 'Password', type: 'password', defaultValue: 'hunter2' }, ]; const { lastFrame } = render( @@ -85,22 +235,28 @@ describe('cli: components/forms', () => { , ); - expect(lastFrame()).toContain('☑'); // Checked + const frame = strip(lastFrame()); + + expect(frame).not.toContain('hunter2'); + expect(lineWith(frame, 'Password')).toContain('•••••••'); }); - it('should render select fields with options', () => { + it('should collapse a select to its current value on one row', () => { const fields: FormField[] = [ { - key: 'dialect', - label: 'Dialect', + key: 'userRole', + label: 'User Role', type: 'select', options: [ - { label: 'PostgreSQL', value: 'postgres' }, - { label: 'MySQL', value: 'mysql' }, + { label: 'Admin', value: 'admin' }, + { label: 'Operator', value: 'operator' }, + { label: 'Reader', value: 'reader' }, ], + defaultValue: 'operator', }, + { key: 'host', label: 'Host', type: 'text', defaultValue: 'localhost' }, ]; const { lastFrame } = render( @@ -109,46 +265,110 @@ describe('cli: components/forms', () => { , ); - expect(lastFrame()).toContain('Dialect'); + const frame = strip(lastFrame()); + + expect(lineWith(frame, 'User Role')).toContain('Operator'); + expect(frame).not.toContain('Admin'); + expect(frame).not.toContain('Reader'); + expect(lineIndexOf(frame, 'Host') - lineIndexOf(frame, 'User Role')).toBe(1); }); - it('should show keyboard shortcuts', () => { + }); + + describe('scrolling', () => { + + const manyFields: FormField[] = Array.from({ length: 12 }, (_, i) => ({ + key: `f${i}`, + label: `Field${String(i).padStart(2, '0')}`, + type: 'text' as const, + defaultValue: `v${i}`, + })); - const fields: FormField[] = [{ key: 'name', label: 'Name', type: 'text' }]; + it('should window fields to the height budget and count what is below', () => { const { lastFrame } = render( - {}} /> + {}} height={12} /> , ); - expect(lastFrame()).toContain('[Enter]'); - expect(lastFrame()).toContain('[Esc]'); - expect(lastFrame()).toContain('[↑↓]'); + const frame = strip(lastFrame()); + + expect(frame).toContain('Field00'); + expect(frame).toContain('Field06'); + expect(frame).not.toContain('Field07'); + expect(frame).toContain('↓ 5 more'); + expect(frame).not.toMatch(/↑ \d+ more/); }); - it('should use custom submit label', () => { + it('should scroll the window so the active field stays visible', async () => { + + const { lastFrame, stdin, unmount } = render( + + {}} height={12} /> + , + ); + + await waitFor(() => strip(lastFrame()).includes('› Field00')); + + for (let i = 0; i < 11; i++) { + + stdin.write(KEYS.DOWN); + await waitFor(() => strip(lastFrame()).includes(`› Field${String(i + 1).padStart(2, '0')}`)); + + } - const fields: FormField[] = [{ key: 'name', label: 'Name', type: 'text' }]; + const frame = strip(lastFrame()); + + expect(frame).toContain('› Field11'); + expect(frame).toContain('↑ 5 more'); + expect(frame).not.toMatch(/↓ \d+ more/); + expect(frame).not.toContain('Field04'); + + unmount(); + + }); + + it('should render every field when the budget is generous', () => { const { lastFrame } = render( - {}} submitLabel="Save Config" /> + {}} height={40} /> , ); - expect(lastFrame()).toContain('Save Config'); + const frame = strip(lastFrame()); + + expect(frame).toContain('Field00'); + expect(frame).toContain('Field11'); + expect(frame).not.toMatch(/[↑↓] \d+ more/); }); - it('should highlight first field initially', () => { + }); - const fields: FormField[] = [ - { key: 'name', label: 'Name', type: 'text' }, - { key: 'host', label: 'Host', type: 'text' }, - ]; + describe('action row and hints', () => { + + const fields: FormField[] = [{ key: 'name', label: 'Name', type: 'text' }]; + + it('should render the submit and cancel buttons', () => { + + const { lastFrame } = render( + + {}} onCancel={() => {}} submitLabel="Save Config" /> + , + ); + + const frame = strip(lastFrame()); + + expect(frame).toContain('[ Save Config ]'); + expect(frame).toContain('[ Cancel ]'); + + }); + + it('should omit the cancel button when the form has no cancel handler', () => { const { lastFrame } = render( @@ -156,13 +376,154 @@ describe('cli: components/forms', () => { , ); - // First field should have the active indicator - const frame = lastFrame() ?? ''; - const nameIndex = frame.indexOf('Name'); - const hostIndex = frame.indexOf('Host'); + const frame = strip(lastFrame()); + + expect(frame).toContain('[ Submit ]'); + expect(frame).not.toContain('[ Cancel ]'); + + }); + + it('should describe the browse keymap in the hint row', async () => { + + const { lastFrame } = render( + + {}} onCancel={() => {}} /> + , + ); + + await waitFor(() => strip(lastFrame()).includes('↵ edit')); + + const frame = strip(lastFrame()); + + expect(frame).toContain('↑↓ field'); + expect(frame).toContain('↵ edit'); + expect(frame).toContain('esc cancel'); + + }); + + it('should replace the action row with the busy label while busy', () => { + + const { lastFrame } = render( + + {}} + onCancel={() => {}} + busy + busyLabel="Testing connection..." + /> + , + ); + + const frame = strip(lastFrame()); + + expect(frame).toContain('Testing connection...'); + expect(frame).not.toContain('[ Submit ]'); + + }); + + it('should surface a status error', () => { + + const { lastFrame } = render( + + {}} statusError="connection refused" /> + , + ); + + expect(strip(lastFrame())).toContain('✘ connection refused'); + + }); + + }); + + describe('cancelling a busy form', () => { + + const fields: FormField[] = [{ key: 'name', label: 'Name', type: 'text' }]; + + it('should say the busy state can be cancelled, since nobody tries an unadvertised key', () => { + + const { lastFrame } = render( + + {}} + busy + busyLabel="Testing connection..." + onCancelBusy={() => {}} + /> + , + ); + + expect(strip(lastFrame())).toContain('[Esc] Cancel'); + + }); + + it('should stay silent about a hatch that is not wired', () => { + + const { lastFrame } = render( + + {}} busy busyLabel="Testing connection..." /> + , + ); + + expect(strip(lastFrame())).not.toContain('[Esc] Cancel'); + + }); + + it('should give Escape to the operation, not to leaving the screen', async () => { + + const cancelled: string[] = []; + + const { stdin, lastFrame, unmount } = render( + + {}} + onCancel={() => cancelled.push('screen')} + busy + busyLabel="Testing connection..." + onCancelBusy={() => cancelled.push('operation')} + /> + , + ); + + await waitFor(() => strip(lastFrame()).includes('[Esc] Cancel')); + + // The focus stack is pushed in an effect, so an Escape written on + // the frame the form first appears on lands before the handler is + // listening. Repeat until it takes. + await pressUntil(stdin, () => cancelled.length > 0); + + // Leaving would abandon the operation rather than stop it, which is + // how a connect ends up still running behind a screen that is gone. + expect(cancelled).toEqual(['operation']); + + unmount(); + + }); + + it('should hand Escape back to the screen once the form is idle', async () => { + + const cancelled: string[] = []; + + const { stdin, lastFrame, unmount } = render( + + {}} + onCancel={() => cancelled.push('screen')} + onCancelBusy={() => cancelled.push('operation')} + /> + , + ); + + await waitFor(() => strip(lastFrame()).includes('[ Submit ]')); + + await pressUntil(stdin, () => cancelled.length > 0); + + expect(cancelled).toEqual(['screen']); - // Name should come before Host - expect(nameIndex).toBeLessThan(hostIndex); + unmount(); }); diff --git a/tests/cli/components/list-position.test.tsx b/tests/cli/components/list-position.test.tsx new file mode 100644 index 00000000..80f2bd9d --- /dev/null +++ b/tests/cli/components/list-position.test.tsx @@ -0,0 +1,512 @@ +/** + * List cursor memory tests. + * + * Pins the reported bug: entering an item from a list screen and coming back + * used to land the cursor on row 0, because the screen unmounts on navigate + * and the cursor lived in component state. Also pins the failure modes that + * a naive fix introduces - restoring by index onto a deleted row, and two + * lists on one route sharing a single memory slot. + */ +import { describe, it, expect, beforeEach, afterAll } from 'bun:test'; +import { render } from 'ink-testing-library'; +import React, { useEffect, useRef } from 'react'; +import { Text } from 'ink'; + +import { FocusProvider } from '../../../src/tui/focus.js'; +import { RouterProvider, useRouter } from '../../../src/tui/router.js'; +import { SelectList } from '../../../src/tui/components/lists/index.js'; +import { + clearListMemory, + listMemoryKey, + recallListPosition, + rememberListPosition, +} from '../../../src/tui/list-memory.js'; + +import type { SelectListItem } from '../../../src/tui/components/lists/index.js'; +import type { Route, RouteParams } from '../../../src/tui/types.js'; + +/** + * Poll until the predicate holds, and fail loudly when it never does. + * + * A waiter that returns quietly on timeout turns every assertion after it into + * a coin flip, so this throws instead. + */ +async function waitFor(predicate: () => boolean, label: string, timeoutMs = 3000): Promise { + + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + + if (predicate()) return; + + await new Promise((r) => setTimeout(r, 5)); + + } + + throw new Error(`waitFor timed out: ${label}`); + +} + +/** + * Let any queued effect and the render it schedules run to completion. + * + * Needed only before asserting that something did *not* happen. There is no + * condition to poll for in that case, and a restore that fires one commit late + * would otherwise slip past an assertion made on the very first frame - which + * is how three of these tests originally passed against a broken build. + */ +async function settle(turns = 10): Promise { + + for (let i = 0; i < turns; i++) { + + await new Promise((r) => setTimeout(r, 10)); + + } + +} + +const DOWN = '\x1B[B'; +const UP = '\x1B[A'; + +function makeItems(labels: string[]): SelectListItem[] { + + return labels.map((label) => ({ key: label, label, value: label })); + +} + +/** + * Drives navigation from a prop so a test can step the router without + * reaching into React internals. + * + * One navigation per change of `step`, tracked on a ref. `navigate` and `back` + * are rebuilt whenever history changes, so an effect that lists them re-fires + * on its own result and walks the stack all the way to the bottom. + */ +function Navigator({ step }: { step: Route | 'back' | null }) { + + const { navigate, back } = useRouter(); + + const lastStepRef = useRef(null); + const navigateRef = useRef(navigate); + const backRef = useRef(back); + navigateRef.current = navigate; + backRef.current = back; + + useEffect(() => { + + if (step === null || lastStepRef.current === step) return; + + lastStepRef.current = step; + + if (step === 'back') { + + backRef.current(); + + return; + + } + + navigateRef.current(step); + + }, [step]); + + return null; + +} + +/** + * A list that only exists on `db/explore/tables`, so navigating away really + * unmounts it - the condition that loses the cursor in the reported bug. + */ +function OneListApp({ + step, + items, + focusLabel, + defaultValue, +}: { + step: Route | 'back' | null; + items: SelectListItem[]; + focusLabel?: string; + defaultValue?: string; +}) { + + return ( + + + + + + + + + ); + +} + +function OnlyOn({ route, children }: { route: Route; children: React.ReactNode }) { + + const { route: current } = useRouter(); + + if (current !== route) return elsewhere:{current}; + + return <>{children}; + +} + +describe('cli: list-position', () => { + + beforeEach(() => { + + clearListMemory(); + + }); + + // The store is module state and the CI group runs every cli file in one + // process, so this file leaves nothing behind for the next one. + afterAll(() => { + + clearListMemory(); + + }); + + describe('key', () => { + + it('should key the same params the same way whatever order they were written in', () => { + + const forwards: RouteParams = { name: 'dev', schema: 'public' }; + const backwards: RouteParams = { schema: 'public', name: 'dev' }; + + expect(listMemoryKey('config', forwards)).toBe(listMemoryKey('config', backwards)); + + }); + + it('should treat different params on one route as different lists', () => { + + expect(listMemoryKey('secret', { name: 'dev' })) + .not.toBe(listMemoryKey('secret', { name: 'prod' })); + + }); + + it('should ignore params that were left undefined', () => { + + expect(listMemoryKey('config', { name: 'dev', schema: undefined })) + .toBe(listMemoryKey('config', { name: 'dev' })); + + }); + + it('should treat two lists on one route as different when they carry a list id', () => { + + expect(listMemoryKey('db/transfer', {}, 'DbTransferDestSelect')) + .not.toBe(listMemoryKey('db/transfer', {}, 'DbTransferTableSelect')); + + }); + + }); + + describe('store', () => { + + it('should hand back what it was given', () => { + + const key = listMemoryKey('config', {}); + + rememberListPosition(key, 'staging'); + + expect(recallListPosition(key)).toBe('staging'); + + }); + + it('should evict the least recently written entry once the cap is passed', () => { + + const first = listMemoryKey('config', { name: 'entry-0' }); + + rememberListPosition(first, 'row'); + + for (let i = 1; i <= 200; i++) { + + rememberListPosition(listMemoryKey('config', { name: `entry-${i}` }), 'row'); + + } + + expect(recallListPosition(first)).toBeUndefined(); + expect(recallListPosition(listMemoryKey('config', { name: 'entry-200' }))).toBe('row'); + + }); + + it('should survive later pressure once it has been rewritten', () => { + + const kept = listMemoryKey('config', { name: 'kept' }); + + rememberListPosition(kept, 'row'); + + for (let i = 0; i < 60; i++) { + + rememberListPosition(listMemoryKey('config', { name: `filler-${i}` }), 'row'); + + } + + // The visit that has to count: rewriting an entry has to move it to + // the young end, or the list a user keeps coming back to is the one + // the cap throws away. Asserting straight after the rewrite proves + // nothing - a plain `set` leaves it there too. + rememberListPosition(kept, 'row'); + + for (let i = 60; i < 120; i++) { + + rememberListPosition(listMemoryKey('config', { name: `filler-${i}` }), 'row'); + + } + + expect(recallListPosition(kept)).toBe('row'); + + }); + + }); + + describe('SelectList', () => { + + /** + * Leaves the cursor on `charlie` and then walks off the screen, which + * unmounts the list. Every restore case starts from here. + */ + async function leaveCursorOnCharlie(items: SelectListItem[], focusLabel?: string) { + + const handle = render( + , + ); + + await waitFor( + () => Boolean(handle.lastFrame()?.includes('❯ alpha')), + 'cursor on first row', + ); + + handle.stdin.write(DOWN); + handle.stdin.write(DOWN); + + // Poll on the cursor, never on a row label: every label is on screen + // from the first frame, so a label predicate returns before anything + // has moved and every assertion after it becomes a coin flip. + await waitFor( + () => Boolean(handle.lastFrame()?.includes('❯ charlie')), + 'cursor moved to charlie', + ); + + handle.rerender( + , + ); + + await waitFor( + () => Boolean(handle.lastFrame()?.includes('elsewhere:')), + 'list unmounted', + ); + + return handle; + + } + + it('should put the cursor back on the row it was left on after a pop', async () => { + + const items = makeItems(['alpha', 'bravo', 'charlie', 'delta']); + const { lastFrame, rerender, unmount } = await leaveCursorOnCharlie(items); + + rerender(); + + await waitFor( + () => Boolean(lastFrame()?.includes('❯ charlie')), + 'cursor restored to charlie', + ); + + expect(lastFrame()).toContain('❯ charlie'); + expect(lastFrame()).not.toContain('❯ alpha'); + + unmount(); + + }); + + it('should open at the top when the route is walked into rather than returned to', async () => { + + const items = makeItems(['alpha', 'bravo', 'charlie', 'delta']); + const { lastFrame, rerender, stdin, unmount } = await leaveCursorOnCharlie(items); + + // Forward navigation, not a pop. A wizard swaps one list for another + // under a single route this way, and each step is meant to open on + // its own first row - `DbTransferScreen` counts on it. + rerender(); + + await settle(); + + expect(lastFrame()).toContain('❯ alpha'); + expect(lastFrame()).not.toContain('❯ charlie'); + + // Then prove it by moving: from the top, Up wraps to delta. A cursor + // wrongly restored to charlie would go to bravo and never show delta, + // so this cannot pass on a frame that merely has not caught up yet. + stdin.write(UP); + + await waitFor(() => Boolean(lastFrame()?.includes('❯ delta')), 'Up wrapped from the top'); + + unmount(); + + }); + + it('should fall back to the first row when the remembered item is gone', async () => { + + const items = makeItems(['alpha', 'bravo', 'charlie', 'delta']); + const { lastFrame, rerender, stdin, unmount } = await leaveCursorOnCharlie(items); + + // charlie deleted while the detail screen was up. An index-keyed + // restore would land on delta, which slid into charlie's slot. + const remaining = makeItems(['alpha', 'bravo', 'delta']); + + rerender(); + + await settle(); + + expect(lastFrame()).toContain('❯ alpha'); + expect(lastFrame()).not.toContain('❯ delta'); + expect(lastFrame()).not.toContain('charlie'); + + // From the top, Down lands on bravo. A cursor restored by index onto + // delta - the row that slid into charlie's slot - is at the end, so + // Down wraps it to alpha and bravo never gets the marker. + stdin.write(DOWN); + + await waitFor(() => Boolean(lastFrame()?.includes('❯ bravo')), 'Down moved off the top'); + + unmount(); + + }); + + it('should restore a row that only arrives after the screen is back', async () => { + + const items = makeItems(['alpha', 'bravo', 'charlie', 'delta']); + const { lastFrame, rerender, unmount } = await leaveCursorOnCharlie(items); + + // The screen comes back before its fetch resolves, so the list + // remounts empty and the remembered row cannot be matched until the + // rows land - a render after the one that read the initial state. + rerender(); + + await waitFor(() => Boolean(lastFrame()?.includes('No items')), 'empty remount'); + + rerender(); + + await waitFor( + () => Boolean(lastFrame()?.includes('❯ charlie')), + 'cursor restored to charlie', + ); + + expect(lastFrame()).toContain('❯ charlie'); + + unmount(); + + }); + + it('should let an explicit defaultValue outrank the remembered row', async () => { + + const items = makeItems(['alpha', 'bravo', 'charlie', 'delta']); + const { lastFrame, rerender, stdin, unmount } = await leaveCursorOnCharlie(items); + + rerender(); + + await settle(); + + expect(lastFrame()).toContain('❯ bravo'); + expect(lastFrame()).not.toContain('❯ charlie'); + + // From bravo, Up lands on alpha. A cursor that let the memory win + // sits on charlie, where Up lands on bravo, so alpha never gets the + // marker either before or after the key. + stdin.write(UP); + + await waitFor(() => Boolean(lastFrame()?.includes('❯ alpha')), 'Up moved off bravo'); + + unmount(); + + }); + + it('should record the cursor under the list id when one is given', async () => { + + const items = makeItems(['alpha', 'bravo', 'charlie', 'delta']); + const { unmount } = await leaveCursorOnCharlie(items, 'TablesList'); + + // The discriminated slot holds it; the bare-route slot a sibling list + // on this route would use is untouched. + expect(recallListPosition(listMemoryKey('db/explore/tables', {}, 'TablesList'))) + .toBe('charlie'); + expect(recallListPosition(listMemoryKey('db/explore/tables', {}))) + .toBeUndefined(); + + unmount(); + + }); + + it('should not remember anything when there is no router above it', async () => { + + const items = makeItems(['alpha', 'bravo', 'charlie']); + + const { lastFrame, stdin, unmount } = render( + + + , + ); + + await waitFor(() => Boolean(lastFrame()?.includes('❯ alpha')), 'cursor on first row'); + + stdin.write(DOWN); + + await waitFor(() => Boolean(lastFrame()?.includes('❯ bravo')), 'cursor moved'); + + expect(recallListPosition(listMemoryKey('db/explore/tables', {}))).toBeUndefined(); + + unmount(); + + }); + + }); + + describe('router reset', () => { + + it('should drop every remembered position when the history stack is discarded', async () => { + + const key = listMemoryKey('config', {}); + + rememberListPosition(key, 'staging'); + + const { lastFrame, unmount } = render( + + + + + , + ); + + await waitFor(() => Boolean(lastFrame()?.includes('route:home')), 'reset ran'); + + expect(recallListPosition(key)).toBeUndefined(); + + unmount(); + + }); + + }); + +}); + +function ResetOnMount() { + + const { reset, route } = useRouter(); + + useEffect(() => { + + reset(); + + }, [reset]); + + return route:{route}; + +} diff --git a/tests/cli/components/terminal.test.tsx b/tests/cli/components/terminal.test.tsx new file mode 100644 index 00000000..b696a64d --- /dev/null +++ b/tests/cli/components/terminal.test.tsx @@ -0,0 +1,660 @@ +/** + * Terminal component tests. + * + * `ResultTable` is the one grid in the app: the SQL terminal, the SQL history + * screen and the explore row peek all draw through it. What is pinned here: + * + * - **A wide result is chopped, not crammed.** Ink shrinks flex items, so a row + * wider than the terminal squeezes every cell at once rather than overflowing + * — `select * from ai_usage` produced headers reading `ai_u`/`sage` and ids + * reading `1283`/`2`. The fit keeps whole columns and reports the rest. + * - **Chopping does not cost density.** The width a column asks for comes from + * its own values, so a dozen narrow columns still all fit. A fit that gave + * every column the same width would show five of them. + * - **Cells are formatted through `documentValue`.** `JSON.stringify` renders a + * `Buffer` as its wrapper and a `Date` as a quoted string, and the width + * allocator then spends real columns on both. + * - **Enter opens a row**, which is the only thing that makes dropping columns + * acceptable, and the cursor survives the round trip. + * + * The suite runs at `FORCE_COLOR=0`, so nothing here may assert on an SGR + * escape: where the cursor is has to be read from something the row viewer + * prints, not from the inverse attribute. + */ +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 { ResultBrowser, ResultTable } from '../../../src/tui/components/terminal/index.js'; + +/** Columns the ink-testing-library terminal reports. */ +const TERMINAL_COLUMNS = 100; + +/** + * A line only the grid prints. + * + * `col_00` shows up in the grid and in the document both, so waiting on it + * after Escape returns while the document is still up — a wait that cannot fail + * is worse than no wait at all. + */ +const GRID_MARKER = '[/] Filter'; + +/** + * Give Ink a tick to register its useInput handler, and another to flush the + * frame that the keystroke produced. + */ +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 KEY = { + down: '', + right: '', + enter: '\r', + escape: '', + filter: '/', + format: 'f', +} as const; + +/** + * Wait until the grid's own input handler is listening. + * + * Ink registers `useInput` in an effect, which runs after the frame it belongs + * to is painted, so polling the frame is not enough on its own: a keystroke + * written the moment the grid appears lands before anything is subscribed and + * is lost. This presses a key until its effect shows up, which is the same + * poll-for-the-condition discipline a fixed sleep would skip. + * + * `/` is the probe because filter mode announces itself in the status bar, and + * because Escape out of it clears whatever the probe typed. + */ +async function settleGrid( + stdin: { write: (data: string) => void }, + frame: () => string, +): Promise { + + const deadline = Date.now() + 2000; + + while (!frame().includes('[Tab] Column') && Date.now() < deadline) { + + stdin.write(KEY.filter); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + } + + stdin.write(KEY.escape); + + await waitFor(() => !frame().includes('[Tab] Column')); + +} + +/** + * Wait until the row document viewer's own focus scope is live. + * + * Same reason as `settleGrid`, one level deeper: `useFocusScope` pushes onto + * the stack in an effect too. `f` is the probe because what it changes is in + * the header rather than in the document, and because pressing it twice puts + * the remembered format back where it started. + */ +async function settleRowView( + stdin: { write: (data: string) => void }, + frame: () => string, +): Promise { + + const start = frame().includes('[f] JSON') ? '[f] JSON' : '[f] YAML'; + const flipped = start === '[f] JSON' ? '[f] YAML' : '[f] JSON'; + + const deadline = Date.now() + 2000; + + while (!frame().includes(flipped) && Date.now() < deadline) { + + stdin.write(KEY.format); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + } + + stdin.write(KEY.format); + + await waitFor(() => frame().includes(start)); + +} + +/** `count` column names, `col_00` upward, so ordinal position reads off the name. */ +function many(count: number): string[] { + + return Array.from({ length: count }, (_, index) => `col_${String(index).padStart(2, '0')}`); + +} + +/** One row whose every cell is `width` characters of its own column's name. */ +function wideRow(columns: string[], width: number): Record { + + const row: Record = {}; + + for (const column of columns) row[column] = column.padEnd(width, '-'); + + return row; + +} + +describe('cli: components/terminal', () => { + + describe('ResultTable filtering', () => { + + const columns = ['name']; + const rows = [{ name: 'alice' }, { name: 'bob' }]; + + it('should erase a filter character when Backspace is pressed', async () => { + + // Terminals send 0x7F for the physical Backspace key. Ink 6.8.0 + // reported that as key.delete, so ResultTable's key.backspace guard + // never fired and the filter term could not be corrected. Ink 7 + // reports it as key.backspace. This pins that mapping: if it ever + // inverts again, filter-mode editing silently dies. + const { stdin, lastFrame, unmount } = render( + , + ); + + await tick(); + stdin.write('/'); + await tick(); + stdin.write('ali'); + await tick(); + + expect(lastFrame()).toContain('"ali"'); + + stdin.write('\x7F'); + await tick(); + + expect(lastFrame()).toContain('"al"'); + + unmount(); + + }); + + it('should match a value in a column too far right to be drawn', async () => { + + // The fit decides what is drawn, not what exists. A reader filtering + // for a value they know is in the result should find its row whether + // or not that column made the cut, because the row view will show it. + const names = many(20); + const first = wideRow(names, 18); + const second = wideRow(names, 18); + + second['col_19'] = 'needle'; + + const { stdin, lastFrame, unmount } = render( + , + ); + + await tick(); + + expect(lastFrame()).not.toContain('col_19'); + + stdin.write('/'); + await tick(); + stdin.write('needle'); + await waitFor(() => Boolean(lastFrame()?.includes('filtered from 2'))); + + expect(lastFrame()).toContain('1 row (filtered from 2)'); + + unmount(); + + }); + + }); + + describe('ResultTable column fit', () => { + + it('should drop the columns that do not fit rather than squeeze them all', async () => { + + const names = many(15); + + const { lastFrame, unmount } = render( + , + ); + + await waitFor(() => Boolean(lastFrame()?.includes('col_00'))); + + const frame = lastFrame() ?? ''; + const lines = frame.split('\n'); + const rule = lines.findIndex((line) => line.startsWith('─')); + + // Five whole columns at sixteen, not fifteen at four. Asserting on + // the header line rather than on the frame is the point: squeezing + // fifteen columns into the row also removes the string `col_05`, + // because it truncates it to `col`, so a frame-wide `not.toContain` + // passes on exactly the bug it was written for. + expect(lines[rule - 1]).toContain('col_00'); + expect(lines[rule - 1]).toContain('col_04'); + expect(lines[rule - 1]).not.toContain('col_05'); + expect(frame).toContain('… 10 more columns'); + + // One line per row. Squeezing wrapped every cell, which is what + // doubled the height of the grid and broke values mid-value. + const blank = lines.indexOf('', rule + 1); + + expect(lines.slice(rule + 1, blank)).toHaveLength(1); + + for (const line of lines) { + + expect(line.length).toBeLessThanOrEqual(TERMINAL_COLUMNS); + + } + + unmount(); + + }); + + it('should keep every column when the values are narrow enough', async () => { + + // Eleven six-character headers fit at their natural width. A fit + // that gave every column the same readable width would show five of + // them and hide six, which is the regression this guards. + const names = many(11); + + const { lastFrame, unmount } = render( + , + ); + + await waitFor(() => Boolean(lastFrame()?.includes('col_00'))); + + const frame = lastFrame() ?? ''; + + expect(frame).toContain('col_10'); + expect(frame).not.toContain('more column'); + + unmount(); + + }); + + it('should draw one column even when it alone overflows the row', async () => { + + const { lastFrame, unmount } = render( + , + ); + + await waitFor(() => Boolean(lastFrame()?.includes('1 row'))); + + const frame = lastFrame() ?? ''; + + expect(frame).toContain('xxxx'); + expect(frame).not.toContain('more column'); + + for (const line of frame.split('\n')) { + + expect(line.length).toBeLessThanOrEqual(TERMINAL_COLUMNS); + + } + + unmount(); + + }); + + it('should offer the row view in the marker only when a row can be opened', async () => { + + const names = many(15); + + const plain = render( + , + ); + + await waitFor(() => Boolean(plain.lastFrame()?.includes('more columns'))); + + expect(plain.lastFrame()).not.toContain('[↵]'); + + plain.unmount(); + + const openable = render( + {}} + />, + ); + + await waitFor(() => Boolean(openable.lastFrame()?.includes('more columns'))); + + expect(openable.lastFrame()).toContain('[↵] on a row shows them all'); + expect(openable.lastFrame()).toContain('[↵] Open row'); + + openable.unmount(); + + }); + + }); + + describe('ResultTable cell formatting', () => { + + async function cell(value: unknown, column = 'v') { + + const { lastFrame, unmount } = render( + , + ); + + await waitFor(() => Boolean(lastFrame()?.includes('1 row'))); + + const frame = lastFrame() ?? ''; + + unmount(); + + return frame; + + } + + it('should summarise a Buffer instead of dumping its JSON wrapper', async () => { + + const frame = await cell(Buffer.from([0x00, 0xff])); + + expect(frame).toContain(''); + expect(frame).not.toContain('"type"'); + expect(frame).not.toContain('Buffer"'); + + }); + + it('should summarise the Uint8Array bun:sqlite returns for the same column', async () => { + + // Three of the four drivers return a Buffer here and bun:sqlite + // returns a plain Uint8Array, so a Buffer.isBuffer check would miss + // exactly the dialect the CLI ships with by default. + const frame = await cell(new Uint8Array([0x00, 0xff])); + + expect(frame).toContain(''); + + }); + + it('should render a Date as a bare timestamp, not a quoted string', async () => { + + const frame = await cell(new Date('2024-03-01T12:34:56.789Z')); + + expect(frame).toContain('2024-03-01T12:34:56.789Z'); + expect(frame).not.toContain('"2024-03-01'); + + }); + + it('should survive the invalid Date mysql returns for a zero date', async () => { + + // `toISOString()` throws on this one, which would take the whole + // screen down rather than one cell. + const frame = await cell(new Date('0000-00-00')); + + expect(frame).toContain(''); + + }); + + it('should render a bigint rather than throwing on it', async () => { + + const frame = await cell(9007199254740993n); + + expect(frame).toContain('9007199254740993'); + + }); + + it('should normalise values nested inside a json column', async () => { + + const frame = await cell([Buffer.from([0x01])]); + + expect(frame).toContain(''); + expect(frame).not.toContain('"type"'); + + }); + + it('should keep NULL and the empty string apart', async () => { + + expect(await cell(null)).toContain('NULL'); + expect(await cell('')).not.toContain('NULL'); + + }); + + }); + + describe('ResultBrowser', () => { + + const names = many(20); + + function browser(rows: Record[], props: Partial> = {}) { + + const view = render( + + + , + ); + + return { ...view, frame: () => view.lastFrame() ?? '' }; + + } + + /** Three rows whose hidden last column identifies which row it is. */ + function rowsOf(count: number): Record[] { + + return Array.from({ length: count }, (_, index) => { + + const row = wideRow(names, 18); + + row['col_00'] = `row-${index}`; + row['col_19'] = `tail-${index}`; + + return row; + + }); + + } + + it('should open the cursor row as a document showing a dropped column', async () => { + + const view = browser(rowsOf(3)); + + await waitFor(() => view.frame().includes(GRID_MARKER)); + await settleGrid(view.stdin, view.frame); + + expect(view.frame()).not.toContain('tail-0'); + + view.stdin.write(KEY.enter); + await waitFor(() => view.frame().includes('row 1 of 3')); + + expect(view.frame()).toContain('col_19: tail-0'); + + view.unmount(); + + }); + + it('should open the row the arrows moved to, not the first one', async () => { + + // One visible row, so the scroll indicator is what says the cursor + // moved. Nothing else does: at FORCE_COLOR=0 the inverse attribute + // the cursor is drawn with is not in the frame, so a fixed sleep + // here would be a guess with no condition behind it. + const view = browser(rowsOf(3), { maxVisibleRows: 1 }); + + await waitFor(() => view.frame().includes(GRID_MARKER)); + await settleGrid(view.stdin, view.frame); + + view.stdin.write(KEY.down); + await waitFor(() => view.frame().includes('1 more above')); + + view.stdin.write(KEY.enter); + await waitFor(() => view.frame().includes('row 2 of 3')); + + expect(view.frame()).toContain('col_19: tail-1'); + + view.unmount(); + + }); + + it('should leave the grid cursor where the row viewer left it', async () => { + + // Escape has to land on the row the reader was reading, not on the + // one they opened, so the cursor the viewer moved is the same + // cursor the grid draws. Re-opening is how that is read back. + const view = browser(rowsOf(3)); + + await waitFor(() => view.frame().includes(GRID_MARKER)); + await settleGrid(view.stdin, view.frame); + + view.stdin.write(KEY.enter); + await waitFor(() => view.frame().includes('row 1 of 3')); + await settleRowView(view.stdin, view.frame); + + view.stdin.write(KEY.right); + await waitFor(() => view.frame().includes('row 2 of 3')); + + view.stdin.write(KEY.escape); + await waitFor(() => view.frame().includes(GRID_MARKER)); + + view.stdin.write(KEY.enter); + await waitFor(() => view.frame().includes('row 2 of 3')); + + expect(view.frame()).toContain('col_19: tail-1'); + + view.unmount(); + + }); + + it('should stop the row viewer at the end of the list', async () => { + + const view = browser(rowsOf(2)); + + await waitFor(() => view.frame().includes(GRID_MARKER)); + await settleGrid(view.stdin, view.frame); + + view.stdin.write(KEY.enter); + await waitFor(() => view.frame().includes('row 1 of 2')); + await settleRowView(view.stdin, view.frame); + + view.stdin.write(KEY.right); + await waitFor(() => view.frame().includes('row 2 of 2')); + + view.stdin.write(KEY.right); + await waitFor(() => false, 150); + + expect(view.frame()).toContain('row 2 of 2'); + + view.unmount(); + + }); + + it('should keep the grid mounted while a row is open, filter and all', async () => { + + const view = browser(rowsOf(3)); + + await waitFor(() => view.frame().includes(GRID_MARKER)); + await settleGrid(view.stdin, view.frame); + + view.stdin.write(KEY.filter); + await waitFor(() => view.frame().includes('[Tab] Column')); + + view.stdin.write('row-1'); + await waitFor(() => view.frame().includes('filtered from 3')); + + view.stdin.write(KEY.enter); + await waitFor(() => !view.frame().includes('[Tab] Column')); + + // Enter applied the filter rather than opening a row: the filter box + // owns its own Enter. + expect(view.frame()).not.toContain('row 1 of'); + + view.stdin.write(KEY.enter); + await waitFor(() => view.frame().includes('row 1 of 1')); + + // The viewer walks the list as displayed, so the one filtered row is + // the whole list. + expect(view.frame()).toContain('col_19: tail-1'); + + view.stdin.write(KEY.escape); + await waitFor(() => view.frame().includes(GRID_MARKER)); + + expect(view.frame()).toContain('filtered from 3'); + + view.unmount(); + + }); + + it('should close an open row and reset the cursor when a new result arrives', async () => { + + const view = browser(rowsOf(3), { maxVisibleRows: 1 }); + + await waitFor(() => view.frame().includes(GRID_MARKER)); + await settleGrid(view.stdin, view.frame); + + view.stdin.write(KEY.down); + await waitFor(() => view.frame().includes('1 more above')); + + view.stdin.write(KEY.enter); + await waitFor(() => view.frame().includes('row 2 of 3')); + + view.rerender( + + + , + ); + + await waitFor(() => view.frame().includes('4 rows')); + + expect(view.frame()).not.toContain('row 2 of'); + + view.stdin.write(KEY.enter); + await waitFor(() => view.frame().includes('row 1 of 4')); + + view.unmount(); + + }); + + it('should tell the caller when a row opens and when it closes', async () => { + + const states: boolean[] = []; + const view = browser(rowsOf(2), { onRowOpenChange: (open) => states.push(open) }); + + await waitFor(() => view.frame().includes(GRID_MARKER)); + await settleGrid(view.stdin, view.frame); + + view.stdin.write(KEY.enter); + await waitFor(() => view.frame().includes('row 1 of 2')); + await settleRowView(view.stdin, view.frame); + + view.stdin.write(KEY.escape); + await waitFor(() => view.frame().includes(GRID_MARKER)); + + expect(states.filter((state) => state)).toHaveLength(1); + expect(states[states.length - 1]).toBe(false); + + view.unmount(); + + }); + + }); + +}); diff --git a/tests/cli/components/text-input.test.tsx b/tests/cli/components/text-input.test.tsx new file mode 100644 index 00000000..6b62b9ea --- /dev/null +++ b/tests/cli/components/text-input.test.tsx @@ -0,0 +1,317 @@ +/** + * TextInput parity and mouse-report tests. + * + * `src/tui/components/forms/TextInput.tsx` is a copy of `@inkjs/ui`'s + * `TextInput` with one deliberate difference: a mouse report is dropped rather + * than typed into the field. Everything else has to match, because the swap + * reached 11 files and 21 call sites and none of them asked for a keyboard + * change. + * + * "Match" is asserted differentially rather than by hand: the same keystroke + * script is driven through both components and their `onChange` / `onSubmit` + * logs are compared. A hand-written expectation would only pin what the author + * remembered to write down, and would keep passing if both drifted. Rendering + * parity is covered the same way, in a child process at `FORCE_COLOR=1` — + * the suite runs with colour off, where the cursor is an ordinary space and a + * cursor assertion cannot fail. + */ +import { describe, it, expect } from 'bun:test'; +import { render } from 'ink-testing-library'; +import { TextInput as UpstreamTextInput } from '@inkjs/ui'; +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 type { TextInputProps } from '../../../src/tui/components/forms/TextInput.js'; + +const ESC = String.fromCharCode(27); + +const KEYS = { + UP: `${ESC}[A`, + DOWN: `${ESC}[B`, + RIGHT: `${ESC}[C`, + LEFT: `${ESC}[D`, + ENTER: '\r', + TAB: '\t', + BACKSPACE: '\x7F', + DELETE: `${ESC}[3~`, +}; + +/** SGR press report. */ +const press = (row = 5, column = 12) => `${ESC}[<0;${column};${row}M`; + +/** SGR release report — same gesture, lowercase terminator. */ +const release = (row = 5, column = 12) => `${ESC}[<0;${column};${row}m`; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 30)); + +interface Log { + changes: string[]; + submits: string[]; + frames: string[]; +} + +/** + * Drives one component through a keystroke script and returns what it reported. + * + * Takes the component rather than an element so the same script can be run + * against ours and upstream's without the caller repeating the props. + */ +async function drive( + Component: (props: TextInputProps) => React.ReactElement, + props: TextInputProps, + keystrokes: string[], +): Promise { + + const changes: string[] = []; + const submits: string[] = []; + + const { stdin, frames, lastFrame, unmount } = render( + changes.push(value)} + onSubmit={(value) => submits.push(value)} + />, + ); + + await tick(); + + for (const keystroke of keystrokes) { + + stdin.write(keystroke); + await tick(); + + } + + const finalFrame = lastFrame() ?? ''; + + unmount(); + await tick(); + + return { changes, submits, frames: [...frames, finalFrame] }; + +} + +/** + * Runs the script through both implementations and asserts they agree. + * + * Returns ours so a caller can add assertions about the absolute values too — + * parity alone would be satisfied by two components that are both wrong. + */ +async function expectParity( + props: TextInputProps, + keystrokes: string[], +): Promise { + + const ours = await drive(TextInput, props, keystrokes); + const upstream = await drive(UpstreamTextInput, props, keystrokes); + + expect(ours.changes).toEqual(upstream.changes); + expect(ours.submits).toEqual(upstream.submits); + expect(ours.frames.at(-1)).toBe(upstream.frames.at(-1) ?? ''); + + return ours; + +} + +describe('cli: components/TextInput', () => { + + describe('keyboard parity with @inkjs/ui', () => { + + it('should report the same changes for plain typing', async () => { + + const ours = await expectParity({}, ['hello']); + + expect(ours.changes).toEqual(['hello']); + + }); + + it('should insert at the cursor after moving left', async () => { + + const ours = await expectParity({}, ['abc', KEYS.LEFT, KEYS.LEFT, 'X']); + + expect(ours.changes.at(-1)).toBe('aXbc'); + + }); + + it('should erase the character before the cursor on backspace', async () => { + + const ours = await expectParity({}, ['abcd', KEYS.LEFT, KEYS.BACKSPACE]); + + expect(ours.changes.at(-1)).toBe('abd'); + + }); + + it('should erase backwards on the Delete key, the way upstream always has', async () => { + + // tui-development.md tells new code to guard on key.backspace + // alone. This is not new code: 21 call sites have had upstream's + // `key.backspace || key.delete` all along, and changing it here + // would smuggle a keyboard change into a mouse fix. + const ours = await expectParity({}, ['abcd', KEYS.DELETE]); + + expect(ours.changes.at(-1)).toBe('abc'); + + }); + + it('should move the cursor back right', async () => { + + const ours = await expectParity({}, ['abc', KEYS.LEFT, KEYS.LEFT, KEYS.RIGHT, 'X']); + + expect(ours.changes.at(-1)).toBe('abXc'); + + }); + + it('should submit the current value on Enter without changing it', async () => { + + const ours = await expectParity({}, ['hi', KEYS.ENTER]); + + expect(ours.submits).toEqual(['hi']); + expect(ours.changes.at(-1)).toBe('hi'); + + }); + + it('should ignore Tab, the arrows that belong to the parent, and Ctrl+C', async () => { + + const ours = await expectParity({}, ['ab', KEYS.TAB, KEYS.UP, KEYS.DOWN, '\x03']); + + expect(ours.changes).toEqual(['ab']); + + }); + + it('should ignore every keystroke while disabled', async () => { + + const ours = await expectParity( + { isDisabled: true, defaultValue: 'fixed' }, + ['typed', KEYS.BACKSPACE, KEYS.ENTER], + ); + + expect(ours.changes).toEqual([]); + expect(ours.submits).toEqual([]); + + }); + + it('should complete a suggestion on Enter', async () => { + + const ours = await expectParity( + { suggestions: ['alpha', 'beta'] }, + ['al', KEYS.ENTER], + ); + + expect(ours.submits).toEqual(['alpha']); + expect(ours.changes.at(-1)).toBe('alpha'); + + }); + + it('should report nothing on mount', async () => { + + const ours = await expectParity({ defaultValue: 'preset' }, []); + + expect(ours.changes).toEqual([]); + + }); + + it('should start the cursor at the end of the default value', async () => { + + const ours = await expectParity({ defaultValue: 'abc' }, ['Z']); + + expect(ours.changes.at(-1)).toBe('abcZ'); + + }); + + }); + + describe('the one deliberate difference', () => { + + it('should drop a press report instead of typing it into the field', async () => { + + const ours = await drive(TextInput, {}, ['hello', press()]); + const upstream = await drive(UpstreamTextInput, {}, ['hello', press()]); + + expect(ours.changes).toEqual(['hello']); + + // Upstream is what makes this test able to fail: it proves the + // report really does reach a TextInput's handler, so a green + // assertion above is the guard working rather than the report + // never arriving. + expect(upstream.changes.at(-1)).toContain('[<0;12;5M'); + + }); + + it('should drop a release report as well as a press', async () => { + + // One gesture emits both. A guard that only matched `M` would let + // the release through, which is half the damage. + const ours = await drive(TextInput, {}, ['hello', release()]); + const upstream = await drive(UpstreamTextInput, {}, ['hello', release()]); + + expect(ours.changes).toEqual(['hello']); + expect(upstream.changes.at(-1)).toContain('[<0;12;5m'); + + }); + + it('should stay intact across a whole click gesture and keep taking input after it', async () => { + + const ours = await drive( + TextInput, + {}, + ['hello', press(), release(), press(), release(), ' world'], + ); + + expect(ours.changes.at(-1)).toBe('hello world'); + expect(ours.frames.at(-1)).not.toContain('[<'); + + }); + + it('should not move the cursor when a report lands mid-string', async () => { + + const ours = await drive( + TextInput, + {}, + ['abc', KEYS.LEFT, press(), release(), 'X'], + ); + + expect(ours.changes.at(-1)).toBe('abXc'); + + }); + + }); + + describe('rendering', () => { + + it('should draw the cursor, placeholder and suggestion exactly as upstream does', () => { + + // In a child process, because chalk decides whether to emit SGR at + // import time from the environment. The suite runs at + // FORCE_COLOR=false, where `inverse` renders as a plain space and + // a cursor assertion cannot fail. + const fixture = join(import.meta.dir, '..', 'fixtures', 'text-input-render-parity.tsx'); + const child = spawnSync('bun', ['run', fixture], { + encoding: 'utf8', + env: { ...process.env, FORCE_COLOR: '1' }, + }); + + expect(child.stderr).toBe(''); + expect(child.status).toBe(0); + + const cases = JSON.parse(child.stdout); + + expect(Object.keys(cases).length).toBeGreaterThan(0); + + for (const [name, pair] of Object.entries<{ ours: string; upstream: string }>(cases)) { + + // Both halves matter: identical output is only meaningful if + // the escape codes are actually being emitted. + expect(`${name}: ${pair.ours}`).toBe(`${name}: ${pair.upstream}`); + + } + + expect(cases['cursor at end'].ours).toContain(`${ESC}[7m`); + expect(cases['placeholder'].ours).toContain(`${ESC}[2m`); + + }); + + }); + +}); diff --git a/tests/cli/fixtures/mouse-sigint-restore.ts b/tests/cli/fixtures/mouse-sigint-restore.ts new file mode 100644 index 00000000..6d652554 --- /dev/null +++ b/tests/cli/fixtures/mouse-sigint-restore.ts @@ -0,0 +1,32 @@ +/** + * Child process for the "SIGINT with no other listener" case. + * + * The transport has to restore the terminal on a signal, and it has to do that + * without becoming the reason the signal stopped killing the process. Both + * halves need a process that is allowed to die, which rules out asserting them + * inside the test runner. + * + * Writes through `writeSync` rather than `process.stdout.write`: stdout is a + * pipe here, and Node's pipe writes are asynchronous on POSIX, so a buffered + * write would be lost when the re-raised signal ends the process. + */ +import { writeSync } from 'node:fs'; + +import { installTerminalRestore, MOUSE_DISABLE } from '../../../src/tui/mouse.js'; + +installTerminalRestore(() => { + + writeSync(1, MOUSE_DISABLE); + +}); + +process.kill(process.pid, 'SIGINT'); + +// Reached only if the transport swallowed the signal. The exit code separates +// that failure from a clean signal death in the parent's assertion. +setTimeout(() => { + + writeSync(1, 'SIGNAL-WAS-SWALLOWED'); + process.exit(7); + +}, 2000); diff --git a/tests/cli/fixtures/text-input-render-parity.tsx b/tests/cli/fixtures/text-input-render-parity.tsx new file mode 100644 index 00000000..7e2713b1 --- /dev/null +++ b/tests/cli/fixtures/text-input-render-parity.tsx @@ -0,0 +1,76 @@ +/** + * Child process for the TextInput rendering-parity case. + * + * `chalk` fixes its colour level when it is imported, and the suite runs at + * `FORCE_COLOR=false`, so inside the runner `inverse` and `dim` emit nothing + * and a cursor assertion has nothing to assert on. Here the parent spawns with + * `FORCE_COLOR=1`, so the escape codes are real and the two implementations can + * be compared byte for byte. + * + * Prints one JSON object of `{ case: { ours, upstream } }` on stdout. + */ +import { render } from 'ink-testing-library'; +import { TextInput as UpstreamTextInput } from '@inkjs/ui'; +import React from 'react'; + +import { TextInput } from '../../../src/tui/components/forms/TextInput.js'; +import type { TextInputProps } from '../../../src/tui/components/forms/TextInput.js'; + +const ESC = String.fromCharCode(27); +const LEFT = `${ESC}[D`; + +const tick = () => new Promise((resolve) => setTimeout(resolve, 30)); + +const CASES: Record = { + 'cursor at end': { props: { defaultValue: 'abc' }, keystrokes: [] }, + 'cursor mid-string': { props: { defaultValue: 'abc' }, keystrokes: [LEFT, LEFT] }, + 'cursor on the last character': { props: { defaultValue: 'abc' }, keystrokes: [LEFT] }, + 'placeholder': { props: { placeholder: 'name' }, keystrokes: [] }, + 'placeholder while disabled': { props: { placeholder: 'name', isDisabled: true }, keystrokes: [] }, + 'empty with no placeholder': { props: {}, keystrokes: [] }, + 'empty with no placeholder while disabled': { props: { isDisabled: true }, keystrokes: [] }, + 'value while disabled': { props: { defaultValue: 'abc', isDisabled: true }, keystrokes: [] }, + 'suggestion at the end': { props: { defaultValue: 'al', suggestions: ['alpha'] }, keystrokes: [] }, + 'suggestion with the cursor inside the value': { + props: { defaultValue: 'al', suggestions: ['alpha'] }, + keystrokes: [LEFT], + }, +}; + +async function frameOf( + Component: (props: TextInputProps) => React.ReactElement, + props: TextInputProps, + keystrokes: string[], +): Promise { + + const { stdin, lastFrame, unmount } = render(); + + await tick(); + + for (const keystroke of keystrokes) { + + stdin.write(keystroke); + await tick(); + + } + + const frame = lastFrame() ?? ''; + + unmount(); + + return frame; + +} + +const results: Record = {}; + +for (const [name, { props, keystrokes }] of Object.entries(CASES)) { + + results[name] = { + ours: await frameOf(TextInput, props, keystrokes), + upstream: await frameOf(UpstreamTextInput, props, keystrokes), + }; + +} + +process.stdout.write(JSON.stringify(results)); diff --git a/tests/cli/focus.test.tsx b/tests/cli/focus.test.tsx index 946d22c7..2ee52c1c 100644 --- a/tests/cli/focus.test.tsx +++ b/tests/cli/focus.test.tsx @@ -122,10 +122,12 @@ describe('cli: focus', () => { }); - // In React 19, errors during render are caught and logged - // Check that the render fails with an error - const { lastFrame } = render(); - const output = lastFrame() ?? ''; + // In React 19, errors during render are caught and logged. + // Ink 7 paints its error overview and then writes a cleared frame + // as it unmounts, so the message is in `frames` but never in + // `lastFrame()`. Search every frame. + const { frames } = render(); + const output = frames.join(''); // Error may appear in rendered output or in console.error const hasErrorInOutput = output.includes('useFocusContext must be used within a FocusProvider'); diff --git a/tests/cli/hooks/useAbortableTask.test.tsx b/tests/cli/hooks/useAbortableTask.test.tsx new file mode 100644 index 00000000..8523e26c --- /dev/null +++ b/tests/cli/hooks/useAbortableTask.test.tsx @@ -0,0 +1,167 @@ +/** + * useAbortableTask tests. + * + * The intent: a result that arrives after its operation was cancelled or + * replaced must be recognisable as stale. That is the difference between an + * escape hatch and a screen that silently un-cancels itself a minute later. + */ +import { describe, it, expect } from 'bun:test'; +import { render } from 'ink-testing-library'; +import { Text } from 'ink'; +import React from 'react'; + +import { useAbortableTask, type AbortableTask } from '../../../src/tui/hooks/useAbortableTask.js'; + +/** + * Hands the hook's handle out to the test, since ink-testing-library renders + * components rather than hooks. + */ +function Probe({ onReady }: { onReady: (task: AbortableTask) => void }) { + + const task = useAbortableTask(); + + onReady(task); + + return probe; + +} + +/** + * Render the probe and return the live handle plus the unmount function. + */ +function mountTask() { + + const captured: AbortableTask[] = []; + + const { unmount } = render( captured.push(t)} />); + + const task = captured[0]; + + if (!task) throw new Error('probe did not render'); + + return { task, unmount }; + +} + +describe('cli: useAbortableTask', () => { + + it('should treat a freshly started operation as current', () => { + + const { task, unmount } = mountTask(); + + const controller = task.start(); + + expect(task.isCurrent(controller)).toBe(true); + expect(controller.signal.aborted).toBe(false); + + unmount(); + + }); + + it('should abort the live operation and report that it did', () => { + + const { task, unmount } = mountTask(); + + const controller = task.start(); + + expect(task.cancel()).toBe(true); + expect(controller.signal.aborted).toBe(true); + + unmount(); + + }); + + it('should report nothing to cancel when no operation is running', () => { + + const { task, unmount } = mountTask(); + + expect(task.cancel()).toBe(false); + + unmount(); + + }); + + it('should report nothing to cancel twice for one operation', () => { + + const { task, unmount } = mountTask(); + + task.start(); + + expect(task.cancel()).toBe(true); + expect(task.cancel()).toBe(false); + + unmount(); + + }); + + it('should stop treating a cancelled operation as current, even though it is still the latest', () => { + + const { task, unmount } = mountTask(); + + const controller = task.start(); + task.cancel(); + + // The driver is free to answer anyway. This is the check that keeps + // that answer off the screen. + expect(task.isCurrent(controller)).toBe(false); + + unmount(); + + }); + + it('should stop treating a superseded operation as current', () => { + + const { task, unmount } = mountTask(); + + const first = task.start(); + const second = task.start(); + + expect(task.isCurrent(first)).toBe(false); + expect(task.isCurrent(second)).toBe(true); + + unmount(); + + }); + + it('should not treat a controller it never issued as current', () => { + + const { task, unmount } = mountTask(); + + task.start(); + + // Identity, not just liveness: a live controller from somewhere else is + // not this screen's operation, and answering to it would let one + // screen's result write over another's. + expect(task.isCurrent(new AbortController())).toBe(false); + + unmount(); + + }); + + it('should abort the operation it replaces, so the abandoned work is released', () => { + + const { task, unmount } = mountTask(); + + const first = task.start(); + task.start(); + + expect(first.signal.aborted).toBe(true); + + unmount(); + + }); + + it('should abort on unmount rather than leaving the operation running', () => { + + const { task, unmount } = mountTask(); + + const controller = task.start(); + + unmount(); + + expect(controller.signal.aborted).toBe(true); + expect(task.isCurrent(controller)).toBe(false); + + }); + +}); diff --git a/tests/cli/hooks/useViewportRows.test.tsx b/tests/cli/hooks/useViewportRows.test.tsx new file mode 100644 index 00000000..771226db --- /dev/null +++ b/tests/cli/hooks/useViewportRows.test.tsx @@ -0,0 +1,212 @@ +/** + * Viewport sizing tests. + * + * Every list in the TUI used to carry a hardcoded `visibleCount`, so a 60-row + * terminal drew the same handful of rows as a 24-row one and the rest of the + * list was unreachable. These tests pin the replacement: the row budget comes + * from the terminal, grows when the terminal is taller, and still leaves a + * usable list when the terminal is tiny. + * + * `useWindowSize` reads the render stream first and falls back to the + * `terminal-size` probe, which reads `process.stdout`. ink-testing-library's + * stream reports `columns` but no `rows`, so the probe is what decides height + * here — and left alone it would report whatever terminal the suite happens to + * run in. Pinning both `process.stdout` dimensions is what makes these + * assertions the same number on a laptop and in CI. + */ +import { describe, it, expect, afterEach } from 'bun:test'; +import { render } from 'ink-testing-library'; +import React from 'react'; + +import { FocusProvider } from '../../../src/tui/focus.js'; +import { SelectList, SearchableList } from '../../../src/tui/components/lists/index.js'; +import { FilePicker } from '../../../src/tui/components/dialogs/index.js'; +import { + viewportRows, + SCREEN_CHROME_ROWS, + MIN_VIEWPORT_ROWS, +} from '../../../src/tui/hooks/useViewportRows.js'; + +/** Property descriptors to put back, so a pinned size cannot leak to the next file. */ +const originalStdout = { + columns: Object.getOwnPropertyDescriptor(process.stdout, 'columns'), + rows: Object.getOwnPropertyDescriptor(process.stdout, 'rows'), +}; + +/** + * Pin the terminal the next render will see. + * + * Both dimensions have to be set: `terminal-size` only trusts `process.stdout` + * when `columns` and `rows` are both truthy, and falls through to a `tput` + * probe otherwise. + */ +function pinTerminal(rows: number, columns = 100) { + + Object.defineProperty(process.stdout, 'columns', { value: columns, configurable: true }); + Object.defineProperty(process.stdout, 'rows', { value: rows, configurable: true }); + +} + +/** Items labelled so a rendered row is countable without matching on chrome. */ +function items(count: number) { + + return Array.from({ length: count }, (_, index) => ({ + key: `k${index}`, + label: `row-${index}`, + value: index, + })); + +} + +/** Rendered rows that carry an item label, ignoring borders and scroll hints. */ +function renderedRows(frame: string | undefined): number { + + return (frame ?? '').split('\n').filter((line) => line.includes('row-')).length; + +} + +/** Render a tree at a pinned terminal height and report the rows it drew. */ +function rowsAt(height: number, tree: React.ReactElement): number { + + pinTerminal(height); + + const { lastFrame, unmount } = render({tree}); + const count = renderedRows(lastFrame()); + + unmount(); + + return count; + +} + +describe('cli: useViewportRows', () => { + + afterEach(() => { + + for (const [key, descriptor] of Object.entries(originalStdout)) { + + if (descriptor) { + + Object.defineProperty(process.stdout, key, descriptor); + + } + else { + + Reflect.deleteProperty(process.stdout, key); + + } + + } + + }); + + describe('viewportRows', () => { + + it('should hand the terminal over once the screen chrome is paid for', () => { + + expect(viewportRows(40)).toBe(40 - SCREEN_CHROME_ROWS); + + }); + + it('should charge the caller for rows the screen spends elsewhere', () => { + + expect(viewportRows(40, 6)).toBe(40 - SCREEN_CHROME_ROWS - 6); + + }); + + it('should never go below the floor, or negative', () => { + + expect(viewportRows(10)).toBe(MIN_VIEWPORT_ROWS); + expect(viewportRows(1)).toBe(MIN_VIEWPORT_ROWS); + expect(viewportRows(24, 100)).toBe(MIN_VIEWPORT_ROWS); + + }); + + }); + + describe('SelectList', () => { + + it('should draw more rows on a tall terminal than a short one', () => { + + const list = ; + + expect(rowsAt(60, list)).toBeGreaterThan(rowsAt(24, list)); + + }); + + it('should keep a usable list on a terminal with no room left', () => { + + expect(rowsAt(6, )).toBeGreaterThanOrEqual(5); + + }); + + it('should stop at the number of items it was given', () => { + + expect(rowsAt(60, )).toBe(4); + + }); + + }); + + describe('SearchableList', () => { + + it('should draw more rows on a tall terminal than a short one', () => { + + const list = ; + + expect(rowsAt(60, list)).toBeGreaterThan(rowsAt(24, list)); + + }); + + it('should leave room for its own search and hint rows', () => { + + const plain = rowsAt(40, ); + const searchable = rowsAt(40, ); + + expect(searchable).toBeLessThan(plain); + + }); + + }); + + describe('FilePicker', () => { + + it('should draw more rows on a tall terminal than a short one', () => { + + const files = Array.from({ length: 60 }, (_, index) => `row-${index}.sql`); + const picker = {}} onCancel={() => {}} />; + + expect(rowsAt(60, picker)).toBeGreaterThan(rowsAt(24, picker)); + + }); + + }); + + describe('resize', () => { + + it('should regrow the list when the terminal is resized taller', async () => { + + pinTerminal(24); + + const { stdout, lastFrame, unmount } = render( + , + ); + + const before = renderedRows(lastFrame()); + + pinTerminal(60); + stdout.emit('resize'); + + await new Promise((resolve) => setTimeout(resolve, 30)); + + const after = renderedRows(lastFrame()); + + unmount(); + + expect(after).toBeGreaterThan(before); + + }); + + }); + +}); diff --git a/tests/cli/mouse.test.tsx b/tests/cli/mouse.test.tsx new file mode 100644 index 00000000..5db13399 --- /dev/null +++ b/tests/cli/mouse.test.tsx @@ -0,0 +1,1079 @@ +/** + * Mouse transport tests. + * + * Ink 7.1.1 ships no mouse support, so everything here is ours: the escape + * sequences that turn tracking on and off, the SGR report parser, the terminal + * restore, and the two components that answer a click. + * + * What is pinned here: + * + * - **The flag off means nothing happens.** Not "nothing visible" — no escape + * sequence on the wire, no `process` listener, and an SGR report typed at a + * list moves no cursor. That is the whole reason the feature ships behind a + * flag, so it gets a test rather than a claim. + * - **The terminal is restored on every path that can end the process.** A + * process that dies still holding `?1000h` leaves click-drag selection broken + * in every shell in that window, not just noorm's. + * - **Coordinates are converted, not assumed.** SGR is 1-based against the + * terminal; `measureElement` is 0-based against the live region. The row a + * click lands on is read out of the rendered frame here rather than counted + * by hand, so an off-by-one in either direction fails. + * + * The suite runs at `FORCE_COLOR=0`, so where a `ResultTable` cursor sits + * cannot be read from the `inverse` attribute. Those assertions go through the + * `onHighlightChange` / `onSelect` callbacks instead. `SelectList` draws a + * literal `❯`, which survives. + */ +import { describe, it, expect, afterEach } from 'bun:test'; +import { render } from 'ink-testing-library'; +import { Text } from 'ink'; +import React from 'react'; +import { spawnSync } from 'node:child_process'; +import { join } from 'node:path'; + +import { FocusProvider } from '../../src/tui/focus.js'; +import { + MOUSE_ENABLE, + MOUSE_DISABLE, + MouseProvider, + parseMouseReport, + isMouseReport, + installTerminalRestore, + useRowMouse, + DOUBLE_CLICK_MS, +} from '../../src/tui/mouse.js'; +import { SelectList } from '../../src/tui/components/lists/index.js'; +import { ResultTable, SqlInput } from '../../src/tui/components/terminal/index.js'; +import { Form } from '../../src/tui/components/forms/index.js'; +import type { FormValues } from '../../src/tui/components/forms/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 release report — same shape, lowercase terminator. */ +const release = (row: number, column = 1, button = 0) => `${ESC}[<${button};${column};${row}m`; + +const WHEEL_UP = 64; +const WHEEL_DOWN = 65; + +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. + * + * `ink-testing-library`'s stdout collects every write, and the transport writes + * escape sequences through the same stream, so `lastFrame()` can be the enable + * sequence rather than the screen. Dropping those two writes leaves the frames. + */ +const uiFrames = (frames: string[]) => frames.filter((f) => f !== MOUSE_ENABLE && f !== MOUSE_DISABLE); + +const lastUi = (frames: string[]) => uiFrames(frames).at(-1) ?? ''; + +/** + * Which terminal row a piece of rendered text landed on, 1-based. + * + * Read out of the frame rather than counted by hand: that is what makes an + * off-by-one in the coordinate conversion fail here instead of on someone's + * terminal. + */ +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; + +} + +function Harness({ children, mouse = true }: { children: React.ReactNode; mouse?: boolean }) { + + return ( + + {children} + + ); + +} + +const ITEMS = [ + { key: 'a', label: 'alpha', value: 'alpha' }, + { key: 'b', label: 'bravo', value: 'bravo' }, + { key: 'c', label: 'charlie', value: 'charlie' }, + { key: 'd', label: 'delta', value: 'delta' }, +]; + +const COLUMNS = ['id', 'name']; + +const ROWS = [ + { id: 1, name: 'ann' }, + { id: 2, name: 'bob' }, + { id: 3, name: 'cyd' }, + { id: 4, name: 'dee' }, +]; + +describe('cli: mouse transport', () => { + + describe('parseMouseReport', () => { + + it('should convert 1-based terminal coordinates to 0-based live-region coordinates', () => { + + const event = parseMouseReport(press(5, 12)); + + expect(event).toEqual({ + kind: 'press', + button: 'left', + row: 4, + column: 11, + shift: false, + alt: false, + ctrl: false, + }); + + }); + + it('should read the report Ink hands to useInput, which has the escape byte stripped', () => { + + const withEscape = parseMouseReport(press(5, 12)); + const asUseInputDelivers = parseMouseReport('[<0;12;5M'); + + expect(asUseInputDelivers).toEqual(withEscape); + + }); + + it('should distinguish press from release by the terminator', () => { + + expect(parseMouseReport(press(3))?.kind).toBe('press'); + expect(parseMouseReport(release(3))?.kind).toBe('release'); + + }); + + it('should decode the three buttons', () => { + + expect(parseMouseReport(press(1, 1, 0))?.button).toBe('left'); + expect(parseMouseReport(press(1, 1, 1))?.button).toBe('middle'); + expect(parseMouseReport(press(1, 1, 2))?.button).toBe('right'); + + }); + + it('should decode wheel notches, which arrive even in press-only mode', () => { + + expect(parseMouseReport(press(1, 1, WHEEL_UP))?.button).toBe('wheel-up'); + expect(parseMouseReport(press(1, 1, WHEEL_DOWN))?.button).toBe('wheel-down'); + + }); + + it('should decode modifier bits', () => { + + expect(parseMouseReport(press(1, 1, 0 + 4))).toMatchObject({ button: 'left', shift: true }); + expect(parseMouseReport(press(1, 1, 0 + 8))).toMatchObject({ button: 'left', alt: true }); + expect(parseMouseReport(press(1, 1, 0 + 16))).toMatchObject({ button: 'left', ctrl: true }); + + }); + + it('should drop motion reports, which press-only tracking never asked for', () => { + + expect(parseMouseReport(press(1, 1, 32))).toBeNull(); + expect(parseMouseReport(press(1, 1, 32 + 2))).toBeNull(); + + }); + + it('should return null for anything that is not an SGR report', () => { + + expect(parseMouseReport('a')).toBeNull(); + expect(parseMouseReport(`${ESC}[B`)).toBeNull(); + expect(parseMouseReport(`${ESC}[<0;12M`)).toBeNull(); + expect(parseMouseReport(`${ESC}[<0;12;5X`)).toBeNull(); + expect(parseMouseReport('')).toBeNull(); + + }); + + 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(); + + expect(isMouseReport('/')).toBe(false); + expect(isMouseReport(`${ESC}[B`)).toBe(false); + + }); + + }); + + describe('escape sequences', () => { + + it('should enable press-and-release tracking with SGR coordinates and nothing stronger', () => { + + expect(MOUSE_ENABLE).toBe(`${ESC}[?1000h${ESC}[?1006h`); + + // ?1002 (drag) and ?1003 (motion) take the mouse away from the + // terminal far more completely than this feature needs. + 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`); + + }); + + }); + + describe('terminal restore', () => { + + it('should write the enable sequence on mount and the disable sequence on unmount', async () => { + + const { frames, unmount } = render( + + + , + ); + + await waitFor(() => frames.includes(MOUSE_ENABLE)); + + expect(frames).toContain(MOUSE_ENABLE); + expect(frames).not.toContain(MOUSE_DISABLE); + + unmount(); + await tick(); + + expect(frames).toContain(MOUSE_DISABLE); + + }); + + it('should restore on process exit, and stop once uninstalled', () => { + + // Asserted against `installTerminalRestore` rather than against a + // rendered provider, because `process.emit('exit')` tears an + // ink-testing-library render down: the unmount cleanup would write + // the disable sequence even with the exit handler gone, and the + // test would pass under exactly the bug it exists to catch. + // Measured, not assumed — tmp/probe-emit-exit.tsx. + // + // The provider's half of the chain is the listener-count test + // below, which pins that it registers one exit listener and takes + // it away again. + const restores: string[] = []; + const uninstall = installTerminalRestore(() => restores.push(MOUSE_DISABLE)); + + process.emit('exit', 0); + + expect(restores).toEqual([MOUSE_DISABLE]); + + uninstall(); + process.emit('exit', 0); + + expect(restores).toEqual([MOUSE_DISABLE]); + + }); + + it('should restore on SIGINT and SIGTERM', async () => { + + // A standing listener stands in for the lifecycle manager's, which + // the TUI always has. Without one the transport re-raises the signal + // and the test runner dies — which is the point of the child-process + // test below. + const standIn = () => undefined; + process.on('SIGINT', standIn); + process.on('SIGTERM', standIn); + + const { frames, unmount } = render( + + + , + ); + + await waitFor(() => frames.includes(MOUSE_ENABLE)); + + process.emit('SIGINT', 'SIGINT'); + expect(frames.filter((f) => f === MOUSE_DISABLE)).toHaveLength(1); + + process.emit('SIGTERM', 'SIGTERM'); + expect(frames.filter((f) => f === MOUSE_DISABLE)).toHaveLength(2); + + process.removeListener('SIGINT', standIn); + process.removeListener('SIGTERM', standIn); + + unmount(); + await tick(); + + }); + + it('should restore and still die on a SIGINT it is the only listener for', () => { + + const fixture = join(import.meta.dir, 'fixtures', 'mouse-sigint-restore.ts'); + const child = spawnSync('bun', ['run', fixture], { encoding: 'utf8' }); + + // Restored before the signal was allowed through... + expect(child.stdout).toContain(MOUSE_DISABLE); + + // ...and the signal still ended the process, rather than the + // transport's listener swallowing it and leaving the app wedged. + expect(child.signal).toBe('SIGINT'); + expect(child.status).toBeNull(); + + }); + + it('should leave no process listeners behind after unmount', async () => { + + const before = { + exit: process.listenerCount('exit'), + sigint: process.listenerCount('SIGINT'), + sigterm: process.listenerCount('SIGTERM'), + sighup: process.listenerCount('SIGHUP'), + }; + + const { frames, unmount } = render( + + + , + ); + + await waitFor(() => frames.includes(MOUSE_ENABLE)); + + expect(process.listenerCount('exit')).toBe(before.exit + 1); + + unmount(); + await tick(); + + expect(process.listenerCount('exit')).toBe(before.exit); + expect(process.listenerCount('SIGINT')).toBe(before.sigint); + expect(process.listenerCount('SIGTERM')).toBe(before.sigterm); + expect(process.listenerCount('SIGHUP')).toBe(before.sighup); + + }); + + }); + + describe('the flag off', () => { + + it('should write no escape sequence and register no process listener', async () => { + + const before = process.listenerCount('exit'); + + const { frames, unmount } = render( + + + , + ); + + await waitFor(() => lastUi(frames).includes('alpha')); + + expect(frames).not.toContain(MOUSE_ENABLE); + expect(frames).not.toContain(MOUSE_DISABLE); + expect(process.listenerCount('exit')).toBe(before); + + unmount(); + await tick(); + + expect(frames).not.toContain(MOUSE_DISABLE); + + }); + + it('should hand out no row refs, so rows are not tracked at all', async () => { + + const seen: (undefined | 'function')[] = []; + + function Probe() { + + const { enabled, rowRef } = useRowMouse({ + isActive: true, + onClick: () => undefined, + onActivate: () => undefined, + onWheel: () => undefined, + }); + + seen.push(typeof rowRef(0) === 'function' ? 'function' : undefined); + + return enabled:{String(enabled)}; + + } + + const off = render( + + + , + ); + + await waitFor(() => lastUi(off.frames).includes('enabled:')); + + expect(lastUi(off.frames)).toContain('enabled:false'); + expect(seen.at(-1)).toBeUndefined(); + + off.unmount(); + await tick(); + + const on = render( + + + , + ); + + await waitFor(() => lastUi(on.frames).includes('enabled:true')); + + expect(seen.at(-1)).toBe('function'); + + on.unmount(); + await tick(); + + }); + + it('should leave a click inert in a list that would answer it when on', async () => { + + const selected: string[] = []; + + const { frames, stdin, unmount } = render( + + selected.push(item.key)} /> + , + ); + + await waitFor(() => lastUi(frames).includes('alpha')); + await tick(); + + const row = terminalRowOf(lastUi(frames), 'charlie'); + const frameBefore = lastUi(frames); + + stdin.write(press(row)); + await tick(); + stdin.write(press(row)); + await tick(); + + expect(lastUi(frames)).toBe(frameBefore); + expect(lastUi(frames)).toContain('❯ alpha'); + expect(selected).toEqual([]); + + unmount(); + + }); + + }); + + describe('SelectList', () => { + + it('should move the cursor to the row that was clicked', async () => { + + const { frames, stdin, unmount } = render( + + + , + ); + + await waitFor(() => lastUi(frames).includes('alpha')); + await tick(); + + expect(lastUi(frames)).toContain('❯ alpha'); + + stdin.write(press(terminalRowOf(lastUi(frames), 'charlie'))); + await waitFor(() => lastUi(frames).includes('❯ charlie')); + + expect(lastUi(frames)).toContain('❯ charlie'); + expect(lastUi(frames)).not.toContain('❯ alpha'); + + unmount(); + + }); + + it('should activate the row on a second click inside the double-click window', async () => { + + const selected: string[] = []; + + const { frames, stdin, unmount } = render( + + selected.push(item.key)} /> + , + ); + + await waitFor(() => lastUi(frames).includes('alpha')); + await tick(); + + const row = terminalRowOf(lastUi(frames), 'bravo'); + + stdin.write(press(row)); + await waitFor(() => lastUi(frames).includes('❯ bravo')); + + expect(selected).toEqual([]); + + stdin.write(press(row)); + await waitFor(() => selected.length > 0); + + expect(selected).toEqual(['b']); + + unmount(); + + }); + + it('should not activate when the second click falls outside the window', async () => { + + const selected: string[] = []; + + const { frames, stdin, unmount } = render( + + selected.push(item.key)} /> + , + ); + + await waitFor(() => lastUi(frames).includes('alpha')); + await tick(); + + const row = terminalRowOf(lastUi(frames), 'bravo'); + + stdin.write(press(row)); + await waitFor(() => lastUi(frames).includes('❯ bravo')); + + await new Promise((resolve) => setTimeout(resolve, DOUBLE_CLICK_MS + 60)); + + stdin.write(press(row)); + await tick(); + + expect(selected).toEqual([]); + expect(lastUi(frames)).toContain('❯ bravo'); + + unmount(); + + }); + + it('should not activate twice when a third click follows the double', async () => { + + const selected: string[] = []; + + const { frames, stdin, unmount } = render( + + selected.push(item.key)} /> + , + ); + + await waitFor(() => lastUi(frames).includes('alpha')); + await tick(); + + const row = terminalRowOf(lastUi(frames), 'delta'); + + stdin.write(press(row)); + await waitFor(() => lastUi(frames).includes('❯ delta')); + + stdin.write(press(row)); + await waitFor(() => selected.length > 0); + + // A triple click is a double followed by a fresh single, not two + // doubles — otherwise every extra click reopens the row. + stdin.write(press(row)); + await tick(); + + expect(selected).toEqual(['d']); + + unmount(); + + }); + + it('should not activate when the two clicks land on different rows', async () => { + + const selected: string[] = []; + + const { frames, stdin, unmount } = render( + + selected.push(item.key)} /> + , + ); + + await waitFor(() => lastUi(frames).includes('alpha')); + await tick(); + + stdin.write(press(terminalRowOf(lastUi(frames), 'bravo'))); + await waitFor(() => lastUi(frames).includes('❯ bravo')); + + stdin.write(press(terminalRowOf(lastUi(frames), 'charlie'))); + await waitFor(() => lastUi(frames).includes('❯ charlie')); + + expect(selected).toEqual([]); + + unmount(); + + }); + + it('should ignore a release report, so one click is one action', async () => { + + const selected: string[] = []; + + const { frames, stdin, unmount } = render( + + selected.push(item.key)} /> + , + ); + + await waitFor(() => lastUi(frames).includes('alpha')); + await tick(); + + const row = terminalRowOf(lastUi(frames), 'bravo'); + + stdin.write(press(row)); + await waitFor(() => lastUi(frames).includes('❯ bravo')); + + stdin.write(release(row)); + await tick(); + + expect(selected).toEqual([]); + + unmount(); + + }); + + it('should ignore a click on a row that is not there', async () => { + + const { frames, stdin, unmount } = render( + + + , + ); + + await waitFor(() => lastUi(frames).includes('alpha')); + await tick(); + + const frameBefore = lastUi(frames); + + stdin.write(press(40)); + await tick(); + + expect(lastUi(frames)).toBe(frameBefore); + expect(lastUi(frames)).toContain('❯ alpha'); + + unmount(); + + }); + + it('should step the cursor one row per wheel notch and clamp at the ends', async () => { + + const { frames, stdin, unmount } = render( + + + , + ); + + await waitFor(() => lastUi(frames).includes('alpha')); + await tick(); + + // Up at the top clamps rather than wrapping to the bottom, which is + // what the arrow keys do — a wheel that wraps reads as a glitch. + stdin.write(press(1, 1, WHEEL_UP)); + await tick(); + + expect(lastUi(frames)).toContain('❯ alpha'); + + stdin.write(press(1, 1, WHEEL_DOWN)); + await waitFor(() => lastUi(frames).includes('❯ bravo')); + + expect(lastUi(frames)).toContain('❯ bravo'); + + stdin.write(press(1, 1, WHEEL_UP)); + await waitFor(() => lastUi(frames).includes('❯ alpha')); + + expect(lastUi(frames)).toContain('❯ alpha'); + + unmount(); + + }); + + it('should not answer a click while another component holds focus', async () => { + + const selected: string[] = []; + + const { frames, stdin, unmount } = render( + + selected.push(item.key)} + /> + , + ); + + await waitFor(() => lastUi(frames).includes('alpha')); + await tick(); + + const row = terminalRowOf(lastUi(frames), 'charlie'); + const frameBefore = lastUi(frames); + + stdin.write(press(row)); + await tick(); + stdin.write(press(row)); + await tick(); + + expect(lastUi(frames)).toBe(frameBefore); + expect(selected).toEqual([]); + + unmount(); + + }); + + it('should toggle rather than submit on a double click in multi-select', async () => { + + const toggled: string[] = []; + const submitted: number[] = []; + + const { frames, stdin, unmount } = render( + + toggled.push(item.key)} + onSubmit={() => submitted.push(1)} + /> + , + ); + + await waitFor(() => lastUi(frames).includes('alpha')); + await tick(); + + const row = terminalRowOf(lastUi(frames), 'charlie'); + + stdin.write(press(row)); + await waitFor(() => lastUi(frames).includes('❯ charlie')); + + stdin.write(press(row)); + await waitFor(() => toggled.length > 0); + + // Enter in multi-select submits the whole list, which is not a + // row-scoped action; a double click names one row, so it does the + // row-scoped thing that Space does. + expect(toggled).toEqual(['c']); + expect(submitted).toEqual([]); + + unmount(); + + }); + + }); + + describe('ResultTable', () => { + + it('should move the cursor to the row that was clicked', async () => { + + const moves: number[] = []; + + const { frames, stdin, unmount } = render( + + moves.push(index)} + /> + , + ); + + await waitFor(() => lastUi(frames).includes('cyd')); + await tick(); + + // The table reports a move to row 0 as it mounts, so a wait on + // "any move at all" would return before the click was answered. + const settled = moves.length; + + stdin.write(press(terminalRowOf(lastUi(frames), 'cyd'))); + await waitFor(() => moves.length > settled); + + expect(moves.at(-1)).toBe(2); + + unmount(); + + }); + + it('should open the row on a double click', async () => { + + const opened: Record[] = []; + + const { frames, stdin, unmount } = render( + + opened.push(row)} + /> + , + ); + + await waitFor(() => lastUi(frames).includes('dee')); + await tick(); + + const row = terminalRowOf(lastUi(frames), 'dee'); + + stdin.write(press(row)); + await tick(); + + expect(opened).toEqual([]); + + stdin.write(press(row)); + await waitFor(() => opened.length > 0); + + expect(opened).toEqual([{ id: 4, name: 'dee' }]); + + unmount(); + + }); + + it('should open the row the sort put there, not the row that was passed in', async () => { + + const opened: Record[] = []; + + const { frames, stdin, unmount } = render( + + opened.push(row)} + /> + , + ); + + // autoSort defaults on and `id` sorts descending, so the top data + // row is id 4. A hit test that indexed the input array would open + // id 1 here. + await waitFor(() => lastUi(frames).includes('dee')); + await tick(); + + const row = terminalRowOf(lastUi(frames), 'ann'); + + stdin.write(press(row)); + await tick(); + stdin.write(press(row)); + await waitFor(() => opened.length > 0); + + expect(opened).toEqual([{ id: 1, name: 'ann' }]); + + unmount(); + + }); + + it('should not answer a click while it is inactive', async () => { + + const moves: number[] = []; + const opened: Record[] = []; + + const { frames, stdin, unmount } = render( + + moves.push(index)} + onSelect={(row) => opened.push(row)} + /> + , + ); + + await waitFor(() => lastUi(frames).includes('cyd')); + await tick(); + + const row = terminalRowOf(lastUi(frames), 'cyd'); + const settled = [...moves]; + + stdin.write(press(row)); + await tick(); + stdin.write(press(row)); + await tick(); + + expect(moves).toEqual(settled); + expect(opened).toEqual([]); + + unmount(); + + }); + + it('should neither type the report into the filter box nor move the cursor behind it', async () => { + + const moves: number[] = []; + + const { frames, stdin, unmount } = render( + + moves.push(index)} + /> + , + ); + + await waitFor(() => lastUi(frames).includes('cyd')); + await tick(); + + stdin.write('/'); + await waitFor(() => lastUi(frames).includes('[Enter] Apply')); + + const settled = [...moves]; + + stdin.write(press(terminalRowOf(lastUi(frames), 'cyd'))); + await tick(); + + // The filter box owns the keys while it is open, and it owns the + // clicks too — a cursor moving behind it is the grid acting on + // input that was not addressed to it. Asserted before anything is + // typed, because applying a filter resets the cursor on its own. + expect(moves).toEqual(settled); + + stdin.write('b'); + await waitFor(() => lastUi(frames).includes('"b"')); + + expect(lastUi(frames)).toContain('"b"'); + expect(lastUi(frames)).not.toContain('[<'); + + unmount(); + + }); + + it('should step the cursor one row per wheel notch', async () => { + + const moves: number[] = []; + + const { frames, stdin, unmount } = render( + + moves.push(index)} + /> + , + ); + + await waitFor(() => lastUi(frames).includes('cyd')); + await tick(); + + const settled = moves.length; + + stdin.write(press(1, 1, WHEEL_DOWN)); + await waitFor(() => moves.length > settled); + + expect(moves.at(-1)).toBe(1); + + stdin.write(press(1, 1, WHEEL_UP)); + await waitFor(() => moves.length > settled + 1); + + expect(moves.at(-1)).toBe(0); + + unmount(); + + }); + + }); + + describe('SqlInput', () => { + + it('should not type the report into the SQL input', async () => { + + let value = ''; + + const { frames, stdin, unmount } = render( + + { + + value = next; + + }} + onSubmit={() => undefined} + onHistoryNavigate={() => undefined} + /> + , + ); + + await waitFor(() => lastUi(frames).includes('Enter SQL query')); + await tick(); + + stdin.write(press(3, 4)); + await tick(); + + expect(value).toBe(''); + + // The same handler still types real characters, so the guard is a + // filter rather than a mute. + stdin.write('x'); + await waitFor(() => value === 'x'); + + expect(value).toBe('x'); + + unmount(); + + }); + + }); + + describe('Form in edit mode', () => { + + it('should not type the report into the field being edited', async () => { + + // The most intricate TextInput consumer: the field only exists + // while the Form is in edit mode, and the Form's own handler is + // registered alongside the field's. A click has to reach neither. + const submitted: FormValues[] = []; + + const { frames, stdin, unmount } = render( + + submitted.push(values)} + /> + , + ); + + await waitFor(() => lastUi(frames).includes('Name')); + await tick(); + + stdin.write('\r'); + await tick(); + + stdin.write('users'); + await waitFor(() => lastUi(frames).includes('users')); + + const row = terminalRowOf(lastUi(frames), 'Name'); + + stdin.write(press(row, 12)); + await tick(); + stdin.write(release(row, 12)); + await tick(); + + expect(lastUi(frames)).not.toContain('[<'); + + // Commit, then walk to the submit action and fire it. What the Form + // hands back is the assertion that matters — the frame could look + // clean while the committed value carried the report. + stdin.write('\r'); + await waitFor(() => !lastUi(frames).includes('❯'), 500); + + stdin.write('\x1B[B'); + await tick(); + stdin.write('\r'); + await waitFor(() => submitted.length > 0); + + expect(submitted).toEqual([{ name: 'users' }]); + + unmount(); + + }); + + }); + +}); + +afterEach(async () => { + + await tick(); + +}); diff --git a/tests/cli/router.test.tsx b/tests/cli/router.test.tsx index 3da07175..100223d4 100644 --- a/tests/cli/router.test.tsx +++ b/tests/cli/router.test.tsx @@ -30,6 +30,25 @@ function RouteDisplay() { /** * Test component that performs navigation on mount. */ +/** + * Poll until the predicate holds instead of sleeping a guessed duration. + * + * The first render in this file pays React and Ink's cold-start cost after the + * rest of the suite has run, which overran the old fixed 50ms budget and made + * whichever test ran first here fail on load rather than on behavior. + */ +async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise { + + const deadline = Date.now() + timeoutMs; + + while (!predicate() && Date.now() < deadline) { + + await new Promise((r) => setTimeout(r, 5)); + + } + +} + function NavigateOnMount({ to, params }: { to: Route; params?: Record }) { const { navigate } = useRouter(); @@ -166,9 +185,12 @@ describe('cli: router', () => { }); - // In React 19, errors during render are caught and logged - const { lastFrame } = render(); - const output = lastFrame() ?? ''; + // In React 19, errors during render are caught and logged. + // Ink 7 paints its error overview and then writes a cleared frame + // as it unmounts, so the message is in `frames` but never in + // `lastFrame()`. Search every frame. + const { frames } = render(); + const output = frames.join(''); // Error may appear in rendered output or in console.error const hasErrorInOutput = output.includes('useRouter must be used within a RouterProvider'); @@ -192,8 +214,7 @@ describe('cli: router', () => { , ); - // Wait for effect - await new Promise((resolve) => setTimeout(resolve, 50)); + await waitFor(() => lastFrame()?.includes('route:config') ?? false); expect(lastFrame()).toContain('route:config'); @@ -207,7 +228,7 @@ describe('cli: router', () => { , ); - await new Promise((resolve) => setTimeout(resolve, 50)); + await waitFor(() => lastFrame()?.includes('canGoBack:true') ?? false); expect(lastFrame()).toContain('canGoBack:true'); // History may have multiple entries due to React strict mode rerenders @@ -226,7 +247,7 @@ describe('cli: router', () => { , ); - await new Promise((resolve) => setTimeout(resolve, 50)); + await waitFor(() => lastFrame()?.includes('route:config/edit') ?? false); expect(lastFrame()).toContain('route:config/edit'); expect(lastFrame()).toContain('"name":"dev"'); @@ -306,7 +327,7 @@ describe('cli: router', () => { , ); - await new Promise((resolve) => setTimeout(resolve, 50)); + await waitFor(() => lastFrame()?.includes('route:config') ?? false); expect(lastFrame()).toContain('route:config'); expect(lastFrame()).toContain('canGoBack:false'); @@ -323,8 +344,7 @@ describe('cli: router', () => { , ); - // Longer wait for CI environments where React effects may be slower - await new Promise((resolve) => setTimeout(resolve, 50)); + await waitFor(() => lastFrame()?.includes('route:config/edit') ?? false); expect(lastFrame()).toContain('route:config/edit'); expect(lastFrame()).toContain('"name":"staging"'); diff --git a/tests/cli/screens/change/change-add-mouse.test.tsx b/tests/cli/screens/change/change-add-mouse.test.tsx new file mode 100644 index 00000000..1c2e217d --- /dev/null +++ b/tests/cli/screens/change/change-add-mouse.test.tsx @@ -0,0 +1,252 @@ +/** + * ChangeAddScreen mouse-report regression. + * + * `@inkjs/ui`'s `TextInput` ends its `useInput` handler with an unconditional + * `state.insert(input)`, and Ink hands every registered handler every keystroke + * — including the SGR mouse reports the transport asks the terminal for. A + * click landed while a field was in edit mode typed the raw report into the + * field. + * + * This screen is why that is not cosmetic. The description feeds a derived + * value: `toKebabCase(name)` becomes the change folder name, and that folder is + * created on disk. A stray click while typing produced + * `2026-08-17-0-20-11m-0-20-11m-0-20-11m` as a real directory. So the case + * pinned here is a *derived* field, not a bare input, and it drives both + * terminators — a press (`M`) and a release (`m`) — because one gesture emits + * both and a guard that only matched `M` would catch half of them. + * + * Drives the real screen through its real providers. The state manager is + * swapped with `mock.module` (the precedent from `change-dry-run.test.tsx` in + * this directory) purely so `activeConfig` is non-null and the screen reaches + * its input step; nothing here touches a database. + */ +import { describe, it, expect, vi, mock, beforeEach, afterEach } from 'bun:test'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { FocusProvider } from '../../../../src/tui/focus.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 { MouseProvider, MOUSE_ENABLE, MOUSE_DISABLE } from '../../../../src/tui/mouse.js'; +import { ChangeAddScreen } from '../../../../src/tui/screens/change/ChangeAddScreen.js'; + +const actualCore = await import('../../../../src/core/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) => `${ESC}[<0;${column};${row}M`; + +/** SGR release report — same shape, lowercase terminator. */ +const release = (row: number, column = 1) => `${ESC}[<0;${column};${row}m`; + +// eslint-disable-next-line no-control-regex -- matching the ANSI SGR escape is the point +const ANSI_PATTERN = /\u001B\[[0-9;]*m/g; + +function strip(frame: string): string { + + return frame.replace(ANSI_PATTERN, ''); + +} + +/** The frames the UI drew, minus the transport's own escape-sequence writes. */ +const lastUi = (frames: string[]) => strip( + frames.filter((f) => f !== MOUSE_ENABLE && f !== MOUSE_DISABLE).at(-1) ?? '', +); + +function lineWith(frame: string, needle: string): string { + + return frame.split('\n').find((line) => line.includes(needle))?.trimEnd() ?? ''; + +} + +/** + * What the screen shows after a label, with the panel border and the trailing + * cursor cell taken off. + */ +function fieldValue(frame: string, label: string): string { + + const line = lineWith(frame, label); + + return line.slice(line.indexOf(label) + label.length).replace(/│\s*$/, '').trim(); + +} + +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; + +} + +async function waitFor(predicate: () => boolean, timeoutMs = 3000): Promise { + + const deadline = Date.now() + timeoutMs; + + while (!predicate() && Date.now() < deadline) { + + await new Promise((resolve) => setTimeout(resolve, 10)); + + } + +} + +const tick = () => new Promise((resolve) => setTimeout(resolve, 50)); + +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:', + }, + }; + +} + +const createMockStateManager = () => ({ + load: vi.fn().mockResolvedValue(undefined), + getActiveConfig: vi.fn().mockReturnValue(makeConfig()), + getActiveConfigName: vi.fn().mockReturnValue('test'), + listConfigs: vi.fn().mockReturnValue([]), + getConfig: vi.fn().mockReturnValue(makeConfig()), + 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' }, + getStages: vi.fn().mockReturnValue({}), + getStage: vi.fn().mockReturnValue(undefined), +}); + +let mockStateManager = createMockStateManager(); +let mockSettingsManager = createMockSettingsManager(); + +mock.module('../../../../src/core/index.js', () => ({ + observer: actualCore.observer, + getStateManager: vi.fn(() => mockStateManager), + getSettingsManager: vi.fn(() => mockSettingsManager), + resetStateManager: vi.fn(), + resetSettingsManager: vi.fn(), +})); + +mock.module('../../../../src/core/identity/index.js', () => ({ + loadExistingIdentity: vi.fn().mockResolvedValue(null), +})); + +describe('cli: ChangeAddScreen with the mouse on', () => { + + let tempDir: string; + + beforeEach(async () => { + + tempDir = await mkdtemp(join(tmpdir(), 'noorm-change-add-mouse-')); + mockStateManager = createMockStateManager(); + mockSettingsManager = createMockSettingsManager(); + + }); + + afterEach(async () => { + + await rm(tempDir, { recursive: true, force: true }); + + }); + + function tree() { + + return ( + + + + + + + + + + + + ); + + } + + it('should leave the description and its derived folder untouched when a click lands mid-typing', async () => { + + const { stdin, frames, unmount } = render(tree()); + + await waitFor(() => lastUi(frames).includes('Description:')); + await tick(); + + stdin.write('Add user roles'); + await waitFor(() => lastUi(frames).includes('add-user-roles')); + + const row = terminalRowOf(lastUi(frames), 'Description:'); + + // One gesture emits a press and a release, and a second press follows + // it inside the double-click window. The captured defect showed all + // three landing in the field. + stdin.write(press(row, 20)); + await tick(); + stdin.write(release(row, 20)); + await tick(); + stdin.write(press(row, 20)); + await tick(); + + const frame = lastUi(frames); + + expect(fieldValue(frame, 'Description:')).toBe('Add user roles'); + expect(fieldValue(frame, 'Folder:')).toMatch(/^\d{4}-\d{2}-\d{2}-add-user-roles$/); + expect(frame).not.toContain('[<'); + + unmount(); + await tick(); + + }); + + it('should still accept typing after a click', async () => { + + const { stdin, frames, unmount } = render(tree()); + + await waitFor(() => lastUi(frames).includes('Description:')); + await tick(); + + stdin.write('Add user'); + await waitFor(() => lastUi(frames).includes('add-user')); + + const row = terminalRowOf(lastUi(frames), 'Description:'); + + stdin.write(press(row, 20)); + await tick(); + stdin.write(release(row, 20)); + await tick(); + + stdin.write(' roles'); + await waitFor(() => lastUi(frames).includes('add-user-roles')); + + const frame = lastUi(frames); + + expect(fieldValue(frame, 'Description:')).toBe('Add user roles'); + expect(fieldValue(frame, 'Folder:')).toMatch(/^\d{4}-\d{2}-\d{2}-add-user-roles$/); + + unmount(); + await tick(); + + }); + +}); diff --git a/tests/cli/screens/config/ConfigEditScreen.test.tsx b/tests/cli/screens/config/ConfigEditScreen.test.tsx index a75d4d37..61aafc1c 100644 --- a/tests/cli/screens/config/ConfigEditScreen.test.tsx +++ b/tests/cli/screens/config/ConfigEditScreen.test.tsx @@ -7,12 +7,11 @@ * block and is already covered by iteration 1's `state/manager.test.ts`. * * A full render assertion of the surfaced error text was tried and dropped: - * the Form's fixed-height `overflowY="hidden"` container (10 fields at the - * 24-row ink-testing-library default terminal) clips the bottom status-error - * row before it reaches `lastFrame()`, making a text assertion flaky/false- - * negative independent of the wiring. Spying on the mock call args is - * deterministic and still proves the wiring is load-bearing: revert the - * `settingsProvider` argument and this test goes red. + * at the 24-row ink-testing-library default terminal the Form windows its 10 + * fields to a budget, so which rows reach `lastFrame()` depends on where the + * cursor sits - a text assertion would be testing the viewport, not the wiring. + * Spying on the mock call args is deterministic and still proves the wiring is + * load-bearing: revert the `settingsProvider` argument and this test goes red. */ import { describe, it, expect, vi, mock, beforeEach, afterEach, afterAll } from 'bun:test'; import { render } from 'ink-testing-library'; @@ -87,11 +86,102 @@ mock.module('../../../../src/core/identity/index.js', () => ({ loadExistingIdentity: vi.fn().mockResolvedValue(null), })); +/** + * The implementation the mock falls back to, and the one it must be left on. + * + * `mock.module` is process-global and never restores, so this file's + * `testConnection` stays installed for every file that runs after it in the + * cli group. Leaving `testConnectionImpl` on a hanging implementation hands + * those files a promise nobody will ever settle: `db-dry-run` sat forever on + * "Truncating tables..." because its screen was waiting on this mock. + */ +const answersImmediately = async () => ({ ok: true }); + +// Mutable so a test can hold the connection open and decide when — or +// whether — it answers. Reset in `afterEach`, not just `beforeEach`, because +// the state that matters outlives this file. +let testConnectionImpl: () => Promise<{ ok: boolean; error?: string; aborted?: boolean }> = + answersImmediately; + +/** + * Resolvers for connections a test is deliberately holding open. + * + * Registered here so `afterEach` can settle anything still parked; a suspended + * submit handler would otherwise keep the screen it belongs to alive for the + * rest of the process. + */ +const heldConnections: Array<(result: { ok: boolean }) => void> = []; + +/** + * A connection test that answers only when this file says so. + */ +function heldConnection(): Promise<{ ok: boolean }> { + + return new Promise<{ ok: boolean }>((resolve) => { + + heldConnections.push(resolve); + + }); + +} + +const testConnectionMock = vi.fn( + (_config: unknown, _options?: { testServerOnly?: boolean; signal?: AbortSignal }) => + testConnectionImpl(), +); + mock.module('../../../../src/core/connection/factory.js', () => ({ ...actualConnectionFactory, - testConnection: vi.fn().mockResolvedValue({ ok: true }), + testConnection: testConnectionMock, })); +/** + * Poll until `predicate` holds. A fixed sleep is the suite's known weak point: + * under load it expires before the frame arrives and reads as a regression. + */ +async function waitFor(predicate: () => boolean, timeoutMs = 3000) { + + const deadline = Date.now() + timeoutMs; + + while (!predicate() && Date.now() < deadline) { + + await new Promise((r) => setTimeout(r, 10)); + + } + +} + +/** + * Move the cursor onto the submit button and press it. + * + * Counting arrow presses is what made this fragile: two Ups is only the right + * number while the cursor starts on the first field, and a press written before + * the focus effect has run lands on nothing. Drive until the frame shows the + * cursor where it belongs instead. + */ +async function submitForm( + stdin: { write: (data: string) => void }, + lastFrame: () => string | undefined, +): Promise { + + const focused = '❯ [ Save Changes ]'; + + await waitFor(() => Boolean(lastFrame()?.includes('[ Save Changes ]'))); + + const deadline = Date.now() + 3000; + + while (!lastFrame()?.includes(focused) && Date.now() < deadline) { + + stdin.write('\x1B[A'); + + await new Promise((r) => setTimeout(r, 30)); + + } + + stdin.write('\r'); + +} + function TestWrapper({ children }: { children: React.ReactNode }) { return ( @@ -112,13 +202,22 @@ describe('cli: ConfigEditScreen', () => { vi.clearAllMocks(); actualCore.observer.clear(); + testConnectionImpl = answersImmediately; }); - afterEach(() => { + afterEach(async () => { actualCore.observer.clear(); + // Put the mock back on an implementation that answers, and let go of + // anything still held. Both matter to the *next* file, not this one. + testConnectionImpl = answersImmediately; + + while (heldConnections.length > 0) heldConnections.pop()?.({ ok: false }); + + await new Promise((r) => setTimeout(r, 20)); + }); afterAll(() => { @@ -146,7 +245,7 @@ describe('cli: ConfigEditScreen', () => { // resolution; the initial render (config unresolved) takes the // early-return branch, then a later render (config resolved) reaches // the bottom of the component - the exact transition that changes - // hook count if useStdout is called after the returns. + // hook count if useWindowSize is called after the returns. await new Promise((r) => setTimeout(r, 200)); const hooksOrderWarning = consoleErrorSpy.mock.calls.some( @@ -165,7 +264,7 @@ describe('cli: ConfigEditScreen', () => { mockStateManager = createMockStateManager('prod', makeConfig('prod')); mockSettingsManager = createMockSettingsManager({ prod: { locked: true } }); - const { stdin, unmount } = render( + const { stdin, lastFrame, unmount } = render( , @@ -173,13 +272,20 @@ describe('cli: ConfigEditScreen', () => { await new Promise((r) => setTimeout(r, 150)); - // Rename "prod" -> "prod2" (name field is active by default) and - // submit via Enter, which TextInput routes straight to handleSubmit. + // Rename "prod" -> "prod2". The name field is active by default but the + // Form starts in browse mode, so Enter opens it for editing, the digit + // lands, and Enter commits back to browse. + stdin.write('\r'); + await new Promise((r) => setTimeout(r, 50)); stdin.write('2'); await new Promise((r) => setTimeout(r, 50)); stdin.write('\r'); + await new Promise((r) => setTimeout(r, 50)); - await new Promise((r) => setTimeout(r, 200)); + // Submission lives on the action row now. + await submitForm(stdin, lastFrame); + + await waitFor(() => mockStateManager.deleteConfig.mock.calls.length > 0); expect(mockStateManager.deleteConfig).toHaveBeenCalledTimes(1); @@ -195,4 +301,80 @@ describe('cli: ConfigEditScreen', () => { }); + it('should offer the escape hatch while a connection test is in flight', async () => { + + mockStateManager = createMockStateManager('prod', makeConfig('prod')); + mockSettingsManager = createMockSettingsManager({ prod: { locked: true } }); + + // Answers only when this test lets it: the hung connect the hatch + // exists for. + testConnectionImpl = heldConnection; + + const { stdin, lastFrame, unmount } = render( + + + , + ); + + await submitForm(stdin, lastFrame); + + await waitFor(() => Boolean(lastFrame()?.includes('Testing connection'))); + + // A busy state that can be cancelled has to say so, or nobody tries it. + expect(lastFrame()).toContain('[Esc] Cancel'); + + // The hatch is only real if the connection layer got a signal to act on. + const options = testConnectionMock.mock.calls[0]?.[1]; + + expect(options?.signal).toBeInstanceOf(AbortSignal); + expect(options?.signal?.aborted).toBe(false); + + stdin.write('\x1B'); + + await waitFor(() => Boolean(options?.signal?.aborted)); + + expect(options?.signal?.aborted).toBe(true); + + unmount(); + + }, 20_000); + + it('should return the form to a usable state on Escape, and drop the late answer', async () => { + + mockStateManager = createMockStateManager('prod', makeConfig('prod')); + mockSettingsManager = createMockSettingsManager({ prod: { locked: true } }); + + testConnectionImpl = heldConnection; + + const { stdin, lastFrame, unmount } = render( + + + , + ); + + await submitForm(stdin, lastFrame); + + await waitFor(() => Boolean(lastFrame()?.includes('Testing connection'))); + + stdin.write('\x1B'); + + await waitFor(() => Boolean(lastFrame()?.includes('Stopped waiting'))); + + // Back to a form, not a spinner: the action row is rendered again. + expect(lastFrame()).toContain('Save Changes'); + expect(lastFrame()).not.toContain('Testing connection'); + + // A driver that ignores the abort and answers "fine" a moment later. + // Acting on that answer is how a cancelled screen saves anyway. + heldConnections.pop()?.({ ok: true }); + + await new Promise((r) => setTimeout(r, 150)); + + expect(mockStateManager.setConfig).not.toHaveBeenCalled(); + expect(lastFrame()).toContain('Stopped waiting'); + + unmount(); + + }, 20_000); + }); diff --git a/tests/cli/screens/db/explore-layout.test.tsx b/tests/cli/screens/db/explore-layout.test.tsx new file mode 100644 index 00000000..66a02939 --- /dev/null +++ b/tests/cli/screens/db/explore-layout.test.tsx @@ -0,0 +1,384 @@ +/** + * Explore viewer column-alignment tests. + * + * Ink's `width` is a flex basis and flex items shrink by default, so the + * explore lists re-flowed per row: a column carrying a long DEFAULT expression + * squeezed the name and type cells, a column carrying none left them full + * width, and the type column landed on a different offset on nearly every row + * of `cron.job`. The contract pinned here is that a row's cell offsets are a + * property of the section, not of what that row happens to hold. + * + * Every case renders inside a container of exactly the budgeted width. That is + * what the terminal does, and it is the only way a cell that still shrinks or + * a row that still overflows shows up in the frame. + * + * Fixtures are the real `cron.job` shape, because that is where the report + * came from: a long `nextval(...)` default, a 30-character identifier, and a + * nullable column with no default at all. + */ +import { describe, it, expect } from 'bun:test'; +import { render } from 'ink-testing-library'; +import { Box } from 'ink'; +import React from 'react'; + +import type { ReactElement } from 'react'; +import type { ColumnDetail, ParameterDetail, IndexSummary, ExploreOverview } from '../../../../src/core/explore/types.js'; + +import { columnRows, parameterRows, indexRows } from '../../../../src/tui/screens/db/explore/ExploreDetailScreen.js'; + +import type { DetailRow } from '../../../../src/tui/screens/db/explore/layout.js'; +import { CategoryList, countBrowsableObjects } from '../../../../src/tui/screens/db/explore/ExploreOverviewScreen.js'; + +/** Labels of every category the overview screen renders a row for. */ +const CATEGORY_LABELS = ['Tables', 'Views', 'Procedures', 'Functions', 'Types', 'Indexes', 'Foreign Keys']; + +/** Row budget inside the explore Panel on a 120-column terminal. */ +const ROOMY = 116; + +/** Row budget inside the explore Panel on a 100-column terminal. */ +const WIDE = 96; + +/** Row budget inside the explore Panel on a 50-column terminal. */ +const NARROW = 46; + +// eslint-disable-next-line no-control-regex -- matching the ANSI SGR escape is the point +const ANSI_PATTERN = /\u001B\[[0-9;]*m/g; + +/** Column assertions have to run against the text, not the styling. */ +function strip(frame: string | undefined): string { + + return (frame ?? '').replace(ANSI_PATTERN, ''); + +} + +/** + * Render a list in a container of exactly the width it was budgeted, so the + * frame reflects what a terminal that size would actually draw. + */ +function frameAt(width: number, element: ReactElement): string { + + return strip(render({element}).lastFrame()); + +} + +/** + * The detail sections are flat row arrays now, so the scroll viewport can slice + * them. Stacking their elements in a column Box is what the viewport does, and + * what the section Box used to do, so the frame under test is unchanged. + * + * Only `element` is drawn here. The `text` beside it is the same line with + * nothing truncated, which is the full-text overlay's business, not this file's. + */ +function rowsFrameAt(width: number, rows: DetailRow[]): string { + + return frameAt(width, {rows.map((row) => row.element)}); + +} + +function lineWith(frame: string, needle: string): string { + + return frame.split('\n').find((line) => line.includes(needle)) ?? ''; + +} + +function column(name: string, dataType: string, overrides: Partial = {}): ColumnDetail { + + return { + name, + dataType, + isNullable: false, + isPrimaryKey: false, + ordinalPosition: 0, + ...overrides, + }; + +} + +/** + * `cron.job` as the report saw it, plus the 30-character identifier and the + * nullable no-default column the layout has to survive. + */ +const CRON_JOB: ColumnDetail[] = [ + column('jobid', 'bigint', { isPrimaryKey: true, defaultValue: 'nextval(\'cron.jobid_seq\'::regclass)' }), + column('schedule', 'text'), + column('command', 'text'), + column('nodename', 'text', { defaultValue: '\'localhost\'::text' }), + column('nodeport', 'integer', { defaultValue: 'inet_server_port()' }), + column('database', 'text', { defaultValue: 'current_database()' }), + column('username', 'text', { defaultValue: 'CURRENT_USER' }), + column('active', 'boolean', { defaultValue: 'true' }), + column('jobname', 'text', { isNullable: true }), + column('last_successful_run_started_at', 'timestamp with time zone', { isNullable: true }), +]; + +/** + * Locate a row by a prefix of its name, and its cells by a prefix of their + * text, so the same offset assertions hold on a terminal narrow enough to + * truncate both. A prefix pins the cell's starting offset exactly, which is + * the property under test. + */ +function rowLine(frame: string, name: string): string { + + return lineWith(frame, name.slice(0, 8)); + +} + +/** Where each row's data-type cell starts. */ +function typeOffsets(frame: string, columns: ColumnDetail[]): number[] { + + return columns.map((col) => rowLine(frame, col.name).indexOf(col.dataType.slice(0, 4))); + +} + +/** Where each row's nullability cell starts. */ +function constraintOffsets(frame: string, columns: ColumnDetail[]): number[] { + + return columns.map((col) => rowLine(frame, col.name).indexOf(col.isNullable ? 'NULL' : 'NOT N')); + +} + +describe('cli: screens/db/explore layout', () => { + + describe('ColumnList', () => { + + it('should start every row\'s type and constraint cell on the same offset', () => { + + const frame = rowsFrameAt(WIDE, columnRows(CRON_JOB, WIDE)); + + const types = typeOffsets(frame, CRON_JOB); + const constraints = constraintOffsets(frame, CRON_JOB); + + expect(types).not.toContain(-1); + expect(types[0]).toBeGreaterThan(0); + expect(new Set(types).size).toBe(1); + + expect(constraints).not.toContain(-1); + expect(constraints[0]).toBeGreaterThan(types[0] ?? 0); + expect(new Set(constraints).size).toBe(1); + + }); + + it('should render one line per column, never wrapping a long default', () => { + + const frame = rowsFrameAt(WIDE, columnRows(CRON_JOB, WIDE)); + + expect(frame.split('\n')).toHaveLength(CRON_JOB.length); + + }); + + it('should show a long default in full when the terminal has room', () => { + + const frame = rowsFrameAt(ROOMY, columnRows(CRON_JOB, ROOMY)); + + expect(frame).toContain('NOT NULL DEFAULT nextval(\'cron.jobid_seq\'::regclass)'); + expect(frame.split('\n')).toHaveLength(CRON_JOB.length); + + }); + + it('should truncate a default that does not fit rather than wrap it under the type column', () => { + + const frame = rowsFrameAt(WIDE, columnRows(CRON_JOB, WIDE)); + + expect(lineWith(frame, 'jobid')).toContain('NOT NULL DEFAULT nextval('); + expect(lineWith(frame, 'jobid')).toEndWith('…'); + expect(frame.split('\n')).toHaveLength(CRON_JOB.length); + + }); + + it('should size the name cell from the longest name rather than a fixed width', () => { + + const short = [column('id', 'bigint'), column('name', 'text')]; + + const narrowNames = rowsFrameAt(WIDE, columnRows(short, WIDE)); + const wideNames = rowsFrameAt(WIDE, columnRows(CRON_JOB, WIDE)); + + expect(lineWith(narrowNames, 'id').indexOf('bigint')) + .toBeLessThan(lineWith(wideNames, 'jobid').indexOf('bigint')); + + }); + + it('should truncate an over-long identifier instead of moving the type column', () => { + + const pathological = 'a_generated_constraint_name_that_nobody_would_ever_type_by_hand'; + const columns = [column('id', 'bigint'), column(pathological, 'text')]; + + const frame = rowsFrameAt(WIDE, columnRows(columns, WIDE)); + + expect(frame).not.toContain(pathological); + expect(frame).toContain('…'); + expect(lineWith(frame, 'a_generated').indexOf('text')) + .toBe(lineWith(frame, 'id').indexOf('bigint')); + + }); + + it('should still fit one line per row on a narrow terminal', () => { + + const frame = rowsFrameAt(NARROW, columnRows(CRON_JOB, NARROW)); + const lines = frame.split('\n'); + + expect(lines).toHaveLength(CRON_JOB.length); + + for (const line of lines) { + + expect(line.length).toBeLessThanOrEqual(NARROW); + + } + + expect(new Set(typeOffsets(frame, CRON_JOB)).size).toBe(1); + + }); + + it('should mark the primary key without shifting the other rows', () => { + + const frame = rowsFrameAt(WIDE, columnRows(CRON_JOB, WIDE)); + + expect(lineWith(frame, 'jobid')).toContain('* jobid'); + expect(lineWith(frame, 'schedule')).toContain(' schedule'); + + }); + + }); + + describe('ParameterList', () => { + + const parameters: ParameterDetail[] = [ + { name: 'p_tenant_identifier', dataType: 'uuid', mode: 'IN', ordinalPosition: 1 }, + { name: 'p_from', dataType: 'timestamp with time zone', mode: 'IN', ordinalPosition: 2 }, + { name: 'p_rows', dataType: 'integer', mode: 'OUT', ordinalPosition: 3 }, + ]; + + it('should start every row\'s type and mode cell on the same offset', () => { + + const frame = rowsFrameAt(WIDE, parameterRows(parameters, WIDE)); + + const types = parameters.map((param) => lineWith(frame, param.name).indexOf(param.dataType)); + const modes = parameters.map((param) => lineWith(frame, param.name).lastIndexOf(param.mode)); + + expect(types).not.toContain(-1); + expect(types[0]).toBeGreaterThan(0); + expect(new Set(types).size).toBe(1); + expect(new Set(modes).size).toBe(1); + + }); + + }); + + describe('IndexList', () => { + + const indexes: IndexSummary[] = [ + { name: 'job_pkey', tableName: 'job', columns: ['jobid'], isUnique: true, isPrimary: true }, + { + name: 'job_username_nodename_database_idx', + tableName: 'job', + columns: ['username', 'nodename', 'database'], + isUnique: false, + isPrimary: false, + }, + { name: 'job_active_idx', tableName: 'job', columns: ['active'], isUnique: false, isPrimary: false }, + ]; + + it('should start every row\'s column list on the same offset', () => { + + const frame = rowsFrameAt(WIDE, indexRows(indexes, WIDE)); + + const offsets = indexes.map((idx) => lineWith(frame, idx.name.slice(0, 12)).indexOf('(')); + + expect(offsets).not.toContain(-1); + expect(offsets[0]).toBeGreaterThan(0); + expect(new Set(offsets).size).toBe(1); + + }); + + it('should render one line per index on a narrow terminal', () => { + + const frame = rowsFrameAt(NARROW, indexRows(indexes, NARROW)); + + expect(frame.split('\n')).toHaveLength(indexes.length); + + }); + + }); + + describe('CategoryList', () => { + + const overview: ExploreOverview = { + tables: 42, + views: 7, + procedures: 0, + functions: 130, + types: 3, + indexes: 88, + foreignKeys: 21, + triggers: 0, + locks: 0, + connections: 0, + }; + + it('should start every count on the same offset', () => { + + const frame = frameAt(WIDE, ); + + const offsets = [ + lineWith(frame, 'Tables').indexOf('42'), + lineWith(frame, 'Views').indexOf('7'), + lineWith(frame, 'Functions').indexOf('130'), + lineWith(frame, 'Foreign Keys').indexOf('21'), + ]; + + expect(offsets).not.toContain(-1); + expect(offsets[0]).toBeGreaterThan(0); + expect(new Set(offsets).size).toBe(1); + + }); + + it('should not pad a cell wider than its content needs', () => { + + const frame = frameAt(WIDE, ); + + // `[1]` is three columns, so the label starts one gap later, not at + // whatever minimum the allocator happens to carry. + expect(lineWith(frame, 'Tables').indexOf('Tables')).toBe(5); + + }); + + }); + + describe('countBrowsableObjects', () => { + + // triggers, locks and connections have no row on this screen, and locks + // and connections are runtime state rather than schema objects. + // Counting them made the total exceed the rows a reader could see. + const withRuntimeState: ExploreOverview = { + tables: 42, + views: 7, + procedures: 0, + functions: 130, + types: 3, + indexes: 88, + foreignKeys: 21, + triggers: 5, + locks: 9, + connections: 12, + }; + + it('should count only the categories the screen lists', () => { + + expect(countBrowsableObjects(withRuntimeState)).toBe(291); + + }); + + it('should equal the sum of the counts it renders', () => { + + const frame = frameAt(WIDE, ); + + const rendered = CATEGORY_LABELS + .map((label) => Number(lineWith(frame, label).match(/(\d+)\s*$/)?.[1] ?? 0)) + .reduce((sum, count) => sum + count, 0); + + expect(countBrowsableObjects(withRuntimeState)).toBe(rendered); + + }); + + }); + +}); diff --git a/tests/cli/screens/db/explore-peek.test.tsx b/tests/cli/screens/db/explore-peek.test.tsx new file mode 100644 index 00000000..163b2caf --- /dev/null +++ b/tests/cli/screens/db/explore-peek.test.tsx @@ -0,0 +1,608 @@ +/** + * Explore detail row-peek tests. + * + * The detail screen describes a table and never shows a row of it, so the only + * way to see what is in one was to leave for the SQL terminal and write the + * query by hand. `p` is that query. + * + * What is pinned here: + * + * - `p` opens the peek and Escape puts the reader back on the same line of the + * detail they left, which is the contract the full-text overlay already + * honours and the reason both overlays swap inside `ScrollView` rather than + * above it. + * - The three shapes a peek can come back in each read as what they are: two + * ends, one whole table, or a head with no tail and a reason why. + * - The rows stay in the order the query put them in. `ResultTable` re-sorts by + * whatever column looks like an id unless told not to, which would silently + * replace "first ten by primary key" with "ten rows, sorted by something". + * - Nothing reaches the database until `p` is pressed. + */ +import { describe, it, expect } from 'bun:test'; +import { render } from 'ink-testing-library'; +import { Text } from 'ink'; +import React from 'react'; + +import type { ColumnDetail, TableDetail } from '../../../../src/core/explore/types.js'; +import type { ConfigAccess } from '../../../../src/core/policy/index.js'; +import type { DetailRow } from '../../../../src/tui/screens/db/explore/layout.js'; + +import { FocusProvider } from '../../../../src/tui/focus.js'; +import { + ScrollView, + tableDetailRows, +} from '../../../../src/tui/screens/db/explore/ExploreDetailScreen.js'; +import { + RowPeekOverlay, + peekPageSize, + setRows, +} from '../../../../src/tui/screens/db/explore/RowPeekOverlay.js'; +import { fitPeekColumns } from '../../../../src/tui/components/terminal/index.js'; +import { detailFooterHints } from '../../../../src/tui/screens/db/explore/layout.js'; +import { createRecordingDb } from '../../../core/explore/recording-db.js'; + +/** Row budget inside the explore Panel on the 100-column test terminal. */ +const WIDE = 96; + +/** Viewport height the overlay cases run at: tall enough for two sets. */ +const HEIGHT = 30; + +/** + * A viewport whose page size works out to exactly `PAGE` rows. + * + * The page is derived from the height, so a case that needs the tail query to + * run at all has to supply a head of exactly that many rows — a shorter page is + * the whole table and the second query never fires. Pinning the height is what + * keeps those fixtures three rows long instead of eight. + */ +const PAGE_HEIGHT = 19; + +/** Rows per set at `PAGE_HEIGHT`. */ +const PAGE = 3; + +/** The overlay's own header, so no case can pass on an overlay that never opened. */ +const PEEK_HEADER = 'Rows ·'; + +const OPEN: ConfigAccess = { user: 'admin', agent: 'admin' }; + +const GATE = { configName: 'test', access: OPEN, channel: 'user' } as const; + +// eslint-disable-next-line no-control-regex -- matching the ANSI SGR escape is the point +const ANSI_PATTERN = /\u001B\[[0-9;]*m/g; + +function strip(frame: string | undefined): string { + + return (frame ?? '').replace(ANSI_PATTERN, ''); + +} + +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 KEY = { + end: '\u001B[F', + escape: '\u001B', + peek: 'r', + value: 'v', +} as const; + +function column(name: string, overrides: Partial = {}): ColumnDetail { + + return { + name, + dataType: 'text', + isNullable: true, + isPrimaryKey: false, + ordinalPosition: 1, + ...overrides, + }; + +} + +/** + * A table whose primary key is `id` and whose other column is `created_at`. + * + * `created_at` is the load-bearing part: it is exactly the column name + * `ResultTable` auto-sorts on, descending, so a peek that forgot to switch that + * off would show the newest row first under a heading that says "first". + */ +function usersTable(overrides: Partial = {}): TableDetail { + + return { + name: 'users', + schema: 'public', + columns: [ + column('id', { isPrimaryKey: true, ordinalPosition: 1, isNullable: false }), + column('created_at', { ordinalPosition: 2 }), + ], + indexes: [], + foreignKeys: [], + ...overrides, + }; + +} + +/** Rows keyed by id, oldest first, so an auto-sort would visibly reverse them. */ +function rows(ids: number[]): Record[] { + + return ids.map((id) => ({ id, created_at: `2024-01-${String(id).padStart(2, '0')}` })); + +} + +interface PeekOptions { + detail?: TableDetail; + head?: Record[]; + tail?: Record[]; + error?: Error; + gate?: typeof GATE | { configName: string; access: ConfigAccess; channel: 'user' | 'agent' }; + height?: number; + until?: (frame: string) => boolean; +} + +/** + * A recording connection answering the peek's queries. + * + * Rules match on the compiled SQL. A table with a key produces two statements + * distinguishable by their `order by` direction; one without produces a single + * statement carrying no order at all, which is why the no-tail case matches on + * the select instead. + */ +function peekDb(options: PeekOptions) { + + if (options.error) return createRecordingDb('postgres', [{ match: /select/, error: options.error }]); + + if (!options.tail) return createRecordingDb('postgres', [{ match: /select/, rows: options.head ?? [] }]); + + return createRecordingDb('postgres', [ + { match: / asc/, rows: options.head ?? [] }, + { match: / desc/, rows: options.tail }, + ]); + +} + +/** + * The overlay, mounted with a recording connection standing in for a database. + */ +async function overlay(options: PeekOptions) { + + const detail = options.detail ?? usersTable(); + const db = peekDb(options); + + const { stdin, lastFrame, unmount } = render( + + {}} + /> + , + ); + + if (options.until) await waitFor(() => options.until!(strip(lastFrame()))); + + return { db, stdin, frame: () => strip(lastFrame()), unmount }; + +} + +/** + * A viewport with a stand-in peek, so the key wiring can be tested without a + * connection. The marker is what proves the swap happened. + */ +const PEEK_MARKER = 'PEEK-OPENED'; + +async function scroller(detailRows: DetailRow[], withPeek: boolean) { + + return mountScroller(detailRows, withPeek ? () => {PEEK_MARKER} : undefined); + +} + +/** + * The same viewport with the *real* overlay behind `p`, so the round trip + * through it — including its Escape — is the one the reader takes. + */ +async function scrollerWithRealPeek(detailRows: DetailRow[], detail: TableDetail) { + + const db = peekDb({ head: rows([1, 2]) }); + + return mountScroller(detailRows, (close) => ( + + )); + +} + +async function mountScroller( + detailRows: DetailRow[], + renderPeek?: (close: () => void) => React.ReactElement, +) { + + const { stdin, lastFrame, unmount } = render( + + + , + ); + + await waitFor(() => strip(lastFrame()).length > 0); + + const press = async (sequence: string, settled: (frame: string) => boolean) => { + + stdin.write(sequence); + + await waitFor(() => settled(strip(lastFrame()))); + + }; + + return { frame: () => strip(lastFrame()), press, unmount }; + +} + +/** A detail long enough to scroll, so an offset exists to preserve. */ +function longTable(): TableDetail { + + return usersTable({ + columns: Array.from({ length: 40 }, (_, index) => column( + `col_${String(index).padStart(2, '0')}`, + { ordinalPosition: index + 1, isPrimaryKey: index === 0 }, + )), + }); + +} + +describe('cli: screens/db/explore row peek', () => { + + describe('reaching it', () => { + + it('should open the peek on p', async () => { + + const view = await scroller(tableDetailRows(usersTable(), WIDE), true); + + expect(view.frame()).not.toContain(PEEK_MARKER); + + await view.press(KEY.peek, (frame) => frame.includes(PEEK_MARKER)); + + expect(view.frame()).toContain(PEEK_MARKER); + + view.unmount(); + + }); + + it('should ignore p when the object has no rows to peek at', async () => { + + const view = await scroller(tableDetailRows(usersTable(), WIDE), false); + + const before = view.frame(); + + view.press(KEY.peek, () => false); + await waitFor(() => false, 100); + + expect(view.frame()).toBe(before); + + view.unmount(); + + }); + + it('should restore the exact scroll offset on Escape', async () => { + + const detail = longTable(); + const view = await scrollerWithRealPeek(tableDetailRows(detail, WIDE), detail); + + await view.press(KEY.end, (frame) => frame.includes('col_39')); + + const before = view.frame(); + + await view.press(KEY.peek, (frame) => frame.includes(PEEK_HEADER)); + + expect(view.frame()).not.toBe(before); + + await view.press(KEY.escape, (frame) => !frame.includes(PEEK_HEADER)); + + expect(view.frame()).toBe(before); + + view.unmount(); + + }); + + it('should still open the full-text view, which shares the same swap', async () => { + + const view = await scroller(tableDetailRows(usersTable(), WIDE), true); + + await view.press(KEY.value, (frame) => frame.includes('Full text')); + + expect(view.frame()).toContain('Full text'); + expect(view.frame()).not.toContain(PEEK_MARKER); + + view.unmount(); + + }); + + }); + + describe('what it shows', () => { + + it('should label both ends when they are different rows', async () => { + + const view = await overlay({ + height: PAGE_HEIGHT, + head: rows([1, 2, 3]), + tail: rows([9, 8, 7]), + until: (frame) => frame.includes('Last'), + }); + + expect(view.frame()).toContain(`First ${PAGE} by id`); + expect(view.frame()).toContain(`Last ${PAGE} by id`); + + view.unmount(); + + }); + + it('should keep the tail in ascending order under its heading', async () => { + + const view = await overlay({ + height: PAGE_HEIGHT, + head: rows([1, 2, 3]), + tail: rows([9, 8, 7]), + until: (frame) => frame.includes('Last'), + }); + + const frame = view.frame(); + const tail = frame.slice(frame.indexOf('Last')); + + expect(tail.indexOf('2024-01-07')).toBeLessThan(tail.indexOf('2024-01-09')); + + view.unmount(); + + }); + + it('should not sort the rows by whatever column looks like a date', async () => { + + const view = await overlay({ + height: PAGE_HEIGHT, + head: rows([1, 2, 3]), + tail: rows([9, 8, 7]), + until: (frame) => frame.includes('First'), + }); + + const frame = view.frame(); + const head = frame.slice(frame.indexOf('First'), frame.indexOf('Last')); + + // Descending would put 03 first. `autoSort` off is the only reason + // it does not. + expect(head.indexOf('2024-01-01')).toBeLessThan(head.indexOf('2024-01-03')); + + view.unmount(); + + }); + + it('should show one set, not two, when the ends overlap', async () => { + + const view = await overlay({ + height: PAGE_HEIGHT, + head: rows([1, 2, 3]), + tail: rows([4, 3, 2]), + until: (frame) => frame.includes('All'), + }); + + const frame = view.frame(); + + expect(frame).toContain('All 4 rows by id'); + expect(frame).not.toContain('First'); + expect(frame).not.toContain('Last'); + + // One row per id, once each. + expect(frame.split('2024-01-03')).toHaveLength(2); + + view.unmount(); + + }); + + it('should say why there is no last set when the table has no primary key', async () => { + + const detail = usersTable({ columns: [column('note', { ordinalPosition: 1 })] }); + + const view = await overlay({ + detail, + height: PAGE_HEIGHT, + head: [{ note: 'a' }, { note: 'b' }, { note: 'c' }], + until: (frame) => frame.includes('First'), + }); + + expect(view.frame()).toContain('No primary key'); + expect(view.frame()).not.toContain('Last'); + + view.unmount(); + + }); + + it('should render an empty table without failing', async () => { + + const view = await overlay({ + head: [], + until: (frame) => frame.includes('All'), + }); + + expect(view.frame()).toContain('All 0 rows'); + expect(view.frame()).toContain('No results'); + + view.unmount(); + + }); + + it('should render a column that is NULL in every row', async () => { + + const view = await overlay({ + head: [{ id: 1, created_at: null }, { id: 2, created_at: null }], + until: (frame) => frame.includes('All'), + }); + + expect(view.frame()).toContain('NULL'); + + view.unmount(); + + }); + + it('should name the table it is showing', async () => { + + const view = await overlay({ + head: rows([1]), + until: (frame) => frame.includes(PEEK_HEADER), + }); + + expect(view.frame()).toContain('public.users'); + + view.unmount(); + + }); + + }); + + describe('when it cannot read', () => { + + it('should show a spinner before the rows arrive', async () => { + + const view = await overlay({ head: rows([1]) }); + + expect(view.frame()).toContain('Reading public.users'); + + view.unmount(); + + }); + + it('should show the database error as a message, not a stack', async () => { + + const view = await overlay({ + error: new Error('relation "public.users" does not exist'), + until: (frame) => frame.includes('Could not read rows'), + }); + + const frame = view.frame(); + + expect(frame).toContain('relation "public.users" does not exist'); + expect(frame).not.toContain('at '); + + view.unmount(); + + }); + + it('should show the policy reason when the channel is denied, having read nothing', async () => { + + const view = await overlay({ + head: rows([1]), + gate: { configName: 'prod', access: { user: 'admin', agent: false }, channel: 'agent' }, + until: (frame) => frame.includes('Could not read rows'), + }); + + expect(view.frame()).toContain('agent'); + expect(view.db.queries).toHaveLength(0); + + view.unmount(); + + }); + + }); + + describe('sizing', () => { + + it('should split the viewport between two sets and their chrome', () => { + + // 30 rows, minus the header, minus each set's label and table chrome. + expect(setRows(30, 2)).toBe(8); + expect(setRows(30, 1)).toBe(23); + + }); + + it('should never ask for a page it cannot draw', () => { + + // Budgeted for two sets, which is the worst case. + expect(peekPageSize(30)).toBe(8); + expect(peekPageSize(PAGE_HEIGHT)).toBe(PAGE); + + }); + + it('should still ask for one row on a terminal with no room at all', () => { + + expect(peekPageSize(12)).toBe(1); + expect(setRows(1, 2)).toBe(1); + + }); + + it('should cap a column that has room to spare', () => { + + // Two columns on a 100-column terminal have room to spare, so the + // cap decides. Forty do not, and are chopped rather than squeezed — + // `explore-row-view.test.tsx` pins that half. + const fit = fitPeekColumns(['id', 'name'], 100); + + expect(fit.width).toBe(24); + expect(fit.hidden).toBe(0); + + expect(fitPeekColumns([], 100).width).toBe(24); + + }); + + it('should cap the page at ten however tall the terminal is', () => { + + expect(peekPageSize(200)).toBe(10); + + }); + + }); + + describe('footer', () => { + + it('should advertise the peek key only on an object that has rows', () => { + + const table = detailFooterHints({ scrolls: false, overlay: 'none', canPeek: true }); + const view = detailFooterHints({ scrolls: false, overlay: 'none', canPeek: false }); + + expect(table).toContain('[r] Rows'); + expect(view).not.toContain('[r] Rows'); + + }); + + it('should offer only Escape while the peek is open, since it does not scroll', () => { + + const hints = detailFooterHints({ scrolls: true, overlay: 'peek', canPeek: true }); + + expect(hints).toEqual(['[Esc] Close']); + + }); + + it('should stay inside one line of an 80-column terminal with the peek hint', () => { + + for (const platform of ['darwin', 'linux'] as const) { + + const width = detailFooterHints({ scrolls: true, overlay: 'none', canPeek: true, platform }) + .join(' ').length; + + expect(width).toBeLessThanOrEqual(80); + + } + + }); + + }); + +}); diff --git a/tests/cli/screens/db/explore-row-view.test.tsx b/tests/cli/screens/db/explore-row-view.test.tsx new file mode 100644 index 00000000..6870265c --- /dev/null +++ b/tests/cli/screens/db/explore-row-view.test.tsx @@ -0,0 +1,1115 @@ +/** + * Explore row-view tests: navigating peeked rows and reading one in full. + * + * The peek draws two grids of truncated cells. This is the way out of them — + * a cursor that moves, Enter that opens the row under it, and a key/value + * document that shows every column including the ones the grid had to drop. + * + * What is pinned here, in rough order of how expensive it would be to get wrong: + * + * - **Value rendering.** Drivers return real JavaScript, not strings, and they + * disagree per dialect. `JSON.stringify` throws outright on a `bigint`, and a + * `Buffer` serializes to `{"type":"Buffer","data":[…]}`, which is the shape of + * the wrapper rather than the value. Both are pinned, along with the three + * things that must stay distinguishable: `null`, the string `"null"`, and the + * empty string. + * - **Column chopping.** A fifteen-column table on a narrow terminal used to + * shrink every column to six, which turns a UUID into `ee3d` and a header + * into two wrapped lines. Now it shows as many whole columns as fit and says + * how many it dropped. + * - **Where Escape lands.** Three levels deep — row view, peek, detail — and + * each level has to give up its own key without taking the level below with + * it. The filter box inside `ResultTable` is a fourth owner of Escape and is + * pinned here so it cannot be quietly disabled. + * - **What the shared `ResultTable` does without the new props**, which is what + * keeps the SQL terminal exactly as it was. + */ +import { describe, it, expect, beforeEach } from 'bun:test'; +import { render } from 'ink-testing-library'; +import chalk from 'chalk'; +import React from 'react'; + +import type { ColumnDetail, TableDetail } from '../../../../src/core/explore/types.js'; +import type { ConfigAccess } from '../../../../src/core/policy/index.js'; + +import { FocusProvider } from '../../../../src/tui/focus.js'; +import { + PEEK_COLUMN_CAP, + ResultTable, + RowViewOverlay, + fitPeekColumns, +} from '../../../../src/tui/components/terminal/index.js'; +import { RowPeekOverlay } from '../../../../src/tui/screens/db/explore/RowPeekOverlay.js'; +import { + DEFAULT_ROW_FORMAT, + documentRow, + documentValue, + preferredRowFormat, + rememberRowFormat, + renderRowDocument, +} from '../../../../src/tui/components/terminal/rowDocument.js'; +import { createRecordingDb } from '../../../core/explore/recording-db.js'; + +const OPEN: ConfigAccess = { user: 'admin', agent: 'admin' }; + +const GATE = { configName: 'test', access: OPEN, channel: 'user' } as const; + +/** Viewport height the overlay cases run at: tall enough for two sets. */ +const HEIGHT = 30; + +/** The peek's own header, so no case can pass on an overlay that never opened. */ +const PEEK_HEADER = 'Rows ·'; + +/** The row view's header, likewise. */ +const VIEW_HEADER = 'Row ·'; + +// eslint-disable-next-line no-control-regex -- matching the ANSI SGR escape is the point +const ANSI_PATTERN = /\u001B\[[0-9;]*m/g; + +function strip(frame: string | undefined): string { + + return (frame ?? '').replace(ANSI_PATTERN, ''); + +} + +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 KEY = { + down: '\u001B[B', + up: '\u001B[A', + left: '\u001B[D', + right: '\u001B[C', + enter: '\r', + escape: '\u001B', + tab: '\t', + format: 'f', + filter: '/', +} as const; + +/** + * Wait until the row view's own focus scope is live. + * + * `useFocusScope` pushes onto the stack in an effect, so a keystroke written on + * the same tick as the mount lands before any handler is listening and is lost. + * Polling the frame is not enough on its own — the first frame is drawn before + * the effect runs — so this presses a key until its effect shows up, which is + * the same "poll for the condition" discipline a fixed sleep here would skip. + * + * `f` is the probe because what it changes is in the header rather than in the + * document, and because pressing it twice puts the remembered format back where + * it started, whichever one it started at. + */ +async function settleRowView( + stdin: { write: (data: string) => void }, + frame: () => string, +): Promise { + + const start = frame().includes('[f] JSON') ? '[f] JSON' : '[f] YAML'; + const flipped = start === '[f] JSON' ? '[f] YAML' : '[f] JSON'; + + const deadline = Date.now() + 2000; + + while (!frame().includes(flipped) && Date.now() < deadline) { + + stdin.write(KEY.format); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + } + + stdin.write(KEY.format); + + await waitFor(() => frame().includes(start)); + +} + +function column(name: string, overrides: Partial = {}): ColumnDetail { + + return { + name, + dataType: 'text', + isNullable: true, + isPrimaryKey: false, + ordinalPosition: 1, + ...overrides, + }; + +} + +function usersTable(overrides: Partial = {}): TableDetail { + + return { + name: 'users', + schema: 'public', + columns: [ + column('id', { isPrimaryKey: true, ordinalPosition: 1, isNullable: false }), + column('created_at', { ordinalPosition: 2 }), + ], + indexes: [], + foreignKeys: [], + ...overrides, + }; + +} + +function rows(ids: number[]): Record[] { + + return ids.map((id) => ({ id, created_at: `2024-01-${String(id).padStart(2, '0')}` })); + +} + +// ----------------------------------------------------------------------------- +// Value rendering +// ----------------------------------------------------------------------------- + +describe('cli: screens/db/explore row document', () => { + + beforeEach(() => rememberRowFormat(DEFAULT_ROW_FORMAT)); + + describe('values a driver actually returns', () => { + + it('should keep null, the string "null" and the empty string apart', () => { + + const row = { a: null, b: 'null', c: '' }; + + const yaml = renderRowDocument(row, ['a', 'b', 'c'], 'yaml'); + const json = renderRowDocument(row, ['a', 'b', 'c'], 'json'); + + expect(yaml).toContain('a: null'); + expect(yaml).toContain('b: "null"'); + expect(yaml).toContain('c: ""'); + + expect(json).toContain('"a": null'); + expect(json).toContain('"b": "null"'); + expect(json).toContain('"c": ""'); + + }); + + it('should render a Date as an ISO instant rather than a locale string', () => { + + const value = documentValue(new Date('2024-03-01T12:34:56.789Z')); + + expect(value).toBe('2024-03-01T12:34:56.789Z'); + + }); + + it('should survive the Invalid Date mysql hands back for a zero timestamp', () => { + + const value = documentValue(new Date('0000-00-00')); + + expect(value).toBe(''); + + }); + + it('should summarize a Buffer instead of dumping its byte array', () => { + + const value = documentValue(Buffer.from([0x00, 0xff, 0x10])); + + expect(value).toBe(''); + expect(String(value)).not.toContain('"type"'); + expect(String(value)).not.toContain('data'); + + }); + + it('should summarize the Uint8Array bun:sqlite hands back for a blob', () => { + + // bun:sqlite returns a plain Uint8Array, so `Buffer.isBuffer` is + // false for exactly the value that most needs summarizing. + const value = documentValue(new Uint8Array([0x00, 0xff, 0x10])); + + expect(Buffer.isBuffer(new Uint8Array([1]))).toBe(false); + expect(value).toBe(''); + + }); + + it('should name an empty binary as empty rather than as nothing', () => { + + expect(documentValue(Buffer.alloc(0))).toBe(''); + + }); + + it('should cut a long binary preview instead of printing a kilobyte of hex', () => { + + const value = documentValue(Buffer.alloc(1024, 0xab)); + + expect(value).toContain('1024 bytes'); + expect(value).toContain('…'); + expect(String(value).length).toBeLessThan(60); + + }); + + it('should render a bigint without letting JSON.stringify throw', () => { + + const row = { big: 9223372036854775807n }; + + // The bug this guards: JSON.stringify throws a TypeError on a + // bigint, so an unguarded document crashes the overlay outright. + expect(() => JSON.stringify(row)).toThrow(); + + expect(renderRowDocument(row, ['big'], 'json')).toContain('"9223372036854775807"'); + expect(renderRowDocument(row, ['big'], 'yaml')).toContain('"9223372036854775807"'); + + }); + + it('should render a parsed jsonb column as structure, not [object Object]', () => { + + const row = { doc: { a: 1, b: [2, 3] } }; + + const yaml = renderRowDocument(row, ['doc'], 'yaml'); + const json = renderRowDocument(row, ['doc'], 'json'); + + expect(yaml).not.toContain('[object Object]'); + expect(yaml).toContain('a: 1'); + expect(yaml).toContain('- 2'); + expect(json).toContain('"a": 1'); + + }); + + it('should render a postgres array column as a list', () => { + + const yaml = renderRowDocument({ tags: [1, 2, 3] }, ['tags'], 'yaml'); + + expect(yaml).toContain('- 1'); + expect(yaml).toContain('- 3'); + + }); + + it('should mark a circular value rather than throwing on it', () => { + + const loop: Record = { name: 'a' }; + loop['self'] = loop; + + const yaml = renderRowDocument({ loop }, ['loop'], 'yaml'); + + expect(yaml).toContain(''); + + }); + + it('should leave a number, a boolean and a string as themselves', () => { + + expect(documentValue(1)).toBe(1); + expect(documentValue(true)).toBe(true); + expect(documentValue('x')).toBe('x'); + + }); + + it('should not fold a long value across lines', () => { + + // yaml's stringify folds at 80 columns by default, which would put + // one column's value on several lines and make the row's line count + // depend on the value rather than on the column count. + // Words, not one long run of `x`: yaml folds at a space, so a + // string without one cannot fold and would pass either way. + const long = Array.from({ length: 40 }, (_, index) => `word${index}`).join(' '); + + const yaml = renderRowDocument({ note: long }, ['note'], 'yaml'); + + expect(yaml.split('\n')).toHaveLength(1); + expect(yaml).toContain(long); + + }); + + }); + + describe('the document as a whole', () => { + + it('should order fields by the column list, not by the object keys', () => { + + // The two orders have to disagree or this proves nothing: a driver + // usually hands back keys in the order the query selected them, + // which is the order the column list already has. + const row = { z: 1, a: 2 }; + + expect(Object.keys(documentRow(row, ['a', 'z']))).toEqual(['a', 'z']); + + }); + + it('should still show a key the column list left out', () => { + + const row = { known: 1, surprise: 2 }; + + expect(Object.keys(documentRow(row, ['known']))).toEqual(['known', 'surprise']); + + }); + + }); + + describe('the remembered format', () => { + + it('should default to yaml', () => { + + expect(DEFAULT_ROW_FORMAT).toBe('yaml'); + expect(preferredRowFormat()).toBe('yaml'); + + }); + + it('should survive the overlay it was chosen in', () => { + + rememberRowFormat('json'); + + expect(preferredRowFormat()).toBe('json'); + + }); + + }); + +}); + +// ----------------------------------------------------------------------------- +// Column chopping +// ----------------------------------------------------------------------------- + +describe('cli: screens/db/explore peek columns', () => { + + const many = (count: number) => Array.from( + { length: count }, + (_, index) => `col_${String(index).padStart(2, '0')}`, + ); + + it('should show every column when they all fit', () => { + + const fit = fitPeekColumns(['id', 'name'], 100); + + expect(fit.columns).toEqual(['id', 'name']); + expect(fit.hidden).toBe(0); + expect(fit.width).toBeLessThanOrEqual(PEEK_COLUMN_CAP); + + }); + + it('should drop columns rather than shrink them below readable', () => { + + // The bug: fifteen columns on a 76-column terminal used to be squeezed + // to six each, which renders a uuid as `ee3d` and wraps every header. + const fit = fitPeekColumns(many(15), 76); + + // Sixteen and three are written out rather than read from the module: + // asserting against the constant would let a change to the constant + // move the bar it is being measured against. + expect(fit.width).toBeGreaterThanOrEqual(16); + expect(fit.columns).toHaveLength(3); + expect(fit.hidden).toBe(12); + + }); + + it('should keep the columns it shows in their original order', () => { + + const fit = fitPeekColumns(many(15), 76); + + expect(fit.columns).toEqual(many(15).slice(0, fit.columns.length)); + + }); + + it('should never fit more columns than the row has room for', () => { + + const fit = fitPeekColumns(many(40), 80); + const used = fit.columns.length * fit.width + (fit.columns.length - 1) * 3; + + expect(used).toBeLessThanOrEqual(80); + + }); + + it('should show one column even on a terminal too narrow for it', () => { + + const fit = fitPeekColumns(many(40), 10); + + expect(fit.columns).toHaveLength(1); + expect(fit.hidden).toBe(39); + + }); + + it('should spend leftover room on width before it spends it on a column', () => { + + // Three columns on a wide terminal have room for the cap; a fourth + // column at minimum width would be worse than three wide ones only if + // it did not fit, so what this pins is that nothing is left on the + // table: the shown columns grow to the cap. + const fit = fitPeekColumns(['a', 'b', 'c'], 120); + + expect(fit.hidden).toBe(0); + expect(fit.width).toBe(PEEK_COLUMN_CAP); + + }); + +}); + +// ----------------------------------------------------------------------------- +// ResultTable: what the new props change, and what they leave alone +// ----------------------------------------------------------------------------- + +describe('cli: components/terminal ResultTable selection', () => { + + async function table(props: Partial> = {}) { + + const { stdin, lastFrame, unmount } = render( + + + , + ); + + await waitFor(() => strip(lastFrame()).includes('name')); + + return { stdin, frame: () => strip(lastFrame()), unmount }; + + } + + it('should ignore Enter and Tab when no callback was given', async () => { + + // The SQL terminal passes neither, so this is that screen's behavior. + const view = await table(); + const before = view.frame(); + + view.stdin.write(KEY.enter); + view.stdin.write(KEY.tab); + await waitFor(() => false, 100); + + expect(view.frame()).toBe(before); + + view.unmount(); + + }); + + it('should report the highlighted row on Enter', async () => { + + const picked: { index: number; row: Record }[] = []; + + const view = await table({ + onSelect: (row, index) => picked.push({ row, index }), + }); + + view.stdin.write(KEY.down); + await waitFor(() => false, 50); + view.stdin.write(KEY.enter); + await waitFor(() => picked.length > 0); + + expect(picked[0]?.index).toBe(1); + expect(picked[0]?.row['name']).toBe('b'); + + view.unmount(); + + }); + + it('should hand Enter to the filter box while one is open', async () => { + + const picked: number[] = []; + + const view = await table({ onSelect: (_row, index) => picked.push(index) }); + + view.stdin.write(KEY.filter); + await waitFor(() => view.frame().includes('[Enter] Apply')); + + view.stdin.write(KEY.enter); + await waitFor(() => !view.frame().includes('[Enter] Apply')); + + expect(picked).toEqual([]); + + view.unmount(); + + }); + + it('should hand Tab to the filter box while one is open', async () => { + + const switched: string[] = []; + + const view = await table({ onTab: () => switched.push('tab') }); + + view.stdin.write(KEY.filter); + await waitFor(() => view.frame().includes('[Enter] Apply')); + + view.stdin.write(KEY.tab); + await waitFor(() => view.frame().includes('[id]')); + + expect(switched).toEqual([]); + + view.unmount(); + + }); + + it('should report Tab in browse mode', async () => { + + const switched: string[] = []; + + const view = await table({ onTab: () => switched.push('tab') }); + + view.stdin.write(KEY.tab); + await waitFor(() => switched > 0); + + expect(switched).toHaveLength(1); + + view.unmount(); + + }); + + it('should take its cursor from the parent when one is supplied', async () => { + + const moves: number[] = []; + + const view = await table({ + highlightedRow: 0, + onHighlightChange: (index) => moves.push(index), + }); + + view.stdin.write(KEY.down); + await waitFor(() => moves.length > 0); + + expect(moves.at(-1)).toBe(1); + + view.unmount(); + + }); + + it('should draw no cursor while it is inactive', async () => { + + // Two tables on screen, both drawing a highlight, is a claim that both + // answer to Enter. Only the active one does. + const view = await table({ active: false }); + + expect(view.frame()).not.toContain('[/] Filter'); + + // The cursor is drawn with an inverse SGR and nothing else, so with + // colour off - which is how CI runs this suite, and how the mutation + // harness runs it - the frame carries no trace of it either way and + // there is nothing left to assert against. Ink and this file share one + // chalk instance, so turning it on here is enough. + const level = chalk.level; + + chalk.level = 1; + + try { + + const raw = render( + + + , + ); + + await waitFor(() => strip(raw.lastFrame()).includes('id')); + + // Bold on the header proves colour is on, so the absence of the + // inverse below is a real absence rather than a disabled renderer. + expect(raw.lastFrame()).toContain('\u001B[1m'); + expect(raw.lastFrame()).not.toContain('\u001B[7m'); + + raw.unmount(); + + } + finally { + + chalk.level = level; + + } + + view.unmount(); + + }); + + it('should truncate a header too wide for its column instead of wrapping it', async () => { + + const view = await table({ + columns: ['a_very_long_column_name_indeed'], + rows: [{ a_very_long_column_name_indeed: 'x' }], + maxColumnWidth: 8, + }); + + const header = view.frame().split('\n').find((line) => line.includes('a_very')); + + expect(header).toContain('…'); + expect(view.frame()).not.toContain('name_indeed'); + + view.unmount(); + + }); + +}); + +// ----------------------------------------------------------------------------- +// The row view itself +// ----------------------------------------------------------------------------- + +describe('cli: screens/db/explore RowViewOverlay', () => { + + beforeEach(() => rememberRowFormat(DEFAULT_ROW_FORMAT)); + + const three = [ + { id: 1, note: 'first', extra: null }, + { id: 2, note: 'second', extra: 'x' }, + { id: 3, note: 'third', extra: '' }, + ]; + + async function viewer(overrides: Partial> = {}) { + + const moves: number[] = []; + const closes: string[] = []; + + const { stdin, lastFrame, unmount, rerender } = render( + + moves.push(index)} + onClose={() => closes.push('close')} + {...overrides} + /> + , + ); + + const frame = () => strip(lastFrame()); + + await waitFor(() => frame().includes(VIEW_HEADER)); + await settleRowView(stdin, frame); + + return { stdin, moves, closed: () => closes.length, frame, rerender, unmount }; + + } + + it('should draw one field per line in the remembered format', async () => { + + const view = await viewer(); + + expect(view.frame()).toContain('id: 1'); + expect(view.frame()).toContain('note: first'); + expect(view.frame()).toContain('extra: null'); + + view.unmount(); + + }); + + it('should say which row of which set is on screen', async () => { + + const view = await viewer({ index: 1 }); + + expect(view.frame()).toContain('First 3 by id'); + expect(view.frame()).toContain('row 2 of 3'); + + view.unmount(); + + }); + + it('should toggle to JSON and back on f', async () => { + + const view = await viewer(); + + view.stdin.write(KEY.format); + await waitFor(() => view.frame().includes('"id": 1')); + + expect(view.frame()).toContain('"note": "first"'); + + view.stdin.write(KEY.format); + await waitFor(() => view.frame().includes('id: 1')); + + view.unmount(); + + }); + + it('should remember the format for the next row it opens', async () => { + + const first = await viewer(); + + first.stdin.write(KEY.format); + await waitFor(() => first.frame().includes('"id": 1')); + + first.unmount(); + + const second = await viewer(); + + expect(second.frame()).toContain('"id": 1'); + + second.unmount(); + + }); + + it('should move to the next and previous row on the arrow keys', async () => { + + const view = await viewer({ index: 1 }); + + view.stdin.write(KEY.right); + await waitFor(() => view.moves.length > 0); + + expect(view.moves.at(-1)).toBe(2); + + view.stdin.write(KEY.left); + await waitFor(() => view.moves.length > 1); + + expect(view.moves.at(-1)).toBe(0); + + view.unmount(); + + }); + + it('should stop at the ends rather than wrap around them', async () => { + + // Wrapping from the last row to the first says they are adjacent, and + // in `ends` mode the set boundary is exactly where they are not. + const first = await viewer({ index: 0 }); + + first.stdin.write(KEY.left); + await waitFor(() => false, 100); + + expect(first.moves).toEqual([]); + + first.unmount(); + + const last = await viewer({ index: 2 }); + + last.stdin.write(KEY.right); + await waitFor(() => false, 100); + + expect(last.moves).toEqual([]); + + last.unmount(); + + }); + + it('should scroll a document taller than the viewport', async () => { + + const wide: Record = {}; + const columns: string[] = []; + + for (let index = 0; index < 40; index += 1) { + + const name = `col_${String(index).padStart(2, '0')}`; + wide[name] = index; + columns.push(name); + + } + + const view = await viewer({ rows: [wide], index: 0, columns, height: 12 }); + + expect(view.frame()).toContain('col_00: 0'); + expect(view.frame()).not.toContain('col_39: 39'); + + view.stdin.write(KEY.down); + await waitFor(() => !view.frame().includes('col_00: 0')); + + expect(view.frame()).toContain('more'); + + view.unmount(); + + }); + + it('should reset the scroll when it moves to another row', async () => { + + const tall = (id: number) => { + + const row: Record = { id }; + + for (let index = 0; index < 40; index += 1) row[`col_${index}`] = index; + + return row; + + }; + + const columns = ['id', ...Array.from({ length: 40 }, (_, index) => `col_${index}`)]; + + const view = await viewer({ rows: [tall(1), tall(2)], index: 0, columns, height: 12 }); + + view.stdin.write(KEY.down); + view.stdin.write(KEY.down); + await waitFor(() => !view.frame().includes('id: 1')); + + view.rerender( + + {}} + onClose={() => {}} + /> + , + ); + + await waitFor(() => view.frame().includes('id: 2')); + + // Without the reset the second row opens at the offset the first was + // left at, and `id` - its first line - is above the fold. + expect(view.frame()).toContain('id: 2'); + + view.unmount(); + + }); + + it('should close on Escape', async () => { + + const view = await viewer(); + + view.stdin.write(KEY.escape); + await waitFor(() => view.closed() > 0); + + expect(view.closed()).toBe(1); + + view.unmount(); + + }); + +}); + +// ----------------------------------------------------------------------------- +// The peek, driving all of it +// ----------------------------------------------------------------------------- + +describe('cli: screens/db/explore peek navigation', () => { + + beforeEach(() => rememberRowFormat(DEFAULT_ROW_FORMAT)); + + /** A peek whose head and tail are distinct, so `ends` mode is what renders. */ + function endsDb(head: Record[], tail: Record[]) { + + return createRecordingDb('postgres', [ + { match: / asc/, rows: head }, + { match: / desc/, rows: tail }, + ]); + + } + + async function peek(options: { + head?: Record[]; + tail?: Record[]; + detail?: TableDetail; + height?: number; + } = {}) { + + const head = options.head ?? rows([1, 2, 3]); + const db = options.tail + ? endsDb(head, options.tail) + : createRecordingDb('postgres', [{ match: /select/, rows: head }]); + + const closes: string[] = []; + + const { stdin, lastFrame, unmount } = render( + + closes.push('close')} + /> + , + ); + + const frame = () => strip(lastFrame()); + + // The header is drawn while the query is still running, so waiting for + // it alone hands back a spinner with no table mounted and every + // keystroke after it lands nowhere. + await waitFor(() => frame().includes(PEEK_HEADER) && !frame().includes('Reading')); + + const press = async (sequence: string, settled: (current: string) => boolean) => { + + stdin.write(sequence); + + await waitFor(() => settled(frame())); + + }; + + /** Enter, plus the wait the row view's own focus scope needs. */ + const open = async () => { + + await press(KEY.enter, (current) => current.includes(VIEW_HEADER)); + await settleRowView(stdin, frame); + + }; + + /** + * A key whose only visible effect is the cursor, which the stripped + * frame cannot show — the cursor is an inverse SGR and `strip` removes + * exactly that. Waiting on the raw frame changing is what keeps the + * next keystroke from being read against a stale render. + */ + const nudge = async (sequence: string) => { + + const before = lastFrame(); + + stdin.write(sequence); + + await waitFor(() => lastFrame() !== before); + + }; + + return { stdin, press, open, nudge, closed: () => closes.length, frame, unmount }; + + } + + /** Height that makes the page exactly 3 rows, so a 3-row head has a tail. */ + const PAGE_HEIGHT = 19; + + it('should open the highlighted row on Enter', async () => { + + const view = await peek(); + + await waitFor(() => view.frame().includes('row-'), 100); + + await view.press(KEY.enter, (current) => current.includes(VIEW_HEADER)); + + expect(view.frame()).toContain('id: 1'); + + view.unmount(); + + }); + + it('should open the row the cursor moved to, not the first one', async () => { + + const view = await peek(); + + await view.nudge(KEY.down); + await view.press(KEY.enter, (current) => current.includes(VIEW_HEADER)); + + expect(view.frame()).toContain('id: 2'); + + view.unmount(); + + }); + + it('should return to the peek on Escape with the cursor where it was', async () => { + + const view = await peek(); + + await view.nudge(KEY.down); + await view.open(); + await view.press(KEY.escape, (current) => !current.includes(VIEW_HEADER)); + + expect(view.frame()).toContain(PEEK_HEADER); + expect(view.closed()).toBe(0); + + // The cursor is still on row two, which Enter proves by opening it. + await view.press(KEY.enter, (current) => current.includes(VIEW_HEADER)); + + expect(view.frame()).toContain('id: 2'); + + view.unmount(); + + }); + + it('should carry the cursor moved inside the row view back to the table', async () => { + + const view = await peek(); + + await view.open(); + await view.press(KEY.right, (current) => current.includes('row 2 of 3')); + await view.press(KEY.escape, (current) => !current.includes(VIEW_HEADER)); + await view.press(KEY.enter, (current) => current.includes(VIEW_HEADER)); + + expect(view.frame()).toContain('id: 2'); + + view.unmount(); + + }); + + it('should close the peek on Escape from browse mode', async () => { + + const view = await peek(); + + await view.press(KEY.escape, () => true); + await waitFor(() => view.closed() > 0); + + expect(view.closed()).toBe(1); + + view.unmount(); + + }); + + it('should give Escape to the filter box before the peek claims it', async () => { + + const view = await peek(); + + await view.press(KEY.filter, (current) => current.includes('[Enter] Apply')); + await view.press(KEY.escape, (current) => !current.includes('[Enter] Apply')); + + expect(view.closed()).toBe(0); + expect(view.frame()).toContain(PEEK_HEADER); + + view.unmount(); + + }); + + it('should move focus between the two sets on Tab', async () => { + + const view = await peek({ + head: rows([1, 2, 3]), + // As a `desc` read returns them; the peek reverses for display. + tail: rows([9, 8, 7]), + height: PAGE_HEIGHT, + }); + + await waitFor(() => view.frame().includes('Last 3')); + + // Only the focused set advertises the keys it owns. + expect(view.frame().match(/\[\/\] Filter/g)).toHaveLength(1); + + await view.nudge(KEY.tab); + await view.press(KEY.enter, (current) => current.includes(VIEW_HEADER)); + + expect(view.frame()).toContain('Last 3'); + expect(view.frame()).toContain('id: 7'); + + view.unmount(); + + }); + + it('should not offer a second set to Tab into when there is only one', async () => { + + const view = await peek(); + + expect(view.frame()).not.toContain('[Tab]'); + + view.unmount(); + + }); + + it('should reach a column the grid had to drop', async () => { + + const many = Array.from({ length: 20 }, (_, index) => `col_${String(index).padStart(2, '0')}`); + const row: Record = {}; + + for (const name of many) row[name] = name; + + const detail = usersTable({ + columns: many.map((name, index) => column(name, { + ordinalPosition: index + 1, + isPrimaryKey: index === 0, + })), + }); + + const view = await peek({ head: [row], detail }); + + // The grid cannot draw twenty columns, so the last one is behind the + // marker; the row view is what makes dropping it acceptable. + expect(view.frame()).toContain('more column'); + expect(view.frame()).not.toContain('col_19'); + + await view.open(); + await view.press('\u001B[F', (current) => current.includes('col_19')); + + expect(view.frame()).toContain('col_19: col_19'); + + view.unmount(); + + }); + +}); diff --git a/tests/cli/screens/db/explore-scroll.test.tsx b/tests/cli/screens/db/explore-scroll.test.tsx new file mode 100644 index 00000000..fe13a1c8 --- /dev/null +++ b/tests/cli/screens/db/explore-scroll.test.tsx @@ -0,0 +1,821 @@ +/** + * Explore detail vertical-scrolling tests. + * + * The detail screen rendered every column, index, and foreign key + * unconditionally, so a table with more columns than the terminal had rows put + * the overflow somewhere no key could reach: the screen's only binding was + * Escape. The contract pinned here is that every row a detail view produces is + * reachable — by arrow, by page, and by End — and that the viewport never + * draws more lines than the budget it was given. + * + * Rows come from the real `tableDetailRows` builder rather than synthetic + * placeholders, so the count under test is the count the screen actually + * renders, section headers and blank separators included. + */ +import { describe, it, expect } from 'bun:test'; +import { render } from 'ink-testing-library'; +import { Box, Text } from 'ink'; +import React from 'react'; + +import type { ColumnDetail, ParameterDetail, TableDetail } from '../../../../src/core/explore/types.js'; + +import { Panel } from '../../../../src/tui/components/index.js'; +import { + ScrollView, + tableDetailRows, + viewDetailRows, + procedureDetailRows, + functionDetailRows, + typeDetailRows, +} from '../../../../src/tui/screens/db/explore/ExploreDetailScreen.js'; +import { + detailFooterHints, + pageKeyLabel, + rowWindow, + viewportRows, + wrapText, +} from '../../../../src/tui/screens/db/explore/layout.js'; + +import type { DetailRow } from '../../../../src/tui/screens/db/explore/layout.js'; + +/** Row budget inside the explore Panel on a 100-column terminal. */ +const WIDE = 96; + +/** Viewport height the scrolling cases run at. */ +const HEIGHT = 12; + +// eslint-disable-next-line no-control-regex -- matching the ANSI SGR escape is the point +const ANSI_PATTERN = /\u001B\[[0-9;]*m/g; + +function strip(frame: string | undefined): string { + + return (frame ?? '').replace(ANSI_PATTERN, ''); + +} + +/** + * Poll rather than sleep a guessed duration: a fixed wait is the suite's known + * flake class under load. + */ +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 KEY = { + up: '\u001B[A', + down: '\u001B[B', + pageUp: '\u001B[5~', + pageDown: '\u001B[6~', + home: '\u001B[H', + end: '\u001B[F', + ctrlU: '\u0015', + ctrlD: '\u0004', + + // Kitty's `CSI 1 ; : ` form, with the super + // modifier bit (8) encoded as 9. The legacy `CSI 1 ; 9 A` a non-kitty + // terminal sends for the same chord parses as Alt and never sets + // `key.super`, which is why this binding stays out of the footer. + superUp: '\u001B[1;9:1A', + superDown: '\u001B[1;9:1B', +} as const; + +/** + * Fixed-width names so `col_07` is never a prefix of another row's name and a + * presence assertion means exactly one row. + */ +function wideTable(columnCount: number): TableDetail { + + const columns: ColumnDetail[] = Array.from({ length: columnCount }, (_, index) => ({ + name: `col_${String(index).padStart(2, '0')}`, + dataType: 'text', + isNullable: index % 2 === 0, + isPrimaryKey: index === 0, + ordinalPosition: index + 1, + })); + + return { + name: 'wide_table', + schema: 'public', + columns, + indexes: [], + foreignKeys: [], + rowCountEstimate: 1234, + }; + +} + +/** + * Render a scroller and hand back a reader plus a key writer that waits for the + * frame to actually change before returning. + */ +async function scroller(rows: DetailRow[], height: number) { + + const { stdin, lastFrame, unmount } = render( + , + ); + + await waitFor(() => strip(lastFrame()).length > 0); + + const press = async (sequence: string, settled: (frame: string) => boolean) => { + + stdin.write(sequence); + + await waitFor(() => settled(strip(lastFrame()))); + + }; + + return { frame: () => strip(lastFrame()), press, unmount }; + +} + +describe('cli: screens/db/explore scrolling', () => { + + describe('rowWindow', () => { + + it('should show everything and reserve no gutter when the content fits', () => { + + expect(rowWindow(8, 0, 12)).toEqual({ start: 0, count: 8, above: 0, below: 0 }); + + }); + + it('should hold back both indicator lines once the content overflows', () => { + + expect(rowWindow(40, 0, 12)).toEqual({ start: 0, count: 10, above: 0, below: 30 }); + + }); + + it('should report what sits above and below the viewport', () => { + + expect(rowWindow(40, 5, 12)).toEqual({ start: 5, count: 10, above: 5, below: 25 }); + + }); + + it('should clamp an offset that would strand the viewport past the end', () => { + + expect(rowWindow(40, 999, 12)).toEqual({ start: 30, count: 10, above: 30, below: 0 }); + + }); + + it('should clamp a negative offset to the top', () => { + + expect(rowWindow(40, -5, 12)).toEqual({ start: 0, count: 10, above: 0, below: 30 }); + + }); + + it('should collapse to no scrolling when the content shrinks under the budget', () => { + + // A resize or a smaller object must not leave the old offset in play. + expect(rowWindow(3, 30, 12)).toEqual({ start: 0, count: 3, above: 0, below: 0 }); + + }); + + }); + + describe('viewportRows', () => { + + it('should reserve the screen\'s real chrome, not the form screen\'s', () => { + + // Shell header and rule (2), status bar and rule (2), panel border + // (2), title and its blank line (2), vertical padding (2), the gap + // above the footer (1), the footer (1). + expect(viewportRows(40)).toBe(28); + + }); + + it('should keep a usable floor on a terminal too short to pay the chrome', () => { + + expect(viewportRows(10)).toBeGreaterThanOrEqual(5); + expect(viewportRows(1)).toBeGreaterThanOrEqual(5); + + }); + + }); + + describe('wrapText', () => { + + it('should break on word boundaries and never exceed the width', () => { + + const lines = wrapText('select a, b, c from some_table where id = 1', 12); + + for (const line of lines) { + + expect(line.length).toBeLessThanOrEqual(12); + + } + + expect(lines.join(' ')).toContain('some_table'); + + }); + + it('should hard-split a token with no break in it', () => { + + const lines = wrapText('a'.repeat(25), 10); + + expect(lines).toHaveLength(3); + + }); + + it('should keep blank lines so a definition\'s shape survives', () => { + + expect(wrapText('one\n\ntwo', 40)).toEqual(['one', '', 'two']); + + }); + + }); + + describe('ScrollView', () => { + + it('should render every row and no indicator when the content fits', async () => { + + const rows = tableDetailRows(wideTable(3), WIDE); + const view = await scroller(rows, 40); + const frame = view.frame(); + + expect(frame).toContain('col_00'); + expect(frame).toContain('col_02'); + expect(frame).not.toContain('more'); + expect(frame.split('\n').length).toBeLessThanOrEqual(40); + + view.unmount(); + + }); + + it('should draw no more lines than the height it was budgeted', async () => { + + const rows = tableDetailRows(wideTable(40), WIDE); + const view = await scroller(rows, HEIGHT); + + expect(rows.length).toBeGreaterThan(HEIGHT); + expect(view.frame().split('\n').length).toBeLessThanOrEqual(HEIGHT); + + // Mid-scroll is the tallest case: both indicators plus the viewport. + await view.press(KEY.down, (frame) => frame.includes('col_07')); + + expect(view.frame().split('\n')).toHaveLength(HEIGHT); + + view.unmount(); + + }); + + it('should mark what is still below the fold', async () => { + + const rows = tableDetailRows(wideTable(40), WIDE); + const view = await scroller(rows, HEIGHT); + const frame = view.frame(); + + expect(frame).toContain('↓'); + expect(frame).toContain('more'); + expect(frame).not.toContain('↑'); + + view.unmount(); + + }); + + // The regression. Before the fix the detail screen bound Escape and + // nothing else, so a row below the fold could not be reached at all. + it('should reach a row past the fold with the down arrow', async () => { + + const rows = tableDetailRows(wideTable(40), WIDE); + const view = await scroller(rows, HEIGHT); + + expect(view.frame()).not.toContain('col_07'); + + await view.press(KEY.down, (frame) => frame.includes('col_07')); + + expect(view.frame()).toContain('col_07'); + expect(view.frame()).toContain('↑'); + + view.unmount(); + + }); + + // The other half of the regression: the far end has to be reachable in + // one keystroke, not by holding an arrow down forty times. + it('should reach the last row with End', async () => { + + const rows = tableDetailRows(wideTable(40), WIDE); + const view = await scroller(rows, HEIGHT); + + expect(view.frame()).not.toContain('col_39'); + + await view.press(KEY.end, (frame) => frame.includes('col_39')); + + const frame = view.frame(); + + expect(frame).toContain('col_39'); + expect(frame).toContain('↑'); + expect(frame).not.toContain('↓'); + expect(frame.split('\n')).toHaveLength(HEIGHT - 1); + + view.unmount(); + + }); + + it('should move by a viewport on PageDown and back on PageUp', async () => { + + const rows = tableDetailRows(wideTable(40), WIDE); + const view = await scroller(rows, HEIGHT); + + await view.press(KEY.pageDown, (frame) => frame.includes('col_16')); + + expect(view.frame()).toContain('col_16'); + expect(view.frame()).not.toContain('col_00'); + + await view.press(KEY.pageUp, (frame) => frame.includes('col_00')); + + expect(view.frame()).toContain('col_00'); + expect(view.frame()).not.toContain('↑'); + + view.unmount(); + + }); + + it('should return to the top with Home', async () => { + + const rows = tableDetailRows(wideTable(40), WIDE); + const view = await scroller(rows, HEIGHT); + + await view.press(KEY.end, (frame) => frame.includes('col_39')); + + // Without this the case passes on a viewport that never moved. + expect(view.frame()).not.toContain('public.wide_table'); + + await view.press(KEY.home, (frame) => frame.includes('wide_table')); + + const frame = view.frame(); + + expect(frame).toContain('public.wide_table'); + expect(frame).not.toContain('↑'); + expect(frame).toContain('↓'); + + view.unmount(); + + }); + + it('should step back one row on the up arrow', async () => { + + const rows = tableDetailRows(wideTable(40), WIDE); + const view = await scroller(rows, HEIGHT); + + await view.press(KEY.end, (frame) => frame.includes('col_39')); + await view.press(KEY.up, (frame) => !frame.includes('col_39')); + + expect(view.frame()).not.toContain('col_39'); + expect(view.frame()).toContain('col_38'); + expect(view.frame()).toContain('↓'); + + view.unmount(); + + }); + + it('should ignore scroll keys while unfocused', async () => { + + const rows = tableDetailRows(wideTable(40), WIDE); + const { stdin, lastFrame, unmount } = render( + , + ); + + await waitFor(() => strip(lastFrame()).length > 0); + + stdin.write(KEY.end); + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(strip(lastFrame())).not.toContain('col_39'); + + unmount(); + + }); + + it('should not strand the viewport past the end when the content shrinks', async () => { + + const rows = tableDetailRows(wideTable(40), WIDE); + const { stdin, lastFrame, rerender, unmount } = render( + , + ); + + await waitFor(() => strip(lastFrame()).length > 0); + + stdin.write(KEY.end); + await waitFor(() => strip(lastFrame()).includes('col_39')); + + rerender(); + await waitFor(() => strip(lastFrame()).includes('col_00')); + + const frame = strip(lastFrame()); + + expect(frame).toContain('public.wide_table'); + expect(frame).toContain('col_00'); + expect(frame).not.toContain('more'); + + unmount(); + + }); + + }); + + /** + * PageUp/PageDown work on a Mac — fn+↑ and fn+↓ send CSI 5~ and + * CSI 6~ — but nothing on the keyboard is labelled that way, and ⌘ is + * swallowed by Terminal.app and iTerm2 before the process ever sees it. So + * Ctrl+U/Ctrl+D are the paging keys that reach every terminal on every + * platform, and the platform-native page keys stay bound behind them. + */ + describe('paging keys', () => { + + it('should move half a viewport on Ctrl+D and back on Ctrl+U', async () => { + + const rows = tableDetailRows(wideTable(40), WIDE); + const view = await scroller(rows, HEIGHT); + + // Ten drawn rows, so half is five: title, blank, heading, col_00 + // and col_01 scroll off and col_11 arrives. + await view.press(KEY.ctrlD, (frame) => frame.includes('col_11')); + + expect(view.frame()).toContain('col_11'); + expect(view.frame()).not.toContain('col_01'); + + await view.press(KEY.ctrlU, (frame) => frame.includes('col_00')); + + expect(view.frame()).toContain('col_00'); + expect(view.frame()).not.toContain('↑'); + + view.unmount(); + + }); + + it('should page on ⌘+↓ and back on ⌘+↑ under the kitty protocol', async () => { + + const rows = tableDetailRows(wideTable(40), WIDE); + const view = await scroller(rows, HEIGHT); + + await view.press(KEY.superDown, (frame) => frame.includes('col_16')); + + expect(view.frame()).toContain('col_16'); + expect(view.frame()).not.toContain('col_00'); + + await view.press(KEY.superUp, (frame) => frame.includes('col_00')); + + expect(view.frame()).toContain('col_00'); + + view.unmount(); + + }); + + // ⌘+↓ must page, not step one row, and the arrow branch is what it + // would fall through to if `key.super` were tested after `key.upArrow`. + it('should not treat ⌘+↓ as a plain down arrow', async () => { + + const rows = tableDetailRows(wideTable(40), WIDE); + const view = await scroller(rows, HEIGHT); + + await view.press(KEY.superDown, (frame) => frame.includes('↑')); + + // The distance is the tell, not which rows are on screen: a page is + // ten rows and a step is one, and both leave col_07 visible. + expect(view.frame()).toContain('↑ 10 more'); + + view.unmount(); + + }); + + }); + + /** + * The footer is the only place these bindings are documented, and it is a + * single wrapping line, so what it names and how wide it runs are both part + * of the contract. `fn ↑↓` on a Mac because no Mac keyboard has a key + * labelled PgUp; ⌘+↑↓ is deliberately absent because it only reaches the + * process under the kitty protocol. + */ + describe('footer hints', () => { + + it('should name the Mac page keys on darwin and the PC ones elsewhere', () => { + + expect(pageKeyLabel('darwin')).toBe('fn ↑↓'); + expect(pageKeyLabel('linux')).toBe('PgUp/PgDn'); + expect(pageKeyLabel('win32')).toBe('PgUp/PgDn'); + + }); + + it('should build a footer that names the platform\'s own page keys', () => { + + const mac = detailFooterHints({ scrolls: true, overlay: 'none', platform: 'darwin' }); + const pc = detailFooterHints({ scrolls: true, overlay: 'none', platform: 'linux' }); + + expect(mac).toContain('[fn ↑↓] Page'); + expect(mac.join(' ')).not.toContain('PgUp'); + + expect(pc).toContain('[PgUp/PgDn] Page'); + expect(pc.join(' ')).not.toContain('fn '); + + }); + + it('should advertise Ctrl+U/Ctrl+D and never ⌘, which most terminals eat', () => { + + for (const platform of ['darwin', 'linux'] as const) { + + const hints = detailFooterHints({ scrolls: true, overlay: 'none', platform }); + + expect(hints).toContain('[^U/^D] Half'); + expect(hints.join(' ')).not.toContain('⌘'); + + } + + }); + + it('should offer the full-text view whether or not the detail scrolls', () => { + + const scrolling = detailFooterHints({ scrolls: true, overlay: 'none', platform: 'darwin' }); + const fitting = detailFooterHints({ scrolls: false, overlay: 'none', platform: 'darwin' }); + + expect(scrolling).toContain('[v] Full text'); + expect(fitting).toContain('[v] Full text'); + + // A detail that fits has nothing to scroll, so it says so. + expect(fitting.join(' ')).not.toContain('Scroll'); + + }); + + it('should swap to the overlay\'s own keys while the full-text view is open', () => { + + const hints = detailFooterHints({ scrolls: true, overlay: 'fullText', platform: 'darwin' }); + + expect(hints).toContain('[Esc] Close'); + expect(hints.join(' ')).not.toContain('[v]'); + expect(hints.join(' ')).not.toContain('Back'); + + }); + + // Adding hints without removing any would push the wrap point up from + // the 58 columns it already sits at. The Home/End hint went to pay for + // the two that arrived. + it('should stay inside one line of an 80-column terminal', () => { + + for (const platform of ['darwin', 'linux'] as const) { + + const width = detailFooterHints({ scrolls: true, overlay: 'none', platform }) + .join(' ').length; + + expect(width).toBeLessThanOrEqual(80); + + } + + }); + + }); + + describe('chrome accounting', () => { + + /** + * A stand-in for the shell the screen renders inside, with the same Box + * props `AppShell` gives its header and status bar. Mirrored rather than + * imported because `AppShell` drags in every provider; if that structure + * changes, `DETAIL_CHROME_ROWS` changes with it and this case is where + * the two are compared. + */ + function shell(terminalRows: number, rows: DetailRow[]) { + + return ( + + + DB › Explore › Table + + + + + + + + [↑↓] Scroll + [PgUp/PgDn] Page + [Home/End] Jump + [Esc] Back + + + + + STATUS + + + ); + + } + + it('should leave the footer and the status bar on screen at every scroll position', async () => { + + const rows = tableDetailRows(wideTable(60), WIDE); + const { stdin, lastFrame, unmount } = render(shell(30, rows)); + + await waitFor(() => strip(lastFrame()).includes('STATUS')); + + expect(strip(lastFrame()).split('\n')).toHaveLength(30); + expect(strip(lastFrame())).toContain('[Esc] Back'); + + // Mid-scroll draws both indicators, which is the tallest the panel + // ever gets. If the reserve were a row short, this is where the + // status bar would be pushed off. + stdin.write(KEY.pageDown); + await waitFor(() => strip(lastFrame()).includes('↑')); + + const frame = strip(lastFrame()); + + expect(frame.split('\n')).toHaveLength(30); + expect(frame).toContain('STATUS'); + expect(frame).toContain('[Esc] Back'); + + unmount(); + + }); + + }); + + describe('tableDetailRows', () => { + + it('should emit one element per visual line', async () => { + + const detail = wideTable(4); + const rows = tableDetailRows(detail, WIDE); + const view = await scroller(rows, 40); + + expect(view.frame().split('\n')).toHaveLength(rows.length); + + view.unmount(); + + }); + + it('should carry the header, the section title, and every column', () => { + + const rows = tableDetailRows(wideTable(4), WIDE); + + // header + blank + section title + 4 columns + expect(rows).toHaveLength(7); + + }); + + it('should separate indexes and foreign keys into their own sections', () => { + + const detail = wideTable(2); + + detail.indexes = [ + { name: 'wide_table_pkey', tableName: 'wide_table', columns: ['col_00'], isUnique: true, isPrimary: true }, + ]; + detail.foreignKeys = [ + { + name: 'wide_table_col_01_fkey', + tableName: 'wide_table', + columns: ['col_01'], + referencedTable: 'other', + referencedColumns: ['id'], + }, + ]; + + const rows = tableDetailRows(detail, WIDE); + + // header, blank, Columns, 2 columns, blank, Indexes, 1 index, + // blank, Foreign Keys, 2 lines for the one key. + expect(rows).toHaveLength(12); + + }); + + }); + + /** + * Only the table view was exercised above, and it is the one with no + * definition dump. These cover the other four: the row count each builder + * claims has to be the number of lines Ink actually draws, or the viewport + * windows to the wrong place. + */ + describe('the other four detail views', () => { + + const parameters: ParameterDetail[] = [ + { name: 'p_tenant', dataType: 'uuid', mode: 'IN', ordinalPosition: 1 }, + { name: 'p_from', dataType: 'timestamp with time zone', mode: 'IN', ordinalPosition: 2 }, + ]; + + const definition = `select ${'col_a, '.repeat(80)}col_z from some_table`; + + async function linesDrawn(rows: DetailRow[]): Promise { + + const view = await scroller(rows, rows.length + 10); + const drawn = view.frame().split('\n').length; + + view.unmount(); + + return drawn; + + } + + it('should count a view\'s rows, wrapped definition included', async () => { + + const rows = viewDetailRows({ + name: 'active_users', + schema: 'public', + columns: [{ name: 'id', dataType: 'bigint', isNullable: false, isPrimaryKey: true, ordinalPosition: 1 }], + definition, + isUpdatable: false, + }, WIDE); + + expect(await linesDrawn(rows)).toBe(rows.length); + expect(rows.length).toBeGreaterThan(6); + + }); + + it('should count a procedure\'s rows', async () => { + + const rows = procedureDetailRows({ + name: 'rebuild_index', + schema: 'public', + parameters, + definition, + }, WIDE); + + expect(await linesDrawn(rows)).toBe(rows.length); + + }); + + it('should count a function\'s rows', async () => { + + const rows = functionDetailRows({ + name: 'tenant_rows', + schema: 'public', + parameters, + returnType: 'integer', + definition, + }, WIDE); + + expect(await linesDrawn(rows)).toBe(rows.length); + + }); + + it('should count an enum type\'s rows and scroll a long one', async () => { + + const values = Array.from({ length: 40 }, (_, index) => `value_${String(index).padStart(2, '0')}`); + const rows = typeDetailRows({ name: 'status', schema: 'public', kind: 'enum', values }, WIDE); + + // header, blank, Values (40), 40 values + expect(rows).toHaveLength(43); + expect(await linesDrawn(rows)).toBe(rows.length); + + const view = await scroller(rows, HEIGHT); + + expect(view.frame()).not.toContain('value_39'); + + await view.press(KEY.end, (frame) => frame.includes('value_39')); + + expect(view.frame()).toContain('value_39'); + + view.unmount(); + + }); + + it('should count a composite and a domain type\'s rows', async () => { + + const composite = typeDetailRows({ + name: 'address', + schema: 'public', + kind: 'composite', + attributes: [ + { name: 'street', dataType: 'text', isNullable: true, isPrimaryKey: false, ordinalPosition: 1 }, + { name: 'zip', dataType: 'text', isNullable: true, isPrimaryKey: false, ordinalPosition: 2 }, + ], + }, WIDE); + + const domain = typeDetailRows({ + name: 'email', + schema: 'public', + kind: 'domain', + baseType: 'text', + }, WIDE); + + expect(composite).toHaveLength(5); + expect(await linesDrawn(composite)).toBe(composite.length); + + expect(domain).toHaveLength(4); + expect(await linesDrawn(domain)).toBe(domain.length); + + }); + + }); + +}); diff --git a/tests/cli/screens/db/explore-value.test.tsx b/tests/cli/screens/db/explore-value.test.tsx new file mode 100644 index 00000000..6c616d0b --- /dev/null +++ b/tests/cli/screens/db/explore-value.test.tsx @@ -0,0 +1,428 @@ +/** + * Explore detail full-text overlay tests. + * + * Aligning the explore columns bought row-to-row consistency by truncating + * whatever overflowed, and left no way to read what was cut: the detail screen + * had no scroll-right, no expand, and no copy, so a Postgres default like + * `nextval('cron.jobid_seq'::regclass)` was only reachable by leaving for the + * SQL terminal and querying `information_schema` by hand. + * + * The contract pinned here is that `v` opens a view in which nothing is + * truncated, that it wraps rather than clipping a second time, that it covers + * every kind of row a detail view emits, and that Escape puts the reader back + * exactly where they were — same scroll offset, same keys live again. + */ +import { describe, it, expect } from 'bun:test'; +import { render } from 'ink-testing-library'; +import React from 'react'; + +import type { + ColumnDetail, + ForeignKeySummary, + IndexSummary, + ParameterDetail, + TableDetail, +} from '../../../../src/core/explore/types.js'; + +import { FocusProvider } from '../../../../src/tui/focus.js'; +import { + ScrollView, + procedureDetailRows, + tableDetailRows, +} from '../../../../src/tui/screens/db/explore/ExploreDetailScreen.js'; + +import type { DetailRow } from '../../../../src/tui/screens/db/explore/layout.js'; + +/** Row budget inside the explore Panel on the 100-column test terminal. */ +const WIDE = 96; + +/** + * The overlay's own header. Asserted on rather than inferred from the content, + * because every one of these cases would otherwise pass on a viewport that + * never opened anything. + */ +const OVERLAY_HEADER = 'Full text'; + +/** Viewport height the scrolling cases run at. */ +const HEIGHT = 12; + +// eslint-disable-next-line no-control-regex -- matching the ANSI SGR escape is the point +const ANSI_PATTERN = /\u001B\[[0-9;]*m/g; + +function strip(frame: string | undefined): string { + + return (frame ?? '').replace(ANSI_PATTERN, ''); + +} + +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 KEY = { + down: '\u001B[B', + end: '\u001B[F', + escape: '\u001B', + value: 'v', +} as const; + +/** + * The default that started this: 35 characters of `nextval(...)` behind a name, + * a type, and a nullability clause, which is more than a 100-column terminal + * has left by the time it gets there. + */ +const LONG_DEFAULT = 'nextval(\'cron.jobid_seq\'::regclass)'; + +function column(name: string, dataType: string, overrides: Partial = {}): ColumnDetail { + + return { + name, + dataType, + isNullable: false, + isPrimaryKey: false, + ordinalPosition: 0, + ...overrides, + }; + +} + +/** + * `cron.job` as the report saw it. The 30-character identifier and the + * `timestamp with time zone` are load-bearing: they are what widen the name and + * type cells far enough that the trailing constraint cell has to truncate the + * `nextval(...)` default on a 100-column terminal. + */ +function cronJob(): TableDetail { + + return { + name: 'job', + schema: 'cron', + columns: [ + column('jobid', 'bigint', { isPrimaryKey: true, defaultValue: LONG_DEFAULT }), + column('schedule', 'text'), + column('command', 'text'), + column('nodename', 'text', { defaultValue: '\'localhost\'::text' }), + column('active', 'boolean', { defaultValue: 'true' }), + column('jobname', 'text', { isNullable: true }), + column('last_successful_run_started_at', 'timestamp with time zone', { isNullable: true }), + ], + indexes: [], + foreignKeys: [], + }; + +} + +/** + * Fixed-width names so `col_07` is never a prefix of another row's name. + */ +function wideTable(columnCount: number): TableDetail { + + const columns: ColumnDetail[] = Array.from({ length: columnCount }, (_, index) => ({ + name: `col_${String(index).padStart(2, '0')}`, + dataType: 'text', + isNullable: index % 2 === 0, + isPrimaryKey: index === 0, + ordinalPosition: index + 1, + })); + + return { + name: 'wide_table', + schema: 'public', + columns, + indexes: [], + foreignKeys: [], + rowCountEstimate: 1234, + }; + +} + +/** + * A scroller inside a focus provider, because the overlay opens a focus scope + * of its own and has to be able to hand focus back when it closes. + */ +async function scroller(rows: DetailRow[], height: number) { + + const { stdin, lastFrame, unmount } = render( + + + , + ); + + await waitFor(() => strip(lastFrame()).length > 0); + + const press = async (sequence: string, settled: (frame: string) => boolean) => { + + stdin.write(sequence); + + await waitFor(() => settled(strip(lastFrame()))); + + }; + + return { frame: () => strip(lastFrame()), press, unmount }; + +} + +describe('cli: screens/db/explore full-text overlay', () => { + + describe('reaching a truncated value', () => { + + it('should clip the long default before the overlay is opened', async () => { + + // The premise. Without this the next case could pass on a viewport + // that never truncated anything. + const view = await scroller(tableDetailRows(cronJob(), WIDE), HEIGHT); + + expect(view.frame()).toContain('…'); + expect(view.frame()).not.toContain(LONG_DEFAULT); + + view.unmount(); + + }); + + it('should show the whole default once v is pressed', async () => { + + const view = await scroller(tableDetailRows(cronJob(), WIDE), HEIGHT); + + await view.press(KEY.value, (frame) => frame.includes(LONG_DEFAULT)); + + expect(view.frame()).toContain(LONG_DEFAULT); + + view.unmount(); + + }); + + it('should wrap a value too long for one line instead of clipping it again', async () => { + + const sprawling = 'a_very_long_default_expression_' + 'x'.repeat(200); + const detail = cronJob(); + + detail.columns[0].defaultValue = sprawling; + + const view = await scroller(tableDetailRows(detail, WIDE), HEIGHT); + + await view.press(KEY.value, (frame) => frame.includes('a_very_long_default_expression_')); + + const frame = view.frame(); + + expect(frame).not.toContain('…'); + + // Wrapped, not clipped: every piece of the value is on screen, and + // no line runs past the terminal. + expect(frame.replace(/\n/g, '')).toContain(sprawling); + + for (const line of frame.split('\n')) { + + expect(line.length).toBeLessThanOrEqual(100); + + } + + view.unmount(); + + }); + + }); + + describe('every row type the screen renders', () => { + + it('should carry a column, an index, and a foreign key in full', async () => { + + const indexes: IndexSummary[] = [{ + name: 'job_username_nodename_database_schedule_idx', + tableName: 'job', + columns: ['username', 'nodename', 'database', 'schedule', 'command'], + isUnique: true, + isPrimary: false, + }]; + + const foreignKeys: ForeignKeySummary[] = [{ + name: 'job_run_details_jobid_fkey', + tableName: 'job_run_details', + columns: ['jobid'], + referencedTable: 'cron.job_definitions_and_history', + referencedColumns: ['jobid'], + }]; + + const detail = cronJob(); + + detail.indexes = indexes; + detail.foreignKeys = foreignKeys; + + const rows = tableDetailRows(detail, WIDE); + const view = await scroller(rows, rows.length + 20); + + await view.press(KEY.value, (frame) => frame.includes(LONG_DEFAULT)); + + const flat = view.frame().replace(/\n/g, ''); + + expect(flat).toContain(LONG_DEFAULT); + expect(flat).toContain('job_username_nodename_database_schedule_idx'); + expect(flat).toContain('username, nodename, database, schedule, command'); + expect(flat).toContain('cron.job_definitions_and_history'); + + view.unmount(); + + }); + + it('should carry a parameter and a definition line in full', async () => { + + const parameters: ParameterDetail[] = [ + { name: 'p_tenant_identifier_with_a_long_name', dataType: 'timestamp with time zone', mode: 'INOUT', ordinalPosition: 1 }, + ]; + + const rows = procedureDetailRows({ + name: 'rebuild_index', + schema: 'public', + parameters, + definition: 'begin refresh materialized view concurrently public.tenant_rollup; end;', + }, WIDE); + + const view = await scroller(rows, rows.length + 20); + + await view.press(KEY.value, (frame) => frame.includes('INOUT')); + + const flat = view.frame().replace(/\n/g, ''); + + expect(flat).toContain('p_tenant_identifier_with_a_long_name'); + expect(flat).toContain('timestamp with time zone'); + expect(flat).toContain('refresh materialized view concurrently'); + + view.unmount(); + + }); + + }); + + describe('giving the screen back', () => { + + it('should restore the exact scroll offset on Escape', async () => { + + const view = await scroller(tableDetailRows(wideTable(40), WIDE), HEIGHT); + + await view.press(KEY.end, (frame) => frame.includes('col_39')); + + const before = view.frame(); + + await view.press(KEY.value, (frame) => frame.includes(OVERLAY_HEADER)); + + expect(view.frame()).toContain(OVERLAY_HEADER); + expect(view.frame()).not.toBe(before); + + await view.press(KEY.escape, (frame) => !frame.includes(OVERLAY_HEADER)); + + expect(view.frame()).toBe(before); + + view.unmount(); + + }); + + it('should let the viewport keep scrolling once the overlay is gone', async () => { + + const view = await scroller(tableDetailRows(wideTable(40), WIDE), HEIGHT); + + await view.press(KEY.value, (frame) => frame.includes(OVERLAY_HEADER)); + + expect(view.frame()).toContain(OVERLAY_HEADER); + + await view.press(KEY.escape, (frame) => !frame.includes(OVERLAY_HEADER)); + + // Focus has to come back, or the screen is stuck on a viewport that + // no longer answers to anything. + await view.press(KEY.down, (frame) => frame.includes('col_07')); + + expect(view.frame()).toContain('col_07'); + + view.unmount(); + + }); + + it('should leave the viewport keys inert while the overlay is up', async () => { + + const rows = tableDetailRows(wideTable(40), WIDE); + const view = await scroller(rows, HEIGHT); + + await view.press(KEY.value, (frame) => frame.includes(OVERLAY_HEADER)); + + // End belongs to the overlay now. It moves the overlay to its last + // line; the viewport underneath must still be where it was. + await view.press(KEY.end, (frame) => frame.includes('col_39')); + + expect(view.frame()).toContain(OVERLAY_HEADER); + + await view.press(KEY.escape, (frame) => !frame.includes(OVERLAY_HEADER)); + + expect(view.frame()).toContain('public.wide_table'); + expect(view.frame()).not.toContain('col_39'); + + view.unmount(); + + }); + + }); + + describe('staying inside its budget', () => { + + it('should never draw more lines than the height it was given', async () => { + + const rows = tableDetailRows(wideTable(60), WIDE); + const view = await scroller(rows, HEIGHT); + + await view.press(KEY.value, (frame) => frame.includes(OVERLAY_HEADER)); + + expect(view.frame()).toContain(OVERLAY_HEADER); + expect(view.frame().split('\n').length).toBeLessThanOrEqual(HEIGHT); + + view.unmount(); + + }); + + it('should open where the reader was, not back at the top', async () => { + + const rows = tableDetailRows(wideTable(60), WIDE); + const view = await scroller(rows, HEIGHT); + + await view.press(KEY.end, (frame) => frame.includes('col_59')); + await view.press(KEY.value, (frame) => frame.includes(OVERLAY_HEADER)); + + const frame = view.frame(); + + // `col_50` is the row the viewport had at its top after End. The + // overlay draws one line fewer than the viewport, so the window is + // not identical - what has to match is where it starts. + expect(frame).toContain(OVERLAY_HEADER); + expect(frame).toContain('col_50'); + expect(frame).not.toContain('public.wide_table'); + expect(frame).not.toContain('col_49'); + + view.unmount(); + + }); + + it('should scroll the overlay itself when the full text overflows', async () => { + + const rows = tableDetailRows(wideTable(60), WIDE); + const view = await scroller(rows, HEIGHT); + + await view.press(KEY.value, (frame) => frame.includes(OVERLAY_HEADER)); + + expect(view.frame()).not.toContain('col_59'); + + await view.press(KEY.end, (frame) => frame.includes('col_59')); + + expect(view.frame()).toContain('col_59'); + expect(view.frame()).toContain(OVERLAY_HEADER); + + view.unmount(); + + }); + + }); + +}); diff --git a/tests/cli/screens/db/sql-cancel.test.tsx b/tests/cli/screens/db/sql-cancel.test.tsx new file mode 100644 index 00000000..cbbec8cf --- /dev/null +++ b/tests/cli/screens/db/sql-cancel.test.tsx @@ -0,0 +1,290 @@ +/** + * SQL terminal: the escape hatch for a query that never comes back. + * + * What is pinned: + * + * - A running query advertises Escape, and Escape returns the screen to the + * input rather than leaving a spinner nobody can dismiss. + * - The wording matches what actually happened on this dialect. Sqlite gets no + * cancel sent to it, so the screen must not claim one. + * - A query that answers after being cancelled is dropped: it neither replaces + * the cancellation on screen nor reaches the history file. + */ +import { describe, it, expect, vi, mock, beforeEach, afterEach, afterAll } from 'bun:test'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { SqlExecutionResult } from '../../../../src/core/sql-terminal/types.js'; + +import { FocusProvider } from '../../../../src/tui/focus.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 { SqlTerminalScreen } from '../../../../src/tui/screens/db/SqlTerminalScreen.js'; + +const actualCore = await import('../../../../src/core/index.js'); +const actualSqlTerminal = await import('../../../../src/core/sql-terminal/index.js'); + +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:', + }, + }; + +} + +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()), + 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' }, + getStages: vi.fn().mockReturnValue({}), + getStage: vi.fn().mockReturnValue(undefined), +}); + +const mockStateManager = createMockStateManager(); +const mockSettingsManager = createMockSettingsManager(); + +mock.module('../../../../src/core/index.js', () => ({ + ...actualCore, + getStateManager: vi.fn(() => mockStateManager), + getSettingsManager: vi.fn(() => mockSettingsManager), + resetStateManager: vi.fn(), + resetSettingsManager: vi.fn(), +})); + +mock.module('../../../../src/core/identity/index.js', () => ({ + loadExistingIdentity: vi.fn().mockResolvedValue(null), +})); + +/** + * What the mocked seams do right now. + * + * `mock.module` is process-global and never restores, so whatever is installed + * here serves every file that runs after this one — the `afterAll` below is a + * courtesy, not a restore. Both entries default to the real implementations + * and are put back in `afterEach`, so a query this file holds open forever + * stays this file's problem. + * + * The real implementation is captured into a const *before* `mock.module` + * runs: a module namespace is a live binding, so reading + * `actualSqlTerminal.executeRawSql` afterwards hands back the mock, and the + * mock delegating to itself is an infinite recursion. + */ +const realExecuteRawSql = actualSqlTerminal.executeRawSql; + +const seam = { + executeRawSql: realExecuteRawSql, +}; + +/** The answer a query gives when the test is not holding it open. */ +const answersImmediately = async (): Promise => ({ + success: true, + columns: ['n'], + rows: [{ n: 1 }], + durationMs: 1, +}); + +/** A query that never comes back: the hang the hatch exists for. */ +const neverAnswers = () => new Promise(() => undefined); + +const executeMock = vi.fn( + ( + db: Parameters[0], + query: string, + configName: string, + gate: Parameters[3], + signal?: AbortSignal, + ) => seam.executeRawSql(db, query, configName, gate, signal), +); + +// The connection layer is deliberately NOT mocked. This config is sqlite +// `:memory:`, so the real `testConnection` and `createConnection` succeed +// without touching anything — and a mock of that seam would outlive this file +// and hand every later file a connection that is not real. +mock.module('../../../../src/core/sql-terminal/index.js', () => ({ + ...actualSqlTerminal, + executeRawSql: executeMock, +})); + +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)); + + } + +} + +describe('cli: SQL terminal cancellation', () => { + + let tempDir: string; + + beforeEach(async () => { + + vi.clearAllMocks(); + seam.executeRawSql = answersImmediately; + tempDir = await mkdtemp(join(tmpdir(), 'noorm-sql-cancel-')); + + }); + + afterEach(async () => { + + // Put the real executor back before the next file runs; a query left + // hanging here would hang there. + seam.executeRawSql = realExecuteRawSql; + + await rm(tempDir, { recursive: true, force: true }); + + }); + + afterAll(() => { + + mock.module('../../../../src/core/index.js', () => actualCore); + mock.module('../../../../src/core/sql-terminal/index.js', () => actualSqlTerminal); + + }); + + /** + * Mount the terminal with a query already typed, run it, and stop once the + * screen reports it is executing. + */ + async function runHangingQuery() { + + const view = render( + + + + + + + + + , + ); + + await waitFor(() => Boolean(view.lastFrame()?.includes('SQL Terminal'))); + await waitFor(() => Boolean(view.lastFrame()?.includes('[h] History'))); + + // The input starts in browse mode; Enter submits what params seeded. + const deadline = Date.now() + 3000; + + while (!view.lastFrame()?.includes('Running query') && Date.now() < deadline) { + + view.stdin.write('\r'); + + await new Promise((r) => setTimeout(r, 25)); + + } + + return view; + + } + + it('should advertise the hatch while a query is running', async () => { + + seam.executeRawSql = neverAnswers; + + const view = await runHangingQuery(); + + expect(view.lastFrame()).toContain('Running query'); + expect(view.lastFrame()).toContain('[Esc] Cancel'); + + view.unmount(); + + }, 20_000); + + it('should stop waiting on Escape and say only that, on a dialect with no cancel to send', async () => { + + seam.executeRawSql = neverAnswers; + + const view = await runHangingQuery(); + + view.stdin.write('\x1B'); + + await waitFor(() => !view.lastFrame()?.includes('Running query')); + + // Sqlite has no second connection to interrupt the first from, so + // "cancelled" would be a claim the screen cannot back up. + expect(view.lastFrame()).toContain('Stopped waiting'); + expect(view.lastFrame()).not.toContain('Running query'); + + view.unmount(); + + }, 20_000); + + it('should drop a result that arrives after the query was cancelled', async () => { + + let answer: (result: SqlExecutionResult) => void = () => undefined; + + seam.executeRawSql = () => new Promise((resolve) => { + + answer = resolve; + + }); + + const view = await runHangingQuery(); + + view.stdin.write('\x1B'); + + await waitFor(() => Boolean(view.lastFrame()?.includes('Stopped waiting'))); + + // The driver answers anyway, minutes later in the real thing. Acting on + // it would replace the cancellation with a success the user never saw. + answer({ success: true, columns: ['n'], rows: [{ n: 1 }], durationMs: 5 }); + + await new Promise((r) => setTimeout(r, 200)); + + expect(view.lastFrame()).toContain('Stopped waiting'); + expect(view.lastFrame()).not.toContain('Query executed'); + + view.unmount(); + + }, 20_000); + + it('should hand the query a signal so the executor can stop waiting too', async () => { + + seam.executeRawSql = neverAnswers; + + const view = await runHangingQuery(); + + const signal = executeMock.mock.calls[0]?.[4]; + + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal?.aborted).toBe(false); + + view.stdin.write('\x1B'); + + await waitFor(() => Boolean(signal?.aborted)); + + expect(signal?.aborted).toBe(true); + + view.unmount(); + + }, 20_000); + +}); diff --git a/tests/cli/screens/db/sql-row-view.test.tsx b/tests/cli/screens/db/sql-row-view.test.tsx new file mode 100644 index 00000000..b8299e06 --- /dev/null +++ b/tests/cli/screens/db/sql-row-view.test.tsx @@ -0,0 +1,364 @@ +/** + * SQL screens: reading a stored result without leaving for another tool. + * + * The complaint that produced this was `select * from ai_usage` in the SQL + * terminal: fifteen columns crammed to their floor, headers wrapped onto two + * lines, values broken mid-value. `ResultTable` now chops instead, which makes + * some columns unreachable on the grid, and Enter on a row is what makes that + * trade honest. + * + * `SqlHistoryScreen` is the screen mounted here because it draws the same + * `ResultBrowser` the terminal does and needs no live connection to get a + * result on screen — the stored result is read off disk. What the terminal adds + * on top is the query that produced it, not a different grid. + * + * What is pinned: + * + * - The grid drops columns and says how many, rather than squeezing all of them. + * - Enter opens the cursor's row and shows a column the grid dropped. + * - Escape unwinds one level at a time: document, then result, then screen. + * - The filter box keeps its own Escape. Before this, the screen claimed Escape + * while a result was up, so cancelling a filter closed the whole result. + */ +import { describe, it, expect, vi, mock, beforeEach, afterEach } from 'bun:test'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { mkdtemp, rm } from 'fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { SqlExecutionResult } from '../../../../src/core/sql-terminal/types.js'; + +import { FocusProvider } from '../../../../src/tui/focus.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 { SqlHistoryScreen } from '../../../../src/tui/screens/db/SqlHistoryScreen.js'; +import { SqlHistoryManager } from '../../../../src/core/sql-terminal/index.js'; + +const actualCore = await import('../../../../src/core/index.js'); + +/** Sqlite admin config, matching the shape `db-dry-run.test.tsx` uses. */ +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:', + }, + }; + +} + +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()), + 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' }, + getStages: vi.fn().mockReturnValue({}), + getStage: vi.fn().mockReturnValue(undefined), +}); + +let mockStateManager = createMockStateManager(); +let mockSettingsManager = createMockSettingsManager(); + +mock.module('../../../../src/core/index.js', () => ({ + observer: actualCore.observer, + getStateManager: vi.fn(() => mockStateManager), + getSettingsManager: vi.fn(() => mockSettingsManager), + resetStateManager: vi.fn(), + resetSettingsManager: vi.fn(), +})); + +mock.module('../../../../src/core/identity/index.js', () => ({ + loadExistingIdentity: vi.fn().mockResolvedValue(null), +})); + +const KEY = { + down: '', + enter: '\r', + escape: '', + filter: '/', + format: 'f', + end: '\u001B[F', +} as const; + +/** A line only the grid prints, so a wait cannot pass on the document. */ +const GRID_MARKER = '[/] Filter'; + +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)); + + } + +} + +/** + * Wait until the grid's own input handler is listening. + * + * Ink registers `useInput` in an effect, which runs after the frame it belongs + * to is painted, so the keystroke that opens a row lands on nothing if it is + * written the moment the grid appears. `/` is the probe because filter mode + * announces itself, and Escape out of it clears whatever the probe typed. + */ +async function settleGrid( + stdin: { write: (data: string) => void }, + frame: () => string, +): Promise { + + const deadline = Date.now() + 2000; + + while (!frame().includes('[Tab] Column') && Date.now() < deadline) { + + stdin.write(KEY.filter); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + } + + stdin.write(KEY.escape); + + await waitFor(() => !frame().includes('[Tab] Column')); + +} + +/** + * Wait until the row document viewer's own focus scope is live. + * + * Same reason, one level deeper: `useFocusScope` pushes onto the stack in an + * effect too. `f` is the probe because it changes the header rather than the + * document, and pressing it twice puts the format back where it started. + */ +async function settleRowView( + stdin: { write: (data: string) => void }, + frame: () => string, +): Promise { + + const start = frame().includes('[f] JSON') ? '[f] JSON' : '[f] YAML'; + const flipped = start === '[f] JSON' ? '[f] YAML' : '[f] JSON'; + + const deadline = Date.now() + 2000; + + while (!frame().includes(flipped) && Date.now() < deadline) { + + stdin.write(KEY.format); + + await new Promise((resolve) => setTimeout(resolve, 20)); + + } + + stdin.write(KEY.format); + + await waitFor(() => frame().includes(start)); + +} + +/** Fifteen columns of uuid-ish values: the shape that produced the complaint. */ +function wideResult(): SqlExecutionResult { + + const columns = Array.from({ length: 15 }, (_, index) => `col_${String(index).padStart(2, '0')}`); + + const row = (seed: number): Record => { + + const out: Record = {}; + + for (const column of columns) out[column] = `${column}-${seed}`.padEnd(20, 'x'); + + out['col_00'] = `row-${seed}`; + out['col_14'] = `tail-${seed}`; + + return out; + + }; + + return { + success: true, + columns, + rows: [row(0), row(1)], + durationMs: 12, + }; + +} + +describe('cli: screens/db sql result browsing', () => { + + let tempDir: string; + + beforeEach(async () => { + + vi.clearAllMocks(); + actualCore.observer.clear(); + + mockStateManager = createMockStateManager(); + mockSettingsManager = createMockSettingsManager(); + + tempDir = await mkdtemp(join(tmpdir(), 'noorm-sql-row-test-')); + + }); + + afterEach(async () => { + + actualCore.observer.clear(); + + await rm(tempDir, { recursive: true, force: true }); + + }); + + /** Seed one stored result and put the screen in front of it. */ + async function screen() { + + const manager = new SqlHistoryManager(tempDir, 'test'); + + await manager.addEntry('select * from ai_usage', wideResult()); + + const view = render( + + + + + + + + + , + ); + + const frame = () => view.lastFrame() ?? ''; + + await waitFor(() => frame().includes('select * from ai_usage')); + + return { ...view, frame }; + + } + + it('should chop a wide stored result instead of squeezing every column', async () => { + + const view = await screen(); + + view.stdin.write(KEY.enter); + await waitFor(() => view.frame().includes(GRID_MARKER)); + await settleGrid(view.stdin, view.frame); + + const frame = view.frame(); + const lines = frame.split('\n'); + // Inside the Panel, so the run of box-drawing has to be told apart from + // the Panel's own top border, which also carries one. + const rule = lines.findIndex((line) => line.startsWith('│') && line.includes('────')); + + // On the header line, not on the frame: squeezing fifteen columns into + // the row truncates `col_14` to `col`, so a frame-wide `not.toContain` + // would pass on exactly the bug this is about. + expect(lines[rule - 1]).toContain('col_00'); + expect(lines[rule - 1]).toContain('col_04'); + expect(lines[rule - 1]).not.toContain('col_14'); + expect(frame).toContain('more columns'); + + // Two rows, two lines, each carrying its whole first cell. Squeezing + // wrapped every cell and cut `row-0` down to `row`. + expect(lines.filter((line) => /row-[01]/.test(line))).toHaveLength(2); + + view.unmount(); + + }); + + it('should open a stored row as a document, dropped columns included', async () => { + + const view = await screen(); + + view.stdin.write(KEY.enter); + await waitFor(() => view.frame().includes(GRID_MARKER)); + await settleGrid(view.stdin, view.frame); + + expect(view.frame()).toContain('[↵] Open row'); + + view.stdin.write(KEY.enter); + await waitFor(() => view.frame().includes('row 1 of 2')); + await settleRowView(view.stdin, view.frame); + + expect(view.frame()).toContain('Stored result'); + + // Fifteen fields do not fit a 24-row terminal, so the column the grid + // dropped is at the bottom of a document that scrolls. Reaching it is + // the point: End is bound here for exactly this. + view.stdin.write(KEY.end); + await waitFor(() => view.frame().includes('col_14')); + + expect(view.frame()).toContain('col_14: tail-0'); + + view.unmount(); + + }); + + it('should unwind one level per Escape: document, result, screen', async () => { + + const view = await screen(); + + view.stdin.write(KEY.enter); + await waitFor(() => view.frame().includes(GRID_MARKER)); + await settleGrid(view.stdin, view.frame); + + view.stdin.write(KEY.enter); + await waitFor(() => view.frame().includes('row 1 of 2')); + await settleRowView(view.stdin, view.frame); + + view.stdin.write(KEY.escape); + await waitFor(() => view.frame().includes(GRID_MARKER)); + + // Back on the grid, not back on the history list. + expect(view.frame()).toContain('Query Result'); + + view.stdin.write(KEY.escape); + await waitFor(() => view.frame().includes('[r] Re-run')); + + expect(view.frame()).toContain('select * from ai_usage'); + + view.unmount(); + + }); + + it('should let the filter box keep its own Escape', async () => { + + const view = await screen(); + + view.stdin.write(KEY.enter); + await waitFor(() => view.frame().includes(GRID_MARKER)); + await settleGrid(view.stdin, view.frame); + + view.stdin.write(KEY.filter); + await waitFor(() => view.frame().includes('[Tab] Column')); + + view.stdin.write('row-1'); + await waitFor(() => view.frame().includes('filtered from 2')); + + view.stdin.write(KEY.escape); + await waitFor(() => !view.frame().includes('filtered from 2')); + + // Escape cancelled the filter. The whole result view used to go with it. + expect(view.frame()).toContain('Query Result'); + expect(view.frame()).toContain(GRID_MARKER); + + view.unmount(); + + }); + +}); diff --git a/tests/cli/screens/init/init-flow.test.tsx b/tests/cli/screens/init/init-flow.test.tsx index 50fe8dc6..5313e1d2 100644 --- a/tests/cli/screens/init/init-flow.test.tsx +++ b/tests/cli/screens/init/init-flow.test.tsx @@ -164,6 +164,26 @@ async function waitForEffects(ms = 50): Promise { } +/** + * Submit the identity form. + * + * The Form browses by default and reserves Enter for opening a field, so + * submitting means walking past the three identity fields onto the action row + * first. Index 3 is the submit button whether or not a Cancel button follows it. + */ +async function submitIdentityForm(stdin: { write: (data: string) => void }): Promise { + + for (let i = 0; i < 3; i++) { + + stdin.write('\x1b[B'); + await waitForEffects(30); + + } + + stdin.write('\r'); + +} + /** * Test wrapper with all required providers. */ @@ -224,8 +244,8 @@ describe('cli: screens/init - flow integration', () => { // Should show identity setup expect(lastFrame()).toContain('Welcome to noorm'); - // Press Enter to submit the pre-filled form - stdin.write('\r'); + // Walk onto the action row and submit the pre-filled form + await submitIdentityForm(stdin); await waitForEffects(100); @@ -253,8 +273,8 @@ describe('cli: screens/init - flow integration', () => { // Should show identity setup expect(lastFrame()).toContain('Welcome to noorm'); - // Press Enter to submit identity form - stdin.write('\r'); + // Walk onto the action row and submit the identity form + await submitIdentityForm(stdin); await waitForEffects(100); @@ -312,8 +332,8 @@ describe('cli: screens/init - flow integration', () => { // Should show identity setup expect(lastFrame()).toContain('Welcome to noorm'); - // Press Enter to submit identity form - stdin.write('\r'); + // Walk onto the action row and submit the identity form + await submitIdentityForm(stdin); await waitForEffects(100); diff --git a/tests/cli/screens/lock/lock-force-cancel.test.tsx b/tests/cli/screens/lock/lock-force-cancel.test.tsx new file mode 100644 index 00000000..2d8a2962 --- /dev/null +++ b/tests/cli/screens/lock/lock-force-cancel.test.tsx @@ -0,0 +1,226 @@ +/** + * LockForceScreen: the escape hatch on a spinner that has no form behind it. + * + * The other cancellable screens sit on a `Form`, which owns Escape while busy. + * This one is a phase machine, so the wiring is its own and needs its own + * proof: a "Checking lock status..." spinner over an unreachable database used + * to be a dead end with no key that did anything. + */ +import { describe, it, expect, vi, mock, beforeEach, afterEach, afterAll } from 'bun:test'; +import { render } from 'ink-testing-library'; +import React from 'react'; + +import { FocusProvider } from '../../../../src/tui/focus.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 { LockForceScreen } from '../../../../src/tui/screens/lock/LockForceScreen.js'; + +const actualCore = await import('../../../../src/core/index.js'); +const actualConnection = await import('../../../../src/core/connection/index.js'); + +function makeConfig() { + + return { + name: 'test', + type: 'local' as const, + isTest: true, + access: { user: 'admin' as const, agent: 'admin' as const }, + connection: { + dialect: 'postgres' as const, + host: 'localhost', + port: 5432, + database: 'test_db', + user: 'admin', + }, + }; + +} + +const mockStateManager = { + 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()), + setConfig: vi.fn().mockResolvedValue(undefined), + setActiveConfig: vi.fn().mockResolvedValue(undefined), + hasPrivateKey: vi.fn().mockReturnValue(true), + isLoaded: true, +}; + +const mockSettingsManager = { + load: vi.fn().mockResolvedValue({ version: '0.1.0' }), + isLoaded: true, + settings: { version: '0.1.0' }, + getStages: vi.fn().mockReturnValue({}), + getStage: vi.fn().mockReturnValue(undefined), +}; + +mock.module('../../../../src/core/index.js', () => ({ + ...actualCore, + getStateManager: vi.fn(() => mockStateManager), + getSettingsManager: vi.fn(() => mockSettingsManager), + resetStateManager: vi.fn(), + resetSettingsManager: vi.fn(), +})); + +mock.module('../../../../src/core/identity/index.js', () => ({ + loadExistingIdentity: vi.fn().mockResolvedValue(null), +})); + +/** + * What the connection seam does right now. + * + * `mock.module` is process-global and never restores, so whatever is installed + * here serves every file that runs after this one — the `afterAll` below is a + * courtesy, not a restore. Defaulting to the real implementations and putting + * them back in `afterEach` is what keeps a deliberately hung connect from + * becoming a hung connect for the rest of the group. + * + * The real implementations are captured into consts *before* `mock.module` + * runs: a module namespace is a live binding, so reading + * `actualConnection.testConnection` afterwards hands back the mock, and the + * mock delegating to itself is an infinite recursion. + */ +const realTestConnection = actualConnection.testConnection; +const realCreateConnection = actualConnection.createConnection; + +const seam = { + testConnection: realTestConnection, + createConnection: realCreateConnection, +}; + +/** A call that never answers: the hung connect this screen had no way out of. */ +const neverAnswers = () => new Promise(() => undefined); + +const testConnectionMock = vi.fn( + (config: Parameters[0], + options?: Parameters[1]) => + seam.testConnection(config, options), +); + +mock.module('../../../../src/core/connection/index.js', () => ({ + ...actualConnection, + testConnection: testConnectionMock, + createConnection: (...args: Parameters) => + seam.createConnection(...args), +})); + +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)); + + } + +} + +describe('cli: LockForceScreen cancellation', () => { + + beforeEach(() => { + + vi.clearAllMocks(); + + seam.testConnection = neverAnswers; + seam.createConnection = neverAnswers; + + }); + + afterEach(() => { + + // Hand the real implementations back before the next file runs. The + // promises already parked belong to screens this file unmounted, so + // nothing is left waiting on them. + seam.testConnection = realTestConnection; + seam.createConnection = realCreateConnection; + + }); + + afterAll(() => { + + mock.module('../../../../src/core/index.js', () => actualCore); + mock.module('../../../../src/core/connection/index.js', () => actualConnection); + + }); + + it('should advertise the hatch while the lock check is in flight', async () => { + + const { lastFrame, unmount } = render( + + + + + + + , + ); + + await waitFor(() => Boolean(lastFrame()?.includes('Checking lock status'))); + + expect(lastFrame()).toContain('[Esc] Cancel'); + + unmount(); + + }, 20_000); + + it('should leave the spinner for a dismissible message on Escape', async () => { + + const { stdin, lastFrame, unmount } = render( + + + + + + + , + ); + + await waitFor(() => Boolean(lastFrame()?.includes('Checking lock status'))); + + stdin.write('\x1B'); + + await waitFor(() => Boolean(lastFrame()?.includes('Stopped waiting'))); + + expect(lastFrame()).not.toContain('Checking lock status'); + expect(lastFrame()).toContain('Stopped waiting'); + expect(lastFrame()).toContain('[Enter/Esc] Back'); + + unmount(); + + }, 20_000); + + it('should abort the signal it handed the connection layer', async () => { + + const { stdin, lastFrame, unmount } = render( + + + + + + + , + ); + + await waitFor(() => Boolean(lastFrame()?.includes('Checking lock status'))); + + const options = testConnectionMock.mock.calls[0]?.[1]; + const signal = options?.signal; + + expect(signal).toBeInstanceOf(AbortSignal); + expect(signal?.aborted).toBe(false); + + stdin.write('\x1B'); + + await waitFor(() => Boolean(signal?.aborted)); + + expect(signal?.aborted).toBe(true); + + unmount(); + + }, 20_000); + +}); diff --git a/tests/core/connection/timeout.test.ts b/tests/core/connection/timeout.test.ts new file mode 100644 index 00000000..82d2a57f --- /dev/null +++ b/tests/core/connection/timeout.test.ts @@ -0,0 +1,165 @@ +/** + * Connect-timeout and cancellation tests for the connection layer. + * + * The intent: a hung connect has to end on its own, and a caller who stops + * waiting has to get control back without abandoning an open pool. Both are + * invisible in a happy-path test, so they are pinned here explicitly. + */ +import { describe, it, expect } from 'bun:test'; +import { attempt } from '@logosdx/utils'; + +import { + DEFAULT_CONNECT_TIMEOUT_MS, + connectTimeoutFor, +} from '../../../src/core/connection/defaults.js'; +import { createConnection, testConnection, discardConnection } from '../../../src/core/connection/index.js'; +import { OperationAbortedError } from '../../../src/core/shared/abort.js'; +import type { ConnectionConfig } from '../../../src/core/connection/types.js'; + +const sqliteConfig: ConnectionConfig = { + dialect: 'sqlite', + database: ':memory:', +}; + +describe('connection: connectTimeoutFor', () => { + + it('should fall back to the shared default when the config says nothing', () => { + + expect(connectTimeoutFor({})).toBe(DEFAULT_CONNECT_TIMEOUT_MS); + + }); + + it('should honour an explicit override, which is the escape valve for a genuinely slow link', () => { + + expect(connectTimeoutFor({ connectTimeoutMs: 60_000 })).toBe(60_000); + + }); + + it('should reject a non-positive override rather than disabling the timeout by accident', () => { + + expect(connectTimeoutFor({ connectTimeoutMs: 0 })).toBe(DEFAULT_CONNECT_TIMEOUT_MS); + expect(connectTimeoutFor({ connectTimeoutMs: -1 })).toBe(DEFAULT_CONNECT_TIMEOUT_MS); + + }); + + it('should keep the default bounded, so an unreachable host cannot wait forever', () => { + + expect(DEFAULT_CONNECT_TIMEOUT_MS).toBeGreaterThan(0); + expect(DEFAULT_CONNECT_TIMEOUT_MS).toBeLessThanOrEqual(60_000); + + }); + +}); + +describe('connection: cancellation', () => { + + describe('createConnection', () => { + + it('should behave exactly as before when no signal is passed', async () => { + + const conn = await createConnection(sqliteConfig); + + expect(conn.dialect).toBe('sqlite'); + + await conn.destroy(); + + }); + + it('should ignore a signal that never fires', async () => { + + const controller = new AbortController(); + + const conn = await createConnection(sqliteConfig, '__test__', {}, controller.signal); + + expect(conn.dialect).toBe('sqlite'); + + await conn.destroy(); + + }); + + it('should refuse to open anything when the signal is already aborted', async () => { + + const controller = new AbortController(); + controller.abort(); + + const [conn, err] = await attempt(() => + createConnection(sqliteConfig, '__test__', {}, controller.signal), + ); + + expect(err).toBeInstanceOf(OperationAbortedError); + expect(conn).toBeNull(); + + }); + + }); + + describe('testConnection', () => { + + it('should keep reporting ok for a reachable target with no signal', async () => { + + const result = await testConnection(sqliteConfig); + + expect(result.ok).toBe(true); + expect(result.aborted).toBeUndefined(); + + }); + + it('should report aborted rather than a database failure when the caller stopped waiting', async () => { + + const controller = new AbortController(); + controller.abort(); + + const result = await testConnection(sqliteConfig, { signal: controller.signal }); + + expect(result.ok).toBe(false); + expect(result.aborted).toBe(true); + + }); + + it('should not mark a genuine failure as aborted', async () => { + + const result = await testConnection( + { dialect: 'sqlite', database: '/nope/does/not/exist/db.sqlite' }, + { testServerOnly: true }, + ); + + expect(result.ok).toBe(false); + expect(result.aborted).toBeUndefined(); + + }); + + }); + + describe('discardConnection', () => { + + it('should close a connection nobody is holding any more', async () => { + + let destroyed = false; + + await discardConnection({ + destroy: async () => { + + destroyed = true; + + }, + }); + + expect(destroyed).toBe(true); + + }); + + it('should give up on a destroy that hangs, because cleanup can hang too', async () => { + + const started = Date.now(); + const [, err] = await attempt(() => + discardConnection({ destroy: () => new Promise(() => undefined) }, 50), + ); + + expect(err).toBeNull(); + expect(Date.now() - started).toBeLessThan(2000); + + }); + + }); + +}); diff --git a/tests/core/explore/peek.test.ts b/tests/core/explore/peek.test.ts new file mode 100644 index 00000000..15f1abce --- /dev/null +++ b/tests/core/explore/peek.test.ts @@ -0,0 +1,423 @@ +/** + * Unit tests for the table data peek. + * + * Two things are pinned here that nothing else can reach: + * + * 1. The exact SQL each dialect emits. Kysely does no dialect adaptation for + * row limiting: `.limit()` compiles to `limit @1` on SQL Server and + * `.top()` compiles to `top(10)` on the other three, and neither throws + * when used on the wrong one. Nothing in the type system or the compile + * step objects, so a full-string assertion per dialect is the only thing + * between this code and a statement the server rejects at runtime. Schema + * and table names arrive from the database itself and are checked here too: + * a name carrying the dialect's own quote character, or a dot, has to + * survive as one identifier. + * 2. Which rows come back under which heading. A table shorter than two pages + * would otherwise show the same rows twice, once as "first" and once as + * "last", and a reader has no way to tell that from a table that genuinely + * has those rows at both ends. + * + * `createRecordingDb` builds a real Kysely instance on the dialect's own + * adapter and compiler behind a driver that records instead of connecting, so + * these run with no container — the same guarantee a `DummyDriver` harness + * would give, on the harness this suite already has. + */ +import { describe, it, expect } from 'bun:test'; +import { attempt } from '@logosdx/utils'; + +import { fetchRowPeek, MAX_PEEK_ROWS } from '../../../src/core/explore/index.js'; +import type { Dialect } from '../../../src/core/connection/types.js'; +import type { ColumnDetail, TableDetail } from '../../../src/core/explore/types.js'; +import type { ConfigAccess } from '../../../src/core/policy/index.js'; +import { createRecordingDb } from './recording-db.js'; + +/** Access that lets the check through, so a denial in a test is never incidental. */ +const OPEN: ConfigAccess = { user: 'admin', agent: 'admin' }; + +/** The gate every non-policy case passes. */ +const GATE = { configName: 'test', access: OPEN, channel: 'user' } as const; + +function column(name: string, overrides: Partial = {}): ColumnDetail { + + return { + name, + dataType: 'text', + isNullable: true, + isPrimaryKey: false, + ordinalPosition: 1, + ...overrides, + }; + +} + +/** + * A table detail as `fetchDetail` would have returned it. Only the fields the + * peek reads are meaningful; the rest are there because the type requires them. + */ +function table(overrides: Partial = {}): TableDetail { + + return { + name: 'users', + schema: 'public', + columns: [ + column('id', { isPrimaryKey: true, ordinalPosition: 1 }), + column('email', { ordinalPosition: 2 }), + ], + indexes: [], + foreignKeys: [], + ...overrides, + }; + +} + +/** Rows with sequential ids, which is what makes an overlap visible. */ +function rowsWithIds(ids: number[]): Record[] { + + return ids.map((id) => ({ id, email: `user${id}@example.com` })); + +} + +/** + * The exact statement each dialect must emit for one key column ascending. + * + * Asserted in full rather than by pattern, because the failure this guards is + * silent: Kysely does no dialect adaptation for row limiting. `.limit()` + * compiles to `limit @1` on SQL Server and `.top()` compiles to `top(10)` on + * the other three; neither throws, neither is a type error, and each is valid + * only on the dialects the other is not. A test that merely looked for "a row + * limit somewhere" would pass against `select top(10) *` on postgres. + */ +const COMPILED: Record = { + postgres: { + schema: 'public', + sql: 'select * from "public"."users" as "peek" order by "id" asc limit $1', + parameters: [10], + }, + mysql: { + schema: 'appdb', + sql: 'select * from `appdb`.`users` as `peek` order by `id` asc limit ?', + parameters: [10], + }, + sqlite: { + schema: undefined, + sql: 'select * from "users" as "peek" order by "id" asc limit ?', + parameters: [10], + }, + mssql: { + schema: 'dbo', + // No `limit`, and the count is inlined rather than bound: `top()` + // takes no parameter. + sql: 'select top(10) * from "dbo"."users" as "peek" order by "id" asc', + parameters: [], + }, +}; + +describe('explore: peek query building', () => { + + for (const dialect of ['postgres', 'mysql', 'sqlite', 'mssql'] as const) { + + const expected = COMPILED[dialect]; + + it(`should compile the exact ${dialect} statement`, async () => { + + const db = createRecordingDb(dialect, [{ match: /select/, rows: [] }]); + + await fetchRowPeek(db.kysely, dialect, table({ schema: expected.schema }), GATE, 10); + + expect(db.queries[0]?.sql).toBe(expected.sql); + expect(db.queries[0]?.parameters).toEqual(expected.parameters); + + }); + + it(`should use the row-limit clause ${dialect} accepts and not the other`, async () => { + + const db = createRecordingDb(dialect, [{ match: /select/, rows: [] }]); + + await fetchRowPeek(db.kysely, dialect, table({ schema: expected.schema }), GATE, 10); + + const compiled = db.queries[0]?.sql ?? ''; + + if (dialect === 'mssql') { + + expect(compiled).toContain('top(10)'); + expect(compiled).not.toContain('limit'); + + } + else { + + expect(compiled).toContain('limit'); + expect(compiled).not.toContain('top('); + + } + + }); + + } + + it('should read the tail with a descending order on the same key', async () => { + + const db = createRecordingDb('postgres', [ + { match: / asc/, rows: rowsWithIds([1, 2, 3]) }, + { match: / desc/, rows: rowsWithIds([9, 8, 7]) }, + ]); + + await fetchRowPeek(db.kysely, 'postgres', table(), GATE, 3); + + expect(db.queries[1]?.sql).toBe( + 'select * from "public"."users" as "peek" order by "id" desc limit $1', + ); + expect(db.queries[1]?.parameters).toEqual([3]); + + }); + + it('should order a composite key by ordinal position, not array order', async () => { + + const detail = table({ + columns: [ + column('tenant_id', { isPrimaryKey: true, ordinalPosition: 2 }), + column('todo_no', { isPrimaryKey: true, ordinalPosition: 1 }), + column('title', { ordinalPosition: 3 }), + ], + }); + + const db = createRecordingDb('postgres', [{ match: / asc/, rows: [] }]); + + await fetchRowPeek(db.kysely, 'postgres', detail, GATE, 10); + + expect(db.queries[0]?.sql).toContain('order by "todo_no" asc, "tenant_id" asc'); + + }); + + it('should omit ORDER BY entirely when the table has no primary key', async () => { + + const detail = table({ columns: [column('note', { ordinalPosition: 1 })] }); + const db = createRecordingDb('postgres', [{ match: /select/, rows: [] }]); + + await fetchRowPeek(db.kysely, 'postgres', detail, GATE, 10); + + expect(db.queries[0]?.sql).toBe('select * from "public"."users" as "peek" limit $1'); + expect(db.queries[0]?.sql).not.toContain('order by'); + + }); + + it('should keep a dotted table name whole instead of reading it as schema.table', async () => { + + const detail = table({ name: 'we.ird', schema: undefined }); + const db = createRecordingDb('sqlite', [{ match: /select/, rows: [] }]); + + await fetchRowPeek(db.kysely, 'sqlite', detail, GATE, 10); + + expect(db.queries[0]?.sql).toContain('from "we.ird"'); + expect(db.queries[0]?.sql).not.toContain('"we"."ird"'); + + }); + + it('should escape a quote inside an identifier rather than close it', async () => { + + const detail = table({ + name: 'we"ird', + schema: 'sch"ema', + columns: [column('i"d', { isPrimaryKey: true, ordinalPosition: 1 })], + }); + + const db = createRecordingDb('postgres', [{ match: / asc/, rows: [] }]); + + await fetchRowPeek(db.kysely, 'postgres', detail, GATE, 10); + + expect(db.queries[0]?.sql).toBe( + 'select * from "sch""ema"."we""ird" as "peek" order by "i""d" asc limit $1', + ); + + }); + + it('should escape a backtick inside a mysql identifier', async () => { + + const detail = table({ + name: 'we`ird', + schema: 'appdb', + columns: [column('id', { isPrimaryKey: true, ordinalPosition: 1 })], + }); + + const db = createRecordingDb('mysql', [{ match: / asc/, rows: [] }]); + + await fetchRowPeek(db.kysely, 'mysql', detail, GATE, 10); + + expect(db.queries[0]?.sql).toBe( + 'select * from `appdb`.`we``ird` as `peek` order by `id` asc limit ?', + ); + + }); + + it('should clamp a nonsense page size', async () => { + + const huge = createRecordingDb('postgres', [{ match: / asc/, rows: [] }]); + await fetchRowPeek(huge.kysely, 'postgres', table(), GATE, 10_000); + + const zero = createRecordingDb('postgres', [{ match: / asc/, rows: [] }]); + await fetchRowPeek(zero.kysely, 'postgres', table(), GATE, 0); + + expect(huge.queries[0]?.parameters).toEqual([MAX_PEEK_ROWS]); + expect(zero.queries[0]?.parameters).toEqual([1]); + + }); + + it('should inline the clamped count on mssql, where top takes no parameter', async () => { + + const db = createRecordingDb('mssql', [{ match: / asc/, rows: [] }]); + + await fetchRowPeek(db.kysely, 'mssql', table({ schema: 'dbo' }), GATE, 10_000); + + expect(db.queries[0]?.sql).toContain(`top(${MAX_PEEK_ROWS})`); + expect(db.queries[0]?.parameters).toEqual([]); + + }); + +}); + +describe('explore: fetchRowPeek', () => { + + it('should report the whole table and skip the tail query when the page came back short', async () => { + + const db = createRecordingDb('postgres', [{ match: / asc/, rows: rowsWithIds([1, 2, 3]) }]); + + const peek = await fetchRowPeek(db.kysely, 'postgres', table(), GATE, 10); + + expect(peek.mode).toBe('whole'); + expect(peek.first.map((row) => row.id)).toEqual([1, 2, 3]); + expect(peek.last).toEqual([]); + expect(db.queries).toHaveLength(1); + + }); + + it('should return an empty whole set for an empty table', async () => { + + const db = createRecordingDb('postgres', [{ match: / asc/, rows: [] }]); + + const peek = await fetchRowPeek(db.kysely, 'postgres', table(), GATE, 10); + + expect(peek.mode).toBe('whole'); + expect(peek.first).toEqual([]); + expect(peek.last).toEqual([]); + expect(db.queries).toHaveLength(1); + + }); + + it('should stop at the head when there is no primary key to order a tail by', async () => { + + const detail = table({ columns: [column('note', { ordinalPosition: 1 })] }); + const db = createRecordingDb('postgres', [ + { match: /select/, rows: [{ note: 'a' }, { note: 'b' }, { note: 'c' }] }, + ]); + + const peek = await fetchRowPeek(db.kysely, 'postgres', detail, GATE, 3); + + expect(peek.mode).toBe('head'); + expect(peek.keyColumns).toEqual([]); + expect(peek.last).toEqual([]); + expect(db.queries).toHaveLength(1); + + }); + + it('should return both ends, tail re-reversed to ascending, when they do not overlap', async () => { + + const db = createRecordingDb('postgres', [ + { match: / asc/, rows: rowsWithIds([1, 2, 3]) }, + { match: / desc/, rows: rowsWithIds([9, 8, 7]) }, + ]); + + const peek = await fetchRowPeek(db.kysely, 'postgres', table(), GATE, 3); + + expect(peek.mode).toBe('ends'); + expect(peek.first.map((row) => row.id)).toEqual([1, 2, 3]); + expect(peek.last.map((row) => row.id)).toEqual([7, 8, 9]); + + }); + + it('should collapse a partial overlap into one set rather than repeat rows', async () => { + + const db = createRecordingDb('postgres', [ + { match: / asc/, rows: rowsWithIds([1, 2, 3]) }, + { match: / desc/, rows: rowsWithIds([5, 4, 3]) }, + ]); + + const peek = await fetchRowPeek(db.kysely, 'postgres', table(), GATE, 3); + + expect(peek.mode).toBe('whole'); + expect(peek.first.map((row) => row.id)).toEqual([1, 2, 3, 4, 5]); + expect(peek.last).toEqual([]); + + }); + + it('should collapse a table of exactly one page, where both ends are the same rows', async () => { + + const db = createRecordingDb('postgres', [ + { match: / asc/, rows: rowsWithIds([1, 2, 3]) }, + { match: / desc/, rows: rowsWithIds([3, 2, 1]) }, + ]); + + const peek = await fetchRowPeek(db.kysely, 'postgres', table(), GATE, 3); + + expect(peek.mode).toBe('whole'); + expect(peek.first.map((row) => row.id)).toEqual([1, 2, 3]); + + }); + + it('should compare every column of a composite key when detecting the overlap', async () => { + + const detail = table({ + columns: [ + column('tenant_id', { isPrimaryKey: true, ordinalPosition: 1 }), + column('todo_no', { isPrimaryKey: true, ordinalPosition: 2 }), + ], + }); + + // Same tenant on both ends, different todo_no: comparing only the first + // key column would call this an overlap and hide the tail. + const db = createRecordingDb('postgres', [ + { match: / asc/, rows: [{ tenant_id: 1, todo_no: 1 }, { tenant_id: 1, todo_no: 2 }] }, + { match: / desc/, rows: [{ tenant_id: 1, todo_no: 9 }, { tenant_id: 1, todo_no: 8 }] }, + ]); + + const peek = await fetchRowPeek(db.kysely, 'postgres', detail, GATE, 2); + + expect(peek.mode).toBe('ends'); + expect(peek.last.map((row) => row.todo_no)).toEqual([8, 9]); + + }); + + it('should name the columns in ordinal order, so both sets draw one grid', async () => { + + const detail = table({ + columns: [ + column('email', { ordinalPosition: 2 }), + column('id', { isPrimaryKey: true, ordinalPosition: 1 }), + ], + }); + + const db = createRecordingDb('postgres', [{ match: / asc/, rows: [] }]); + + const peek = await fetchRowPeek(db.kysely, 'postgres', detail, GATE, 10); + + expect(peek.columns).toEqual(['id', 'email']); + + }); + + it('should refuse a denied channel before touching the database', async () => { + + const db = createRecordingDb('postgres', [{ match: / asc/, rows: rowsWithIds([1]) }]); + + const gate = { + configName: 'prod', + access: { user: 'admin', agent: false }, + channel: 'agent', + } as const; + + const [peek, err] = await attempt(() => fetchRowPeek(db.kysely, 'postgres', table(), gate, 10)); + + expect(peek).toBeNull(); + expect(err?.message).toContain('agent'); + expect(db.queries).toHaveLength(0); + + }); + +}); diff --git a/tests/core/settings/manager.test.ts b/tests/core/settings/manager.test.ts index 5eb1d3ce..54fdbe55 100644 --- a/tests/core/settings/manager.test.ts +++ b/tests/core/settings/manager.test.ts @@ -167,6 +167,57 @@ stages: }); + it('should carry a hand-written ui.mouse flag through to the resolved view', async () => { + + const { tempDir, manager, cleanup } = createTestContext(); + + try { + + const settingsDir = join(tempDir, '.test-settings'); + mkdirSync(settingsDir, { recursive: true }); + + const yaml = ` +ui: + mouse: true +`; + writeFileSync(join(settingsDir, 'settings.yml'), yaml); + + await manager.load(); + + // The TUI reads `manager.settings`, which is the document plus + // the env overlay rather than the document itself. A section + // the overlay dropped would leave the flag unreachable from the + // only place that consumes it. + expect(manager.settings.ui?.mouse).toBe(true); + + } + finally { + + cleanup(); + + } + + }); + + it('should leave the ui section absent when settings.yml never mentioned it', async () => { + + const { manager, cleanup } = createTestContext(); + + try { + + await manager.load(); + + expect(manager.settings.ui).toBeUndefined(); + + } + finally { + + cleanup(); + + } + + }); + }); describe('save', () => { diff --git a/tests/core/settings/schema.test.ts b/tests/core/settings/schema.test.ts index 2c0a3bf1..3695871e 100644 --- a/tests/core/settings/schema.test.ts +++ b/tests/core/settings/schema.test.ts @@ -7,6 +7,7 @@ import { validateRule, SettingsValidationError, } from '../../../src/core/settings/schema.js'; +import { isMouseEnabled, DEFAULT_UI_MOUSE } from '../../../src/core/settings/defaults.js'; describe('settings: schema validation', () => { @@ -479,6 +480,80 @@ describe('settings: schema validation', () => { }); + describe('ui', () => { + + it('should carry the mouse flag through parseSettings', () => { + + const result = parseSettings({ ui: { mouse: true } }); + + expect(result.ui?.mouse).toBe(true); + + }); + + it('should default the mouse flag on when the section is present but empty', () => { + + const result = parseSettings({ ui: {} }); + + expect(result.ui?.mouse).toBe(true); + + }); + + it('should keep an explicit false false', () => { + + // The whole point of the flag now that the default is on: a user + // whose terminal handles tracking badly writes this one line, and + // nothing may quietly promote it back to the default. + const result = parseSettings({ ui: { mouse: false } }); + + expect(result.ui?.mouse).toBe(false); + expect(isMouseEnabled(result)).toBe(false); + + }); + + it('should leave the section absent when it was not written', () => { + + // Absent stays absent through the schema. What absent *means* is + // decided once, by isMouseEnabled, rather than by each reader. + const result = parseSettings({ build: { include: [] } }); + + expect(result.ui).toBeUndefined(); + expect(isMouseEnabled(result)).toBe(true); + + }); + + it('should reject a non-boolean mouse flag', () => { + + expect(() => validateSettings({ ui: { mouse: 'yes' } })).toThrow(); + + }); + + }); + + describe('isMouseEnabled', () => { + + it('should be on when there are no settings at all', () => { + + expect(DEFAULT_UI_MOUSE).toBe(true); + expect(isMouseEnabled(null)).toBe(true); + expect(isMouseEnabled(undefined)).toBe(true); + + }); + + it('should be on when the ui section exists without the flag', () => { + + expect(isMouseEnabled({ ui: {} })).toBe(true); + + }); + + it('should be off only when the flag is written false', () => { + + expect(isMouseEnabled({ ui: { mouse: false } })).toBe(false); + expect(isMouseEnabled({ ui: { mouse: true } })).toBe(true); + + }); + + }); + describe('SettingsValidationError', () => { it('should include field and issues in error', () => { diff --git a/tests/core/shared/abort.test.ts b/tests/core/shared/abort.test.ts new file mode 100644 index 00000000..d1daaf4b --- /dev/null +++ b/tests/core/shared/abort.test.ts @@ -0,0 +1,221 @@ +/** + * Cancellation primitive tests. + * + * The intent being pinned is that a caller can stop waiting without losing + * track of what it stopped waiting for: the abandoned work still settles, and + * its result has to reach the salvage handler or the resource leaks. + */ +import { describe, it, expect } from 'bun:test'; +import { attempt, attemptSync } from '@logosdx/utils'; + +import { + OperationAbortedError, + raceAbort, + throwIfAborted, +} from '../../../src/core/shared/abort.js'; + +/** + * A promise plus the handles to settle it later, so a test can decide when + * "the driver came back" happens relative to the abort. + */ +function deferred() { + + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + + const promise = new Promise((res, rej) => { + + resolve = res; + reject = rej; + + }); + + return { promise, resolve, reject }; + +} + +describe('shared: abort', () => { + + describe('throwIfAborted', () => { + + it('should do nothing without a signal', () => { + + const [, err] = attemptSync(() => throwIfAborted(undefined)); + + expect(err).toBeNull(); + + }); + + it('should do nothing while the signal is live', () => { + + const controller = new AbortController(); + + const [, err] = attemptSync(() => throwIfAborted(controller.signal)); + + expect(err).toBeNull(); + + }); + + it('should throw OperationAbortedError once aborted', () => { + + const controller = new AbortController(); + controller.abort(); + + const [, err] = attemptSync(() => throwIfAborted(controller.signal)); + + expect(err).toBeInstanceOf(OperationAbortedError); + + }); + + }); + + describe('raceAbort', () => { + + it('should hand back the work promise untouched when no signal is given', async () => { + + const work = Promise.resolve('done'); + + expect(raceAbort(work)).toBe(work); + expect(await raceAbort(work)).toBe('done'); + + }); + + it('should resolve normally when the work wins the race', async () => { + + const controller = new AbortController(); + + const value = await raceAbort(Promise.resolve('done'), controller.signal); + + expect(value).toBe('done'); + + }); + + it('should propagate the work error when the work fails first', async () => { + + const controller = new AbortController(); + const boom = new Error('boom'); + + const [value, err] = await attempt(() => + raceAbort(Promise.reject(boom), controller.signal), + ); + + expect(err).toBe(boom); + expect(value).toBeNull(); + + }); + + it('should reject with OperationAbortedError as soon as the signal fires', async () => { + + const controller = new AbortController(); + const never = deferred(); + + const raced = attempt(() => raceAbort(never.promise, controller.signal)); + + controller.abort(); + + const [value, err] = await raced; + + expect(err).toBeInstanceOf(OperationAbortedError); + expect(value).toBeNull(); + + }); + + it('should reject an already-aborted signal without waiting for the work', async () => { + + const controller = new AbortController(); + controller.abort(); + + const [, err] = await attempt(() => + raceAbort(deferred().promise, controller.signal), + ); + + expect(err).toBeInstanceOf(OperationAbortedError); + + }); + + it('should hand a late result to onAbandoned so the caller can close it', async () => { + + const controller = new AbortController(); + const late = deferred(); + const salvaged: string[] = []; + + const raced = attempt(() => + raceAbort(late.promise, controller.signal, (value) => salvaged.push(value)), + ); + + controller.abort(); + await raced; + + expect(salvaged).toEqual([]); + + late.resolve('a live pool nobody is holding'); + await new Promise((r) => setTimeout(r, 10)); + + expect(salvaged).toEqual(['a live pool nobody is holding']); + + }); + + it('should salvage a late result even when the signal was already aborted', async () => { + + const controller = new AbortController(); + controller.abort(); + + const late = deferred(); + const salvaged: string[] = []; + + await attempt(() => + raceAbort(late.promise, controller.signal, (value) => salvaged.push(value)), + ); + + late.resolve('opened after nobody was waiting'); + await new Promise((r) => setTimeout(r, 10)); + + expect(salvaged).toEqual(['opened after nobody was waiting']); + + }); + + it('should never call onAbandoned when the work wins', async () => { + + const controller = new AbortController(); + const salvaged: string[] = []; + + const value = await raceAbort( + Promise.resolve('done'), + controller.signal, + (v) => salvaged.push(v), + ); + + controller.abort(); + await new Promise((r) => setTimeout(r, 10)); + + expect(value).toBe('done'); + expect(salvaged).toEqual([]); + + }); + + it('should swallow a late rejection instead of crashing the process', async () => { + + const controller = new AbortController(); + const late = deferred(); + const unhandled: unknown[] = []; + + const onUnhandled = (reason: unknown) => unhandled.push(reason); + process.on('unhandledRejection', onUnhandled); + + const raced = attempt(() => raceAbort(late.promise, controller.signal)); + + controller.abort(); + await raced; + + late.reject(new Error('driver gave up long after nobody cared')); + await new Promise((r) => setTimeout(r, 50)); + + process.off('unhandledRejection', onUnhandled); + + expect(unhandled).toEqual([]); + + }); + + }); + +}); diff --git a/tests/core/sql-terminal/executor-abort.test.ts b/tests/core/sql-terminal/executor-abort.test.ts new file mode 100644 index 00000000..c105b2fb --- /dev/null +++ b/tests/core/sql-terminal/executor-abort.test.ts @@ -0,0 +1,213 @@ +/** + * SQL executor cancellation tests. + * + * The intent being pinned is honesty about what a cancel achieved. Stopping + * the client is always possible; stopping the server is not, and the result + * has to say which of the two happened so the UI cannot overclaim. + * + * Whether the server really stops is proved against live databases in + * `tests/integration/sql-terminal/cancel.test.ts` — it cannot be proved here. + */ +import { describe, it, expect } from 'bun:test'; +import { sql } from 'kysely'; + +import { createConnection } from '../../../src/core/connection/index.js'; +import { + abortMessageFor, + executeRawSql, + executeRawSqlUnchecked, + hasServerSideCancel, + readSessionId, +} from '../../../src/core/sql-terminal/executor.js'; +import { DEFAULT_ACCESS } from '../../../src/core/policy/index.js'; +import type { ConnectionResult } from '../../../src/core/connection/types.js'; + +async function sqliteConnection(): Promise { + + return createConnection({ dialect: 'sqlite', database: ':memory:' }); + +} + +describe('sql-terminal: hasServerSideCancel', () => { + + it('should claim server-side cancellation only where a cancel is actually sent', () => { + + expect(hasServerSideCancel('postgres')).toBe(true); + expect(hasServerSideCancel('mysql')).toBe(true); + + }); + + it('should not claim it for dialects where the client only stops listening', () => { + + expect(hasServerSideCancel('mssql')).toBe(false); + expect(hasServerSideCancel('sqlite')).toBe(false); + + }); + +}); + +describe('sql-terminal: abortMessageFor', () => { + + it('should say the server was asked to stop, where a cancel is genuinely sent', () => { + + expect(abortMessageFor('postgres')).toContain('server was asked to stop'); + expect(abortMessageFor('mysql')).toContain('server was asked to stop'); + + }); + + it('should say only that the client stopped waiting, everywhere else', () => { + + expect(abortMessageFor('mssql')).toContain('may still be running'); + expect(abortMessageFor('sqlite')).toContain('may still be running'); + + }); + +}); + +describe('sql-terminal: readSessionId', () => { + + it('should read a numeric session id', () => { + + expect(readSessionId([{ id: 4711 }])).toBe(4711); + + }); + + it('should read a numeric string, which is how some drivers return a bigint', () => { + + expect(readSessionId([{ id: '4711' }])).toBe(4711); + + }); + + it('should reject anything that is not a positive integer', () => { + + // The id is interpolated into `KILL QUERY`, which cannot be prepared. + // This guard is the only thing between a surprising driver value and + // that string. + expect(readSessionId([{ id: '4711; drop table users' }])).toBeUndefined(); + expect(readSessionId([{ id: 'pid' }])).toBeUndefined(); + expect(readSessionId([{ id: 12.5 }])).toBeUndefined(); + expect(readSessionId([{ id: 0 }])).toBeUndefined(); + expect(readSessionId([{ id: -1 }])).toBeUndefined(); + expect(readSessionId([{ id: null }])).toBeUndefined(); + + }); + + it('should reject an empty probe result rather than guessing', () => { + + expect(readSessionId([])).toBeUndefined(); + expect(readSessionId([{}])).toBeUndefined(); + + }); + +}); + +describe('sql-terminal: executor cancellation', () => { + + it('should behave exactly as before when no signal is passed', async () => { + + const conn = await sqliteConnection(); + + const result = await executeRawSqlUnchecked(conn.db, 'SELECT 1 AS n', 'test'); + + expect(result.success).toBe(true); + expect(result.rows).toEqual([{ n: 1 }]); + expect(result.aborted).toBeUndefined(); + + await conn.destroy(); + + }); + + it('should ignore a signal that never fires', async () => { + + const conn = await sqliteConnection(); + const controller = new AbortController(); + + const result = await executeRawSqlUnchecked(conn.db, 'SELECT 1 AS n', 'test', { + signal: controller.signal, + dialect: 'sqlite', + }); + + expect(result.success).toBe(true); + expect(result.aborted).toBeUndefined(); + + await conn.destroy(); + + }); + + it('should report stopped-waiting for a dialect with no cancel to send', async () => { + + const conn = await sqliteConnection(); + const controller = new AbortController(); + controller.abort(); + + const result = await executeRawSqlUnchecked(conn.db, 'SELECT 1 AS n', 'test', { + signal: controller.signal, + dialect: 'sqlite', + }); + + expect(result.success).toBe(false); + expect(result.aborted).toBe('stopped-waiting'); + expect(result.errorMessage).toContain('still be running'); + + await conn.destroy(); + + }); + + it('should thread the signal through the policy-gated entry point', async () => { + + const conn = await sqliteConnection(); + const controller = new AbortController(); + controller.abort(); + + const result = await executeRawSql( + conn.db, + 'SELECT 1 AS n', + 'test', + { access: DEFAULT_ACCESS, channel: 'user', dialect: 'sqlite' }, + controller.signal, + ); + + expect(result.success).toBe(false); + expect(result.aborted).toBe('stopped-waiting'); + + await conn.destroy(); + + }); + + it('should leave a genuine query failure unmarked as aborted', async () => { + + const conn = await sqliteConnection(); + const controller = new AbortController(); + + const result = await executeRawSqlUnchecked(conn.db, 'SELECT * FROM nope', 'test', { + signal: controller.signal, + dialect: 'sqlite', + }); + + expect(result.success).toBe(false); + expect(result.aborted).toBeUndefined(); + + await conn.destroy(); + + }); + + it('should keep the connection usable after a cancel, not poison it', async () => { + + const conn = await sqliteConnection(); + const controller = new AbortController(); + controller.abort(); + + await executeRawSqlUnchecked(conn.db, 'SELECT 1 AS n', 'test', { + signal: controller.signal, + dialect: 'sqlite', + }); + + const after = await sql<{ n: number }>`SELECT 2 AS n`.execute(conn.db); + + expect(after.rows).toEqual([{ n: 2 }]); + + await conn.destroy(); + + }); + +}); diff --git a/tests/integration/connection/timeout-abort.test.ts b/tests/integration/connection/timeout-abort.test.ts new file mode 100644 index 00000000..595a6134 --- /dev/null +++ b/tests/integration/connection/timeout-abort.test.ts @@ -0,0 +1,150 @@ +/** + * Connect timeout and escape-hatch integration tests. + * + * Two claims that only a real socket can settle: an unreachable host now ends + * the attempt on its own instead of waiting forever, and a caller who stops + * waiting gets control back in a fraction of that time without leaving an open + * pool behind. + */ +import { describe, it, expect } from 'bun:test'; +import { attempt } from '@logosdx/utils'; + +import { + createConnection, + testConnection, + getConnectionManager, +} from '../../../src/core/connection/index.js'; +import { OperationAbortedError } from '../../../src/core/shared/abort.js'; +import { TEST_CONNECTIONS, skipIfNoContainer } from '../../utils/db.js'; +import type { ConnectionConfig } from '../../../src/core/connection/types.js'; + +/** + * RFC 5737 TEST-NET-1, reserved for documentation and guaranteed never to be + * routed. Packets to it are dropped rather than refused, which is what + * reproduces "the screen gets stuck" — a connect with nothing to fail on. + */ +const BLACKHOLE_HOST = '192.0.2.1'; + +/** Short enough to keep the suite quick, long enough to outlast the abort. */ +const SHORT_TIMEOUT_MS = 1_500; + +function blackholeConfig(dialect: ConnectionConfig['dialect']): ConnectionConfig { + + return { + dialect, + host: BLACKHOLE_HOST, + port: dialect === 'mysql' ? 3306 : 5432, + user: 'nobody', + password: 'nobody', + database: 'nothing', + connectTimeoutMs: SHORT_TIMEOUT_MS, + }; + +} + +describe('integration: unreachable host', () => { + + it('should give up on its own rather than waiting forever', async () => { + + const started = Date.now(); + + const [conn, err] = await attempt(() => + createConnection(blackholeConfig('postgres'), '__blackhole__', { retries: 1, delay: 0 }), + ); + + const elapsed = Date.now() - started; + + expect(conn).toBeNull(); + expect(err).toBeInstanceOf(Error); + expect(err).not.toBeInstanceOf(OperationAbortedError); + expect(elapsed).toBeLessThan(SHORT_TIMEOUT_MS * 4); + + // The driver's own message, not the generic wrapper's. The two + // deadlines race, and the driver has to win it: only its timeout tears + // the socket down, and only it can name what actually failed. + expect(err?.message).toContain('connection timeout'); + + }, 30_000); + + it('should come back far sooner than the timeout when the caller stops waiting', async () => { + + const controller = new AbortController(); + const started = Date.now(); + + const pending = attempt(() => + createConnection( + blackholeConfig('postgres'), + '__blackhole__', + { retries: 1, delay: 0 }, + controller.signal, + ), + ); + + setTimeout(() => controller.abort(), 100); + + const [conn, err] = await pending; + const elapsed = Date.now() - started; + + expect(conn).toBeNull(); + expect(err).toBeInstanceOf(OperationAbortedError); + expect(elapsed).toBeLessThan(SHORT_TIMEOUT_MS); + + }, 30_000); + + it('should report an aborted testConnection as aborted, not as a database failure', async () => { + + const controller = new AbortController(); + + const pending = testConnection(blackholeConfig('postgres'), { signal: controller.signal }); + + setTimeout(() => controller.abort(), 100); + + const result = await pending; + + expect(result.ok).toBe(false); + expect(result.aborted).toBe(true); + + }, 30_000); + +}); + +describe('integration: abandoned connection cleanup', () => { + + it('should close a connection that finishes opening after the caller gave up', async () => { + + await skipIfNoContainer('postgres'); + + const manager = getConnectionManager(); + const baseline = manager.size; + + const controller = new AbortController(); + + const pending = attempt(() => + createConnection(TEST_CONNECTIONS.postgres, '__abandoned__', {}, controller.signal), + ); + + // The connect has not had a tick to complete yet, so the abort wins the + // race and the pool that opens a moment later has no owner at all. + controller.abort(); + + const [conn, err] = await pending; + + expect(conn).toBeNull(); + expect(err).toBeInstanceOf(OperationAbortedError); + + // The salvage path untracks as it destroys, so the manager returning to + // its baseline is the proof the abandoned pool was actually closed + // rather than merely forgotten. + const deadline = Date.now() + 10_000; + + while (manager.size > baseline && Date.now() < deadline) { + + await new Promise((r) => setTimeout(r, 50)); + + } + + expect(manager.size).toBe(baseline); + + }, 30_000); + +}); diff --git a/tests/integration/explore/row-peek.test.ts b/tests/integration/explore/row-peek.test.ts new file mode 100644 index 00000000..d9353eb2 --- /dev/null +++ b/tests/integration/explore/row-peek.test.ts @@ -0,0 +1,516 @@ +/** + * Integration tests for the table data peek, against every dialect. + * + * The unit tests pin the SQL string; this pins that the SQL is *accepted*. + * Those are different claims, and the gap between them is exactly where the + * portability problem lives: `SELECT TOP (10) *` compiles from the same code + * path as `LIMIT 10` and only a server can say whether either one parses. The + * same goes for the identifier quoting — `"dbo"."peek_seq"` is only a valid + * table reference on SQL Server while QUOTED_IDENTIFIER is on, which is the + * driver's default and not something a compiled string reveals. + * + * Every case runs against all four dialects from one table of fixtures, so a + * dialect cannot quietly go untested. + */ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { sql } from 'kysely'; +import { attempt, attemptSync } from '@logosdx/utils'; + +import type { Kysely } from 'kysely'; +import type { Dialect } from '../../../src/core/connection/types.js'; +import type { TableDetail } from '../../../src/core/explore/types.js'; +import type { ConfigAccess } from '../../../src/core/policy/index.js'; + +import { fetchDetail, fetchRowPeek } from '../../../src/core/explore/index.js'; +import { renderRowDocument } from '../../../src/tui/components/terminal/rowDocument.js'; +import { createTestConnection, skipIfNoContainer } from '../../utils/db.js'; + +/** Access that lets `sql:read` through, so a denial in a test is never incidental. */ +const OPEN: ConfigAccess = { user: 'admin', agent: 'admin' }; + +const GATE = { configName: 'noorm_test', access: OPEN, channel: 'user' } as const; + +/** Page size every case uses, small enough to keep the fixtures readable. */ +const PAGE = 4; + +/** + * Fixture tables, named for what each one proves. + * + * Row counts are stated relative to `PAGE` rather than as bare numbers: the + * overlap cases are the ones that break if the page size ever moves. + */ +const TABLES = { + /** Comfortably more than two pages: the two ends are disjoint. */ + seq: 'peek_seq', + /** Between one and two pages: the ends meet and must not be drawn twice. */ + short: 'peek_short', + /** Exactly one page: both queries return the same rows. */ + exact: 'peek_exact', + /** No rows at all. */ + empty: 'peek_empty', + /** No primary key, so there is no tail to read. */ + nokey: 'peek_nokey', + /** Two-column primary key. */ + pair: 'peek_pair', + /** Every row NULL in one column. */ + nulls: 'peek_nulls', + /** One row holding a value of every kind the row view has to render. */ + values: 'peek_values', +} as const; + +const ALL_TABLES = Object.values(TABLES); + +/** + * The value fixture, per dialect. + * + * Written out four times rather than generated, because the whole point is that + * the four disagree: `bytea` against `varbinary` against `blob`, a real + * `timestamptz` against SQLite's text, and a `jsonb` the driver parses against a + * string it does not. A generated statement would have to paper over exactly the + * differences under test. + */ +const VALUE_FIXTURE: Record = { + postgres: { + ddl: `CREATE TABLE ${TABLES.values} ( + id integer PRIMARY KEY, + c_null text, + c_empty text, + c_nullword text, + c_flag boolean, + c_ts timestamptz, + c_big bigint, + c_bytes bytea, + c_doc jsonb + )`, + insert: `INSERT INTO ${TABLES.values} VALUES ( + 1, NULL, '', 'null', true, TIMESTAMPTZ '2024-03-01 12:34:56+00', + 9223372036854775807, '\\x00ff10'::bytea, '{"a":1,"b":[2,3]}'::jsonb + )`, + }, + mysql: { + ddl: `CREATE TABLE ${TABLES.values} ( + id int PRIMARY KEY, + c_null text, + c_empty text, + c_nullword text, + c_flag boolean, + c_ts datetime, + c_big bigint, + c_bytes varbinary(16), + c_doc json + )`, + insert: `INSERT INTO ${TABLES.values} VALUES ( + 1, NULL, '', 'null', true, '2024-03-01 12:34:56', + 9223372036854775807, X'00ff10', '{"a":1,"b":[2,3]}' + )`, + }, + mssql: { + ddl: `CREATE TABLE ${TABLES.values} ( + id int PRIMARY KEY, + c_null nvarchar(50), + c_empty nvarchar(50), + c_nullword nvarchar(50), + c_flag bit, + c_ts datetime2, + c_big bigint, + c_bytes varbinary(16), + c_doc nvarchar(max) + )`, + insert: `INSERT INTO ${TABLES.values} VALUES ( + 1, NULL, '', 'null', 1, '2024-03-01 12:34:56', + 9223372036854775807, 0x00ff10, '{"a":1,"b":[2,3]}' + )`, + }, + sqlite: { + ddl: `CREATE TABLE ${TABLES.values} ( + id integer PRIMARY KEY, + c_null text, + c_empty text, + c_nullword text, + c_flag integer, + c_ts text, + c_big integer, + c_bytes blob, + c_doc text + )`, + insert: `INSERT INTO ${TABLES.values} VALUES ( + 1, NULL, '', 'null', 1, '2024-03-01 12:34:56', + 9007199254740993, X'00ff10', '{"a":1,"b":[2,3]}' + )`, + }, +}; + +/** Dialects whose driver parses a JSON column into an object before we see it. */ +const PARSES_JSON: Dialect[] = ['postgres', 'mysql']; + +/** + * SQLite is in-memory and lives only as long as the connection, so it needs no + * container. The rest do, and `skipIfNoContainer` throws rather than skipping. + */ +async function connect(dialect: Dialect) { + + if (dialect !== 'sqlite') await skipIfNoContainer(dialect); + + return createTestConnection(dialect); + +} + +async function run(db: Kysely, statement: string): Promise { + + await sql.raw(statement).execute(db); + +} + +/** + * Insert `count` rows numbered from 1, one statement per row. + * + * One at a time rather than a multi-row VALUES list because MSSQL caps a single + * INSERT at 1000 rows and the syntax differences are not worth the saving on + * fixtures this size. + */ +async function fill(db: Kysely, table: string, count: number): Promise { + + for (let id = 1; id <= count; id += 1) { + + await run(db, `INSERT INTO ${table} (id, label) VALUES (${id}, 'row-${id}')`); + + } + +} + +async function createFixtures(db: Kysely, dialect: Dialect): Promise { + + for (const table of ALL_TABLES) { + + await run(db, `DROP TABLE IF EXISTS ${table}`); + + } + + await run(db, `CREATE TABLE ${TABLES.seq} (id INTEGER PRIMARY KEY, label VARCHAR(50))`); + await fill(db, TABLES.seq, PAGE * 4); + + await run(db, `CREATE TABLE ${TABLES.short} (id INTEGER PRIMARY KEY, label VARCHAR(50))`); + await fill(db, TABLES.short, PAGE * 2 - 1); + + await run(db, `CREATE TABLE ${TABLES.exact} (id INTEGER PRIMARY KEY, label VARCHAR(50))`); + await fill(db, TABLES.exact, PAGE); + + await run(db, `CREATE TABLE ${TABLES.empty} (id INTEGER PRIMARY KEY, label VARCHAR(50))`); + + await run(db, `CREATE TABLE ${TABLES.nokey} (label VARCHAR(50))`); + + for (let i = 1; i <= PAGE * 2; i += 1) { + + await run(db, `INSERT INTO ${TABLES.nokey} (label) VALUES ('row-${i}')`); + + } + + // `zone_id` before `item_no` on purpose: alphabetical order would put + // `item_no` first, so a peek that sorted the key columns by name instead of + // by ordinal position produces a visibly different first page here. + await run( + db, + `CREATE TABLE ${TABLES.pair} ( + zone_id INTEGER NOT NULL, + item_no INTEGER NOT NULL, + label VARCHAR(50), + PRIMARY KEY (zone_id, item_no) + )`, + ); + + for (let zone = 1; zone <= 2; zone += 1) { + + for (let no = 1; no <= PAGE * 2; no += 1) { + + await run( + db, + `INSERT INTO ${TABLES.pair} (zone_id, item_no, label) VALUES (${zone}, ${no}, 'z${zone}-${no}')`, + ); + + } + + } + + await run(db, `CREATE TABLE ${TABLES.nulls} (id INTEGER PRIMARY KEY, maybe VARCHAR(50))`); + + for (let id = 1; id <= 3; id += 1) { + + await run(db, `INSERT INTO ${TABLES.nulls} (id, maybe) VALUES (${id}, NULL)`); + + } + + await run(db, VALUE_FIXTURE[dialect].ddl); + await run(db, VALUE_FIXTURE[dialect].insert); + +} + +async function dropFixtures(db: Kysely): Promise { + + for (const table of ALL_TABLES) { + + await run(db, `DROP TABLE IF EXISTS ${table}`); + + } + +} + +for (const dialect of ['postgres', 'mysql', 'mssql', 'sqlite'] as const) { + + describe(`integration: ${dialect} row peek`, () => { + + let db: Kysely; + let destroy: () => Promise; + + /** + * The detail the screen would already be holding when the reader asks + * for a peek, fetched the same way the screen fetches it. + */ + const detailFor = async (table: string): Promise => { + + const detail = await fetchDetail(db, dialect, 'tables', table); + + expect(detail).not.toBeNull(); + + return detail!; + + }; + + beforeAll(async () => { + + const conn = await connect(dialect); + db = conn.db; + destroy = conn.destroy; + + await createFixtures(db, dialect); + + }); + + afterAll(async () => { + + if (!destroy) return; + + await dropFixtures(db); + await destroy(); + + }); + + it('should read both ends of a table longer than two pages', async () => { + + const peek = await fetchRowPeek(db, dialect, await detailFor(TABLES.seq), GATE, PAGE); + + expect(peek.mode).toBe('ends'); + expect(peek.keyColumns).toEqual(['id']); + expect(peek.first.map((row) => Number(row['id']))).toEqual([1, 2, 3, 4]); + expect(peek.last.map((row) => Number(row['id']))).toEqual([13, 14, 15, 16]); + + }); + + it('should carry the row values, not just the keys', async () => { + + const peek = await fetchRowPeek(db, dialect, await detailFor(TABLES.seq), GATE, PAGE); + + expect(peek.first[0]?.['label']).toBe('row-1'); + expect(peek.last.at(-1)?.['label']).toBe('row-16'); + + }); + + it('should show one set when the table holds fewer than two pages', async () => { + + const peek = await fetchRowPeek(db, dialect, await detailFor(TABLES.short), GATE, PAGE); + + const ids = peek.first.map((row) => Number(row['id'])); + + expect(peek.mode).toBe('whole'); + expect(peek.last).toEqual([]); + expect(ids).toEqual([1, 2, 3, 4, 5, 6, 7]); + expect(new Set(ids).size).toBe(ids.length); + + }); + + it('should show one set when the table holds exactly one page', async () => { + + const peek = await fetchRowPeek(db, dialect, await detailFor(TABLES.exact), GATE, PAGE); + + expect(peek.mode).toBe('whole'); + expect(peek.first.map((row) => Number(row['id']))).toEqual([1, 2, 3, 4]); + + }); + + it('should return an empty set for an empty table without failing', async () => { + + const peek = await fetchRowPeek(db, dialect, await detailFor(TABLES.empty), GATE, PAGE); + + expect(peek.mode).toBe('whole'); + expect(peek.first).toEqual([]); + expect(peek.columns).toEqual(['id', 'label']); + + }); + + it('should read the head only when the table has no primary key', async () => { + + const peek = await fetchRowPeek(db, dialect, await detailFor(TABLES.nokey), GATE, PAGE); + + expect(peek.mode).toBe('head'); + expect(peek.keyColumns).toEqual([]); + expect(peek.first).toHaveLength(PAGE); + expect(peek.last).toEqual([]); + + }); + + it('should order a composite key by ordinal position, not column name', async () => { + + const peek = await fetchRowPeek(db, dialect, await detailFor(TABLES.pair), GATE, PAGE); + + // Sorted by name the key would read `item_no, zone_id`, and the + // first page would be z1-1, z2-1, z1-2, z2-2 instead. + expect(peek.mode).toBe('ends'); + expect(peek.keyColumns).toEqual(['zone_id', 'item_no']); + expect(peek.first.map((row) => row['label'])).toEqual(['z1-1', 'z1-2', 'z1-3', 'z1-4']); + expect(peek.last.map((row) => row['label'])).toEqual(['z2-5', 'z2-6', 'z2-7', 'z2-8']); + + }); + + it('should render a column that is NULL in every row', async () => { + + const peek = await fetchRowPeek(db, dialect, await detailFor(TABLES.nulls), GATE, PAGE); + + expect(peek.mode).toBe('whole'); + expect(peek.first).toHaveLength(3); + expect(peek.first.every((row) => row['maybe'] === null)).toBe(true); + + }); + + /** + * The one row of `peek_values`, rendered the way the row view renders + * it. + * + * The unit tests feed the formatter values chosen by hand; this feeds it + * whatever this driver actually returns, which is the only way to find + * out that `bun:sqlite` hands back a `Uint8Array` where the other three + * hand back a `Buffer`. + */ + const valueDocument = async (format: 'json' | 'yaml') => { + + const peek = await fetchRowPeek(db, dialect, await detailFor(TABLES.values), GATE, PAGE); + const row = peek.first[0]; + + expect(row).toBeDefined(); + + return renderRowDocument(row!, peek.columns, format); + + }; + + it('should keep NULL apart from the empty string and the word null', async () => { + + const document = await valueDocument('yaml'); + + expect(document).toContain('c_null: null'); + expect(document).toContain('c_empty: ""'); + expect(document).toContain('c_nullword: "null"'); + + }); + + it('should summarize this driver\'s binary rather than dumping its wrapper', async () => { + + const document = await valueDocument('yaml'); + + expect(document).toContain('c_bytes: '); + expect(document).not.toContain('"type"'); + expect(document).not.toContain('Buffer'); + + }); + + it('should render every value as something other than an object tag', async () => { + + const document = await valueDocument('yaml'); + + expect(document).not.toContain('[object Object]'); + expect(document).not.toContain(''); + expect(document).not.toContain('undefined'); + + }); + + it('should produce JSON that parses back', async () => { + + // The claim under this is that nothing in the row made + // `JSON.stringify` throw or emit something it cannot read again - + // a bigint is the value that does the former. + const document = await valueDocument('json'); + const [parsed, err] = attemptSync(() => JSON.parse(document)); + + expect(err).toBeNull(); + expect(parsed).toBeDefined(); + + }); + + it('should carry a large integer without rounding it into a float', async () => { + + const document = await valueDocument('yaml'); + + // MySQL and SQLite hand back a JS `number` for a 64-bit integer and + // have already lost precision by the time we see it; postgres and + // mssql hand back a string and keep it. The formatter's job is not + // to invent the digits back, it is to print what arrived without + // scientific notation or a thrown TypeError. + expect(document).toMatch(/c_big: "?9\d{15,18}"?/); + + }); + + it('should render a timestamp as a readable instant', async () => { + + const document = await valueDocument('yaml'); + + expect(document).toContain('c_ts: 2024-03-01'); + + }); + + if (PARSES_JSON.includes(dialect)) { + + it('should render a parsed json column as nested structure', async () => { + + const document = await valueDocument('yaml'); + + expect(document).toContain('c_doc:'); + expect(document).toContain('a: 1'); + expect(document).toContain('- 2'); + + }); + + } + + it('should refuse the read before it reaches the database when policy denies', async () => { + + const detail = await detailFor(TABLES.seq); + + const [peek, err] = await attempt(() => fetchRowPeek(db, dialect, detail, { + configName: 'noorm_test', + access: { user: 'admin', agent: false }, + channel: 'agent', + }, PAGE)); + + expect(peek).toBeNull(); + expect(err?.message).toContain('agent'); + + }); + + it('should surface a readable error when the table is gone', async () => { + + const detail = await detailFor(TABLES.seq); + + const [peek, err] = await attempt(() => fetchRowPeek( + db, + dialect, + { ...detail, name: 'peek_does_not_exist' }, + GATE, + PAGE, + )); + + expect(peek).toBeNull(); + expect(err).toBeInstanceOf(Error); + expect(err?.message.length).toBeGreaterThan(0); + + }); + + }); + +} diff --git a/tests/integration/sql-terminal/cancel.test.ts b/tests/integration/sql-terminal/cancel.test.ts new file mode 100644 index 00000000..ec5942fd --- /dev/null +++ b/tests/integration/sql-terminal/cancel.test.ts @@ -0,0 +1,286 @@ +/** + * Cancellation integration tests against live databases. + * + * The claim under test is the one that cannot be made with a fake clock: that + * on postgres and mysql an abort actually stops the query on the server, and + * that on mssql it does not — only the client stops listening. Each dialect is + * asked the same question twice: did the caller get control back, and is the + * query still burning a session a second later. + */ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import { sql } from 'kysely'; +import type { Kysely } from 'kysely'; + +import { executeRawSqlUnchecked } from '../../../src/core/sql-terminal/executor.js'; +import { createTestConnection, skipIfNoContainer } from '../../utils/db.js'; + +/** A sleep long enough that it can only end by being cancelled. */ +const SLEEP_SECONDS = 20; + +/** How long the caller may wait for control back after pressing the hatch. */ +const RETURN_BUDGET_MS = 3_000; + +/** + * Poll `probe` until it reports zero, or the deadline passes. + * + * A cancel is asynchronous on every server here — the backend notices at its + * next interrupt check — so a single read right after the abort would be + * racing the database rather than testing it. + */ +async function waitForNoSessions(probe: () => Promise, timeoutMs = 10_000): Promise { + + const deadline = Date.now() + timeoutMs; + + let running = await probe(); + + while (running > 0 && Date.now() < deadline) { + + await new Promise((r) => setTimeout(r, 100)); + running = await probe(); + + } + + return running; + +} + +describe('integration: postgres query cancellation', () => { + + let db: Kysely; + let destroy: () => Promise; + + beforeAll(async () => { + + await skipIfNoContainer('postgres'); + + const conn = await createTestConnection('postgres'); + db = conn.db; + destroy = conn.destroy; + + }); + + afterAll(async () => { + + if (destroy) await destroy(); + + }); + + const runningSleeps = async () => { + + const result = await sql<{ n: number | string }>` + select count(*) as n from pg_stat_activity + where query like ${`%pg_sleep(${SLEEP_SECONDS})%`} + and query not like '%pg_stat_activity%' + and state = 'active' + `.execute(db); + + return Number(result.rows[0]?.n ?? 0); + + }; + + it('should hand control back promptly and stop the query on the server', async () => { + + const controller = new AbortController(); + const started = Date.now(); + + const pending = executeRawSqlUnchecked(db, `select pg_sleep(${SLEEP_SECONDS})`, 'test', { + signal: controller.signal, + dialect: 'postgres', + }); + + // Long enough that the query is genuinely running, short enough that a + // result arriving on its own would mean the sleep never happened. + await new Promise((r) => setTimeout(r, 750)); + + expect(await runningSleeps()).toBe(1); + + controller.abort(); + + const result = await pending; + const elapsed = Date.now() - started; + + expect(result.success).toBe(false); + expect(result.aborted).toBe('server-cancel-requested'); + expect(elapsed).toBeLessThan(RETURN_BUDGET_MS); + + expect(await waitForNoSessions(runningSleeps)).toBe(0); + + }, 40_000); + + it('should leave the pool usable once a query has been cancelled', async () => { + + const controller = new AbortController(); + + const pending = executeRawSqlUnchecked(db, `select pg_sleep(${SLEEP_SECONDS})`, 'test', { + signal: controller.signal, + dialect: 'postgres', + }); + + await new Promise((r) => setTimeout(r, 500)); + controller.abort(); + await pending; + + await waitForNoSessions(runningSleeps); + + const after = await executeRawSqlUnchecked(db, 'select 1 as n', 'test'); + + expect(after.success).toBe(true); + expect(after.rows).toEqual([{ n: 1 }]); + + }, 40_000); + +}); + +describe('integration: mysql query cancellation', () => { + + let db: Kysely; + let destroy: () => Promise; + + beforeAll(async () => { + + await skipIfNoContainer('mysql'); + + const conn = await createTestConnection('mysql'); + db = conn.db; + destroy = conn.destroy; + + }); + + afterAll(async () => { + + if (destroy) await destroy(); + + }); + + const runningSleeps = async () => { + + const result = await sql<{ n: number | string }>` + select count(*) as n from information_schema.processlist + where info like ${`%sleep(${SLEEP_SECONDS})%`} + and info not like '%information_schema.processlist%' + `.execute(db); + + return Number(result.rows[0]?.n ?? 0); + + }; + + it('should hand control back promptly and kill the query on the server', async () => { + + const controller = new AbortController(); + const started = Date.now(); + + const pending = executeRawSqlUnchecked(db, `select sleep(${SLEEP_SECONDS})`, 'test', { + signal: controller.signal, + dialect: 'mysql', + }); + + await new Promise((r) => setTimeout(r, 750)); + + expect(await runningSleeps()).toBe(1); + + controller.abort(); + + const result = await pending; + const elapsed = Date.now() - started; + + expect(result.success).toBe(false); + expect(result.aborted).toBe('server-cancel-requested'); + expect(elapsed).toBeLessThan(RETURN_BUDGET_MS); + + expect(await waitForNoSessions(runningSleeps)).toBe(0); + + }, 40_000); + +}); + +describe('integration: mssql query cancellation', () => { + + let db: Kysely; + let destroy: () => Promise; + + // Short on purpose. An abandoned mssql batch keeps its pool connection to + // itself until it finishes, and `db.destroy()` waits for it — so a long + // delay here would hang the suite's own teardown, which is exactly the + // hazard `discardConnection`'s timeout exists to bound in production. + const DELAY = "'00:00:04'"; + + const runningWaits = async () => { + + const result = await sql<{ n: number | string }>` + select count(*) as n + from sys.dm_exec_requests r + cross apply sys.dm_exec_sql_text(r.sql_handle) t + where t.text like '%waitfor delay%' + and t.text not like '%dm_exec_requests%' + `.execute(db); + + return Number(result.rows[0]?.n ?? 0); + + }; + + beforeAll(async () => { + + await skipIfNoContainer('mssql'); + + const conn = await createTestConnection('mssql'); + db = conn.db; + destroy = conn.destroy; + + }); + + afterAll(async () => { + + if (destroy) await destroy(); + + }); + + it('should hand control back but say so honestly, because nothing reaches the server', async () => { + + const controller = new AbortController(); + const started = Date.now(); + + const pending = executeRawSqlUnchecked(db, `waitfor delay ${DELAY}`, 'test', { + signal: controller.signal, + dialect: 'mssql', + }); + + await new Promise((r) => setTimeout(r, 750)); + + controller.abort(); + + const result = await pending; + const elapsed = Date.now() - started; + + expect(result.success).toBe(false); + expect(result.aborted).toBe('stopped-waiting'); + expect(result.errorMessage).toContain('may still be running'); + expect(elapsed).toBeLessThan(RETURN_BUDGET_MS); + + await waitForNoSessions(runningWaits); + + }, 40_000); + + it('should still be running the abandoned batch on the server, which is what stopped-waiting means', async () => { + + const controller = new AbortController(); + + const pending = executeRawSqlUnchecked(db, `waitfor delay ${DELAY}`, 'test', { + signal: controller.signal, + dialect: 'mssql', + }); + + await new Promise((r) => setTimeout(r, 750)); + controller.abort(); + await pending; + + // A second after the caller gave up, the server has not been told + // anything. This is the assertion that keeps the UI wording honest. + await new Promise((r) => setTimeout(r, 1_000)); + + expect(await runningWaits()).toBeGreaterThan(0); + + await waitForNoSessions(runningWaits); + + }, 40_000); + +});