/** * 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 { 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(); 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[] => 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, 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 ( {indicator} ⬆ .. ); } if (entry?.kind === "directory") { return ( {indicator} 📁 {item.label}/ ); } return ( {indicator} {item.label} ); }, [layerId], ); const listFocus = layerId ? false : focus; return ( Directory: {formatDirectoryPath(currentDirectory)} {loadError ? ( {loadError} ) : null} activateSelectedEntry()} focus={listFocus} maxVisible={maxVisible} emptyMessage="No matching files or folders" renderItem={renderItem} /> ); }