Add import template into tui. Fix tests that fail on macos. Fix some updates.

This commit is contained in:
2026-05-29 18:16:00 +02:00
parent 85746c3306
commit 2f8dad7d8d
20 changed files with 1748 additions and 46 deletions

View File

@@ -0,0 +1,273 @@
/**
* Terminal file picker for browsing directories and selecting files.
*
* This component does not include a dialog wrapper — consumers wrap it in
* {@link DialogWrapper} when needed. When used inside a dialog overlay, pass
* `layerId` so keyboard input is routed through the input-layer stack instead
* of conflicting with background {@link ScrollableList} handlers.
*/
import React, { useCallback, useEffect, useMemo, useState } from "react";
import { Box, Text } from "ink";
import { ScrollableList, type ListItemData } from "./List.js";
import { useLayeredInput } from "../hooks/useInputLayer.js";
import { colors } from "../theme.js";
import {
listDirectoryEntries,
type DirectoryEntry,
} from "../utils/list-directory-entries.js";
/**
* Props for {@link FilePicker}.
*/
export interface FilePickerProps {
/** Starting directory. Defaults to `process.cwd()`. */
initialDirectory?: string;
/**
* Allowed file extensions without a leading dot (e.g. `['json']`).
* Omit to show all files. Directories are always shown.
*/
extensions?: string[];
/**
* Input-layer id for dialog use. When set, this component handles ↑↓/Enter
* via {@link useLayeredInput} and disables {@link ScrollableList} focus.
*/
layerId?: string;
/** Whether the list receives keyboard focus when `layerId` is not set. */
focus?: boolean;
/** Maximum visible rows in the scroll window. */
maxVisible?: number;
/** Called when the user confirms a file with Enter. */
onSelectFile: (absolutePath: string) => void;
/** Optional callback whenever the browsed directory changes. */
onDirectoryChange?: (absolutePath: string) => void;
}
/**
* Truncates a long path for display, keeping the end visible.
*/
function formatDirectoryPath(directoryPath: string, maxLength = 56): string {
if (directoryPath.length <= maxLength) {
return directoryPath;
}
return `...${directoryPath.slice(-(maxLength - 3))}`;
}
/**
* Builds list row metadata for a directory entry.
*/
function toListItem(entry: DirectoryEntry): ListItemData<DirectoryEntry> {
if (entry.kind === "parent") {
return {
key: "__parent__",
label: "..",
description: "Parent directory",
value: entry,
};
}
if (entry.kind === "directory") {
return {
key: `dir:${entry.absolutePath}`,
label: entry.name,
description: "Directory",
value: entry,
};
}
return {
key: `file:${entry.absolutePath}`,
label: entry.name,
value: entry,
};
}
/**
* Generic terminal file picker with optional extension filtering.
*/
export function FilePicker({
initialDirectory = process.cwd(),
extensions,
layerId,
focus = true,
maxVisible = 10,
onSelectFile,
onDirectoryChange,
}: FilePickerProps): React.ReactElement {
const [currentDirectory, setCurrentDirectory] = useState(() =>
initialDirectory,
);
const [selectedIndex, setSelectedIndex] = useState(0);
const [loadError, setLoadError] = useState<string | undefined>();
const { entries, error } = useMemo(
() => listDirectoryEntries(currentDirectory, { extensions }),
[currentDirectory, extensions],
);
useEffect(() => {
setLoadError(error);
}, [error]);
useEffect(() => {
setSelectedIndex(0);
}, [currentDirectory, extensions]);
useEffect(() => {
if (entries.length === 0) {
setSelectedIndex(0);
return;
}
if (selectedIndex >= entries.length) {
setSelectedIndex(entries.length - 1);
}
}, [entries, selectedIndex]);
const listItems = useMemo(
(): ListItemData<DirectoryEntry>[] => entries.map(toListItem),
[entries],
);
/**
* Moves selection to the previous visible row, wrapping at the top.
*/
const selectPrevious = useCallback((): void => {
setSelectedIndex((previous) =>
previous <= 0 ? Math.max(entries.length - 1, 0) : previous - 1,
);
}, [entries.length]);
/**
* Moves selection to the next visible row, wrapping at the bottom.
*/
const selectNext = useCallback((): void => {
setSelectedIndex((previous) =>
entries.length === 0
? 0
: previous >= entries.length - 1
? 0
: previous + 1,
);
}, [entries.length]);
/**
* Applies the current row: navigate for parent/directory, select for files.
*/
const activateSelectedEntry = useCallback((): void => {
const entry = entries[selectedIndex];
if (!entry) {
return;
}
if (entry.kind === "parent" || entry.kind === "directory") {
setCurrentDirectory(entry.absolutePath);
onDirectoryChange?.(entry.absolutePath);
return;
}
onSelectFile(entry.absolutePath);
}, [entries, onDirectoryChange, onSelectFile, selectedIndex]);
/**
* Dialog overlays must pass `layerId` because ScrollableList uses raw ink
* `useInput`, which does not respect the input capture stack.
*/
useLayeredInput(
layerId ?? "file-picker-standalone",
(_input, key) => {
if (!layerId) {
return;
}
if (key.upArrow) {
selectPrevious();
return;
}
if (key.downArrow) {
selectNext();
return;
}
if (key.return) {
activateSelectedEntry();
}
},
{ isActive: Boolean(layerId) },
);
const renderItem = useCallback(
(
item: ListItemData<DirectoryEntry>,
isSelected: boolean,
isFocused: boolean,
): React.ReactNode => {
const entry = item.value;
/**
* Inside dialogs, ScrollableList focus is disabled (input comes from layerId).
* Treat the selected row as highlighted so it matches other focused lists.
*/
const isHighlighted = layerId ? isSelected : isFocused;
const textColor = isHighlighted ? colors.focus : colors.text;
const indicator = isHighlighted ? "▸ " : " ";
if (entry?.kind === "parent") {
return (
<Text color={textColor} bold={isSelected}>
{indicator}
..
</Text>
);
}
if (entry?.kind === "directory") {
return (
<Text color={textColor} bold={isSelected}>
{indicator}
📁 {item.label}/
</Text>
);
}
return (
<Text color={textColor} bold={isSelected}>
{indicator}
{item.label}
</Text>
);
},
[layerId],
);
const listFocus = layerId ? false : focus;
return (
<Box flexDirection="column">
<Text color={colors.textMuted}>
Directory: {formatDirectoryPath(currentDirectory)}
</Text>
{loadError ? (
<Box marginTop={1}>
<Text color={colors.error}>{loadError}</Text>
</Box>
) : null}
<Box marginTop={1} flexDirection="column">
<ScrollableList
items={listItems}
selectedIndex={selectedIndex}
onSelect={setSelectedIndex}
onActivate={() => activateSelectedEntry()}
focus={listFocus}
maxVisible={maxVisible}
emptyMessage="No matching files or folders"
renderItem={renderItem}
/>
</Box>
</Box>
);
}