171 lines
4.4 KiB
TypeScript
171 lines
4.4 KiB
TypeScript
/**
|
|
* 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}`,
|
|
};
|
|
}
|
|
}
|