Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .changeset/inspect-scroll-and-masked-secrets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
---
"@noormdev/cli": minor
---

Make `run inspect` scrollable, and show secrets partially rather than as a count.

The inspect screen rendered its context as a nested tree that grew with the
project, and Ink has no scroll offset — so on any real template the bottom of
the view sat below the fold with no key that could reach it, and the screen's
own footer was what got pushed off to make room. Every view it offers (summary,
expanded, rendered SQL, and render errors) is now a flat list of one element per
visual line behind a viewport, scrolled with the same `↑↓` / `^U` / `^D` keys the
explore and SQL screens already use.

`$.secrets` and `$.globalSecrets` reported a key count, which cannot answer the
question the screen is opened to answer: a stale password and a fresh one are
both `Object (7 keys)`. Both tiers now show a partial reveal that narrows as the
value gets shorter — a four-character value shows nothing, a long one shows two
characters and a four-character suffix — with the length beside it as a number,
so a value that is set but empty is distinguishable from one that is set wrong.
`$.env` is listed and masked on the same terms, because it is the whole of
`process.env` and nothing in the screen can tell which of its keys are
credentials.

The mouse wheel now scrolls every viewport, which it never did. Only `SelectList`
and `ResultTable` consumed wheel notches, so the explore detail view, the
full-text overlay and the row viewer ignored them — and because the TUI runs in
the alternate screen, which has no scrollback, and mouse tracking intercepts the
notches a terminal would otherwise translate into arrow keys, turning the mouse
on had actually removed the only wheel behaviour those panes had.

Also fixes an error path that could never render: a template whose helper failed
to load set the screen's error phase with a file selected, which no branch
matched, so the most likely failure showed as "Unknown phase".
3 changes: 3 additions & 0 deletions src/core/shared/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export { OperationAbortedError, throwIfAborted, raceAbort } from './abort.js';
// Files
export { filterFilesByPaths, findUnmatchedIncludePatterns, findUnmatchedExcludePatterns } from './files.js';

// Secret display
export { maskSecret } from './mask.js';

// Dialect quoting
export { createDialectQuoting, type DialectQuoting } from './dialect-quoting.js';

Expand Down
88 changes: 88 additions & 0 deletions src/core/shared/mask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* Partial masking for a secret that is being shown to the person who owns it.
*
* The inspect screen exists to answer "did this template get the values I
* think it got", and a row of `Object (7 keys)` cannot answer it. Neither can
* a full reveal, which turns a screen someone leaves open during a screen
* share into a credential leak. What answers it is enough of the value to
* recognise which secret it is and to catch the two mistakes that actually
* happen — a stale value, and a key resolved from the wrong tier.
*
* How much is safe to show depends on how much there is. A four-character
* value has no middle to hide, so revealing its ends reveals the value; a
* forty-character token gives away nothing in six characters. The bands below
* reveal less as the value gets shorter, and stop revealing at all once a
* value is short enough that any window is most of it.
*
* The mask core is a fixed width on purpose. Sizing it to the value would
* publish the exact length of every secret on screen, which is a real
* narrowing hint against a value someone is trying to guess. Callers that
* want the length — inspect does, because "set but empty" and "set to the
* 8-character staging password" are different bugs — ask for it separately
* and render it as a number, where it reads as the diagnostic it is rather
* than as part of the value.
*
* @example
* maskSecret('hunter2'); // 'h*****2'
* maskSecret('postgres://user:pw@host/db'); // 'po*****t/db'
*/

/**
* What stands in for the hidden middle, at every length that has one.
*
* Fixed rather than proportional so the rendering never encodes how long the
* value is. See the module note.
*/
const MASK_CORE = '*****';

/**
* Longest value that is shown as nothing but mask.
*
* At four characters a first-and-last window is half the value, which is not
* a mask.
*/
const OPAQUE_MAX = 4;

/** Longest value that reveals only one character at each end. */
const NARROW_MAX = 8;

/** Longest value that reveals a suffix but no prefix. */
const SUFFIX_ONLY_MAX = 12;

/** What an empty value renders as, so it is not mistaken for an unset one. */
const EMPTY_LABEL = '(empty)';

/**
* A secret rendered for display, revealing less the shorter it is.
*
* @example
* maskSecret(''); // '(empty)'
* maskSecret('abcd'); // '*****'
* maskSecret('abcdefgh'); // 'a*****h'
* maskSecret('abcdefghijkl'); // '*****ijkl'
* maskSecret('abcdefghijklm'); // 'ab*****jklm'
*/
export function maskSecret(value: string): string {

// Code points, not `.length`. A `String.prototype.slice` offset counts
// UTF-16 code units, so a value ending in an emoji or any other non-BMP
// character gets sliced through the middle of a surrogate pair and the
// reveal renders as a replacement glyph — the one part of the value a
// reader is meant to recognise, corrupted.
const characters = [...value];
const { length } = characters;

const head = (count: number) => characters.slice(0, count).join('');
const tail = (count: number) => characters.slice(-count).join('');

if (length === 0) return EMPTY_LABEL;

if (length <= OPAQUE_MAX) return MASK_CORE;

if (length <= NARROW_MAX) return `${head(1)}${MASK_CORE}${tail(1)}`;

if (length <= SUFFIX_ONLY_MAX) return `${MASK_CORE}${tail(4)}`;

return `${head(2)}${MASK_CORE}${tail(4)}`;

}
10 changes: 9 additions & 1 deletion src/tui/components/terminal/RowViewOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import type { ReactElement } from 'react';
import type { RowFormat } from './rowDocument.js';

import { useFocusScope } from '../../focus.js';
import { useWheelScroll } from '../../mouse.js';
import { preferredRowFormat, rememberRowFormat, renderRowDocument } from './rowDocument.js';
import { rowBudget, rowWindow, scrollTarget, wrapText } from './viewport.js';

Expand Down Expand Up @@ -118,6 +119,13 @@ export function RowViewOverlay({
const view = rowWindow(lines.length, offset, budget);
const maxOffset = lines.length - view.count;

const scrollTo = (next: number) => setOffset(Math.min(Math.max(next, 0), maxOffset));

// Scrolls the document, not the row cursor: ←/→ change rows, and a wheel
// that jumped between rows would lose the reader's place in a long one.
// Inert without a MouseProvider above it or with the setting off.
useWheelScroll({ isActive: isFocused, onWheel: (delta) => scrollTo(view.start + delta) });

const move = (next: number) => {

if (next < 0 || next > rows.length - 1 || next === index) return;
Expand Down Expand Up @@ -169,7 +177,7 @@ export function RowViewOverlay({

const target = scrollTarget(input, key, view, maxOffset);

if (target !== null) setOffset(Math.min(Math.max(target, 0), maxOffset));
if (target !== null) scrollTo(target);

});

Expand Down
93 changes: 93 additions & 0 deletions src/tui/components/terminal/ScrollPane.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* ScrollPane - a vertical viewport over a flat list of pre-laid-out lines.
*
* Ink has no scroll offset. A screen that renders more rows than the terminal
* holds does not clip them, it pushes its own footer off the bottom, so the
* content past the fold is not merely unscrolled — it is unreachable. Every
* screen that can overflow therefore has to flatten itself to one element per
* visual line and draw a slice of that list, which is what this does.
*
* It is the plain form of the pattern: offset state, the shared window
* arithmetic, the shared scroll keys, and the two "more above / more below"
* indicators. `ExploreDetailScreen`'s `ScrollView` is the same viewport with a
* full-text overlay and a row peek switched in over the top of it, and it stays
* where it is — its overlays are built from explore's own layout module, so
* pulling it down here would drag a screen's vocabulary into a shared
* component for no gain. Anything that needs a viewport and not those overlays
* uses this.
*
* Takes `height` and `isFocused` as props rather than measuring or scoping for
* itself, so the screen stays the single place that accounts for chrome, and so
* a test can pin a viewport without a terminal to measure.
*
* @example
* <ScrollPane lines={contextLines} height={viewportRows(rows)} isFocused={isFocused} />
*/
import { useState } from 'react';
import { Box, Text, useInput } from 'ink';

import type { ReactElement } from 'react';

import { useWheelScroll } from '../../mouse.js';
import { rowWindow, scrollTarget } from './viewport.js';

/**
* Props for the scroll pane.
*/
export interface ScrollPaneProps {

/**
* One element per visual line, each carrying its own `key`.
*
* One *visual* line: an element that wraps to two rows makes the window
* arithmetic wrong by one, so callers wrap their own text (`wrapText`) or
* truncate it (`wrap="truncate"`) before handing it over.
*/
lines: ReactElement[];

/** Rows the viewport may draw, indicators included. */
height: number;

/** Focus comes from the screen; this component opens no scope of its own. */
isFocused: boolean;

}

/**
* ScrollPane component.
*/
export function ScrollPane({ lines, height, isFocused }: ScrollPaneProps): ReactElement {

const [offset, setOffset] = useState(0);

const view = rowWindow(lines.length, offset, height);
const maxOffset = lines.length - view.count;

const scrollTo = (next: number) => setOffset(Math.min(Math.max(next, 0), maxOffset));

// Inert without a MouseProvider above it or with the setting off.
useWheelScroll({ isActive: isFocused, onWheel: (delta) => scrollTo(view.start + delta) });

useInput((input, key) => {

if (!isFocused) return;

// Rebases on `view.start` rather than on `offset`: the window clamps
// what it draws, so a stale offset left by a resize or by shorter
// content cannot send the next keypress somewhere the viewport never
// was.
const target = scrollTarget(input, key, view, maxOffset);

if (target !== null) scrollTo(target);

});

return (
<Box flexDirection="column">
{view.above > 0 && <Text dimColor> ↑ {view.above} more</Text>}
{lines.slice(view.start, view.start + view.count)}
{view.below > 0 && <Text dimColor> ↓ {view.below} more</Text>}
</Box>
);

}
3 changes: 3 additions & 0 deletions src/tui/components/terminal/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@ export type { ResultBrowserProps } from './ResultBrowser.js';
export { RowViewOverlay } from './RowViewOverlay.js';
export type { RowViewOverlayProps } from './RowViewOverlay.js';

export { ScrollPane } from './ScrollPane.js';
export type { ScrollPaneProps } from './ScrollPane.js';

export { fitGridColumns, fitPeekColumns, PEEK_COLUMN_CAP } from './columnFit.js';
export type { GridColumnFit, PeekColumnFit } from './columnFit.js';

Expand Down
66 changes: 66 additions & 0 deletions src/tui/mouse.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -435,6 +435,72 @@ export function useMouseTransport(): MouseTransport {

}

/**
* Options for useWheelScroll.
*/
export interface WheelScrollOptions {

/**
* Whether the component owning the viewport currently has input.
*
* The same guard its `useInput` handler uses: a wheel notch acts on
* whatever already has focus, so a pane sitting behind an overlay must not
* move under it.
*/
isActive: boolean;

/** A wheel notch: -1 for up, 1 for down. */
onWheel: (delta: -1 | 1) => void;

}

/**
* Wheel notches for a viewport that scrolls but has no rows to click.
*
* `useRowMouse` already carries wheel handling, but it is built around
* hit-testing a list of registered row refs, and a viewport has none — it draws
* a slice of a flat line list where nothing is selectable. Subscribing for the
* wheel alone is the whole of what those panes need.
*
* Without this the wheel is not merely unhandled, it is broken: the TUI runs in
* the alternate screen, which has no scrollback of its own, and mouse tracking
* takes the notches that a terminal would otherwise translate into arrow keys.
* So turning the mouse on removes the only wheel behaviour a viewport had.
*
* @example
* useWheelScroll({ isActive: isFocused, onWheel: (delta) => scrollTo(view.start + delta) });
*/
export function useWheelScroll({ isActive, onWheel }: WheelScrollOptions): void {

const { enabled, subscribe } = useMouseTransport();

// Registered once, reading the latest props through this ref. Listing them
// as dependencies would resubscribe on every render, since every caller
// passes an inline arrow.
const latest = useRef({ isActive, onWheel });

latest.current = { isActive, 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);

else if (event.button === 'wheel-down') current.onWheel(1);

});

}, [enabled, subscribe]);

}

/**
* Options for useRowMouse.
*/
Expand Down
8 changes: 8 additions & 0 deletions src/tui/screens/db/explore/ExploreDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import type { ScreenProps } from '../../../types.js';

import { useRouter } from '../../../router.js';
import { useFocusScope } from '../../../focus.js';
import { useWheelScroll } from '../../../mouse.js';
import { useAppContext } from '../../../app-context.js';
import { Panel, Spinner } from '../../../components/index.js';
import { useConnection, useAsyncEffect } from '../../../hooks/index.js';
Expand Down Expand Up @@ -614,6 +615,13 @@ export function ScrollView({
// cannot send the next keypress somewhere the viewport never was.
const scrollTo = (next: number) => setOffset(Math.min(Math.max(next, 0), maxOffset));

// Only while this component owns the viewport: an overlay draws over it and
// scrolls itself, so the pane underneath must not move under the notch.
useWheelScroll({
isActive: isFocused && overlay === 'none',
onWheel: (delta) => scrollTo(view.start + delta),
});

const open = (next: DetailOverlay) => {

setOverlay(next);
Expand Down
8 changes: 7 additions & 1 deletion src/tui/screens/db/explore/FullTextOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { Box, Text, useInput, useWindowSize } from 'ink';
import type { ReactElement } from 'react';

import { useFocusScope } from '../../../focus.js';
import { useWheelScroll } from '../../../mouse.js';
import { rowBudget, rowWindow, scrollTarget, wrapText } from './layout.js';

/**
Expand Down Expand Up @@ -96,6 +97,11 @@ export function FullTextOverlay({ text, startRow, height, onClose }: FullTextOve
const view = rowWindow(lines.length, offset, budget);
const maxOffset = lines.length - view.count;

const scrollTo = (next: number) => setOffset(Math.min(Math.max(next, 0), maxOffset));

// Inert without a MouseProvider above it or with the setting off.
useWheelScroll({ isActive: isFocused, onWheel: (delta) => scrollTo(view.start + delta) });

useInput((input, key) => {

if (!isFocused) return;
Expand All @@ -110,7 +116,7 @@ export function FullTextOverlay({ text, startRow, height, onClose }: FullTextOve

const target = scrollTarget(input, key, view, maxOffset);

if (target !== null) setOffset(Math.min(Math.max(target, 0), maxOffset));
if (target !== null) scrollTo(target);

});

Expand Down
Loading