- fs-tree: requireAbsolute 改用 path.isAbsolute(win32 UNC 放行、C:foo 仍拒); 目录列表仅对 symlink 行 stat 一次,暴露 isSymlink/broken,目录软链接可展开 - html-route: UNC 路径经 '//' 标记编码往返;标记仅在 Windows 风格会话启用, POSIX '//' 绝对路径不再被误判(平台守卫) - client: isAbsolutePath/isWindowsStylePath 镜像 + resolveSidebarPath 识别 UNC, htmlUrl 以会话 cwd 形态传递平台信号;FsEntry 增加 isSymlink/broken - explorer: 软链接行链接徽标、悬空链接标红 + tooltip(issue #65) - 测试: tests/platform.ts 守卫助手;win32 专属用例改为 skipIf 可见跳过, 补 POSIX 反向分支与 UNC 矩阵;新增 fs-tree-symlink.spec
This commit is contained in:
@@ -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>
|
||||
)
|
||||
|
||||
+13
-4
@@ -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 ?? ''))
|
||||
}
|
||||
|
||||
@@ -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,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).
|
||||
|
||||
@@ -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;
|
||||
|
||||
+32
-10
@@ -2,11 +2,13 @@
|
||||
* Single-level directory listing for the sidebar explorer. Streams the level
|
||||
* with opendir, sorts directories first then names (case-insensitive), and
|
||||
* marks POSIX-hidden entries (dot-prefixed) for dimmed display. Symlinks are
|
||||
* reported as files without probing their target — the explorer shows what
|
||||
* dirent says, keeping the read cheap for arbitrarily large levels.
|
||||
* stat'ed once to expose their target kind — a symlink to a directory
|
||||
* expands like a directory — and dangling links are flagged broken. The
|
||||
* probe runs only for entries that are actually symlinks, so levels without
|
||||
* links stay as cheap as before.
|
||||
*/
|
||||
import { opendir } from 'node:fs/promises'
|
||||
import { basename, dirname, join, resolve } from 'node:path'
|
||||
import { opendir, stat } from 'node:fs/promises'
|
||||
import { basename, dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import { SidebarError } from './wire.ts'
|
||||
|
||||
/** One explorer row. */
|
||||
@@ -15,6 +17,10 @@ export interface SidebarFsEntry {
|
||||
path: string
|
||||
isDir: boolean
|
||||
hidden: boolean
|
||||
/** Whether the row is a symlink; `isDir` then describes the link's target. */
|
||||
isSymlink: boolean
|
||||
/** For symlinks: the target is missing or unreadable (stat failed). */
|
||||
broken: boolean
|
||||
}
|
||||
|
||||
/** One listed level. */
|
||||
@@ -52,12 +58,22 @@ export async function listDirectory(path: string, maxEntries = 1000): Promise<Si
|
||||
overflow += 1
|
||||
continue
|
||||
}
|
||||
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
|
||||
// would leak mixed separators into every row's path.
|
||||
const fullPath = join(path, dirent.name)
|
||||
const isSymlink = dirent.isSymbolicLink()
|
||||
// Probe the link's target once so a symlinked directory renders as an
|
||||
// expandable directory row. stat follows the chain; any failure
|
||||
// (missing target, ELOOP, permission) leaves the row as a broken
|
||||
// file-shaped link the editor will refuse to read. Non-symlink entries
|
||||
// skip the syscall entirely — dirent already classified them.
|
||||
const info = isSymlink ? await stat(fullPath).catch(() => undefined) : undefined
|
||||
rows.push({
|
||||
name: dirent.name,
|
||||
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
|
||||
// would leak mixed separators into every row's path.
|
||||
path: join(path, dirent.name),
|
||||
isDir: dirent.isDirectory(),
|
||||
path: fullPath,
|
||||
isDir: info !== undefined ? info.isDirectory() : dirent.isDirectory(),
|
||||
isSymlink,
|
||||
broken: isSymlink && info === undefined,
|
||||
hidden: dirent.name.startsWith('.'),
|
||||
})
|
||||
}
|
||||
@@ -80,9 +96,15 @@ export function parentOf(path: string): string | undefined {
|
||||
return parent === path ? undefined : parent
|
||||
}
|
||||
|
||||
/** Normalize a caller-supplied path to an absolute, resolved path or throw fs-error. */
|
||||
/**
|
||||
* Normalize a caller-supplied path to an absolute, resolved path or throw
|
||||
* fs-error. `path.isAbsolute()` is the OS's own notion of absolute: POSIX
|
||||
* roots (`/...`), Windows drive letters (`C:\...`) and — on win32 — UNC
|
||||
* network shares (`\\server\share\...`); drive-relative forms (`C:foo`)
|
||||
* stay rejected.
|
||||
*/
|
||||
export function requireAbsolute(path: string): string {
|
||||
if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) {
|
||||
if (!isAbsolute(path)) {
|
||||
throw new SidebarError('fs-error', `"${path}" is not an absolute path`, 400)
|
||||
}
|
||||
return resolve(path)
|
||||
|
||||
+50
-18
@@ -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
|
||||
// '<sid>//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 } }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user