# 文件编辑器 Activity Bar 插件注入说明
文件编辑器(仿 VS Code 面板)和文件工作台左侧列表都支持外部插件注册自定义视图,二者使用完全独立的注册表。
编辑器内置三个视图(文件 / 搜索 / Git 提交记录),工作台内置文件导航视图;注册的插件视图会按顺序出现在内置视图下方。
在文件工作台中,用户视图只替换右侧主区域:用户注册按钮嵌入原有左侧列表的“用户插槽”分组,与文件导航同级;该分组可单独折叠。原有左侧导航、命令栏和状态栏继续保留并可使用;右侧在文件列表与用户内容之间切换,不与用户内容混用。点击文件按钮后恢复文件列表。
- 文件编辑器注入:`window.__dshFileWorkbenchVSCode__.activityBar.register(view)`
- 文件工作台注入:`window.__dshFileWorkbenchWorkbench__.activityBar.register(view)`
- 技术要求:**无**。挂载契约是框架无关的 `mount(el, ctx)`,Vue / React / 原生 JS 均可
- 信任边界:该 API 页面上任何脚本都可调用 —— 能注册 = 宿主加载了你的脚本(与
`__dshVSCodeMountPane__` 同一暴露级别)
- 当前契约版本 **apiVersion: 1**:视图注入 +
activeFile 感知 + 命令 + 状态栏项/扩展菜单 + 工作台后台任务/独立扩展菜单 + 编辑器内部交互门面
(`ctx.editor`:缓冲区读写 / 光标选区 / `createDecorations` 装饰工厂)全部属于 v1 契约
## 快速开始
在任意脚本(DSH 宿主加载的插件 bundle / 用户脚本等)中:
```js
const api = window.__dshFileWorkbenchVSCode__;
if (api && api.apiVersion >= 1) {
api.activityBar.register({
id: "myPlugin.notes",
title: { zh: "我的笔记", en: "My Notes" },
description: { zh: "记录项目开发笔记", en: "Project development notes" },
icon: "fileOut",
order: 50,
mount(el, ctx) {
// 1) 渲染你的 UI 到 el(el 已占满左栏内容区)
el.innerHTML = `
`;
// 2) 通过 ctx 与编辑器互动
el.querySelector(".dir").textContent = ctx.projectDir ?? "未打开项目";
el.querySelector(".open-readme").onclick = () => {
if (ctx.projectDir) ctx.openFile(`${ctx.projectDir}/README.md`, { line: 1 });
else ctx.toast("info", "请先打开项目目录");
};
// 3) 订阅面板状态
const off = ctx.onProjectChange((dir) => {
el.querySelector(".dir").textContent = dir ?? "未打开项目";
});
// 4) 返回清理函数(切换走该视图 / 面板卸载时调用)
return () => {
off();
el.replaceChildren();
};
},
});
}
```
注册成功后,视图只会出现在对应目标:编辑器注册只影响编辑器,工作台注册只影响工作台;
点击即挂载你的视图,再点击其他图标时自动调用你返回的清理函数。
在文件工作台中,`ctx.openFile(path)` 会打开宿主的文件预览;编辑器中的同一调用则打开编辑器标签。
### 感知当前激活文件
编辑器面板的 `ctx` 额外提供 `activeFile` / `listOpenFiles()` / `onDidChangeActiveFile(fn)`,
让插件跟随用户正在编辑的文件(例如做「当前文件的引用列表」「按语言切换工具」)。这些成员仅在
编辑器 `ctx` 上有效(v1 契约即含,可按 `typeof ctx.onDidChangeActiveFile === "function"` 防御性判断):
```js
mount(el, ctx) {
if (ctx.onDidChangeActiveFile) {
const off = ctx.onDidChangeActiveFile((path) => {
// path = 当前激活文件绝对路径或 null
el.querySelector(".cur").textContent = path ?? "无激活文件";
});
return () => { off(); el.replaceChildren(); };
}
}
```
### 编辑器内部交互门面 `ctx.editor`
编辑器面板的 `ctx` 额外提供 **`editor`** 门面——把当前激活文件的 CodeMirror 6 视图以最小 API 暴露出来,
对标 VS Code 的 `window.activeTextEditor`。有了它,插件才能读**未保存缓冲区**、跟随**光标/选区**、
**应用编辑(保留撤销栈)**、注入**装饰**(错误波浪线 / gutter 图标 / 行内 hint)——即补全 IntelliSense、
linter、CodeLens、rename 预览这类"VS Code 内核体验"的地基。这些成员仅在**编辑器** `ctx` 上有效,
工作台 `ctx` 无此成员。用 `!!ctx.editor` 探测后再调用:
| 成员 | 说明 |
| ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `view` | 当前激活文件的 CodeMirror `EditorView`(getter;无标签 / 卸载期间为 `null`)。高级用法直接用它 `state` / `dispatch` |
| `getText()` | 当前缓冲区全文(**含未保存改动**);等价 `view?.state.doc.toString()` |
| `isDirty()` | 该文件是否有未保存改动(对标 `document.isDirty`) |
| `onDidChangeTextDocument(fn)` | 订阅文档变更(仅 `docChanged` 触发),回调带 `{ text, changes }`;立即以当前内容回调一次。返回退订 |
| `getSelection()` | 当前选区(CM `EditorSelection`,支持多光标);无视图时 `null` |
| `onDidChangeSelection(fn)` | 订阅选区变化;立即以当前选区回调一次。返回退订 |
| `applyEdit(changes)` | 走 `view.dispatch({ changes })` 应用编辑,**保留撤销栈**。`changes` 为 CM 风格数组 `[{ from, to?, insert? }]`;无视图返回 `false` |
| `setDecorations(deco)` | 注入 / 清除插件装饰层:传入插件用 `@codemirror/view` 构建好的**扩展**(`Decoration` + `ViewPlugin`/`StateField`),传 `null` 清除。独立于内置查找高亮,随文件切换自动显隐。**仅 Cordis 插件适用**(见下条限制) |
| `createDecorations(spec)` | **宿主侧装饰工厂**。传入纯 JSON `spec = { items: DecorationItem[] }`,由宿主用真实 CM6 构造并返回不透明句柄 `{ extension, update(items) }`;首建即应用到当前激活文件,`update()` 就地重绘。同源页插件的**唯一可用装饰路径** |
> **透传 CodeMirror 原生对象**:门面刻意复用 CM6 的 `EditorView` / `EditorSelection` / `ChangeSet` 等类型,
> 不另造抽象。**但注意**:workbench 把 `@codemirror/view` / `@codemirror/state` 打进私有 vendor chunk、
> 未对同源页导出全局。**同源页脚本自行 `import` CM6 拿到的是不同实例**,其 `Decoration`/`ViewPlugin`
> 与编辑器实际使用的不是同一份,`setDecorations` 必然失效。故:
>
> - **Cordis 插件**(走框架无关 `mount`,能共享宿主 CM6 实例)→ 可用 `setDecorations(自建扩展)`;
> - **同源页插件** → 必须改用 `createDecorations(spec)`,只传纯 JSON,样式靠语义 `category` 或自带 `className`。
>
> `DecorationItem`:`{ type: "mark"|"gutter"|"widget", start: {offset?|line?,character?}, end?, category?, message?, className? }`;
> `category` ∈ `error|warning|info|hint|bookmark|highlight|deprecated`(决定默认波浪线/gutter 颜色)。
```js
const api = window.__dshFileWorkbenchVSCode__;
if (api) {
api.activityBar.register({
id: "myPlugin.lint",
title: { zh: "外部 Linter", en: "External Linter" },
icon: "search",
async mount(el, ctx) {
const ed = ctx.editor;
if (!ed || !ed.view) return;
// 同源页插件:只传纯 JSON,交给宿主装饰工厂(不 import CM6)。
// 这里示意:把每行 "TODO" 标成警告波浪线 + gutter 圆点。
let handle = null;
const refresh = () => {
const doc = ed.getText();
const items = [];
let offset = 0;
for (const lineText of doc.split("\n")) {
const col = lineText.indexOf("TODO");
if (col >= 0) {
const start = offset + col;
items.push({ type: "mark", start: { offset: start }, end: { offset: start + 4 }, category: "warning", message: "TODO" });
items.push({ type: "gutter", start: { offset: start }, category: "warning" });
}
offset += lineText.length + 1;
}
if (!handle) handle = ed.createDecorations({ items });
else handle.update(items);
};
refresh();
// 文档变更后重算
const offDoc = ed.onDidChangeTextDocument(() => refresh());
return () => {
offDoc();
handle?.update([]); // 清空当前文件装饰
};
},
});
}
```
## 国际化文案
`title` 支持普通字符串,也支持按语言环境传入对象:
```js
{
title: {
zh: "项目工作台",
en: "Project Workbench",
},
}
```
文件工作台和文件编辑器都会显示当前语言对应的注入标题。
页面语言优先匹配完整 locale,其次按 `zh-*` / 非中文语言回退到 `zh` 或 `en`;普通字符串仍保持兼容。
## ActivityView 契约
| 字段 | 类型 | 说明 |
| ------- | ----------------------------------------------------------------- | -------------------------------------------------------------- |
| `id` | `string` | 唯一 id,建议带插件命名空间(如 `"xxx.notes"`)。**同 id 重复注册 = 覆盖**(幂等,可用于热更新) |
| `title` | `string | Record` |
| `mount` | `(el: HTMLElement, ctx: ActivityContext) => void \| (() => void)` | 挂载函数。`el` 已占满左栏内容区(容器可滚动)。返回清理函数则由面板在切走/卸载时调用 |
| `when` | `(ctx: { projectDir }) => boolean` | 可见性谓词,例如「未打开项目目录时隐藏该图标」;缺省恒显示 |
## ActivityContext 契约(apiVersion: 1)
| 成员 | 起始版本 | 说明 |
| --------------------------- | ---- | ------------------------------------------------------------------------------------------- |
| `projectDir` | v1 | 当前项目目录(getter,读取即最新值);未打开项目时为 `null` |
| `theme` | v1 | 当前主题 `"dark" \| "light"`(getter) |
| `activeFile` | v1 | 当前激活标签对应的文件绝对路径(getter);无激活文件时为 `null` |
| `onProjectChange(fn)` | v1 | 订阅项目切换;**立即回调一次当前值**。返回退订函数 |
| `onThemeChange(fn)` | v1 | 订阅主题切换;立即回调一次。返回退订函数 |
| `onDidChangeActiveFile(fn)` | v1 | 订阅激活文件切换;立即回调一次当前值。返回退订函数 |
| `openFile(path, opts?)` | v1 | 在编辑器中打开文件(加入标签、加载内容);`opts.line` 打开后跳到该行 |
| `listOpenFiles()` | v1 | 当前已打开的文件标签绝对路径列表(按打开顺序) |
| `openDiff(title, lines)` | v1 | 以 diff 伪标签展示文本行,格式同 `git diff`(`+` 新增 / `-` 删除 / `@@` hunk 头),复用内置 diff 着色 |
| `toast(level, msg)` | v1 | 右下角统一消息提示;`level`: `"ok" \| "info" \| "error"` |
| `editor` | v1 | 编辑器内部交互门面(缓冲区读写 / 光标选区 / 装饰注入,含 `createDecorations` 宿主装饰工厂);详见上节「编辑器内部交互门面」。**仅编辑器 ctx 提供** |
> 内测阶段契约统一为 v1(对外发布前不保留历史版本号)。后续新增成员一律向后兼容,届时再递增并在本表标注起始版本。
## 命令贡献点
除了注册视图,插件还能注册**可被调用的动作**——对标 VS Code 的 `commands.registerCommand`。
命令存在模块级表里,注册后不会自动改动编辑器行为,需由其他脚本 / 面板主动 `executeCommand(id, ...args)` 才生效。
适合「一个插件提供能力、另一个插件(或你自己的宿主逻辑)触发」的解耦场景。
window API 命名空间:`window.__dshFileWorkbenchVSCode__.commands`
| 方法 | 说明 |
| ----------------------- | --------------------------------------------------- |
| `register(id, handler)` | 注册命令(幂等:同 id 覆盖,支持热更新)。`handler` 返回值原样回传给 `execute` |
| `unregister(id)` | 注销命令;返回是否存在并被移除 |
| `execute(id, ...args)` | 执行命令;未注册的 id 静默返回 `undefined`(no-op)。处理器抛错向上冒泡给调用方 |
| `list()` | 当前已注册命令 id 列表 |
| `has(id)` | 是否已注册某命令 |
```js
const c = window.__dshFileWorkbenchVSCode__?.commands;
if (c) {
// 注册:把「在当前激活文件所在目录开终端」做成一个可复用动作
c.register("myPlugin.openTerminalHere", (...args) => {
const dir = args[0] ?? null;
// 你的实现……(此处仅示意,可结合 ctx.activeFile 推导目录)
return { ok: true, dir };
});
// 触发(可在别的插件 / 宿主逻辑里)
if (c.has("myPlugin.openTerminalHere")) {
const r = c.execute("myPlugin.openTerminalHere", "/path/to/dir");
console.log(r);
}
}
```
> 命令 id 建议带插件命名空间(如 `"myPlugin.xxx"`),避免与其他插件冲突。
## 状态栏项贡献点
在「命令」之上,插件还能把命令**暴露成一个可点击条目**——注册后集中出现在编辑器**底部状态栏的「扩展」
弹出菜单**里,点击条目即自动 `execute(commandId, ctx)`。这就是命令的内置触发面:无需自己找地方放按钮,
也不用手写事件绑定。(历史上这些条目是平铺在状态栏上的按钮;现统一收进「扩展」入口,见下节。)
window API 命名空间:`window.__dshFileWorkbenchVSCode__.statusbar`
| 方法 | 说明 |
| ---------------- | ------------------------ |
| `register(item)` | 注册状态栏项(幂等:同 id 覆盖)。契约见下表 |
| `unregister(id)` | 注销状态栏项(不存在时静默) |
| `list()` | 当前已注册状态栏项列表 |
`StatusBarItem` 契约:
| 字段 | 类型 | 说明 |
| ------------ | ---------------------------------------- | --------------------------------------------------- |
| `id` | `string` | 唯一 id,建议带命名空间;同 id 覆盖 |
| `text` | `string` | 条目文案(显示在「扩展」菜单里) |
| `textFn` | `(() => string)?` | 实时文案读取器:每次渲染现取,用于进度类动态文本;提供时 `text` 只作兜底。替代「同 id 反复 register 刷文案」(见行为须知 8) |
| `commandId` | `string` | 点击时执行的命令 id(须先经 `commands.register` 注册,否则点击为 no-op) |
| `tooltip` | `string?` | 悬浮提示;缺省用 `commandId` |
| `order` | `number?` | 排序权重(小的靠前;与扩展菜单项合并后统一按此升序排列) |
| `when(ctx?)` | `(ctx: { path, projectDir }) => boolean` | 可用性谓词,例如「有激活文件才可用」;为假时该条目在菜单里**置灰禁用**,缺省恒可用 |
点击时面板注入的上下文对象 `{ path, projectDir }`(均为最新值,可能为 `null`)会作为命令处理器的第一个入参。
```js
const api = window.__dshFileWorkbenchVSCode__;
if (api) {
// 1) 先注册命令:收到条目点击时传入的 { path, projectDir }
api.commands.register("myPlugin.countLines", ({ path }) => {
if (!path) return;
// 你的实现……(示意:统计当前文件行数并提示)
});
// 2) 再把它挂成「扩展」菜单条目,仅在有激活文件时可用
api.statusbar.register({
id: "myPlugin.countLines",
text: "统计行数",
commandId: "myPlugin.countLines",
tooltip: "统计当前文件的行数",
order: 50,
when: (ctx) => !!ctx.path,
});
}
```
> **多面板共用一条命令表**:分栏 / 浮窗的每份编辑器都会渲染同一个「扩展」入口、点击都打到同一份
> 命令注册表。若不希望多面板重复触发副作用,请在命令处理器内部自行去重。
## 扩展菜单贡献点
底部「扩展」弹出菜单是插件注册动作的统一归集处。除上一节的 `statusbar.register` 外,另有一组
语义完全相同、但**独立注册表**的方法 `statusbar.registerMenu` —— 二者最终都合并渲染进同一个「扩展」
菜单(按 `order` 升序混排),只是给插件两个可选的登记入口,便于把「常驻动作」和「分组动作」分开管理。
- 无任何注册项时,底栏不显示「扩展」入口;有条目时才出现,点开向上弹出。
- `when(ctx)` 为假的条目在菜单里**置灰禁用**(让用户仍能看到该动作存在、只是当前不可用)。
window API(挂在 `statusbar` 命名空间下的三个新方法):
| 方法 | 说明 |
| -------------------- | ---------------------------------------------------- |
| `registerMenu(item)` | 注册扩展菜单项(幂等:同 id 覆盖,按 `order` 升序)。契约同 `StatusBarItem` |
| `unregisterMenu(id)` | 注销扩展菜单项(不存在时静默) |
| `listMenu()` | 当前已注册扩展菜单项列表 |
```js
const api = window.__dshFileWorkbenchVSCode__;
if (api) {
api.commands.register("myPlugin.runTests", (ctx) => { /* … */ });
// 底栏「扩展」菜单里出现一条,点击即 executeCommand
api.statusbar.registerMenu({
id: "myPlugin.runTests.item",
text: "▶ 运行贡献点测试",
commandId: "myPlugin.runTests",
tooltip: "运行贡献点集成测试",
order: 1,
});
}
```
## 工作台扩展菜单贡献点(文件工作台)
上面两节的状态栏 / 扩展菜单都挂在**文件编辑器**上。**文件工作台**(`__dshFileWorkbenchWorkbench__`)
也有了自己的底部「扩展」弹出菜单——入口在面板左下角、后台任务按钮旁,点开向上弹出,逐条展示插件注册的
动作。**它与编辑器的两套注册表完全独立**:在工作台注册的条目不会出现在编辑器里,反之亦然;命令表
(`commands`)则仍是全局共享的一份。
window API 命名空间:`window.__dshFileWorkbenchWorkbench__.statusbar`
| 方法 | 说明 |
| ---------------- | ----------------------------------------- |
| `register(item)` | 注册工作台扩展菜单项(幂等:同 id 覆盖,按 `order` 升序)。契约见下表 |
| `unregister(id)` | 注销(不存在时静默) |
| `list()` | 当前已注册的工作台扩展菜单项列表 |
条目契约与编辑器的 `StatusBarItem` 一致(`id/text/commandId/tooltip/order/when`),只是传给命令处理器 /
`when` 谓词的上下文是 `{ path: null, projectDir }`——工作台没有「编辑器激活标签」概念,故 `path` 恒为
`null`,`projectDir` 为当前工作区根目录(未打开时为 `null`)。
```js
const wb = window.__dshFileWorkbenchWorkbench__;
if (wb) {
wb.commands.register("myPlugin.wbAction", ({ projectDir }) => {
if (!projectDir) return;
// 你的实现……(示意:对当前工作区做某件事)
});
wb.statusbar.register({
id: "myPlugin.wbAction.item",
text: "对工作区执行操作",
commandId: "myPlugin.wbAction",
tooltip: "对当前工作区根目录执行操作",
order: 50,
when: (ctx) => !!ctx.projectDir, // 未打开工作区时置灰禁用
});
}
```
> 与编辑器同理:无任何注册项时底栏不显示「扩展」入口;`when(ctx)` 为假的条目置灰禁用而非隐藏。
### 早期注册
插件脚本**先于本插件 Vue bundle 加载**也没问题:client 侧会先在 window 上放一套代理 API,
`statusbar.register` / `commands.register` 等调用被缓存,bundle 就绪后自动补放到真实注册表。
两点注意:
1. **用函数引用调用,不要解构/缓存方法**——代理的就绪切换是整体替换 window 上的对象,
提前解构出来的旧函数引用不会跟着换:
```js
// ✅ 始终经对象取方法
window.__dshFileWorkbenchWorkbench__.statusbar.register(item);
// ❌ 早期解构拿到的是旧代理引用,绑定后可能失效
const { register } = window.__dshFileWorkbenchWorkbench__.statusbar;
```
2. **别依赖早期的 `list()` 回读**:代理在绑定前只反映影子表内容,最稳妥是在 UI 可见后再查询。
## 后台任务贡献点(文件工作台)
文件工作台自带一套「后台任务」系统——上传 / 压缩 / 解压等长操作会登记进底部状态栏的任务按钮,
点开可看运行中列表、历史与归档。**v1 契约即含**:外部插件也能把这套系统当作通用进度登记处复用——
自己的耗时操作(批量处理、拉取远端数据等)用 `start()` 登记,即可出现在同一个任务面板里,
用户统一可见、可追溯。
window API 命名空间:`window.__dshFileWorkbenchWorkbench__.backgroundTasks`
| 方法 | 说明 |
| --------------------- | --------------------------------------------------------------------------------------------- |
| `start(label, opts?)` | 发起一个后台任务,返回句柄 `{ step, updateLabel, done, fail }`。`opts`: `{ detail?, fileType?, fileSize? }` |
| `clearFinished()` | 清除已结束任务(活跃历史先归档再清空)。返回 Promise |
| `clearAll()` | 清空全部任务(含运行中)。返回 Promise |
句柄方法:
| 方法 | 说明 |
| ------------------------------------------------- | -------------------------- |
| `step(msg, file?, detail?, fileType?, fileSize?)` | 记录一个中间步骤(仅运行中生效),进入任务日志时间线 |
| `updateLabel(label)` | 修改任务描述文案(运行中展示与结束后记录同步更新) |
| `done(msg?)` | 标记成功(可带补充消息),任务转入历史 |
| `fail(msg?)` | 标记失败(带原因) |
```js
const wb = window.__dshFileWorkbenchWorkbench__;
if (wb) {
const task = wb.backgroundTasks.start("导出报告", { detail: "report.pdf" });
// 异步推进:每完成一步记一条日志,最后收尾
(async () => {
try {
for (let i = 1; i <= 3; i++) {
await new Promise((r) => setTimeout(r, 500)); // 你的真实工作……
task.step(`生成第 ${i} 页`, `page-${i}.png`);
}
task.done("已导出 3 页");
} catch (e) {
task.fail(String(e?.message ?? e));
}
})();
}
```
> **注入视图激活时后台任务仍可用**:在文件工作台里切到某个外部注入视图时,顶部命令栏会被禁用,
> 但底部状态栏**只置灰本地导航区(文件信息 / 视图切换)**,最左的后台任务按钮保持可点击、可查看——
> 因为插件很可能正往这里登记耗时任务,需要随时点开看进度。
## 运行时导入 `.js` 插件(插件管理视图)
除了「随宿主 profile 安装 cordis bundle」这条传统路径,文件编辑器还内置了一个**「插件管理」
Activity Bar 视图**(图标 puzzle,恒排在视图列表最后),让用户**直接把 `.js` 插件拖进浏览器即时
生效——无需安装、无需重启 `dsh web`**。官方 11 件套件插件(引用查找 / 书签 / 外部诊断 / TODO 面板 /
Git 追溯 / 大纲 / 正则高亮 / JSON 转接口 / Markdown 预览 / 十六进制查看 / 本地历史)列在该视图里,
可一键启用 / 停用;它们不再构建期内嵌,而是 bootstrap 时经 host `GET /plugin-index` +
`GET /plugin-src?k=` 在运行时推导,不依赖 profile 安装。
### 支持的三种 `.js` 形态
1. **源码形态(最简,推荐手写 / AI 生成)**:与 `plugins/*.js` 一致的三种顶层导出——
`export const meta = {…}`(纯 JSON 字面量)+ `export const inject = []` +
`export function apply(_ctx)`。导入时宿主**自动打包**成下述 loader bundle(与
`npm run build` 同一套规则),名称/描述取自 `meta`,无需任何构建步骤。
2. **ModuleLoader bundle(与套件产物一致)**:顶层两段声明——先 `manifest(...)` 清单、再 `load(...)` 模块:
```js
// 清单:纯数据。「插件管理」导入时只读它来填名称/描述/版本,无需执行任何插件代码。
window.__ModuleLoader__.manifest({
id: "my.plugin", name: "我的插件", version: "1.0.0",
description: "……", nameEn: "My Plugin", descriptionEn: "… Counterpart of VS Code XXX.",
});
window.__ModuleLoader__.load({
id: "my.plugin",
factory: (require) => {
function apply() {
const api = window.__dshFileWorkbenchVSCode__;
api.activityBar.register({ id: "my.view", title: "我的视图", icon: "code", order: 70, mount(el, ctx) { /* … */ } });
}
return { apply, inject: [] };
},
});
```
「插件管理」会装一个 `__ModuleLoader__` shim 接住这两条:`manifest()` 归档元数据(列表显示
`name/description/nameEn/descriptionEn/version`,缺清单时回退文件名),`load()` 登记模块、
启用时取出 `factory` 执行 `apply()`。本仓库套件的写法见 `plugins/*.js` 的
`export const meta = {...}` 块——构建期 `scripts/pack-plugins.mjs` 自动生成上面两段外壳。
3. **极简自注册脚本**:不套 loader,直接在顶层调
`window.__dshFileWorkbenchVSCode__.activityBar.register(...)` 亦可被识别加载(列表回退文件名显示)。
> **给 AI 助手**:运行时可经宿主直接拉走作者资源——
> `GET /api/dsh-file-workbench/plugin-src?k=_skeleton`(完整可改写的源码形态骨架)与
> `GET /api/dsh-file-workbench/assets/plugin-authoring-check.js`(静态自检脚本,配合本地 Node
> 验证约定)。让本机 AI 工具读本文档 + 骨架即可产出可一键导入的插件。
> 插件的 `mount(el, ctx)` 契约、`ctx.editor` 门面、命令/状态栏贡献点等**与本文其余章节完全一致**——
> 运行时导入只是换了一种「把脚本喂给页面」的方式,能力不打折。
### 三种导入入口
- **导入本地 `.js`**:选择本机文件 → 读取文本 → 先静态读出其 `manifest(...)` 清单填列表元数据 → 立即执行并启用。
- **从 URL 导入**:填 `http(s)` 地址 → 经宿主 `POST /fetch-plugin` 服务端代拉(绕开浏览器 CORS,并对
内网/环回地址做基础 SSRF 拦截、限 2MB)→ 读清单 → 执行并启用。
- **内置种子**:bootstrap 时经 host `/plugin-index`(清单)+ `/plugin-src`(bundle)推导进列表,默认「已停用」,勾选即启用。
### 启停 / 移除 / 持久化
- **启用**:执行插件代码,并按「注册表 diff」记录它新增的视图 / 命令 / 状态栏项 / 扩展菜单项归属。
- **停用**:据归属逐条 `unregister`,图标与贡献点即刻消失(激活中的视图会被卸载并调用其清理函数)。
- **移除**:先停用再从列表剔除(内置项也可移除=彻底隐藏)。
- **持久化**:导入项连同**源码快照**写入 `/persist?k=dsh-fw.userPlugins`;下次打开编辑器自动重新加载并
恢复上次的启用状态。URL 来源离线时仍可用(用的是快照,不重新联网)。
### 信任边界
运行时导入等价于「用户往页面注入任意脚本」——被导入的 `.js` 拥有**当前页面的全部权限**(可读 DOM、
调宿主 REST、发网络请求)。因此**只应导入可信来源**;这与用户自行在控制台执行脚本是同一暴露级别,
宿主不做沙箱隔离(设计上明确取舍)。若两个插件注册了同名 id,后注册者覆盖前者(同既有语义),故插件
id 务必带命名空间前缀。
## 行为须知
1. **重挂载**:切换走再切回插件视图会重新执行 `mount`(面板不保留插件内部状态);
需要保留的状态请由插件自行缓存(如存到模块级变量)。
2. **多面板**:编辑器面板可同时挂多份(DSH 分栏 / 浮窗),一次注册所有面板都会出现;
每个 `el` 独立,互不干扰。文件工作台当前是单实例面板。**状态栏项与命令表同理**——注册一次,
所有编辑器面板的状态栏都会出现该按钮,且共享同一份命令表。
3. **生命周期**:注册表在模块级,编辑器面板卸载再挂载(切换 DSH 右侧 tab)**不会丢注册**;
`unregister(id)` 可彻底移除(图标消失,激活中的视图会被卸载并调用清理函数)。
4. **主题**:插件的 DOM 在工作台变量树下,可直接用 `var(--dsh-fg)` / `var(--dsh-bg2)` 等
token 自动跟随深浅色;或监听 `onThemeChange` 自行处理。
5. **DOM 隔离**:`el` 不是 shadow root,插件样式请自带命名空间类名(如 `myplugin-xxx`),
避免与工作台或其他插件冲突。
6. **滚动**:注入容器 `el` 自身已可纵向滚动(`overflow-y:auto`)。若你的内容包在某个
`overflow:hidden` 的包裹层里(常见于 Tailwind preflight / reset.css 的全局规则),内容会被
裁掉且外层不出现滚动条 —— 表现为「鼠标滚不动」。宿主会在挂载后自动把这类**确实溢出却被裁切**
的包裹层改回可滚动(不影响你有意为之的内部滚动区);一般无需处理,如需自管可自行设置。
7. **命令抛错**:状态栏按钮点击时,命令处理器抛出的异常会被面板捕获并以 error toast 呈现,
不会中断编辑器;主动 `executeCommand` 调用则异常向上冒泡给调用方。
8. **动态文案用 `textFn`,不要反复 `register`**:条目契约新增可选 `textFn: () => string`——
宿主每次渲染「扩展」菜单时现取一次,用于展示进度类文本(如「统计中 240/512」)。
以同 id 反复 `register` 来刷新文案会更换注册表数组引用,导致打开中的菜单整体重渲染、
位置抖动;`textFn` 只提供读数,节流(如每 N 个文件更新)由插件自行把握。编辑器与工作台
两套注册表均支持。
## 宿主 HTTP API 全清单(已实现,按开放程度分级)
前缀统一 `/api/dsh-file-workbench`,同源直连。除特殊说明外响应都是 `{ ok, data, error? }` 信封;
下文只写 `data` 的形状。路径参数一律是**绝对路径**(本地 `D:\...` / `/home/...`),或 SSH 引用
`ssh:///<远端POSIX路径>`(`/files /read /save /list /detail` 等 fs 接口与 git/svn 均透明支持两种)。
可选 `key` 参数用于多窗口工作区(缺省即当前窗口根)。
⚠️ **安全提示**:宿主无鉴权、同源即可调用——插件能调的边界等于用户自己开控制台的边界(见上节
「信任边界」)。标 🔒 的接口涉及凭据机密或破坏性系统操作,**不建议插件调用**。
### 推荐开放
| 端点 | 方法 | 参数 | `data` |
| --- | --- | --- | --- |
| `/root` | GET/POST | GET `?key`;POST body `{ path }`(`ssh://` 串直接作为根) | `{ root }` |
| `/list` | GET | `?path`(缺省=根,任意目录可列) | 目录列表 `{ entries, root, crumbs, truncated, inaccessible }` |
| `/parent` | GET | `?path` | `{ root, parent }` |
| `/browse` | GET | `?path`(缺省 home) | 同 `/list` 但 entries 附带详情(大小/mtime) |
| `/mycomputer` | GET | — | `{ items }`(盘符/Home/下载/工作区顶层入口) |
| `/drives` | GET | — | `{ drives }`(含容量与卷标) |
| `/files` | GET | `?path`(缺省=根) | 项目文件相对路径索引(「快速打开」用,模糊匹配在前端做) |
| `/read` | GET | `?path&encoding=&bom=1\|0` | `{ content, size, mtime, encoding, hasBom, eol, binary }`;行尾已归一为 LF;>8MB 报 413 |
| `/save` | POST | `{ path, content, encoding?, hasBom?, eol?, expectedMtime?, force? }` | `{ path, mtime }`;磁盘已被别处改写且未 `force` → **412** `{ code:"mtime-conflict" }` |
| `/search` | GET | `?q&path&limit(≤2000)&case=1®ex=1` | `{ matches: rel[], byContent: rel[], snippets: {rel:片段}, truncated, scope }`(rel 相对搜索范围) |
| `/grep` | GET | `?q&path&sub&case=1®ex=1&word=1&include=&exclude=` | `{ files: [{ rel, hits: [{ ln, text }] }], total, truncated, scope }`;`sub` 限定子目录,rel 仍相对项目根 |
| `/detail` | GET | `?path` | stat 详情(size/mtime/目录标志等) |
| `/download` | GET | `?path&inline=1` | 原始字节流(`inline=1` 供 PDF/图片 iframe 内联加载),非 JSON 信封 |
| `/_read-image` | GET | `?path` | 原始 image/* 流;非图片扩展名 400 |
| `/openExternal` | POST | `{ path }` | `{ path }`(系统默认程序打开) |
| `/plugin-data` | GET/POST | GET `?k`;POST `{ k, v }`(v ≤8MB) | GET 回 value(不存在为 `null`);key 建议 `dsh-fw.<插件>.` |
| `/plugin-index` | GET | — | 内置插件清单数组 `[{ name, id, titleZh, description }]` |
| `/plugin-registry` | GET | — | 在线注册表数组 `[{ name, url, title?, description?, descriptionEn? }]`(离线回退随包 registry.json) |
| `/plugin-src` | GET | `?k=` 或路径形态 `/plugin-src/.js` | bundle 原文(JavaScript,不套信封),可互读其他插件源码作参考 |
| `/plugin-doc` | GET | `?`(裸路径)或 `/plugin-doc/.md` | 作者文档原文(markdown,不套信封) |
### 文件 CRUD(受工作区守卫:root 外写需用户开启「允许操作工作区外」prefs.allowOutsideRoot,否则 403;受保护系统目录恒 403)
| 端点 | 方法 | 参数 | `data` |
| --- | --- | --- | --- |
| `/mkdir` | POST | `{ path }` | `{ path }`(解析后安全路径) |
| `/touch` | POST | `{ path }` | `{ path }` |
| `/rename` | POST | `{ from, to }` | `{ path: to }`(跨目录=移动) |
| `/copy` | POST | `{ src, destDir }` | `{ path: dest }`(重名自动加后缀) |
| `/remove` | DELETE | `?path` | `{ path }` —— **移入系统回收站**,可经 recycle-* 恢复 |
| `/upload` | POST | query `?dir=&name=`,body 为**原始文件字节**(非 JSON) | `{ path }` |
| `/compress` | POST | `{ path, to? }`(to 缺省=同目录 `.zip`,重名自动避让) | `{ path: zip }` |
| `/extract` | POST | `{ zipPath, destDir? }`(destDir 缺省=zip 所在目录) | `{ destDir, count }` |
| `/replace` | POST | `{ scope?, q, replacement, caseSensitive?, regex?, wholeWord?, preserveCase?, include?, exclude? }` | `{ changed: [{ rel, count }], files, replacements, truncated }`;**严格限工作区根内**,ssh 根 501 |
### Git(读写均可;ssh 引用在远端服务器上执行 git)
GET 传 `?path=`,POST 放 body `{ path, ... }`。
| 端点 | 方法 | 额外参数 | 说明 |
| --- | --- | --- | --- |
| `/git/status` | GET | — | 目录 git 状态徽标 |
| `/git/panel` | GET | — | 仓库级快照(面板数据) |
| `/git/log` | GET | `?count`(缺省 20) | 提交历史 |
| `/git/diff` | GET | — | 单路径改动文本 |
| `/git/config` | GET/POST | POST `{ name, email }` | user.name/email 读写 |
| `/git/gh-releases` | GET | — | GitHub Releases 列表(远端 skipped) |
| `/git/add` `/unstage` `/ignore` `/discard` | POST | — | 暂存/取消暂存/加 ignore/还原 |
| `/git/commit` | POST | `{ message }` | 提交 |
| `/git/branch` | POST | `{ action: "create"\|"checkout"\|"delete", name }` | 分支操作 |
| `/git/sync` | POST | `{ action: "pull"\|"fetch"\|"push" }` | 同步 |
| `/git/clone` | POST | `{ url, dir, name?, depth?, accountId? }` | 克隆到新目录(dir 可为 ssh 引用→远端克隆;已存在 409) |
| `/git/gh-release` | POST | `{ tag, name?, body? }` | 发布 GitHub Release(仅本机) |
| `/git/run` | POST | `{ args: string[] }` | **任意 git 子命令**(如 `["blame","-L","1,10","file"]`) |
### SVN(与 git 对称,本机 + ssh 远端 exec)
| 端点 | 方法 | 参数 | 说明 |
| --- | --- | --- | --- |
| `/svn/info` | GET | `?path` | 探测工作副本 + svn 环境 |
| `/svn/checkout` | POST | `{ url, dir, name?, revision?, accountId? }` | 检出到新目录 |
| `/svn/run` | POST | `{ path, args: string[] }` | 任意 svn 子命令(自动注入匹配账号凭据) |
### 常驻终端(ConPTY / 远端 pty)
会话按 `session` id 隔离、跨连接常驻;`kind:"ssh"` 会话手动输密码登录(输入被截获为口令行)。
| 端点 | 方法 | 参数 | 说明 |
| --- | --- | --- | --- |
| `/exec-open` | POST | `{ session, kind:"local"\|"ssh", shell?("cmd"\|"powershell"), cwd?, hostId?, remote? }` | 建立/复用会话,回 `{ cwd, kind }`(**必须核对 kind**,老宿主会把 ssh 静默建成本机) |
| `/exec-mux-ws` | WS 升级 | 一条连接覆盖全部会话,帧带 `session` 字段 | 输出流主链路(SSE 兜底:`/exec-mux-stream`) |
| `/exec-stream` | GET(SSE) | `?session&shell&cwd&key` | 单会话输出流 |
| `/exec-input` | POST | `{ session, data }` | 写 stdin(`\r` 即执行) |
| `/exec-resize` | POST | `{ session, cols, rows }` | 尺寸上报(全屏程序需要) |
| `/exec-kill` | POST | `{ session }` | 终止(杀进程树) |
| `/term-env` | GET | — | `{ elevated }` 宿主是否提权 |
> 插件跑一次性命令的更简路径:`/git/run` 只收 git;要跑任意程序就 `/exec-open` + `/exec-input` +
> 监听 mux 流,或考虑把命令包装成 git alias。
### 推送通道 WebSocket:`/push`
一条连接三类订阅(JSON 文本帧):
```
发 { type:"watch", paths:[...] } → 收 { type:"changed", items:{ [path]:{mtimeMs,size}|null } } // 落盘改动,1s 采样,null=已删除
发 { type:"session-watch", id:string|null } → 收 { type:"session-ev", ev:{type:"snapshot"|"files"|"status",...} } // AI 会话触碰的文件
发 { type:"ssh-watch", ids:[...] } → 收 { type:"ssh-status", items:{ [id]:{alive,error?} } }
发 { type:"ssh-check", ids:[...] } → 无条件立刻回推一次 ssh-status
```
首次 watch 只记基线不推送;「外部改动检测」「SSH 连通」都由此驱动,插件无需轮询。
(SSE 等价旧出口 `/stream/session?session=` 仍在,但占 HTTP 连接池配额,优先用 WS。)
### 谨慎开放 / 不建议插件使用
- `/persist`:GET `?k` / POST `{k,v}` —— 键有**白名单**(prefs/favorites/recent/layout/vscode/…),
其中 `ssh-hosts`、`accounts` 两个键**含明文凭据文件内容**,插件不应读写;跨重启存储请一律用 `/plugin-data`。
- `/task-archives`:GET(`?list` 只回日期)/ POST `{ map }` **整体覆写**后台任务归档——会冲掉宿主自己的记录,勿动。
- `/recycle-list` `-count` `-restore` `-delete` `-empty`:系统回收站全家桶;`-delete`/`-empty` 不可逆,
插件最多该用 `/recycle-list`+`/recycle-restore` 做「撤销删除」类功能。
- 🔒 `/ssh/*`(add/update/remove/test/trust/cache/ping)与 🔒 `/accounts/*`(增删改/测试/写入系统):
主机与凭据管理属宿主 UI 职责;唯一例外 `/accounts/match?kind=git|svn&url=`(只回抹密后的命中账号,
可用于提示「本仓库将用 xxx 提交」)。
- `/subagent/spawn`:POST `{ path?, isDir?, instruction?, session? }` → 拉起官方 DSH 子代理会话。
能力很强但会消耗用户会话资源,插件调用前应明确征得同意。
- `/fetch-plugin`:POST `{ url }` 服务端代拉插件源码(SSRF 拦截 + 2MB 上限)——插件管理专用。
## API 参考(lib 侧)
- 注册表实现:`src/vue/stores/activityBar.ts`(编辑器的 `registerActivityView`,工作台的
`registerWorkbenchActivityView`,以及对应的注销/列表 API;命令贡献点 `registerCommand` /
`executeCommand` / `unregisterCommand` / `listCommands` / `hasCommand`;状态栏项贡献点
`registerStatusBarItem` / `unregisterStatusBarItem` / `listStatusBarItems`;扩展菜单贡献点
`registerExtensionMenuItem` / `unregisterExtensionMenuItem` / `listExtensionMenuItems`;工作台扩展菜单
贡献点(独立注册表)`registerWorkbenchStatusBarItem` / `unregisterWorkbenchStatusBarItem` /
`listWorkbenchStatusBarItems`;后台任务贡献点 `backgroundTasks.start` 桥接到 `composables/session/tasks`)
- 面板集成:`src/vue/components/business/vscode/VSCodePane.vue`(编辑器底栏「扩展」弹出菜单 + 状态栏项渲染)
与 `src/vue/components/business/explorer/StatusBar.vue`(工作台底栏「扩展」弹出菜单,独立注册表;
ExplorerPane 负责扩展视图挂载;注入激活时底部仅置灰本地导航区、保留后台任务按钮 + 「扩展」入口可点击)
- **v1 编辑器门面 `ctx.editor`**:契约见 `stores/activityBar.ts` 的 `EditorAccess`。桥接点——
`stores/vscode.ts` 增加按槽活视图注册表(`setLiveEditorView` / `getLiveEditorView`)与文档/选区变更
多播总线(`onEditorDocChange` / `onEditorSelectionChange` + `emit*`);`CodeEditor.vue` 增加 `extCompartment`
装饰层(`setExtension` 经 `defineExpose` 暴露,按 path 暂存随文件切换显隐)并在 `handleUpdate` 内向总线广播;
`VSCodePane.vue` 用本面板 `store.slot` 组装出 `editor` 挂进 `extCtx`
- **宿主装饰工厂 `createDecorations`**:因 CM6 打进私有 vendor chunk、同源页插件 `import` 到的是不同实例,
`setDecorations(自建扩展)` 对同源页失效。改由 `components/business/vscode/pluginDecorations.ts`(唯一持真实
CM6 的模块)把纯 JSON `DecorationSpec` 编译成 `ViewPlugin`+gutter `StateField` 扩展;`CodeEditor.vue` 的
`setDecorationsSpec(items)` 按 path 缓存 `Decorator` 句柄、首建注入 / 再调 `dispatch(ReconfigureGutter)` 就地重绘;
`VSCodePane.vue` 的 `createDecorations(spec)` 透传当前激活文件并返回 `{ extension, update }`
- **运行时 `.js` 导入 + 插件管理**:`stores/userPlugins.ts`(模块级列表 + `__ModuleLoader__` shim + eval/apply +
注册表 diff 归属 + `/persist?k=dsh-fw.userPlugins` 快照持久化 + 启停/移除/导入 API);宿主内置视图
`components/business/vscode/pluginManagerView.ts`(`registerPluginManagerView()`,由 VSCodePane onMounted 调用一次);
官方套件已收进本仓库 `plugins/.js`(纯 JS 单文件、无版本管理、目录内只有 .js 插件,
清单写在源码的 `export const meta = {...}` 块里),`scripts/build.mjs` 构建时经
`scripts/pack-plugins.mjs`(静态校验 meta 并生成 `manifest(...)` + `.load(...)` 两段外壳)
直接写入 `lib/web/plugin-src/`——内置列表**不再构建期内嵌**,
由前端 bootstrap 经 host `GET /plugin-index`(清单)+ `GET /plugin-src?k=`(bundle,顶层
`__ModuleLoader__.manifest(...)` 声明提供元数据,无需执行插件代码)在运行时推导;URL 导入走 host 新路由
`POST /fetch-plugin`(`src/host/routes/routes-plugins.ts`,服务端代拉 + SSRF 拦截 + 2MB 上限)
- dev 演示插件:`stores/activityBar.ts` 内 `demo.hello`(视图 + ctx 互动)与 `demo.statusPing`
(命令 + 状态栏项)、`demo.extMenuPing`(编辑器扩展菜单条目)、`demo.wbStatusPing`(工作台扩展菜单条目),
仅 `import.meta.env.DEV` 注册,可作参考实现
- 测试探针实体按钮:`test/contribution-points.test.js` 的 `installTestButtons()`(控制台执行
`import('/api/dsh-file-workbench/assets/__dsh-test-probe.js').then(m => m.installTestButtons())`)——
编辑器底栏「扩展」菜单出现「▶ 运行贡献点测试」(statusbar.register 路径)、工作台底栏「扩展」菜单
出现「▶ 运行工作台扩展测试」(workbench statusbar.register 独立注册表路径,点击 toast 当前工作区根),
另在两侧各挂一个「贡献点测试报告」视图;`uninstallTestButtons()` 对称移除。测试弹层(工作台提示、
汇总卡片)复用框架 `.fw-toast` 样式类,随 `html[data-theme]` 自动切换深浅色背景
## 示例:结合全局内容搜索做一个「TODO 列表」插件
```js
window.__dshFileWorkbenchVSCode__.activityBar.register({
id: "todo.finder",
title: "TODO 列表",
icon: "search",
when: (ctx) => !!ctx.projectDir, // 未打开项目时隐藏
async mount(el, ctx) {
const list = document.createElement("div");
list.style.cssText = "padding:8px;font-size:12px;color:var(--dsh-fg,#c9d1d9)";
el.appendChild(list);
// 用工作台 API 扫描项目文件,把命中 TODO 的行做成可点击列表
const res = await fetch(`/api/dsh-file-workbench/grep?dir=${encodeURIComponent(ctx.projectDir)}&q=TODO`).then(r => r.json());
for (const hit of res.hits ?? []) {
const row = document.createElement("div");
row.textContent = `${hit.file}:${hit.line}`;
row.style.cursor = "pointer";
row.onclick = () => ctx.openFile(hit.file, { line: hit.line });
list.appendChild(row);
}
},
});
```
> 上例的接口路径仅为示意,实际请使用工作台 host 提供的搜索接口或你自己的服务。