274 lines
7.0 KiB
TypeScript
274 lines
7.0 KiB
TypeScript
/**
|
|
* 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>
|
|
);
|
|
}
|