Files
apes-sidebar/src/client/api.ts
T
Menghuan1918 5c95002641 perf(sidebar): fs.tree 22.8ms→3.8ms + 删除工作区路径检测(放开权限)+ 树展开状态按会话
用户实测反馈:

1) `/sidebar/api/fs.tree` 性能极差 —— 先测后改(10k 条目目录、热缓存、中位数):
   - 冷调用 cap=1000:22.76ms → **3.77ms**;TTL 内重复:≈23ms → **0.000ms**
   - 改法:opendir for-await → readdir(withFileTypes) 一次取回;只为 cap 条建行
     (slice + 定长数组);路径用字符串拼接而非 path.join;排序弃 localeCompare
     (14.6ms/10k → 0.35ms)改小写比较 + 原名码点兜底;新增 1.5s 目录级缓存
     (键 绝对路径\0cap,上限 512),由 fs.write/rename/remove/mkdir/upload 与
     fs-watch 的变更通知显式失效
   - 契约逐字不变;新增 tests/fs-tree.spec.ts 6 例 + fs-watch 失效用例(判别性验证)

2) 删除工作区路径检测(默认不检测、放开权限):
   - path-security 变纯词法解析(realpath/包含性判定删除)、fs-operations 去 fence
     分支、所有 fs 路由不再读 fence、403 分支消失、config 删 workspaceFence 字段
     (schemastery 容忍未知键,旧 profile 不会加载失败)、settings 开关删除
   - ⚠️ 安全影响:插件 fs 路由(tree/read/write/rename/remove/mkdir/媒体/HTML/
     upload/archive)现在可读写宿主用户可访问的任意路径,只受 OS 权限约束;
     指南 §8.1 已加醒目声明
   - 死代码清理:FenceErrorNotice 组件 + EditorHost 分支 + 4 个词条(settingsFence*、
     fenceErrorReason、fenceDisableAction)+ isOutsideWorkspace* 助手 + TreePanel 的
     store prop + 相关 CSS 块

3) 批量列举路由 `fs.trees`(单层失败只在该层带 error)+ 客户端批量加载:
   挂载/刷新时的 N+1 请求变为 1 个(8 个展开目录:9 → 1,探针断言)
   refreshTick 不再先清缓存(旧行保留到新答案;失败保留旧行 + warn 提示)

4) 修复「关闭文件后返回文件树,展开状态被重置」:
   原生承载面的展开集合原来存在每个 tab 的记录里(drop 即丢),改为**每会话状态**
   的投影(ensure 从会话态读、toggleExpanded 写回会话态、registerNativeSurface 用
   attachStore 订阅);同一会话多 tab 共享、不同会话隔离;revealed 保持 per-tab

验证:typecheck / lint / test(136 文件 / 1484 通过 / 9 跳过)/ build 全绿;
DSH_CMD='npx -y --package @deepseek-ai/dsh@0.1.7-rc.1 dsh' pnpm test:mount 7/7 通过;
版本镜像同步(dsh.plugin.json + SIDEBAR_SERVICE_VERSION)
2026-09-28 20:37:56 +08:00

502 lines
23 KiB
TypeScript

/**
* Typed fetch wrapper over the /sidebar JSON API. Every call posts to
* `/sidebar/api/<method>` with the sessionId and — when known — the session's
* cwd from the client's own list summary. The host prefers its attached
* session header and uses the summary cwd only while the session is still
* hydrating at page load (a detached session would otherwise fail the
* request). Failures surface as {@link SidebarApiError} with the wire code.
*/
import { encodeHtmlUrl } from '../html-route.ts'
import type { SidechatLiveEvent, SidechatLogEvent, SidechatThreadInfo } from '../sidechat-core.ts'
import type {
SidebarCreateTeamTaskRequest,
SidebarChildLiveView,
SidebarSessionEvent,
SidebarTeamTaskView,
SidebarUpdateTeamTaskRequest,
} from '../context-types.ts'
import type { WorkflowRunView } from '../workflow-runs.ts'
/** One wire failure. */
export class SidebarApiError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message)
}
}
/** Explorer row (host fs-tree shape). */
export interface FsEntry {
name: string
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 level of a `fs.trees` batch: a listing, or that level's failure. */
export interface FsLevel {
path: string
entries: FsEntry[]
truncated: boolean
/** Present only when THIS level failed (the batch itself succeeded). */
error?: string
}
/** Git status entry (host git shape). */
export interface GitStatusEntry {
path: string
xy: string
}
/** Git status snapshot. */
export interface GitStatusResult {
isRepo: boolean
branch?: string
entries: GitStatusEntry[]
/** True when the host capped `entries` (huge untracked set); the panel
* shows a truncation notice instead of freezing (#369). */
truncated?: boolean
root?: string
repositories?: string[]
}
/** One linked Git checkout. */
export interface GitWorktree {
path: string
branch: string
current: boolean
changes: number
}
/** One git log row. */
export interface GitLogEntry {
/** Short hash (7+ chars, display). */
hash: string
/** Full 40-char hash (advanced operations). */
hashFull: string
subject: string
author: string
/** ISO 8601 author date (`%ai`). */
date: string
/** Ref decorations (--decorate=short), e.g. `HEAD -> main, origin/main`; '' when none. */
refs: string
}
/** Text read result. */
export interface FsTextResult { kind: 'text'; content: string; truncated: boolean }
/** Binary read result (no content; images load through the media route).
* `head` carries the first bytes (base64) for viewer detect sniffing. */
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean; head: string }
/** The `subagents.live` response: one row per tree child (and the root). */
export type SubagentLiveResult = { live: Record<string, SidebarChildLiveView> }
/** The `workflows.list` response: the tree's folded workflow runs. */
export type WorkflowsListResult = { runs: WorkflowRunView[] }
/** The `teams.taskCreate` request payload (minus the rootSessionId). */
export type TeamsTaskCreateRequest = SidebarCreateTeamTaskRequest
/** The `teams.taskUpdate` request payload (minus the rootSessionId). */
export type TeamsTaskUpdateRequest = SidebarUpdateTeamTaskRequest
/**
* One team-task write as CALLERS consume it. The board's READ path has no
* route at all (it rides the Lead Session's `agentTeam` projection, see
* team-projection.ts), and a rejected write is a RESULT rather than an
* exception: the route answers 409 `team-conflict` for a stale revision and
* 400 `team-error` otherwise, {@link writeTask} turns that failed envelope
* back into `{ok:false, code, message}`, and the task window routes on the
* code (a stale revision deserves its own wording, not "operation failed").
*/
export type TeamsTaskMutationResult =
| { ok: true; value: SidebarTeamTaskView }
| { ok: false; code?: string; message: string }
/**
* Parse one `/sidebar` JSON response envelope into its value. A non-ok
* status, an unparseable body, or any shape other than `{ok: true, value}`
* surfaces as {@link SidebarApiError} carrying the wire code (falling back
* to the HTTP status). Shared by the JSON api route and the raw upload
* route, whose envelopes are identical.
*/
async function readEnvelope<T>(response: Response): Promise<T> {
const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null
= await response.json().catch(() => null)
if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) {
throw new SidebarApiError(
parsed?.error?.code ?? 'http',
parsed?.error?.message ?? `HTTP ${response.status}`,
)
}
return parsed.value as T
}
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
let response: Response
try {
response = await fetch(`/sidebar/api/${method}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
signal,
})
} catch (error) {
throw new SidebarApiError('network', error instanceof Error ? error.message : String(error))
}
return readEnvelope<T>(response)
}
/**
* One team-task write with the rejection turned back into a RESULT. Every
* other `/sidebar/api` caller lets a failed envelope throw (that is what
* {@link readEnvelope} is for); here the route's error CODE is data the task
* window needs — `team-conflict` means "someone changed this task, refresh",
* which is a different sentence from every other failure.
* @param method - `teams.taskCreate` or `teams.taskUpdate`.
* @param payload - the request plus its `rootSessionId`.
*/
async function writeTask(
method: string,
payload: Record<string, unknown>,
): Promise<TeamsTaskMutationResult> {
try {
return { ok: true, value: await call<SidebarTeamTaskView>(method, payload) }
} catch (error) {
if (error instanceof SidebarApiError) {
return { ok: false, code: error.code, message: error.message }
}
throw error
}
}
/**
* Upload one file to the sidebar's raw upload route: the File goes straight
* into the POST body (no JSON/base64 re-encoding — the host streams it into
* the workspace). Failure surfaces as {@link SidebarApiError} with the wire
* code, exactly like every `/sidebar/api` call. An aborted `signal` rejects
* with the DOMException as-is (the caller decides whether that is an error).
*/
async function fetchUpload<T>(
scope: SessionScope,
dir: string,
relativePath: string,
body: Blob,
signal?: AbortSignal,
): Promise<T> {
const params = new URLSearchParams({ sessionId: scope.sessionId, dir, relativePath })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
let response: Response
try {
response = await fetch(`/sidebar/upload?${params.toString()}`, {
method: 'POST',
headers: { 'content-type': 'application/octet-stream' },
body,
signal,
})
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') throw error
throw new SidebarApiError('network', error instanceof Error ? error.message : String(error))
}
return readEnvelope<T>(response)
}
/** One request's session scope: the conversation id plus its cwd when known. */
export interface SessionScope {
sessionId: string
/** The session's working directory from the client list summary (optional). */
cwd?: string
/** Selected Git repository when cwd is a workspace container. */
repoRoot?: string
}
/** Fold a scope into a JSON payload ({cwd} only when present). */
function scopePayload(scope: SessionScope, extra: Record<string, unknown>): Record<string, unknown> {
return {
sessionId: scope.sessionId,
...(scope.cwd !== undefined && scope.cwd !== '' ? { cwd: scope.cwd } : {}),
...(scope.repoRoot !== undefined && scope.repoRoot !== '' ? { repoRoot: scope.repoRoot } : {}),
...extra,
}
}
/** Add a linked-worktree selection to a scoped Git request. The host validates
* membership before using it as a command cwd. */
function gitPayload(scope: SessionScope, worktree: string | undefined, extra: Record<string, unknown>): Record<string, unknown> {
return scopePayload(scope, { ...(worktree !== undefined && worktree !== '' ? { worktree } : {}), ...extra })
}
/** One external-open request from the file tree. */
type OpenExternalPayload =
| { action: 'reveal'; path: string }
| { action: 'url'; url: string }
/** The host route's success shape. */
type OpenExternalResult = { started: boolean }
/**
* Remote VSCode-family URLs must be consumed on the browser/client machine:
* the DSH host can be a headless remote server with no editor or DISPLAY.
* Local editor URLs and reveal actions still belong to the host opener.
*/
function shouldOpenExternalOnClient(payload: OpenExternalPayload): payload is { action: 'url'; url: string } {
if (payload.action !== 'url') return false
let parsed: URL
try {
parsed = new URL(payload.url)
} catch {
return false
}
return parsed.protocol !== 'http:'
&& parsed.protocol !== 'https:'
&& parsed.hostname === 'vscode-remote'
&& parsed.pathname.startsWith('/ssh-remote+')
}
/**
* Dispatch an external-open request to the correct machine. SSH remote-editor
* URLs stay in the synchronous user-click chain and navigate the client so
* its registered vscode:// / cursor:// handler can launch. Everything else
* keeps using the DSH host route.
*/
function openExternal(payload: OpenExternalPayload): Promise<OpenExternalResult> {
if (!shouldOpenExternalOnClient(payload)) {
return call<OpenExternalResult>('open.external', payload)
}
try {
window.location.assign(payload.url)
return Promise.resolve({ started: true })
} catch (error) {
return Promise.reject(error)
}
}
/** The sidebar API surface (session scope threaded through every call). */
export const api = {
sessionCwd: (scope: SessionScope, signal?: AbortSignal) =>
call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal),
fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', scopePayload(scope, { path }), signal),
/**
* Batch listing: every requested level in ONE request (the tree's mount and
* refresh send the expanded set instead of N `fsTree` calls). A level that
* failed carries `error` in place — the batch itself still succeeds.
*/
fsTrees: (scope: SessionScope, paths: readonly string[], signal?: AbortSignal) =>
call<{ levels: FsLevel[] }>('fs.trees', scopePayload(scope, { paths: [...paths] }), signal),
/** Global recursive file-name search rooted at the session cwd (the editor
* side panel's search box); matches are cwd-relative '/'-separated paths. */
fsSearch: (scope: SessionScope, query: string, signal?: AbortSignal) =>
call<{ matches: string[]; truncated: boolean }>('fs.search', scopePayload(scope, { query }), signal),
fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
fsWrite: (scope: SessionScope, path: string, content: string) =>
call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })),
/** Rename one tree row within its directory (single-segment name; the
* server refuses existing destinations, the workspace root, and — while
* the fence is armed — anything resolving outside the workspace). */
fsRename: (scope: SessionScope, path: string, name: string) =>
call<{ path: string }>('fs.rename', scopePayload(scope, { path, name })),
/** Permanently delete one tree row (recursive for directories; a symlink
* row unlinks the link only). The UI confirms before calling this. */
fsRemove: (scope: SessionScope, path: string) =>
call<{ path: string }>('fs.remove', scopePayload(scope, { path })),
/** Create one directory row inside `path` (single-segment name; the server
* refuses existing destinations, the workspace root, and — while the fence
* is armed — anything resolving outside the workspace). */
fsMkdir: (scope: SessionScope, path: string, name: string) =>
call<{ path: string }>('fs.mkdir', scopePayload(scope, { path, name })),
/** Upload one file's raw bytes into `dir` (keeps the folder tree via
* `relativePath`); the host streams it under the session workspace. */
uploadFile: (scope: SessionScope, dir: string, relativePath: string, body: Blob, signal?: AbortSignal) =>
fetchUpload<{ path: string; size: number }>(scope, dir, relativePath, body, signal),
gitWorktrees: (scope: SessionScope, signal?: AbortSignal) =>
call<GitWorktree[]>('git.worktrees', scopePayload(scope, {}), signal),
gitStatus: (scope: SessionScope, worktree?: string, signal?: AbortSignal) =>
call<GitStatusResult>('git.status', gitPayload(scope, worktree, {}), signal),
gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, worktree?: string, signal?: AbortSignal) =>
call<{ diff: string }>('git.diff', gitPayload(scope, worktree, { ...(path !== undefined ? { path } : {}), staged }), signal),
gitStage: (scope: SessionScope, path?: string, worktree?: string) =>
call<{ ok: true }>('git.stage', gitPayload(scope, worktree, { ...(path !== undefined ? { path } : {}) })),
gitUnstage: (scope: SessionScope, path?: string, worktree?: string) =>
call<{ ok: true }>('git.unstage', gitPayload(scope, worktree, { ...(path !== undefined ? { path } : {}) })),
gitCommit: (scope: SessionScope, message: string, worktree?: string) =>
call<{ ok: true }>('git.commit', gitPayload(scope, worktree, { message })),
gitBranch: (scope: SessionScope, worktree?: string, signal?: AbortSignal) =>
call<{ current: string; names: string[] }>('git.branch', gitPayload(scope, worktree, {}), signal),
gitCheckout: (scope: SessionScope, branch: string, worktree?: string) =>
call<{ ok: true }>('git.checkout', gitPayload(scope, worktree, { branch })),
/** Recent commit history, lazily pageable (skip/count; defaults 0/30). */
gitLog: (scope: SessionScope, count?: number, skip?: number, worktree?: string, signal?: AbortSignal) =>
call<GitLogEntry[]>('git.log', gitPayload(scope, worktree, {
...(count !== undefined ? { count } : {}),
...(skip !== undefined ? { skip } : {}),
}), signal),
/** Full patch text of one commit (diff display for the history rows). */
gitCommitDiff: (scope: SessionScope, hash: string, worktree?: string, signal?: AbortSignal) =>
call<{ diff: string }>('git.commit-diff', gitPayload(scope, worktree, { hash }), signal),
/** One file's content at a revision (`git show <rev>:<path>`); null when the
* revision has no such path. The diff views' on-demand hunk-fold expansion
* reads both sides' full contents through this. */
gitShow: (scope: SessionScope, rev: string, path: string, worktree?: string, signal?: AbortSignal) =>
call<{ content: string | null }>('git.show', gitPayload(scope, worktree, { rev, path }), signal),
/** The session's file-tool events for the changes tab's session lens: the
* `tool/call` + `tool/result` rows past `afterSeq` (0 = whole window),
* capped to the recent window host-side. The client runtime exposes no
* event-log face, so the lens polls this delta route. */
changesOps: (scope: SessionScope, afterSeq?: number, signal?: AbortSignal) =>
call<{ events: SidebarSessionEvent[]; lastSeq: number }>('changes.ops', scopePayload(scope, {
...(afterSeq !== undefined && afterSeq > 0 ? { afterSeq } : {}),
}), signal),
/** Discard the worktree changes of one file (the index is untouched). */
gitDiscard: (scope: SessionScope, path: string, worktree?: string) =>
call<{ ok: true }>('git.discard', gitPayload(scope, worktree, { path })),
/** Revert one commit onto the current branch. */
gitRevert: (scope: SessionScope, hash: string, worktree?: string) =>
call<{ ok: true }>('git.revert', gitPayload(scope, worktree, { hash })),
/** Cherry-pick one commit onto the current branch. */
gitCherryPick: (scope: SessionScope, hash: string, worktree?: string) =>
call<{ ok: true }>('git.cherry-pick', gitPayload(scope, worktree, { hash })),
/**
* One batch live-preview fetch for the whole Subagent tree. The payload is
* the already-resolved topology ROOT (not a session scope); the host
* enumerates descendants once and folds every child's newest process range
* into the main agent's merged-activity summary.
*/
subagentsLive: (rootSessionId: string, signal?: AbortSignal) =>
call<SubagentLiveResult>('subagents.live', { rootSessionId }, signal),
/**
* The workflow runs of the whole tree, folded host-side from the
* `tool-workflow/*` session events (the same four types the official
* workflow-run panel folds). Empty when the tree never ran a workflow —
* absence is the normal case, never an error.
*/
workflowsList: (rootSessionId: string, signal?: AbortSignal) =>
call<WorkflowsListResult>('workflows.list', { rootSessionId }, signal),
/** Create one shared task on the root-led team. */
teamsTaskCreate: (rootSessionId: string, req: TeamsTaskCreateRequest) =>
writeTask('teams.taskCreate', { rootSessionId, ...req }),
/** CAS-mutate one shared task; a stale revision yields `team-conflict`. */
teamsTaskUpdate: (rootSessionId: string, req: TeamsTaskUpdateRequest) =>
writeTask('teams.taskUpdate', { rootSessionId, ...req }),
/** Create a Side Chat thread: a child session seeded with the parent's
* full log up to now. Empty question = immediate create (Codex-style):
* the thread opens empty, the first prompt carries the boundary. */
sidechatStart: (sessionId: string, question?: string) =>
call<{ childId: string }>('sidechat.start', { sessionId, question: question ?? '' }),
/** Deliver one follow-up message to a Side Chat thread. */
sidechatPrompt: (childId: string, text: string) =>
call<{ accepted: true }>('sidechat.prompt', { childId, text }),
/** Abort a Side Chat thread's running turn (queued work is preserved). */
sidechatCancel: (childId: string) =>
call<{ accepted: true }>('sidechat.cancel', { childId }),
/** Release a Side Chat thread's live agent (history stays persisted). */
sidechatDispose: (childId: string) =>
call<{ accepted: true }>('sidechat.dispose', { childId }),
/** Live state + agent identity (provider/model/preset) of a thread. */
sidechatInfo: (childId: string) =>
call<SidechatThreadInfo>('sidechat.info', { childId }),
/** One transcript pull of a Side Chat thread: the thread's OWN events
* (the inherited seed is cut host-side and never crosses the wire).
* `afterSeq` narrows the response to the delta beyond it (poll tail).
* `live` is the thread's in-flight model deltas (DSH 0.1.5 publishes them
* outside the session log) — the CURRENT attempt on every pull, never a
* delta, so the caller replaces its live set instead of appending. */
sidechatEvents: (childId: string, afterSeq?: number, signal?: AbortSignal) =>
call<{ events: SidechatLogEvent[]; live: SidechatLiveEvent[] }>('sidechat.events', {
childId,
...(afterSeq !== undefined ? { afterSeq } : {}),
}, signal),
/** Read the side card preferences (plugin-global, no session scope). */
settingsGet: () =>
call<{ value?: unknown; revision?: number; externalDisable?: boolean }>('settings.get', {}),
/** Merge a patch into the side card preferences (revision-guarded). */
settingsUpdate: (patch: Record<string, unknown>, expectedRevision?: number) =>
call<{ value?: unknown; revision?: number }>('settings.update', {
patch,
...(expectedRevision !== undefined ? { expectedRevision } : {}),
}),
/** External open for the file tree's "open with" menu. Remote SSH editor
* URLs are launched on the browser/client machine; reveal and local URLs
* keep using the host's platform opener. */
openExternal,
}
/** Absolute URL of the media route for one path (images only). */
export function mediaUrl(scope: SessionScope, path: string): string {
return fileUrl(scope, path, false)
}
/** Absolute URL of the download route: serves raw bytes (binary-safe) with
* `Content-Disposition: attachment`, so the browser saves the file. */
export function downloadUrl(scope: SessionScope, path: string): string {
return fileUrl(scope, path, true)
}
/**
* Start one archive build. The host fences and walks the selection, then
* returns an id IMMEDIATELY — the zip itself is produced in the background and
* polled through {@link api.archiveStatus}. `paths` are absolute paths in the
* session's namespace (the same values `fsTree` / `downloadUrl` take).
*/
export function archiveBuild(
scope: SessionScope,
paths: readonly string[],
name: string,
): Promise<{ id: string; entries: number }> {
return call<{ id: string; entries: number }>('archive.build', scopePayload(scope, { paths: [...paths], name }))
}
/** One archive build's progress (the shape `archive.status` returns). */
export interface ArchiveBuildStatus {
state: 'building' | 'ready' | 'error'
/** Entries finished so far. */
done: number
/** Entries the archive will contain. */
total: number
/** Uncompressed bytes read so far. */
bytes: number
/** Present only when `state === 'error'`. */
error?: string
}
/** Poll one archive build (its id came from {@link archiveBuild}). */
export function archiveStatus(id: string): Promise<ArchiveBuildStatus> {
return call<ArchiveBuildStatus>('archive.status', { id })
}
/**
* Absolute URL of one FINISHED archive's bytes (the `archive.status` result
* must be `ready` first): GET /sidebar/archive?id=…&sessionId=… The session
* scope rides along because the host answers only the session that built it.
*/
export function archiveDownloadUrl(scope: SessionScope, id: string): string {
return `/sidebar/archive?${new URLSearchParams({ sessionId: scope.sessionId, id }).toString()}`
}
/** Shared URL builder for the /sidebar/file route (media vs download). */
function fileUrl(scope: SessionScope, path: string, download: boolean): string {
const params = new URLSearchParams({ sessionId: scope.sessionId, path })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
if (download) params.set('download', '1')
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. 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)
}