dsh-web
zhu1090093659/dsh-webDeepSeek Harness(DSH)Web 插件聚合生态包 · 一切皆插件,创意工坊分发(dsh-market.com)
为 DeepSeek Harness Web UI 右侧栏文件树提供 VS Code 风格右键菜单:复制绝对/相对路径与名称、在资源管理器或 Finder 中显示、用默认程序打开。通过 extension 优先级带原地接管内置 files 标签页,不新增 tab,卸载即还原。
VS Code-style right-click menu for the DeepSeek Harness Web UI right-sidebar file tree.
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.
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:
present call.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.
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.
dsh --profile web --dump-config | grep -A2 sidebar-file-menu
Expected:
# == dsh-sidebar-file-menu
- id: sidebar-file-menu
name: dsh-sidebar-file-menu
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.
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.
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:
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."
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 }.
A route that opens local files is a capability worth taking seriously. Every request passes, in order:
connection.requestRejection(req) — the composition's own Host/Origin fence and login-token cookie check. An unauthenticated caller never reaches path work.POST only, application/json only.verb must be one of three exact literals; path and sessionId must be non-empty strings.canOpenNativePath() before any path work, so a headless host answers "no desktop" instead of failing obscurely.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:
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.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 |
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.
"$DSH_CHECKOUT/node_modules/.bin/tsdown" # rebuild the browser bundle after editing src/client/
pnpm pack # pack; lib/index.js needs no build
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.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.
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.ui-deliverables documents the same constraint.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.test:coverage and snapshot infrastructure, which a standalone package cannot run. Verification is manual; see Verify the install.MIT