diff --git a/docs/plans/2026-08-11-html-browser-builtins-design.md b/docs/plans/2026-08-11-html-browser-builtins-design.md
index 3626c33..17e4888 100644
--- a/docs/plans/2026-08-11-html-browser-builtins-design.md
+++ b/docs/plans/2026-08-11-html-browser-builtins-design.md
@@ -110,6 +110,7 @@ URL 采用**路径编码**(无 query)——WHATSG URL 解析对纯路径相
- 设计初期考虑过 srcdoc + `` 注入以实现"预览反映未保存草稿",因 srcdoc 无沙箱时继承父源的致命风险与 base 注入的脆弱性,改采 route-src(预览 = 已保存文件);相对资源经路径编码 URL 天然解析,无需内容改写。
- `normalizeBrowserUrl` 的 scheme 判别:朴素正则会把 `example.com:8080` 误判为 scheme,最终采用「`://` 完整 URL + 危险 scheme 黑名单 + 解析后协议兜底」三段式。
- `nextBrowser` 在 sanitize 中为宽松字段(缺失/畸形回退 1),避免旧持久化布局整体失效(与严格校验的 `nextTerminal` 不同)。
+- **UNC 路径编码标记(PR #134 整合 #79/#115 时)**:`/sidebar/html` 对 `\\server\share\...` / `//server/share/...` 采用 sessionId 后的 `//` 标记,且解码统一重建为**前斜杠** `//server/share/...` 而非反斜杠——`node:path` 在 win32/POSIX 上分别解析为 `\\server\share\...` / `/server/share/...`,因此无需任何平台信号;原「400 双斜杠」约束改为「首个空段仅作 UNC 标记,其余空段仍 400」。
## 8.1 追加需求(2026-08-11,v0.5.0 内)
diff --git a/src/client/api.ts b/src/client/api.ts
index 194473b..bfab3de 100644
--- a/src/client/api.ts
+++ b/src/client/api.ts
@@ -8,7 +8,6 @@
*/
import { encodeHtmlUrl } from '../html-route.ts'
import type { BrowserProbeResult } from './browser.ts'
-import { isWindowsStylePath } from './paths.ts'
/** One wire failure. */
export class SidebarApiError extends Error {
@@ -212,29 +211,14 @@ function fileUrl(scope: SessionScope, path: string, download: boolean): string {
return `/sidebar/file?${params.toString()}`
}
-/** A forward-slash UNC share (`//server/share/...`) — ambiguous with a POSIX
- * absolute path, so it only counts as Windows-style when the session cwd is
- * itself a forward-slash UNC (see htmlUrl). */
-function isForwardSlashUnc(path: string): boolean {
- return /^\/\/[^/]/.test(path)
-}
-
/**
* Absolute URL of the HTML preview route (see html-route.ts): the path is
* fully encoded so the previewed page's relative assets resolve back into
- * the same route with the session scope intact. The platform guard for the
- * UNC marker comes from the session cwd's shape (the browser OS may differ
- * from the host OS, so no client-side platform detection is used), extended
- * by the file path itself: an unambiguous backslash UNC path keeps its '//'
- * marker even while the cwd is still hydrating or arrives in forward-slash
- * form. A forward-slash `//` path is only marked when the cwd is itself a
- * forward-slash UNC — a POSIX session keeps `//server/share/...` as an
- * ordinary absolute path.
+ * the same route with the session scope intact. The UNC marker is
+ * platform-neutral — the host's requireAbsolute resolves the decoded
+ * forward-slash `//server/share/...` form on both win32 and POSIX — so no
+ * client-side platform signal is needed.
*/
export function htmlUrl(scope: SessionScope, path: string): string {
- const cwd = scope.cwd ?? ''
- const windowsPathStyle = isWindowsStylePath(cwd)
- || isWindowsStylePath(path)
- || (isForwardSlashUnc(path) && isForwardSlashUnc(cwd))
- return encodeHtmlUrl(scope.sessionId, path, windowsPathStyle)
+ return encodeHtmlUrl(scope.sessionId, path)
}
diff --git a/src/client/paths.ts b/src/client/paths.ts
index 4247ab9..d87569b 100644
--- a/src/client/paths.ts
+++ b/src/client/paths.ts
@@ -5,25 +5,14 @@
* to '/' before comparison.
*
* This module is dependency-free (no node:path in the client bundle): the
- * host is the authority for path semantics, so these mirrors deliberately
- * accept a SUPERSET of absolute forms — anything a Windows host would emit
+ * host is the authority for path semantics, so this mirror deliberately
+ * accepts a SUPERSET of absolute forms — anything a Windows host would emit
* (drive letters, UNC) plus POSIX roots. A form the host would reject
* (e.g. a backslash UNC path on a POSIX host) passes through here and then
* fails loudly in the host's requireAbsolute instead of being silently
* joined onto the cwd.
*/
-/**
- * Whether a path looks like it belongs to a Windows-style session: a drive
- * letter or any backslash (UNC shares, and drive paths in general).
- * Used as the platform signal for the html route's UNC marker — the client
- * cannot read process.platform (the browser OS may differ from the host),
- * but the session cwd shape is authoritative.
- */
-export function isWindowsStylePath(path: string): boolean {
- return path.includes('\\') || /^[A-Za-z]:[\\/]/.test(path)
-}
-
/**
* Mirror of the host's absolute-path notion (see fs-tree.requireAbsolute):
* POSIX roots, Windows drive letters, and Windows UNC network shares in
diff --git a/src/fs-tree.ts b/src/fs-tree.ts
index 67ddb0a..89cdc5f 100644
--- a/src/fs-tree.ts
+++ b/src/fs-tree.ts
@@ -60,30 +60,51 @@ export async function listDirectory(path: string, maxEntries = 1000): Promise 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 {
+ 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)
diff --git a/src/html-route.ts b/src/html-route.ts
index 8b5b43b..1dfac34 100644
--- a/src/html-route.ts
+++ b/src/html-route.ts
@@ -13,16 +13,17 @@
* /sidebar/html/S/Users/me/proj/index.html
* + ./style.css → /sidebar/html/S/Users/me/proj/style.css
* Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html
- * Windows UNC (windowsPathStyle): \\server\share\proj\a.html
+ * UNC (\\server\share\... or //server/share/...):
* → /sidebar/html/S//server/share/proj/a.html ('//' right after the
* sessionId marks the UNC prefix; the WHATWG URL keeps '//' intact so
* relative assets still resolve inside the same route)
*
- * The `windowsPathStyle` flag is the platform guard: a leading `//` is a
- * legitimate POSIX absolute path, so the UNC marker is only emitted for
- * sessions whose cwd shape identifies them as Windows (see the client's
- * isWindowsStylePath) — otherwise a POSIX `//server/share/...` would decode
- * back to a backslash UNC form the host then rejects.
+ * The decoder rebuilds the marker as a forward-slash `//server/share/...`
+ * path. That form is intentionally platform-neutral: `node:path` resolves it
+ * to `\\server\share\...` on win32 and `/server/share/...` on POSIX, so the
+ * host's existing requireAbsolute + isWithin fence needs no platform signal
+ * (a leading `//` is a legal POSIX absolute path, so no data is lost on
+ * either platform).
*
* This module is intentionally dependency-free (no node imports, no wire
* helpers) so the client bundle can import `encodeHtmlUrl` without tripping
@@ -45,15 +46,9 @@ export type HtmlDecodeResult =
/** The route prefix both encoders/decoders agree on. */
export const HTML_ROUTE_PREFIX = '/sidebar/html/'
-/**
- * Build the route URL for one absolute file path (client + tests).
- * @param windowsPathStyle - whether the path belongs to a Windows-style
- * session (drive or UNC). Only then is a `\\`/`//`-prefixed UNC path marked
- * with the '//' marker; POSIX sessions keep `//` as an ordinary absolute
- * path (see the module comment for the platform guard rationale).
- */
-export function encodeHtmlUrl(sessionId: string, path: string, windowsPathStyle = false): string {
- const unc = windowsPathStyle && /^[\\/]{2}[^\\/]/.test(path)
+/** Build the route URL for one absolute file path (client + tests). */
+export function encodeHtmlUrl(sessionId: string, path: string): string {
+ const unc = /^[\\/]{2}[^\\/]/.test(path)
const segments = path.split(/[\\/]+/).filter(segment => segment !== '')
return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${unc ? '/' : ''}${segments.map(encodeURIComponent).join('/')}`
}
@@ -84,9 +79,9 @@ export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
return { ok: false, status: 400, message: 'sessionId and file path are required' }
}
// An empty FIRST path segment is the UNC marker (encodeHtmlUrl emits
- // '//server/share/...' for Windows UNC paths); the encoder filters
- // empty segments everywhere else, so an empty segment can only be the
- // marker or a malformed URL — both handled here.
+ // '//server/share/...' for UNC paths); the encoder filters empty
+ // segments everywhere else, so an empty segment can only be the marker or
+ // a malformed URL — both handled here.
const unc = pathSegments[0] === ''
const tail = unc ? pathSegments.slice(1) : pathSegments
if (tail.length === 0 || tail.some(segment => segment === '')) {
@@ -94,9 +89,9 @@ export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
}
let path: string
if (unc) {
- // Rebuild the UNC form \\server\share\... so requireAbsolute() +
- // isWithin(cwd) see the same path the explorer works with.
- path = `\\\\${tail.join('\\')}`
+ // Rebuild the platform-neutral forward-slash form `//server/share/...`;
+ // requireAbsolute() resolves it to the platform's own UNC/POSIX spelling.
+ path = `//${tail.join('/')}`
} else if (/^[A-Za-z]:$/.test(tail[0] ?? '')) {
// A Windows drive segment ('D:') is the FIRST path segment of an encoded
// drive path. Rejoining it with a leading slash would yield '/D:/work/...'
diff --git a/tests/fs-tree-symlink.spec.ts b/tests/fs-tree-symlink.spec.ts
index 8211a48..dfbeb30 100644
--- a/tests/fs-tree-symlink.spec.ts
+++ b/tests/fs-tree-symlink.spec.ts
@@ -86,4 +86,28 @@ describe.skipIf(!canSymlink)('fs-tree symlink listing', () => {
rmSync(dir, { recursive: true, force: true })
}
})
+
+ it('classifies every row correctly across a symlink-heavy level (bounded probe)', async () => {
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-symlink-many-'))
+ mkdirSync(join(dir, 'real-dir'))
+ writeFileSync(join(dir, 'real-file.txt'), 'content')
+ try {
+ // More links than the probe concurrency cap exercises the worker pool;
+ // every directory link must still classify as a directory and every
+ // file link as a file (order is already checked by the sort assertion).
+ for (let index = 0; index < 48; index += 1) {
+ symlinkSync(join(dir, 'real-dir'), join(dir, `dir-link-${index}`))
+ symlinkSync(join(dir, 'real-file.txt'), join(dir, `file-link-${index}`))
+ }
+ const listing = await listDirectory(dir)
+ for (let index = 0; index < 48; index += 1) {
+ expect(listing.entries.find(entry => entry.name === `dir-link-${index}`))
+ .toMatchObject({ isDir: true, isSymlink: true, broken: false })
+ expect(listing.entries.find(entry => entry.name === `file-link-${index}`))
+ .toMatchObject({ isDir: false, isSymlink: true, broken: false })
+ }
+ } finally {
+ rmSync(dir, { recursive: true, force: true })
+ }
+ })
})
diff --git a/tests/fs-tree.spec.ts b/tests/fs-tree.spec.ts
index 7037ec7..2175e72 100644
--- a/tests/fs-tree.spec.ts
+++ b/tests/fs-tree.spec.ts
@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { resolve } from 'node:path'
import { compareEntries, isWithin, parentOf, requireAbsolute, rootLabel } from '../src/fs-tree.ts'
-import { isPosix, isWin32 } from './platform.ts'
+import { isWin32 } from './platform.ts'
describe('fs-tree', () => {
it('sorts directories first, then names case-insensitively', () => {
diff --git a/tests/html-route.spec.ts b/tests/html-route.spec.ts
index 4da79ef..8bc8959 100644
--- a/tests/html-route.spec.ts
+++ b/tests/html-route.spec.ts
@@ -21,22 +21,19 @@ describe('encodeHtmlUrl', () => {
.toBe('/sidebar/html/sess-1/C%3A/Users/me/a.html')
})
- it('encodes a Windows UNC path with the // marker only for Windows-style sessions', () => {
- // Backslash UNC is unambiguous: the marker is emitted whenever the
- // caller declares the session Windows-style.
- expect(encodeHtmlUrl('sess-1', '\\\\server\\share\\proj\\a.html', true))
+ it('encodes a UNC path with the // marker (backslash and forward-slash forms)', () => {
+ expect(encodeHtmlUrl('sess-1', '\\\\server\\share\\proj\\a.html'))
.toBe('/sidebar/html/sess-1//server/share/proj/a.html')
- // Forward-slash UNC needs the same marker on a Windows-style session.
- expect(encodeHtmlUrl('sess-1', '//server/share/proj/a.html', true))
+ expect(encodeHtmlUrl('sess-1', '//server/share/proj/a.html'))
.toBe('/sidebar/html/sess-1//server/share/proj/a.html')
})
- it('keeps a POSIX // path marker-free (the platform guard)', () => {
- // A leading // is a legitimate POSIX absolute path: without the
- // Windows-style flag it must NOT be marked as UNC, or the host would
- // decode it back into a backslash form it then rejects.
+ it('keeps a POSIX // path marker-encoded (the marker is platform-neutral)', () => {
+ // The decoder rebuilds '//server/share/...', which node:path resolves to
+ // '/server/share/...' on POSIX and '\\server\share\...' on win32 — so the
+ // leading double slash round-trips without any platform signal.
expect(encodeHtmlUrl('s-1', '//server/share/a.html'))
- .toBe('/sidebar/html/s-1/server/share/a.html')
+ .toBe('/sidebar/html/s-1//server/share/a.html')
})
it('percent-encodes special characters in segments', () => {
@@ -74,19 +71,21 @@ describe('decodeHtmlUrl', () => {
expect(resolve('C:/Users/me/a.html')).toBe('C:\\Users\\me\\a.html')
})
- it('decodes a UNC round-trip back to the network path', () => {
- const url = encodeHtmlUrl('sess-1', '\\\\server\\share\\proj\\a.html', true)
+ it('decodes a UNC round-trip back to the platform-neutral forward-slash form', () => {
+ const url = encodeHtmlUrl('sess-1', '\\\\server\\share\\proj\\a.html')
expect(decodeHtmlUrl(url)).toEqual({
ok: true,
- ref: { sessionId: 'sess-1', path: '\\\\server\\share\\proj\\a.html' },
+ ref: { sessionId: 'sess-1', path: '//server/share/proj/a.html' },
})
+ // The host's requireAbsolute resolves that form per-platform:
+ // '\\server\share\proj\a.html' on win32, '/server/share/proj/a.html' on POSIX.
})
- it('decodes a marker-free POSIX // path as a plain absolute path', () => {
+ it('round-trips a POSIX // path through the same marker', () => {
const url = encodeHtmlUrl('s-1', '//server/share/a.html')
expect(decodeHtmlUrl(url)).toEqual({
ok: true,
- ref: { sessionId: 's-1', path: '/server/share/a.html' },
+ ref: { sessionId: 's-1', path: '//server/share/a.html' },
})
})
@@ -167,12 +166,12 @@ describe('relative asset resolution stays in-route', () => {
it('relative assets of a UNC document stay inside the same route', () => {
// The WHATWG URL preserves the '//' marker during relative resolution,
// so ./style.css lands back on the route with the UNC prefix intact.
- const doc = `http://h${encodeHtmlUrl('s', '\\\\server\\share\\proj\\index.html', true)}`
+ const doc = `http://h${encodeHtmlUrl('s', '\\\\server\\share\\proj\\index.html')}`
expect(new URL('./style.css', doc).pathname)
.toBe('/sidebar/html/s//server/share/proj/style.css')
expect(decodeHtmlUrl(new URL('./style.css', doc).pathname)).toEqual({
ok: true,
- ref: { sessionId: 's', path: '\\\\server\\share\\proj\\style.css' },
+ ref: { sessionId: 's', path: '//server/share/proj/style.css' },
})
})
})
diff --git a/tests/paths.spec.ts b/tests/paths.spec.ts
index b053aee..cee6fec 100644
--- a/tests/paths.spec.ts
+++ b/tests/paths.spec.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
-import { isAbsolutePath, isWindowsStylePath, relativeTo } from '../src/client/paths.ts'
+import { isAbsolutePath, relativeTo } from '../src/client/paths.ts'
import { resolveSidebarPath } from '../src/client/produced-files.ts'
import { htmlUrl } from '../src/client/api.ts'
@@ -56,33 +56,13 @@ describe('path helpers', () => {
expect(isAbsolutePath('rel/x.ts')).toBe(false)
})
- it('detects windows-style session paths (platform signal for the html route)', () => {
- expect(isWindowsStylePath('C:\\work\\proj')).toBe(true)
- expect(isWindowsStylePath('C:/work/proj')).toBe(true)
- expect(isWindowsStylePath('\\\\server\\share\\proj')).toBe(true)
- expect(isWindowsStylePath('//server/share/proj')).toBe(false) // ambiguous form needs the drive/backslash signal
- expect(isWindowsStylePath('/Users/me/code')).toBe(false)
- })
-
- it('htmlUrl marks UNC paths from the file path itself (cwd-independent signal)', () => {
- // cwd not hydrated yet: the unambiguous backslash UNC path must still
- // keep its '//' marker, or win32 would decode /server/... against the
- // current drive and the isWithin fence would reject the preview (403).
+ it('htmlUrl always marks UNC paths (platform-neutral marker)', () => {
+ // The marker is platform-neutral now: the host resolves the decoded
+ // '//server/share/...' form per-platform, so no cwd/OS signal is needed.
expect(htmlUrl({ sessionId: 's' }, '\\\\server\\share\\proj\\a.html'))
.toBe('/sidebar/html/s//server/share/proj/a.html')
- // A forward-slash-form cwd (still Windows) does not mask the backslash path.
- expect(htmlUrl({ sessionId: 's', cwd: '//server/share/proj' }, '\\\\server\\share\\proj\\a.html'))
- .toBe('/sidebar/html/s//server/share/proj/a.html')
- // Both cwd and path in the forward-slash UNC form: the '//' path matches
- // the '//' cwd, so it is UNC and keeps the marker (win32 round-trips).
- expect(htmlUrl({ sessionId: 's', cwd: '//server/share/proj' }, '//server/share/proj/a.html'))
- .toBe('/sidebar/html/s//server/share/proj/a.html')
- // A Windows drive cwd marks forward-slash UNC paths.
- expect(htmlUrl({ sessionId: 's', cwd: 'C:\\work' }, '//server/share/proj/a.html'))
- .toBe('/sidebar/html/s//server/share/proj/a.html')
- // A POSIX session keeps forward-slash paths marker-free.
expect(htmlUrl({ sessionId: 's', cwd: '/home/me' }, '//server/share/a.html'))
- .toBe('/sidebar/html/s/server/share/a.html')
+ .toBe('/sidebar/html/s//server/share/a.html')
expect(htmlUrl({ sessionId: 's', cwd: '/home/me' }, '/home/me/index.html'))
.toBe('/sidebar/html/s/home/me/index.html')
})
diff --git a/tests/platform.ts b/tests/platform.ts
index c9633cf..ae65a87 100644
--- a/tests/platform.ts
+++ b/tests/platform.ts
@@ -8,6 +8,3 @@
/** True when running on Windows (drive letters / UNC path semantics apply). */
export const isWin32 = process.platform === 'win32'
-
-/** True on every non-Windows host (POSIX path semantics). */
-export const isPosix = !isWin32