Skip to content
dsh.fish
Bundle

dsh-plugin-file-explorer

Workspace file explorer docked in the DeepSeek Harness sidebar: a lazy directory tree of the current session's working directory, a Settings on/off switch, and drag-to-resize.

Source
mabaoguo9527
License
MIT
Updated
Updated 15 hours ago

Readme

# dsh-plugin-file-explorer

**停靠在 DeepSeek Harness 侧边栏里的工作目录文件浏览器。**

[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![DeepSeek Harness](https://img.shields.io/badge/DeepSeek%20Harness-0.1.5--rc.1-4B32C3.svg)](https://github.com/deepseek-ai/DeepSeek-Harness)
[![Node](https://img.shields.io/badge/node-%3E%3D20-brightgreen.svg)](https://nodejs.org)
[![Plugin form](https://img.shields.io/badge/form-profile%20plugin%20%2B%20dynamic%20plugin-orange.svg)](#两种安装方式)

[English](README.en.md) | 中文

它把当前会话**工作目录**的懒加载目录树放在侧边栏左下角——就在 `设置` 那一行的正上方——
自带筛选、刷新、**设置 → 通用**里的开关,以及可拖动的高度边缘。宽度跟随侧边栏列。

![文件浏览器停靠在侧边栏脚部](docs/panel.png)

*面板停靠在侧边栏脚部,位于 `Cordis Plugin` 与 `设置` 上方。宽度与侧边栏一致,高度可拖动上边缘调整。*

```
┌──────────────────────────────┐
│  新会话                       │
│  工作区                 ⌕ ⋯   │
│  ▾ DeepseekWorkSpace         │
│      会话一             2m    │
│      会话二             4d    │
│                              │
│ ──────────────────────────── │  ← 拖动这条边调整高度
│  ▸  文件浏览器            ↻  │
│  筛选文件…                    │
│  ▾ 📁 src                    │
│    ▸ 📁 client               │
│      📄 index.js             │
│      📄 package.json         │
│  ▸ 📁 test                   │
│ ──────────────────────────── │
│  Cordis Plugin     1 running │
│  ⚙ 设置                      │
└──────────────────────────────┘
```

## 目录

- [功能](#功能)
- [前置要求](#前置要求)
- [两种安装方式](#两种安装方式)
- [作为 profile 插件安装](#作为-profile-插件安装)
- [作为动态插件安装](#作为动态插件安装)
- [使用方式](#使用方式)
- [配置](#配置)
- [安全](#安全)
- [实现原理](#实现原理)
- [HTTP 接口](#http-接口)
- [兼容性](#兼容性)
- [疑难排查](#疑难排查)
- [卸载](#卸载)
- [开发](#开发)
- [参与贡献](#参与贡献)
- [许可证](#许可证)

## 功能

| | |
|---|---|
| **停靠而非浮窗** | 渲染在侧边栏自己的脚部座位上,使用侧边栏的底色——没有卡片、边框、圆角或阴影,看起来就是这一列的一部分。 |
| **宽度跟随侧边栏** | 面板测量座位容器,并用 `ResizeObserver` 跟踪变化,所以拖动侧边栏边缘时面板始终对齐。 |
| **拖动调整高度** | 上边缘是一条 8px 拖拽热区(`ns-resize`)。默认高度约为侧边栏的一半,并被限制在脚部分区以上的空间内,标题行永远不会被裁掉。 |
| **懒加载目录树** | 只读取你展开的层级。目录优先排序,展开状态在重渲染后保留,筛选框按名称过滤已加载层级。 |
| **内存态开关** | **设置 → 通用 → 文件浏览器**。面板标题行左侧的箭头是同一个开关的快捷方式——它把面板收起到只剩标题行。 |
| **只读且有围栏** | 宿主半只列目录项,拒绝会话工作目录之外的任何路径,每次列举上限 800 项。 |
| **双语** | 通过 `ctx.locale` 注册英文与简体中文字典,面板跟随界面语言。 |
| **感知收起态** | 侧边栏收起成 56px 竖条时,面板完全不渲染。 |
| **安装无需构建** | `lib/` 以构建产物形式提交,git 依赖可以直接用。 |

## 前置要求

- **带 Web GUI 的 DeepSeek Harness**(`dsh web`)。开发与验证基于 `@deepseek-ai/dsh`
  **0.1.5-rc.1** 及其自带的 `web` profile。
- **Node.js ≥ 20**(宿主半使用了全局 `URL` 与 `Buffer.byteLength`)。
- 宿主需要提供 **`fs`** 与 **`webServer`** 服务——两者都属于标准 Web profile。
- 本插件不使用网络、不需要 API Key、不读取任何凭据。

## 两种安装方式

| | profile 插件 | 动态插件 |
|---|---|---|
| 形态 | 真实的 npm 风格包,作为 composition 的一行挂载 | 两段纯 JS,通过 Cordis 工具加载 |
| 生命周期 | 跨重启存活,属于你的 profile | 只存在于当前 DSH 进程 |
| 安装 | 一条命令 —— `dsh plugin --profile web add …`(自动激活) | 让你的 agent 加载 `dynamic-plugin/` |
| 是否改动 profile | 是 | 否 |
| 适用 | 日常使用 | 单个会话里先试试 |

## 作为 profile 插件安装

### 1. 把包装进 profile

CLI 会把 profile 名之后的参数原样转发给 profile 目录里的 `pnpm`,而插件的模块解析正是
从这个 profile 目录出发的:

```sh
# 直接从 Git 仓库安装(无需发布 npm,lib/ 已是构建产物):
dsh plugin --profile web add github:mabaoguo9527/dsh-file-explorer

# 固定某个发布版本:
dsh plugin --profile web add github:mabaoguo9527/dsh-file-explorer#v0.1.2

# 或者从本地检出安装——开发时用这个:
dsh plugin --profile web add /absolute/path/to/dsh-file-explorer
```

### 2. 重启 Web UI

安装就到这里。本包声明了 `dsh.bundle`,所以 CLI 会**自动把它追加到
`dsh.profile.bundles`**(profile 的有序层列表),并把它的
[`cordis.patch.yml`](cordis.patch.yml) 作为一个层应用:

```jsonc
// $DSH_HOME/profiles/web/package.json,由 `dsh plugin add` 写入
"dsh": {
  "profile": {
    "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-plugin-file-explorer"],
    "patchReload": "live"
  }
}
```

层列表在启动时读取,所以重启一次 `dsh web`。想在不启动服务的前提下检查组合结果:

```sh
dsh --profile web --dump-config | grep -A2 dsh-plugin-file-explorer
```

### 备选:自己挂载 composition 行

如果你不想重启,或者想在正式安装前先评估,同一行也可以作为你自己掌控的 patch 层应用。
仓库已经把它放在 [`cordis.patch.yml`](cordis.patch.yml) 里:

```yaml
- insert:
    - id: file-explorer
      name: 'dsh-plugin-file-explorer'
```

**先不改任何文件试一次**,把该文件当作额外的 patch 层传入:

```sh
dsh --profile web --patch ./node_modules/dsh-plugin-file-explorer/cordis.patch.yml
```

**不走 bundle 路线时长期生效**:把这条 entry 合并进 profile 本来就会加载的 patch 文件
`$DSH_HOME/profiles/web/cordis.patch.yml`(`$DSH_HOME` 默认是 `~/.dsh`)。该文件默认内容
是空数组 `[]`,所以多数情况下直接整体替换;如果里面已经有内容,就把这个 `- insert:`
条目追加到既有的顶层数组里,而不要新增一个 YAML 文档。

patch 文件是**被监听**的(自定义 profile 默认 `patchReload: live`),所以这条路线无需重启:
刷新浏览器页面即可。

> 两条路线只能选一条。既手动挂载了这一行、又把包留在 `dsh.profile.bundles` 里,会导致同一个
> row id 被插入两次。

### 验证

打开 GUI:面板出现在 `设置` 行上方,**设置 → 通用**里会多出一个 **文件浏览器** 开关。
想不用 UI 确认组合结果:

```sh
dsh --profile web --dump-config | grep -A2 dsh-plugin-file-explorer
```

## 作为动态插件安装

[`dynamic-plugin/`](dynamic-plugin) 用单会话的 Cordis 动态插件实现了同样的功能:
`host.js` 与 `client.js`,都是纯 JavaScript,不需要包、不需要 composition 行、不改
profile。

把这两个文件交给 DSH agent,让它定义并运行插件即可——agent 会用两半代码调用
`cordis_define`,然后调用 `cordis_run`。面板立刻出现;DSH 进程重启后消失,因为动态插件
是进程内的。具体提示词与和 profile 插件的差异见
[`dynamic-plugin/README.md`](dynamic-plugin/README.md)。

## 使用方式

| 操作 | 结果 |
|---|---|
| 点击目录行 | 展开或收起该层,首次展开时才读取 |
| 点击文件行 | 选中它,底部显示它相对工作目录的路径 |
| `筛选文件…` | 按名称过滤已加载的层级;目录仍可继续展开 |
| `↻` | 重新读取根目录以及所有已展开的层级 |
| 标题行的箭头 | 把面板收起到只剩标题行,或再次展开 |
| 拖动上边缘 | 调整高度;不会超过侧边栏脚部,也不会低于 120px |
| **设置 → 通用 → 文件浏览器** | 整块开关 |

标题行显示工作目录的末级目录名,悬停可看到绝对路径。

面板在运行中的应用里就是这样一块:![面板在运行中的应用里](docs/screenshot.png)

## 配置

插件**不接受任何插件配置**。它唯一的偏好就是那个开关,而且该状态刻意保持在**内存**中:
它存在于插件 fiber 里,插件(重新)加载时重置为*开*,不会写入 `settings.yaml`。动态插件
本身就是进程内的,而一个布尔值不值得单开一个 settings 命名空间,所以这个开关是有意做成
会话级的。

高度同理,保存在内存中,重新加载后回到默认高度。

## 安全

宿主半之所以存在,是因为浏览器读不到 harness 的文件系统。它的全部对外面就是一个只读路由,
限制都是刻意设计的:

- **收容校验**:每个请求都会先把会话工作目录解析为根,再对**解析后的**目标做
  `fs.contains(root, target)` 判断,因此逃逸出根的 `..` 段与符号链接会被 `400` 拒绝,
  而不是被渲染出来。
- **只读元数据**:只使用 `fs.stat` 与 `fs.listDir`,**从不读取文件内容**,也没有任何写入、
  重命名、移动或删除的接口。
- **有上界**:一次列举最多返回 800 项。
- **默认本地**:路由由 harness 的 Web 服务器提供,因此继承该服务器的绑定地址与可信主机
  策略。如果你把 GUI 绑定到局域网网卡,那么任何能访问 GUI 的人都能列举工作目录内的目录。
  除非你确实需要,否则请绑定回环地址。
- **不碰凭据**:插件从不访问 `ctx.credentials`,也不发起任何对外网络请求。

## 实现原理

一个功能,两半实现。

**宿主半 —— `lib/index.js`。** 一个 Cordis 插件(`apply(ctx)` +
`inject = ['webServer', 'fs']`),注册一个 `exact` 路由 `/dsh-file-explorer/tree`。
处理函数通过组合出来的 `fs` 服务解析目标目录、执行上面的收容与数量限制,然后返回 JSON。
注册放在 `ctx.effect(...)` 里,所以卸载插件时会释放该路径,而不是留下悬空的处理函数。

**浏览器半 —— `lib/client.js`。** 以客户端模块系统从 `exports["./client"]` 提供的
构建产物格式发布,也就是交给
`window.__ModuleLoader__.load({ id, factory })` 的一个工厂函数。它只请求基线模块
`react`,这也是 `dsh.client.external` 为空、完全不需要打包器的原因。它导出的
`inject = ['slots', 'locale']` 让 Cordis 在 `apply` 执行前先等待座位注册表与字典注册表。

**它坐在哪里。** 两个增量座位,都是 `replaceRisk: none`:

| 座位 | 用途 | 注册 |
|---|---|---|
| `sidebar.footer.action` | 侧边栏脚部、`设置` 上方那一行 | `id: dsh-file-explorer`、`order: -100` |
| `settings.general.item` | 设置 → 通用 里的一个偏好行 | `id: dsh-file-explorer`、`order: 30` |

**为什么面板用绝对定位而不是普通流。** `sidebar.footer.action` 是一个横向 flex 行,而
内置占用者(`Cordis Plugin` 触发器)是 `flex: none; width: 100%`——它独占整行。于是任何
在流内的兄弟条目都会被压成**精确的 0 宽度**:高度保留(留下一片空白),但每个子元素都被
挤成只剩自己的内边距并被裁掉。因此这里的条目是一个**零尺寸 flex 项**,只作为定位锚点,
面板本身脱离该行绝对定位:

- `left: calc(-1 * var(--dsh-sidebar-inline-padding))` 贴到侧边栏列的左边缘;
- 锚点上的 `bottom: 0` 就是脚部分区的顶部边缘,于是面板**向上**生长,正好落在内置触发器
  上方,不需要任何写死的偏移;
- 该列的 `overflow: hidden` 会把面板裁在侧边栏内,因此它永远不会溢到会话栏。

**宽度。** 侧边栏不向座位条目暴露宽度——既没有对应的 slot prop,也没有 CSS 变量。因此面板
从自己的节点向外找第一个有真实盒子的祖先(座位容器),在其宽度上左右各补一次继承来的
`--dsh-sidebar-inline-padding`,并用 `ResizeObserver` 跟踪变化。以上任何一步失败时,都会
回退到 CSS 里的固定宽度,面板照常渲染。

**高度。** 上边缘使用 pointer capture 拖拽。拖拽的起始状态放在插件闭包里而不是组件内,
因为 React 每次渲染都会重建组件内的绑定,否则拖到一半的拖拽会被重置。

## HTTP 接口

浏览器半使用这个路由;它足够稳定,可以直接脚本化调用。

```http
GET /dsh-file-explorer/tree?base=<绝对路径>&path=<相对 base 的路径>
```

`base` 是会话工作目录(`.` 表示回退到文件系统后端自己的默认值)。`path` 相对 `base`;
`.` 表示 `base` 本身。

```jsonc
// 200
{
  "path": "/Users/you/project/src",     // 解析后的绝对目录
  "entries": [
    { "name": "client",       "type": "directory", "size": null },
    { "name": "package.json", "type": "file",      "size": 812 }
  ]
}
```

`type` 取值 `file`、`directory`、`other`。后端不上报大小时 `size` 为 `null`。错误返回
`{ "error": "<消息>" }`,状态码为 `400`(超出工作目录、不是目录)、`404`(不存在)、
`405`(方法不允许)或 `500`(后端失败)。

## 兼容性

- 基于 **`@deepseek-ai/dsh` 0.1.5-rc.1**(Web profile)构建与验证。
- 只使用有文档的接缝:`fs` 与 `webServer` 服务、`slots` 与 `locale` 客户端服务、
  `sidebar.footer.action` 与 `settings.general.item` 座位、`dsh.client` 包声明,以及
  用于清理的 `ctx.effect`。
- 唯一的结构性假设是上面描述的宽度测量。它写得比较防御,失败时退化为固定宽度而不是报错;
  但如果未来版本改变了侧边栏的 DOM 嵌套,请预期退化为回退宽度。
- 主题颜色都取自已有的 Design Token 变量(`--dsw-specific-sidebar-fill`、`--dsw-alias-*`),
  因此浅色/深色主题自动跟随,无需额外处理。

## 疑难排查

**面板不见了。**
按顺序检查:composition 行是否存在(`dsh --profile web --dump-config`);侧边栏是否被收起成
56px 竖条;**设置 → 通用 → 文件浏览器** 开关是否为开(重新加载插件会把它恢复为开)。

**面板在,但是空的并带一行错误。**
错误文本就是宿主半原样返回的内容。`path is outside the working directory` 通常意味着会话的
工作目录在面板打开期间变了——按 `↻`。`path not found` 意味着该目录被移动或删除了。

**重启 `dsh web` 后面板消失了。**
动态插件方式下这是预期行为:动态插件是进程内的。想跨重启保留,请按 profile 插件方式安装。

**主题看起来不对。**
面板使用与侧边栏本身相同的表面色与文本色 token。自定义主题若重定义了它们,面板会跟随;如果
发现某个 token 缺失,请提 issue。

**拖动时感觉卡住。**
拖拽使用 pointer capture,所以拖到面板外也会继续。若浏览器丢掉了 capture,松手重新拖即可;
高度在每次 move 时都已提交。

## 卸载

```sh
# 1. 从 $DSH_HOME/profiles/web/cordis.patch.yml 中删掉该 `- insert:` 条目(或整块)
# 2. 移除包
dsh plugin --profile web remove dsh-plugin-file-explorer
# 3. 若 profile 使用 patchReload: startup,重启 dsh web
```

动态插件方式则在侧边栏 `设置` 上方的 Cordis 面板里停止或移除该插件。

## 开发

```
dsh-file-explorer/
├── lib/
│   ├── index.js          # 宿主半 —— 目录列举路由
│   └── client.js         # 浏览器半 —— 停靠面板与设置行
├── cordis.patch.yml      # 挂载插件的 composition 行
├── dynamic-plugin/       # 同一功能的单会话动态插件版
├── test/                 # node:test 冒烟测试(不需要浏览器)
└── .github/workflows/    # CI:语法检查 + 测试
```

**没有构建步骤**:`lib/client.js` 本身就是 bundle,直接按客户端模块系统提供的格式编写。
检查命令:

```sh
npm run check   # 对两半执行 node --check
npm test        # node:test 冒烟测试
```

### 验证状态

- **动态插件**版本已在 `0.1.5-rc.1` 的真实 Web GUI 中端到端使用过:加载目录、展开、
  筛选、设置开关、宽度跟随与拖动调整高度。
- **profile 插件包**由 `npm test` 覆盖——宿主半针对真实临时目录运行(列举、越界拒绝、
  `404`、`405`),浏览器 bundle 会被实际求值并断言其两处注册;另外还把 `package.json`
  与内置 `dsh.client` 解析器的规则做了交叉核对(`dsh-client-modules`:`platform` 必须是
  字符串、Loader row 名必须是裸包名、`exports["./client"]` 必须是字符串或 `{ default: string }`、
  不得声明 `external` 请求)。

- **profile 插件包**已在隔离实例(独立 `DSH_HOME`)上、对本包进入
  `dsh.profile.bundles` 的真实服务端做了端到端验证:组合树里有 `file-explorer` 行;
  路由返回 `200` 与正确目录列表(越界路径 `400`、不存在 `404`、非 GET `405`);
  浏览器 bundle 由 `/plugins/??dsh-plugin-file-explorer/client.js` 正常提供。
要对活的 GUI 迭代开发,把本地检出作为本地依赖安装
(`dsh plugin --profile web add /absolute/path/to/dsh-file-explorer`),启动命令里保留
`--patch`,改完 `lib/client.js` 后刷新页面即可。

## 参与贡献

欢迎提 issue 与 PR。请保持本插件赖以成立的两条不变量:浏览器半除了基线 `react` 模块之外
不得依赖任何东西;宿主半必须严格只读,并且被限制在工作目录内。提交 PR 前请先跑
`npm run check && npm test`。

如果你愿意为这个 README 补一张真实会话里的面板截图,非常欢迎。

## 许可证

[MIT](LICENSE) © 2026 mabaoguo9527

## 致谢

- [DeepSeek Harness](https://github.com/deepseek-ai/DeepSeek-Harness) 以及本插件所接入的
  [Cordis](https://github.com/deepseek-ai/cordis) 插件框架。
- 插件遵循 harness 自身的约定——`apply(ctx)` 模块、基于座位的 UI 注册、`ctx.locale`
  字典、`ctx.effect` 清理——参见官方教程
  [Your first plugin](https://github.com/deepseek-ai/DeepSeek-Harness/blob/master/docs/user/develop/basic/index.md)。
- 面板样式对齐内置工作区浏览器(`ui-workspace`)的度量:36px 分区标题行、28px 圆形图标按钮、
  28px 行高与 8px 圆角。

Install

dsh plugin --profile web add github:mabaoguo9527/dsh-file-explorer

Profile: web

  • This source has no pinned commit, so a later push upstream changes what installs. Prefer pinning a commit.
Source