# dsh-plugin-file-explorer **A workspace file explorer that docks into the DeepSeek Harness sidebar.** [![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)](#two-ways-to-install) English | [中文](README.md) It puts a lazy directory tree of the current session's **working directory** in the bottom-left of the sidebar — directly above the `Settings` row — with a filter, a refresh action, an on/off switch in **Settings → General**, and a drag handle for the height. The width follows the sidebar column. ![The file explorer docked in the sidebar foot](docs/panel.png) *Docked in the sidebar foot, above the `Cordis Plugin` and `Settings` rows. Width follows the sidebar; height is resizable from the top edge.* ``` ┌──────────────────────────────┐ │ New session │ │ Workspaces ⌕ ⋯ │ │ ▾ DeepseekWorkSpace │ │ session one 2m │ │ session two 4d │ │ │ │ ──────────────────────────── │ ← drag this edge to resize │ ▸ File explorer ↻ │ │ Filter files… │ │ ▾ 📁 src │ │ ▸ 📁 client │ │ 📄 index.js │ │ 📄 package.json │ │ ▸ 📁 test │ │ ──────────────────────────── │ │ Cordis Plugin 1 running │ │ ⚙ Settings │ └──────────────────────────────┘ ``` ## Table of contents - [Features](#features) - [Requirements](#requirements) - [Two ways to install](#two-ways-to-install) - [Install as a profile plugin](#install-as-a-profile-plugin) - [Install as a dynamic plugin](#install-as-a-dynamic-plugin) - [Usage](#usage) - [Configuration](#configuration) - [Security](#security) - [How it works](#how-it-works) - [HTTP contract](#http-contract) - [Compatibility](#compatibility) - [Troubleshooting](#troubleshooting) - [Uninstall](#uninstall) - [Development](#development) - [Contributing](#contributing) - [License](#license) ## Features | | | |---|---| | **Docked, not floating** | Renders in the sidebar's own foot seat with the sidebar's surface colour — no card, border, radius, or shadow. It reads as part of the column. | | **Width follows the sidebar** | The panel measures the seat container and tracks it with a `ResizeObserver`, so dragging the sidebar edge keeps the panel aligned. | | **Drag to resize** | The top edge is an 8 px drag handle (`ns-resize`). Default height is about half the sidebar; it is clamped to the space above the sidebar foot so the header can never be clipped. | | **Lazy tree** | Only the levels you expand are read. Directories sort first, expansion state survives re-renders, and a filter narrows the loaded levels by name. | | **In-memory on/off switch** | **Settings → General → File explorer**. The same switch is reachable from the panel header, which collapses the panel to a single title row. | | **Read-only and fenced** | The node half only ever lists directory entries, refuses any path outside the session working directory, and caps each listing at 800 entries. | | **Bilingual** | English and Simplified Chinese dictionaries are registered through `ctx.locale`, so the panel follows the GUI language. | | **Rail-aware** | When the sidebar is collapsed to its 56 px rail, the panel does not render at all. | | **No build step to install** | `lib/` is committed prebuilt, so a git dependency works as-is. | ## Requirements - **DeepSeek Harness** with the Web GUI (`dsh web`). Built against `@deepseek-ai/dsh` **0.1.5-rc.1** and the `web` profile it ships. - **Node.js ≥ 20** (the node half uses the global `URL` and `Buffer.byteLength`). - The host must provide the **`fs`** and **`webServer`** services — both are part of the standard Web profile. - No network access, no API key, and no credentials are used by this plugin. ## Two ways to install | | Profile plugin | Dynamic plugin | |---|---|---| | Form | A real npm-style package mounted as a composition row | Two plain-JS halves loaded through the Cordis tools | | Lifetime | Survives restarts, part of your profile | Lives only in the running DSH process | | Install | One command — `dsh plugin --profile web add …` (auto-activated) | Ask your agent to load `dynamic-plugin/` | | Touches your profile | Yes | No | | Recommended for | Everyday use | Trying it out in one session | ## Install as a profile plugin ### 1. Install the package into your profile The CLI forwards everything after the profile name to `pnpm` inside the profile directory, which is where the loader resolves out-of-tree plugin names: ```sh # From the Git repository (no npm publish needed, `lib/` is committed prebuilt): dsh plugin --profile web add github:mabaoguo9527/dsh-file-explorer # Pin one released version: dsh plugin --profile web add github:mabaoguo9527/dsh-file-explorer#v0.1.2 # Or from a local checkout, which is what you want while developing: dsh plugin --profile web add /absolute/path/to/dsh-file-explorer ``` ### 2. Restart the Web UI That is the whole install. This package declares `dsh.bundle`, so the CLI **automatically appends it to `dsh.profile.bundles`** — the profile's ordered layer list — and its [`cordis.patch.yml`](cordis.patch.yml) is applied as a layer: ```jsonc // $DSH_HOME/profiles/web/package.json, written by `dsh plugin add` "dsh": { "profile": { "bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-plugin-file-explorer"], "patchReload": "live" } } ``` The layer list is read at boot, so restart `dsh web` once. To inspect the composed tree without booting it: ```sh dsh --profile web --dump-config | grep -A2 dsh-plugin-file-explorer ``` ### Alternative: mount the row yourself If you would rather not restart, or you want to evaluate the plugin before installing it, the same row can be applied as a patch layer you control. The repository ships it in [`cordis.patch.yml`](cordis.patch.yml): ```yaml - insert: - id: file-explorer name: 'dsh-plugin-file-explorer' ``` **One-off, nothing to edit** — pass that file as an extra patch layer: ```sh dsh --profile web --patch ./node_modules/dsh-plugin-file-explorer/cordis.patch.yml ``` **Permanent, without the bundle route** — merge the entry into the patch file your profile already loads, `$DSH_HOME/profiles/web/cordis.patch.yml` (default `$DSH_HOME` is `~/.dsh`). That file ships as an empty array `[]`, so in the common case you replace all of it; if it already has entries, append this `- insert:` item to the existing top-level array instead of adding a second YAML document. Patch files **are** watched (`patchReload: live` is the default for custom profiles), so this route needs no restart — reload the browser page. > Pick one route. Mounting the row by hand *and* leaving the package in > `dsh.profile.bundles` inserts the same row id twice. ### Verify Open the GUI: the panel appears above the `Settings` row and **Settings → General** gains a **File explorer** switch. To confirm the composition without the UI: ```sh dsh --profile web --dump-config | grep -A2 dsh-plugin-file-explorer ``` ## Install as a dynamic plugin [`dynamic-plugin/`](dynamic-plugin) contains the same feature as a single-session Cordis dynamic plugin: `host.js` and `client.js`, both plain JavaScript, no package, no composition row, no profile change. Give both files to a DSH agent and ask it to define and run the plugin — the agent calls `cordis_define` with the two halves and then `cordis_run`. The panel appears immediately and disappears when the DSH process restarts, because dynamic plugins are process-local. See [`dynamic-plugin/README.en.md`](dynamic-plugin/README.en.md) for the exact prompt and the differences from the profile plugin. ## Usage | Action | Result | |---|---| | Click a directory row | Expand or collapse that level, reading it on first expand | | Click a file row | Select it; its path relative to the working directory appears in the footer | | `Filter files…` | Narrows the already-loaded levels by name; directories still expand | | `↻` | Re-reads the root and every level you had open | | Chevron in the title row | Collapses the panel to its title row, or expands it again | | Drag the top edge | Resizes the panel; it never grows past the sidebar foot or below 120 px | | **Settings → General → File explorer** | Turns the whole panel on or off | The header shows the working directory's basename and, on hover, its absolute path. Here is the panel in a running app: ![The panel in the running app](docs/screenshot.png) ## Configuration The plugin takes **no plugin config**. Its only preference is the on/off switch, and that state is deliberately **in-memory**: it lives in the plugin fiber, is reset to *on* when the plugin is (re)loaded, and is not written to `settings.yaml`. A dynamic plugin is process-local by design, and a single boolean is not worth a settings namespace, so the switch is intentionally session-scoped. Resizing is likewise in-memory and resets to the default height on reload. ## Security The node half exists because the browser cannot read the harness filesystem. Its whole surface is one read-only route, and its limits are deliberate: - **Containment.** Every request resolves the session working directory as a root and checks `fs.contains(root, target)` on *resolved* targets, so `..` segments and symlinks that escape the root are rejected with `400` rather than rendered. - **Metadata only.** `fs.stat` and `fs.listDir` are used. File **contents are never read**, and there is no endpoint that writes, renames, moves, or deletes anything. - **Bounded.** One listing returns at most 800 entries. - **Local by default.** The route is served by the harness web server, so it inherits that server's bind address and trusted-host policy. If you bind the GUI to a LAN interface, anyone who can reach the GUI can list directories inside the working directory. Bind to loopback unless you intend otherwise. - **No credentials.** The plugin never touches `ctx.credentials` and makes no outbound network requests. ## How it works Two halves, one feature. **Node half — `lib/index.js`.** A Cordis plugin (`apply(ctx)` + `inject = ['webServer', 'fs']`) that registers one `exact` route, `/dsh-file-explorer/tree`. The route handler resolves the requested directory through the composed `fs` service, applies the containment and size limits above, and answers JSON. It is registered inside `ctx.effect(...)`, so unloading the plugin releases the path instead of leaving a dangling handler. **Browser half — `lib/client.js`.** Shipped in the built-bundle format the client module system serves from `exports["./client"]`, i.e. a factory handed to `window.__ModuleLoader__.load({ id, factory })`. It requests only the baseline `react` module, which is why `dsh.client.external` is empty and no bundler is required. Its exported `inject = ['slots', 'locale']` makes Cordis wait for the seat registry and the dictionary registry before `apply` runs. **Where it seats itself.** Two additive seats, both `replaceRisk: none`: | Seat | Purpose | Registration | |---|---|---| | `sidebar.footer.action` | The sidebar foot row, above `Settings` | `id: dsh-file-explorer`, `order: -100` | | `settings.general.item` | One preference row inside Settings → General | `id: dsh-file-explorer`, `order: 30` | **Why the panel is positioned rather than in-flow.** `sidebar.footer.action` is a horizontal flex row, and the shipped occupant (the `Cordis Plugin` trigger) is `flex: none; width: 100%` — it owns the entire row. Any in-flow sibling is therefore squeezed to exactly zero width: it keeps its height (leaving a blank strip) while every child collapses into its own padding and is clipped. The entry here is instead a **zero-size flex item** used purely as a positioned anchor, and the panel is absolutely positioned out of that row: - `left: calc(-1 * var(--dsh-sidebar-inline-padding))` reaches the sidebar column's left edge; - `bottom: 0` on the anchor is the foot area's top edge, so the panel grows **upward** and lands directly above the shipped trigger with no hard-coded offset; - the column's `overflow: hidden` clips the panel to the sidebar, so it can never spill into the conversation column. **Width.** The sidebar exposes no width to a seated entry — there is no slot prop and no CSS variable for it. The panel therefore measures the seat container by walking out from its own node (`parentElement`) to the first ancestor with a real box, adds the inherited `--dsh-sidebar-inline-padding` on both sides, and tracks changes with a `ResizeObserver`. If any of that fails, the CSS fallback width applies and the panel still renders. **Height.** A pointer-captured drag on the top edge. The drag's start state lives in the plugin closure rather than the component, because React re-creates component bindings on every render and an in-flight drag would otherwise reset mid-gesture. ## HTTP contract The browser half uses this route; it is stable enough to script against. ```http GET /dsh-file-explorer/tree?base=&path= ``` `base` is the session working directory (`.` falls back to the filesystem backend's default). `path` is relative to `base`; `.` means `base` itself. ```jsonc // 200 { "path": "/Users/you/project/src", // absolute resolved directory "entries": [ { "name": "client", "type": "directory", "size": null }, { "name": "package.json", "type": "file", "size": 812 } ] } ``` `type` is one of `file`, `directory`, `other`. `size` is `null` when the backend does not report one. Errors are `{ "error": "" }` with `400` (outside the working directory, not a directory), `404` (not found), `405` (method not allowed), or `500` (backend failure). ## Compatibility - Built against **`@deepseek-ai/dsh` 0.1.5-rc.1** (Web profile). - Uses only documented seams: the `fs` and `webServer` services, the `slots` and `locale` client services, the `sidebar.footer.action` and `settings.general.item` seats, the `dsh.client` package declaration, and `ctx.effect` for cleanup. - The one structural assumption is the width measurement described above. It is written defensively and degrades to a fixed width rather than failing, but if a future release changes the sidebar's DOM nesting, expect the fallback. - Theme colours are read from existing Design Token variables (`--dsw-specific-sidebar-fill`, `--dsw-alias-*`), so the panel follows light and dark themes with no extra work. ## Troubleshooting **The panel is not there.** Check, in order: the composition row is present (`dsh --profile web --dump-config`); the sidebar is not collapsed to its 56 px rail; the **Settings → General → File explorer** switch is on (reloading the plugin turns it back on). **The panel is there but empty, with an error line.** The error text is the node half's answer verbatim. `path is outside the working directory` usually means the session's working directory changed under the panel — press `↻`. `path not found` means the directory was moved or deleted. **The panel disappeared after restarting `dsh web`.** That is expected for the dynamic-plugin route: dynamic plugins are process-local. Install it as a profile plugin to make it survive restarts. **The theme looks wrong.** The panel uses the same surface and text tokens as the sidebar itself. If a custom theme redefines them, the panel follows it; report a token that is missing. **Resize feels stuck mid-drag.** Pointer capture is used so the drag continues outside the panel. If a browser drops the capture, release and drag again; the height is committed on every move. ## Uninstall ```sh # 1. Remove the `- insert:` entry (or the whole `- insert:` block) from # $DSH_HOME/profiles/web/cordis.patch.yml # 2. Remove the package dsh plugin --profile web remove dsh-plugin-file-explorer # 3. Restart `dsh web` if the profile uses patchReload: startup ``` For the dynamic-plugin route, stop or remove the plugin from the Cordis panel above `Settings` in the sidebar. ## Development ``` dsh-file-explorer/ ├── lib/ │ ├── index.js # node half — the directory listing route │ └── client.js # browser half — the docked panel and settings row ├── cordis.patch.yml # the composition row that mounts the plugin ├── dynamic-plugin/ # the same feature as a single-session dynamic plugin ├── test/ # node:test smoke tests (no browser needed) └── .github/workflows/ # CI: syntax check + tests ``` There is **no build step**: `lib/client.js` is the bundle, written directly in the format the client module system serves. Run the checks with: ```sh npm run check # node --check on both halves npm test # node:test smoke tests ``` ### Verification status - The **dynamic-plugin** variant was exercised end-to-end in a live Web GUI on `0.1.5-rc.1`: tree loading, expansion, filtering, the settings switch, the tracked width, and drag-resizing. - The **profile package** is covered by `npm test` — the node half is driven against a real temporary directory (listing, containment rejection, `404`, `405`), and the browser bundle is evaluated and its registrations asserted — plus a cross-check of `package.json` against the shipped `dsh.client` parser's rules (`dsh-client-modules`: string `platform`, bare loader-row name, `exports["./client"]` as a string or `{ default: string }`, no `external` requests). - The **profile package** was verified end-to-end against a real server booted from an isolated `DSH_HOME` with this package in `dsh.profile.bundles`: the composed tree contained the `file-explorer` row, the route answered `200` with the expected listing (`400` for a path outside the working directory, `404` for a missing one, `405` for a non-GET method), and the boot graph served the browser bundle from `/plugins/??dsh-plugin-file-explorer/client.js`. To iterate against a live GUI, install the checkout as a local dependency (`dsh plugin --profile web add /absolute/path/to/dsh-file-explorer`), keep the `--patch` flag in your start command, and reload the page after editing `lib/client.js`. ## Contributing Issues and pull requests are welcome. Please keep the two invariants this plugin was built around: the browser half must never need more than the baseline `react` module, and the node half must stay strictly read-only and confined to the working directory. Run `npm run check && npm test` before opening a pull request. A screenshot of the panel in a real session would be a welcome addition to this README. ## License [MIT](LICENSE) © 2026 mabaoguo9527 ## Acknowledgements - [DeepSeek Harness](https://github.com/deepseek-ai/DeepSeek-Harness) and the [Cordis](https://github.com/deepseek-ai/cordis) plugin framework this plugs into. - The plugin follows the harness's own conventions — `apply(ctx)` modules, seat-based UI registration, `ctx.locale` dictionaries, and `ctx.effect` cleanup — as documented in [Your first plugin](https://github.com/deepseek-ai/DeepSeek-Harness/blob/master/docs/user/develop/basic/index.md). - Panel styling mirrors the metrics of the shipped workspace browser (`ui-workspace`): 36 px section header, 28 px circular icon buttons, 28 px rows with 8 px radii.