# dsh-multi-root-explorer（工作区资源管理器）

给 [DeepSeek Harness](https://github.com/anywhere-labs/dsh-desktop) 用的工作区浏览器：它接管左侧栏的工作区区域，换成两个选项卡——**对话**按每个会话真实的工作目录归入目录树，**资源**是一个只读的文件树，点文件用 DSH 原生右侧栏预览。

中文 | [English](README.md)

![插件在侧边栏里的样子：已登记的工作区展开为带会话数量的文件夹，上方是「对话 / 资源」选项卡，以及小眼睛、刷新和设置按钮（目录名与会话标题已打码）](assets/sidebar.png)

> **状态：预览版。** 自动化测试全部通过，但尚未在所有 DSH 版本上做完整实机验收。安装前请先看[已知限制](#已知限制)。

## 它能做什么

- **固定多根目录。** 你导入的目录就是根，不会随着会话增减被重算成某个公共祖先。
- **会话变成目录树。** 每个会话按它真实的 `cwd` 归位：当前目录的直接会话排在前面，子文件夹排在后面，逐层递归。`cwd` 在根目录之外的历史会话保留独立入口；没有 `cwd` 的会话单独一组。
- **资源只读浏览。** 目录按层加载并分页，打开一个大盘不会先递归扫全盘。点文件交给 DSH 自己的右侧栏预览，主栏对话不受影响。
- **资源树自己跟上磁盘。** 已加载的目录层每隔几秒拿一次 Host 的目录版本号做探测，**只有真的变了**才重新取那一层，而且在已经翻到的深度内重取——手工翻出来的分页不会被收回去。探测是逐个分片轮着来的，目录再多也不会一次打满。
- **两个选项卡各自独立的隐藏，且完全可逆。** 点标题栏的小眼睛进入选择模式：已隐藏的项会显示出来并处于选中状态。**点**一个未选中的文件夹＝选中它（整个子树随之显示为选中）；**点**一个"被父目录覆盖"的子项＝把它单独排除，祖先展开成同级项、其余保持选中；**单击**已选中的文件夹＝它连同子树一起取消；**双击**已选中的文件夹＝父目录取消、全部子项保持选中，提交后就是一个空文件夹。选中的后代按需向 Host 查询该目录自身的层级，所以**没展开过的子文件夹同样会被覆盖**。再点一次小眼睛就只隐藏最终选中的那些；隐藏只影响显示，不动任何文件、目录或会话记录。
- **每个文件夹都有操作菜单。** 右键或右端 `···` 可以在该目录里开启新会话、在系统资源管理器中打开；资源还支持复制绝对路径。新会话**不预先命名**：标题留给 DSH 自己的标题器，按你发的第一条消息命名。
- **拖进输入框就是原生引用。** 资源行是标准拖拽源（`text/plain`、`text/uri-list`，外加一个插件私有的格式），效果固定为 `copy`。拖到对话输入框里时会自动提升成 DSH 原生的**原子引用**——文件图标 + 文件名 + 业务色，和从 `@` 菜单里选中一个候选完全一样。提升失败时（例如落点紧邻文字、补全菜单没有为这段文本打开）会保留一条可读的引用文本并说明原因，不会把草稿弄乱。
- **`@` 能检索所有工作区。** DSH 自带的文件补全只索引会话的工作目录，插件另外注册了一个 `@` 源，覆盖全部已登记工作区（包括其他盘上的根），并在 Host 侧维护一份有界索引：沿用官方的排除目录策略（`node_modules` 等）、切片后台遍历、查询永不阻塞。
- **工作状态诚实。** 会话的状态卡和行首圆点不只看会话自己的 agent：**子 agent 还在工作时**（即使主 agent 已经停下）以及 **goal 仍在逐轮推进时**（两轮之间的空档），都显示「工作中」。
- **支持原生归档。** 归档/取消归档走 DSH 自己的服务，并且和「永久删除」明确分开。
- **卸载后不会丢对话。** 插件运行期间会把有普通会话的目录登记为 DSH 原生工作区，所以停用或卸载插件后，你的对话仍然能在原生界面找到。

## 环境要求

- DeepSeek Harness `>= 0.2.0-rc.1`（声明在 `dsh.engines.dsh`）。
- 浏览器需支持 **Popover API**（`element.showPopover`）。菜单和悬停卡渲染在顶层；不支持时插件会明确报错并关闭菜单，而不是把菜单放错位置。
- 通过回环地址访问 DSH。插件的 HTTP 路由会拒绝非回环 socket、非回环 `Host` 头和跨站请求。

插件**没有任何运行时依赖**，只用 DSH 客户端模块加载器已经提供的 `react`。

## 安装

四种方式，任选一种。**装完都要重启 DSH（重启应用与 Host），然后刷新页面** —— Host 路由不支持热重载，只刷新页面不够。

> 💡 **方式 2、3 需要机器上有 `dsh` 命令。** 桌面应用版用户如果提示找不到命令，请用 **方式 1** 或 **方式 4** —— 这两条完全不依赖命令行。

### 1. 应用内插件市场（最省事，不依赖命令行）

打开**设置 → 插件市场**，搜索 `dsh-multi-root-explorer`，一键安装。市场会替你改好 profile 并提示何时重启。社区目录上架前（搜不到时）请用方式 2 或 4。

### 2. npm 包（命令行；永远取最新已发布版本）

```sh
dsh plugin --profile <你的 profile 名> add dsh-multi-root-explorer
```

写**包名**而不是 git 地址：包管理器会取 npm 上的最新已发布版本，以后发新版自动跟上。当前版本号见 [npm 页面](https://www.npmjs.com/package/dsh-multi-root-explorer)。

### 3. 从 GitHub 装（不经过 npm）

**方式 A：预构建包（永远取最新发布版）**

```sh
dsh plugin --profile <你的 profile 名> add https://github.com/Nethur-auro/dsh-multi-root-explorer/releases/latest/download/dsh-multi-root-explorer.tgz
```

链接里的 `latest` 在**请求时**解析，而资源文件名刻意**不带版本号**，所以它永远指向最新一次发布、也不会随发版失效。（本插件没有构建步骤：`lib/` 是随仓库提交的成品，`scripts` 里只有 `test`，所以预构建包和源码装出来的东西一致。）

**方式 B：源码（跟踪 `main` 分支）**

```sh
dsh plugin --profile <你的 profile 名> add git+https://github.com/Nethur-auro/dsh-multi-root-explorer.git
```

装的是仓库 `main` 的当前提交。本项目**每次发布都把 `main` 停在那个发布提交上**，所以 `main` HEAD 与最新 tag 始终是同一点 —— **源码装到的就是最新发布版**。代价是包管理器按 commit 缓存，升级要重装一次。

### 4. 手动改 profile（不依赖命令行）

插件管理器本质上就是改你 profile 的 `$DSH_HOME/profiles/<你的 profile 名>/package.json`（默认 `~/.dsh`）。加依赖和 bundle 条目：

```json
{
  "dependencies": {
    "dsh-multi-root-explorer": "^0.1.8"
  },
  "dsh": {
    "profile": {
      "bundles": ["dsh-multi-root-explorer"]
    }
  }
}
```

然后在该目录里 `pnpm install`，再重启 DSH。手动做和插件管理器做的是同一件事，所以除非有特别理由，优先用方式 1 或 2。

### 怎么确认装上了

看左侧栏：工作区区域应该出现**对话 / Conversations**和**资源 / Resources**两个选项卡，并且有插件自己的标题栏。如果看起来没变化，说明 Host 还没加载新代码——**重启 DSH，而不是只刷新页面**，Host 路由不支持热重载。

## 使用

1. 点「添加工作区」选目录，需要几个根就加几个。
2. 「对话」页显示文件夹和会话；「资源」页显示文件夹和文件。目录第一次展开时才加载该层。
3. 点文件用右侧栏预览。预览需要一个可用会话作为授权上下文。
4. 文件夹右键或 `···`：在该目录开启新会话、在系统资源管理器中打开、（资源）复制路径。
5. 会话右键：打开、重命名、归档、永久删除。永久删除有风险，见[安全与永久删除](#安全与永久删除)。
6. 用**小眼睛**按选项卡隐藏条目；被你隐藏的东西都能在**设置**里恢复显示。
7. 「显示隐藏项」额外显示点开头的名字、`node_modules` 和 `dsh-acl-recovery`——它们默认是隐藏的。

## 数据与隐私

插件自己的数据都在 `$DSH_HOME/storages/workspace-explorer/` 下：

- `config.json`——版本 3：根目录、目录别名、两份互相独立的隐藏列表、展开状态和偏好。v1/v2 在迁移前会先备份；文件损坏或版本比程序新时会拒绝覆盖，而不是静默替换成空配置。
- `native-bridge.json`——插件自己的幂等与进度记录。它只是记账，**不是**你的会话或原生工作区状态的副本。

写入是串行化 + 原子替换的。移除 bundle 不会删掉这个目录；要清理请先确认没有待完成的新会话操作，并且**绝不要**把删除 DSH 会话数据当成插件清理的一部分。

资源浏览从头到尾都是只读的：插件不会在你的工作区里创建、写入、移动或删除任何文件。请求受注册根、`realpath` 边界（含中间链接）和回环/同源校验限制。

## 安全与永久删除

**永久删除是一个早就存在、被刻意隔离的功能，而且不可恢复。** 目标 DSH 版本没有官方会话删除接口，所以那一个动作会直接移除会话自己的持久化产物。它依赖具体版本的存储结构，不是 DSH 支持的接口，应当只当作最后手段。请优先使用归档。

停用插件、卸载插件或清除配置，都**不会**触发这条代码路径。

## 已知限制

- **自动化测试是契约测试，不是实机验收。** 它们用 mock，不碰你的真实会话。「已命名但未发出」的会话跨新 Host 进程是否恢复、卸载后原生列表是否立刻可见、不同缩放下浮层位置是否正确，都还需要在你的版本上验证。不要在正在工作的 profile 上强制退出做持久化测试。
- **把拖拽提升成引用用到了未公开的接口。** 编辑器只把 `text/plain` 当纯文本插入，而引用块只能由输入管线在"选中一个补全候选"时生成，所以插件在文本落位之后调用管线自己的 `pick` 来补上这一步。每一环都做了能力探测，任何一环对不上就**退化成引用文本**（也就是拖拽最初的行为）；但 **DSH 升级若改了这些内部字段，自动提升会失效**——`lib/client.js` 里标了 `ADAPTATION POINT` 注释，写明了依赖的确切字段，便于跟着适配。
- **选中一个文件夹需要一次 Host 查询。** 资源树是分页懒加载的，而"选中父文件夹"必须覆盖它的全部子项，所以插件在点击时按需读一次该目录自身的层级（并翻完分页），而不是只看已经展开过的部分。目录特别大时这一次点击会稍慢。
- **菜单需要 Popover API。** 浏览器不支持时，插件会给出明确错误提示，而不是显示一个位置错乱的菜单。
- **改源码不等于改了运行中的 Host。** 在本仓库改文件不会更新正在运行的 DSH；Host 路由需要重启。

## 测试

无依赖、无需安装：

```sh
node test/run.mjs
# 或 npm test
```

运行器会找出所有 `*.test.mjs` 和 `selftest.mjs`，用同一个 Node 可执行文件依次跑完（当前 160 + 172 + 16）。覆盖范围：目录列举与分页、配置与迁移与各项上限、路由族与信任栅栏、路径命名空间与跨平台拼写、对话树模型、隐藏作用域与目标边界校验、引用文本与 `@` 候选索引及排序、拖拽提升的每一条降级路径、工作状态折叠（子 agent 与 goal）、资源层的新旧比对、原生桥接的创建/重命名/保存契约。

## 设计文档

- [缺陷修复方案与验收矩阵](docs/bugfix-design-2026-10-01.md)
- [树顺序与分栏独立隐藏设计](docs/tree-visibility-design-2026-10-02.md)

## 许可

[MIT](LICENSE) © 2026 Nethur-auro
