Add import template into tui. Fix tests that fail on macos. Fix some updates.
This commit is contained in:
170
src/tui/utils/list-directory-entries.ts
Normal file
170
src/tui/utils/list-directory-entries.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* 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}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user