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
This commit is contained in:
Menghuan1918
2026-08-16 23:24:43 +08:00
parent bb3b40ff15
commit db189e3852
16 changed files with 424 additions and 62 deletions
+32 -10
View File
@@ -2,11 +2,13 @@
* 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.
* 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 } from 'node:fs/promises'
import { basename, dirname, join, resolve } from 'node:path'
import { opendir, stat } from 'node:fs/promises'
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
import { SidebarError } from './wire.ts'
/** One explorer row. */
@@ -15,6 +17,10 @@ export interface SidebarFsEntry {
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. */
@@ -52,12 +58,22 @@ export async function listDirectory(path: string, maxEntries = 1000): Promise<Si
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,
// 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(),
path: fullPath,
isDir: info !== undefined ? info.isDirectory() : dirent.isDirectory(),
isSymlink,
broken: isSymlink && info === undefined,
hidden: dirent.name.startsWith('.'),
})
}
@@ -80,9 +96,15 @@ export function parentOf(path: string): string | undefined {
return parent === path ? undefined : parent
}
/** Normalize a caller-supplied path to an absolute, resolved path or throw fs-error. */
/**
* 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 (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) {
if (!isAbsolute(path)) {
throw new SidebarError('fs-error', `"${path}" is not an absolute path`, 400)
}
return resolve(path)