/** * Single-level directory listing for the sidebar explorer. Streams the level * with opendir, sorts directories first then names (case-insensitive), and * marks POSIX-hidden entries (dot-prefixed) for dimmed display. Symlinks are * stat'ed once to expose their target kind — a symlink to a directory * expands like a directory — and dangling links are flagged broken. The * probe runs only for entries that are actually symlinks, so levels without * links stay as cheap as before. */ import { opendir, stat } from 'node:fs/promises' import { basename, dirname, isAbsolute, join, resolve } from 'node:path' import { SidebarError } from './wire.ts' /** One explorer row. */ export interface SidebarFsEntry { name: string path: string isDir: boolean hidden: boolean /** Whether the row is a symlink; `isDir` then describes the link's target. */ isSymlink: boolean /** For symlinks: the target is missing or unreadable (stat failed). */ broken: boolean } /** One listed level. */ export interface SidebarFsListing { path: string entries: SidebarFsEntry[] truncated: boolean } /** Directory-first, case-insensitive name ordering (VSCode explorer order). */ export function compareEntries(a: SidebarFsEntry, b: SidebarFsEntry): number { if (a.isDir !== b.isDir) return a.isDir ? -1 : 1 return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }) } /** * List one directory level. * @param path - absolute directory path. * @param maxEntries - row bound of one level (extra rows flag `truncated`). * @returns the sorted listing. * @throws {SidebarError} fs-error when the level is unreadable or not a directory. */ export async function listDirectory(path: string, maxEntries = 1000): Promise { let level try { level = await opendir(path) } catch (error) { throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400) } const rows: SidebarFsEntry[] = [] let overflow = 0 try { for await (const dirent of level) { if (rows.length >= maxEntries) { overflow += 1 continue } // Platform join: on Windows the level path uses '\' — a hardcoded '/' // would leak mixed separators into every row's path. const fullPath = join(path, dirent.name) const isSymlink = dirent.isSymbolicLink() // Probe the link's target once so a symlinked directory renders as an // expandable directory row. stat follows the chain; any failure // (missing target, ELOOP, permission) leaves the row as a broken // file-shaped link the editor will refuse to read. Non-symlink entries // skip the syscall entirely — dirent already classified them. const info = isSymlink ? await stat(fullPath).catch(() => undefined) : undefined rows.push({ name: dirent.name, path: fullPath, isDir: info !== undefined ? info.isDirectory() : dirent.isDirectory(), isSymlink, broken: isSymlink && info === undefined, hidden: dirent.name.startsWith('.'), }) } } catch (error) { throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400) } rows.sort(compareEntries) return { path, entries: rows, truncated: overflow > 0 } } /** The root row label of a listing: the last path segment (or the full path at the filesystem root). */ export function rootLabel(path: string): string { const base = basename(path) return base !== '' ? base : path } /** Parent of a path, or undefined at the filesystem root (the explorer's "up" target). */ export function parentOf(path: string): string | undefined { const parent = dirname(path) return parent === path ? undefined : parent } /** * Normalize a caller-supplied path to an absolute, resolved path or throw * fs-error. `path.isAbsolute()` is the OS's own notion of absolute: POSIX * roots (`/...`), Windows drive letters (`C:\...`) and — on win32 — UNC * network shares (`\\server\share\...`); drive-relative forms (`C:foo`) * stay rejected. */ export function requireAbsolute(path: string): string { if (!isAbsolute(path)) { throw new SidebarError('fs-error', `"${path}" is not an absolute path`, 400) } return resolve(path) } /** * Whether `target` lies under `base` (or equals it), tolerant of separator * style and — on Windows, where the filesystem is case-insensitive — of * letter case. The media route uses this instead of a raw `startsWith` so a * case-mismatched or mixed-separator path can never be misclassified * (e.g. `C:\Users\Me` vs `c:/users/me/file.png`). * @param platform - filesystem semantics; injectable so both branches are * unit-testable on any host. */ export function isWithin(base: string, target: string, platform: NodeJS.Platform = process.platform): boolean { const norm = (value: string): string => value.replace(/[\\/]+/g, '/').replace(/\/$/, '') const b = norm(base) const t = norm(target) if (platform === 'win32') { const lb = b.toLowerCase() const lt = t.toLowerCase() return lt === lb || lt.startsWith(`${lb}/`) } return t === b || t.startsWith(`${b}/`) } /** Message text of an unknown thrown value. */ export function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) }