diff --git a/README.md b/README.md
index 533ce48..43ee559 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@
## ✨ 功能一览
-- **🗂️ 文件工作台**:资源管理器(懒加载目录树)+ CodeMirror 编辑器;图片 / Markdown / HTML / PDF / Office 内联预览
+- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown / HTML / PDF / Office 内联预览
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原
diff --git a/README_EN.md b/README_EN.md
index 8c2c779..df6dacf 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -21,7 +21,7 @@
## ✨ Features
-- **🗂️ File Workbench**: file explorer (lazy-loading tree) + CodeMirror editor; inline preview for images / Markdown / HTML / PDF / Office
+- **🗂️ File Workbench**: file explorer (lazy-loading tree; symlinks show their target kind — directory links expand, dangling links flagged) + CodeMirror editor; inline preview for images / Markdown / HTML / PDF / Office
- **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links are routed by protocol by default — HTTP opens in the sidebar, HTTPS goes to the system browser (both adjustable in settings)
- **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert
diff --git a/docs/external-plugin-guide.md b/docs/external-plugin-guide.md
index 6f28f87..cb3cea7 100644
--- a/docs/external-plugin-guide.md
+++ b/docs/external-plugin-guide.md
@@ -480,7 +480,7 @@ const { value } = await res.json() // 错误时 { ok: false, error: { code, me
| 方法 | 说明 |
|---|---|
| `session.cwd` | 会话权威 cwd(`{ cwd, root, parent }`) |
-| `fs.tree` | 目录列表(`{ path, entries: FsEntry[], truncated }`) |
+| `fs.tree` | 目录列表(`{ path, entries: FsEntry[], truncated }`;FsEntry 含 `isSymlink`/`broken`,目录软链接的 `isDir` 按目标类型) |
| `fs.read` | 读文件:文本返回 `{ kind: 'text', content, truncated }`;二进制返回 `{ kind: 'binary', size, truncated, head }`(head = base64 前 4KB) |
| `fs.write` | 原子写文件 |
| `git.status` / `git.diff` / `git.log` 等 | 全套 Git 只读 + 写操作 |
diff --git a/src/client/ExplorerView.tsx b/src/client/ExplorerView.tsx
index 9a313b6..e21e8d1 100644
--- a/src/client/ExplorerView.tsx
+++ b/src/client/ExplorerView.tsx
@@ -14,7 +14,7 @@ import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNo
import clsx from 'clsx'
import {
IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16,
- IconRefreshOutline16, Menu, writeClipboard,
+ IconLinkOutline16, IconRefreshOutline16, Menu, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { api, downloadUrl, type FsEntry } from './api.ts'
import { relativeTo } from './paths.ts'
@@ -163,6 +163,7 @@ export function ExplorerView(props: {
>
{isOpen ? : }
{entry.name}
+ {entry.isSymlink && }
{rowActions(entry)}
{isOpen && renderLevel(entry.path, depth + 1)}
@@ -174,9 +175,9 @@ export function ExplorerView(props: {
key={entry.path}
role="button"
tabIndex={0}
- className={clsx(css.explorerRow, entry.hidden && css.explorerHidden)}
+ className={clsx(css.explorerRow, entry.hidden && css.explorerHidden, entry.broken && css.explorerBroken)}
style={{ paddingLeft: depth * 22 + 6 }}
- title={entry.path}
+ title={entry.broken ? `${entry.path} — ${t('brokenSymlink')}` : entry.path}
onClick={() => { onOpenFile(entry.path) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
@@ -188,6 +189,7 @@ export function ExplorerView(props: {
>
{entry.name}
+ {entry.isSymlink && }
{rowActions(entry)}
)
diff --git a/src/client/api.ts b/src/client/api.ts
index 619b60a..6a56bff 100644
--- a/src/client/api.ts
+++ b/src/client/api.ts
@@ -8,6 +8,7 @@
*/
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 {
@@ -25,6 +26,10 @@ export interface FsEntry {
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
}
/** Git status entry (host git shape). */
@@ -207,9 +212,13 @@ function fileUrl(scope: SessionScope, path: string, download: boolean): string {
return `/sidebar/file?${params.toString()}`
}
-/** 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. */
+/**
+ * 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 session cwd's shape
+ * supplies the platform guard for the UNC marker (the browser OS may differ
+ * from the host OS, so no client-side platform detection is used).
+ */
export function htmlUrl(scope: SessionScope, path: string): string {
- return encodeHtmlUrl(scope.sessionId, path)
+ return encodeHtmlUrl(scope.sessionId, path, isWindowsStylePath(scope.cwd ?? ''))
}
diff --git a/src/client/locales.ts b/src/client/locales.ts
index b92e122..3eaa597 100644
--- a/src/client/locales.ts
+++ b/src/client/locales.ts
@@ -17,6 +17,7 @@ export const zh = {
editor: '编辑器',
newTab: '新建标签页',
openExplorer: '资源管理器',
+ brokenSymlink: '失效的软链接',
openGit: 'Git 面板',
newTerminal: '新终端',
terminalLimit: '终端数量已达上限 (3)',
@@ -239,6 +240,7 @@ export const en: Record = {
editor: 'Editor',
newTab: 'New tab',
openExplorer: 'Explorer',
+ brokenSymlink: 'Broken symlink',
openGit: 'Git panel',
newTerminal: 'New terminal',
terminalLimit: 'Terminal limit reached (3)',
diff --git a/src/client/paths.ts b/src/client/paths.ts
index 70bf192..4247ab9 100644
--- a/src/client/paths.ts
+++ b/src/client/paths.ts
@@ -3,8 +3,38 @@
* the session cwd (for the @-reference button and "copy relative path").
* The fs-tree joins with '/' even on Windows, so both separators normalize
* 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
+ * (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
+ * both backslash (`\\server\share\...`) and forward-slash
+ * (`//server/share/...`) form. Deliberately a superset — see the module
+ * comment — so a produced UNC path is never joined onto the cwd.
+ */
+export function isAbsolutePath(path: string): boolean {
+ return path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path) || /^[\\/]{2}[^\\/]/.test(path)
+}
+
/**
* The path relative to the session's working directory.
* @param cwd - the explorer root (absolute).
diff --git a/src/client/produced-files.ts b/src/client/produced-files.ts
index a0c2a7b..4a7a2f5 100644
--- a/src/client/produced-files.ts
+++ b/src/client/produced-files.ts
@@ -6,6 +6,7 @@
* Kept dependency-free so the takeover logic is unit-testable and the
* replica is easy to diff against upstream when it drifts.
*/
+import { isAbsolutePath } from './paths.ts'
/** Paths a tool-result view reports as produced, by render intent. */
export function producedPaths(view: unknown): readonly string[] {
@@ -76,10 +77,13 @@ export function selectProducedFiles(owner: unknown): readonly string[] | null {
return paths.length === 0 ? null : paths
}
-/** Resolve a (possibly relative) path against the session cwd for the sidebar. */
+/**
+ * Resolve a (possibly relative) path against the session cwd for the sidebar.
+ * Absolute detection mirrors the host (see client/paths.isAbsolutePath):
+ * POSIX roots, drive letters and UNC shares must not be joined onto the cwd.
+ */
export function resolveSidebarPath(cwd: string | undefined, path: string): string {
- const absolute = path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)
- if (absolute) return path
+ if (isAbsolutePath(path)) return path
const base = cwd ?? ''
if (base === '') return path
const separator = base.includes('\\') ? '\\' : '/'
diff --git a/src/client/sidebar.module.css b/src/client/sidebar.module.css
index 2426173..570b532 100644
--- a/src/client/sidebar.module.css
+++ b/src/client/sidebar.module.css
@@ -703,6 +703,18 @@
opacity: 0.45;
}
+/* The link badge on symlink rows (folder and file rows alike). */
+.explorerSymlink {
+ flex: none;
+ color: var(--dsw-alias-label-tertiary);
+}
+
+/* A symlink whose target is missing: error ink on the name (the row stays
+ clickable — the editor surfaces the read failure). */
+.explorerBroken .explorerName {
+ color: var(--dsw-alias-state-error-primary);
+}
+
.explorerName {
overflow: hidden;
text-overflow: ellipsis;
diff --git a/src/fs-tree.ts b/src/fs-tree.ts
index 437cff7..67ddb0a 100644
--- a/src/fs-tree.ts
+++ b/src/fs-tree.ts
@@ -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 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)
diff --git a/src/html-route.ts b/src/html-route.ts
index b81a556..8b5b43b 100644
--- a/src/html-route.ts
+++ b/src/html-route.ts
@@ -13,6 +13,16 @@
* /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
+ * → /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.
*
* This module is intentionally dependency-free (no node imports, no wire
* helpers) so the client bundle can import `encodeHtmlUrl` without tripping
@@ -35,25 +45,32 @@ 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). */
-export function encodeHtmlUrl(sessionId: string, path: string): string {
+/**
+ * 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)
const segments = path.split(/[\\/]+/).filter(segment => segment !== '')
- return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${segments.map(encodeURIComponent).join('/')}`
+ return `${HTML_ROUTE_PREFIX}${encodeURIComponent(sessionId)}/${unc ? '/' : ''}${segments.map(encodeURIComponent).join('/')}`
}
/**
* Decode a route pathname into the session + absolute file path. Rejects
- * a wrong prefix (404), an empty or double-slash path, malformed percent
- * encoding, and a missing sessionId or file path (400). The caller still
- * must bound the decoded path with requireAbsolute + isWithin(cwd) — a
- * decoded `..` segment resolves outside the cwd and is refused there.
+ * a wrong prefix (404), an empty path, malformed percent encoding, and a
+ * missing sessionId or file path (400). The caller still must bound the
+ * decoded path with requireAbsolute + isWithin(cwd) — a decoded `..`
+ * segment resolves outside the cwd and is refused there.
*/
export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
if (!pathname.startsWith(HTML_ROUTE_PREFIX)) {
return { ok: false, status: 404, message: 'not an html route' }
}
const rest = pathname.slice(HTML_ROUTE_PREFIX.length)
- if (rest === '' || rest.includes('//')) {
+ if (rest === '') {
return { ok: false, status: 400, message: 'invalid html route path' }
}
let segments: string[]
@@ -63,17 +80,32 @@ export function decodeHtmlUrl(pathname: string): HtmlDecodeResult {
return { ok: false, status: 400, message: 'malformed URL encoding' }
}
const [sessionId, ...pathSegments] = segments
- if (sessionId === undefined || sessionId === '' || pathSegments.length === 0 || pathSegments.some(segment => segment === '')) {
+ if (sessionId === undefined || sessionId === '') {
return { ok: false, status: 400, message: 'sessionId and file path are required' }
}
- // 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/...'
- // which node's path.resolve() mangles into 'D:\D:\work\...' on Windows —
- // the html route's isWithin(cwd) fence would then reject every drive path.
- // Keep the drive form slash-free so requireAbsolute() resolves it verbatim.
- const first = pathSegments[0] ?? ''
- const path = /^[A-Za-z]:$/.test(first)
- ? pathSegments.join('/')
- : `/${pathSegments.join('/')}`
+ // 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.
+ const unc = pathSegments[0] === ''
+ const tail = unc ? pathSegments.slice(1) : pathSegments
+ if (tail.length === 0 || tail.some(segment => segment === '')) {
+ return { ok: false, status: 400, message: 'sessionId and file path are required' }
+ }
+ 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('\\')}`
+ } 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/...'
+ // which node's path.resolve() mangles into 'D:\D:\work\...' on Windows —
+ // the html route's isWithin(cwd) fence would then reject every drive path.
+ // Keep the drive form slash-free so requireAbsolute() resolves it verbatim.
+ path = tail.join('/')
+ } else {
+ path = `/${tail.join('/')}`
+ }
return { ok: true, ref: { sessionId, path } }
}
diff --git a/tests/fs-tree-symlink.spec.ts b/tests/fs-tree-symlink.spec.ts
new file mode 100644
index 0000000..8211a48
--- /dev/null
+++ b/tests/fs-tree-symlink.spec.ts
@@ -0,0 +1,89 @@
+/**
+ * fs-tree symlink listing: the explorer must show a symlink as what it
+ * points at (a symlink to a directory expands like a directory) and flag
+ * links whose target is missing. The host listing keeps the probe cheap:
+ * only entries that are actually symlinks are stat'ed.
+ */
+import { describe, expect, it } from 'vitest'
+import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { listDirectory } from '../src/fs-tree.ts'
+
+/**
+ * Symlink creation needs extra privileges on Windows; skip the suite there
+ * rather than fail (mirror of the smoke spec's platform-tolerant fixtures).
+ */
+const canSymlink = (() => {
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-symlink-probe-'))
+ try {
+ symlinkSync('target', join(dir, 'link'))
+ return true
+ } catch {
+ return false
+ } finally {
+ rmSync(dir, { recursive: true, force: true })
+ }
+})()
+
+/** A scratch level with real entries plus three symlinks (dir/file/dangling). */
+function makeFixture(): string {
+ const dir = mkdtempSync(join(tmpdir(), 'dsh-sidebar-symlink-'))
+ mkdirSync(join(dir, 'real-dir'))
+ writeFileSync(join(dir, 'real-file.txt'), 'content')
+ symlinkSync(join(dir, 'real-dir'), join(dir, 'link-to-dir'))
+ symlinkSync(join(dir, 'real-file.txt'), join(dir, 'link-to-file'))
+ symlinkSync(join(dir, 'missing-target'), join(dir, 'broken-link'))
+ return dir
+}
+
+describe.skipIf(!canSymlink)('fs-tree symlink listing', () => {
+ it('reports a symlink to a directory as an expandable directory', async () => {
+ const dir = makeFixture()
+ try {
+ const listing = await listDirectory(dir)
+ const row = listing.entries.find(entry => entry.name === 'link-to-dir')
+ expect(row).toMatchObject({ isDir: true, isSymlink: true, broken: false })
+ } finally {
+ rmSync(dir, { recursive: true, force: true })
+ }
+ })
+
+ it('reports a symlink to a file as a file with a link badge', async () => {
+ const dir = makeFixture()
+ try {
+ const listing = await listDirectory(dir)
+ const row = listing.entries.find(entry => entry.name === 'link-to-file')
+ expect(row).toMatchObject({ isDir: false, isSymlink: true, broken: false })
+ } finally {
+ rmSync(dir, { recursive: true, force: true })
+ }
+ })
+
+ it('flags a dangling symlink as broken', async () => {
+ const dir = makeFixture()
+ try {
+ const listing = await listDirectory(dir)
+ const row = listing.entries.find(entry => entry.name === 'broken-link')
+ expect(row).toMatchObject({ isDir: false, isSymlink: true, broken: true })
+ } finally {
+ rmSync(dir, { recursive: true, force: true })
+ }
+ })
+
+ it('leaves regular entries unmarked and sorts symlinked dirs with the directories', async () => {
+ const dir = makeFixture()
+ try {
+ const listing = await listDirectory(dir)
+ const realDir = listing.entries.find(entry => entry.name === 'real-dir')
+ const realFile = listing.entries.find(entry => entry.name === 'real-file.txt')
+ expect(realDir).toMatchObject({ isDir: true, isSymlink: false, broken: false })
+ expect(realFile).toMatchObject({ isDir: false, isSymlink: false, broken: false })
+ // Directory-first ordering counts a symlinked directory as a directory.
+ const dirs = listing.entries.filter(entry => entry.isDir).map(entry => entry.name)
+ expect(dirs).toEqual(['link-to-dir', 'real-dir'])
+ } finally {
+ rmSync(dir, { recursive: true, force: true })
+ }
+ })
+})
diff --git a/tests/fs-tree.spec.ts b/tests/fs-tree.spec.ts
index 4e19eb4..7037ec7 100644
--- a/tests/fs-tree.spec.ts
+++ b/tests/fs-tree.spec.ts
@@ -1,43 +1,76 @@
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'
describe('fs-tree', () => {
it('sorts directories first, then names case-insensitively', () => {
const rows = [
- { name: 'b.txt', path: '/x/b.txt', isDir: false, hidden: false },
- { name: 'A', path: '/x/A', isDir: true, hidden: false },
- { name: 'a.txt', path: '/x/a.txt', isDir: false, hidden: false },
- { name: '.hidden', path: '/x/.hidden', isDir: false, hidden: true },
+ { name: 'b.txt', path: '/x/b.txt', isDir: false, hidden: false, isSymlink: false, broken: false },
+ { name: 'A', path: '/x/A', isDir: true, hidden: false, isSymlink: false, broken: false },
+ { name: 'a.txt', path: '/x/a.txt', isDir: false, hidden: false, isSymlink: false, broken: false },
+ { name: '.hidden', path: '/x/.hidden', isDir: false, hidden: true, isSymlink: false, broken: false },
]
expect(rows.sort(compareEntries).map(row => row.name)).toEqual(['A', '.hidden', 'a.txt', 'b.txt'])
})
- it('derives root labels and parents', () => {
+ it('derives root labels and parents (POSIX-style)', () => {
// POSIX-style inputs behave identically on both platforms (win32 parses '/'
// as a separator), so these assertions are platform-independent.
expect(rootLabel('/Users/me/code')).toBe('code')
expect(rootLabel('/')).toBe('/')
expect(parentOf('/Users/me/code')).toBe('/Users/me')
expect(parentOf('/')).toBeUndefined()
- // Windows-drive roots and segments, asserted only where win32 semantics apply.
- if (process.platform === 'win32') {
- expect(rootLabel('C:\\')).toBe('C:\\')
- expect(parentOf('C:\\')).toBeUndefined()
- expect(rootLabel('C:\\Users\\me')).toBe('me')
- expect(parentOf('C:\\Users\\me')).toBe('C:\\Users')
- }
})
- it('accepts absolute paths and rejects relative ones', () => {
+ it.skipIf(!isWin32)('derives root labels and parents for Windows drives', () => {
+ expect(rootLabel('C:\\')).toBe('C:\\')
+ expect(parentOf('C:\\')).toBeUndefined()
+ expect(rootLabel('C:\\Users\\me')).toBe('me')
+ expect(parentOf('C:\\Users\\me')).toBe('C:\\Users')
+ })
+
+ it('accepts POSIX absolute paths and rejects relative ones', () => {
// resolve() is platform-native: '/a/b' roots to the current drive on win32.
- expect(requireAbsolute('/a/b')).toBe(process.platform === 'win32' ? '\\a\\b' : '/a/b')
- if (process.platform === 'win32') {
- expect(requireAbsolute('C:/proj')).toBe('C:\\proj')
- }
+ expect(requireAbsolute('/a/b')).toBe(resolve('/a/b'))
expect(() => requireAbsolute('a/b')).toThrow(/not an absolute path/)
expect(() => requireAbsolute('../a')).toThrow(/not an absolute path/)
})
+ // Windows-only path semantics — skipped (not silently passing) on POSIX:
+ // drive letters and UNC shares are absolute on win32, drive-relative 'C:foo'
+ // is not.
+ describe.skipIf(!isWin32)('win32 path semantics', () => {
+ it('accepts drive letters and normalizes their separators', () => {
+ expect(requireAbsolute('C:/proj')).toBe('C:\\proj')
+ expect(requireAbsolute('C:\\proj')).toBe('C:\\proj')
+ // A drive path resolves against its own drive, never a bare root.
+ expect(resolve('/a/b')).toMatch(/^[A-Za-z]:/)
+ })
+
+ it('accepts UNC network shares in both separator styles', () => {
+ expect(requireAbsolute('\\\\server\\share\\proj')).toBe('\\\\server\\share\\proj')
+ expect(requireAbsolute('//server/share/proj')).toBe('\\\\server\\share\\proj')
+ })
+
+ it('rejects drive-relative paths', () => {
+ expect(() => requireAbsolute('C:proj')).toThrow(/not an absolute path/)
+ })
+ })
+
+ // POSIX-only path semantics — the reverse branch of the win32 suite: forms
+ // the host can never access must be refused loudly instead of mangled.
+ describe.skipIf(isWin32)('POSIX path semantics', () => {
+ it('rejects Windows drive paths', () => {
+ expect(() => requireAbsolute('C:/proj')).toThrow(/not an absolute path/)
+ expect(() => requireAbsolute('C:\\proj')).toThrow(/not an absolute path/)
+ })
+
+ it('rejects backslash UNC paths (not absolute on POSIX)', () => {
+ expect(() => requireAbsolute('\\\\server\\share\\proj')).toThrow(/not an absolute path/)
+ })
+ })
+
it('isWithin tolerates separators and (on win32) letter case', () => {
expect(isWithin('/work/proj', '/work/proj/src/a.ts')).toBe(true)
expect(isWithin('/work/proj', '/work/proj')).toBe(true)
@@ -54,5 +87,12 @@ describe('fs-tree', () => {
// Windows drive-root containment.
expect(isWithin('C:\\', 'C:\\Users\\me\\a.png', 'win32')).toBe(true)
expect(isWithin('c:\\users', 'C:/USERS/me/b.png', 'win32')).toBe(true)
+ // UNC network-share containment: the '//' share prefix must not defeat
+ // the prefix test, and a sibling share must stay outside. The platform
+ // parameter is injected, so these win32-semantics assertions run on
+ // every host without any platform guard.
+ expect(isWithin('\\\\server\\share\\proj', '\\\\server\\share\\proj\\src\\a.ts', 'win32')).toBe(true)
+ expect(isWithin('\\\\server\\share\\proj', '\\\\server\\share\\proj2\\a.ts', 'win32')).toBe(false)
+ expect(isWithin('\\\\server\\share\\proj', '\\\\other\\share\\a.ts', 'win32')).toBe(false)
})
})
diff --git a/tests/html-route.spec.ts b/tests/html-route.spec.ts
index 95b5d22..4da79ef 100644
--- a/tests/html-route.spec.ts
+++ b/tests/html-route.spec.ts
@@ -8,6 +8,7 @@
import { describe, expect, it } from 'vitest'
import { resolve } from 'node:path'
import { decodeHtmlUrl, encodeHtmlUrl } from '../src/html-route.ts'
+import { isWin32 } from './platform.ts'
describe('encodeHtmlUrl', () => {
it('encodes a POSIX absolute path into route segments', () => {
@@ -20,6 +21,24 @@ 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))
+ .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))
+ .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.
+ expect(encodeHtmlUrl('s-1', '//server/share/a.html'))
+ .toBe('/sidebar/html/s-1/server/share/a.html')
+ })
+
it('percent-encodes special characters in segments', () => {
expect(encodeHtmlUrl('s-1', '/a b/中文/100%.html'))
.toBe('/sidebar/html/s-1/a%20b/%E4%B8%AD%E6%96%87/100%25.html')
@@ -45,13 +64,35 @@ describe('decodeHtmlUrl', () => {
ok: true,
ref: { sessionId: 'sess-1', path: 'C:/Users/me/a.html' },
})
+ })
+
+ it.skipIf(!isWin32)('decoded drive paths resolve back verbatim on win32', () => {
// The host runs requireAbsolute() over the decoded path: with the old
// '/C:/...' form node's resolve() produced 'C:\C:\Users\...' (drive
// doubled) on Windows and the isWithin(cwd) fence rejected every drive
// path. The slash-free form must resolve back to the original path.
- if (process.platform === 'win32') {
- expect(resolve('C:/Users/me/a.html')).toBe('C:\\Users\\me\\a.html')
- }
+ 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)
+ expect(decodeHtmlUrl(url)).toEqual({
+ ok: true,
+ ref: { sessionId: 'sess-1', path: '\\\\server\\share\\proj\\a.html' },
+ })
+ })
+
+ it('decodes a marker-free POSIX // path as a plain absolute path', () => {
+ const url = encodeHtmlUrl('s-1', '//server/share/a.html')
+ expect(decodeHtmlUrl(url)).toEqual({
+ ok: true,
+ ref: { sessionId: 's-1', path: '/server/share/a.html' },
+ })
+ })
+
+ it('refuses a marker-only UNC URL and stray double slashes (400)', () => {
+ expect(decodeHtmlUrl('/sidebar/html/s//').ok).toBe(false)
+ expect(decodeHtmlUrl('/sidebar/html/s//server//x.html').ok).toBe(false)
})
it('decodes a lowercase-drive Windows path without a leading slash', () => {
@@ -122,4 +163,16 @@ describe('relative asset resolution stays in-route', () => {
expect(new URL('img/x.png', doc).pathname).toBe('/sidebar/html/s/a/b/img/x.png')
expect(new URL('../c.css', doc).pathname).toBe('/sidebar/html/s/a/c.css')
})
+
+ 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)}`
+ 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' },
+ })
+ })
})
diff --git a/tests/paths.spec.ts b/tests/paths.spec.ts
index 55fb596..b053aee 100644
--- a/tests/paths.spec.ts
+++ b/tests/paths.spec.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
-import { relativeTo } from '../src/client/paths.ts'
+import { isAbsolutePath, isWindowsStylePath, relativeTo } from '../src/client/paths.ts'
import { resolveSidebarPath } from '../src/client/produced-files.ts'
+import { htmlUrl } from '../src/client/api.ts'
describe('path helpers', () => {
it('derives relative paths under the cwd (and "." for the cwd itself)', () => {
@@ -32,4 +33,57 @@ describe('path helpers', () => {
expect(resolveSidebarPath('C:\\work\\proj', 'C:\\abs\\x.ts')).toBe('C:\\abs\\x.ts')
expect(resolveSidebarPath('C:\\work\\proj\\', 'C:\\abs\\x.ts')).toBe('C:\\abs\\x.ts')
})
+
+ it('keeps UNC produced paths absolute instead of joining them onto the cwd', () => {
+ // Pure client function: UNC detection is platform-independent, so these
+ // assertions run on every host without a platform guard.
+ expect(resolveSidebarPath('C:\\work\\proj', '\\\\server\\share\\abs\\x.ts'))
+ .toBe('\\\\server\\share\\abs\\x.ts')
+ expect(resolveSidebarPath('C:\\work\\proj', '//server/share/abs/x.ts'))
+ .toBe('//server/share/abs/x.ts')
+ // A relative path under a UNC cwd joins with backslashes.
+ expect(resolveSidebarPath('\\\\server\\share\\proj', 'src/a.ts'))
+ .toBe('\\\\server\\share\\proj\\src/a.ts')
+ })
+
+ it('mirrors the host absolute-path notion without node:path', () => {
+ expect(isAbsolutePath('/abs/x.ts')).toBe(true)
+ expect(isAbsolutePath('C:\\abs\\x.ts')).toBe(true)
+ expect(isAbsolutePath('C:/abs/x.ts')).toBe(true)
+ expect(isAbsolutePath('\\\\server\\share\\x.ts')).toBe(true)
+ expect(isAbsolutePath('//server/share/x.ts')).toBe(true)
+ expect(isAbsolutePath('C:relative.ts')).toBe(false)
+ 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).
+ 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')
+ 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
new file mode 100644
index 0000000..c9633cf
--- /dev/null
+++ b/tests/platform.ts
@@ -0,0 +1,13 @@
+/**
+ * Shared platform guards for tests. Use vitest's `describe.skipIf` /
+ * `it.skipIf` with these so platform-specific cases are REPORTED as skipped
+ * on other hosts instead of silently passing through an inline `if` — and
+ * so the opposite branch (e.g. POSIX rejecting drive paths) is always
+ * asserted on the matching host.
+ */
+
+/** 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