fix: symlink 探测有界并发 + UNC 标记平台中性化(回应 CR/自审)

- fs-tree: symlink 目标 stat 从串行改为 32 并发工作池(readdir 流关闭后统一
  探测),symlink 密集/网络目标目录不再累加延迟;顺序与分类语义不变
- html-route: 解码不再重建反斜杠 UNC,统一重建为平台中性的 //server/share/...
  前斜杠形态(win32/POSIX 均正确 resolve),从而删除 windowsPathStyle 平台信号
  与 client isWindowsStylePath 助手;同时解决无 cwd 时正斜杠 UNC 的往返盲点
- 测试/文档同步;移除未使用的 isPosix
This commit is contained in:
Menghuan1918
2026-08-16 23:35:57 +08:00
parent 9652bbda59
commit be95787c00
10 changed files with 104 additions and 114 deletions
+33 -12
View File
@@ -60,30 +60,51 @@ export async function listDirectory(path: string, maxEntries = 1000): Promise<Si
}
// 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,
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)