- fs-tree: symlink 目标 stat 从串行改为 32 并发工作池(readdir 流关闭后统一 探测),symlink 密集/网络目标目录不再累加延迟;顺序与分类语义不变 - html-route: 解码不再重建反斜杠 UNC,统一重建为平台中性的 //server/share/... 前斜杠形态(win32/POSIX 均正确 resolve),从而删除 windowsPathStyle 平台信号 与 client isWindowsStylePath 助手;同时解决无 cwd 时正斜杠 UNC 的往返盲点 - 测试/文档同步;移除未使用的 isPosix
159 lines
5.9 KiB
TypeScript
159 lines
5.9 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
|
|
* 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<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
|
|
}
|
|
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
|
|
// would leak mixed separators into every row's path.
|
|
rows.push({
|
|
name: dirent.name,
|
|
path: join(path, dirent.name),
|
|
isDir: dirent.isDirectory(),
|
|
isSymlink: dirent.isSymbolicLink(),
|
|
broken: false,
|
|
hidden: dirent.name.startsWith('.'),
|
|
})
|
|
}
|
|
} catch (error) {
|
|
throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400)
|
|
}
|
|
// Probe symlink targets AFTER the readdir stream closes, with bounded
|
|
// concurrency: a symlink-heavy level (UNC/network targets) would otherwise
|
|
// serialize up to maxEntries stat calls and stall the explorer. Non-symlink
|
|
// rows are skipped by the probe, so levels without links stay as cheap as
|
|
// before.
|
|
await probeSymlinkTargets(rows)
|
|
rows.sort(compareEntries)
|
|
return { path, entries: rows, truncated: overflow > 0 }
|
|
}
|
|
|
|
/** How many symlink target stats run in flight during one level listing. */
|
|
const SYMLINK_PROBE_CONCURRENCY = 32
|
|
|
|
/** Probe each symlink row's target once (bounded concurrency, order-preserving). */
|
|
async function probeSymlinkTargets(rows: SidebarFsEntry[], concurrency = SYMLINK_PROBE_CONCURRENCY): Promise<void> {
|
|
let next = 0
|
|
const workers = Array.from({ length: Math.min(concurrency, rows.length) }, async () => {
|
|
for (;;) {
|
|
const index = next
|
|
next += 1
|
|
if (index >= rows.length) return
|
|
const row = rows[index]!
|
|
if (!row.isSymlink) continue
|
|
// stat follows the chain; any failure (missing target, ELOOP, permission)
|
|
// leaves the row as a broken file-shaped link the editor refuses to read.
|
|
const info = await stat(row.path).catch(() => undefined)
|
|
row.isDir = info !== undefined ? info.isDirectory() : row.isDir
|
|
row.broken = info === undefined
|
|
}
|
|
})
|
|
await Promise.all(workers)
|
|
}
|
|
|
|
/** 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)
|
|
}
|