Merge pull request #134 from omdsh-dev/feat/unified-path-handling
feat: 统一路径处理——UNC/软链接分类/html 路由平台守卫(#65 #67 #43 #79 #115)
This commit is contained in:
@@ -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、历史、右键暂存 / 提交 / 还原
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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 只读 + 写操作 |
|
||||
|
||||
@@ -110,6 +110,7 @@ URL 采用**路径编码**(无 query)——WHATSG URL 解析对纯路径相
|
||||
- 设计初期考虑过 srcdoc + `<base>` 注入以实现"预览反映未保存草稿",因 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 内)
|
||||
|
||||
|
||||
@@ -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 ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
||||
{rowActions(entry)}
|
||||
</div>
|
||||
{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: {
|
||||
>
|
||||
<IconCodeOutline16 size={14} />
|
||||
<span className={css.explorerName}>{entry.name}</span>
|
||||
{entry.isSymlink && <IconLinkOutline16 size={12} className={css.explorerSymlink} />}
|
||||
{rowActions(entry)}
|
||||
</div>
|
||||
)
|
||||
|
||||
+12
-3
@@ -25,6 +25,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 +211,14 @@ 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 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 {
|
||||
return encodeHtmlUrl(scope.sessionId, path)
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export const zh = {
|
||||
editor: '编辑器',
|
||||
newTab: '新建标签页',
|
||||
openExplorer: '资源管理器',
|
||||
brokenSymlink: '失效的软链接',
|
||||
openGit: 'Git 面板',
|
||||
newTerminal: '新终端',
|
||||
terminalLimit: '终端数量已达上限 (3)',
|
||||
@@ -239,6 +240,7 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
editor: 'Editor',
|
||||
newTab: 'New tab',
|
||||
openExplorer: 'Explorer',
|
||||
brokenSymlink: 'Broken symlink',
|
||||
openGit: 'Git panel',
|
||||
newTerminal: 'New terminal',
|
||||
terminalLimit: 'Terminal limit reached (3)',
|
||||
|
||||
@@ -3,8 +3,27 @@
|
||||
* 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 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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).
|
||||
|
||||
@@ -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('\\') ? '\\' : '/'
|
||||
|
||||
@@ -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;
|
||||
|
||||
+51
-8
@@ -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,22 +58,53 @@ 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.
|
||||
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(),
|
||||
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)
|
||||
@@ -80,9 +117,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)
|
||||
|
||||
+43
-16
@@ -13,6 +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
|
||||
* 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 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
|
||||
@@ -37,23 +48,24 @@ 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 {
|
||||
const unc = /^[\\/]{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 +75,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
|
||||
// '<sid>//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 === '')) {
|
||||
return { ok: false, status: 400, message: 'sessionId and file path are required' }
|
||||
}
|
||||
let path: string
|
||||
if (unc) {
|
||||
// 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/...'
|
||||
// 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 } }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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 })
|
||||
}
|
||||
})
|
||||
|
||||
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 })
|
||||
}
|
||||
})
|
||||
})
|
||||
+57
-17
@@ -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 { 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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,21 @@ describe('encodeHtmlUrl', () => {
|
||||
.toBe('/sidebar/html/sess-1/C%3A/Users/me/a.html')
|
||||
})
|
||||
|
||||
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')
|
||||
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-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')
|
||||
})
|
||||
|
||||
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 +61,37 @@ 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 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' },
|
||||
})
|
||||
// The host's requireAbsolute resolves that form per-platform:
|
||||
// '\\server\share\proj\a.html' on win32, '/server/share/proj/a.html' on POSIX.
|
||||
})
|
||||
|
||||
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' },
|
||||
})
|
||||
})
|
||||
|
||||
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 +162,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')}`
|
||||
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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
+35
-1
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { 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'
|
||||
|
||||
describe('path helpers', () => {
|
||||
it('derives relative paths under the cwd (and "." for the cwd itself)', () => {
|
||||
@@ -32,4 +33,37 @@ 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('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')
|
||||
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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 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'
|
||||
Reference in New Issue
Block a user