Files
apes-sidebar/src/fs-tree.ts
T
Menghuan1918 db189e3852 feat: 统一路径处理——UNC/软链接分类/html 路由平台守卫(#65 #67 #43 #79 #115)
- fs-tree: requireAbsolute 改用 path.isAbsolute(win32 UNC 放行、C:foo 仍拒);
  目录列表仅对 symlink 行 stat 一次,暴露 isSymlink/broken,目录软链接可展开
- html-route: UNC 路径经 '//' 标记编码往返;标记仅在 Windows 风格会话启用,
  POSIX '//' 绝对路径不再被误判(平台守卫)
- client: isAbsolutePath/isWindowsStylePath 镜像 + resolveSidebarPath 识别 UNC,
  htmlUrl 以会话 cwd 形态传递平台信号;FsEntry 增加 isSymlink/broken
- explorer: 软链接行链接徽标、悬空链接标红 + tooltip(issue #65)
- 测试: tests/platform.ts 守卫助手;win32 专属用例改为 skipIf 可见跳过,
  补 POSIX 反向分支与 UNC 矩阵;新增 fs-tree-symlink.spec
2026-08-16 23:24:43 +08:00

138 lines
5.2 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.
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)
}