Files
apes-sidebar/src/client/resource-address.ts
T
Menghuan1918 0b6ac5d267 feat(dsh): adapt to DSH 0.1.5-alpha.2
Baseline moves to DSH 0.1.5-alpha.2 only (peer floor ^0.1.5-alpha.2,
CI pin, manifest engines); alpha.1 users stay on v0.19.0-alpha.0.

Host-contract changes handled:
- the native guide entry lost its `description` field (entries are
  icon+title capsules now): `TabDescriptor.description` and the six
  `guideDesc*` keys plus the `nativeGuideDesc` fallback are removed.
- the file address grammar changed: `fileAddressFor` always returns a
  session-scoped address (absolute paths keep their leading `/`),
  `parseFileAddress` is prefix-based and strips `?`/`#`. The plugin's
  purity-gated mirror follows.
- global main panels replaced the `conversation` root slot with the
  keyed `main` slot (`main.conversation`): the bottom workbench's
  center-column locator now accepts both keys and skips
  `display: contents` slot hosts.

Fixes a pre-existing fill bug the alpha.2 probe made obvious: the native
tab body host is a block scroller with a definite height, so tab roots
that only declared `flex: 1` collapsed to content height and the side
chat composer drifted off the pane bottom. The native adapter now wraps
every tab body in a `height: 100%` column flex host.

Also promotes the dependencies `dsh-client-ui-primitives@0.1.5-alpha.2`
dropped from its manifest (anser / shiki / mdast-util-* / micromark-* /
katex / @shikijs/langs) and the newly unmet `dsh-session-persistence`
peer into devDependencies.
2026-09-09 23:09:48 +08:00

151 lines
7.0 KiB
TypeScript

/**
* The DSH resource-address grammar for files (`dsh-resource://file/…`).
*
* DSH's right Sidebar opens content by ADDRESS, and a file's address IS the
* tab's content identity: the same address is the same tab. Two scopes exist:
*
* - `dsh-resource://file/session/<sessionId>/<path>` names a file by its path
* relative to that session's workspace root OR by its absolute path kept
* absolute inside the session scope (`src/a.ts` vs `/outside/a.ts` — the
* leading `/` survives; 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 (`absolute/home/me/x.txt`; Windows
* `absolute/C:/x/y.txt`; a UNC path keeps an empty first segment,
* `absolute//server/share/x.txt`). It carries no session. `fileAddressFor`
* no longer produces this scope on DSH 0.1.5-alpha.2, but it must keep
* parsing: legacy and third-party addresses still spell it.
*
* 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.
*
* The plugin parses these addresses itself instead of importing
* `@deepseek-ai/dsh-util-workspace-path`: the client bundle's purity gate
* forbids value-importing an unlisted `@deepseek-ai/*` package. This module
* mirrors that package's implementation — `packages/util/workspace-path/src/file-address.ts`
* and `fileAddressFor` in `packages/util/workspace-path/src/index.ts` in
* DSH 0.1.5-alpha.2 (github.com/deepseek-ai/deepseek-harness, tag
* `dsh-v0.1.5-alpha.2`) — and is pinned by tests/resource-address.spec.ts.
*/
/** The scheme and type every file address opens with. */
export const FILE_ADDRESS_PREFIX = 'dsh-resource://file/'
/**
* A file resource address, in one of two scopes.
*/
export type FileAddress =
| {
readonly scope: 'session'
/** The session whose workspace root resolves the path. */
readonly sessionId: string
/** Absolute or workspace-relative `/`-separated path; empty for the workspace root itself. */
readonly path: string
}
| {
readonly scope: 'absolute'
/** Absolute `/`-separated path: `/a/b` on POSIX, `C:/a/b` for a drive, `//server/share/a` for UNC. */
readonly path: string
}
/** 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 read through one session.
* @param sessionId - the session whose workspace root resolves the path.
* @param path - absolute or workspace-relative path; backslashes are normalized
* to `/`, and leading `./` prefixes are dropped (a leading `/` is KEPT: an
* absolute path stays absolute inside the session scope).
* @returns the `dsh-resource://file/session/<sessionId>/<path>` address.
*/
export function sessionFileAddress(sessionId: string, path: string): string {
const normalized = path.replace(/\\/g, '/').replace(/^(?:\.\/)+/, '')
return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(normalized)}`
}
/**
* 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 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 without resolving `.` or `..`.
* Query and fragment suffixes are ignored; encoded path segments are decoded.
* @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 {
if (!address.startsWith(FILE_ADDRESS_PREFIX)) return undefined
const end = address.search(/[?#]/)
const [scope, ...rest] = address.slice(FILE_ADDRESS_PREFIX.length, end === -1 ? undefined : end).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 {
// `decodeURIComponent` throws URIError on a malformed escape.
return undefined
}
}
/** Whether a path is absolute in either spelling the host accepts. */
function isAbsolutePath(path: string): boolean {
return path.startsWith('/') || /^[A-Za-z]:[/\\]/.test(path) || path.startsWith('\\\\')
}
/**
* The address for a path as a caller holds it: ALWAYS session-scoped. A
* relative path, or an absolute path inside the session's workspace, becomes
* the relative spelling; an absolute path outside it, or one whose workspace
* root is unknown, keeps its absolute path in that session's address. (Mirror
* of `fileAddressFor` — on alpha.2 this helper no longer produces the
* `absolute` scope, though such addresses still parse.)
* @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, 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 (!isAbsolutePath(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 sessionFileAddress(sessionId, normalized)
}