claude-code/components/design-system/FuzzyPicker.tsx

312 lines
40 KiB
TypeScript
Raw Permalink Normal View History

import { c as _c } from "react/compiler-runtime";
import * as React from 'react';
import { useEffect, useState } from 'react';
import { useSearchInput } from '../../hooks/useSearchInput.js';
import { useTerminalSize } from '../../hooks/useTerminalSize.js';
import type { KeyboardEvent } from '../../ink/events/keyboard-event.js';
import { clamp } from '../../ink/layout/geometry.js';
import { Box, Text, useTerminalFocus } from '../../ink.js';
import { SearchBox } from '../SearchBox.js';
import { Byline } from './Byline.js';
import { KeyboardShortcutHint } from './KeyboardShortcutHint.js';
import { ListItem } from './ListItem.js';
import { Pane } from './Pane.js';
type PickerAction<T> = {
/** Hint label shown in the byline, e.g. "mention" → "Tab to mention". */
action: string;
handler: (item: T) => void;
};
type Props<T> = {
title: string;
placeholder?: string;
initialQuery?: string;
items: readonly T[];
getKey: (item: T) => string;
/** Keep to one line — preview handles overflow. */
renderItem: (item: T, isFocused: boolean) => React.ReactNode;
renderPreview?: (item: T) => React.ReactNode;
/** 'right' keeps hints stable (no bounce), but needs width. */
previewPosition?: 'bottom' | 'right';
visibleCount?: number;
/**
* 'up' puts items[0] at the bottom next to the input (atuin-style). Arrows
* always match screen direction walks visually up regardless.
*/
direction?: 'down' | 'up';
/** Caller owns filtering: re-filter on each call and pass new items. */
onQueryChange: (query: string) => void;
/** Enter key. Primary action. */
onSelect: (item: T) => void;
/**
* Tab key. If provided, Tab no longer aliases Enter it gets its own
* handler and hint. Shift+Tab falls through to this if onShiftTab is unset.
*/
onTab?: PickerAction<T>;
/** Shift+Tab key. Gets its own hint. */
onShiftTab?: PickerAction<T>;
/**
* Fires when the focused item changes (via arrows or when items reset).
* Useful for async preview loading keeps I/O out of renderPreview.
*/
onFocus?: (item: T | undefined) => void;
onCancel: () => void;
/** Shown when items is empty. Caller bakes loading/searching state into this. */
emptyMessage?: string | ((query: string) => string);
/**
* Status line below the list, e.g. "500+ matches" or "42 matches…".
* Caller decides when to show it pass undefined to hide.
*/
matchLabel?: string;
selectAction?: string;
extraHints?: React.ReactNode;
};
const DEFAULT_VISIBLE = 8;
// Pane (paddingTop + Divider) + title + 3 gaps + SearchBox (rounded border = 3
// rows) + hints. matchLabel adds +1 when present, accounted for separately.
const CHROME_ROWS = 10;
const MIN_VISIBLE = 2;
export function FuzzyPicker<T>({
title,
placeholder = 'Type to search…',
initialQuery,
items,
getKey,
renderItem,
renderPreview,
previewPosition = 'bottom',
visibleCount: requestedVisible = DEFAULT_VISIBLE,
direction = 'down',
onQueryChange,
onSelect,
onTab,
onShiftTab,
onFocus,
onCancel,
emptyMessage = 'No results',
matchLabel,
selectAction = 'select',
extraHints
}: Props<T>): React.ReactNode {
const isTerminalFocused = useTerminalFocus();
const {
rows,
columns
} = useTerminalSize();
const [focusedIndex, setFocusedIndex] = useState(0);
// Cap visibleCount so the picker never exceeds the terminal height. When it
// overflows, each re-render (arrow key, ctrl+p) mis-positions the cursor-up
// by the overflow amount and a previously-drawn line flashes blank.
const visibleCount = Math.max(MIN_VISIBLE, Math.min(requestedVisible, rows - CHROME_ROWS - (matchLabel ? 1 : 0)));
// Full hint row with onTab+onShiftTab is ~100 chars and wraps inconsistently
// below that. Compact mode drops shift+tab and shortens labels.
const compact = columns < 120;
const step = (delta: 1 | -1) => {
setFocusedIndex(i => clamp(i + delta, 0, items.length - 1));
};
// onKeyDown fires after useSearchInput's useInput, so onExit must be a
// no-op — return/downArrow are handled by handleKeyDown below. onCancel
// still covers escape/ctrl+c/ctrl+d. Backspace-on-empty is disabled so
// a held backspace doesn't eject the user from the dialog.
const {
query,
cursorOffset
} = useSearchInput({
isActive: true,
onExit: () => {},
onCancel,
initialQuery,
backspaceExitsOnEmpty: false
});
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'up' || e.ctrl && e.key === 'p') {
e.preventDefault();
e.stopImmediatePropagation();
step(direction === 'up' ? 1 : -1);
return;
}
if (e.key === 'down' || e.ctrl && e.key === 'n') {
e.preventDefault();
e.stopImmediatePropagation();
step(direction === 'up' ? -1 : 1);
return;
}
if (e.key === 'return') {
e.preventDefault();
e.stopImmediatePropagation();
const selected = items[focusedIndex];
if (selected) onSelect(selected);
return;
}
if (e.key === 'tab') {
e.preventDefault();
e.stopImmediatePropagation();
const selected = items[focusedIndex];
if (!selected) return;
const tabAction = e.shift ? onShiftTab ?? onTab : onTab;
if (tabAction) {
tabAction.handler(selected);
} else {
onSelect(selected);
}
}
};
useEffect(() => {
onQueryChange(query);
setFocusedIndex(0);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]);
useEffect(() => {
setFocusedIndex(i => clamp(i, 0, items.length - 1));
}, [items.length]);
const focused = items[focusedIndex];
useEffect(() => {
onFocus?.(focused);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [focused]);
const windowStart = clamp(focusedIndex - visibleCount + 1, 0, items.length - visibleCount);
const visible = items.slice(windowStart, windowStart + visibleCount);
const emptyText = typeof emptyMessage === 'function' ? emptyMessage(query) : emptyMessage;
const searchBox = <SearchBox query={query} cursorOffset={cursorOffset} placeholder={placeholder} isFocused isTerminalFocused={isTerminalFocused} />;
const listBlock = <List visible={visible} windowStart={windowStart} visibleCount={visibleCount} total={items.length} focusedIndex={focusedIndex} direction={direction} getKey={getKey} renderItem={renderItem} emptyText={emptyText} />;
const preview = renderPreview && focused ? <Box flexDirection="column" flexGrow={1}>
{renderPreview(focused)}
</Box> : null;
// Structure must not depend on preview truthiness — when focused goes
// undefined (e.g. delete clears matches), switching row→fragment would
// change both layout AND gap count, bouncing the searchBox below.
const listGroup = renderPreview && previewPosition === 'right' ? <Box flexDirection="row" gap={2} height={visibleCount + (matchLabel ? 1 : 0)}>
<Box flexDirection="column" flexShrink={0}>
{listBlock}
{matchLabel && <Text dimColor>{matchLabel}</Text>}
</Box>
{preview ?? <Box flexGrow={1} />}
</Box> :
// Box (not fragment) so the outer gap={1} doesn't insert a blank line
// between list/matchLabel/preview — that read as extra space above the
// prompt in direction='up'.
<Box flexDirection="column">
{listBlock}
{matchLabel && <Text dimColor>{matchLabel}</Text>}
{preview}
</Box>;
const inputAbove = direction !== 'up';
return <Pane color="permission">
<Box flexDirection="column" gap={1} tabIndex={0} autoFocus onKeyDown={handleKeyDown}>
<Text bold color="permission">
{title}
</Text>
{inputAbove && searchBox}
{listGroup}
{!inputAbove && searchBox}
<Text dimColor>
<Byline>
<KeyboardShortcutHint shortcut="↑/↓" action={compact ? 'nav' : 'navigate'} />
<KeyboardShortcutHint shortcut="Enter" action={compact ? firstWord(selectAction) : selectAction} />
{onTab && <KeyboardShortcutHint shortcut="Tab" action={onTab.action} />}
{onShiftTab && !compact && <KeyboardShortcutHint shortcut="shift+tab" action={onShiftTab.action} />}
<KeyboardShortcutHint shortcut="Esc" action="cancel" />
{extraHints}
</Byline>
</Text>
</Box>
</Pane>;
}
type ListProps<T> = Pick<Props<T>, 'visibleCount' | 'direction' | 'getKey' | 'renderItem'> & {
visible: readonly T[];
windowStart: number;
total: number;
focusedIndex: number;
emptyText: string;
};
function List(t0) {
const $ = _c(27);
const {
visible,
windowStart,
visibleCount,
total,
focusedIndex,
direction,
getKey,
renderItem,
emptyText
} = t0;
if (visible.length === 0) {
let t1;
if ($[0] !== emptyText) {
t1 = <Text dimColor={true}>{emptyText}</Text>;
$[0] = emptyText;
$[1] = t1;
} else {
t1 = $[1];
}
let t2;
if ($[2] !== t1 || $[3] !== visibleCount) {
t2 = <Box height={visibleCount} flexShrink={0}>{t1}</Box>;
$[2] = t1;
$[3] = visibleCount;
$[4] = t2;
} else {
t2 = $[4];
}
return t2;
}
let t1;
if ($[5] !== direction || $[6] !== focusedIndex || $[7] !== getKey || $[8] !== renderItem || $[9] !== total || $[10] !== visible || $[11] !== visibleCount || $[12] !== windowStart) {
let t2;
if ($[14] !== direction || $[15] !== focusedIndex || $[16] !== getKey || $[17] !== renderItem || $[18] !== total || $[19] !== visible.length || $[20] !== visibleCount || $[21] !== windowStart) {
t2 = (item, i) => {
const actualIndex = windowStart + i;
const isFocused = actualIndex === focusedIndex;
const atLowEdge = i === 0 && windowStart > 0;
const atHighEdge = i === visible.length - 1 && windowStart + visibleCount < total;
return <ListItem key={getKey(item)} isFocused={isFocused} showScrollUp={direction === "up" ? atHighEdge : atLowEdge} showScrollDown={direction === "up" ? atLowEdge : atHighEdge} styled={false}>{renderItem(item, isFocused)}</ListItem>;
};
$[14] = direction;
$[15] = focusedIndex;
$[16] = getKey;
$[17] = renderItem;
$[18] = total;
$[19] = visible.length;
$[20] = visibleCount;
$[21] = windowStart;
$[22] = t2;
} else {
t2 = $[22];
}
t1 = visible.map(t2);
$[5] = direction;
$[6] = focusedIndex;
$[7] = getKey;
$[8] = renderItem;
$[9] = total;
$[10] = visible;
$[11] = visibleCount;
$[12] = windowStart;
$[13] = t1;
} else {
t1 = $[13];
}
const rows = t1;
const t2 = direction === "up" ? "column-reverse" : "column";
let t3;
if ($[23] !== rows || $[24] !== t2 || $[25] !== visibleCount) {
t3 = <Box height={visibleCount} flexShrink={0} flexDirection={t2}>{rows}</Box>;
$[23] = rows;
$[24] = t2;
$[25] = visibleCount;
$[26] = t3;
} else {
t3 = $[26];
}
return t3;
}
function firstWord(s: string): string {
const i = s.indexOf(' ');
return i === -1 ? s : s.slice(0, i);
}
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJSZWFjdCIsInVzZUVmZmVjdCIsInVzZVN0YXRlIiwidXNlU2VhcmNoSW5wdXQiLCJ1c2VUZXJtaW5hbFNpemUiLCJLZXlib2FyZEV2ZW50IiwiY2xhbXAiLCJCb3giLCJUZXh0IiwidXNlVGVybWluYWxGb2N1cyIsIlNlYXJjaEJveCIsIkJ5bGluZSIsIktleWJvYXJkU2hvcnRjdXRIaW50IiwiTGlzdEl0ZW0iLCJQYW5lIiwiUGlja2VyQWN0aW9uIiwiYWN0aW9uIiwiaGFuZGxlciIsIml0ZW0iLCJUIiwiUHJvcHMiLCJ0aXRsZSIsInBsYWNlaG9sZGVyIiwiaW5pdGlhbFF1ZXJ5IiwiaXRlbXMiLCJnZXRLZXkiLCJyZW5kZXJJdGVtIiwiaXNGb2N1c2VkIiwiUmVhY3ROb2RlIiwicmVuZGVyUHJldmlldyIsInByZXZpZXdQb3NpdGlvbiIsInZpc2libGVDb3VudCIsImRpcmVjdGlvbiIsIm9uUXVlcnlDaGFuZ2UiLCJxdWVyeSIsIm9uU2VsZWN0Iiwib25UYWIiLCJvblNoaWZ0VGFiIiwib25Gb2N1cyIsIm9uQ2FuY2VsIiwiZW1wdHlNZXNzYWdlIiwibWF0Y2hMYWJlbCIsInNlbGVjdEFjdGlvbiIsImV4dHJhSGludHMiLCJERUZBVUxUX1ZJU0lCTEUiLCJDSFJPTUVfUk9XUyIsIk1JTl9WSVNJQkxFIiwiRnV6enlQaWNrZXIiLCJyZXF1ZXN0ZWRWaXNpYmxlIiwiaXNUZXJtaW5hbEZvY3VzZWQiLCJyb3dzIiwiY29sdW1ucyIsImZvY3VzZWRJbmRleCIsInNldEZvY3VzZWRJbmRleCIsIk1hdGgiLCJtYXgiLCJtaW4iLCJjb21wYWN0Iiwic3RlcCIsImRlbHRhIiwiaSIsImxlbmd0aCIsImN1cnNvck9mZnNldCIsImlzQWN0aXZlIiwib25FeGl0IiwiYmFja3NwYWNlRXhpdHNPbkVtcHR5IiwiaGFuZGxlS2V5RG93biIsImUiLCJrZXkiLCJjdHJsIiwicHJldmVudERlZmF1bHQiLCJzdG9wSW1tZWRpYXRlUHJvcGFnYXRpb24iLCJzZWxlY3RlZCIsInRhYkFjdGlvbiIsInNoaWZ0IiwiZm9jdXNlZCIsIndpbmRvd1N0YXJ0IiwidmlzaWJsZSIsInNsaWNlIiwiZW1wdHlUZXh0Iiwic2VhcmNoQm94IiwibGlzdEJsb2NrIiwicHJldmlldyIsImxpc3RHcm91cCIsImlucHV0QWJvdmUiLCJmaXJzdFdvcmQiLCJMaXN0UHJvcHMiLCJQaWNrIiwidG90YWwiLCJMaXN0IiwidDAiLCIkIiwiX2MiLCJ0MSIsInQyIiwiYWN0dWFsSW5kZXgiLCJhdExvd0VkZ2UiLCJhdEhpZ2hFZGdlIiwibWFwIiwidDMiLCJzIiwiaW5kZXhPZiJdLCJzb3VyY2VzIjpbIkZ1enp5UGlja2VyLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCdcbmltcG9ydCB7IHVzZUVmZmVjdCwgdXNlU3RhdGUgfSBmcm9tICdyZWFjdCdcbmltcG9ydCB7IHVzZVNlYXJjaElucHV0IH0gZnJvbSAnLi4vLi4vaG9va3MvdXNlU2VhcmNoSW5wdXQuanMnXG5pbXBvcnQgeyB1c2VUZXJtaW5hbFNpemUgfSBmcm9tICcuLi8uLi9ob29rcy91c2VUZXJtaW5hbFNpemUuanMnXG5pbXBvcnQgdHlwZSB7IEtleWJvYXJkRXZlbnQgfSBmcm9tICcuLi8uLi9pbmsvZXZlbnRzL2tleWJvYXJkLWV2ZW50LmpzJ1xuaW1wb3J0IHsgY2xhbXAgfSBmcm9tICcuLi8uLi9pbmsvbGF5b3V0L2dlb21ldHJ5LmpzJ1xuaW1wb3J0IHsgQm94LCBUZXh0LCB1c2VUZXJtaW5hbEZvY3VzIH0gZnJvbSAnLi4vLi4vaW5rLmpzJ1xuaW1wb3J0IHsgU2VhcmNoQm94IH0gZnJvbSAnLi4vU2VhcmNoQm94LmpzJ1xuaW1wb3J0IHsgQnlsaW5lIH0gZnJvbSAnLi9CeWxpbmUuanMnXG5pbXBvcnQgeyBLZXlib2FyZFNob3J0Y3V0SGludCB9IGZyb20gJy4vS2V5Ym9hcmRTaG9ydGN1dEhpbnQuanMnXG5pbXBvcnQgeyBMaXN0SXRlbSB9IGZyb20gJy4vTGlzdEl0ZW0uanMnXG5pbXBvcnQgeyBQYW5lIH0gZnJvbSAnLi9QYW5lLmpzJ1xuXG50eXBlIFBpY2tlckFjdGlvbjxUPiA9IHtcbiAgLyoqIEhpbnQgbGFiZWwgc2hvd24gaW4gdGhlIGJ5bGluZSwgZS5nLiBcIm1lbnRpb25cIiDihpIgXCJUYWIgdG8gbWVudGlvblwiLiAqL1xuICBhY3Rpb246IHN0cmluZ1xuICBoYW5kbGVyOiAoaXRlbTogVCkgPT4gdm9pZFxufVxuXG50eXBlIFByb3BzPFQ+ID0ge1xuICB0aXRsZTogc3RyaW5nXG4gIHBsYWNlaG9sZGVyPzogc3RyaW5nXG4gIGluaXRpYWxRdWVyeT86IHN0cmluZ1xuICBpdGVtczogcmVhZG9ubHkgVFtdXG4gIGdldEtleTogKGl0ZW06IFQpID0+IHN0cmluZ1xuICAvKiogS2VlcCB0byBvbmUgbGluZSDigJQgcHJldmlldyBoYW5kbGVzIG92ZXJmbG93LiAqL1xuICByZW5kZXJJdGVtOiAoaXRlbTogVCwgaXNGb2N1c2VkOiBib29sZWFuKSA9PiBSZWFjdC5SZWFjdE5vZGVcbiAgcmVuZGVyUHJldmlldz86IChpdGVtOiBUKSA9PiBSZWFjdC5SZWFjdE5vZGVcbiAgLyoqICdyaWdodCcga2VlcHMgaGludHMgc3RhYmxlIChubyBib3VuY2UpLCBidXQgbmVlZHMgd2lkdGguICovXG4gIHByZXZpZXdQb3NpdGlvbj86ICdib3R0b20nIHwgJ3JpZ2h0J1xuICB2aXNpYmxlQ291bnQ/OiBudW1iZXJcbiAgLyoqXG4gICAqICd1cCcgcHV0cyBpdGVtc1swXSBhdCB0aGUgYm90dG9tIG5leHQgdG8gdGhlIGlucHV0IChhdHVpbi1zdHlsZSkuIEFycm93c1xuICAgKiBhbHdheXMgbWF0Y2ggc2NyZWVuIGRpcmVjdGlvbiDigJQg4oaRIHdhbGtzIHZpc3VhbGx5IHVwIHJlZ2FyZGxlc3MuXG4gICAqL1xuICBkaXJlY3Rpb24/OiAnZG93bicgfCAndXAnXG4gIC8qKiBDYWxsZXIgb3ducyBmaWx0ZXJpbmc6IHJlLWZpbHRlciBvbiBlYWNoIGNhbGwgYW5kIHBhc3MgbmV3IGl0ZW1zLiAqL1xuICBvblF1ZXJ5Q2hhbmdlOiAocXVlcnk6IHN0cmluZykgPT4gdm9pZFxuICAvKiogRW50ZXIga2V5LiBQcmltYXJ5IGFjdGlvbi4gKi9cbiAgb25TZWxlY3Q6IChpdGVtOiBUKSA9PiB2b2lkXG4gIC8qKlxuICAgKiBUYWIga2V5LiBJZiBwcm92aWRlZCwgVGFiIG5vIGxvbmdlciBhbGlhc2VzIEVudGVyIOKAlCBpdCBnZXRzIGl0cyBvd25cbiAgICogaGFuZGxlciBhbmQgaGludC4gU2hpZnQrVGFiIGZhbGxzIHRocm91Z2ggdG8gdGhpcyBpZiBvblNoaWZ0VGFiIGlzIHVuc2V0LlxuICAgKi9cbiAgb25UYWI/OiBQaWNrZXJBY3Rpb248VD5cbiAgLyoqIFN