yudaxia1/dsh-sidebar-file-menu

dsh-sidebar-file-menu

为 DeepSeek Harness Web UI 右侧栏文件树提供 VS Code 风格右键菜单:复制绝对/相对路径与名称、在资源管理器或 Finder 中显示、用默认程序打开。通过 extension 优先级带原地接管内置 files 标签页,不新增 tab,卸载即还原。

Uncategorizeddeepseek-harnessdeepseek-harness-plugindeepseek-harness-plugins

dsh-sidebar-file-menu

VS Code-style right-click menu for the DeepSeek Harness Web UI right-sidebar file tree.

中文 README

Right-clicking any row in the file tree opens a menu with:

Menu itemWhat it does
Copy Absolute PathPuts the row's absolute path on the system clipboard.
Copy Relative PathPuts the path relative to the Session's workspace root on the clipboard. Hidden for a row outside the root.
Copy NamePuts the final path segment on the clipboard.
Reveal in File ManagerSelects the path in Explorer (Windows), Finder (macOS), or the desktop file manager (Linux).
Open with Default ApplicationHands 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

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 ./<file>.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

dsh --profile web --dump-config | grep -A2 sidebar-file-menu

Expected:

# == 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: <type id> }) 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:

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: 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 bya decorated method on a Host servicectx.webServer.register()
Reaches the Client whenthe build selects the contribution into @deepseek-ai/dsh-api-remotesthe plugin's Host half is mounted
Fitstyped business operationsa 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:

MechanismWhere
Slot registrationsidebar.right.pane.tab, keyed by type id
Priority-band takeoversidebarRightTabs.register with the extension band
Typed locale dictionariesctx.locale.register into sidebarFileMenu, plus reuse of the shipped sidebarFiles
Cordis effectsevery registration is inside ctx.effect, so disposal rolls it back
Generated Remote consumptionctx.remote.workspaceFiles.list for directory listings
Host route registrationctx.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:

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

"$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:

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:

# 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:

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:

$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.

License

MIT