# dsh-sidebar-file-menu VS Code-style right-click menu for the [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness) Web UI right-sidebar file tree. > [中文 README](./README.md) Right-clicking any row in the file tree opens a menu with: | Menu item | What it does | |---|---| | **Copy Absolute Path** | Puts the row's absolute path on the system clipboard. | | **Copy Relative Path** | Puts the path relative to the Session's workspace root on the clipboard. Hidden for a row outside the root. | | **Copy Name** | Puts the final path segment on the clipboard. | | **Reveal in File Manager** | Selects the path in Explorer (Windows), Finder (macOS), or the desktop file manager (Linux). | | **Open with Default Application** | Hands the file to the OS default application. Files only — a directory has no default application. | The last two are the only gestures that need the Host; they go through an authenticated local HTTP route on the DSH web server, which authorizes the path against the owning Session's workspace root before touching the desktop. ## Why this exists The shipped Web UI already opens *some* paths. `ui-deliverables` turns files a turn created or explicitly delivered into clickable chips, and its card menu can reveal those in Explorer or Finder. Two gaps remain: 1. **Only declared or mutated files are clickable.** Any other path the model writes in prose is inert. The deliverables README states this under its limitations: terminal-created files require an explicit `present` call. 2. **Directories have no destination.** The same README records that the native folder handoff was removed rather than replaced. Meanwhile the sidebar file tree — the one surface that lists everything in the workspace — gave a row exactly one gesture: left-click to open or expand. It had no `onContextMenu` handler, so the path a reader most often wants was the one they had to retype. This plugin adds that gesture. Nothing else about the tree changes. ## Install ### From a tarball ```sh cd work/dsh-sidebar-file-menu pnpm pack cp dsh-sidebar-file-menu-0.1.0.tgz "$DSH_HOME/profiles/web/" dsh plugin --profile web add './dsh-sidebar-file-menu-0.1.0.tgz' ``` The `dsh plugin add` step must run with the profile directory as the working directory when the tarball lives there: `dsh` resolves a bare relative path against the profile's own `plugins/` directory, not the shell's cwd. An absolute path is resolved relative to that same directory, so a path with spaces fails to match; copying the tarball into the profile and naming it as `./.tgz` is the form that works. Then restart `dsh web` and refresh the page. The plugin is served from the composed roster, which is built at boot — a running server will not pick it up, and its boot graph will not list this package until it restarts. ### Verify the install ```sh dsh --profile web --dump-config | grep -A2 sidebar-file-menu ``` Expected: ```yaml # == dsh-sidebar-file-menu - id: sidebar-file-menu name: dsh-sidebar-file-menu ``` ## Use Open the right sidebar, choose the **Files** tab, and right-click any row. The tree itself behaves exactly as before: a directory toggles, a file opens in the right sidebar's preview. ## Architecture This section is the reason the plugin is shaped the way it is; it is also the material for walking a team through how a DSH Web plugin is put together. ### Taking over a shipped tab type The right sidebar composes tab types through two stages: `ctx.sidebarRightTabs.register()` declares what a type *is*, and `ctx.slots.register({ name: 'sidebar.right.pane.tab', key: })` supplies what it *draws*. The shipped tree declares kind `files` in the **`builtin`** priority band. `ui-sidebar-right` allows at most one `builtin` and one `extension` registration per kind, and the `extension` band is the highest — its own source records that the key space stays open "because a tab type may ship from outside this repository." So registering a second type under kind `files` and omitting `priority` puts this implementation in force without editing the shipped package: ```ts ctx.sidebarRightTabs.register({ id: MENU_ID, kind: 'files', title, guide }) ``` Two properties follow, and both matter: - **It is reversible.** The shipped registration was never touched, so unloading this plugin restores the shipped tree exactly. There is no patch to revert. - **It is exclusive.** Only the registration in force mounts a body, so the shipped `sidebarFiles` dictionary registration is simply not mounted while this one is. There is no copy collision, and this plugin reuses the shipped namespace for the tree's own rows and failure lines. The trade-off is real and is recorded under [Known limitations](#known-limitations-and-deferred-work): the tree is reproduced rather than extended, because the shipped `FilesBody` exposes no per-row extension seat — its own README says no grid-level seat exists "because nothing needs one yet." ### Browser to Host: why a route and not a Remote Clipboard writes never leave the page. Revealing a path or opening an application is a native command, so it has to reach the Host. DSH offers two ways across that line, and they are not interchangeable: | | Typert Remote (`@Remote`) | Plain `webServer` route | |---|---|---| | Declared by | a decorated method on a Host service | `ctx.webServer.register()` | | Reaches the Client when | the build selects the contribution into `@deepseek-ai/dsh-api-remotes` | the plugin's Host half is mounted | | Fits | typed business operations | a small, self-contained side effect | A third-party plugin cannot add itself to `@deepseek-ai/dsh-api-remotes` — that assembly is built inside the monorepo — so a Remote would need a code change in the harness. A route needs none. `dsh-host-open-in-app` reached the same conclusion for its own launch endpoint; this plugin follows that precedent, which is recorded in the promotion note for that package. The route is `POST /sidebar-file-menu/action` with `{ verb, path, sessionId }`. ### The trust fence A route that opens local files is a capability worth taking seriously. Every request passes, in order: 1. **`connection.requestRejection(req)`** — the composition's own Host/Origin fence and login-token cookie check. An unauthenticated caller never reaches path work. 2. **Method and media type** — `POST` only, `application/json` only. 3. **A 16 KiB body ceiling**, with the remainder drained so a refusal is a readable response rather than a socket cut. 4. **Shape validation** — `verb` must be one of three exact literals; `path` and `sessionId` must be non-empty strings. 5. **Capability check** — `canOpenNativePath()` before any path work, so a headless host answers "no desktop" instead of failing obscurely. 6. **Filesystem authorization** — the Session's workspace root is resolved, the path is `lstat`-ed *before* anything follows it (so a link whose destination leaves the workspace is classified, not followed), and the resolved target must be contained by that root. A path outside it is refused with `403` rather than opened. Consequences worth stating plainly: - The route cannot be turned into a general file launcher. It opens paths inside the owning Session's workspace and nothing else. - Argument injection is not a concern: the openers spawn executables with an argv array and never a shell. - Failures are reduced to codes (`no-desktop`, `not-found`, `outside-workspace`, `native-command-failed`) and re-localized on the Client, so no Host prose reaches the reader and no Host message needs translating. ### Extension points exercised For a walkthrough, this one small plugin touches six distinct DSH mechanisms: | Mechanism | Where | |---|---| | Slot registration | `sidebar.right.pane.tab`, keyed by type id | | Priority-band takeover | `sidebarRightTabs.register` with the `extension` band | | Typed locale dictionaries | `ctx.locale.register` into `sidebarFileMenu`, plus reuse of the shipped `sidebarFiles` | | Cordis effects | every registration is inside `ctx.effect`, so disposal rolls it back | | Generated Remote consumption | `ctx.remote.workspaceFiles.list` for directory listings | | Host route registration | `ctx.webServer.register` behind the connection trust fence | ### Build: the artifact the shell expects The shell does not `import` a plugin's client half. It fetches `lib/client.js` and evaluates it, and that artifact must call: ```js window.__ModuleLoader__.load({ id, factory: (require) => { /* … */ } }) ``` `module` and `exports` do not exist in that scope, which is why `tsdown.config.ts` supplies them in the `banner` and closes the factory in the `footer`. The shell also seeds a fixed module table (`packages/client/web/src/platform.ts`) with exactly nine specifiers. `neverBundle` is that list and nothing more: a `require()` the table cannot answer throws at boot, so every other value — including `clsx` and `@deepseek-ai/dsh-util-workspace-path` — is inlined. The shared `clientBundle` tsdown preset does all of this, but it is not published to npm and imports helpers from the harness checkout, so a package outside the monorepo cannot call it. `tsdown.config.ts` here reproduces the parts that determine the artifact: format, externals, wrapper, and output name. The stylesheet is carried as text in `src/client/style.ts` for the same reason — the preset compiles CSS through `lightningcss`, which is not resolvable from the plugin directory. Nothing depends on the plugin living inside the harness checkout. It is built and packed as a standalone package. ## Development ```sh "$DSH_CHECKOUT/node_modules/.bin/tsdown" # rebuild the browser bundle after editing src/client/ pnpm pack # pack; lib/index.js needs no build ``` ### Verification Four harnesses run without a browser, a live server, or a desktop. Each takes an optional path argument, so they can be pointed at the built artifact or at the installed one: ```sh node verify.mjs [path/to/client.js] # load the bundle, drive apply() with a fake context node verify-render.mjs [path/to/client.js] # render the components with real React node verify-host.mjs [path/to/index.js] # body parsing and path authorization node verify-route.mjs [path/to/index.js] # the HTTP handler chain end to end ``` - **`verify.mjs`** reproduces the shell's handoff: it stubs the platform module table, evaluates the bundle, captures the `window.__ModuleLoader__.load` registration, calls the factory, and drives `apply()` with a fake Cordis context. It asserts the takeover specifically — kind `files`, priority omitted, body keyed by the same id, dictionary registered, every registration inside `ctx.effect`. - **`verify-render.mjs`** puts the **real** `react` and `react-dom/server` in the module table, captures the component from the slot registration, and renders it: a directory listing with directories and files, the no-workspace notice, a loading level, and a failed level. It also asserts the menu's action ids and dictionary keys agree, which is the kind of literal that otherwise drifts into a blank menu row at runtime. - **`verify-host.mjs`** exercises the security decision with a fake filesystem: a well-formed body, an unknown verb, a path resolving outside the workspace, a missing entry, a non-file entry, an unresolvable root, and the unknown-Session fallback. It also pins the `lstat(path, opts, signal)` argument shape. - **`verify-route.mjs`** serves the captured route handler on a loopback port and drives it with real `IncomingMessage`/`ServerResponse` objects, so the middleware order is exercised as production runs it: the trust fence before anything else, then method, media type, body ceiling, shape, and authorization. It only asserts cases refused before a native opener would run, so it never opens a window. ### Live boot against an isolated home The four harnesses fake the Cordis context, so they cannot catch a wiring mistake that only the real container rejects. Booting a **throwaway instance** does, without touching the running profile or its Sessions: ```sh # Build an isolated home that shares the real profile's package cache read-only. export DSH_HOME="$TEMP/dsh-verify-home" # PowerShell: $env:DSH_HOME = ... mkdir -p "$DSH_HOME/profiles/web" # Junction "$DSH_HOME/profiles/node_modules" at the real profile's node_modules, # then give profiles/web a package.json whose dsh.profile.bundles lists this # plugin, and install the tarball with `dsh plugin --profile web add ./plugin.tgz`. dsh web --port 3099 --host 127.0.0.1 ``` Then confirm the plugin is in the served boot graph and that its route answers: ```sh curl -s "$BASE/?token=$TOKEN" | grep -o 'dsh-sidebar-file-menu/client.js' # must appear curl -s -X POST "$BASE/sidebar-file-menu/action" -H 'content-type: application/json' \ -d '{"verb":"reveal","path":"x","sessionId":"y"}' # {"ok":false,"code":"not-found"} ``` That second call is the real activation proof: a framework 404 means the route never registered, while this plugin's own JSON means it did — behind the trust fence — in a live Loader. **This is the check that matters most, and it caught a bug the harnesses could not.** `apply` reads `ctx.sessions`, but `sessions` was missing from the exported `inject` list. A plain fake context answers `undefined` for an undeclared property, so every harness passed; the real Cordis proxy **throws** `cannot get property "sessions" without inject` and the whole plugin tree failed to boot. The fix is the `inject` list itself. Any new service `apply` reads must be named there. The one thing no harness covers is **browser DOM rendering in the live tree**. The render suite proves the component tree builds and React accepts it, but confirming the menu actually appears needs a browser. `node_modules/clsx`, `node_modules/@deepseek-ai/dsh-util-workspace-path`, and `node_modules/@deepseek-ai/dsh-native-command` are required locally: the first two so the bundler **inlines** them instead of leaving a `require` the browser's module table cannot answer, and the third so the host half and its harnesses can be imported outside the profile. All are gitignored; recreate them as links to the copies the DSH profile already installs: ```powershell $prof = "$env:USERPROFILE\.dsh\profiles\node_modules" New-Item -ItemType Junction -Path node_modules\clsx -Target "$prof\clsx" New-Item -ItemType Junction -Path node_modules\@deepseek-ai\dsh-util-workspace-path -Target "$prof\@deepseek-ai\dsh-util-workspace-path" New-Item -ItemType Junction -Path node_modules\@deepseek-ai\dsh-native-command -Target "$prof\@deepseek-ai\dsh-native-command" ``` `lib/index.js` is hand-written plain JavaScript and needs no build step. `lib/` and `*.tgz` are gitignored. Because `pnpm pack` and `dsh plugin add` both cache by version, **bump `version` or remove then re-add** when reinstalling a changed build; a same-version tarball reports "Already up to date" and keeps the old bytes. ## Known limitations and deferred work - **The tree is reproduced, not extended.** The shipped `FilesBody` declares no per-row extension seat, so adding one gesture means owning the component. Consequence: layout fixes made to `ui-sidebar-files` upstream do not reach this tree automatically. A future harness release that adds a row-action slot would let this plugin shrink to just the menu. - **Native opening is per the serving Host.** A remote browser opens applications on the *server's* desktop, not the reader's. `ui-deliverables` documents the same constraint. - **No keyboard entry point.** The menu is reachable by right-click or the context-menu key, but there is no command-palette action and no shortcut binding. - **Relative paths are computed on the Client** by normalizing separators and stripping the root prefix, rather than asking the Host. This is adequate for display and clipboard text but is not a containment check; the Host route performs that separately. - **The `openText` verb exists but is unused.** It is wired through the Host route (macOS bypasses file-type association so a YAML-to-browser association cannot swallow the gesture) in case a text-specific action is added later. It is currently dead code reachable only by calling the route directly. - **No tests.** The harness gates tests behind the monorepo's `test:coverage` and snapshot infrastructure, which a standalone package cannot run. Verification is manual; see [Verify the install](#verify-the-install). ## License MIT