DeepSeek Harness Plugin Hub

Publish and manage complete Harness Profiles. Discover Plugins for your next setup.

Explore

PluginsPresetsDocsNews

Community

Publish a pluginContactReport an issue

Resources

Plugin Hub on GitHubDeepSeek HarnessSystem statusPrivacy notice
© 2026 DeepSeek Harness Plugin HubPowered byPaxTech

Independent and unofficial. Not affiliated with, authorized by, or endorsed by DeepSeek.

DeepSeek Harness Plugin Hub
ProfilesPluginsCategoriesNewsDocsSign inManage Profiles
ProfilesPluginsCategoriesNewsDocsSign in
← Plugins

@weniatall/dsh-file-viewer

File Viewer

Workspace file viewer and Git panel for DeepSeek Harness: file tree, syntax-highlighted code, sandboxed HTML preview, images, PDF, Excel and Word, plus local/remote branches, paged commit history, commit diffs, working-tree changes and changed-file naviga

The plugin will be installed here. Keep web if you are unsure.

npx -y @deepseek-ai/dsh plugin --profile web add @weniatall/dsh-file-viewer@0.3.59
READMECompatibilityVersions

Compatibility and provenance

File Viewer is published as @weniatall/dsh-file-viewer and currently resolves to version 0.3.59. The Hub verifies its manifest and preserves the exact installation source for reproducible installs.

DSH compatibility
*
Runtime surfaces
web
Release source
npm
Registry updated
11/4/2026

Versions

0.3.59stable
11/4/2026
0.3.58stable
11/4/2026
0.3.43stable
11/3/2026
Show 27 more versionsCollapse versions
0.3.39stable
10/27/2026
0.3.28stable
10/26/2026
0.3.25stable
10/26/2026
0.3.24stable
10/26/2026
0.3.23stable
10/26/2026
0.3.22stable
10/25/2026
0.3.21stable
10/25/2026
0.3.18stable
10/24/2026
0.3.17stable
10/23/2026
0.3.14stable
10/23/2026
0.3.13stable
10/23/2026
0.3.12stable
10/23/2026
0.3.8stable
10/23/2026
0.3.7stable
10/23/2026
0.3.6

Related plugins

Loading related plugins…

Latest
0.3.59
DSH
*
HMR
Process restart
Tree shaking
Safe tree shaking not declared
Unpacked size
775.7 kB
Files
7
Surface
web
License
MIT
Source
npm
GitHub
★ 2
Weekly downloads
567
Last push
11/4/2026
View source ↗Project homepage ↗
README badge

Click the badge to copy Markdown for your README.

Do you maintain this Plugin?Claim benefit · Priority security scan

Verify the GitHub repository declared in package.json to manage this listing. After you claim it, Hub will prioritize a security scan of the current version and publish the result when it passes.

Claim this Plugin →
Report an issue
stable
10/22/2026
0.3.5stable
10/22/2026
0.3.4stable
10/21/2026
0.3.3stable
10/20/2026
0.3.2stable
10/20/2026
0.3.1stable
10/20/2026
0.2.12stable
10/20/2026
0.2.11stable
10/20/2026
0.2.10stable
10/20/2026
0.2.9stable
10/19/2026
0.2.8stable
10/19/2026
0.2.7stable
10/19/2026
0.2.6stable
10/19/2026

Related plugins

More verified plugins in developer-tools.

Web App@deepseek-ai/dsh-web-appThe dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)Sdk Minimal@deepseek-ai/dsh-sdk-minimalThe standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, and JSONL sessionsSdk App@deepseek-ai/dsh-sdk-appThe dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-baseExperimental Inspector@deepseek-ai/dsh-experimental-inspectorExperimental cross-realm CDP hub for Host debugging and Client Runtime inspection

README

dsh-file-viewer

在 DeepSeek Harness 里查看工作区文件:左侧目录树,右侧按格式渲染内容。

支持的格式:

格式渲染方式
代码、配置、纯文本语法高亮(复用 DSH 内置 shiki,26 种语言),带行号,按 500 行分页
Markdown预览(GFM + 公式)与源码切换
JSON可展开的结构树与源码切换
图片PNG、JPEG、GIF、WebP、AVIF、BMP、ICO、SVG
PDF交给浏览器内置阅读器(可搜索、翻页、缩放)
Excel.xlsx / .xlsm / .xls,工作表标签 + 表格
Word.docx / .doc,转 Markdown / 提取文本后渲染
其他二进制显示文件信息,可用本地程序打开

安装

dsh plugin --profile web add <release-tgz-url>

重启生效

@deepseek-ai/dsh 官方 CLI 本身没有 service 子命令,执行 dsh service restart 会报错。请使用本仓库自带的控制脚本:

  • macOS / Linux:
    ./scripts/dsh-web.sh restart
    
  • Windows:
    .\scripts\dsh-web.ps1 restart
    # 或 cmd:
    scripts\dsh-web.cmd restart
    

装完确认 profile 的 dsh.profile.bundles 里有 dsh-file-viewer,否则插件不会加载。

使用

  • 唤起入口:
    • PC 端输入框工具栏:严禁悬浮按钮;文件查看器按钮干净聚焦地内嵌于输入框操作条左侧(与快捷命令 + 号、权限模式标签并列),在新建空白会话(居中的 Hero 输入框)与常规对话(底部常驻输入框)全场景始终清晰可见且不遮挡内容;
    • 移动端原生集成:严禁悬浮按钮;手机端通过底部工作区工具箱菜单(四宫格图标 -> “📁 文件查看器”)或顶部适配栏原生唤出;
    • 会话中点击文件:在会话回复(变更文件卡片、产物交付卡片、行内代码引用等)中点击任何文件路径,自动唤起文件查看抽屉并直接选中预览该文件。
    • 卡片的展开按钮不算文件:改动卡片与交付卡片底部那个「全部 N 个文件 / 收起」按钮,点了是展开列表,不会被当成交付文件而弹出查看器。判据是它是带 aria-expanded 的 disclosure 控件——卡片容器本身仍然算点击区域(点卡片里的文件名照样打开),只是容器里的这个开关被单独排除。
    • 每一条入口都经同一条通道:无论点的是行内文件名、工具卡片的 fileLink、引用/技能卡片、改动卡片的 diff 面板,还是交付卡片,宿主最终都会调用 sidebarRight.openResource(fileAddressFor(...));改动卡片走 changes-review,计划卡片走 plan。插件包装的正是这个函数,所以新出现的卡片只要仍走这条通道就自动覆盖——详见下一节。
  • Esc 或点击抽屉外部遮罩关闭。

桌面端接管说明:桌面版宿主判定自己能在系统里打开路径,因此会在文件路径与更改卡片旁额外渲染原生「在应用中打开」按钮,更改卡片还会走 /api/changes.open 这条直接交给操作系统的路由。插件在这两处都会先接管,改为唤起文件查看抽屉:原生打开按钮按 data-open-path-open 识别,更改卡片打开路由则从 URL 里的 sessionId / seq / index 回查文件路径。取不到路径时一律放行,仍按宿主原方式打开,不会出现点了没反应。

覆盖范围:宿主的每种"类文件卡片"

宿主的客户端由 54 个 dsh-client-ui-* 包组成,逐包审计过它们打开文件的方式。结论是所有打开最终都汇聚到一个函数,这也是插件选择包装它的原因:

入口宿主调用地址形态覆盖方式
行内文件名、@文件 提及、工具卡片 fileLinkchat → openFilefile点击拦截 + openResource 包装
引用卡片、技能卡片reference / skillfile同上
改动卡片 data-changed-filesdeliverableschanges-review同上
改动卡片 diff 面板的「打开文件」deliverables → tab.actions.openResourcefile该动作等价于 sidebarRight.openResourceIn,已被包装
交付物卡片 data-presented-filedeliverablesfile点击拦截 + 包装
计划卡片 data-plan-cardplanplan点击拦截 + 包装(见「计划文档」)
桌面版原生「在应用中打开」open-in-app—data-open-path-open 识别

确认不需要接管的(它们不打开文件,openTab 打开的是面板本身):schedule(任务详情)、subagent(子会话)、sidebar-files / sidebar-browser / sidebar-terminal 的自有面板;目标模式(goal)、反馈模式(message-feedback)、审批(approval)、工作流(workflow-run)、用户提问(user-questions)只有普通按钮,没有任何文件打开动作。data-context-files(上下文清单)是纯展示的 li,没有点击行为。

这条通道的性质值得记住:包装 openResource 意味着宿主以后新增的卡片只要仍走 openResource 就自动覆盖。真正需要单独适配的只有"带自己路径、绕过通道"的卡片——计划卡片就是第一个这样的例子(它连路径都没有)。

顶部可切换工作区、刷新、显示或隐藏点文件。目录在树中原地展开,子级首次展开时才加载。

多标签页

右侧内容区是一个标签栏,打开的文件、提交和工作区改动各自占一个标签,切换标签即切换内容,不会互相覆盖:

  • 新建标签:在文件树里点击文件、在 Git 面板里点击提交或某条工作区改动,都会新开一个标签;点击已经打开的文件不会新建标签,而是切回原来那个。
  • 关闭:标签右侧的 × 关闭该标签。关掉当前标签后落到右邻标签(没有则左邻);关掉最后一个标签时内容区回到「未打开文件」,并把焦点交还左侧栏。
  • 关闭抽屉再打开是「隐藏」而不是「重来」:已打开的标签、当前选中的标签、左侧停在文件还是 Git 面板、树里展开的目录,都原样保留;重新打开时还会把树里正在显示的目录重读一遍,所以内容是新的、形状是你上次留下的。Esc、点遮罩、手机返回手势都只是隐藏。
  • 只有真正换了项目才会重置:切到另一个工作区(根目录)时,标签、展开集、内容区一起清空——标签里记的路径只对打开它的那个根目录有意义,留着会指到同名的另一个文件。
  • 同一个文件可以有两个标签:一个显示改动(diff),一个显示文件内容,两者是不同的文档,刻意并存——来回对照补丁和代码正是它们的用途。为了不至于分不清,改动标签的副标题写明「已修改 · 改动」,文件标签的副标题是它所在的目录。从改动跳到文件用的就是这条路径。

标签页与文件树联动

左侧目录树会始终高亮右侧当前标签打开的那个文件,两半永远描述同一个文件:

  • 高亮跟随标签,不跟随点击:换一个标签、用 ← / → 在标签栏里移动、关掉当前标签落到邻居,左侧的高亮都立刻跟过去。它不是"记住最后点过的那一行",所以从 Git 面板、会话里的文件链接、@ 引用等任何入口打开的标签都会同步。

  • 打开即高亮,不等文件读完:高亮取自标签自己记录的路径,而不是文件读取完成后才有的元数据。刚点开、内容还在加载时那一行就已经亮了,不会出现"标签开了、树没反应"的空窗。

  • 自动展开到目标文件:标签指向的文件可能藏在收起的目录里,甚至那个目录还从没被列过。树会自动展开沿途每一级目录(并请求缺失的列表),让这一行真正出现;它只加展开项,不会关掉你已经打开的目录。

  • 展开只发生一次,之后就归你管:到达一个文件时(切标签、点树、从链接进来)自动展开一次;此后你手动收起任何目录,它保持收起,即使那个目录里正装着右侧打开的文件。切到另一个文件时会再展开一次新路径——否则高亮就无处可亮了。

    这条规则是踩出来的:最初的实现把 expanded 放进了展开逻辑的依赖里,而 expanded 正是它自己会写的那份状态。收起装着当前文件的目录改变了 expanded → 重新触发展开 → 目录又被弹开,那个目录根本折不上。现在展开逻辑只依赖"当前是哪个文件",树的状态通过 ref 读取,不再订阅。测试 arrival.test.mjs 里那两条折叠行为用例锁的就是这件事,并且已验证:把依赖改回去、同时去掉一次性守卫,两条测试立刻失败。

  • 自动滚动到可见位置:目标行不在可视区时,树会把它滚到中间;已经在可视区内就不动,避免和手动滚动打架。遵循系统的"减少动态效果"设置(此时直接跳转、不做平滑动画)。

  • 只高亮工作区里的真实文件:Git 提交标签、工作区改动的 diff 标签、以及从提交历史打开的某个版本(其路径在磁盘上可能已不存在)都不在目录树里,因此左侧不标记任何行,不会假装那是一个正在查看的工作区文件。

  • 没有打开的标签就没有高亮:关掉最后一个标签后,左侧不再留一行亮着——高亮被当作"这个文件正开着"的陈述,不能比事实多停留。

高亮本身由三部分构成,保证在浅色与深色主题下都读得出来:整行底色、左侧 2px 品牌色竖条、行内文字加粗。键盘聚焦时的焦点框与"当前文件"分开,鼠标点完不会留下和选中态抢眼的方框。

标签上右键(键盘为 Shift+F10 或菜单键)打开五项菜单:

菜单项作用
定位文件让左侧目录树重新展开并把该行滚动到视区中央。就是这个入口解决"我误收起了目录、高亮看不见了" ——自动展开只在切换文件时发生一次,之后折叠的目录保持折叠,想找回来点这里。行本来就在视区里时不滚动(无位移的滚动只是干扰),但会给它一次描边闪烁,否则点了菜单什么都没发生,读者会以为坏了。非文件标签(提交、工作区改动 diff、历史版本)没有对应的树行,该项置灰。
刷新当前重新读取该标签的内容:文件重读元数据与内容,提交与工作区改动重跑对应的 Git 读取。只刷新这一个标签,其余标签原样不动。
关闭当前关闭右键的这一个标签
关闭其它只保留右键的这一个标签(只剩一个标签时该项置灰)
关闭所有关闭全部标签

文件树支持右键上下文菜单与单项 @ 快捷按钮:

  • 复制相对路径:复制文件/目录相对于工作区根目录的路径;
  • 复制绝对路径:复制文件/目录在操作系统中的完整物理路径;
  • 引用到输入框 (@):将文件/目录以 @path 格式插入对话输入框。支持在输入框当前光标处精确追加,并带有自适应空格规整;绝不覆盖或冲掉输入框中用户已输入的已有内容;
  • 文件下载到本地:
    • 顶部工具栏:选中文件后,标题栏常驻提供 ⬇️ 下载图标按钮,一键将当前预览文件另存到本地;
    • 右键上下文菜单:在文件树右键点击任意文件,选择 ⬇️ 下载文件 即可直接提取;
    • 二进制/超大文件兜底区:对于无法在线渲染的二进制文件或大文件,提示卡片内提供“下载到本地”按钮,完美解决 Web/远程模式下无法提取文件的痛点。
  • 删除文件 / 目录:菜单最底部,与上面的安全项之间有一条分隔线,文字与图标常驻红色。

删除

删除必须先确认,确认框会明确写出要删的是哪一个:

┌ 删除文件? ────────────────────┐
│  a.txt                        │
│  src/a.txt                    │
│  该文件将被永久删除,无法撤销。   │
│                    [取消] [删除] │
└───────────────────────────────┘
  • 目标名 + 相对路径都显示:树里可能有多个同名文件散在不同目录,只写「a.txt」不足以让人确认删的是哪一个。
  • 目录是更强的警告:「该目录及其中的所有内容都会被永久删除」,并且宿主端只有带 recursive 才肯执行——不带就被拒绝(is-a-directory),免得一个以为在删文件的调用把整棵子树带走。
  • 默认焦点在「取消」上,Esc 也取消。删除按钮是红的,但一次反射性的回车不会触发它。
  • 根目录永远删不掉:path 为空、.、./、sub/.. 这类指向根目录的写法一律拒绝(cannot-delete-root)。这个判断在写第一版时漏了 ./,是测试抓出来的。
  • 越界路径一律拒绝:和所有读取走同一道闸(resolveInRoot)——先做绝对路径与 .. 的形状检查,再用 ctx.fs.contains 比对规范化后的身份,符号链接绕不出去。
  • 删完把界面收拾干净:指向该路径(或该目录内)的标签页会被关闭,树里正在显示的目录重新读取,Git 徽标重算——否则会留下一个打开着、但文件已经不存在的标签页。

实现说明:宿主的 dsh-fs 服务是只读的(只有 writeText / editText,没有任何 unlink),所以删除是本插件唯一一处直接用 Node fs 碰磁盘的地方。正因为绕开了宿主的抽象,上面那些校验是承重墙而不是防御性代码。删除失败时只回传原因码,不回显绝对路径——fs 错误里带着宿主路径,而这个插件其余部分一直避免泄露它。

调整宽度

两处宽度可以分开拖动,互不影响:

  • 面板整体宽度:拖动抽屉左边缘(鼠标移到左侧边框会出现高亮提示条)。抽屉贴在窗口右侧,所以往左拖是加宽、往右拖是收窄。最小 420px,最大不超过窗口宽度——上限是窗口本身,避免把面板自己的关闭按钮推出屏幕。双击手柄恢复默认宽度(min(64vw, 1100px)),拖动结果会记住。
  • 侧栏宽度:拖动文件树与预览之间那条分隔线,调整的是侧栏占面板内部的比例;双击恢复默认 280px。

两个宽度分别持久化(dsh-file-viewer.shell-width 与 dsh-file-viewer.tree-width),所以调宽面板不会改变侧栏比例。

收起侧栏

顶部工具栏有一个侧栏开关(面板轮廓图标),点一下把整个左侧栏(文件树或 Git 面板)收起来,内容区占满整个宽度;再点一下恢复。

  • 收起的是整栏,不是单个目录。 树里每个目录的展开/折叠仍然是点目录行,两者互不干扰。
  • 状态会被记住,下次打开还是上次的样子。 收起与展开都持久化(dsh-file-viewer.tree-collapsed),关掉抽屉、刷新页面、重开浏览器,回来都是你离开时的状态——两个方向都记,不会只记住"收起"而把想反悔的人困住。
  • 宽度不会被重置。 收起状态与 tree-width 是两个键——你把侧栏拖窄到 160px 再收起,展开回来还是 160px,不会跳回默认值。
  • 树是隐藏而不是卸载。 滚动位置、展开的目录、正在加载的列表都留在原处,所以展开回来是瞬时的,不是重新读一遍。
  • 手机上没有这个开关。 窄屏是"目录/内容整屏切换"(由 data-pane 驱动),没有并排布局可收,按钮隐藏而不是留一个点了没反应的控件。

调整阅读字号

内容区工具栏右侧有一个字号步进器(A- / A+,中间显示当前值),用于把正在读的内容放大或缩小。范围 11–24px,步长 1px,取值记住在本地(dsh-file-viewer.font-size)。

只对渲染文本的视图出现:纯文本/代码、Markdown、JSON。图片、PDF、Excel、Word、二进制文件没有这个按钮——它们各自有自己的缩放,或者本来就不是可调排版的正文。

三处文本视图都会跟随这个设置:

视图字号生效方式
源码 / 代码(宿主 ReadBlock)重声明代码卡的 font: 简写
Markdown 预览重声明预览根节点的 font: 简写,标题按 1.35em、代码块按 0.95em 等比跟随
JSON 预览重声明 JSON 树根节点的 font: 简写

宿主的这几处根节点都用 font: 简写把字号写死,而简写会一次性重置字号、字重与行高——外层容器上的 font-size 继承不进去。这正是 v0.3.19 之前"预览里点字号按钮毫无反应"的原因:规则都在、看着都对,但被简写压掉了。现在每个根节点各自重新声明字号,并连带声明行高,否则放大后行会互相重叠。

这类"规则存在但赢不了层叠"的问题无法靠断言规则文本来防住,因此 test/cascade.test.mjs 用真实 Chrome 计算最终字号:把宿主样式表放在插件样式表之前(对插件最不利的顺序),再读取 getComputedStyle 的实际结果。没有 Chrome 的环境会跳过这三条而不是假装通过。

左边缘手柄也支持键盘:聚焦后 ← / → 每次 ±16px(按住 Shift 为 ±64px),Home 到最小宽度,End 到窗口宽度。

手机端(宽度 ≤ 960px)与全屏模式下抽屉已经铺满可视区域,左边缘手柄自动隐藏。

手机端抽屉占满全屏,文件列表与内容单列切换(左上角 ‹ 返回列表)。

桌面版标题栏避让

两个桌面平台的窗口都有系统绘制的按钮区,且永远盖在页面之上,所以抽屉顶部必须从它们下方开始。两端用不同的宿主标记,不要用 UA 或窗口尺寸去猜平台:

平台宿主标记避让高度行为
Windows 桌面版html[data-windows-titlebar]--dsh-windows-titlebar-height(默认 40px)抽屉从右上角最小化/最大化/关闭按钮下方开始
macOS 桌面版html[data-platform="darwin"]--dsh-frame-overlay-top → 回落 --dsh-frame-top-clearance(48px)抽屉从左上角交通灯按钮下方开始
全屏(任一平台)html[data-fullscreen]偏移撤销条带/交通灯隐藏,抽屉贴到顶边,不留死区
Web 浏览器无上述标记无偏移没有系统按钮要让位

Windows 端读宿主变量而不是写死 40px:125% / 150% 缩放下条带会变成 50 / 60px,写死就会错位(已按 40 / 50 / 60 / 80 四档验证)。

macOS 端按规范优先消费宿主算好的 --dsh-frame-overlay-top——宿主已经把平台与全屏状态折进这个值,插件再自己拼一套判断只会多出一个需要同步的口径。变量缺失时回落到原始的 --dsh-frame-top-clearance,两者都没有则不加任何偏移(凭空造一条偏移会变成点不到的死区)。

两条偏移都带 !important:抽屉为退出宿主"居中卡片化"而声明的 inset:0!important 会展开出同样重要的 top:0,普通声明赢不过它——这正是该问题在 v0.3.6 之后复发的原因。

验证方式:test/package.test.mjs 锁住规则文本、!important 与选择器特异性,并把"新出现的、会设置抽屉垂直几何的 !important 规则"列为必须人工复核的白名单;test/cascade.test.mjs 进一步在真实 Chrome 里测量抽屉头部的最终坐标——四种平台/全屏组合逐一断言,elementFromPoint 确认工具栏按钮在最上层可点击。规范见仓库根目录 AGENTS.md 的「桌面版兼容」一节。

手机端不需要这条偏移:抽屉在手机上占满全屏,而 dsh-mobile-adapter 会把含 scrim 的 overlay 层提到 z-index:1100(高于它自己 900 的顶栏),所以抽屉工具栏不会被顶栏压住。这两点由插件测试之外的因素保证,改动移动端层级时需一并复查。

桌面版圆角:抽屉是"卡片",不是"浮层"

Windows 桌面版的抽屉与宿主内容卡从同一条边开始——标题栏条带的下沿。宿主把内容卡的左上角切 16px 圆角(--dsh-windows-content-radius),其余三个角保持直角。抽屉如果四角全直,就会在这条共用边上与内容卡拼成一条连续横带:会话标题、会话页签和抽屉工具栏连成一片,抽屉看起来像是盖在窗口之上的一层,而不是窗口里的一块面板——这正是"层级比窗体和会话高太多"的视觉来源。

因此 Windows 桌面版下抽屉只切左上角,取值直接消费宿主变量:

html[data-windows-titlebar]:not([data-fullscreen]) .fv-shell[data-mobile="false"]{
  border-radius:var(--dsh-windows-content-radius,16px) 0 0 0}
  • 只切一个角:四角全圆会变成"悬浮卡片",那是与窗口的另一种关系;右侧和底部贴住窗口边缘,保持直角。
  • 消费宿主变量而非写死 16px:宿主按 DPI 换算,写死会在 150% 缩放下错位。
  • 全屏撤掉:全屏时条带消失、抽屉铺满 frame,圆角会在窗口边缘留下缺口,所以用 :not([data-fullscreen]) 收口。
  • 不带 !important:这条规则不与标题栏偏移争夺同一属性,留在普通声明里,也就不会进"必须人工复核"的白名单。
平台抽屉圆角
Windows 桌面版(窗口态)左上角 --dsh-windows-content-radius,其余直角
Windows 桌面版(全屏)四角直角
macOS 桌面版四角直角(交通灯让位由 top 偏移负责,几何与 Windows 不同)
Web 浏览器四角直角(没有需要对齐的宿主内容卡)

验证方式:test/cascade.test.mjs 在真实 Chrome 里读取 getComputedStyle 的四个角,逐平台/全屏组合断言最终半径,并验证改宿主变量时圆角跟着变(证明消费的是变量而不是字面量);test/package.test.mjs 锁住"只切左上角"与"不带 !important"。移除该规则后这两项测试即失败。

计划文档

会话末尾的计划卡片(exit_plan_mode 提交的方案,卡片上写着标题与「计划 · Markdown」)点击后由本插件打开,而不是宿主自带的工件面板。

它为什么原先不走插件:计划不是文件。卡片上只有一个 tool call id:

<div data-plan-artifacts>
  <button data-plan-card="call_00_…">接口代理(/api/v3)设计方案 / 打开</button>
</div>

没有 data-presented-file、没有 title、没有 aria-describedby、也没有 data-open-path-open,而正文存在会话事件里,所以按路径识别的那套规则一条也匹配不上,点击就落到了宿主的原生预览。

现在的做法:宿主端按 call id 从会话日志里取出该计划的 Markdown,写进会话自己的工程目录——<工作区>/.dsh-plans/<计划标题>.md——再把相对路径交回插件。于是它就是一份普通 Markdown 文件:在标签页里打开、目录树能定位、大纲与字号都继承、关掉标签页就等于关掉计划。

这里换过三版,前两版都不该再走:

v1 把计划目录注册成一个 root(~/.dsh/file-viewer/plans)。结果工作区下拉框里多出一个「计划文档」条目——它不是真正的项目,选中后切不回去。 v2 改成虚拟文档:内容随 RPC 回复交给客户端,抽屉里单独渲染,不进 roots。工作区干净了,但它需要一整套自己的状态,而且打开后关不掉——它不是标签页,没有"关闭"这个动作。 v3(现在)落盘到工程内的 .dsh-plans/ 并作为纯净的普通文件打开。它天然属于已有 root,不需要新根、不需要专用侧栏、不需要额外状态;"怎么回到目录树""怎么关掉它""怎么在看计划时对照项目代码"这些问题全部由既有的标签页与完整目录树回答。

  1. 文件名防冲突:文件名以清洗后的标题为主体并拼接 callId 后缀(如 接口代理设计方案-0_mkzd43.md),保证不同会话或多次提交的同名计划独立留存,杜绝相互覆盖。
  2. 零侵入的 Git 忽略:自动将 .dsh-plans/ 写入 Git 本地私有排除配置 .git/info/exclude(而不是根目录的 .gitignore),完全不产生任何未提交的代码变动,保持工作区绝对干净。
  3. 目录树可见性:左侧目录树永远保持完整项目结构,并且在默认隐藏 dotfile 的情况下对 .dsh-plans 放行展示,用户既可在标签页中直接点 × 关闭计划,也可在目录树中随时查看或切换。

重启之后的历史计划:为什么必须"按 id 查"而不是"扫一遍"

服务重启后,历史会话不在内存里,计划正文只能从磁盘归档 ~/.dsh/sessions/<编码后 cwd>/<sessionId>/ 里取回。这里有一个必须避开的地雷:

第一版是"扫一遍":遍历机器上全部归档,逐个解压找那个 call id。本机实测是 501 个文件、438 MB、耗时 21.2 秒。用户两秒内没看到反应就会认定"点了没反应"——这正是当时收到的反馈。

现在的做法是"按 id 查":归档目录名本身就是 sessionId,而卡片地址 dsh-resource://plan/session/<sessionId>/<callId> 里就带着它,所以目标是可以直接寻址的,不需要搜索。实测同一个计划:定位 14 ms、解压 0.28 秒,端到端 389 ms。相比 21.2 秒快了约 54 倍。

只有在直接寻址落空时(例如卡片来自更早、目录已改名的归档)才启动有预算的兜底扫描:按 mtime 倒序、最多 80 个文件、总时长上限 5 秒、单文件 zstd 时限 3 秒。最坏情况是几秒,而不是几十秒。同一会话内重复点击走缓存,第二次实测 2 ms。

一个被实测否掉的方案:曾想用 Node 内置的 zlib.zstdDecompressSync 取代外部 zstd 命令。不可行——这些归档是多帧的(每次 append 一帧),zstdDecompressSync 与 createZstdDecompress 都只解出第一帧 211 字节(整个文件 13 MB),流式还会抛 Unknown frame descriptor。更糟的是它不报错,于是"读不出来"会伪装成"没有这份计划",把排查引向完全错误的方向。所以解压必须依赖外部 zstd 二进制;机器上没有它时给出明确的 plan-archive-unavailable("未安装 zstd,无法读取历史计划"),而不是谎称计划不存在。

慢的时候也不让你干等:超过 1.5 秒文案换成「正在检索历史归档…」,超过 8 秒出现「改用原生面板打开」按钮——点击即放弃拦截,交回宿主自己的计划面板,任何情况下都有出路。

绝不猜工作区:早期版本在工作区未知时会退回"第一个工作区",结果把 A 项目的计划写进了 B 项目的目录。现在只认三种来源:内存中该会话的 cwd → 归档里 session 事件的真实 cwd → workspaceRegistry 中按 sessionId 归属匹配到的路径。三者都拿不到就明确报错,不做任何猜测。

Markdown 目录

渲染 Markdown 预览时,内容区右上角有一个目录按钮,展开后按标题层级列出全文小节,点击即滚动到对应位置;滚动时当前小节会高亮。层级缩进从文档中最浅的标题算起,所以以 ## 开头的文档不会凭空多缩进一级。

目录面板最高 min(60vh, 420px),超出部分由列表自己滚动。

这里踩过一个 flex 的坑:列表是 flex-direction: column,行是它的 flex item,而 flex item 默认允许压缩。标题一多,面板到顶之后不是列表滚动,而是每一行被压扁——每行内容仍占 27px,盒子却只剩 13px,于是每行文字的下半部分都被裁掉。overflow-y:auto 一直在,但它没东西可滚:内容早已被挤进可视范围了。

修法是给行加 flex:none(行保持自身高度,让列表去滚),并给列表加 flex:1 1 auto(让它占据面板剩余高度,滚动条落在列表上而不是面板上)。test/cascade.test.mjs 里那一条用真实 Chrome 量渲染后的盒子:行高必须等于内容高、面板装不下时列表必须可滚动、装得下时不该出现滚动条。把 flex:none 去掉,这条测试立即失败。

HTML 预览(运行页面)与录制

.html / .htm 默认以源码打开,点顶部「预览」才在沙箱里真的跑起来。默认不跑是有原因的:解析并执行一个 2 MB 的页面要占住主线程,而这件事原来发生在读者还没提出任何要求的时候——于是每次打开大报告、大原型,抽屉都先卡一下。源码视图很便宜,也足够回答"这是个什么文件";运行是一次明确的动作。

源码分页,预览整文件——这两条是分开的,因为它们优化的是相反的东西:

  • 源码每次只读一页(500 行),带「上一页 / 下一页」和真实行号范围。一次渲染十万行会让抽屉卡住好几秒,之后滚动也照样难用;分页让打开大页面就像打开任何大文件。
  • 预览一定拿整个文件。半个文档跑出来的不是"慢一点的页面",而是另一个坏掉的页面(后半截 HTML 根本没到)。所以「预览」走的是独立的整文件读取(whole),读取期间按钮显示「准备中…」,读到才挂载——绝不会让页面带着残缺内容开始执行。

首次读取若已带回全文(2 MB 以内的整文档),点预览就是纯切换,不发第二次请求。

切换预览时,框架会先让「正在加载页面…」画出来,再挂载 iframe。加载提示延迟 150ms 淡入:小页面一两帧就绪,一个只闪 16ms 的提示不是反馈而是闪烁;超过 150ms 才是读者会等待的那种慢。页框就绪前显示转圈和文字,而不是一块和"渲染失败"无法区分的白板。

录制页面动画

预览模式下有「录制」按钮,录下来的视频自动下载。

为什么只能靠屏幕捕获:被预览的页面是 opaque origin,它的像素从父页面根本读不到——canvas 不行,captureStream 也不行。getDisplayMedia 拿的是屏幕像素而不是 DOM,所以沙箱不是障碍。反过来说,不要为了录制去放宽 sandbox:沙箱不是这里的问题所在。

  • 浏览器会让你选录制范围,Chrome 下可勾选当前标签页。

  • 支持 Region Capture(Chrome 94+)时,画面会被自动裁剪到预览区域,视频里只有页面、没有抽屉;不支持时退化为整标签页录制,功能照常。

  • 录制中按钮变红并显示时长(精确到 0.1 秒,一次录制对应一个由 recording 状态驱动的定时器,开始与清理成对)——屏幕录制没有别的迹象表明它还在跑。

  • 关闭文件或卸载面板会丢弃录制,不会弹出一个你早就放弃的下载;同时释放捕获轨道(视频与 GIF 两条管线都注册了自己的 teardown),系统的共享指示不会一直亮着。

  • 浏览器自带的「停止共享」同样会结束录制并保存文件;GIF 管线同样接了这个事件,并负责自己收尾(它还有帧要编码),不会被上层提前把流掐断。

  • 录制格式可选(工具栏下拉框),选择按浏览器记住:

    格式适用
    MP4 (H.264)(默认)兼容性最好:微信、手机、PowerPoint、剪辑软件都能直接打开
    WebM (VP9)体积更小,适合只在浏览器里播放
    GIF (动图)直接贴在文档 / README / 聊天里,不需要播放器;体积大、帧率低

    MP4 排在前面不是因为它编码更好,而是因为录制多半是要发给别人的——发给聊天、工单、幻灯片。一个对方打不开的 WebM,价值不如稍大一点但能播的 MP4。

    列表只显示当前浏览器真正支持的格式(逐个过 MediaRecorder.isTypeSupported,连 codec 一起校验——浏览器可能接受容器却拒绝编码),所以选得中的一定能录。文件扩展名按实际产出的 MIME 类型决定,不是按请求的格式;写错扩展名会让文件双击打不开。

GIF 是怎么来的

浏览器完全不能写 GIF,这不是配置问题。三种最可能的路径实测全部走不通:

尝试实际结果
canvas.toBlob('image/gif')静默返回 image/png
canvas.toDataURL('image/gif')返回 data:image/png
WebCodecs VideoEncoder无 GIF 编码器

所以这个插件自己实现了一个 GIF 编码器(中位切分量化 + GIF 版 LZW + GIF89a 容器),内联在 lib/client.js 里——因为客户端是手工维护的单文件、没有打包器,files 直接发布源码,没有地方解析 import。

抓帧管线与视频不同:捕获流送进一个已挂到 DOM 上的隐藏 <video>(游离元素会被 Chrome 的省电策略停帧,drawImage 只会反复画到同一张首帧),按 10fps 画到 canvas,录制期间只把 RGBA 原始帧推进数组,量化推迟到停止之后。

这条顺序是被一次真实故障逼出来的:最初的实现在抓帧定时器里同步做中位切分量化,一帧 30 万像素的距离计算要一百多毫秒,JS 主线程被完全占满,秒表停在 00:00 一动不动,页面也一并卡死。量化本身必须做(未量化的帧是 4 字节/像素,几秒钟就几百 MB;变成索引后 1 字节/像素,15 秒才装得下),但它属于录制结束之后的工作:停止后分批量化,每若干帧让出一次主线程并更新「正在量化帧 N/M」,事件循环全程不被堵死。

护栏(都是硬上限,到点就停):

限制值理由
最大宽度640 px先缩小再画,大像素根本不产生
帧率10 fpsGIF 无帧间压缩,帧率越高体积越失控
最长15 秒内存与文件大小的上限
调色板256 色GIF 格式上限

调色板取自开头几帧并全局复用(GIF 只有一张全局色表,且页面配色通常不变)。编码在停止后进行,先让进度提示画出来再开始,期间按钮锁定——半张编码完的 GIF 不是可用文件。

编码器不是"看起来对"就算完成。test/html-preview.test.mjs 里有一条真实 Chrome 端到端用例:把编码器从 bundle 中原样抽出来,在浏览器里画 6 帧渐变+彩色方块,编码,再让 Chrome 解码回来逐像素比对(平均色差 < 12)。

这条测试最初只比对宽高,结果我故意破坏 LZW 码宽和色表尺寸,测试依然全绿——两个都是真实缺陷,只是简单图案既不触发码宽增长、也不影响尺寸。改成像素比对后两处破坏都能被抓住。

它仍有一个已知局限,写在测试注释里:它证明的是「Chrome 能解码回同一张图」,不是「字节流完全符合规范」。Chrome 对某些偏差是宽容的(比如省掉 LZW 结束码仍能解码),这类回归会漏过。编码器仍然正确输出了结束码。

视频存在哪:只存在你自己的电脑上

录制的视频从不经过 DSH 主机。片段在浏览器标签页内由屏幕捕获拼装,直接写到你运行浏览器的那台机器上——即使 DSH 跑在远程服务器,视频也不会出现在服务器的文件系统里。

  • 录制一结束,预览区下方无条件出现一条保存条:「已生成录制:xxx-录制.mp4(2.3 MB)[保存到本地][×]」,保留 120 秒。
  • 同时尝试弹「另存为」让你选目录和文件名(需要 File System Access API;Chrome 支持),不支持时回退到浏览器普通下载。
  • 为什么要有那条保存条:MediaRecorder.onstop 不是用户手势,在这个回调里调 showSaveFilePicker 会抛 AbortError。早先的版本把它当成"读者点了取消"直接 return,于是既没弹窗也没下载、界面上什么也没发生——表现就是"停止后毫无反应"。现在对话框失败一律走下载兜底,而保存条上的按钮是真实的同步点击手势,永远不会被拦。
  • 在另存为对话框里点取消不会接着偷偷下载——你拒绝过的东西不会再被保存,但保存条还在,你想存时点它就行。
  • 提示语写明去向,因为在一个文件都放在服务器上的工具里,「已保存」本身说明不了任何事。

为什么没有「复制到剪贴板」

不是没做,是浏览器的白名单里没有。在本机 Chrome 上逐个实测 ClipboardItem.supports():

类型支持
image/pngtrue
image/svg+xmltrue
text/plaintrue
video/webm / video/mp4false
image/giffalse
image/jpeg / image/webpfalse
file(文件本身)false

也就是说:视频不能复制,动图 GIF 同样不能。剪贴板只接受 PNG、SVG 和文本,任何网站都绕不过去(navigator.clipboard.write 会抛 NotAllowedError)。

要用录好的动图,走「保存文件 → 拖进聊天窗口 / 编辑器」这条路,动画完整保留。曾考虑过 HTML + data URI 的偏门写法,但只有部分富文本编辑器会解析 <img>,聊天工具不会,不值得做。也没有退化成「复制首帧 PNG」:那会悄悄丢掉动画,而使用者要的就是动画。

限制(都是屏幕捕获的固有性质,不是实现缺陷):录制的是像素,所以被别的窗口遮挡时会录到遮挡物;需要 HTTPS 或 localhost(DSH 的 127.0.0.1 满足);录制期间不能最小化窗口。

沙箱为什么没有 allow-same-origin

这一个标志就是「预览」与「漏洞」的分界。加上它,被预览的页面与本站同源:可以读 localStorage(宿主在里面存凭据)、带着本站 Cookie 调用宿主 RPC、直接访问父文档。更糟的是,同时持有 allow-scripts 与 allow-same-origin 的页面可以自己删掉 sandbox 属性再重载——沙箱边界就没了。

所以 sandbox 只给 allow-scripts allow-forms allow-modals allow-popups allow-downloads,永不包含 allow-same-origin。此时框架拿到的是 opaque origin:脚本照跑、样式照生效,但 localStorage 抛错、document.cookie 为空、父文档不可达。

脚本是故意开放的——一个不能交互的静态快照,正是这个功能要取代的东西。

代价是真实的,所以写在界面上而不是藏起来:页面无法使用存储与同站 Cookie,依赖它们的页面在预览里会显得坏掉(直接用浏览器打开却正常)。框架下方那行小字就是在说这件事。

为什么用 srcdoc 而不是 blob URL

blob: URL 继承本站 origin,还必须手工 revoke;srcdoc 两者都没有。代价是页面内的相对引用(./style.css、../img/logo.png)不会解析——自包含的单文件页面(生成的报告、原型)渲染结果与别处完全一致,其余情况用「源码」视图或下载后在浏览器打开。

一个仍然存在的残余风险

opaque origin 挡住了凭据读取与 DOM 访问,但挡不住盲发请求:页面里的脚本仍可 fetch('/api/...')。之所以危害有限,是因为该请求处于 CORS 模式、credentials 为 same-origin 而目标跨源,浏览器不会附带本站 Cookie;且宿主不返回 Access-Control-Allow-Origin,响应也读不到。也就是说请求可能发出、但既无凭据也拿不回结果。

要彻底堵住只能禁止脚本,而这恰恰会让「预览」退化成静态截图。考虑到被预览的是你自己工程里的文件、且文件树本就能读到同目录的一切,这个取舍是可接受的——但它是有意接受的,不是没想到。

修复记录

  • v0.3.59:修复「定位文件」只展开祖先目录却不滚动的问题——滚动 effect 的依赖里只有 [open, activeFilePath],而手动定位只改 revealStamp,它清空了滚动票却没有任何东西去消费;同时补上 expanded/nodes 两项依赖,因为展开祖先或异步拿到目录列表都是在下一次提交才产生那一行。行已在视区时不滚动但给一次描边闪烁,避免点了菜单毫无反馈。
  • v0.3.58:修复录制收尾的一批真实缺陷——GIF 模式下浏览器原生「停止分享」被忽略(ended 只认 MediaRecorder)、且上层会先掐断流导致 GIF 拿不到帧;GIF 结束时不释放会话槽,第二次录制被入口守卫静默吃掉;captureGif 三条早退路径不清理状态;秒表被 useEffect 与 beginRecording 各起一个定时器导致停止后仍有一个无人清理、永久计时;卸载面板时不 teardown GIF 管线会继续抓帧;屏幕选择器打开期间重复点击会弹出第二个对话框(新增 pending 守卫)。
  • v0.3.57:恢复格式选择下拉框中的 GIF 动图选项,并将 GIF 动图录制完全无缝接入解耦抓帧与分片量化压缩管线。
  • v0.3.56:修复 const window = state.data 局部变量遮蔽全局 window 导致的 TDZ ReferenceError: Cannot access 'window' before initialization 报错,重命名为 dataWindow 并增加回归测试。
  • v0.3.55:重构秒表计时机制为声明式 useEffect([recording]) 钩子,消除异步闭包与 React 状态渲染时序竞态,保证录制期间秒表 100ms 丝滑跳动。
  • v0.3.54:严格保证 MediaRecorder 启动成功后才激活定时器,增加多处状态容错与诊断日志,强化异步下载卡片常驻 120 秒,避免任何静默中止导致无文件保存。
  • v0.3.53:彻底重构录屏启动与停止状态机:拿到屏幕捕获流后秒表立即精确到 0.1 秒递增并持续跳动,杜绝视觉停滞;在录制完成时常驻弹出保存横条并兜底触发自动下载,解决异步回调导致 showSaveFilePicker 抛 AbortError 被静默拦截从而没有任何保存动作的问题。
  • v0.3.52:给 Region Capture (CropTarget) 增加 400ms 超时安全竞速,防止非当前标签页或异步元素导致录屏初始化卡顿。
  • v0.3.51:修复录制 GIF 时时间停在 00:00 及停止分享后无文件的严重问题:将实时量化从抓帧循环中彻底解耦、移至录制结束后异步分片切分与量化,释放主线程事件循环确保秒表流畅跳动;在 videoTrack.ended 中补全 GIF 停止逻辑,支持浏览器顶部「停止分享」横条结束录制;并在页面底部新增悬浮下载卡片保障,防止自动触发被浏览器安全策略静默拦截。
  • v0.3.50:录制按钮与格式下拉框增加 canRecordScreen 环境能力检测:手机端浏览器(Android/iOS)及非安全的局域网 HTTP (http://192.168.x.x) 环境不暴露 getDisplayMedia,不渲染无效控件;若异常触发则精准提示缺少 HTTPS 还是平台不支持,避免移动端排版拥挤与误导。
  • v0.3.49:修复打开 HTML 时因 beginPreview 在 modeSwitch 之后声明导致的暂时性死区报错(Cannot access 'beginPreview' before initialization)。

边界

  • 页面超过预览上限(2 MB)时宿主只发分页窗口,此时不渲染,直接落到源码视图并说明原因——渲染半张页面是在骗人。
  • .svg 仍然走图片预览(可缩放、旋转):它首先是图片,其次才是文档,而几乎用不到的脚本沙箱不值得牺牲缩放。
  • 页面在 fork/子会话里同样可预览;历史提交里的 .html 也能按同一套逻辑打开。

沙箱这件事不能只靠「属性看起来对」来验证。test/html-preview.test.mjs 里有两个真实 Chrome 用例:一个在 sandbox 内试图访问父文档、localStorage、cookie,断言全部被拒;另一个用同样的代码配 allow-same-origin 作为对照,断言它能够访问父文档——没有这个对照,前者在框架根本没跑起来的机器上也会「通过」。

目录树刷新

侧栏「文件」标签内部右侧有一个刷新图标(只在「文件」页签出现,随它一起消失),目录行的右键菜单里还有「刷新此目录」。两者解决同一个问题:目录树对每个目录缓存一份 listing——这正是展开顿挫感消失的原因,但也意味着在插件外面新建的文件(shell 命令产物、git checkout 的结果、另一个编辑器里保存的文件)在抽屉关掉重开之前都不会出现。

整树刷新(标签内的图标)重读根目录 + 所有已展开目录。目录刷新(右键菜单)重读该目录,以及其中已经展开的子目录——只刷被点的那个目录会漏掉读者刚打开过的子目录,而那往往才是最旧的那份。文件行没有 listing 可刷,所以菜单里不出现该项。

刷新做了三件事,也刻意没做什么:

  • 只重读指定的目录,不多不少。只刷根目录是不够的——展开的子目录仍会显示旧内容,而那恰恰是最需要更新的地方。
  • 不动 expanded:刷新后树的形状、你的展开状态、滚动位置都保持原样。一个会把树折叠起来的刷新按钮,比它修掉的问题更烦人。
  • 不先清空 nodes:把树瞬间清空去等网络,会比多显示 100ms 旧数据更难用。根目录的 listing 就地在位替换。
  • 递增 generation:与正在进行中的到达加载并发时,旧的响应会被判定为过期,不能覆盖新结果。

整树刷新期间图标转圈并禁用;目录刷新撞上刷新中会提示「正在刷新,请稍候」而不是静默丢弃。prefers-reduced-motion 下动画取消、改用半透明表示「正在刷新」。

刷新最初是放在两个页签旁边的第三个控件。截图反馈很直接:它读起来像第三个视图("标签行里怎么冒出来一个图标?"),而且挤占了两个标签的宽度。移进「文件」标签内部之后语义才明确——它是这个面板的控件,不是另一个面板。

Git 面板

左侧栏顶部有两个页签:文件(目录树)与 Git(源代码管理)。Git 页签按 VSCode 源代码管理面板的方式组织:仓库 → 分支 → 提交记录 → 该提交的改动文件。

  • 仓库识别:当前根目录本身是仓库时直接读取它;根目录不是仓库、但一级子目录里有仓库时,逐个列出(例如一个工作区下并列多个项目)。根目录位于某个仓库内部(工作区是仓库的子目录)时,读取的仍是那个仓库,且只能打开根目录以内的文件——其余文件在列表里标为「根目录外」并禁用。

  • 分支:下拉框按「本地分支 / 远程分支」分组,只切换查看哪条分支的历史;右侧「检出」按钮才会真正执行 git checkout 改写工作区,两者分开以避免误触。远程分支(origin/xxx)标注为只读,检出按钮禁用——检出远程分支只会得到游离 HEAD 或悄悄新建本地分支,不是用户以为的切换。工作区有未提交改动、git 拒绝检出时,面板直接给出「工作区有未提交的改动,无法切换分支」,不会静默失败。

  • 工作区改动:面板上部常驻工作区变更列表,按 VSCode 的习惯分成冲突 / 已暂存 / 未暂存 / 未跟踪四组;同一个文件既暂存又再修改时标「部分暂存」。点击任一条目在右侧显示该文件的 diff,可暂存/未暂存两侧切换;未跟踪文件没有 diff,按「整文件都是新增」渲染。

  • 从改动跳到文件:diff 上方的标题区有一个「打开文件」按钮,点击直接在文件查看器里打开工作区中的这个文件(不是历史版本),左侧目录树的高亮也随之跟过去。已删除的文件按钮置灰并写明「文件已删除」——磁盘上没有可打开的内容,但控件留在原位而不是凭空消失,免得让人以为功能坏了。

  • 提交记录:按 30 条一页加载,显示标题、短 SHA、作者与日期,底部「加载更多」按需追加,并显示「已显示 N / 总数」。

  • 改动文件列表:点击任一提交,右侧显示该提交的完整信息(标题、完整 SHA、作者、时间、正文)与相对第一父提交的改动文件列表(合并提交同理;根提交与空树比对)。A / M / D / R / C 等状态用颜色区分。

  • 该提交的 diff:提交详情有「变更文件 / 改动内容」两个页签,后者是该提交针对同一基准(第一父提交)的完整 patch,按文件分段并各自标注 +N -M。两个页签取的是同一对 revision,不会出现列表与 diff 基准不一致的情况。

  • 跳转到文件:点击改动文件会在右侧已有的预览器里打开该提交中的版本,而不是工作区当前内容——两者恰恰在历史最有价值的时候不同。被该提交删除的文件从父提交读取,所以删除行也能打开;重命名显示旧路径的内容。预览顶部会标明来源提交,避免被误认为当前文件。‹ 返回 「变更文件」列表继续浏览。

    打开后有两个页签:改动(这个提交对该文件做了什么,默认显示)和完整内容(该版本文件的全文,可翻页、切换语法高亮)。重命名的 diff 会保留为「重命名」而不是退化成「删除 + 新增」——宿主端把新旧两侧路径一起交给 git,否则 -M 无法配对。

只读操作(列出仓库、分支、历史、改动文件)不会修改仓库;面板中唯一的写操作是用户显式点击「检出」。

边界与安全约束

  • 检出范围:如果当前根目录只是某个仓库的子目录(仓库在上级),检出会一并改写根目录以外的文件。面板会先显示醒目提示,并在执行前要求确认;宿主端在未收到显式同意时直接拒绝(checkout-outside-root),不会默认放行。
  • 仓库探测上限:为控制开销,子目录探测最多 32 个候选目录。达到上限时面板明确提示「列表可能不完整」,而不是静默返回一个看似完整的空列表。
  • 环境隔离:每次调用 git 都会剥离 GIT_DIR、GIT_WORK_TREE、GIT_INDEX_FILE 等变量,避免宿主进程环境把命令重定向到根目录以外的仓库;检出时用 -c core.hooksPath= 禁用仓库 hooks,不会执行用户仓库里的脚本。
  • 错误如实上报:git 不存在报 git-unavailable,diff 失败报 git-failed,都不会伪装成「没有仓库」或「没有改动」。

Git 页签在首次打开时才请求仓库信息(识别仓库需要对子目录做 git 探测),切换根目录会丢弃已缓存的仓库与历史;刷新会保留正在浏览的分支。

访问边界与安全访问路径

插件内置可访问所有已注册的工作区目录以及各会话的工作目录。

如需让文件查看器访问工作区以外的目录或磁盘,可通过配置安全访问路径(safePaths)放开指定路径的访问权限:

- id: file-viewer
  name: dsh-file-viewer
  config:
    # 安全访问路径列表(支持字符串路径,或带展示标签的对象)
    safePaths:
      - /opt/data
      - /var/log
      # 支持自定义标签:
      # - path: /data/share
      #   label: 共享数据盘
      # Windows 示例:
      # - D:/Notes
    # 在线预览(图片/PDF/表格/文档)单文件上限,默认 20 MB
    maxBytes: 20971520
    # 下载到本地单文件上限,默认 100 MB
    maxDownloadBytes: 104857600

配置后:

  • 在文件查看器顶部的工作区下拉框中,将分组呈现「工作区」与「安全访问路径」,可直接切换浏览;
  • 在对话回复或工具调用中点击属于安全访问路径内的文件路径时,文件查看器会自动定位并直接打开预览;
  • 每次请求都会先规范化路径,再用 ctx.fs.contains 严格校验目标落在工作区或已配置的安全访问路径之内,严防 .. 目录穿越与符号链接逃逸;
  • 为保持向后兼容,原有的 extraRoots 与 safeAccessPaths 别名依然有效并会自动合并;
  • Web 界面动态配置与双重持久化:可在输入框工具栏文件查看器入口或文件树顶部的「安全访问路径设置」弹窗中直接添加、查看或删除安全路径,保存后会自动双重持久化写入当前 Profile 配置(cordis.patch.yml)与本地文件(~/.dsh/file-viewer/settings.json),DSH 服务重启后配置稳定保留不丢失。
  • 「内置工作区」默认折叠:弹窗里那张只读的工作区清单是参考信息("这些目录已经放行了,不用再添加"),用户并不在这里编辑它。注册的工作区一多,它就把用户真正要管理的那部分挤出面板——本机实测该区块展开占 363px、折叠占 36px,一次省下 327px。所以它改为可折叠的按钮标题(带数量徽标),默认折叠,展开/收起状态单独持久化(键 dsh-file-viewer.workspaces-open,与侧栏收起互不影响)。折叠是不渲染而不是靠 CSS 收起——否则行不显示了,高度还在。

下载与预览使用两套上限

下载上限(maxDownloadBytes,默认 100 MB)与在线预览上限(maxBytes,默认 20 MB)是独立的两个值,因为两条通道的代价不同:

  • 预览把整个文件以 base64 塞进 JSON 响应,浏览器还要再 atob 解码一次;20 MB 的文件已经会产生约 27 MB 的传输文本,因此这条通道刻意保持小预算;
  • 下载由浏览器直接消费,或由桌面端流式读取后交给保存流程,不需要解码进页面,因此可以给到 100 MB。

两个上限都支持通过配置调整;把 maxDownloadBytes 调大时,宿主仍需一次性读出该文件,请按可用内存权衡。

下载在不同宿主中的行为

插件在 /api 之下注册了一个带鉴权的流式下载端点,并让客户端按宿主能力选择传输方式:

  • 浏览器:先发一次 HEAD 预检(超限直接提示「文件超过下载限制(上限 100MB)」,不会产生一条失败的下载记录),随后把鉴权后的 URL 交给浏览器原生下载管理器。进度、暂停、续传与保存位置都由浏览器负责,页面内存不随文件大小增长;
  • 桌面端(Electron):桌面外壳没有浏览器的下载面板,因此改用 fetch 流式读取,并在抽屉内实时显示百分比进度(如 正在下载 dataset.zip (45% - 45.2 MB / 100.0 MB)...),完成后交给保存流程;
  • 兜底:流式端点不可用(旧宿主或路由被占用)时,自动回退到既有的 RPC bytes 通道,功能不缺失。

下载端点的错误响应只回传机器可读的原因码(如 FS_TOO_LARGE、too-large),不会回显宿主的绝对路径;这与 RPC 通道既有的「绝不泄露路径」约定一致。

未知后缀文件的文本识别

后缀无法识别时(如 config.yaml.prod、env.dev、env.staging),宿主端不会直接判为二进制,而是读取文件开头的 8 KB 采样窗口做内容判断:

  • 采样中没有 NUL 字节(与 git、file(1) 相同的启发式);
  • 且采样能按 UTF-8 解码(采样末尾被截断的多字节字符会被保留,不算错误)。

两者同时满足即按文本渲染。这样做有两个要点:

  • 采样走 ctx.fs.readByteRange,以窗口为界而非以整个文件为界,因此 20 MB 的未知后缀文本文件也能正确识别,且宿主不会整体缓冲该文件;
  • 若只判断 NUL 字节,GBK 等非 UTF-8 编码的文件会被误判为文本,然后在读取阶段失败;因此这里一并校验 UTF-8,识别不出时诚实报「不是 UTF-8 文本」而不是「读取失败,可能没有访问权限」。

语言格式识别与手动指定

后缀认不出时,插件不会止步于「按纯文本显示」:它会按内容猜一次语言,并把最终决定权交给用户。

语言是怎么定的

按以下优先级决定,先命中者胜出:

优先级来源说明
1用户指定在下拉框里手动选定,记在本地,之后打开这个文件一直生效
2文件后缀已知后缀(.py、.yaml、.go …)比内容启发式更可靠
3文件内容后缀认不出时按内容判断
4无都不成立则按纯文本显示,不做高亮

内容识别的依据

只认有明确特征的内容,宁可不高亮也不猜错——猜错会让高亮落在语法并不期望的记号上,把文件染成误导性的颜色。识别规则按特异性从强到弱:

  • Shebang:#!/bin/bash、#!/usr/bin/env python3、#!/usr/bin/env node 等,解释器直接写在第一行,最可靠;
  • 精确首标记:<?xml、<!DOCTYPE html、\documentclass、syntax = "proto3";、resource "…" {(Terraform/HCL)、query Foo {(GraphQL);
  • JSON:必须真的能解析才算,截断的采样不算证据(所以被截断的 JSON 不会被误判成 CSV);
  • YAML / INI / CSV / Dockerfile:要求结构证据——YAML 需要多数行是 key: value 且不出现 = 赋值;INI 需要至少一个 [section] 或三条以上赋值;CSV 需要两行以上且列数一致(单行含逗号太常见,不足以判定);Dockerfile 需要以 FROM 开头且其余行都是合法指令。

手动指定

打开文件后,标题栏右侧会出现语言下拉框,自动为默认项:

  • 选任意语言即固定高亮,选择按文件路径记忆(不是按后缀),所以同一个 .prod 后缀下不同文件可以各自不同;
  • 选纯文本可关掉高亮;
  • 选回自动即清除记忆,回到上面的自动判定;
  • 识别来源看 tooltip:把鼠标停在语言下拉框上会显示「语法高亮:TypeScript(按后缀识别)」。括号里说明当前语言是按后缀识别、按内容识别还是未识别,让你能分辨「这是猜的」还是「这是确定的」;手动指定过的文件显示「已指定」。

识别来源不再作为常驻文字显示在工具栏上。那段文字宽度随来源变化,会让文件大小与语言下拉框左右跳动,同一排控件在不同文件之间忽宽忽窄;放进 tooltip 后工具栏是一排固定宽度的控件,信息也没有丢失。

下拉框的选项来自宿主端(languages RPC),与宿主校验用的同一份目录,因此界面不可能给出宿主不接受的选项;提交的指定也会在宿主端重新校验,未知 id 一律忽略并回落到自动判定。

已知限制

  • 文件列表不显示修改时间:ctx.fs 的目录项只提供名称、类型与大小。
  • 工作区需要手动选择:宿主端不存在"当前活跃工作区"的概念,该状态只在前端 UI 中。
  • 大表格截断到 2000 行 × 64 列。
  • Word 转换保留标题、强调、列表与表格,复杂排版与图片会降级。
  • 文本识别只看开头 8 KB:文件开头是 UTF-8 文本、之后才出现非 UTF-8 字节时,仍会在读取阶段报「不是 UTF-8 文本」。
  • 文件树中的图标按名称判断(只有图片后缀显示图片图标),实际能否预览以打开后的元数据为准。
  • 内容识别也只看开头 8 KB(meta 阶段);打开文件时 read 已持有全文,会用全文再判一次,因此特征出现在 8 KB 之后的文件在打开后仍能正确高亮。
  • 语言指定只记在本机浏览器(localStorage),不跨设备同步;上限 200 条,超出后淘汰最旧的。
  • 语言下拉框只对源码类视图(文本 / Markdown / JSON)显示;图片、表格、文档没有语法可指定。

开发

pnpm install
pnpm --filter dsh-file-viewer test