/** * Directory listing helpers for terminal file pickers. * * Uses synchronous filesystem APIs to match other TUI screens (e.g. SeedInput). */ import fs from "node:fs"; import path from "node:path"; /** * Kind of entry shown in a file picker list. */ export type DirectoryEntryKind = "parent" | "directory" | "file"; /** * A single row in a directory listing. */ export interface DirectoryEntry { /** Display name (e.g. ".." or "foo.json"). */ name: string; /** Absolute path on disk. */ absolutePath: string; /** Whether this row navigates up, into a folder, or selects a file. */ kind: DirectoryEntryKind; } /** * Options for {@link listDirectoryEntries}. */ export interface ListDirectoryEntriesOptions { /** * Allowed file extensions without a leading dot (e.g. `['json']`). * When omitted or empty, all non-hidden files are included. * Directories are always included regardless of this filter. */ extensions?: string[]; } /** * Result of listing a directory for the file picker. */ export interface ListDirectoryEntriesResult { entries: DirectoryEntry[]; /** Set when the directory could not be read. */ error?: string; } /** * Returns true when the file extension matches one of the allowed extensions. * Comparison is case-insensitive; extensions may be passed with or without a dot. */ function matchesExtension( filename: string, extensions: string[] | undefined, ): boolean { if (extensions === undefined || extensions.length === 0) { return true; } const fileExtension = path.extname(filename).slice(1).toLowerCase(); if (fileExtension.length === 0) { return false; } return extensions.some((extension) => { const normalized = extension.startsWith(".") ? extension.slice(1) : extension; return normalized.toLowerCase() === fileExtension; }); } /** * Lists files and folders in `directory` for display in a terminal file picker. * * - Prepends `..` when not at the filesystem root. * - Always shows subdirectories (except `.` and `..` from readdir). * - Filters files by optional `extensions`. * - Sort order: parent link first, then directories A→Z, then files A→Z. * - Returns an empty list and `error` instead of throwing on permission or missing paths. */ export function listDirectoryEntries( directory: string, options: ListDirectoryEntriesOptions = {}, ): ListDirectoryEntriesResult { const resolvedDirectory = path.resolve(directory); const { extensions } = options; try { if (!fs.existsSync(resolvedDirectory)) { return { entries: [], error: `Directory does not exist: ${resolvedDirectory}`, }; } const directoryStat = fs.statSync(resolvedDirectory); if (!directoryStat.isDirectory()) { return { entries: [], error: `Not a directory: ${resolvedDirectory}`, }; } const entries: DirectoryEntry[] = []; const parentDirectory = path.dirname(resolvedDirectory); if (parentDirectory !== resolvedDirectory) { entries.push({ name: "..", absolutePath: parentDirectory, kind: "parent", }); } const childNames = fs.readdirSync(resolvedDirectory); const directories: DirectoryEntry[] = []; const files: DirectoryEntry[] = []; for (const name of childNames) { if (name === "." || name === "..") { continue; } const absolutePath = path.join(resolvedDirectory, name); let childStat: fs.Stats; try { childStat = fs.statSync(absolutePath); } catch { // Skip broken symlinks or entries we cannot stat. continue; } if (childStat.isDirectory()) { directories.push({ name, absolutePath, kind: "directory", }); continue; } if (childStat.isFile() && matchesExtension(name, extensions)) { files.push({ name, absolutePath, kind: "file", }); } } const sortByName = (a: DirectoryEntry, b: DirectoryEntry): number => a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); directories.sort(sortByName); files.sort(sortByName); return { entries: [...entries, ...directories, ...files], }; } catch (error) { const message = error instanceof Error ? error.message : String(error); return { entries: [], error: `Unable to read directory: ${message}`, }; } }