Add import template into tui. Fix tests that fail on macos. Fix some updates.
This commit is contained in:
273
src/tui/components/FilePicker.tsx
Normal file
273
src/tui/components/FilePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user