# dsh-workspace-files 设计总览 ## 目标 给 DSH Web UI 一个轻量的工作区文件浏览面板: - 会话标题栏按钮开关,右侧面板,宽度可拖拽调整 - 懒加载文件树(根 = 会话工作区目录) - 递归文件名搜索,跳过 `.git` / `node_modules`(可配置) - Markdown 渲染 + 多语言语法高亮预览;**双击浮窗居中放大**(同款渲染,Esc/遮罩关闭) - 单文件读取上限、二进制检测;**任意文件一键下载**(二进制安全,attachment 响应头)——远程用户的文件拉取正路(DSH 原生"点击文件名打开"被官方 fence 钉死在 loopback,域名访问不可用) ## 非目标 - 文件编辑/写回(只读浏览;需要编辑用 dsh-file-explorer 或 better-sidebar) - 终端、Git、图片预览等工作台功能 - 全文内容搜索(只搜文件名/目录名) - 文件系统 watcher(手动刷新按钮) ## 工作原理 双半结构,同一 npm 包: ``` src/index.ts host 半部:HTTP 路由 + 双重围栏 src/trust-fence.ts host 纯函数:浏览器信任围栏 src/client/markdown.mjs 纯函数:Markdown 块/行内解析器(无依赖) src/client/highlight.mjs纯函数:多语言语法高亮分词器(无依赖) src/client/index.js client 半部:面板 UI(React.createElement,无 JSX) scripts/build-client.mjs把三个 client 源文件拼成 lib/client.js bundle ``` **Host 半部**(`inject: ['fs', 'webServer', 'sessions']`)在 `webServer` 上注册四个 exact 路由: | 路由 | 作用 | | --- | --- | | `GET /plugins/workspace-files/list?sid=&path=` | 列目录 | | `GET /plugins/workspace-files/read?sid=&path=` | 读文件(上限/二进制检测) | | `GET /plugins/workspace-files/download?sid=&path=` | 二进制安全下载(`fs.readBytes`,`attachment` 响应头,`maxDownloadBytes` 上限) | | `GET /plugins/workspace-files/search?sid=&q=` | 递归搜索文件名 | 每个请求过两道围栏(见下)。文件操作全部走官方 `fs` 服务抽象(`resolve` / `stat` / `listDir` / `readText` / `readBytes` / `contains` / `processPath`),不直接碰 node:fs。下载文件名用 RFC 5987 双段 `Content-Disposition`(ASCII 回退 + `filename*`),中文文件名完整保留;`contentDisposition` 纯函数有单测。 **Client 半部**经 `dsh.client` 声明进入 boot graph,bundle 以 `window.__ModuleLoader__.load` 工厂闭包注册(与官方 client-bundle preset 同构,由 `scripts/build-client.mjs` 产出)。React 通过模块表 `require("react")` 获得。注册两个 slot: - `shell.overlay`(list,root scope)——面板本体 - `conversation.session.header.actions`(list,session scope)——「🗂 文件」开关按钮 面板组件从 slot 标准属性拿会话事实:`props.useSessions(st => st.current)` 得当前会话,`props.useWorkspaces` 得工作区列表,包含当前会话的工作区 `path` 即文件树根。 **交互**(VS Code 式单击/双击分层):单击文件 → 面板右栏预览;双击 → 居中浮窗(`.wsfp-float-veil` 遮罩 + `.wsfp-float` 对话框,`position: fixed`;right-dock 无 transform 祖先,fixed 正确相对视口)。预览栏与浮窗的标题行都有下载按钮(`` 同源直链,浏览器走流式保存,不经 JS 内存);二进制/超限文件在提示语旁另有文字下载链接。浮窗内容与预览栏共用 `previewContent` 渲染器(模块级组件,避免每次父渲染重建组件身份导致重挂载)。 ## 安全模型 **双重围栏**,两个参考实现各取一半并合并: 1. **浏览器信任围栏**(借自 better-sidebar 的 trust-fence,其本身复刻官方 `/api` 网关):Host 头必须是回环地址(`localhost` / `[::1]` / `127.x.x.x`)或 `trustedHosts` 配置列出的授权项;`sec-fetch-site: cross-site` 与 Origin/Host 不匹配均拒绝。防 DNS 重绑定与跨站请求。与官方 `/api` 的差异:官方把 `host.openPath` 等特权方法钉死 loopback(`trustedHosts` 不放行),本插件的路由按整插件粒度信任 `trustedHosts`——远程可用性(预览/搜索/下载)与网关层认证(如 nginx basic auth + HTTPS)配套,见 ADR 0004。 2. **会话 cwd 围栏**(file-explorer 没有、本插件的改良点):文件作用域 = 请求会话的权威 cwd(`sessions.get(sid).header.cwd`,客户端只传 sid 不可指定根路径),所有路径经 `fs.resolve` 后用官方 `fs.contains(root, target)` 校验必须在 cwd 内,越界直接拒绝。配合 `skipDirs` 搜索预算(`maxSearchNodes` / `maxSearchMatches`)防止大目录打爆。下载同样过这两道围栏,且受 `maxDownloadBytes` 上限(`fs.readBytes` 的 cap 语义,超限 `FS_TOO_LARGE`)。 ## Markdown 渲染与语法高亮 无第三方依赖,全部自研纯函数(`src/client/markdown.mjs` + `highlight.mjs`),单测覆盖: - Markdown:ATX 标题、围栏代码块、引用、有序/无序列表(一层嵌套)、管道表格、分隔线、段落;行内——代码、粗体、斜体、删除线、链接、图片、自动链接 - 高亮:按语言 profile 的单遍分词器(行/块注释、字符串、数字、关键字/内建、函数调用后置识别、yaml/toml key 着色),覆盖 js/ts/json/py/sh/ps1/yaml/toml/ini/css/sql/go/rs/java/c 及常用别名;GitHub 风格双主题配色(`body[data-ds-dark-theme]` 切换暗色板) 取舍:这不是完整 CommonMark / Tree-sitter——目标是 README 级文档的可读渲染,不是精确解析器。 ## 边界与限制 - 预览走 `fs.readText` 全量读取(≤ maxReadBytes),大文件不预览但可下载(≤ maxDownloadBytes) - 下载响应整体缓冲在内存(`readBytes` 一次性返回),超大约 200 MB 的文件应调大 `maxDownloadBytes` 前先想内存;无流式下发 - 无 watcher:外部修改文件后需手动刷新 - 搜索只匹配文件名/目录名(大小写不敏感子串) - `Session log` 等含路径按钮不做联动(无文件打开拦截) - 客户端半部变更需重新构建 bundle;DSH 对 client 插件有热重载接收器,但 host 半部变更需重启 ## 决策记录 见 [decisions/](decisions/): - [0001-transport-http-routes.md](decisions/0001-transport-http-routes.md) — 数据通道选 HTTP 路由而非 Package 私有 RPC - [0002-session-cwd-fence.md](decisions/0002-session-cwd-fence.md) — 会话 cwd 作为文件作用域围栏 - [0003-self-contained-rendering.md](decisions/0003-self-contained-rendering.md) — 自研 Markdown/高亮而非引入依赖 - [0004-remote-trusted-download.md](decisions/0004-remote-trusted-download.md) — 远程域名放行(trustedHosts)+ 下载路由的取舍