Pārlūkot izejas kodu

feat(workspace-path): define Session and absolute file resource addresses

imccyu 1 mēnesi atpakaļ
vecāks
revīzija
4a4b86d9b0

+ 11 - 1
packages/util/workspace-path/README.md

@@ -9,15 +9,25 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-Browser-safe path helpers shared by Workspace-facing client and controller packages. The package joins Workspace-relative paths, abbreviates POSIX home directories for display, and derives Workspace titles from POSIX or Windows paths. It has no Cordis service or runtime state.
+Browser-safe path helpers shared by Workspace-facing client and controller packages. The package joins Workspace-relative paths, abbreviates POSIX home directories for display, derives Workspace titles from POSIX or Windows paths, and owns the `dsh-resource://file/…` address grammar that names a workspace file across the Sidebar and the resource model. It has no Cordis service or runtime state.
 
 ## Table of Contents
 
+- [File addresses](#file-addresses)
 - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work)
 - [Dev Note](#dev-note)
 
 -----
 
+<a id="file-addresses"></a>
+## File addresses
+
+A resource address is `dsh-resource://<type>/…`, and the type — the URI host — is the resource protocol key (`file`, or one a plugin declares in `ResourceProtocolMap`); any other scheme is a navigation protocol, defined elsewhere. A file address has one of two scopes. `dsh-resource://file/session/<sessionId>/<path>` names a file by its path relative to that Session's workspace root (`dsh-resource://file/session/abc123/src/notes.txt`), which the Host resolves against the root it holds for the Session. `dsh-resource://file/absolute/<path>` names a file by its absolute path with the leading `/` dropped (`dsh-resource://file/absolute/home/ys/notes.txt` on POSIX, `dsh-resource://file/absolute/C:/x/y.txt` for a Windows drive, `dsh-resource://file/absolute//server/share/y.txt` for a UNC path, whose empty first segment keeps its identity); it carries no Session, so the reader's own Session resolves it, and the Host's workspace confinement still applies. The grammar lives in [`src/file-address.ts`](src/file-address.ts); the path helpers stay in [`src/index.ts`](src/index.ts), which re-exports it.
+
+`sessionFileAddress(sessionId, relativePath)` and `absoluteFileAddress(absolutePath)` build one: `\` becomes `/`, a leading `./` or `/` is dropped, and every id and path segment is component-encoded with `:` kept literal, so `#`, `?`, and spaces in a name survive while a drive letter reads as written. `fileAddressFor(sessionId, cwd, path)` chooses the scope for a path as a caller holds it: a relative path, or an absolute path inside `cwd`, becomes `session`-relative; any other absolute path becomes `absolute`. `parseFileAddress(address)` reads one back through `new URL()`: the scheme must be `dsh-resource` and the host exactly `file`; a `session` address yields `{ scope, sessionId, path }` with the workspace-relative path, an `absolute` address yields `{ scope, path }` with the leading `/` restored (`//` for a UNC path) unless the path starts with a drive letter. It returns `undefined` for another type or scheme, an unknown scope, a missing id or path, a non-URL, or a malformed escape — the caller decides whether that is a failure.
+
+-----
+
 ## Known Limitations and Deferred Work
 
 <a id="known-limitations-and-deferred-work"></a>

+ 11 - 1
packages/util/workspace-path/README.zh.md

@@ -9,15 +9,25 @@ kind: "package-library"
 
 ## 概述
 
-供 Workspace 相关客户端和控制器包共享、可在浏览器使用的路径辅助函数。该包负责拼接 Workspace 相对路径、缩写用于展示的 POSIX 主目录,以及从 POSIX 或 Windows 路径提取 Workspace 标题;它不提供 Cordis service,也不持有运行时状态。
+供 Workspace 相关客户端和控制器包共享、可在浏览器使用的路径辅助函数。该包负责拼接 Workspace 相对路径、缩写用于展示的 POSIX 主目录、从 POSIX 或 Windows 路径提取 Workspace 标题,并拥有在 Sidebar 与资源模型之间命名工作区文件的 `dsh-resource://file/…` 地址语法;它不提供 Cordis service,也不持有运行时状态。
 
 ## 目录
 
+- [文件地址](#file-addresses)
 - [已知限制与暂缓事项](#known-limitations-and-deferred-work)
 - [开发备注](#dev-note)
 
 -----
 
+<a id="file-addresses"></a>
+## 文件地址
+
+资源地址 = `dsh-resource://<type>/…`,type(URI 的 host)即资源协议键(`file`,或插件在 `ResourceProtocolMap` 中声明的键);其他 scheme 属导航协议,另行定义。文件地址有两种作用域。`dsh-resource://file/session/<sessionId>/<path>` 以相对该 Session 工作区根的路径命名文件(`dsh-resource://file/session/abc123/src/notes.txt`),由 Host 对它为该 Session 持有的根解析。`dsh-resource://file/absolute/<path>` 以去掉前导 `/` 的绝对路径命名文件(POSIX 上为 `dsh-resource://file/absolute/home/ys/notes.txt`,Windows 盘符为 `dsh-resource://file/absolute/C:/x/y.txt`,UNC 路径为 `dsh-resource://file/absolute//server/share/y.txt`,其空的首段保留 UNC 身份);它不带 Session,由读者自己的 Session 解析,Host 的工作区限制照样适用。语法住在 [`src/file-address.ts`](src/file-address.ts);路径辅助函数留在 [`src/index.ts`](src/index.ts) 并再导出它。
+
+`sessionFileAddress(sessionId, relativePath)` 与 `absoluteFileAddress(absolutePath)` 构造地址:`\` 归一为 `/`,去掉前导 `./` 或 `/`,id 与每个路径段做组件编码但 `:` 保持字面,因此名字里的 `#`、`?`、空格都能保留,盘符也照原样可读。`fileAddressFor(sessionId, cwd, path)` 按调用方手里的路径选作用域:相对路径或落在 `cwd` 内的绝对路径成为 `session` 相对地址,其他绝对路径成为 `absolute` 地址。`parseFileAddress(address)` 用 `new URL()` 读回:scheme 必须是 `dsh-resource`、host 必须恰为 `file`;`session` 地址得到 `{ scope, sessionId, path }`(path 为工作区相对路径),`absolute` 地址得到 `{ scope, path }` 并还原前导 `/`(UNC 路径还原为 `//`),以盘符开头者除外。对其他 type 或 scheme、未知作用域、缺 id 或路径、非 URL、或转义格式错误的输入返回 `undefined`,是否算失败由调用方决定。
+
+-----
+
 ## 已知限制与暂缓事项
 
 <a id="known-limitations-and-deferred-work"></a>

+ 113 - 0
packages/util/workspace-path/src/file-address.ts

@@ -0,0 +1,113 @@
+/**
+ * The `dsh-resource://file/…` address grammar: how a file is named across the
+ * Sidebar and the resource model, built and parsed without touching a
+ * filesystem.
+ * @module
+ */
+
+/**
+ * A file resource address, in one of two scopes.
+ *
+ * Every resource address is `dsh-resource://<type>/…`, the URI host naming the
+ * resource protocol; for `file` the path opens with the scope:
+ *
+ * - `dsh-resource://file/session/<sessionId>/<path>` names a file by its path
+ *   relative to that Session's workspace root (`src/a.ts`, no leading `/`); the
+ *   Host resolves it against the root it holds for the Session.
+ * - `dsh-resource://file/absolute/<path>` names a file by its absolute path with
+ *   the leading `/` dropped (`dsh-resource://file/absolute/home/ys/notes.txt`;
+ *   Windows `dsh-resource://file/absolute/C:/x/y.txt`; a UNC path keeps an empty
+ *   first segment, `dsh-resource://file/absolute//server/share/x.txt`). It carries
+ *   no Session: the reader's own Session resolves it, and the Host's workspace
+ *   confinement still applies.
+ *
+ * Every id and path segment is component-encoded, so a name carrying `#`, `?`,
+ * or a space survives the round trip; `:` stays literal so a drive letter reads
+ * as written.
+ */
+export type FileAddress =
+  | {
+    readonly scope: 'session'
+    /** The Session whose workspace root the path is relative to. */
+    readonly sessionId: string
+    /** Workspace-relative `/`-separated path, no leading `/`; empty for the root itself. */
+    readonly path: string
+  }
+  | {
+    readonly scope: 'absolute'
+    /** Absolute `/`-separated path: `/a/b` on POSIX, `C:/a/b` for a Windows drive, `//server/share/a` for a UNC path. */
+    readonly path: string
+  }
+
+/** The scheme and type every file address opens with. */
+const FILE_ADDRESS_PREFIX = 'dsh-resource://file/'
+
+/** Component-encode one id or path segment, keeping `:` literal for drive letters. */
+function encodeSegment(segment: string): string {
+  return encodeURIComponent(segment).replace(/%3A/gi, ':')
+}
+
+/** Encode a `/`-separated path segment by segment. */
+function encodePath(path: string): string {
+  return path.split('/').map(encodeSegment).join('/')
+}
+
+/** Whether a decoded first path segment is a Windows drive (`C:`). */
+function isDriveSegment(segment: string | undefined): boolean {
+  return segment !== undefined && /^[A-Za-z]:$/.test(segment)
+}
+
+/**
+ * Build the address of a file inside one Session's workspace.
+ * @param sessionId - the Session whose workspace root the path is relative to.
+ * @param path - workspace-relative path; backslashes are normalized to `/`, and a leading `./` or `/` is dropped.
+ * @returns the `dsh-resource://file/session/<sessionId>/<path>` address.
+ */
+export function sessionFileAddress(sessionId: string, path: string): string {
+  const relative = path.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '').replace(/^\/+/, '')
+  return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(relative)}`
+}
+
+/**
+ * Build the address of a file by its absolute path.
+ * @param path - absolute path; backslashes are normalized to `/` and the leading `/` is dropped,
+ *   except that a UNC path (`\\server\share`) keeps one empty first segment.
+ * @returns the `dsh-resource://file/absolute/<path>` address.
+ */
+export function absoluteFileAddress(path: string): string {
+  const normalized = path.replace(/\\/g, '/')
+  const unc = normalized.startsWith('//')
+  const absolute = normalized.replace(/^\/+/, '')
+  return `${FILE_ADDRESS_PREFIX}absolute/${unc ? '/' : ''}${encodePath(absolute)}`
+}
+
+/**
+ * Read a file address back into its parts.
+ * @param address - a candidate address.
+ * @returns the parts, or `undefined` when the string is not a `dsh-resource://file/` URI in a known scope with a path, or a segment is not validly encoded.
+ */
+export function parseFileAddress(address: string): FileAddress | undefined {
+  try {
+    const url = new URL(address)
+    if (url.protocol !== 'dsh-resource:' || url.host !== 'file') return undefined
+    const [, scope, ...rest] = url.pathname.split('/')
+    if (scope === 'session') {
+      const [id, ...segments] = rest
+      if (id === undefined || id === '' || segments.length === 0) return undefined
+      return { scope, sessionId: decodeURIComponent(id), path: segments.map(decodeURIComponent).join('/') }
+    }
+    if (scope === 'absolute') {
+      // An empty first segment with more behind it is a UNC path's `//`; alone it is no path.
+      const unc = rest[0] === '' && rest.length > 1
+      const segments = (unc ? rest.slice(1) : rest).map(decodeURIComponent)
+      if (segments.length === 0 || segments[0] === '') return undefined
+      if (unc) return { scope, path: `//${segments.join('/')}` }
+      return { scope, path: isDriveSegment(segments[0]) ? segments.join('/') : `/${segments.join('/')}` }
+    }
+    return undefined
+  } catch {
+    // `new URL` throws TypeError on a non-URL and `decodeURIComponent` throws
+    // URIError on a malformed escape; both mean "not a file address".
+    return undefined
+  }
+}

+ 32 - 1
packages/util/workspace-path/src/index.ts

@@ -2,12 +2,22 @@
  * Browser-safe Workspace path and display helpers.
  * @module @deepseek-ai/dsh-util-workspace-path
  */
+import { absoluteFileAddress, sessionFileAddress } from './file-address.ts'
 
 /** Whether a path uses a Windows drive or UNC prefix. */
 function isWindowsStylePath(value: string): boolean {
   return /^[A-Za-z]:[/\\]/.test(value) || value.startsWith('\\\\')
 }
 
+/**
+ * Whether a path is absolute in either spelling the Host accepts: POSIX (`/a/b`) or Windows drive or UNC.
+ * @param path - the path to classify.
+ * @returns `true` for an absolute path; `false` for a Workspace-relative one.
+ */
+export function isAbsoluteWorkspacePath(path: string): boolean {
+  return path.startsWith('/') || isWindowsStylePath(path)
+}
+
 /**
  * Resolve a Workspace-relative path into the Host-facing spelling used by path operations.
  * @param cwd - Session Workspace root, when known.
@@ -15,7 +25,7 @@ function isWindowsStylePath(value: string): boolean {
  * @returns an absolute path when a Workspace root is available, otherwise the original path.
  */
 export function resolveWorkspacePath(cwd: string | undefined, path: string): string {
-  if (path.startsWith('/') || isWindowsStylePath(path)) return path
+  if (isAbsoluteWorkspacePath(path)) return path
   if (cwd === undefined || cwd === '') return path
   const separator = isWindowsStylePath(cwd) && cwd.includes('\\') ? '\\' : '/'
   const base = cwd.replace(/[/\\]+$/, '')
@@ -50,3 +60,24 @@ export function workspaceTitleOf(path: string): string {
   const separator = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
   return trimmed.slice(separator + 1)
 }
+
+export * from './file-address.ts'
+
+/**
+ * The address for a path as a caller holds it: a relative path, or an absolute
+ * path inside the Session's workspace, becomes a `session`-scoped address; an
+ * absolute path outside it, or one whose workspace root is unknown, becomes an
+ * `absolute`-scoped address.
+ * @param sessionId - the Session the path is read in.
+ * @param cwd - that Session's workspace root, when known.
+ * @param path - absolute or workspace-relative path, in either separator spelling.
+ * @returns the `dsh-resource://file/…` address.
+ */
+export function fileAddressFor(sessionId: string, cwd: string | undefined, path: string): string {
+  const normalized = path.replace(/\\/g, '/')
+  if (!isAbsoluteWorkspacePath(normalized)) return sessionFileAddress(sessionId, normalized)
+  const root = cwd === undefined ? '' : cwd.replace(/\\/g, '/').replace(/\/+$/, '')
+  if (root !== '' && normalized === root) return sessionFileAddress(sessionId, '')
+  if (root !== '' && normalized.startsWith(`${root}/`)) return sessionFileAddress(sessionId, normalized.slice(root.length + 1))
+  return absoluteFileAddress(normalized)
+}