- fs-tree joins listed paths with the platform separator (Windows rows previously leaked mixed separators) - media route containment check goes through isWithin(): separator- tolerant and case-insensitive on win32, so case-mismatched Windows paths are not misclassified - relativeTo compares case-insensitively (Windows / case-insensitive macOS volumes) while keeping the caller's own casing in the output - tests: platform-conditional path assertions, os.tmpdir() instead of /tmp, isWithin/relativeTo/resolveSidebarPath win32 coverage
116 lines
4.1 KiB
TypeScript
116 lines
4.1 KiB
TypeScript
/**
|
|
* 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
|
|
* reported as files without probing their target — the explorer shows what
|
|
* dirent says, keeping the read cheap for arbitrarily large levels.
|
|
*/
|
|
import { opendir } from 'node:fs/promises'
|
|
import { basename, dirname, join, resolve } from 'node:path'
|
|
import { SidebarError } from './wire.ts'
|
|
|
|
/** One explorer row. */
|
|
export interface SidebarFsEntry {
|
|
name: string
|
|
path: string
|
|
isDir: boolean
|
|
hidden: 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<SidebarFsListing> {
|
|
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
|
|
}
|
|
rows.push({
|
|
name: dirent.name,
|
|
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
|
|
// would leak mixed separators into every row's path.
|
|
path: join(path, dirent.name),
|
|
isDir: dirent.isDirectory(),
|
|
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. */
|
|
export function requireAbsolute(path: string): string {
|
|
if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(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)
|
|
}
|