Compare commits

..
Author SHA1 Message Date
Menghuan1918 4060bd53e1 fix: 分割线改为 1px 发丝线(保留 7px 拖拽热区),v0.1.3
- 分割线视觉从 7px 实底色块改为居中 1px 发丝线(::after 伪元素)
- 悬停/拖拽时线变色为强调色,不再整条填充
- 拖拽热区保持 7px 不变;reduced-motion 下伪元素过渡一并禁用
- version 同步 0.1.3(package.json + dsh.plugin.json)
2026-08-09 23:53:52 +08:00
Menghuan1918 a1286191d6 feat: plugin-registry 标准发布面(dsh.plugin.json + 双 client bundle + registry 打包),v0.1.2
- dsh.plugin.json 原生清单:id dsh-external/dsh-better-sidebar,version 0.1.2,
  main = lib/index.js,client.main = lib/client-registry.js,contributes 空声明
- tsdown client 构建参数化:lib/client.js(官方通道,id = 包名)+ lib/client-registry.js
  (registry 通道,id = 清单 id),同源不漂移
- scripts/package-registry.mjs 组装 registry/ 安装根(gitignore,不入库)
- tests/manifest-consistency.spec.ts:id 两段校验 / version 同步 / 入口存在 /
  双 bundle id 不互换 / contributes 空
- README:新增「通过 plugin-registry 安装(标准)」章节 + 保留官方 profile 安装
  方案 + 通道互斥说明;架构表补 manifest 与双 client 行
2026-08-09 23:49:58 +08:00
Menghuan1918 9b00135de4 feat: DSH-native flat chrome + Side card settings (plugin-owned settings wire)
- Remove every box-shadow; panel uses the native sidebar fill surface
  (--dsw-specific-sidebar-fill) with a hairline border, the collapse
  control becomes the native 36px circular rail button, the active tab
  uses the native interactive-bg-active fill, and rows/typography follow
  the native 34px/14px session-row recipe.
- New 'Side card' settings section in the DSH Settings shell: open-by-
  default for new conversations (boolean) and default width share as a
  percent of the window (20-60). New sessions seed from these prefs;
  existing per-session layouts stay untouched.
- The settings namespace is read/written through the plugin's own fenced
  /sidebar routes calling the settings seam in-process: the DSH settings
  RPC domain only serves allowlisted namespaces to configuration clients,
  so third-party namespaces cannot use connection.api.settings (reads are
  silently filtered, writes refused with 'not exposed'). The seam still
  persists to the user settings document with revision-guarded writes.
2026-08-09 23:49:58 +08:00
Atlas Zeng 06440d0365 Update README.md 2026-08-09 22:12:53 +08:00
Menghuan1918 c4851dceb7 docs: update prompt for DSH (same one-shot style as install) 2026-08-09 22:11:37 +08:00
Menghuan1918 ed6d86d5ee chore: release 0.1.1 2026-08-09 22:07:03 +08:00
Menghuan1918 260e3b8d6b docs: one-shot install prompt for DSH; platform support notes
- README leads with a self-contained prompt the user pastes into DSH:
  clone URL, build, profile registration, cordis mount row, restart
- manual install/update section kept as the no-prompt alternative
- platform support section documents win32/linux/macOS adaptation and
  the node-pty build-tool requirements
2026-08-09 22:07:03 +08:00
Menghuan1918 81ab0c5312 fix: cross-platform path handling and tests
- fs-tree joins listed paths with the platform separator (Windows rows
  previously leaked mixed separators)
- media route containment check goes through isWithin(): separator-
  tolerant and case-insensitive on win32, so case-mismatched Windows
  paths are not misclassified
- relativeTo compares case-insensitively (Windows / case-insensitive
  macOS volumes) while keeping the caller's own casing in the output
- tests: platform-conditional path assertions, os.tmpdir() instead of
  /tmp, isWithin/relativeTo/resolveSidebarPath win32 coverage
2026-08-09 22:07:03 +08:00
Menghuan1918 9b153844e9 feat: explorer file download via the host route; editor line numbers
- explorer row menu gains a download action (files only); the host route
  serves raw bytes with Content-Disposition: attachment, binary-safe
- editor enables line numbers
2026-08-09 22:07:03 +08:00
Menghuan1918 e2ee427ea3 feat: per-session store + configurable host limits; fix divider drag runaway
- state: side-store per conversation, injected as a prop instead of a module singleton
- host: schemastery Config (read/media/list/terminal limits) resolved from cordis config
- shape: types/exports/peerDependencies for the web profile + plugin-shape spec
- fix: split divider emits incremental deltas so drags track the cursor 1:1
- docs: README refresh
2026-08-09 22:07:03 +08:00
Atlas Zeng a10c9fc77e Update README.md 2026-08-09 11:46:58 +08:00
Menghuan1918 2247f6ecce docs: refresh README — pitch, feature map, shortcut cheat-sheet, state self-healing
Restructures the README for readability: a one-line pitch plus "why use
it" bullets, emoji-grouped feature sections, a keyboard-shortcut table,
and a quick-start flow. Adds the recently shipped capabilities to the
feature map (tab/+ menu icons, the @-reference row button, right-click
copy relative/absolute path, persisted-state self-healing) and documents
the split-id de-duplication under architecture.
2026-08-09 11:45:15 +08:00
Menghuan1918 fec53cef0d fix: split panes duplicate when a file opens — stale uid collisions with persisted ids
The uid counter is module-global and resets on every reload, while the
persisted split tree keeps its old ids (pane:1, split:1, ...). A split
created AFTER a reload minted a fresh "pane:1" beside the persisted
"pane:1"; mapLeaf then visited BOTH leaves, so every open (explorer click,
produced-files chip) landed the tab in both panes of a top-bottom split.

- loadState seeds the counter past the raw persisted ids before sanitize
- setSession re-seeds on cache hits (another session's smaller max must
  not leave the counter below this session's ids)
- sanitize re-ids stale duplicate pane/split ids (heals states already
  corrupted) and follows the activePane rename
- openTab falls back to the first pane when the active pane is gone
  instead of silently dropping the open

Unit tests cover the re-id heal and the stale-activePane fallback;
verified end-to-end in headless Chrome: a state with two "pane:1" leaves
loads healed and a file click lands in exactly one pane.
2026-08-09 00:14:27 +08:00
Menghuan1918 c81de3374f fix: @-reference button no-ops — read conversation via ctx.get, not the ctx property
Cordis only grants property reads for services declared in the plugin's
inject list; every other read throws "cannot get property X without
inject" (the app's own plugins read 'conversation' through ctx.get for
this reason). referenceInChat now resolves the conversation service lazily
via ctx.get('conversation') and no-ops when absent. Verified end-to-end in
headless Chrome: clicking the @file button on root/dir/file rows now
appends the at-relative-path mention to the composer draft with no
console errors.
2026-08-09 00:01:56 +08:00
Menghuan1918 f091ac322b fix: sidebar crash #310 — referenceInChat hook sat behind the no-session early return
The new useCallback was declared after the early return for the no-session
state, so the first render (no session) ran fewer hooks than the follow-up
render once a session arrived — React error #310, boundary strip on load.
Moved the hook above the early return (with a sessionId guard) and verified
headlessly: the error no longer appears and the panel renders fully.
2026-08-08 23:51:42 +08:00
Menghuan1918 6e36a99c88 feat: explorer copy-path & @-reference actions; icons on tabs and + menu
- Tabs, the + menu, and empty-pane cards carry per-type icons (folder /
  branch / terminal / code file); a terminal glyph joins the icon set
- Hovering an explorer row reveals an @文件 button at the far right that
  appends @<relative path> to the composer draft (session-scoped input
  service, space-separated append)
- Right-clicking a row opens a context menu: copy relative / absolute
  path (writeClipboard + transient in-row "copied" label)
- Rows become div role=button (Enter/Space) to host the inner button;
  relativeTo helper unit-tested
2026-08-08 23:46:14 +08:00
Menghuan1918 88b08de957 docs: README — keepalive semantics, theme adaptation, DSH-native chrome 2026-08-08 17:32:23 +08:00
Menghuan1918 439adf3967 ui: align the sidebar with the app's own chrome (rows, buttons, motion)
Explorer rows become the app's tree-row cell — 34px, radius 8, inset by
the scroll padding, 22px indent step, and the session-row mount fade —
and git rows get the same cell plus the proper selected fill
(interactive-bg-active). Icon buttons switch to the app's 28px circle
pattern (ui-workspace), headers settle at the 36px section rhythm, and
keyboard focus gets a visible ring everywhere per the styling policy,
with a prefers-reduced-motion block turning off the slides and fades.
2026-08-08 17:30:45 +08:00
Menghuan1918 7aa24badf0 theme: terminal and editor follow the app's color scheme, re-theming in place
The app resolves light/dark from prefers-color-scheme and the user's
choice (body[data-ds-dark-theme]), but xterm and CodeMirror were pinned to
hardcoded dark themes — a light-mode user got a dark card inside a light
panel. New theme.ts reads the resolved scheme + token values and notifies
on flips (the presenter toggles the body attribute); the terminal now takes
its surface colors from the tokens and a curated one-dark/one-light ANSI
palette, and the editor gets a token-driven surface with the same curated
syntax palettes. Both re-theme in place on a scheme flip: the terminal
re-applies its theme, the editor reconfigures a Compartment holding only
the scheme-dependent extensions, so the document, undo history and scroll
survive. one-dark is dropped (superseded by the curated highlight style).
2026-08-08 17:29:13 +08:00
Menghuan1918 d1cdca3324 terminal: keep the pty alive while its tab is open, whatever the view does
The close frame was sent on every unmount, so switching conversations (or
refreshing) killed the shell even though the tab was still open; coming
back spawned a fresh terminal. Now the unmount close frame only fires when
the tab is truly gone (checked against the per-session store state), so a
bare unmount hands the process to the host's reconnect grace and the next
connect reattaches the SAME shell with its transcript. Closing a tab
releases the quota immediately: the socket-open path keeps the close
frame, and a new pty.close API covers the socket-down (reconnect loop)
case that previously left a 30s zombie holding the quota.

The host also records the spawn cwd and respawns when a reconnect carries
a different authoritative cwd — the page-load hydrate race can attach the
real session cwd after the first connect fell back to the process cwd, and
a shell in the wrong directory must not linger.
2026-08-08 17:24:08 +08:00
Atlas Zeng cef0251aba Update README.md 2026-08-08 16:23:36 +08:00
Menghuan1918 9363ccfd9d docs: rewrite README — features first, then install-from-source
Restructured per the user's request: a full feature walkthrough (explorer,
CodeMirror editor with preview/edit toggle, terminal, git, split workbench,
tab bar, session isolation, interception) followed by step-by-step source
installation (clone → pnpm install/build → web profile registration →
restart). Corrected stale limitations (syntax highlighting now shipped;
the sidebar squeezes the layout instead of floating).
2026-08-08 16:20:48 +08:00
Menghuan1918 f4b86770d0 tabs: keep all tab content mounted (inactive hidden), so switching tabs never reloads a terminal
Switching tabs used to unmount the active view; a terminal's unmount sent
the tab-close frame, killing its pty, and switching back respawned a fresh
shell. Every tab now stays mounted and inactive ones are display:none: a
terminal keeps its WebSocket + pty + scrollback, an editor keeps its
CodeMirror view and unsaved draft, explorer/git keep their data. The close
frame now fires only when a tab is truly closed (close button, middle
click, pane removal). Re-shown terminals re-fit via the existing
ResizeObserver; editors re-measure via CodeMirror's own observer.
2026-08-08 16:19:18 +08:00
Menghuan1918 e80c1f6625 terminal: build the ws URL like the app's own downlinks; show the attempted URL on failure
The served bundle's wsUrl is provably correct, yet one report showed
ws://host/ws//host/sidebar/ws/terminal (a /ws/ prefix no version ever
emitted). Mirror the app's own downlink construction (new URL(path,
location.origin) + protocol swap — the construction the chat's working
event streams use), and surface the exact attempted URL in the failure
banner plus a console.error, so a recurrence identifies the source
(stale page vs environment rewrite) immediately.
2026-08-08 15:23:01 +08:00
Menghuan1918 97e1f60e47 ui: preview/edit toggle for markdown, empty-pane open cards, + flush to the rightmost tab
- markdown tabs open in rendered preview with a 预览/编辑 segmented toggle;
  edit mode uses the CodeMirror source editor (markdown highlighting), the
  preview renders the live unsaved draft, and the editor stays mounted while
  previewing so edits survive the toggle.
- an empty pane shows openable-type cards (explorer / git / terminal, the
  same options as the + menu, terminal dimmed at the cap) instead of the
  bare 'this pane is empty' message; clicking a card opens the tab.
- the + button sits immediately right of the last tab (sticky at the
  scrollport's right edge when the tab row overflows, so it stays
  reachable).
2026-08-08 15:16:09 +08:00
Menghuan1918 47ebffcf9b fix: session-cwd attach race, terminal failure surfacing, white-screen hardening; CodeMirror editor
- cwd resolution falls back (attached header → client summary cwd → process
  cwd) across the JSON API, media route and terminal upgrade, so explorer/
  git/terminal work from first paint instead of 'session ... has no working
  directory' while the web client hydrates the session (refresh-only fix).
- terminal client threads the summary cwd into the WS URL and stops the
  reconnect loop after three unreasoned failures, showing the close code
  with a manual retry; a 1011+reason refusal already showed the reason.
- white screen: persisted state is structurally sanitized on load (malformed
  shapes fall back to default) and the restored width is clamped to the
  viewport; the layout margin is capped at the viewport; the client entry
  wraps every lifecycle step in try/catch and pins a visible diagnostic bar
  on load/mount/interception failure instead of failing silently.
- editor: textarea replaced with CodeMirror 6 — line wrapping, per-extension
  syntax highlighting (js/ts/jsx/tsx, json, python, html, css, xml, yaml,
  sql, java, c/c++, rust, go, php, shell, toml, nginx, dockerfile,
  properties), one-dark theme, dirty dot + Ctrl/Cmd+S save kept.
- tests: +9 (cwd fallback chain over the API route, state sanitization,
  language mapping); 36 pass, typecheck + build + vm materialize clean.
2026-08-08 15:12:56 +08:00
Menghuan1918 8950e2ae69 fix: zombie terminals eating the quota; tab drop double-firing into two panes
- The per-session terminal quota counted exited zombie handles (shells
  closed or dropped on hosts without the close-frame protocol), so enough
  abandoned terminals permanently blocked new ones — the 'disconnected,
  reconnecting' loop. open() now sweeps the session's exited handles before
  the quota check, and the running instance's leftovers disappear on the
  next restart (disposeAll).
- Dragging a tab onto the strip's background fired the drop TWICE: the
  TabBar handled it (merge into this pane) and the event bubbled to the
  pane root, which ran its edge-zone handler again — producing two splits /
  a duplicated instance (up+down). The strip now stops propagation for
  drag-over and drop.
- Tests: +1 real-pty smoke (exited handle swept, new tab spawns under a
  quota of 1).
2026-08-08 14:54:40 +08:00
Menghuan1918 302bd85e62 sidebar: squeeze the conversation layout instead of floating over it
The open panel now occupies layout space: #root (the AppFrame grid) gives
up the panel width on the right via margin-right driven by a
--dsh-sidebar-width CSS variable, and only the center column is flexible,
so the squeeze lands exactly on the agent output and the input bar (VSCode
sidebar behavior). The fixed panel covers the vacated strip; expand/
collapse animates the margin and the slide on the same theme duration,
width drags disable the transition, and prefers-reduced-motion is honored.
2026-08-08 14:51:05 +08:00
Menghuan1918 0621504e1b fix: terminal quota leak and dead-terminal reuse; error boundary against blank panel
- Terminals were never destroyed when their tab closed (socket drop only
  detached listeners), so the per-session quota (3) filled up and new
  terminals failed to mount with 'terminal limit reached'. Now the client
  sends a {type:'close'} control frame on tab teardown and the host releases
  the pty immediately; a bare socket drop (refresh/switch) keeps the process
  for a 30s grace period, and a reconnect within it cancels the pending
  close. open() also respawns a process whose shell already exited instead
  of reusing a dead handle (input sink).
- The sidebar portal renders inside an error boundary: a render failure now
  shows a dismissible error strip with the message (console.error with the
  component stack) instead of a silent blank panel.
- Tests: +2 real-pty smoke specs (close releases quota + respawn after
  exit; reconnect cancels pending close).
2026-08-08 12:31:22 +08:00
Menghuan1918 56a27990d7 sidebar: + menu always reachable on empty panes, unified default width (content-area half), expand/collapse animation
- An empty pane (fresh split, or last tab dragged out) now keeps the tab
  strip with its + menu mounted, so a new tab can always be created or
  dragged in; the placeholder body still explains the state.
- Default width unified: new sessions open at half the CONTENT area
  (viewport minus the left sidebar), so the panel never swallows half the
  whole window and old/new sessions no longer disagree (previously old
  persisted widths ~1/4 vs fresh sessions 1/2). Fullscreen restore targets
  the same content-half width.
- Expand/collapse now animates: the panel stays mounted and slides
  off-screen (translateX + visibility after the slide); fullscreen width
  changes animate too, and drag-resizing disables the transition.
2026-08-08 12:05:25 +08:00
Menghuan1918 c7a7c7e5af terminal: fix posix_spawnp failure (spawn-helper exec bit), half-viewport default, fullscreen toggle, middle-click close
- Root cause of the endless 'disconnected, reconnecting' banner: pnpm
  strips the executable bit from node-pty's prebuilt spawn-helper, so every
  spawn failed with posix_spawnp failed and the client retried forever.
  ensureSpawnHelper() restores 0755 at plugin activation (mirrors
  dsh-pty-local's postinstall; works for link installs too). Verified
  against the live GUI: terminal now connects and echoes.
- TerminalView stops auto-reconnecting on a server refusal (close 1011 with
  a reason): shows the reason plus a manual retry instead of spinning.
- defaultShell() also guards an empty SHELL.
- New sessions open at half the viewport width; width clamping now allows
  filling the window, and a header fullscreen button expands the sidebar to
  the full viewport (toggle restores half).
- Middle-click on a tab closes it (auxclick button 1, autoscroll
  suppressed).
- Tests: +2 (shell fallback, spawn-helper exec bit idempotent).
2026-08-08 11:28:43 +08:00
Menghuan1918 58b4bd71ba sidebar: VSCode-style drag-to-edge splitting, collapse instead of close, theme-compliant tokens
- Split panes by dragging a tab to a pane edge (left/right/up/down zones
  with a drop overlay) or into the center to merge; the toolbar split
  buttons are gone (split-pane.tsx, state.ts moveTabToEdge/insertLeafAt).
- The header close button is now a collapse button; the expand button on
  the right edge stays visible whenever the panel is collapsed, so the
  sidebar can always be reopened (Sidebar.tsx, locales collapse/expand).
- Styles now follow the DSH web-styling rules exactly: --dsw-alias-*
  semantic colors (state-error/warn tokens), --dsw-font-* typography roles
  (size + line-height paired), --dsw-shadow-lv* elevation, no color
  literals or static palette values (sidebar.module.css).
- Tests: +3 for moveTabToEdge (edge split, center merge, self reorder).
2026-08-08 10:35:47 +08:00
Menghuan1918 63e2988ef1 dsh-better-sidebar: VSCode-like right sidebar plugin for the DSH web GUI
Host half (src/index.ts): /sidebar/api JSON API (session cwd, fs tree/read/
write, git status/diff/stage/commit/branch/log/show), /sidebar/file media
route, /sidebar/ws/terminal WebSocket over node-pty; routes share the /api
trust fence (Host-header loopback or connection.trustedHosts).

Client half (src/client): portal-mounted right panel with a per-session
split-pane workbench (recursive row/col splits, draggable dividers, tab
drag-move), tab bar with max-width 160px + plus-menu (explorer/git/terminal
multi-open rules), lazy file explorer rooted at the session cwd, editable
text editor (dirty dot, Ctrl/Cmd+S), image + Markdown preview, xterm
terminal with reconnect, and a VSCode-style git panel. Per-session state
persists to localStorage (dsh-sidebar:v1:<sessionId>). Intercepts the chat
turn-tail produced-files row (priority -1 chain entry) to open files in the
sidebar. UI styled with the dsh --dsw-* theme tokens and ui-primitives.

Build: tsdown emits the host lib plus the __ModuleLoader__ client bundle
(externals = module table words; xterm/css inlined). Tests: 19 unit + smoke
specs (pure state/tree/git parsers + real git/fs integration).
2026-08-08 01:02:24 +08:00
Atlas Zeng 4f4e462f98 Initial commit 2026-08-08 00:04:01 +08:00
57 changed files with 8578 additions and 4984 deletions
+140 -24
View File
@@ -1,31 +1,147 @@
# Build output
lib/
dist/
# Dependencies
node_modules/
# Logs & caches
# Logs
logs
*.log
.vite/
.vitest/
coverage/
.cache/
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS / editor
.DS_Store
Thumbs.db
.idea/
.vscode/
*.swp
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Local env
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.local
.env.*.local
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
.output
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp directory
.temp
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# pnpm
pnpm-debug.log*
.pnpm-store
# Unrelated work that landed in this directory (not part of apes-sidebar)
maxkb-integration/
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/
lib/
# plugin-registry install staging (assembled by scripts/package-registry.mjs)
registry/
-6
View File
@@ -1,6 +0,0 @@
# apes-sidebar build config
# Allow node-pty (a transitive runtime dep of dsh-better-sidebar's terminal
# panel) to run its native build script. Phase 0 never exercises the terminal
# panel, but pnpm's ignored-builds gate hard-fails the preinstall dep check
# otherwise, which blocks `pnpm build`.
onlyBuiltDependencies=node-pty
-157
View File
@@ -1,157 +0,0 @@
# apes-sidebar — engineering conventions
A DSH (DeepSeek Harness) **sidebar plugin** that registers five information-layer
tabs into the `dsh-better-sidebar` host: `apes:tasks`, `apes:agents`,
`apes:apps`, `apes:messages`, `apes:profile`.
This repo is a **from-scratch v2.0.0 rewrite** of the legacy JS-bundle sidebar.
The engineering template is `dsh-better-sidebar` (TS + tsdown + host/client
split). If a convention here conflicts with `dsh-better-sidebar`, follow
better-sidebar and file a note in `docs/`.
## Stack
- **Language**: TypeScript (strict, `noUncheckedIndexedAccess`, ESM, `.ts` extension imports)
- **Build**: `tsdown` (rolldown). Dual output — `lib/index.js` + `lib/invariant.js`
(host, ESM) and `lib/client.js` (client, self-contained bundle with a named
`apply` export)
- **Type check**: `tsc --noEmit -p tsconfig.json`
- **Unit tests**: `vitest` (node environment, no DOM)
- **Package manager**: `pnpm` (Node ≥ 22.13 required — `node:sqlite` + pnpm 11)
- **Peer**: `dsh-better-sidebar` ≥ 0.18.0 (host-provided, never bundled)
- **Dev-only types**: `@deepseek-ai/cordis` (types; host provides at runtime)
## Commands
```bash
pnpm install # install deps (preinstall re-checks on build)
pnpm typecheck # tsc --noEmit (strict)
pnpm build # rm -rf lib && tsc (d.ts) && tsdown (bundles)
pnpm test # vitest run (unit only; no e2e in Phase 0)
pnpm build && pnpm test && pnpm typecheck # the full gate
```
Run the **full gate** before pushing. All three must be green.
## Layout
```
src/
index.ts # HOST entry: export name/inject/apply (namespace plugin shape)
invariant.ts # invariant surface (assert, semver, peer guard) — dep-free
context-types.ts # service face contracts (ApesContext = CordisContext & {...})
client/
index.tsx # CLIENT entry: named `apply(ctx)` registering apes:* tabs
locales.ts # i18n (zh default, en fallback) + useApesLocale hook
tests/
smoke.spec.ts # host plugin shape + apply + invariant surface
locales.spec.ts # i18n contract against a mock ApesLocaleService
tsdown.config.ts # dual build config (host ESM + client CJS)
vitest.config.ts
cordis.patch.yml # plugin registration into the DSH profile
```
## Hard rules
1. **Namespace plugin export shape.** `src/index.ts` MUST export
`{ name, inject, apply }` as named exports — **never** a `default` export.
A stray `default` makes the cordis `Loader.unwrapExports` discard
`name`/`inject`/`apply` and the plugin silently fails to mount.
`tests/smoke.spec.ts` pins this.
2. **Host / client split is load-bearing.**
- The **host** half (`lib/index.js`) runs in Node. It may use Node builtins.
- The **client** half (`lib/client.js`) runs in the DSH browser shell. Its
entry is a **named `export function apply(ctx: ApesClientContext)`** (not a
default export); tsdown bundles it to a self-contained module whose export
face is `{ apply, ... }`. The host wires it via `ctx.betterSidebar` /
`ctx.effect` (read from `ctx`, not a separately-injected `hostApi`).
React is external (host-provided). Never `import` a Node builtin into
`src/client/**`.
- `src/invariant.ts` is reachable from BOTH halves — it must stay
dependency-free (no Node builtins, no @deepseek-ai imports).
3. **`dsh-better-sidebar` is a peerDependency, not a bundled dep.** The tsdown
config lists it in `deps.neverBundle`. If a build inlines it, the client
bundle ships a second copy of the sidebar registry — tabs register into a
dead instance and never appear.
4. **`@deepseek-ai/cordis` is devDependency (types only).** The host provides
the real cordis context at runtime. Do NOT add cordis to `dependencies` or
to `deps.neverBundle` — that would force the client to resolve it.
5. **Tab IDs are `apes:*`.** Never `wb:*` (legacy prefix) or any better-sidebar
internal id. The five Phase 1 tabs: `apes:tasks`, `apes:agents`,
`apes:apps`, `apes:messages`, `apes:profile`.
6. **Scope lock.** This plugin is the **information layer only**:
tasks / agents / apps / messages / profile. Do NOT add file/terminal/browser
panels — those belong to `dsh-better-sidebar` itself. Do NOT add team/room
features — those belong to `dsh-background-agents`.
7. **Bilingual i18n.** Every user-facing string goes through `t(key)` in
`src/client/locales.ts` with BOTH a `zh` and an `en` entry. No raw strings in
JSX.
8. **pnpm build-script approval.** `node-pty` (a transitive runtime dep of
better-sidebar's terminal panel) needs its native build approved once:
`pnpm approve-builds` (pick `node-pty`, `y`). Without this, `pnpm build`'s
preinstall dep-check hard-fails. It is approved in the committed
`pnpm-lock.yaml` state — don't reset it.
## Testing conventions
- Unit tests run in a **node** vitest environment. No DOM, no jsdom (the
client React components are not rendered in Phase 0 — a full cordis scratch
mount smoke lands in Phase 1).
- The host `apply()` is tested against a **minimal fake context** (see
`tests/smoke.spec.ts`) — never against a live DSH shell in CI.
- The locale contract is tested against a **mock `ApesLocaleService`** (see
`tests/locales.spec.ts`).
- A test that needs the real DSH host is a **Phase 1+** concern (cordis
scratch profile); do not add such tests to Phase 0.
## Gotchas (learned, don't rediscover)
- `tsdown` 0.22 deprecated `external`/`noExternal` in favour of
`deps.neverBundle`/`deps.alwaysBundle`. **`external` and `deps.neverBundle`
cannot coexist** — using both throws at build. Use only the `deps` API.
- `allowImportingTsExtensions` (for `.ts`-extension imports) is set in
`tsconfig.json`. `tsconfig.build.json` inherits it but emits `d.ts`, which is
compatible (tsdown handles the strip). If `tsc` build complains, the
declaration emit needs `emitDeclarationOnly` + `noEmit: false` — that is the
current `tsconfig.build.json` setup.
- `node-pty` build approval is **per-machine state** in `pnpm-lock.yaml` + the
pnpm store. A fresh `pnpm install` on a clean checkout will re-prompt.
- The workspace path contains spaces and Chinese characters
(`文稿 - figmarLite的MacBook Pro/...`) — always quote it in shell commands.
- **`lib/client.js` is a DSH browser `__ModuleLoader__` bundle, NOT plain
CJS/ESM.** Line 1 is `window.__ModuleLoader__.load({...})` — it is consumed
by the DSH browser shell's module loader and **cannot be `require()`d in
Node** (top-level `window` reference throws `window is not defined`). This is
why the cordis scratch mount smoke cannot run in the unit gate: the client
artifact is browser-only by construction, not just because the `dsh` CLI is
absent. Unit specs test the **source** `src/client/index.tsx` (`apply`
contract), never the built bundle.
## Phase status
- **Phase 0 (done)**: engineering skeleton, host/client split, i18n, invariant
surface, build gate green, unit smoke green.
- **Phase 1 (done)**: real tab registration for the five `apes:*` panels
against a mock host registry (type-only real `TabDescriptor` /
`TabComponentProps` / `Context` from `dsh-better-sidebar`), i18n for all
five views, `ctx.effect`-wrapped registration with HMR-safe disposer, and
graceful degrade when `betterSidebar` is absent. Unit smoke: `tests/tabs.spec.ts`
(6 tests). The cordis scratch mount smoke (real DSH host process) is deferred
to Phase 2 — it needs a live host and is outside the unit gate.
- **Phase 2 (partial)**: badge counts + auto-collapse (visible-gated
subscription pause) + per-tab state landed for the information panels.
`apes:tasks` / `apes:agents` show live count pills (running / subagent);
`apes:tasks` persists a `showInactive` preference through the settings
namespace (`apesSidebar.tasks`, `applies: 'live'`). The cordis scratch mount
smoke and wiring `apps` / `messages` / `profile` to real feeds are **deferred**
— better-sidebar 0.18.0 exposes no workspace-apps / message-stream /
model-provider host feeds for those three panels, and a live scratch mount
needs a `dsh` CLI (absent locally) plus a DOM harness the Phase 0 convention
deliberately avoids. See docs/ for the deferral rationale.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 dsh-external
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+286
View File
@@ -0,0 +1,286 @@
# dsh-better-sidebar
> DeepSeek Harness Web GUI 的 **VSCode 风格右侧侧边栏**插件 —— 文件管理、编辑预览、真实终端、Git 面板,一个面板全部搞定。
<img width="4632" height="2720" alt="image" src="https://github.com/user-attachments/assets/39d86636-7654-412f-86ea-c60a2d5f20f0" />
<img width="1000" height="1186" alt="image" src="https://github.com/user-attachments/assets/9dadffe0-0738-4b6d-b929-f452f51768a2" />
## 🚀 通过 plugin-registry 安装(标准)
前置条件:DSH 已集成 [plugin-registry](https://github.com/dsh-external/plugin-registry)(`dsh registry` 命令可用;集成步骤见其 README)。
```sh
git clone https://github.com/dsh-external/DSH-better-sidebar.git
cd DSH-better-sidebar
pnpm install && pnpm build # 产物: lib/index.js + lib/invariant.js (host) + lib/client.js + lib/client-registry.js (client) + lib/types
node scripts/package-registry.mjs # 组装 registry/ 暂存目录(只含清单 + 构建产物 + README,不入库)
dsh registry install ./registry # 安装(默认禁用——信任边界)
dsh registry enable dsh-external/dsh-better-sidebar # 启用
```
> **通道互斥**:registry 安装与下方「不依赖 plugin-registry 的官方 profile 安装」是同一插件的两种安装方式,**每个部署二选一**——同时启用会双挂载(Node 半挂两次、页面渲染两个侧边栏)。已用官方方式装过时,先移除 `~/.dsh/profiles/web/cordis.patch.yml` 里的 better-sidebar 挂载行与 `package.json` 的 `link:` 依赖,再走 registry。
### 更新(registry 通道)
```sh
cd DSH-better-sidebar && git pull && pnpm install && pnpm build
node scripts/package-registry.mjs
dsh registry uninstall dsh-external/dsh-better-sidebar
dsh registry install ./registry
dsh registry enable dsh-external/dsh-better-sidebar
```
## 🚀 一键安装(把提示词发给 DSH)
把下面提示词**整段**发给 DSH,它会自动完成克隆、构建、注册与安装(前置条件:已安装 DSH 且 `dsh web` 可运行,Node.js ≥ 20、pnpm ≥ 10):
```text
请帮我把 dsh-better-sidebar 插件安装到我的 web profile(插件 = VSCode 风格右侧侧边栏,仓库 https://github.com/dsh-external/DSH-better-sidebar):
1. 克隆并构建:
git clone https://github.com/dsh-external/DSH-better-sidebar.git ~/Code/DSH-better-sidebar
cd ~/Code/DSH-better-sidebar && pnpm install && pnpm build
(若 pnpm install 因 @deepseek-ai/* 的 link: 依赖解析失败,说明 DSH 源码 checkout 不在 ~/.dsh/source/current —— 停下来告诉我,不要继续)
2. 注册到 web profile:
a. 编辑 ~/.dsh/profiles/web/package.json,在 dependencies 中加入 "dsh-better-sidebar": "link:<第 1 步克隆目录的绝对路径>"
b. 编辑 ~/.dsh/profiles/web/cordis.patch.yml,追加挂载行:
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
3. 在 ~/.dsh/profiles/web 目录执行 pnpm install
4. 全部完成后告诉我,我重启 DSH 并硬刷新(Cmd/Ctrl+Shift+R)验证
```
> 安装 = 把插件登记进 web profile 的依赖清单(等价于 `dsh plugin --profile web add link:<路径>`)+ 一行 cordis 挂载行,**与 portal 无关**——portal 只指侧边栏面板在页面上的渲染方式(见下文[规范符合性](#规范符合性))。
### 更新(同样把提示词发给 DSH)
已安装过(`link:` 引用)时,把下面提示词**整段**发给 DSH 即可更新到最新版:
```text
请帮我更新 dsh-better-sidebar 插件(仓库在 ~/Code/DSH-better-sidebar,已通过 link: 安装到我的 web profile):
1. 拉取最新代码并重新构建:
cd ~/Code/DSH-better-sidebar && git pull && pnpm install && pnpm build
(若 pnpm install 因 @deepseek-ai/* 的 link: 依赖解析失败,说明 DSH 源码 checkout 不在 ~/.dsh/source/current —— 停下来告诉我,不要继续)
2. 核对注册仍然有效(缺失才需要补):
a. ~/.dsh/profiles/web/package.json 的 dependencies 中含 "dsh-better-sidebar": "link:<仓库目录的绝对路径>"
b. ~/.dsh/profiles/web/cordis.patch.yml 中含挂载行(id: better-sidebar, name: 'dsh-better-sidebar')
c. 若有缺失,补齐后在该 profile 目录执行 pnpm install
3. 完成后告诉我本次改动涉及 client 还是 host:
- 仅 client(src/client/*)→ 我硬刷新(Cmd/Ctrl+Shift+R)即可
- 含 host(src/index.ts、src/config.ts 等)→ 我需要重启 DSH 再硬刷新
```
## 为什么用它
- **⚡ 工作流不断档**:编辑文件、跑终端、看 Git 状态、拖 Tab 分栏对比,全在对话旁边完成,无需切窗口
- **🧠 按会话记忆与隔离**:每个 conversation 拥有独立的布局、分栏、Tab 与目录展开状态,切换会话即切换整套工作台
- **🎨 与 GUI 浑然一体**:完全复用 DSH 主题变量与官方组件,明暗主题自适应,观感就是原生的
---
## ✨ 功能一览
### 🗂️ 文件资源管理器
- 懒加载目录树,根目录 = 当前会话工作目录(cwd);目录优先、隐藏文件变暗、每层 1000 项上限
- 点击文件在侧边栏打开编辑器;刷新按钮重载
- **悬浮行尾 `@文件` 按钮**:一键把 `@相对路径` 追加到输入框草稿(如 `@src/main.ts`),引用文件不再手打路径
- **右键行 → 复制相对地址 / 复制绝对地址**,复制成功行内短暂显示"已复制"
### 📝 文件编辑与预览
- **CodeMirror 6**:自动换行 + 按扩展名语法高亮(js/ts/jsx/tsx、json、python、html、css、xml、yaml、sql、java、c/c++、rust、go、php、shell、toml、nginx、dockerfile、properties…)
- 脏点标记 + **Ctrl/Cmd+S 保存**(原子写入);512KB 截断横幅、二进制文件提示
- 图片直接预览;**Markdown 预览/编辑切换**,预览实时渲染未保存草稿
- 切换 Tab 不卸载:编辑器保留草稿与撤销栈
### 💻 终端
- xterm.js + node-pty **真实 shell**,每会话最多 3 个
- **Tab 打开期间一直保活**:切换 Tab/分栏、切换会话(30 秒内返回)、刷新页面都重连到同一个 shell 进程并回放转录
- 真正关闭 Tab 立即释放配额;连续失败停止重连并显示原因与重试按钮
### 🌿 Git 面板
- status 暂存/未暂存分组、按文件 diff(HEAD vs 工作区/暂存区)、stage/unstage(单个与全部)、commit、分支切换、提交历史
### 🧩 VSCode 式分栏工作台
- **拖 Tab 到分栏边缘**(左/右/上/下)即拆分新分栏,拖到中间合并,拖到另一个 Tab 上插入其前
- 分隔线拖拽调整比例;面板宽度从左侧缘拖拽;折叠滑出隐藏 + 全屏展开
### 📑 Tab 栏
- **Tab 与 `+` 菜单均带类型图标**(文件夹 / 分支 / 终端 / 代码文件)
- `+` 菜单新建 Explorer / Git / 终端(终端受 3 个上限约束);**中键点击关闭 Tab**
- 空分栏显示可打开类型卡片,点击即开
### 🔁 会话隔离与状态自愈
- 布局/分栏/Tab/目录展开按会话持久化(localStorage),切换会话即切换整套状态
- **陈旧状态自动净化**:结构校验、宽度钳制视口、损坏的重复 id 自动重新编号——刷新后分栏与新生成的 id 永不冲突,不会出现"文件开进两个分栏"
- 侧边栏**占用布局**(挤窄对话列而非悬浮遮挡,动画过渡)
- 拦截对话"产出文件"行:点击改为在侧边栏打开,而不是系统默认程序
### 🎨 界面风格
- 完全对齐 DSH 原生 chrome:**无阴影**的扁平面板(`--dsw-specific-sidebar-fill` 表面 + hairline 边框)、原生 rail 圆形图标按钮、`interactive-bg-active` 选中填充、34px/14px 原生行规格
- 键盘焦点可见(focus-visible 环)、`prefers-reduced-motion` 下关闭全部动画
### ⚙️ Side card 设置(DSH 设置页)
- 设置页新增 **Side card(侧边卡片)** 分区,持久化到 DSH 用户设置文档:
- **新会话默认打开**:新建会话是否自动展开侧边卡片(已存在的会话保持各自布局)
- **默认宽度占比**:新建会话时侧边卡片占窗口宽度的百分比(20–60,默认 30)
## ⌨️ 快捷键速查
| 操作 | 按键 |
|---|---|
| 保存编辑 | `Ctrl/Cmd + S` |
| Git 提交 | `Ctrl + Enter` |
| 关闭 Tab | 鼠标中键 |
| 拆分分栏 | 拖 Tab 到分栏边缘 |
| 合并分栏 | 拖 Tab 到分栏中间 |
| 引用文件到输入框 | 悬浮行尾 `@文件` 按钮 |
| 复制文件路径 | 右键行 → 复制相对/绝对地址 |
---
## 🧰 手动安装与更新
不想用提示词时,也可以手动完成(等价于上面提示词的步骤):
### 前置条件
- 已安装 DSH(`dsh web` 可运行),Node.js ≥ 20、pnpm ≥ 10
- **构建/类型检查**需要 DSH 源码 checkout 位于 `~/.dsh/source/current`(`devDependencies` 中的 `@deepseek-ai/*` 以 `link:` 指向它;若 checkout 在其他路径,修改 `package.json` 中对应的 `link:` 路径)。**运行期不依赖该 checkout**——`@deepseek-ai/*` 声明在 `peerDependencies`,由 web profile 提供
### 1. 克隆、安装、构建
```sh
git clone https://github.com/dsh-external/DSH-better-sidebar.git
cd DSH-better-sidebar
pnpm install
pnpm build # 产物: lib/index.js + lib/invariant.js (host) + lib/client.js + lib/client-registry.js (client) + lib/types/*.d.ts
```
> 运行期依赖(cordis、react、`@deepseek-ai/dsh-client-*` 等)按官方插件清单规范声明在 `peerDependencies`——web profile 已内置全部 peer 依赖,安装本插件无需额外装包;`dependencies` 只保留插件自有的运行时依赖(node-pty、ws、xterm、CodeMirror、schemastery 等)。
### 2. 注册到 web profile
```sh
dsh plugin --profile web add link:/绝对路径/DSH-better-sidebar
```
(等价于手动把 `"dsh-better-sidebar": "link:<绝对路径>"` 写入 `~/.dsh/profiles/web/package.json` 的 `dependencies` 并 `pnpm install`。)
然后编辑 `~/.dsh/profiles/web/cordis.patch.yml`,追加插件挂载行(`dsh plugin` 只管理依赖清单,不写挂载行):
```yaml
- insert:
- id: better-sidebar
name: 'dsh-better-sidebar'
```
### 3. 重启 DSH 并刷新页面
重启 DSH(你的 `dsh web` 启动方式),浏览器**硬刷新**(Cmd/Ctrl+Shift+R)后,右侧出现侧边栏即安装成功。
### 更新
不想用提示词时手动更新(等价于[更新提示词](#更新同样把提示词发给-dsh)):
```sh
git pull && pnpm install && pnpm build # 在插件仓库目录执行
```
- 只改了 **client** 代码(`src/client/*`)→ 硬刷新页面即可(bundle 由服务器按请求读取)
- 改了 **host** 代码(`src/index.ts`、`src/config.ts` 等)→ 重启 DSH + 硬刷新
`link:` 引用无需重新 install。
### 通过 registry 安装(手动,与上面的官方方案二选一)
前置条件:`dsh registry` 命令可用(DSH 已集成 plugin-registry)。安装源是 `scripts/package-registry.mjs` 组装的 `registry/` 暂存目录——只含清单 + 构建产物 + README,避免把 `node_modules/`(本机 `link:` 符号链接、node-pty 二进制)与 `.git/` 一并装进 `<dshHome>/plugins`。
```sh
pnpm build # 先构建(产物见上)
node scripts/package-registry.mjs # 组装 registry/(重新运行会整体重建)
dsh registry install ./registry # 安装(默认禁用)
dsh registry enable dsh-external/dsh-better-sidebar
```
更新:
```sh
git pull && pnpm install && pnpm build
node scripts/package-registry.mjs
dsh registry uninstall dsh-external/dsh-better-sidebar
dsh registry install ./registry
dsh registry enable dsh-external/dsh-better-sidebar
```
- 只改了 **client** 代码 → 硬刷新页面即可(registry 通道的 bundle 由服务器按请求读取)
- 改了 **host** 代码 → 重启 DSH + 硬刷新
- 与官方方案的互斥见上文「通道互斥」说明
## 🛠️ 开发
```sh
pnpm typecheck # tsc --noEmit
pnpm build # rm -rf lib && tsc(声明) + tsdown → lib/index.js + lib/invariant.js + lib/client.js + lib/client-registry.js + lib/types
pnpm test # vitest(单元 + 冒烟 + 插件形态 guard:真实 git/fs/node-pty 交互;manifest 一致性测试读取 lib/,需先 build)
pnpm watch # tsdown --watch(client bundle 热重建)
```
### 规范符合性
插件按 DSH 官方插件规范组织(参考 [dsh-external/turtle-ui](https://github.com/dsh-external/turtle-ui) 与 mainline `packages/client/AGENTS.md`),并额外提供 plugin-registry 标准发布面(`dsh.plugin.json`)。**安装通道与渲染方式是两个独立概念**:安装走 profile 清单协议(`dsh plugin` / cordis.yml 行,见[一键安装](#一键安装把提示词发给-dsh))**或** registry 通道(`dsh registry`,见[通过 plugin-registry 安装](#通过-plugin-registry-安装标准)),二者互斥;下面的 portal 条目只描述面板在页面上的渲染方式。
- **插件形态**:`export const name / inject / Config / apply`,无 default 导出;`tests/plugin-shape.spec.ts` 通过 `Loader.unwrapExports` 守卫该形态
- **清单**:`types` + `exports`(`.` / `./invariant` / `./client` / `./src/*` / `./package.json`)、`dshClient`(`platform: 'web'` + 信息性 `inject` 边)、peerDependencies、`engines`、`files` 产物明细、`prepare`(消费者侧 `tsdown`,git 安装可用)
- **client 契约**:仅导出 `apply`/`inject`(+ 类型);store 为 `createSidebarStore()` 工厂,实例归 `apply` 所有;`src/invariant.ts` 伴生;client bundle 复刻官方 preset(externals = 平台模块表 + runtime/client 豁免、纯度门、CSS Modules 内联)
- **registry 形态(标准发布面)**:仓库根 `dsh.plugin.json`(原生清单:id `dsh-external/dsh-better-sidebar`、`main` = host 构建产物、`client.main` = registry client bundle、`contributes` 空声明);`tests/manifest-consistency.spec.ts` 守卫清单与构建产物一致性(id 严格两段、version 与 package.json 同步、bundle id = 清单 id)
- **双 client bundle**:同一 `src/client/index.tsx` 构建出 `lib/client.js`(官方通道,id = 包名 `dsh-better-sidebar`)与 `lib/client-registry.js`(registry 通道,id = 清单 id)——两通道的 bundle-id 契约互不相同,各产出一份、同源不漂移;每个部署二选一(见安装章节「通道互斥」)
- **已知偏差**:侧边栏面板经 portal(`document.body` + `createRoot`)挂载而非 slot——官方 slot 系统的 `'root'` 由 ui-layout 独占声明,重复声明 fail loud,外部插件无整面板 slot 可用;与 shell 的集成点(`conversation.chat.turnTail`)走官方 chain-slot 机制
## 🏗️ 架构
一个 npm 包,host/client 双半结构(与 DSH monorepo 内 client 包同构):
| 半 | 入口 | 职责 |
|---|---|---|
| host | `src/index.ts` → `lib/index.js` | cordis 插件(`Config` 为 schemastery schema,可调 readLimit/mediaLimit/listLimit/terminalsPerSession/reconnectGraceMs):`/sidebar/api/*` JSON API、`/sidebar/file` 媒体路由、`/sidebar/ws/terminal` WebSocket;fs / git / pty 服务 |
| invariant | `src/invariant.ts` → `lib/invariant.js` | 包属 invariant 伴生(注册包名,无运行时断言) |
| client(官方通道) | `src/client/index.tsx` → `lib/client.js` | 浏览器 bundle(`__ModuleLoader__.load` 闭包工厂,id = 包名):portal 侧边栏 + 各视图 + turnTail 拦截 |
| client(registry 通道) | `src/client/index.tsx` → `lib/client-registry.js` | 同一源码的 registry 版本(id = 清单 id `dsh-external/dsh-better-sidebar`,`dsh.plugin.json` 的 `client.main` 指向它) |
| manifest | `dsh.plugin.json`(仓库根;`scripts/package-registry.mjs` 组装 `registry/` 安装根) | registry 发布面:id / version / main / client.main;仅 registry 通道读取 |
- 所有 API 携带 `sessionId`;cwd 权威值取自会话 header,会话未附加(页面加载竞态)时回退客户端摘要 cwd,再回退进程 cwd;终端按 `${sessionId}:${tabId}` 键控,重连时若权威 cwd 变化则重启 shell 到正确目录
- 路由与 `/api` 同款信任围栏(Host 头 loopback / `connection.trustedHosts`,`src/trust-fence.ts`,拷贝自 `@deepseek-ai/dsh-client-connection`,BSD-3-Clause)
- client 状态按会话持久化到 `localStorage`(`dsh-sidebar:v1:<sessionId>`),读取时结构校验 + 宽度钳制视口 + 重复 id 重新编号
- client bundle externals 仅模块表词条(react/cordis/ui-primitives 等),xterm/CodeMirror 等全部内联
## 🔐 安全边界
- 路由受 Host 头信任围栏保护(与 `/api` 一致;`0.0.0.0` 部署时由 `dsh web` 启动器动态派生的 LAN IP 列表生效)
- `fs.write` 仅文本写入、原子替换(临时文件 + rename);`/sidebar/file` 仅限会话 cwd 内的媒体文件
- 提交/分支操作仅调用 git CLI,绝不设置 git 身份
## ⚠️ 已知限制(v1 非目标)
- Git 无 push/pull/fetch;无文件 watcher(手动刷新)
- 工具行内的文件打开按钮(核心代码)不可拦截,仅"产出文件"行被接管
- 终端 Tab **拖拽到另一分栏**时会重挂载(shell 重开);切换 Tab / 切换会话(30 秒内返回)/ 刷新页面不会
## 🖥️ 平台支持
代码按 **Windows / Linux / macOS** 三平台适配(macOS 为日常验证平台;Windows/Linux 的平台分支经单元测试覆盖,建议真机冒烟):
- 路径处理全部走 `node:path` 平台 API(列表拼接、`resolve`);媒体路由的前缀检查与相对路径投影容忍混合分隔符,Windows 上大小写不敏感
- Windows 上终端 shell 为 `powershell.exe`;node-pty 的 spawn-helper 修复(pnpm 剥执行位)自动跳过 Windows
- `node-pty` 安装优先下载预编译二进制(`prebuild.js`,需网络),失败则源码编译——Windows 需 Visual Studio Build Tools,Linux 需 `make`/`g++`/`python3`,macOS 需 Xcode Command Line Tools;`pnpm-workspace.yaml` 已声明 `allowBuilds: node-pty: true`
- git 操作仅依赖 `git` 在 PATH(Windows 需 Git for Windows)
-13
View File
@@ -1,13 +0,0 @@
# apes-sidebar bundle patch: inserts the host plugin row into the profile
# roster. The client half (sidebar tabs: Tasks / Agents / Messages / Apps /
# Profile) loads through the dsh.client.inject declaration in package.json;
# this patch mounts the Node-side host entry only.
#
# Double-mount guard: if another *enabled* entry already mounts the same
# package (e.g. via an aggregate bundle), this row backs off.
- insert:
- id: apes-sidebar
name: apes-sidebar
disabled: !!js >
[...ctx.loader.entries()].some((e) =>
e.options.name === 'apes-sidebar' && e.options.id !== 'apes-sidebar' && !e.disabled)
-362
View File
@@ -1,362 +0,0 @@
# apes-sidebar 重写方案(v2)— 冲突检查通过,从零开始
> 目标:重写现有 `apes-sidebar`(v0.4.0,单文件手写 JS client bundle),
> **代码规范/工程方案学习 [DSH-better-sidebar v0.18.0](https://github.com/omdsh-dev/DSH-better-sidebar)**,
> **需求功能/交互能力学习 [tutti-os/tutti](https://github.com/tutti-os/tutti)**。
>
> 分析基线:DSH-better-sidebar `v0.18.0`(浅克隆 /tmp/DSH-better-sidebar)· Tutti(本地 /Users/figmarlite/Desktop/…/APES/tutti)
> 生成时间:2026-09-03 · 用途:重构开工前对齐,供评审与拆单。
## 0. 冲突检查结论(2026-09-03)
**目标自洽,不冲突,无需推翻重规划。** 两个参考源互补:
- better-sidebar v0.18.0 = 「怎么建」(工程规范/构建/测试/i18n/token/服务契约)
- tutti-os/tutti = 「做什么」(面板模型/角标/agent 可编程驱动)
三处主动调整(借"从零开始"授权做干净化):
1. **Tab id 换前缀**:旧 `wb:*` → 新 `apes:*`(apes:tasks / apes:agents / apes:apps / apes:messages / apes:profile),不留 `wb` 历史包袱。
2. **定位收敛**:只做 **tasks/agents/apps/messages/profile** 五面板信息层;files/terminal/browser/editor 由 better-sidebar 宿主提供,**不重复造轮子**(复用 `openTab/openFile` + 宿主内置 tab 即可)。
3. **放弃团队房间**:tutti 的 rooms/team 依赖 `dsh-background-agents` 0.7.x,与 DSH 基座 loader id 冲突会崩(历史教训),v2 不做。
唯一硬约束:peer 钉 `@deepseek-ai/cordis ^4.0.2` + `dsh-better-sidebar ^0.18.0`(better-sidebar 发 npm `next` dist-tag,随 DSH rc.x 漂移;升级前必须跑挂载冒烟)。
---
## 1. 现状诊断(旧版 v0.4.0 的痛点)
### 1.1 现状结构
```
figmar/apes-sidebar (v0.4.0)
├── package.json # peer: cordis ^4.0.1 / better-sidebar ^0.16.0 / react 18
├── cordis.patch.yml # insert apes-sidebar(双挂载守卫)
├── lib/index.js # host 最小入口(~1KB,只打日志)
└── lib/client.js # 45KB 单文件,window.__ModuleLoader__.load 手写 bundle
# 纯 JS + React.createElement,无 JSX/TS/构建/测试
```
注册 4 个 Tab(全部基于 better-sidebar `ctx.betterSidebar.registerTab`):
| Tab id | 作用 | 参照 |
|---|---|---|
| `wb:tasks` | 工作区+时间分组会话列表、搜索、运行中徽标、运行中任务区 | WorkBuddy |
| `wb:agents` | 子代理/后台 agent 列表、状态、一键跳转 | Tutti multi-agent |
| `wb:apps` | 应用中心(Design/PPT/Video 网格) | Tutti App Center |
| `wb:profile` | Provider/模型信息 + 设置/帮助入口 | WorkBuddy 底部 |
配置:better-sidebar 设置页 `pluginSettings['apes-sidebar']`(`enableTasks`/`enableAgents`/`enableApps`/`enableProfile`/`displayName`/`showTime`/`showUngrouped`/`appsList`)。
### 1.2 主要痛点
1. **单文件 45KB 手写 JS bundle**:无类型、无模块拆分、无构建产物管理;
CSS 以字符串内联注入(`APPS_CSS` 拼接),每次改样式要手工编辑字符串。
2. **零测试**:无 spec,无 CI 挂载冒烟——任何 API 漂移(如 DSH rc 升级)静默崩。
3. **i18n 手写**:本地 zh/en 两字典,不跟随宿主 `ctx.locale`,第三方语言直接缺。
4. **皮肤脆弱**:CSS 硬编码 `#fff` 渐变等(`card-icon` 用 `#3370ff→#7c3aed`),不消费
`--dsw-alias-*` token 链,深色/浅色/品牌换肤会破。
5. **状态模型简单**:轮询/快照读取 sessions/workspaces,无订阅模型、无 memo、无懒加载。
6. **无 host 能力**:只有日志入口。需要 host 路由(读会话日志、子代理拓扑、job 回放)
时只能绕 DSH 网关或放弃。
7. **版本基线落后**:peer 只到 better-sidebar ^0.16.0;而 better-sidebar v0.18.0
已毕业正式版、只支持 DSH 0.1.2-rc.1+(协议、RPC 方言、client store 均大变)。
---
## 2. 参考源 A:DSH-better-sidebar v0.18.0 工程规范(「怎么建」)
### 2.1 包结构(published npm package)
- `name: dsh-better-sidebar` / `version: 0.18.0` / `type: module` / `pnpm@11.8.0`
- `main: lib/index.js`;exports 子路径:`.` / `./invariant` / `./client` / `./client/service` / `./client/api` / `./src/*` / `./package.json`
- `dsh.bundle.patch: ./cordis.patch.yml`(bundle 激活的**硬性资格**)
- `dsh.client.inject`: `[locale, ui-slots, ui-conversation, modules]`,`platform: web`
- `dsh.plugin.json`:市场 manifest 形态(id/version/main/engines.dsh/contributes/client.main)
- `files`: lib + src + cordis.patch.yml + scripts + README
### 2.2 构建与质量
- `build`: `tsc -p tsconfig.build.json && tsdown`(类型声明 + JS 产物)
- `typecheck` / `test`(vitest) / `lint:ts`(oxlint) / `playwright`(e2e)
- **懒加载 chunk**:重依赖(xterm/CodeMirror/mermaid)独立 bundle `lib/client-<name>.js`,
经 `/sidebar/bundle` 按需下发、`globalThis.__dshChunks__` 物化(`src/client/chunk-loader.ts`);
核心 bundle 禁止静态 import chunks。
- **CI 挂载冒烟**(`plugin-mount` job):`pnpm pack` → scratch profile 真实安装 →
`dsh web`(keyless)→ Playwright 断言 `[data-dsh-better-sidebar]` 挂载、无 pageerror、
+ 菜单逐 tab 打开、editor chunk 强制加载。本地:`pnpm build && pnpm pack && pnpm test:mount`。
### 2.3 仓库硬约束(AGENTS.md)
- 禁止修改 DSH 源码(零写入 `~/.dsh/source/current`);改动必须 PR(`feat/*`/`fix/*`)。
- 挂载只走 `cordis.patch.yml` + profile;**不反向侵入 DSH**。
- 市场约束:`dependencies/peerDependencies/optionalDependencies` **一律不得出现 `cordis`**;
`scripts` 不得含 `preinstall/install/postinstall/prepare`(`tests/market-manifest.spec.ts` 守护)。
- 缺能力时用 DSH 现成只读/公开 API 或插件自有路由(如 jobs.output 事件回放),不改 DSH。
### 2.4 架构:host half + client half
**host half(Node 侧,`src/index.ts` ~1300 行)**
- 导出 `export const name = 'dsh-better-sidebar'`、`inject = ['webServer','sessions','webRuntime','tools']`、`export function apply(ctx)`。
- 插件自有路由全走同源 fence:`/sidebar/api/*`(JSON:fs.tree/search/read/write、git.*、session.cwd、
changes.ops…)、`/sidebar/file`(媒体)、`/sidebar/html`(预览)、`/sidebar/bundle`(懒加载)、
`/sidebar/ws/*`(终端 WebSocket upgrade)。
- **信任围栏** `trust-fence.ts`:与 /api 网关同源(Host-header loopback / webRuntime trustedHosts),
逐请求读取;**路径安全** `path-security.ts`:会话 cwd 内解析、workspaceFence 守卫、写路径原子写(tmp+rename)。
- 会话隔离:所有请求带 sessionId,权威 cwd 来自 session store(header → client cwd → persistence → process.cwd 四级回退)。
- PTY 管理:`pty-manager.ts` + `agent-pty.ts`(agent 终端注册表、resize gate、按会话配额)。
- 工具注册:`registerTools`(agent 可调用)、`agent-opens.ts`(agent 主动开 tab 的 registry + open tool)。
**client half(浏览器侧,`src/client/index.tsx` ~446 行)**
- `inject = ['slots','sessions','locale','modules','connection']`
- `attachLocale(ctx.locale)` + `ctx.effect` 注册词典(zh/en 为主,第三语言走 better-locale 可选覆盖)。
- `createSidebarStore()` 单例 per activation → `ctx.provide('betterSidebar', service)`。
- `ctx.effect(() => registerBuiltins(ctx, service))`:内置 7 tab + 6 viewer **自己吃自己的狗粮**。
- 挂载:`document.body` 追加 host div → `createRoot` 渲染 `RenderBoundary > Sidebar`;
bodyObserver 防宿主移除;几何自检防页面 transform 漂移(degraded viewport sync)。
- 拦截层:turn-tail 拦截、open-path 拦截(`remote.session.openWorkspacePath` 遮蔽)、外链拦截
(urlTarget 认领,默认 browser tab)、**IME 守卫**(composition 键防第三方 UI 抢键)。
- 设置面:`ctx.slots.inject('settings.section', …)` 声明「Side card」设置分区。
### 2.5 服务契约(消费插件 API,也是 apes-sidebar 重写后要面向的基座)
`ctx.betterSidebar`(client-only):
```ts
registerTab(descriptor: TabDescriptor): () => void // 返回 disposer,HMR-safe
registerFileViewer(descriptor: FileViewerDescriptor): () => void
getTabs() / getFileViewers() / getTab(id)
isTabEnabled(id) / isViewerEnabled(id)
matchFileViewer(path, head?)
openTab(seed: OpenTabSeed, scope?) / closeTab(tabId, scope?)
openFile(scope, path, title?)
updateTab / activateTab
subscribe(listener) / getSnapshot() / subscribeState(listener)
```
`TabDescriptor` 关键字段(v0.18.0 全量):
```ts
interface TabDescriptor {
id: string
title: string | (() => string)
icon?: ReactNode | ((size: number) => ReactNode)
order?: number // + 菜单排序,默认 100
hidden?: boolean
available?: (ctx, scope, state) => boolean // + 菜单禁用谓词
single?: boolean // 单实例(dedupeKey: () => id 语法糖)
dedupeKey?: (tab) => string | undefined
createTab?: (state) => { tab, patch? } | null // 自定义 mint tab id
urlTarget?: (url: URL) => boolean // 外链认领
settings?: SidebarSettingsDeclaration // toggles / pluginToggles / render
badge?: (ctx, scope, state) => string | number | null | undefined // Tab 角标
onOpen? / onActivate? / onClose? // 生命周期(仅 service 路径触发)
component: (props: TabComponentProps) => ReactNode
}
```
声明式设置:`'switch' | 'text' | 'number' | 'select'`;select 支持 `multi` 与图标选项卡。
`TabComponentProps = { ctx, store, scope, tab, visible, expanded?, revealed?, onToggleDir?/onOpenFile?/… }`。
**接入规范(外部插件视角)**:
- peerDependencies: `@deepseek-ai/cordis` + `dsh-better-sidebar`(optional);
type-only import 触发 `declare module '@deepseek-ai/cordis'` 类型合并;
- 纯浏览器插件走 `import type {} from 'dsh-better-sidebar/client/service'`(零 Node 依赖图);
- client half `inject: ['betterSidebar', …]`,`ctx.effect(() => ctx.betterSidebar.registerTab(...))`;
- 宿主 half 无此服务——需要读状态走 better-sidebar 自己的 HTTP/WS 路由。
### 2.6 i18n / 皮肤 / 无障碍规范
- i18n:词典注册到 DSH `ctx.locale`(LOCALE_NS);新增 zh key 必须同步 ja(否则 ja 回退 en);
渲染 MarkdownText 必须经 `markdownTextProps()`(labels 嵌套契约)。
- 皮肤:**视觉值只消费 `--dsw-alias-*` / `--dsw-font-*` / `--ds-*` 令牌**,无硬编码颜色;
硬编码 hex 只能做 token 链末端 fallback(`color:var(--x,#fff)`),并有 `tests/theme.spec.ts` 守护。
- a11y:role=tablist/tab/separator、aria-selected/valuenow、focus-visible ring、IME guard。
---
## 3. 参考源 B:Tutti 侧边栏能力(「做什么」)
### 3.1 面板模型(packages/agent/gui/workbench/tool-sidebar/model.ts)
```ts
export const agentToolPanelIds = ['files','terminal','browser','tasks','apps','messages','side'] as const
```
- 每面板有 label、可开多实例(resourceId 区分,如 browser tab 带 agent session id)。
- 每面板默认/最小/最大宽度(files 最大不限、side 默认 440 最大 600、其余默认 720/最小 420)。
- `mainContentMinWidth = 280`:主内容区最小宽度保护。
### 3.2 行为层(AgentToolSidebar.tsx + useAgentToolSidebarController)
- **多标签**:TabBar(role=tablist),每个 tab = panel + resourceId;关闭/激活/拖拽。
- **可编程 handle**(AgentToolSidebarHandle,agent/宿主可驱动):
`addPanel / ensurePanel / openPanel / closeTab / close / collapseForContainerConstraint`
- **容器约束自动折叠**:`shouldAutoCollapseAgentToolSidebar({containerWidth, mainContentMinWidth, sidebarWidth})`
→ 窗口变窄时 `collapseForContainerConstraint()`(不关 tab,只收起到 Dock)。
- **宽度记忆 + 动画**:`onLayoutWidthChange`、resize handle(键盘可操作 role=separator)、
width transition 260ms。
- **懒挂载渲染**:`contentReadyTabIds` + `renderLoading`(未就绪 tab 显示 loading,就绪才挂)。
- **空态 Picker**:`isEmptySidebar` → AgentToolSidebarPicker(从 + 菜单选面板)。
- **展开态**:expand 按钮让活动面板撑满(expanded panel 概念)。
### 3.3 信息架构与角标(StandaloneAgentToolSidebar.tsx)
- `panels` 条件组装:`side` 面板仅在有 side conversation identity 时出现(`canAdd: false`)。
- `quickActionPanels`: 有自动化 browser 时把 browser 提到 quick action;常驻 tasks/apps/messages。
- `reminders`(角标计数):
- `browser: automationBrowserCount`(运行中 automation browser tab 数)
- `messages: messageCenterWorkingCount`(工作会话中 working agent 消费者计数,订阅 sessionEngine)
- 对应旧版 apes-sidebar 的 badge 概念:tasks 徽标 = 运行中会话数;agents 徽标 = 运行子代理数。
### 3.4 各面板承载(渲染层职责划分)
| 面板 | 承载 | apes-sidebar 对应/借鉴 |
|---|---|---|
| files | 文件树/编辑器 | (better-sidebar 已有 editor)不重复 |
| terminal | 终端 | (better-sidebar 已有)不重复 |
| browser | 内嵌浏览器 | (better-sidebar 已有)不重复 |
| tasks | Issue Manager/任务编排 | **apes:wb:tasks 升级对象**:会话/任务拓扑 |
| apps | App Center | **apes:wb:apps 对齐对象**:应用网格+调起 |
| messages | 消息中心(审批/通知/后台工作汇总) | **apes 可新增 wb:messages** |
| side | Side Conversation(子会话并排) | 子代理会话展示可借鉴 |
Tutti 其余值得迁移的交互:控制中心(一个视图看所有 agent 对话+待审批+运行任务)、
goal→tasks 自动拆分、@ 深度引用、task 分配给人/agent。
### 3.5 进程/事件模型
- 会话与活动数据经 `sessionEngine`/activity 服务订阅(`subscribe + getSnapshot` 模式,
即 React 19 `useSyncExternalStore` 形状)——ape-sidebar 应把「轮询」升级为订阅 + memo。
- 子代理/后台工作 → `subagent-live-route` / jobs 回放(better-sidebar 已实现:
`jobs-routes.ts` 从会话事件日志回放 job 输出、`subagent-live-route.ts` 批量子代理状态)。
---
## 4. apes-sidebar v2 目标架构
### 4.1 定位
> **apes-sidebar v2 = 构建在 better-sidebar v0.18.0 之上的「工作空间指挥层」**,
> 不重复 files/terminal/browser/editor(better-sidebar 已有),聚焦 Tutti 风格
> **任务 / Agent / 消息 / 应用**四类信息面板 + host 侧路由与 agent 可编程驱动。
仍是一个 **client-half 注册型插件**(注册到 `ctx.betterSidebar`),但工程规范全面对齐 better-sidebar。
### 4.2 目标仓库布局
```
apes-sidebar/
├── package.json # peer: @deepseek-ai/cordis ^4.0.2 / dsh-better-sidebar ^0.18.0 / react ^18
├── cordis.patch.yml # insert apes-sidebar(双挂载守卫,抄 v0.18.0 写法)
├── tsconfig.json / tsconfig.build.json
├── tsdown.config.ts # 多产物:lib/index.js + lib/client.js + lib/client-<chunk>.js
├── vitest.config.ts / playwright.config.ts
├── scripts/e2e-mount.sh # scratch profile 真实挂载冒烟
├── AGENTS.md # 仓库硬约束(参照 better-sidebar)
├── src/
│ ├── index.ts # host half:路由 + 工具(若需要)
│ ├── context-types.ts # declare module '@deepseek-ai/cordis' 类型合并
│ ├── client/
│ │ ├── index.tsx # apply():attachLocale → 注册词典 → registerTabs
│ │ ├── locales.ts # zh/en(跟随 ctx.locale)
│ │ ├── api.ts # 插件自有路由 client
│ │ ├── tabs/
│ │ │ ├── TasksTab.tsx # 会话/任务导航(保留 wb:tasks id 兼容持久化)
│ │ │ ├── AgentsTab.tsx # Agent/子代理拓扑(wb:agents)
│ │ │ ├── AppsTab.tsx # 应用中心(wb:apps)
│ │ │ ├── MessagesTab.tsx # (新)消息中心/审批汇总
│ │ │ └── ProfileTab.tsx # 底部用户区(wb:profile)
│ │ └── builtins.ts # 统一注册(吃自己的狗粮)
│ └── (host routes 需要时) sidechat-routes.ts / subagent-routes.ts
└── tests/
├── tabs.spec.tsx / theme.spec.ts / market-manifest.spec.ts
└── e2e/mount.e2e.ts
```
### 4.3 关键工程决策
1. **TS + tsdown 双产物**:lib/index.js(host)、lib/client.js(client 入口)、
chunks 按需(若引入 mermaid/图表等重依赖);`files` 白名单发布。
2. **client inject 精简**:只声明实际 require 的宿主包
(`@deepseek-ai/dsh-client-ui-primitives` + 服务注入 betterSidebar/sessions/workspaces/locale 经 cordis inject,不塞 dsh.client.inject)。
3. **host half 保持最小**:纯展示可无 host;需要会话日志回放/子代理状态时新增
better-sidebar 风格的自有路由(同源 fence + 会话隔离),不碰 DSH 源码。
4. **Tab id 向后兼容**:`wb:tasks` / `wb:agents` / `wb:apps` / `wb:profile` 保持不变
(避免破坏已持久化 pluginToggles / 用户布局);样式 data 属性带 `apes-sidebar-` 前缀。
5. **配置迁移到声明式 settings**:沿用 `pluginToggles`(switch/select)机制,
在 better-sidebar 设置页渲染;新增 `displayName` select(apes-sidebar/feyOS/自定义)。
6. **i18n 对齐**:词典注册 `ctx.locale`(zh/en);新增 key 同步第三方语言或做 en 回退。
7. **皮肤对齐**:全部视觉消费 `--dsw-alias-*`/`--ds-*` token,硬编码 hex 仅作链尾 fallback。
8. **badge/reminders 对齐 Tutti**:TasksTab 徽标 = 运行中会话/任务数;AgentsTab 徽标 =
运行子代理数(订阅 sessionEngine 而非轮询)。若可经 host 路由读 agent consumer counts,
则消息中心徽标 = working count(对应 Tutti messageCenterWorkingCount)。
9. **懒挂载**:未激活 tab 不渲染内容;首次打开再挂(contentReady + loading),
与 better-sidebar TabContent 行为一致。
10. **测试门禁**:至少 tabs 注册/开关/渲染 smoke + 挂载 e2e + 皮肤 token 守护 +
market manifest 守护(cordis 不得出现在 dependencies)。
---
## 5. 功能清单(Tutti → apes-sidebar 映射)
| # | 能力 | Tutti 参照 | apes-sidebar v2 落地 | 优先级 |
|---|---|---|---|---|
| F1 | 会话/任务导航(工作区+时间分组、搜索、运行徽标) | Tasks | `wb:tasks` 升级:订阅式 + 运行中任务区 + 拓扑 | P0 |
| F2 | Agent 总览(子代理/后台 agent、状态、跳转) | multi-agent | `wb:agents` 升级:订阅式、subagent lineage | P0 |
| F3 | 应用中心(已装应用网格 + 调起) | App Center | `wb:apps`:appsList 可配置、viewId 调起多级降级 | P0 |
| F4 | 消息中心/审批/工作汇总(角标) | messages + messageCenterWorkingCount | `wb:messages`(新) | P1 |
| F5 | 任务自动拆分(goal→tasks) | Tutti goal 编排 | 规划:输入目标→子任务草稿→一键建会话(DSH 能力内简化版) | P2 |
| F6 | 显示名/开关/子设置 | — | 声明式 settings(pluginToggles) | P0 |
| F7 | agent 可编程开 tab | AgentToolSidebarHandle | host route + better-sidebar openTab/openFile 组合 | P1 |
| F8 | 自动折叠/宽度记忆 | shouldAutoCollapseAgentToolSidebar | 交 better-sidebar 容器(若支持)或本地监听 | P2 |
| F9 | Profile 用户区 | — | `wb:profile`:模型/provider/连接状态 | P0 |
| F10 | 深色/浅色/换肤 | skin tokens | 全 token 化 | P0 |
---
## 6. 分阶段实施
### Phase 0:工程骨架(0.5d)
- 建仓、tsconfig/tsdown/vitest/playwright、package.json(peer 钉 cordis ^4.0.2 + better-sidebar ^0.18.0)
- cordis.patch.yml(v0.18.0 守卫写法)、AGENTS.md、最小 host+client 空注册冒烟
- 通过 `pnpm test:mount` 在 scratch profile 验证挂载
### Phase 1:4 Tab 平移 + 质量化(1-1.5d)
- 把旧 client.js 的 4 视图用 TSX 重写,行为不变(保留 wb:* id)
- i18n → ctx.locale、皮肤 → token、样式 → CSS modules
- 配置 → pluginToggles 声明式
- 单测:注册数/开关/渲染/badge
### Phase 2:Tutti 能力增强(2-3d)
- `wb:tasks`:订阅式 sessionEngine 快照 + 运行中任务区(子代理拓扑优先排序)
- `wb:agents`:subagent lineage + 状态订阅
- 新增 `wb:messages`(若 host 有 working count 数据源)
- agent 可编程开 tab(F7)最小实现
### Phase 3:打磨与发布(1d)
- 皮肤 token 全检、a11y、IME guard、错误边界
- e2e 挂载冒烟入 CI、Gitea 发布 + npm 发版脚本
---
## 7. 验证清单(每个 Phase 完成时)
```bash
pnpm typecheck
pnpm test # vitest 单测
pnpm lint:ts
pnpm build && pnpm pack
pnpm test:mount # scratch profile 真实挂载(需 dsh + playwright chromium)
# 真机:dsh plugin --profile desktop add file:<tarball> → 硬刷新 → + 菜单见新 tab
```
---
## 8. 风险与注意
- **DSH rc 漂移**:better-sidebar 是 0.1.2-rc.1 线,DSH 官方 rc.x 发 next dist-tag;
重写期间锁定 devDeps peer,升级前跑 `pnpm peers check`。
- **不要装 dsh-background-agents 0.7.x**:与 DSH 基座 loader 条目 id 冲突 → 启动崩溃
(历史教训),团队房间类能力暂缓。
- **apps 调起无公开一键 API**:维持多级防御(uiConversation.activateView → slots.set → 提示)。
- **client 纯模块表**:运行时只能 require react/react-dom/primitives 等宿主提供模块;
其余依赖必须内联或走懒加载 chunk,否则构建纯度门会拦。
+16
View File
@@ -0,0 +1,16 @@
{
"id": "dsh-external/dsh-better-sidebar",
"version": "0.1.3",
"main": "./lib/index.js",
"description": "VSCode 风格右侧侧边栏:文件资源管理器 / 编辑器 / 终端 / Git,按会话隔离",
"engines": {
"dsh": ">=0.0.1"
},
"contributes": {
"tools": [],
"skills": []
},
"client": {
"main": "./lib/client-registry.js"
}
}
+110 -57
View File
@@ -1,69 +1,122 @@
{
"name": "apes-sidebar",
"version": "2.0.0",
"description": "WorkBuddy/Tutti 风格右侧边栏增强(构建在 dsh-better-sidebar 之上:任务 / Agent / 消息 / 应用 / Profile 五面板信息层)",
"name": "dsh-better-sidebar",
"version": "0.1.3",
"description": "DSH web plugin: a VSCode-like right sidebar (explorer / editor / terminal / git), isolated per conversation session.",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": "./lib/index.js",
"./invariant": "./lib/invariant.js",
"./client": "./lib/client.js",
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./client": {
"types": "./lib/types/client/index.d.ts",
"default": "./lib/client.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib",
"src",
"cordis.patch.yml",
"scripts",
"AGENTS.md",
"README.md"
],
"scripts": {
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
"typecheck": "tsc --noEmit -p tsconfig.json",
"test": "vitest run",
"verify:mount": "node scripts/verify-mount.cjs",
"lint:ts": "oxlint src tests tsdown.config.ts vitest.config.ts",
"dev": "tsdown --watch",
"pack:plugin": "pnpm build && pnpm pack --pack-destination dist"
},
"peerDependencies": {
"dsh-better-sidebar": "^0.18.0"
},
"peerDependenciesMeta": {
"dsh-better-sidebar": {
"optional": true
}
},
"devDependencies": {
"@deepseek-ai/cordis": "^4.0.2",
"@types/node": "^22.0.0",
"@types/react": "~18.3.1",
"dsh-better-sidebar": "^0.18.0",
"lightningcss": "^1.32.0",
"oxlint": "^0.15.0",
"react": "~18.3.1",
"tsdown": "^0.22.2",
"typescript": "^5.6.0",
"vitest": "^4.1.8"
},
"engines": {
"node": ">=20"
},
"packageManager": "pnpm@11.8.0",
"dsh": {
"bundle": {
"patch": "./cordis.patch.yml"
},
"client": {
"inject": [
"betterSidebar",
"locale",
"sessions"
],
"platform": "web"
}
"dshClient": {
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-slots",
"@deepseek-ai/dsh-client-ui-conversation"
],
"platform": "web"
},
"license": "MIT"
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/client.js",
"lib/types/**/*.d.ts",
"src"
],
"scripts": {
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
"typecheck": "tsc --noEmit",
"bundle": "tsdown",
"watch": "tsdown --watch",
"prepare": "tsdown",
"test": "vitest run"
},
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-schema-form": "^0.0.1",
"@deepseek-ai/dsh-client-ui-conversation": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
"@deepseek-ai/dsh-client-ui-settings": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
"@deepseek-ai/dsh-host-webserver": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-settings": "^0.0.1",
"cordis": "^4.0.0-rc.7",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"dependencies": {
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-cpp": "^6.0.3",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/lang-go": "^6.0.1",
"@codemirror/lang-html": "^6.4.12",
"@codemirror/lang-java": "^6.0.2",
"@codemirror/lang-javascript": "^6.2.5",
"@codemirror/lang-json": "^6.0.2",
"@codemirror/lang-markdown": "^6.5.2",
"@codemirror/lang-php": "^6.0.2",
"@codemirror/lang-python": "^6.2.1",
"@codemirror/lang-rust": "^6.0.2",
"@codemirror/lang-sql": "^6.10.0",
"@codemirror/lang-xml": "^6.1.0",
"@codemirror/lang-yaml": "^6.1.3",
"@codemirror/language": "^6.12.4",
"@codemirror/legacy-modes": "^6.5.3",
"@codemirror/search": "^6.7.1",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.8",
"@lezer/highlight": "^1.2.3",
"@xterm/addon-fit": "^0.10.0",
"clsx": "^2.1.1",
"node-pty": "^1.1.0",
"schemastery": "^3.18.0",
"ws": "^8.18.0",
"xterm": "^5.3.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-runtime": "link:/Users/menghuan/.dsh/source/current/packages/client/runtime",
"@deepseek-ai/dsh-client-schema-form": "link:/Users/menghuan/.dsh/source/current/packages/client/schema-form",
"@deepseek-ai/dsh-client-ui-conversation": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-conversation",
"@deepseek-ai/dsh-client-ui-primitives": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-primitives",
"@deepseek-ai/dsh-client-ui-settings": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-settings",
"@deepseek-ai/dsh-client-ui-slots": "link:/Users/menghuan/.dsh/source/current/packages/client/ui-slots",
"@deepseek-ai/dsh-client-web-react": "link:/Users/menghuan/.dsh/source/current/packages/client/web-react",
"@deepseek-ai/dsh-host-webserver": "link:/Users/menghuan/.dsh/source/current/packages/host/webserver",
"@deepseek-ai/dsh-invariants": "link:/Users/menghuan/.dsh/source/current/packages/support/invariants",
"@deepseek-ai/dsh-session": "link:/Users/menghuan/.dsh/source/current/packages/core/session",
"@deepseek-ai/dsh-settings": "link:/Users/menghuan/.dsh/source/current/packages/settings/settings",
"@types/node": "^24.0.0",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.1",
"@types/ws": "^8.5.10",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"cordis": "^4.0.0-rc.7",
"lightningcss": "^1.32.0",
"react": "^18.2.0",
"react-dom": "18.2.0",
"tsdown": "^0.22.2",
"typescript": "^5.6.0",
"vitest": "^4.1.8"
}
}
+544 -2927
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -1,4 +1,8 @@
packages:
- .
# node-pty's install script copies its shipped prebuilt binaries into place
# (no source compilation on supported platforms); pnpm 10+ blocks dependency
# build scripts unless approved.
allowBuilds:
node-pty: true
minimumReleaseAgeExclude:
- dsh-better-sidebar@0.18.0
+49
View File
@@ -0,0 +1,49 @@
#!/usr/bin/env node
/**
* Assemble the plugin-registry installable root (`registry/`) from the built
* `lib/` output. Run after `pnpm build`:
*
* pnpm build
* node scripts/package-registry.mjs
* dsh registry install ./registry
* dsh registry enable dsh-external/dsh-better-sidebar
*
* The registry install copies the whole source directory (`cp -r`), so
* installing the repo root would drag in `node_modules/` (machine-local
* `link:` devDependency symlinks, node-pty binaries) and `.git/`. This
* staging directory carries exactly the files the manifest references plus
* the docs — same layout as the dsh-subagent-tree `registry/` pattern. It is
* gitignored and rebuilt from scratch on every run.
*/
import { cpSync, existsSync, mkdirSync, rmSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const root = resolve(fileURLToPath(import.meta.url), '..', '..')
const out = join(root, 'registry')
/** Files copied into registry/, preserving relative paths (lib/ stays lib/). */
const files = [
'dsh.plugin.json',
'lib/index.js',
'lib/invariant.js',
'lib/client-registry.js',
'lib/client-registry.js.map',
'README.md',
'LICENSE',
]
rmSync(out, { recursive: true, force: true })
mkdirSync(out, { recursive: true })
for (const file of files) {
const source = join(root, file)
if (!existsSync(source)) {
console.error(`missing ${file} — run \`pnpm build\` first`)
process.exit(1)
}
const target = join(out, file)
mkdirSync(dirname(target), { recursive: true })
cpSync(source, target)
}
console.log(`registry/ assembled (${files.length} files)`)
console.log('next: dsh registry install ./registry && dsh registry enable dsh-external/dsh-better-sidebar')
-80
View File
@@ -1,80 +0,0 @@
/**
* 验收验证:模拟 DSH host 的 resolveOverlayPackage + bundle 加载
* - 确认 apes-sidebar 在 profile node_modules 可解析
* - 确认 host entry (lib/index.js) 的命名导出 shape { name, inject, apply }
* - 确认 client entry (lib/client.js) 存在且是 __ModuleLoader__ bundle(browser artifact)
* - 确认 invariant surface 可用
*/
const path = require('path')
const fs = require('fs')
const { createRequire } = require('module')
const PROFILE = process.env.HOME + '/.dsh/profiles/desktop'
const req = createRequire(path.join(PROFILE, 'noop.js'))
let pass = 0, fail = 0
const check = (label, cond, detail = '') => {
if (cond) { pass++; console.log(' ✓', label) }
else { fail++; console.log(' ✗', label, detail) }
}
console.log('### apes-sidebar 挂载验收')
// 1. 包可解析
try {
const pkgPath = req.resolve('apes-sidebar/package.json')
const pkg = req('apes-sidebar/package.json')
check('package.json 可解析', true, pkgPath)
check('name=apes-sidebar', pkg.name === 'apes-sidebar')
check('version=2.0.0', pkg.version === '2.0.0')
} catch (e) {
check('package.json 可解析', false, e.message)
}
// 2. host entry 加载 + shape
try {
const host = req('apes-sidebar')
check('host entry 加载成功', true)
check('导出 name (string)', typeof host.name === 'string', 'got ' + typeof host.name)
check('导出 inject (string[])', Array.isArray(host.inject), 'got ' + typeof host.inject)
check('inject === [] (无硬依赖 service)', JSON.stringify(host.inject) === '[]', JSON.stringify(host.inject))
check('导出 apply (function)', typeof host.apply === 'function')
check('无 default export(namespace 规则)', !('default' in host))
console.log(' name =', JSON.stringify(host.name))
} catch (e) {
check('host entry 加载', false, e.message)
}
// 3. invariant surface
try {
const inv = req('apes-sidebar/invariant')
check('invariant 可加载', true)
check('invariant.assert 是函数', typeof inv.assert === 'function')
} catch (e) {
check('invariant 可加载', false, e.message)
}
// 4. client entry 存在 + 是 browser artifact
try {
const clientPath = req.resolve('apes-sidebar/client')
const clientSrc = fs.readFileSync(clientPath, 'utf8')
check('client entry 文件存在', true, clientPath)
check('client 是 __ModuleLoader__ bundle(line 1 window)',
clientSrc.split('\n')[0].includes('window.__ModuleLoader__.load'),
'line1=' + clientSrc.split('\n')[0].slice(0, 50))
} catch (e) {
check('client entry', false, e.message)
}
// 5. peer dep dsh-better-sidebar 版本满足 ^0.18.0
try {
const bs = req('dsh-better-sidebar/package.json')
const v = bs.version
check('dsh-better-sidebar 版本满足 ^0.18.0',
v === '0.18.0', 'got ' + v)
} catch (e) {
check('dsh-better-sidebar 版本', false, e.message)
}
console.log('\n=== 验收结果: ' + pass + ' passed, ' + fail + ' failed ===')
process.exit(fail > 0 ? 1 : 0)
+240
View File
@@ -0,0 +1,240 @@
/**
* The file editor tab: a CodeMirror 6 editor with line wrapping and syntax
* highlighting (extension-keyed language), a dirty dot and Ctrl/Cmd+S save,
* image viewing through the media route, and a plain notice for binary
* files. Markdown files open in rendered preview with a preview/edit toggle:
* edit mode shows the source in the editor, preview renders the live draft,
* and unsaved edits survive the toggle. Reads cap at the host's 512KB bound
* (a banner marks truncation).
*/
import { useEffect, useRef, useState } from 'react'
import clsx from 'clsx'
import { EditorState } from '@codemirror/state'
import { EditorView as CodeMirrorView, keymap, lineNumbers } from '@codemirror/view'
import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'
import { IconCheckOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import { api, mediaUrl, type SessionScope } from './api.ts'
import { languageForPath } from './lang.ts'
import { cmSurfaceTheme, CmThemeCompartment } from './cm-themes.ts'
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
const IMAGE_EXT = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico', '.avif']
const MD_EXT = ['.md', '.markdown']
type EditorLoad =
| { status: 'loading' }
| { status: 'error'; message: string }
| { status: 'ready'; kind: 'text' | 'image' | 'md'; content: string; truncated: boolean }
| { status: 'binary' }
/** Previewable files (rendered output vs source editing). */
type ViewMode = 'preview' | 'edit'
export function EditorView(props: { scope: SessionScope; path: string; title: string }) {
const { scope, path, title } = props
const [load, setLoad] = useState<EditorLoad>({ status: 'loading' })
const [mode, setMode] = useState<ViewMode>('preview')
/** The editor's current text (null while clean); preview renders this. */
const [draft, setDraft] = useState<string | null>(null)
const [dirty, setDirty] = useState(false)
const [saveState, setSaveState] = useState<'idle' | 'saving' | 'saved' | 'failed'>('idle')
const hostRef = useRef<HTMLDivElement>(null)
const viewRef = useRef<CodeMirrorView | null>(null)
const savingRef = useRef(false)
/** The theme compartment of the current view (reconfigured on scheme flip). */
const themeCompRef = useRef<CmThemeCompartment | null>(null)
/** The app's resolved color scheme; the editor re-themes in place on flips. */
const [dark, setDark] = useState(() => isDarkScheme())
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
useEffect(() => {
let cancelled = false
setLoad({ status: 'loading' })
setMode('preview')
setDraft(null)
setDirty(false)
setSaveState('idle')
api.fsRead(scope, path).then((result) => {
if (cancelled) return
const ext = extOfPath(path)
if (result.kind === 'binary') {
setLoad({ status: 'binary' })
return
}
if (IMAGE_EXT.includes(ext)) {
setLoad({ status: 'ready', kind: 'image', content: '', truncated: false })
return
}
setLoad({
status: 'ready',
kind: MD_EXT.includes(ext) ? 'md' : 'text',
content: result.content,
truncated: result.truncated,
})
}).catch((error: unknown) => {
if (cancelled) return
setLoad({ status: 'error', message: error instanceof Error ? error.message : String(error) })
})
return () => { cancelled = true }
}, [scope.sessionId, scope.cwd, path])
// Create the CodeMirror editor once the file is loaded. The view owns the
// document; React only tracks dirty/draft state through the update
// listener. For markdown the view stays mounted while previewing (hidden),
// so unsaved edits survive the preview/edit toggle. The theme + syntax
// colors live in a compartment so a scheme flip reconfigures only that
// part — the document, undo history and scroll position survive.
useEffect(() => {
if (load.status !== 'ready' || (load.kind !== 'text' && load.kind !== 'md')) return
const host = hostRef.current
if (host === null) return
const language = languageForPath(path)
const themeComp = new CmThemeCompartment()
themeCompRef.current = themeComp
const state = EditorState.create({
doc: load.content,
extensions: [
CodeMirrorView.lineWrapping,
lineNumbers(),
history(),
EditorState.tabSize.of(2),
CodeMirrorView.contentAttributes.of({ spellcheck: 'false' }),
cmSurfaceTheme,
themeComp.of(dark),
...(language !== null ? [language] : []),
CodeMirrorView.updateListener.of((update) => {
if (update.docChanged) {
setDraft(update.state.doc.toString())
setDirty(true)
}
}),
keymap.of([
{
key: 'Mod-s',
preventDefault: true,
run: () => { save(); return true },
},
...defaultKeymap,
...historyKeymap,
]),
],
})
const view = new CodeMirrorView({ state, parent: host })
viewRef.current = view
return () => {
view.destroy()
viewRef.current = null
themeCompRef.current = null
}
// The keymap's save() reads live refs; scope/path are stable for a
// tab's lifetime, and the dark flip is handled by the reconfigure
// effect below (recreating the view here would drop the draft).
}, [load])
// Scheme flip: re-theme in place (the compartment holds only the
// scheme-dependent extensions; everything else is untouched).
useEffect(() => {
const view = viewRef.current
const themeComp = themeCompRef.current
if (view === null || themeComp === null) return
view.dispatch({ effects: themeComp.reconfigure(dark) })
}, [dark])
// The editor may have been display:none while previewing; re-measure when
// it becomes visible again (CodeMirror sizes itself on reveal).
useEffect(() => {
if (mode === 'edit') viewRef.current?.requestMeasure()
}, [mode])
const save = (): void => {
const view = viewRef.current
if (view === null || savingRef.current) return
savingRef.current = true
setSaveState('saving')
api.fsWrite(scope, path, view.state.doc.toString()).then(() => {
savingRef.current = false
setDraft(null)
setDirty(false)
setSaveState('saved')
}).catch(() => {
savingRef.current = false
setSaveState('failed')
})
}
const previewable = load.status === 'ready' && load.kind === 'md'
const editable = load.status === 'ready' && (load.kind === 'text' || load.kind === 'md')
const saveLabel = saveState === 'saving' ? t('loading') : saveState === 'saved' ? t('saved') : saveState === 'failed' ? t('saveFailed') : ''
return (
<div className={css.editor}>
<div className={css.editorHeader}>
<span className={css.editorTitle} title={path}>{title}</span>
{previewable && (
<div className={css.editorModeToggle}>
<button
type="button"
className={clsx(css.editorModeButton, mode === 'preview' && css.editorModeActive)}
onClick={() => { setMode('preview') }}
>
{t('preview')}
</button>
<button
type="button"
className={clsx(css.editorModeButton, mode === 'edit' && css.editorModeActive)}
onClick={() => { setMode('edit') }}
>
{t('edit')}
</button>
</div>
)}
{dirty && <span className={css.dirtyDot} title={t('unsaved')} />}
{editable && (
<button
type="button"
className={css.iconButton}
aria-label={t('save')}
title={`${t('save')} (Ctrl/Cmd+S)`}
onClick={save}
>
<IconCheckOutline16 />
</button>
)}
{saveLabel !== '' && <span className={clsx(css.editorStatus, saveState === 'failed' && css.editorStatusError)}>{saveLabel}</span>}
</div>
{load.status === 'loading' && <div className={css.editorPlaceholder}>{t('loading')}</div>}
{load.status === 'error' && <div className={css.editorError}>{load.message}</div>}
{load.status === 'binary' && <div className={css.editorPlaceholder}>{t('binary')}</div>}
{editable && (
<>
{load.truncated && mode === 'edit' && <div className={css.editorBanner}>{t('truncation')}</div>}
<div
className={clsx(css.editorCm, previewable && mode === 'preview' && css.editorCmHidden)}
ref={hostRef}
/>
</>
)}
{load.status === 'ready' && load.kind === 'image' && (
<div className={css.editorImageWrap}>
<img className={css.editorImage} src={mediaUrl(scope, path)} alt={title} />
</div>
)}
{previewable && mode === 'preview' && (
<div className={css.editorMd}>
<MarkdownText text={draft ?? load.content} />
</div>
)}
</div>
)
}
/** The lowercased file extension of a path ('' when none). */
function extOfPath(path: string): string {
const at = path.lastIndexOf('.')
if (at === -1) return ''
const base = path.slice(at).toLowerCase()
return base.includes('/') || base.includes('\\') ? '' : base
}
+283
View File
@@ -0,0 +1,283 @@
/**
* The file explorer: a lazy VSCode-style tree rooted at the session's
* working directory. Levels load on expansion (one API call per directory),
* directories sort first, hidden entries render dimmed, and the expansion
* set lives in the per-session state. Clicking a file opens an editor tab.
*
* Row actions: hovering a row reveals an @-reference button on the far
* right (appends `@<relative path>` to the composer draft), and right-click
* opens a context menu to copy the relative or absolute path (with a brief
* "copied" label replacing the button after a successful write); file rows
* also offer a download action (the host serves raw bytes, binary-safe).
*/
import { useCallback, useEffect, useRef, useState, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import {
IconCodeOutline16, IconCopyOutline16, IconDownloadOutline16, IconFolderClose16, IconFolderOpen16,
IconRefreshOutline16, Menu, writeClipboard,
} from '@deepseek-ai/dsh-client-ui-primitives'
import { api, downloadUrl, type FsEntry } from './api.ts'
import { relativeTo } from './paths.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
interface LevelData {
entries?: FsEntry[]
error?: string
}
/** Root label: the last path segment (mirror of the host rootLabel). */
function baseName(path: string): string {
const trimmed = path.replace(/[\\/]+$/, '')
const at = Math.max(trimmed.lastIndexOf('/'), trimmed.lastIndexOf('\\'))
return at === -1 ? trimmed : trimmed.slice(at + 1)
}
/** How long the row's "copied" label stays after a successful write. */
const COPIED_MS = 1200
export function ExplorerView(props: {
sessionId: string
cwd: string | undefined
expanded: string[]
onToggle: (path: string) => void
onOpenFile: (path: string) => void
/** Insert `@<relative path>` into the composer draft. */
onReferenceFile: (path: string) => void
}) {
const { sessionId, cwd, expanded, onToggle, onOpenFile, onReferenceFile } = props
const [data, setData] = useState<Record<string, LevelData>>({})
const dataRef = useRef(data)
const [refreshTick, setRefreshTick] = useState(0)
/** The row whose path was just copied ("copied" label replaces its button). */
const [copiedPath, setCopiedPath] = useState<string | null>(null)
/** Open context menu: the row path (and whether it is a directory) plus the cursor position. */
const [rowMenu, setRowMenu] = useState<{ path: string; isDir: boolean; x: number; y: number } | null>(null)
const storeLevel = useCallback((path: string, level: LevelData) => {
dataRef.current = { ...dataRef.current, [path]: level }
setData(dataRef.current)
}, [])
const loadDir = useCallback((dir: string) => {
if (dataRef.current[dir] !== undefined) return
storeLevel(dir, {})
api.fsTree({ sessionId, cwd }, dir).then((listing) => {
storeLevel(dir, { entries: listing.entries })
}).catch((error: unknown) => {
storeLevel(dir, { error: error instanceof Error ? error.message : String(error) })
})
}, [sessionId, cwd, storeLevel])
useEffect(() => {
// Load the visible set; already-loaded levels (kept in the cache) are
// not refetched. Only the refresh button wipes the cache.
const root = cwd
if (root === undefined) return
loadDir(root)
for (const dir of expanded) loadDir(dir)
}, [cwd, expanded, refreshTick, loadDir])
/** Copy `text`; on success flip the row's copied label for a moment. */
const copyPath = useCallback((text: string, path: string): void => {
void writeClipboard(text).then((ok) => {
if (!ok) return
setCopiedPath(path)
window.setTimeout(() => {
setCopiedPath(current => current === path ? null : current)
}, COPIED_MS)
})
}, [])
/** The row's trailing actions: the @-reference button, or the copied label. */
const rowActions = (entry: FsEntry): ReactNode => {
if (copiedPath === entry.path) {
return <span className={css.explorerCopied}>{t('copied')}</span>
}
return (
<button
type="button"
className={css.explorerRef}
aria-label={t('referenceFile')}
title={t('referenceFile')}
onClick={(event) => {
event.stopPropagation()
onReferenceFile(entry.path)
}}
>
{t('referenceFile')}
</button>
)
}
const openRowMenu = (event: MouseEvent, path: string, isDir: boolean): void => {
event.preventDefault()
event.stopPropagation()
setRowMenu({ path, isDir, x: event.clientX, y: event.clientY })
}
/** Download a file through the host route (raw bytes, binary-safe). */
const downloadFile = (path: string): void => {
const url = downloadUrl({ sessionId, cwd }, path)
const anchor = document.createElement('a')
anchor.href = url
anchor.style.display = 'none'
document.body.appendChild(anchor)
anchor.click()
anchor.remove()
}
const root = cwd
const renderLevel = (dir: string, depth: number): ReactNode => {
const level = data[dir]
if (level === undefined) {
return <div className={css.explorerRow} style={{ paddingLeft: depth * 22 + 6 }}>{t('loading')}</div>
}
if (level.error !== undefined) {
return (
<div className={clsx(css.explorerRow, css.explorerError)} style={{ paddingLeft: depth * 22 + 6 }}>
{level.error}
</div>
)
}
const entries = level.entries ?? []
return entries.map(entry => {
if (entry.isDir) {
const isOpen = expanded.includes(entry.path)
return (
<div key={entry.path}>
<div
role="button"
tabIndex={0}
className={clsx(css.explorerRow, css.explorerDir, entry.hidden && css.explorerHidden)}
style={{ paddingLeft: depth * 22 + 6 }}
onClick={() => { onToggle(entry.path) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onToggle(entry.path)
}
}}
onContextMenu={(event) => { openRowMenu(event, entry.path, true) }}
>
{isOpen ? <IconFolderOpen16 size={14} /> : <IconFolderClose16 size={14} />}
<span className={css.explorerName}>{entry.name}</span>
{rowActions(entry)}
</div>
{isOpen && renderLevel(entry.path, depth + 1)}
</div>
)
}
return (
<div
key={entry.path}
role="button"
tabIndex={0}
className={clsx(css.explorerRow, entry.hidden && css.explorerHidden)}
style={{ paddingLeft: depth * 22 + 6 }}
title={entry.path}
onClick={() => { onOpenFile(entry.path) }}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
onOpenFile(entry.path)
}
}}
onContextMenu={(event) => { openRowMenu(event, entry.path, false) }}
>
<IconCodeOutline16 size={14} />
<span className={css.explorerName}>{entry.name}</span>
{rowActions(entry)}
</div>
)
})
}
return (
<div className={css.explorer}>
<div className={css.explorerHeader}>
<span className={css.explorerRoot} title={root}>{root === undefined ? t('noSession') : baseName(root)}</span>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={() => {
dataRef.current = {}
setData({})
setRefreshTick(tick => tick + 1)
}}
>
<IconRefreshOutline16 />
</button>
</div>
<div className={css.explorerBody}>
{root === undefined ? (
<div className={css.explorerEmpty}>{t('noSession')}</div>
) : (
<>
<div
className={css.explorerRow}
style={{ paddingLeft: 6 }}
onContextMenu={(event) => { openRowMenu(event, root, true) }}
>
<IconFolderOpen16 size={14} />
<span className={css.explorerName}>{baseName(root)}</span>
{copiedPath === root
? <span className={css.explorerCopied}>{t('copied')}</span>
: (
<button
type="button"
className={css.explorerRef}
aria-label={t('referenceFile')}
title={t('referenceFile')}
onClick={(event) => {
event.stopPropagation()
onReferenceFile(root)
}}
>
{t('referenceFile')}
</button>
)}
</div>
{data[root] !== undefined && renderLevel(root, 1)}
</>
)}
</div>
{/*
The one shared context menu, positioned at the right-click cursor
(portal so the explorer's overflow clip cannot crop it).
*/}
<Menu
open={rowMenu !== null}
onClose={() => { setRowMenu(null) }}
items={[
// Download applies to files only (the host route refuses directories).
...(rowMenu?.isDir === false
? [{ id: 'download', label: t('download'), icon: <IconDownloadOutline16 size={14} /> }]
: []),
{ id: 'relative', label: t('copyRelative'), icon: <IconCopyOutline16 size={14} /> },
{ id: 'absolute', label: t('copyAbsolute'), icon: <IconCopyOutline16 size={14} /> },
]}
onSelect={(id) => {
const target = rowMenu
if (target === null) return
setRowMenu(null)
if (id === 'download') {
downloadFile(target.path)
return
}
copyPath(
id === 'relative' ? relativeTo(cwd ?? '', target.path) : target.path,
target.path,
)
}}
portal
align="start"
getAnchorRect={() => (rowMenu === null ? null : new DOMRect(rowMenu.x, rowMenu.y, 0, 0))}
anchor={<span />}
/>
</div>
)
}
+271
View File
@@ -0,0 +1,271 @@
/**
* The source-control panel: status list (staged vs unstaged), per-file diff
* through the shared DiffBlock (old = HEAD/index content, new = worktree or
* index content), stage/unstage, commit with a message box, branch list with
* switch, and recent history. Refresh is manual + on mount/focus (no file
* watcher — KISS).
*/
import { useCallback, useEffect, useState, type ReactNode } from 'react'
import clsx from 'clsx'
import {
DiffBlock, IconBranchOutline16, IconRefreshOutline16, IconTrashOutline16,
Input,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { GitLogEntry, GitStatusEntry, GitStatusResult, SessionScope } from './api.ts'
import { api } from './api.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** The XY status letters a row badge shows (X = index, Y = worktree). */
function badgeOf(entry: GitStatusEntry): string {
const index = entry.xy[0]
const worktree = entry.xy[1]
if (index !== undefined && index !== ' ' && index !== '?') return index
if (worktree !== undefined && worktree !== ' ' && worktree !== '?') return worktree
return '?'
}
export function GitView(props: { scope: SessionScope }) {
const { scope } = props
const [status, setStatus] = useState<GitStatusResult | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [branchNames, setBranchNames] = useState<string[]>([])
const [logEntries, setLogEntries] = useState<GitLogEntry[]>([])
const [commitMsg, setCommitMsg] = useState('')
const [busy, setBusy] = useState(false)
const [commitError, setCommitError] = useState<string | null>(null)
// Selected diff: path + side (staged) + the two text sides for DiffBlock.
const [diffPath, setDiffPath] = useState<string | null>(null)
const [diffStaged, setDiffStaged] = useState(false)
const [diffOld, setDiffOld] = useState<string | null>(null)
const [diffNew, setDiffNew] = useState('')
const refresh = useCallback(async (): Promise<void> => {
setLoading(true)
setError(null)
try {
const [statusResult, branchResult, logResult] = await Promise.all([
api.gitStatus(scope),
api.gitBranch(scope).catch(() => ({ current: '', names: [] as string[] })),
api.gitLog(scope).catch(() => [] as GitLogEntry[]),
])
setStatus(statusResult)
setBranchNames(branchResult.names)
setLogEntries(logResult)
} catch (reason) {
setError(reason instanceof Error ? reason.message : String(reason))
} finally {
setLoading(false)
}
}, [scope.sessionId, scope.cwd])
useEffect(() => { void refresh() }, [refresh])
/** Load the diff sides for one entry: HEAD content vs worktree/index content. */
const selectDiff = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
setDiffPath(entry.path)
setDiffStaged(staged)
setDiffOld(null)
setDiffNew('')
const oldPromise = api.gitShow(scope, 'HEAD', entry.path)
.then(result => result.content)
.catch(() => null)
const newPromise = staged
? api.gitShow(scope, ':', entry.path).then(result => result.content ?? '').catch(() => '')
: api.fsRead(scope, entry.path)
.then(result => (result.kind === 'text' ? result.content : ''))
.catch(() => '')
const [oldText, newText] = await Promise.all([oldPromise, newPromise])
setDiffOld(oldText)
setDiffNew(newText)
}
const stageEntry = async (entry: GitStatusEntry, staged: boolean): Promise<void> => {
setBusy(true)
try {
if (staged) await api.gitUnstage(scope, entry.path)
else await api.gitStage(scope, entry.path)
setDiffPath(null)
await refresh()
} finally {
setBusy(false)
}
}
const stageAll = async (staged: boolean): Promise<void> => {
setBusy(true)
try {
if (staged) await api.gitUnstage(scope)
else await api.gitStage(scope)
setDiffPath(null)
await refresh()
} finally {
setBusy(false)
}
}
const commit = async (): Promise<void> => {
const message = commitMsg.trim()
if (message === '' || busy) return
setBusy(true)
setCommitError(null)
try {
await api.gitCommit(scope, message)
setCommitMsg('')
setDiffPath(null)
await refresh()
} catch (reason) {
setCommitError(reason instanceof Error ? reason.message : String(reason))
} finally {
setBusy(false)
}
}
const checkout = async (branch: string): Promise<void> => {
if (branch === status?.branch || busy) return
setBusy(true)
setCommitError(null)
try {
await api.gitCheckout(scope, branch)
setDiffPath(null)
await refresh()
} catch (reason) {
setCommitError(`${t('checkoutError')}: ${reason instanceof Error ? reason.message : String(reason)}`)
} finally {
setBusy(false)
}
}
const stagedEntries = (status?.entries ?? []).filter(entry => badgeOf(entry) !== '?')
const unstagedEntries = (status?.entries ?? []).filter(entry => badgeOf(entry) === '?')
const renderEntry = (entry: GitStatusEntry, staged: boolean): ReactNode => {
const selected = diffPath === entry.path && diffStaged === staged
return (
<div key={`${staged ? 's' : 'u'}:${entry.path}`} className={clsx(css.gitRow, selected && css.gitRowSelected)}>
<button
type="button"
className={css.gitRowMain}
title={entry.path}
onClick={() => { void selectDiff(entry, staged) }}
>
<span className={css.gitBadge}>{badgeOf(entry)}</span>
<span className={css.gitName}>{entry.path}</span>
</button>
<button
type="button"
className={css.iconButton}
aria-label={staged ? t('unstage') : t('stage')}
title={staged ? t('unstage') : t('stage')}
disabled={busy}
onClick={() => { void stageEntry(entry, staged) }}
>
{staged ? <IconTrashOutline16 /> : <IconBranchOutline16 />}
</button>
</div>
)
}
return (
<div className={css.git}>
<div className={css.gitHeader}>
<select
className={css.gitBranchSelect}
value={status?.branch ?? ''}
onChange={(event) => { void checkout(event.target.value) }}
disabled={busy || (status !== null && !status.isRepo)}
>
{(status?.branch ?? '') !== '' && <option value={status!.branch}>{status!.branch}</option>}
{branchNames.filter(name => name !== status?.branch).map(name => <option key={name} value={name}>{name}</option>)}
</select>
<button
type="button"
className={css.iconButton}
aria-label={t('refresh')}
title={t('refresh')}
onClick={() => { void refresh() }}
>
<IconRefreshOutline16 />
</button>
</div>
{loading && <div className={css.gitPlaceholder}>{t('loading')}</div>}
{!loading && error !== null && <div className={css.gitError}>{error}</div>}
{!loading && status !== null && !status.isRepo && (
<div className={css.gitPlaceholder}>{t('notRepo')}</div>
)}
{status !== null && status.isRepo && (
<>
<div className={css.gitSection}>
<div className={css.gitSectionHeader}>
<span>{t('staged')} ({stagedEntries.length})</span>
{stagedEntries.length > 0 && (
<button type="button" className={css.gitLink} disabled={busy} onClick={() => { void stageAll(true) }}>
{t('unstageAll')}
</button>
)}
</div>
{stagedEntries.length === 0 && <div className={css.gitEmpty}>{t('noChanges')}</div>}
{stagedEntries.map(entry => renderEntry(entry, true))}
</div>
<div className={css.gitSection}>
<div className={css.gitSectionHeader}>
<span>{t('unstaged')} ({unstagedEntries.length})</span>
{unstagedEntries.length > 0 && (
<button type="button" className={css.gitLink} disabled={busy} onClick={() => { void stageAll(false) }}>
{t('stageAll')}
</button>
)}
</div>
{unstagedEntries.length === 0 && <div className={css.gitEmpty}>{t('noChanges')}</div>}
{unstagedEntries.map(entry => renderEntry(entry, false))}
</div>
{diffPath !== null && (
<div className={css.gitDiff}>
<DiffBlock
diffs={[{ path: diffPath, oldText: diffOld, newText: diffNew }]}
maxLines={300}
/>
</div>
)}
<div className={css.gitCommit}>
<Input
className={css.gitCommitInput}
placeholder={t('commitPlaceholder')}
value={commitMsg}
disabled={busy}
onChange={(event) => { setCommitMsg(event.target.value); setCommitError(null) }}
onKeyDown={(event) => {
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') void commit()
}}
/>
<button
type="button"
className={css.gitCommitButton}
disabled={busy || commitMsg.trim() === '' || stagedEntries.length === 0}
onClick={() => { void commit() }}
>
{t('commit')}
</button>
</div>
{commitError !== null && <div className={css.gitError}>{commitError}</div>}
<div className={css.gitSection}>
<div className={css.gitSectionHeader}><span>{t('history')}</span></div>
{logEntries.map(entry => (
<div key={entry.hash} className={css.gitLogRow} title={`${entry.author} · ${entry.date}`}>
<span className={css.gitLogHash}>{entry.hash}</span>
<span className={css.gitLogSubject}>{entry.subject}</span>
</div>
))}
</div>
</>
)}
</div>
)
}
+90
View File
@@ -0,0 +1,90 @@
/**
* Side card settings section chrome: the native settings-row recipe (figma
* row 16/0 padding, hairline separator, 14px/22px title, 12px/17px desc) with
* the native checkbox (16px, brand accent) and the Input atom + "%" suffix.
* The shell strips the last row's separator via the trailing-child rule.
*/
.section {
display: flex;
flex-direction: column;
width: 100%;
}
.section > :last-child {
border-bottom: none;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 0;
border-bottom: 1px solid var(--dsw-alias-border-l2);
cursor: pointer;
}
.rowText {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.title {
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-primary);
}
.desc {
font-size: 12px;
line-height: 17px;
color: var(--dsw-alias-label-secondary);
}
/* Native checkbox: 16px, brand accent, no custom chrome (RiskConfirmation recipe). */
.toggle {
flex: none;
width: 16px;
height: 16px;
margin: 0;
accent-color: var(--dsw-alias-button-primary-fill);
cursor: pointer;
}
.toggle:focus-visible {
outline: 2px solid var(--dsw-alias-border-l4);
outline-offset: 2px;
}
.control {
flex: none;
display: flex;
align-items: center;
gap: 6px;
}
.percentInput {
width: 76px;
}
.suffix {
font-size: 14px;
line-height: 22px;
color: var(--dsw-alias-label-secondary);
}
.error {
padding: 10px 0 2px;
font-size: 12px;
line-height: 17px;
color: var(--dsw-alias-state-error-primary);
}
@media (prefers-reduced-motion: reduce) {
.row {
transition: none;
}
}
+181
View File
@@ -0,0 +1,181 @@
/**
* "Side card" settings section: the user-facing preferences for the sidebar
* panel, rendered natively in the DSH Settings shell (nav label "Side card").
* Two rows:
* - new conversations open the panel by default (native checkbox), and
* - the default panel width as a percent of the window (number input + %).
*
* Writes ride the plugin's own fenced settings route (the host calls the
* settings seam in-process — the DSH settings RPC domain does not serve
* third-party namespaces to configuration clients); the shared SidebarStore
* is refreshed on success so the very next brand-new session seeds from the
* new values. Any failure reverts the optimistic UI and shows the wire error
* inline — a broken settings surface never crashes the shell.
*/
import { useEffect, useRef, useState } from 'react'
import { Input } from '@deepseek-ai/dsh-client-ui-primitives'
// Type-only: pulls the settings shell's SlotMap merges ('settings.section').
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
import type { PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import {
clampWidthPercent,
WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN,
type SidebarPrefs,
} from '../prefs-shared.ts'
import { api } from './api.ts'
import { parsePrefs } from './prefs.ts'
import { t } from './locales.ts'
import type { SidebarStore } from './state.ts'
import css from './SideCardSection.module.css'
/** Injected business face: the shared store (prefs cache). */
export interface SideCardSectionInjected {
store: SidebarStore
}
/** Full section props: the runtime share plus the injected face. */
export type SideCardSectionProps = PropsRuntime<'settings.section'> & SideCardSectionInjected
/** Map one wire failure to the inline message (the conflict gets friendly copy). */
function messageOf(error: unknown): string {
if (error instanceof Error && 'code' in error && (error as { code?: unknown }).code === 'settings-conflict') {
return `${t('settingsSaveFailed')} ${t('settingsConflict')}`
}
return `${t('settingsSaveFailed')} ${error instanceof Error ? error.message : String(error)}`
}
/**
* Render the Side card preferences section.
* @param props - composed slot props (runtime share + injected store).
* @returns the section element tree.
*/
export function SideCardSection({ store }: SideCardSectionProps) {
const [prefs, setPrefs] = useState<SidebarPrefs>(() => store.getPrefs())
const [widthDraft, setWidthDraft] = useState<string>(String(store.getPrefs().defaultWidthPercent))
const [error, setError] = useState<string | null>(null)
// The settings document revision (guards concurrent writes). A ref: commits
// read the freshest value at execution time, no re-render needed.
const revisionRef = useRef<number | undefined>(undefined)
// Whether the user already wrote since mount: the mount read must not
// clobber a newer optimistic edit (the window is milliseconds, but a slow
// route must never silently revert a just-made change).
const dirtyRef = useRef(false)
// Serialize commits: a queued write must observe the previous write's
// revision; a failed write must not poison the queue for later ones.
const inFlightRef = useRef<Promise<unknown>>(Promise.resolve())
// Sync the persisted document once on mount: the revision and the current
// values (another tab may have changed them since the store hydrated).
useEffect(() => {
let cancelled = false
void api.settingsGet().then((view) => {
if (cancelled) return
revisionRef.current = view.revision
if (dirtyRef.current) return
const next = parsePrefs(view.value)
setPrefs(next)
setWidthDraft(String(next.defaultWidthPercent))
}).catch(() => { /* the store's defaults stay authoritative */ })
return () => { cancelled = true }
}, [])
/** Persist one patch through the settings route (serialized, revision-guarded). */
const commit = (patch: Partial<SidebarPrefs>): Promise<{ ok: boolean; prefs: SidebarPrefs }> => {
dirtyRef.current = true
const run = inFlightRef.current.then(async () => {
const view = await api.settingsUpdate(
{ ...patch },
revisionRef.current,
)
const next = parsePrefs(view.value)
revisionRef.current = view.revision
store.setPrefs(next)
return next
})
// A failed commit must not poison the queue: later writes still run.
inFlightRef.current = run.then(() => undefined, () => undefined)
return run.then(
(next) => ({ ok: true, prefs: next }),
(caught) => {
setError(messageOf(caught))
return { ok: false, prefs }
},
)
}
/** Settle one commit: success adopts the server values, failure reverts. */
const applyOutcome = (previous: SidebarPrefs, outcome: { ok: boolean; prefs: SidebarPrefs }): void => {
const settled = outcome.ok ? outcome.prefs : previous
setPrefs(settled)
setWidthDraft(String(settled.defaultWidthPercent))
}
const onToggle = (next: boolean): void => {
const previous = prefs
setPrefs({ ...previous, openByDefault: next })
setError(null)
void commit({ openByDefault: next }).then(outcome => applyOutcome(previous, outcome))
}
const commitWidth = (): void => {
const parsed = Number(widthDraft)
if (!Number.isFinite(parsed)) {
setWidthDraft(String(prefs.defaultWidthPercent))
return
}
const clamped = clampWidthPercent(parsed)
const previous = prefs
setPrefs({ ...previous, defaultWidthPercent: clamped })
setWidthDraft(String(clamped))
setError(null)
void commit({ defaultWidthPercent: clamped }).then(outcome => applyOutcome(previous, outcome))
}
return (
<div className={css.section}>
<label className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsOpenTitle')}</span>
<span className={css.desc}>{t('settingsOpenDesc')}</span>
</span>
<input
type="checkbox"
className={css.toggle}
checked={prefs.openByDefault}
aria-label={t('settingsOpenTitle')}
onChange={event => { onToggle(event.currentTarget.checked) }}
/>
</label>
<div className={css.row}>
<span className={css.rowText}>
<span className={css.title}>{t('settingsWidthTitle')}</span>
<span className={css.desc}>{t('settingsWidthDesc')}</span>
</span>
<span className={css.control}>
<Input
type="number"
className={css.percentInput}
value={widthDraft}
min={WIDTH_PERCENT_MIN}
max={WIDTH_PERCENT_MAX}
step={1}
aria-label={t('settingsWidthTitle')}
onChange={event => { setWidthDraft(event.currentTarget.value) }}
onBlur={commitWidth}
onKeyDown={event => {
if (event.key === 'Enter') event.currentTarget.blur()
}}
/>
<span className={css.suffix}>{t('settingsWidthSuffix')}</span>
</span>
</div>
{error !== null && (
<div className={css.error} role="alert">
{error}
</div>
)}
</div>
)
}
+352
View File
@@ -0,0 +1,352 @@
/**
* The sidebar shell: a fixed-position right panel portalled onto
* document.body (the core AppFrame owns the left sidebar / center / details
* columns and has no right-side hole for plugins), plus the edge toggle
* button. The panel width drags from its left edge; the whole layout lives
* in the per-session store, so switching conversations swaps the sidebar.
*
* The shell binds the workbench actions to the store and dispatches tab
* content to the four views. New tabs come from the + menu (explorer / git /
* terminal; editors open from the explorer).
*/
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useSyncExternalStore } from 'react'
import clsx from 'clsx'
import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutline16, Tooltip } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context, SidebarConversation } from '../context-types.ts'
import {
allLeaves, closeTab, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openTab,
resizeSplit, setWidth, toggleExpanded, togglePanel,
TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
} from './state.ts'
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
import type { NewTabOption } from './TabBar.tsx'
import type { TabDragPayload } from './TabBar.tsx'
import { tabTypeIcon } from './icons.tsx'
import { relativeTo } from './paths.ts'
import { ExplorerView } from './ExplorerView.tsx'
import { EditorView } from './EditorView.tsx'
import { TerminalView } from './TerminalView.tsx'
import { GitView } from './GitView.tsx'
import { t } from './locales.ts'
import { api } from './api.ts'
import { openSidebarFile } from './intercept.tsx'
import css from './sidebar.module.css'
/** Render the content of one tab (dispatched by type). */
function TabContent(props: {
tab: SidebarTab
sessionId: string
cwd: string | undefined
expanded: string[]
onToggleDir: (path: string) => void
onReferenceFile: (path: string) => void
ctx: Context
store: SidebarStore
}) {
const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store } = props
const scope = { sessionId, cwd }
switch (tab.type) {
case 'explorer':
return (
<ExplorerView
sessionId={sessionId}
cwd={cwd}
expanded={expanded}
onToggle={onToggleDir}
onOpenFile={(path) => { openSidebarFile(ctx, store, sessionId, path) }}
onReferenceFile={onReferenceFile}
/>
)
case 'git':
return <GitView scope={scope} />
case 'terminal':
return <TerminalView scope={scope} tabId={tab.id} store={store} />
case 'editor':
return <EditorView scope={scope} path={tab.path ?? ''} title={tab.title} />
}
}
/** The + menu options for the current state (terminal cap applied). */
function buildNewTabOptions(state: SidebarState): NewTabOption[] {
const terminalCount = allLeaves(state.splits)
.flatMap(leaf => leaf.tabs)
.filter(tab => tab.type === 'terminal').length
return [
{ id: 'explorer', label: t('openExplorer'), icon: tabTypeIcon('explorer', 16) },
{ id: 'git', label: t('openGit'), icon: tabTypeIcon('git', 16) },
{
id: 'terminal',
label: terminalCount >= TERMINAL_LIMIT ? `${t('newTerminal')} (${t('terminalLimit')})` : t('newTerminal'),
disabled: terminalCount >= TERMINAL_LIMIT,
icon: tabTypeIcon('terminal', 16),
},
]
}
export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
const { ctx, store } = props
// Current conversation (the sessions list feed).
const sessionList = useSyncExternalStore(
useMemo(() => (callback: () => void) => ctx.sessions.list.subscribe(callback), [ctx]),
useCallback(() => ctx.sessions.list.getSnapshot(), [ctx]),
)
const current = sessionList.current
// Per-session sidebar state.
const snapshot = useSyncExternalStore(
useCallback((callback: () => void) => store.subscribe(callback), [store]),
useCallback(() => store.getSnapshot(), [store]),
)
useEffect(() => { store.setSession(current) }, [current, store])
const state = snapshot.state
const sessionId = snapshot.sessionId
const summaryCwd = sessionId === undefined ? undefined : sessionList.byId[sessionId]?.cwd
// While the session's header is still hydrating (or the session is blank),
// the list summary may carry no cwd; ask the host once (it falls back to
// the process cwd) so the explorer root and terminal cwd are real from
// first paint instead of showing "no session".
const [fetchedCwd, setFetchedCwd] = useState<string | undefined>(undefined)
useEffect(() => {
setFetchedCwd(undefined)
if (sessionId === undefined || summaryCwd !== undefined) return
let cancelled = false
api.sessionCwd({ sessionId })
.then(result => { if (!cancelled) setFetchedCwd(result.cwd) })
.catch(() => { /* the explorer/git rows surface their own errors */ })
return () => { cancelled = true }
}, [sessionId, summaryCwd])
const cwd = summaryCwd ?? fetchedCwd
// Panel width drag (left edge strip).
const widthDrag = useRef({ startX: 0, startWidth: 0 })
const [draggingWidth, setDraggingWidth] = useState(false)
// Layout push: the app shell gives up the panel's width while it is open
// (0 while collapsed), so the conversation and input bar are squeezed
// instead of covered. The margin is capped at the viewport so a stale
// persisted width (e.g. fullscreen on a bigger window) can never crush
// the app shell to zero. Dragging disables the layout transition.
useEffect(() => {
const width = snapshot.state?.panelOpen === true
? Math.min(snapshot.state.width, window.innerWidth)
: 0
document.documentElement.style.setProperty('--dsh-sidebar-width', `${width}px`)
}, [snapshot.state?.panelOpen, snapshot.state?.width])
useEffect(() => {
if (draggingWidth) document.body.setAttribute('data-dsh-sidebar-dragging', '')
else document.body.removeAttribute('data-dsh-sidebar-dragging')
}, [draggingWidth])
// Whether the panel currently fills the viewport (fullscreen expansion).
const fullscreen = state !== undefined && window.innerWidth - state.width < 8
const actions: WorkbenchActions = useMemo(() => ({
closeTab: (paneId, tabId) => {
// A closed terminal releases its pty immediately — including when its
// socket is mid-reconnect, where the unmount close frame never reaches
// the host and the process would hold the quota until the grace ends.
const current = store.getSnapshot().state
const leaf = current === undefined ? undefined : leafWithTab(current.splits, tabId)
const tab = leaf?.tabs.find(candidate => candidate.id === tabId)
store.reduce(s => closeTab(s, paneId, tabId))
if (sessionId !== undefined && tab?.type === 'terminal') {
void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
}
},
activateTab: (paneId, tabId) => {
store.reduce(s => ({
...s,
activePane: paneId,
splits: mapLeaf(s.splits, paneId, (leaf) => {
if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId
}),
}))
},
focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) },
moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => {
store.reduce(s => moveTabToEdge(s, payload.paneId, payload.tabId, toPane, zone))
},
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => {
store.reduce((s) => {
let index = -1
const source = leafWithTab(s.splits, beforeTabId)
if (source !== undefined && source.id === toPane) {
index = source.tabs.findIndex(tab => tab.id === beforeTabId)
}
return moveTab(s, payload.paneId, payload.tabId, toPane, index)
})
},
resizeSplit: (splitId, index, deltaFrac) => {
store.reduce(s => ({ ...s, splits: resizeSplit(s.splits, splitId, index, deltaFrac) }))
},
}), [store, sessionId, cwd])
/**
* The explorer's @-reference button: append `@<relative path>` to the
* session's composer draft (space-separated). The conversation service is
* resolved lazily through `ctx.get` (the inject-free read — the app's own
* plugins read 'conversation' the same way); a missing service or scope
* degrades to a logged no-op, never a crash. Defined above the no-session
* early return — a hook must never sit behind a conditional return
* (React counts hooks per render).
*/
const referenceInChat = useCallback((path: string): void => {
if (sessionId === undefined) return
try {
const actx = ctx.sessions.scope(sessionId)
if (actx === undefined) return
const conversation = ctx.get('conversation') as SidebarConversation | undefined
if (conversation === undefined) return
const input = conversation.input.for(actx)
const mention = `@${relativeTo(cwd ?? '', path)}`
const draft = input.state.getSnapshot().draft
input.setDraft(draft.trim() === '' ? mention : `${draft} ${mention}`)
} catch (error) {
console.warn('[dsh-better-sidebar] reference insert failed:', error)
}
}, [ctx, sessionId, cwd])
if (state === undefined || sessionId === undefined) {
return (
<div className={css.toggleRail}>
<Tooltip label={t('noSession')} side="bottom" delayMs={500}>
<button type="button" className={css.toggleButton} disabled aria-label={t('noSession')}>
<IconPanelLeftOutline16 size={16} />
</button>
</Tooltip>
</div>
)
}
const onNewTab = (optionId: string): void => {
store.reduce((s) => {
if (optionId === 'explorer') {
return openTab(s, { id: 'explorer', type: 'explorer', title: t('explorer') })
}
if (optionId === 'git') {
return openTab(s, { id: 'git', type: 'git', title: t('git') })
}
if (optionId === 'terminal') {
const count = allLeaves(s.splits).flatMap(leaf => leaf.tabs).filter(tab => tab.type === 'terminal').length
if (count >= TERMINAL_LIMIT) return s
const tab: SidebarTab = {
id: `terminal:${s.nextTerminal}`,
type: 'terminal',
title: `${t('terminal')} ${s.nextTerminal}`,
}
return { ...openTab(s, tab), nextTerminal: s.nextTerminal + 1 }
}
return s
})
}
/**
* The explorer's @-reference button: append `@<relative path>` to the
* session's composer draft (space-separated). Resolves the session-scope
* ctx and the conversation input service at click time; a missing service
* or scope degrades to a logged no-op, never a crash.
*/
const renderTab = (tab: SidebarTab) => (
<TabContent
tab={tab}
sessionId={sessionId}
cwd={cwd}
expanded={state.expanded}
onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
onReferenceFile={referenceInChat}
ctx={ctx}
store={store}
/>
)
return (
<>
{!state.panelOpen && (
<div className={css.toggleRail}>
<Tooltip label={t('expand')} side="bottom" delayMs={500}>
<button
type="button"
className={css.toggleButton}
aria-label={t('expand')}
onClick={() => { store.reduce(togglePanel) }}
>
<IconPanelLeftOutline16 size={16} />
</button>
</Tooltip>
</div>
)}
{/*
The panel stays mounted while collapsed (hidden off-screen) so the
slide in/out can animate; visibility hides it after the slide settles.
*/}
<div
className={clsx(css.panel, !state.panelOpen && css.panelHidden)}
style={{ width: Math.min(state.width, window.innerWidth) }}
data-dragging={draggingWidth || undefined}
>
<div
className={clsx(css.panelResize, draggingWidth && css.panelResizeActive)}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
widthDrag.current = { startX: event.clientX, startWidth: state.width }
setDraggingWidth(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const { startX, startWidth } = widthDrag.current
store.reduce(s => setWidth(s, startWidth + (startX - event.clientX)))
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
setDraggingWidth(false)
}}
/>
<div className={css.panelHeader}>
<button
type="button"
className={css.iconButton}
aria-label={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
title={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
onClick={() => {
const viewport = window.innerWidth
store.reduce(s => setWidth(s, fullscreen
? Math.max(280, Math.round((viewport - 320) / 2))
: viewport))
}}
>
<IconFullscreenOutline16 />
</button>
<span className={css.panelTitle}>
{t('explorer')}
{cwd !== undefined ? ` · ${cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd}` : ''}
</span>
<button
type="button"
className={css.iconButton}
aria-label={t('collapse')}
title={t('collapse')}
onClick={() => { store.reduce(togglePanel) }}
>
<IconChevronRightOutline14 />
</button>
</div>
<div className={css.panelBody}>
<Workbench
state={state}
newTabOptions={buildNewTabOptions(state)}
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
/>
</div>
</div>
</>
)
}
+165
View File
@@ -0,0 +1,165 @@
/**
* The tab strip of one pane: tabs capped at TAB_MAX_WIDTH (ellipsized),
* overflow scrolls horizontally, a close button per tab, a four-way split
* button cluster, and the + menu that opens new tabs (explorer / git /
* terminal). Tabs are draggable; dropping onto another tab inserts before it,
* dropping on the strip background appends to this pane.
*/
import { useState, type ReactNode } from 'react'
import clsx from 'clsx'
import {
IconCloseFill14, IconPlusOutline16, Menu,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { SidebarTab } from './state.ts'
import { tabTypeIcon } from './icons.tsx'
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** One + menu option. */
export interface NewTabOption {
id: string
label: string
disabled?: boolean
/** Leading icon (Menu row). */
icon?: ReactNode
}
/** Drag payload for tab moves (HTML5 DnD dataTransfer). */
export const TAB_DRAG_TYPE = 'application/x-dsh-tab'
export interface TabDragPayload {
tabId: string
paneId: string
}
export function serializeDrag(payload: TabDragPayload): string {
return JSON.stringify(payload)
}
export function parseDrag(raw: string): TabDragPayload | null {
try {
const parsed = JSON.parse(raw) as TabDragPayload
if (typeof parsed.tabId === 'string' && typeof parsed.paneId === 'string') return parsed
return null
} catch {
return null
}
}
export function TabBar(props: {
paneId: string
tabs: SidebarTab[]
active: string | null
onActivate: (tabId: string) => void
onClose: (tabId: string) => void
onNewTab: (optionId: string) => void
newTabOptions: NewTabOption[]
/** Drop of a tab from any pane: (payload, insertBeforeTabId | null). */
onDropTab: (payload: TabDragPayload, before: string | null) => void
}) {
const {
paneId, tabs, active, onActivate, onClose, onNewTab, newTabOptions, onDropTab,
} = props
const [menuOpen, setMenuOpen] = useState(false)
const [dragOver, setDragOver] = useState(false)
return (
<div
className={clsx(css.tabBar, dragOver && css.tabBarDrop)}
onDragOver={(event) => {
// The strip owns drops on itself (merge into this pane); stopping
// propagation keeps the pane root from also running its edge-zone
// handler on the same drop.
event.preventDefault()
event.stopPropagation()
setDragOver(true)
}}
onDragLeave={() => { setDragOver(false) }}
onDrop={(event) => {
event.preventDefault()
event.stopPropagation()
setDragOver(false)
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
const payload = parseDrag(raw)
if (payload !== null) onDropTab(payload, null)
}}
>
<div className={css.tabList}>
{tabs.map(tab => (
<div
key={tab.id}
className={clsx(css.tab, active === tab.id && css.tabActive)}
title={tab.title}
draggable
onDragStart={(event) => {
event.dataTransfer.setData(TAB_DRAG_TYPE, serializeDrag({ tabId: tab.id, paneId }))
event.dataTransfer.effectAllowed = 'move'
}}
onDragOver={(event) => { event.preventDefault(); event.stopPropagation() }}
onDrop={(event) => {
event.preventDefault()
event.stopPropagation()
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
const payload = parseDrag(raw)
if (payload !== null) onDropTab(payload, tab.id)
}}
onClick={() => { onActivate(tab.id) }}
onAuxClick={(event) => {
// Middle-click closes the tab (and suppresses autoscroll).
if (event.button === 1) {
event.preventDefault()
onClose(tab.id)
}
}}
>
{tabTypeIcon(tab.type, 14)}
<span className={css.tabTitle}>{tab.title}</span>
<button
type="button"
className={css.tabClose}
aria-label={t('close')}
onClick={(event) => {
event.stopPropagation()
onClose(tab.id)
}}
>
<IconCloseFill14 />
</button>
</div>
))}
{/*
The + sits immediately after the rightmost tab (sticky at the
right edge of the scrollport when the tabs overflow, so it stays
reachable no matter how many tabs are open).
*/}
<Menu
open={menuOpen}
onClose={() => { setMenuOpen(false) }}
items={newTabOptions.map(option => ({
id: option.id,
label: option.label,
...(option.disabled === true ? { disabled: true } : {}),
...(option.icon !== undefined ? { icon: option.icon } : {}),
}))}
onSelect={(id) => {
onNewTab(id)
setMenuOpen(false)
}}
portal
align="end"
anchor={(
<button
type="button"
className={css.tabBarPlus}
aria-label={t('newTab')}
title={t('newTab')}
onClick={() => { setMenuOpen(v => !v) }}
>
<IconPlusOutline16 />
</button>
)}
/>
</div>
</div>
)
}
+212
View File
@@ -0,0 +1,212 @@
/**
* The interactive terminal: xterm.js over a WebSocket to the host pty.
* The host replays the session's transcript on connect, then streams live
* output; input frames are raw text, resize frames are JSON with
* type:"resize". Transient disconnects (page refresh, host restart) reconnect
* automatically; a server-side refusal (close code 1011 with a reason, e.g.
* a failed pty spawn) stops the loop and shows the reason with a manual
* retry, and repeated unreasoned failures surface the close code after three
* attempts, so the banner never spins forever.
*/
import { useEffect, useRef, useState } from 'react'
import { Terminal, type ITheme } from 'xterm'
import { FitAddon } from '@xterm/addon-fit'
import 'xterm/css/xterm.css'
import { t } from './locales.ts'
import type { SessionScope } from './api.ts'
import type { SidebarStore } from './state.ts'
import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts'
import css from './sidebar.module.css'
/** How many consecutive unreasoned failures before showing the error banner. */
const FAILURE_LIMIT = 3
/**
* Curated ANSI palettes for the terminal. The surface colors (background,
* foreground, cursor, selection) ride the theme tokens so the terminal
* blends with the panel in both schemes; the 16 ANSI colors are the same
* designed palettes the app's code surfaces use (one-dark family for dark,
* one-light family for light), read live so a scheme flip re-themes in
* place.
*/
const ANSI_DARK: Record<string, string> = {
black: '#282c34', red: '#e06c75', green: '#98c379', yellow: '#e5c07b',
blue: '#61afef', magenta: '#c678dd', cyan: '#56b6c2', white: '#abb2bf',
brightBlack: '#5c6370', brightRed: '#e06c75', brightGreen: '#98c379',
brightYellow: '#e5c07b', brightBlue: '#61afef', brightMagenta: '#c678dd',
brightCyan: '#56b6c2', brightWhite: '#ffffff',
}
const ANSI_LIGHT: Record<string, string> = {
black: '#383a42', red: '#e45649', green: '#50a14f', yellow: '#c18401',
blue: '#0184bc', magenta: '#a626a4', cyan: '#0997b3', white: '#a0a1a7',
brightBlack: '#4f525e', brightRed: '#e45649', brightGreen: '#50a14f',
brightYellow: '#c18401', brightBlue: '#0184bc', brightMagenta: '#a626a4',
brightCyan: '#0997b3', brightWhite: '#fafafa',
}
/** The xterm theme for the current scheme (surface from tokens, ANSI curated). */
function xtermTheme(): ITheme {
const dark = isDarkScheme()
const background = tokenValue('--dsw-alias-bg-base') || (dark ? '#111114' : '#ffffff')
const foreground = tokenValue('--dsw-alias-label-primary') || (dark ? '#e6e6e6' : '#1a1a1a')
return {
background,
foreground,
cursor: foreground,
cursorAccent: background,
selectionBackground: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)',
...(dark ? ANSI_DARK : ANSI_LIGHT),
}
}
export function TerminalView(props: { scope: SessionScope; tabId: string; store: SidebarStore }) {
const { scope, tabId, store } = props
const hostRef = useRef<HTMLDivElement>(null)
const [connected, setConnected] = useState(false)
const [fatal, setFatal] = useState<string | null>(null)
const [lastUrl, setLastUrl] = useState<string | null>(null)
const connectRef = useRef<(() => void) | null>(null)
useEffect(() => {
const host = hostRef.current
if (host === null) return
const term = new Terminal({
cursorBlink: true,
fontSize: 13,
fontFamily: tokenValue('--ds-font-family-code') || '"SF Mono", Menlo, Consolas, "Liberation Mono", monospace',
allowTransparency: true,
convertEol: false,
scrollback: 4000,
theme: xtermTheme(),
})
const fit = new FitAddon()
term.loadAddon(fit)
term.open(host)
fit.fit()
// Re-theme in place when the app's scheme flips (tokens + palette).
const applyTheme = (): void => {
term.options.theme = xtermTheme()
term.refresh(0, term.rows - 1)
}
const schemeSub = subscribeColorScheme(applyTheme)
let socket: WebSocket | null = null
let closed = false
let retry: number | undefined
let failures = 0
const wsUrl = (): string => {
const params = new URLSearchParams({ sessionId: scope.sessionId, tab: tabId })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
// Same construction the app's own downlink WebSockets use (new URL
// over location.origin + protocol swap): whatever the environment
// does to the app's websockets applies identically here.
const url = new URL('/sidebar/ws/terminal', location.origin)
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
url.search = params.toString()
return url.toString()
}
const sendResize = (): void => {
if (socket !== null && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
}
}
const connect = (): void => {
if (closed) return
const url = wsUrl()
setLastUrl(url)
socket = new WebSocket(url)
socket.onopen = () => {
failures = 0
setConnected(true)
setFatal(null)
sendResize()
}
socket.onmessage = (event) => {
if (typeof event.data === 'string') term.write(event.data)
}
socket.onclose = (event) => {
setConnected(false)
// A server-side refusal carries a close code + reason; retrying it
// forever would only spin the banner, so surface it with a retry.
if (event.code === 1011 && event.reason !== '') {
setFatal(event.reason)
return
}
// Unreasoned drops (upgrade rejected, host down, mid-handshake
// refusal) normally recover on the next attempt; after a few
// consecutive failures stop spinning and show the close code.
failures += 1
if (failures >= FAILURE_LIMIT) {
const detail = event.reason !== '' ? ` (${event.code}: ${event.reason})` : ` (${event.code})`
console.error('[dsh-better-sidebar] terminal connection failed:', event.code, event.reason, url)
setFatal(`${t('terminalConnectFailed')}${detail}`)
return
}
if (!closed) retry = window.setTimeout(connect, 2000)
}
socket.onerror = () => {
socket?.close()
}
}
connectRef.current = connect
const inputSub = term.onData((data) => {
if (socket !== null && socket.readyState === WebSocket.OPEN) socket.send(data)
})
const observer = new ResizeObserver(() => {
try {
fit.fit()
sendResize()
} catch {
// The terminal may be mid-dispose; ignore.
}
})
observer.observe(host)
connect()
return () => {
closed = true
window.clearTimeout(retry)
observer.disconnect()
schemeSub()
inputSub.dispose()
// The close frame tells the host the owning tab is GONE (immediate
// quota release). A bare unmount — conversation switch, re-render,
// page unload — leaves the tab open, so the socket drop alone hands
// the process to the host's reconnect grace: switching back or
// refreshing reattaches the SAME shell instead of respawning one.
// (The host respawns on its own when the authoritative cwd changed.)
if (!store.tabOpen(scope.sessionId, tabId)
&& socket !== null && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'close' }))
}
socket?.close()
term.dispose()
connectRef.current = null
}
}, [scope.sessionId, scope.cwd, tabId, store])
return (
<div className={css.terminalWrap}>
{fatal !== null && (
<div className={css.terminalBanner}>
{t('terminalError')}: {fatal}
{lastUrl !== null && <div className={css.terminalBannerUrl}>{lastUrl}</div>}
<button
type="button"
className={css.terminalRetry}
onClick={() => { setFatal(null); connectRef.current?.() }}
>
{t('terminalRetry')}
</button>
</div>
)}
{fatal === null && !connected && <div className={css.terminalBanner}>{t('disconnected')}</div>}
<div ref={hostRef} className={css.terminal} />
</div>
)
}
+150
View File
@@ -0,0 +1,150 @@
/**
* Typed fetch wrapper over the /sidebar JSON API. Every call posts to
* `/sidebar/api/<method>` with the sessionId and — when known — the session's
* cwd from the client's own list summary. The host prefers its attached
* session header and uses the summary cwd only while the session is still
* hydrating at page load (a detached session would otherwise fail the
* request). Failures surface as {@link SidebarApiError} with the wire code.
*/
/** One wire failure. */
export class SidebarApiError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message)
}
}
/** Explorer row (host fs-tree shape). */
export interface FsEntry {
name: string
path: string
isDir: boolean
hidden: boolean
}
/** Git status entry (host git shape). */
export interface GitStatusEntry {
path: string
xy: string
}
/** Git status snapshot. */
export interface GitStatusResult {
isRepo: boolean
branch?: string
entries: GitStatusEntry[]
}
/** One git log row. */
export interface GitLogEntry {
hash: string
subject: string
author: string
date: string
}
/** Text read result. */
export interface FsTextResult { kind: 'text'; content: string; truncated: boolean }
/** Binary read result (no content; images load through the media route). */
export interface FsBinaryResult { kind: 'binary'; size: number; truncated: boolean }
async function call<T>(method: string, payload: Record<string, unknown>, signal?: AbortSignal): Promise<T> {
let response: Response
try {
response = await fetch(`/sidebar/api/${method}`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(payload),
signal,
})
} catch (error) {
throw new SidebarApiError('network', error instanceof Error ? error.message : String(error))
}
const parsed: { ok?: boolean; value?: unknown; error?: { code?: string; message?: string } } | null
= await response.json().catch(() => null)
if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === undefined) {
throw new SidebarApiError(
parsed?.error?.code ?? 'http',
parsed?.error?.message ?? `HTTP ${response.status}`,
)
}
return parsed.value as T
}
/** One request's session scope: the conversation id plus its cwd when known. */
export interface SessionScope {
sessionId: string
/** The session's working directory from the client list summary (optional). */
cwd?: string
}
/** Fold a scope into a JSON payload ({cwd} only when present). */
function scopePayload(scope: SessionScope, extra: Record<string, unknown>): Record<string, unknown> {
return { sessionId: scope.sessionId, ...(scope.cwd !== undefined && scope.cwd !== '' ? { cwd: scope.cwd } : {}), ...extra }
}
/** The sidebar API surface (session scope threaded through every call). */
export const api = {
sessionCwd: (scope: SessionScope, signal?: AbortSignal) =>
call<{ sessionId: string; cwd: string; root: string; parent: string | null }>('session.cwd', scopePayload(scope, {}), signal),
fsTree: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<{ path: string; entries: FsEntry[]; truncated: boolean }>('fs.tree', scopePayload(scope, { path }), signal),
fsRead: (scope: SessionScope, path: string, signal?: AbortSignal) =>
call<FsTextResult | FsBinaryResult>('fs.read', scopePayload(scope, { path }), signal),
fsWrite: (scope: SessionScope, path: string, content: string) =>
call<{ ok: true }>('fs.write', scopePayload(scope, { path, content })),
gitStatus: (scope: SessionScope, signal?: AbortSignal) =>
call<GitStatusResult>('git.status', scopePayload(scope, {}), signal),
gitDiff: (scope: SessionScope, path: string | undefined, staged: boolean, signal?: AbortSignal) =>
call<{ diff: string }>('git.diff', scopePayload(scope, { ...(path !== undefined ? { path } : {}), staged }), signal),
gitShow: (scope: SessionScope, rev: string, path: string, signal?: AbortSignal) =>
call<{ content: string | null }>('git.show', scopePayload(scope, { rev, path }), signal),
gitStage: (scope: SessionScope, path?: string) =>
call<{ ok: true }>('git.stage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
gitUnstage: (scope: SessionScope, path?: string) =>
call<{ ok: true }>('git.unstage', scopePayload(scope, { ...(path !== undefined ? { path } : {}) })),
gitCommit: (scope: SessionScope, message: string) =>
call<{ ok: true }>('git.commit', scopePayload(scope, { message })),
gitBranch: (scope: SessionScope, signal?: AbortSignal) =>
call<{ current: string; names: string[] }>('git.branch', scopePayload(scope, {}), signal),
gitCheckout: (scope: SessionScope, branch: string) =>
call<{ ok: true }>('git.checkout', scopePayload(scope, { branch })),
gitLog: (scope: SessionScope, signal?: AbortSignal) =>
call<GitLogEntry[]>('git.log', scopePayload(scope, {}), signal),
/** Release a terminal's process immediately (tab closed; the WS close frame
* may be unreachable while the socket is down, so the host also accepts
* this explicit route). */
ptyClose: (scope: SessionScope, tab: string) =>
call<{ ok: true }>('pty.close', scopePayload(scope, { tab })),
/** Read the side card preferences (plugin-global, no session scope). */
settingsGet: () =>
call<{ value?: unknown; revision?: number }>('settings.get', {}),
/** Merge a patch into the side card preferences (revision-guarded). */
settingsUpdate: (patch: Record<string, unknown>, expectedRevision?: number) =>
call<{ value?: unknown; revision?: number }>('settings.update', {
patch,
...(expectedRevision !== undefined ? { expectedRevision } : {}),
}),
}
/** Absolute URL of the media route for one path (images only). */
export function mediaUrl(scope: SessionScope, path: string): string {
return fileUrl(scope, path, false)
}
/** Absolute URL of the download route: serves raw bytes (binary-safe) with
* `Content-Disposition: attachment`, so the browser saves the file. */
export function downloadUrl(scope: SessionScope, path: string): string {
return fileUrl(scope, path, true)
}
/** Shared URL builder for the /sidebar/file route (media vs download). */
function fileUrl(scope: SessionScope, path: string, download: boolean): string {
const params = new URLSearchParams({ sessionId: scope.sessionId, path })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
if (download) params.set('download', '1')
return `/sidebar/file?${params.toString()}`
}
+129
View File
@@ -0,0 +1,129 @@
/**
* CodeMirror 6 theme pieces for the sidebar editor. The editor surface
* (background, caret, gutter) rides the DSH theme tokens so it blends with
* the panel in both schemes; only the syntax token colors need concrete
* values, and those come from the same designed palettes the app's code
* surfaces use — the one-dark family for dark, the one-light family for
* light. The scheme flip reconfigures these via a compartment (see
* EditorView), so the document, undo history and scroll survive re-theming.
*/
import { Compartment } from '@codemirror/state'
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { tags, type Tag } from '@lezer/highlight'
import { EditorView } from '@codemirror/view'
/** Token-driven surface shared by both schemes (pure CSS values). */
export const cmSurfaceTheme = EditorView.theme({
'&': {
height: '100%',
fontSize: '13px',
backgroundColor: 'transparent',
color: 'var(--dsw-alias-label-primary)',
},
'.cm-scroller': {
overflow: 'auto',
fontFamily: 'var(--ds-font-family-code)',
},
'.cm-content': {
caretColor: 'var(--dsw-alias-label-primary)',
},
'.cm-gutters': {
backgroundColor: 'transparent',
color: 'var(--dsw-alias-label-tertiary)',
border: 'none',
},
})
/** Scheme-specific surface tints (selection, active line). */
function cmSurfaceTint(dark: boolean): ReturnType<typeof EditorView.theme> {
return EditorView.theme({
'.cm-selectionBackground, .cm-focused .cm-selectionBackground, ::selection': {
backgroundColor: dark ? 'rgba(255,255,255,0.22)' : 'rgba(0,0,0,0.12)',
},
'.cm-activeLine, .cm-activeLineGutter': {
backgroundColor: dark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)',
},
})
}
/** One syntax rule: a tag (or tag set) mapped to a concrete color/style. */
interface HighlightRule {
tag: Tag | readonly Tag[]
color?: string
fontStyle?: string
}
/** one-dark syntax palette (mirrors @codemirror/theme-one-dark). */
const HIGHLIGHTS_DARK: HighlightRule[] = [
{ tag: tags.comment, color: '#5c6370', fontStyle: 'italic' },
{ tag: tags.keyword, color: '#c678dd' },
{ tag: tags.string, color: '#98c379' },
{ tag: tags.number, color: '#d19a66' },
{ tag: tags.bool, color: '#d19a66' },
{ tag: tags.atom, color: '#d19a66' },
{ tag: tags.typeName, color: '#e5c07b' },
{ tag: tags.className, color: '#e5c07b' },
{ tag: tags.propertyName, color: '#e06c75' },
{ tag: tags.function(tags.variableName), color: '#61afef' },
{ tag: tags.variableName, color: '#e06c75' },
{ tag: tags.operator, color: '#56b6c2' },
{ tag: tags.tagName, color: '#e06c75' },
{ tag: tags.attributeName, color: '#d19a66' },
{ tag: tags.heading, color: '#e06c75', fontStyle: 'bold' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strong, fontStyle: 'bold' },
{ tag: tags.link, color: '#61afef', fontStyle: 'underline' },
{ tag: tags.meta, color: '#e5c07b' },
{ tag: tags.invalid, color: '#ffffff', fontStyle: 'bold' },
]
/** one-light syntax palette (the light counterpart of one-dark). */
const HIGHLIGHTS_LIGHT: HighlightRule[] = [
{ tag: tags.comment, color: '#a0a1a7', fontStyle: 'italic' },
{ tag: tags.keyword, color: '#a626a4' },
{ tag: tags.string, color: '#50a14f' },
{ tag: tags.number, color: '#986801' },
{ tag: tags.bool, color: '#0184bc' },
{ tag: tags.atom, color: '#0184bc' },
{ tag: tags.typeName, color: '#c18401' },
{ tag: tags.className, color: '#c18401' },
{ tag: tags.propertyName, color: '#e45649' },
{ tag: tags.function(tags.variableName), color: '#c18401' },
{ tag: tags.variableName, color: '#e45649' },
{ tag: tags.operator, color: '#383a42' },
{ tag: tags.tagName, color: '#e45649' },
{ tag: tags.attributeName, color: '#986801' },
{ tag: tags.heading, color: '#e45649', fontStyle: 'bold' },
{ tag: tags.emphasis, fontStyle: 'italic' },
{ tag: tags.strong, fontStyle: 'bold' },
{ tag: tags.link, color: '#4078f2', fontStyle: 'underline' },
{ tag: tags.meta, color: '#c18401' },
{ tag: tags.invalid, color: '#ffffff', fontStyle: 'bold' },
]
/** The scheme-dependent extension pair (surface tint + syntax highlight). */
function cmThemeExtensions(dark: boolean): Array<ReturnType<typeof EditorView.theme>> {
return [
cmSurfaceTint(dark),
syntaxHighlighting(HighlightStyle.define(dark ? HIGHLIGHTS_DARK : HIGHLIGHTS_LIGHT)),
]
}
/**
* A Compartment holding the two scheme-dependent extensions. Created once
* per editor view; a scheme flip dispatches `reconfigure(dark)` on it, so
* the document, undo history, scroll and keymaps survive re-theming.
*/
export class CmThemeCompartment {
private readonly compartment = new Compartment()
/** `of(...)` payload for EditorState.create. */
of(dark: boolean): ReturnType<Compartment['of']> {
return this.compartment.of(cmThemeExtensions(dark))
}
/** Reconfigure for a new scheme. */
reconfigure(dark: boolean): ReturnType<Compartment['reconfigure']> {
return this.compartment.reconfigure(cmThemeExtensions(dark))
}
}
+5
View File
@@ -0,0 +1,5 @@
/** CSS Modules class-map declaration (tsdown compiles .module.css at build). */
declare module '*.module.css' {
const classes: Record<string, string>
export default classes
}
+33
View File
@@ -0,0 +1,33 @@
/**
* Icons the sidebar needs beyond the primitives set: a terminal glyph (the
* icon library has none) and the per-tab-type icon mapping shared by the
* tab strip, the + menu, and the empty-pane cards.
*/
import type { ReactNode } from 'react'
import {
IconBranchOutline16, IconCodeOutline16, IconFolderOpen16,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { IconProps } from '@deepseek-ai/dsh-client-ui-primitives'
import type { TabType } from './state.ts'
/**
* Terminal glyph in the app's outline style (1.5px stroke, currentColor):
* a rounded frame with a prompt chevron and underscore cursor.
*/
export const IconTerminalOutline16 = ({ size = 16, className }: IconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1.5" y="2.5" width="13" height="11" rx="2" stroke="currentColor" strokeWidth="1.5" />
<path d="M4.5 6.25 6.75 8 4.5 9.75" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
<path d="M8.5 10.4h3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
/** The one icon per tab type (shared by tabs, the + menu, and pane cards). */
export function tabTypeIcon(type: TabType, size = 16): ReactNode {
switch (type) {
case 'explorer': return <IconFolderOpen16 size={size} />
case 'git': return <IconBranchOutline16 size={size} />
case 'terminal': return <IconTerminalOutline16 size={size} />
case 'editor': return <IconCodeOutline16 size={size} />
}
}
+131 -454
View File
@@ -1,471 +1,148 @@
/**
* apes-sidebar client-half entry (Phase 1: the five real `apes:*` tabs).
*
* Loaded through the package.json `dsh.client.inject` declaration (web
* platform). The entry registers the five apes-sidebar tab descriptors —
* `apes:tasks`, `apes:agents`, `apes:apps`, `apes:messages`, `apes:profile` —
* into the better-sidebar host's `ctx.betterSidebar` registry, then disposes
* cleanly on fiber teardown (HMR-safe).
*
* Registration is wrapped in a single `ctx.effect(...)`: the effect body
* registers the i18n dictionaries plus all five tabs and returns the combined
* disposer, which cordis invokes on activation teardown. A missing
* `betterSidebar` (bare DSH, no better-sidebar installed) degrades silently —
* a missing peer must never break the shell.
*
* Type base: the descriptors and the component props are the REAL
* better-sidebar `TabDescriptor` / `TabComponentProps` (type-only import —
* erased at build, never bundled, and it triggers the `declare module
* '@deepseek-ai/cordis'` augmentation so `ctx.betterSidebar` type-checks).
*
* inject list (client-only):
* - betterSidebar: the better-sidebar registry service (peer, optional)
* - locale: the DSH locale runtime (dictionaries register under apesSidebar)
* - sessions: the client session list feed (drives tasks + agents)
* Client half of dsh-better-sidebar: resolves the user's "Side card"
* preferences through the plugin's own fenced settings route, mounts the
* right sidebar portal (inside an error boundary so a rendering failure
* shows an error strip instead of a blank panel), registers the turn-tail
* interception, and contributes the Side card settings section to the DSH
* Settings shell. Requires the runtime's slots and sessions services; the
* bundle itself is a module-table consumer only (react + ui-primitives +
* xterm, all provided or inlined).
*/
import { useSyncExternalStore, type ReactElement, type ReactNode } from 'react'
import type { TabDescriptor, TabComponentProps, Context } from 'dsh-better-sidebar'
import { t, registerLocales, useApesLocale } from './locales.ts'
import { Component, createElement, type ErrorInfo, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import type { Context } from '../context-types.ts'
import { createSidebarStore } from './state.ts'
import { Sidebar } from './Sidebar.tsx'
import { registerTurnTailInterception } from './intercept.tsx'
import { loadPrefs } from './prefs.ts'
import { SideCardSection } from './SideCardSection.tsx'
import { api } from './api.ts'
import { t } from './locales.ts'
import css from './sidebar.module.css'
import './layout.css'
/** Services required before mounting (provided by the client runtime). */
export const inject = ['slots', 'sessions']
/**
* The five tab ids, in + menu order (ascending). Phase 2 adds badge counts
* and auto-collapse on top of these same descriptors.
* Error boundary over the sidebar tree: a render error must never blank the
* whole panel silently — it shows a dismissible error strip and logs the
* stack for diagnosis.
*/
const TABS = {
tasks: 'apes:tasks',
agents: 'apes:agents',
apps: 'apes:apps',
messages: 'apes:messages',
profile: 'apes:profile',
} as const
class SidebarBoundary extends Component<{ children: ReactNode }, { error: string | null }> {
state = { error: null as string | null }
/**
* The cordis Context face this entry resolves against: the better-sidebar
* `Context` (a `CordisContext & SidebarContextShape` intersection that
* carries `sessions`, `locale`, and `betterSidebar`). `betterSidebar` is
* typed required by the peer, but a bare DSH profile lacks it at runtime —
* the apply() guard handles the absence, so we keep the peer's type as-is
* and guard on the value.
*/
type ApesClientContext = Context
/**
* Read the client session list uSES-safely, with `visible`-gated pausing
* (Phase 2 auto-collapse). `useSyncExternalStore` re-subscribes whenever the
* `subscribe` identity changes, so passing a no-op subscribe while the tab is
* not the active-open one stops live session-list churn from re-rendering a
* hidden panel — exactly better-sidebar's "live views pause otherwise"
* contract. The snapshot itself is always read (cheap), so a hidden tab still
* shows its last-known data when re-shown.
*/
const NOOP_SUBSCRIBE = () => () => {}
function useSessionList(ctx: Context, visible: boolean) {
const list = ctx.sessions.list
return useSyncExternalStore(
visible ? list.subscribe : NOOP_SUBSCRIBE,
list.getSnapshot,
list.getSnapshot,
)
}
/** Count of sessions whose agent is currently running (badge source). */
function runningCount(ctx: Context): number {
const { byId } = ctx.sessions.list.getSnapshot()
let n = 0
for (const id in byId) {
if (byId[id]?.running) n++
static getDerivedStateFromError(error: unknown): { error: string } {
return { error: error instanceof Error ? error.message : String(error) }
}
return n
}
/** Count of subagent-origin sessions (badge source for apes:agents). */
function subagentCount(ctx: Context): number {
const { byId } = ctx.sessions.list.getSnapshot()
let n = 0
for (const id in byId) {
if (byId[id]?.origin === 'subagent') n++
componentDidCatch(error: Error, info: ErrorInfo): void {
console.error('[dsh-better-sidebar] render error:', error, info.componentStack)
}
return n
}
/**
* Per-tab state (Phase 2): the apes:tasks panel's `showInactive` preference,
* persisted across reloads through better-sidebar's settings service
* (`ctx.settings.register`). The namespace handle is registered ONCE at
* module scope (registering per render would re-stack schemas); the hook
* reads it uSES-safely and writes back via `update`.
*
* Degrades to the default (`showInactive: true`) when `ctx.settings` is
* absent — a bare DSH profile or a unit-test fake that omits the service.
*/
const TASKS_NS = 'apesSidebar.tasks'
interface TasksPrefs {
showInactive: boolean
}
let tasksPrefsHandle: {
get(): TasksPrefs
watch(cb: (next: TasksPrefs, prev: TasksPrefs) => void): () => void
update(patch: Partial<TasksPrefs>): Promise<void>
} | undefined
function getTasksPrefsHandle(ctx: Context) {
if (tasksPrefsHandle) return tasksPrefsHandle
if (!ctx.settings?.register) return undefined
tasksPrefsHandle = ctx.settings.register<TasksPrefs>(TASKS_NS, null, {
base: { showInactive: true },
applies: 'live',
})
return tasksPrefsHandle
}
const DEFAULT_TASKS_PREFS: TasksPrefs = { showInactive: true }
/**
* Pure row filter for the tasks list: when `showInactive` is false, only
* running sessions render. Exported for unit testing (the hook itself needs
* a render, but this decision is a plain function).
*/
export function filterTasksRows<T extends { running?: boolean }>(
all: T[],
showInactive: boolean,
): T[] {
return showInactive ? all : all.filter((s) => s.running)
}
function useTasksPrefs(ctx: Context): { prefs: TasksPrefs; setShowInactive: (v: boolean) => void } {
const handle = getTasksPrefsHandle(ctx)
const prefs = useSyncExternalStore(
handle ? (cb) => handle.watch(cb) : NOOP_SUBSCRIBE,
handle ? handle.get : () => DEFAULT_TASKS_PREFS,
handle ? handle.get : () => DEFAULT_TASKS_PREFS,
)
return {
prefs,
setShowInactive: (v: boolean) => {
void handle?.update({ showInactive: v })
},
}
}
/** A row in a list view: id, title, and optional flag chips. */
function SessionRow({
title,
running,
current,
subagent,
}: {
title: string
running?: boolean
current?: boolean
subagent?: boolean
}): ReactElement {
return (
<div
data-apes-row
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
padding: '4px 6px',
fontSize: 12,
borderRadius: 4,
}}
>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{title}
</span>
{running && <Chip label={t('tasks.running')} tone="run" />}
{current && <Chip label={t('tasks.current')} tone="cur" />}
{subagent && <Chip label={t('tasks.subagent')} tone="sub" />}
</div>
)
}
/** A small status pill. */
function Chip({ label, tone }: { label: string; tone: 'run' | 'cur' | 'sub' }): ReactElement {
const bg = tone === 'run' ? '#2ea04333' : tone === 'cur' ? '#1f6feb33' : '#6e40c933'
const fg = tone === 'run' ? '#2ea043' : tone === 'cur' ? '#1f6feb' : '#6e40c9'
return (
<span
style={{
flexShrink: 0,
fontSize: 10,
lineHeight: '16px',
padding: '0 5px',
borderRadius: 8,
background: bg,
color: fg,
whiteSpace: 'nowrap',
}}
>
{label}
</span>
)
}
/** A panel shell: a title bar + a scrollable body. */
function Panel({ titleKey, children }: { titleKey: string; children: ReactNode }): ReactElement {
return (
<div data-apes-panel style={{ display: 'flex', flexDirection: 'column', height: '100%', padding: 10, fontSize: 13 }}>
<div style={{ fontWeight: 600, marginBottom: 8 }}>{t(titleKey)}</div>
<div style={{ flex: 1, overflowY: 'auto' }}>{children}</div>
</div>
)
}
/** The empty-state line every panel falls back to. */
function Empty({ key: key }: { key: string }): ReactElement {
return <div style={{ opacity: 0.55, fontSize: 12 }}>{t(key)}</div>
}
/**
* apes:tasks — the session list. Running/current/subagent rows are flagged.
* Clicking a row selects it as current (when the host exposes `open`).
*/
function TasksView(props: TabComponentProps): ReactElement {
useApesLocale()
const list = useSessionList(props.ctx, props.visible)
const { prefs, setShowInactive } = useTasksPrefs(props.ctx)
// Per-tab state: when showInactive is off, only running sessions render.
const all = Object.values(list.byId)
const rows = prefs.showInactive ? all : all.filter((s) => s.running)
const open = (id: string) => props.ctx.sessions.open?.(id)
return (
<Panel titleKey="tab.tasks.title">
{/* The showInactive toggle — the persisted per-tab state. */}
<label
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
fontSize: 11,
opacity: 0.8,
marginBottom: 6,
cursor: 'pointer',
userSelect: 'none',
}}
>
<input
type="checkbox"
checked={prefs.showInactive}
onChange={(e) => setShowInactive(e.target.checked)}
style={{ margin: 0 }}
/>
{t('tasks.showInactive')}
</label>
{rows.length === 0 ? (
<Empty key="tasks.empty" />
) : (
rows.map((s) => (
<SessionRow
key={s.id}
title={s.displayTitle}
running={s.running}
current={s.id === list.current}
subagent={s.origin === 'subagent'}
// The row is a button only when a host `open` is available.
{...{ onClick: () => open(s.id), role: 'button', tabIndex: 0, style: { cursor: 'pointer' } }}
/>
))
)}
</Panel>
)
}
/**
* apes:agents — the subagent children, grouped by their durable parent
* session. Reads the same sessions feed and filters `origin === 'subagent'`.
*/
function AgentsView(props: TabComponentProps): ReactElement {
useApesLocale()
const list = useSessionList(props.ctx, props.visible)
const children = Object.values(list.byId).filter((s) => s.origin === 'subagent')
if (children.length === 0) {
return (
<Panel titleKey="tab.agents.title">
<Empty key="agents.empty" />
<div style={{ opacity: 0.45, fontSize: 12, marginTop: 4 }}>{t('agents.hint')}</div>
</Panel>
)
}
// Group by parentId (undefined groups under a synthetic root).
const groups = new Map<string, typeof children>()
for (const s of children) {
const key = s.parentId ?? ''
const arr = groups.get(key) ?? []
arr.push(s)
groups.set(key, arr)
}
const jump = (childSessionId: string) => props.onSubagentJump?.(childSessionId)
return (
<Panel titleKey="tab.agents.title">
{[...groups.entries()].map(([parentId, rows]) => (
<div key={parentId || 'root'} style={{ marginBottom: 10 }}>
{parentId && (
<div style={{ opacity: 0.5, fontSize: 11, marginBottom: 4 }}>
{list.byId[parentId]?.displayTitle ?? parentId}
</div>
)}
{rows.map((s) => (
<SessionRow
key={s.id}
title={s.displayTitle}
running={s.running}
subagent
{...{ onClick: () => jump(s.id), role: 'button', tabIndex: 0, style: { cursor: 'pointer' } }}
/>
))}
render(): ReactNode {
if (this.state.error !== null) {
return (
<div className={css.boundaryError}>
<span>dsh-better-sidebar: {this.state.error}</span>
<button
type="button"
className={css.terminalRetry}
onClick={() => { this.setState({ error: null }) }}
>
{t('terminalRetry')}
</button>
</div>
))}
</Panel>
)
}
/** apes:apps — placeholder (Phase 2 wires the workspace app feed). */
function AppsView(): ReactElement {
useApesLocale()
return (
<Panel titleKey="tab.apps.title">
<Empty key="apps.empty" />
<div style={{ opacity: 0.45, fontSize: 12, marginTop: 4 }}>{t('apps.hint')}</div>
</Panel>
)
}
/** apes:messages — placeholder (Phase 2 wires the message/reminder stream). */
function MessagesView(): ReactElement {
useApesLocale()
return (
<Panel titleKey="tab.messages.title">
<Empty key="messages.empty" />
<div style={{ opacity: 0.45, fontSize: 12, marginTop: 4 }}>{t('messages.hint')}</div>
</Panel>
)
}
/**
* apes:profile — account/runtime overview. Phase 1 shows the shell + a hint;
* Phase 2 reads model/provider from a host route (none exposed yet).
*/
function ProfileView(): ReactElement {
useApesLocale()
return (
<Panel titleKey="tab.profile.title">
<div style={{ fontSize: 12, marginBottom: 8 }}>
<div>
<span style={{ opacity: 0.55 }}>{t('profile.model')}: </span>
<span>{t('profile.unknown')}</span>
</div>
<div>
<span style={{ opacity: 0.55 }}>{t('profile.provider')}: </span>
<span>{t('profile.unknown')}</span>
</div>
</div>
<div style={{ opacity: 0.45, fontSize: 12 }}>{t('profile.hint')}</div>
</Panel>
)
}
/**
* Build the five tab descriptors. Each `component` receives the real
* better-sidebar `TabComponentProps` (ctx / store / scope / tab / visible).
*
* Phase 2 additions:
* - `badge` on tasks/agents: a cheap count pill (running sessions / subagent
* count). Called on every tab-bar render, so it reads the live sessions
* snapshot synchronously and returns `null` when the count is 0 (hidden).
* - `visible` is now honored by the live views: `useSessionList` swaps to a
* no-op subscribe when the tab is not the active-open one (auto-collapse —
* hidden panels stop re-rendering on session-list churn).
*/
function buildDescriptors(): TabDescriptor[] {
return [
{
id: TABS.tasks,
title: () => t('tab.tasks.title'),
order: 90,
single: true,
// Running-session count pill (99+ capped by the host; 0 → hidden).
badge: (ctx) => {
const n = runningCount(ctx)
return n === 0 ? null : n
},
component: (props) => <TasksView {...props} />,
},
{
id: TABS.agents,
title: () => t('tab.agents.title'),
order: 91,
single: true,
// Active-subagent count pill (0 → hidden).
badge: (ctx) => {
const n = subagentCount(ctx)
return n === 0 ? null : n
},
component: (props) => <AgentsView {...props} />,
},
{
id: TABS.apps,
title: () => t('tab.apps.title'),
order: 92,
single: true,
component: () => <AppsView />,
},
{
id: TABS.messages,
title: () => t('tab.messages.title'),
order: 93,
single: true,
component: () => <MessagesView />,
},
{
id: TABS.profile,
title: () => t('tab.profile.title'),
order: 94,
single: true,
component: () => <ProfileView />,
},
]
}
/**
* Register the i18n dictionaries plus all five tabs; returns the combined
* disposer (locale registrations + the five tab disposers). Safe to call
* from inside a cordis effect.
*/
function registerAll(ctx: ApesClientContext): () => void {
const better = ctx.betterSidebar
const disposers: (() => void)[] = [registerLocales(ctx.locale)]
for (const descriptor of buildDescriptors()) {
disposers.push(better.registerTab(descriptor))
)
}
return this.props.children
}
return () => {
for (const d of disposers) {
try {
d()
} catch {
// Already disposed (double-dispose on HMR) — ignore.
}
}
/**
* Client plugin body.
* @param ctx - the client cordis context (slots, sessions).
*/
export function apply(ctx: Context): void {
// One store instance per activation: production code creates it only here,
// then hands it to the mounted panel and closes over it in the slot
// registrations (the official createXXXStore() factory rule — no
// module-level singleton).
const sidebarStore = createSidebarStore()
// A failure anywhere in the client lifecycle must never take the app down
// silently: log with the plugin prefix and pin a visible diagnostic strip
// to the page so a blank panel is never the only symptom.
const fail = (phase: string, error: unknown): void => {
console.error(`[dsh-better-sidebar] ${phase} error:`, error)
try {
const bar = document.createElement('div')
bar.style.cssText = 'position:fixed;left:8px;bottom:8px;z-index:2147483000;max-width:70vw;padding:8px 12px;'
+ 'font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:#f2a1a1;background:#1b1b22;'
+ 'border:1px solid #f2a1a1;border-radius:8px;white-space:pre-wrap'
bar.textContent = `[dsh-better-sidebar] ${phase} error: ${error instanceof Error ? error.message : String(error)}`
document.body.appendChild(bar)
} catch {
// Nothing left to report with.
}
}
}
try {
ctx.effect(() => {
let disposed = false
let root: Root | undefined
let host: HTMLDivElement | undefined
void (async () => {
// Resolve the user's side card prefs BEFORE the first session seeds,
// so a brand-new conversation opens (or stays closed) at the chosen
// width from first paint. A settings route failure falls back to the
// schema defaults; the sidebar still mounts (a stalled wire gives up
// after the timeout and mounts on the defaults).
const prefs = await Promise.race([
loadPrefs(api),
new Promise<null>(resolve => { const timer = window.setTimeout(() => resolve(null), 2000) }),
])
if (prefs !== null) sidebarStore.setPrefs(prefs)
if (disposed) return
try {
host = document.createElement('div')
host.setAttribute('data-dsh-better-sidebar', '')
document.body.appendChild(host)
root = createRoot(host)
root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx, store: sidebarStore })))
} catch (error) {
fail('mount', error)
}
})()
return () => {
disposed = true
root?.unmount()
host?.remove()
}
}, 'dsh-better-sidebar: sidebar mount')
/** The cordis client entry. */
export function apply(ctx: ApesClientContext): void {
if (!ctx.betterSidebar) {
// better-sidebar not present in this profile: the tabs cannot register.
// Expected on a bare DSH; the host half still mounts (logs only).
// Do NOT throw — a missing peer must degrade, never break the shell.
return
}
if (typeof ctx.effect === 'function') {
// cordis effect takes a zero-arg thunk; capture ctx in the closure.
// The body returns the combined disposer; cordis runs it on activation
// teardown (HMR-safe).
ctx.effect(() => registerAll(ctx))
} else {
// No effect API (non-cordis harness / unit test): register synchronously;
// disposal rides process teardown.
registerAll(ctx)
ctx.effect(
() => {
try {
return registerTurnTailInterception(ctx, sidebarStore)
} catch (error) {
fail('interception', error)
return undefined
}
},
'dsh-better-sidebar: turn-tail interception',
)
// The "Side card" settings section: appears in the DSH Settings shell
// once the shell's declaration is on the ledger (slots.inject waits for
// it); the section reads/writes the prefs through the plugin's own
// fenced settings route and keeps the shared store in sync.
ctx.slots.inject('settings.section', () => ctx.slots.register({
name: 'settings.section',
id: 'better-sidebar',
order: 100,
label: () => t('settingsNav'),
inject: () => ({ store: sidebarStore }),
}, SideCardSection))
} catch (error) {
fail('load', error)
}
}
+73
View File
@@ -0,0 +1,73 @@
/**
* Interception of the chat's produced-files row: the turn-tail chain entry
* that replaces ui-deliverables' row when the closing turn produced files.
* The takeover looks identical (same chip row); the chips open the file in
* the sidebar instead of the host OS. Priority -1 runs before the default-0
* deliverables entry; when nothing was produced the selector returns null
* and the original row renders unchanged.
*/
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import type { Context } from '../context-types.ts'
import { openTab, type SidebarStore } from './state.ts'
import { t } from './locales.ts'
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
import css from './sidebar.module.css'
/** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */
export function openSidebarFile(ctx: Context, store: SidebarStore, sessionId: string, path: string): void {
const summary = ctx.sessions.list.getSnapshot().byId[sessionId]
const absolute = resolveSidebarPath(summary?.cwd, path)
const at = Math.max(absolute.lastIndexOf('/'), absolute.lastIndexOf('\\'))
const title = at === -1 ? absolute : absolute.slice(at + 1)
store.reduce(state => openTab(state, {
id: `editor:${absolute}`,
type: 'editor',
title,
path: absolute,
}))
}
/** The intercepted produced-files row (visual twin of the deliverables chips). */
export function SidebarProducedFiles(props: {
matched: readonly string[]
openInSidebar: (path: string) => void
}) {
const { matched, openInSidebar } = props
const shown = matched.slice(0, 6)
const hidden = matched.length - shown.length
return (
<div className={css.producedRow}>
<span className={css.producedLabel}>{t('produced')}</span>
{shown.map(path => {
const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\'))
const name = at === -1 ? path : path.slice(at + 1)
return (
<button
key={path}
type="button"
className={css.producedChip}
title={path}
onClick={() => { openInSidebar(path) }}
>
<IconCodeOutline16 size={12} />
<span>{name}</span>
</button>
)
})}
{hidden > 0 && <span className={css.producedMore}>+{hidden}</span>}
</div>
)
}
/** Register the turn-tail interception (returns the disposer). */
export function registerTurnTailInterception(ctx: Context, store: SidebarStore): () => void {
return ctx.slots.register({
name: 'conversation.chat.turnTail',
select: selectProducedFiles,
priority: -1,
registrant: 'dsh-better-sidebar',
inject: (sessionId: string) => ({
openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) },
}),
}, SidebarProducedFiles)
}
+95
View File
@@ -0,0 +1,95 @@
/**
* Syntax highlighting for the file editor: extension → CodeMirror language
* mapping. The key derivation is pure and unit-tested; the factories pull in
* the CodeMirror language packages (bundled into the client).
*/
import { Language, LanguageSupport, StreamLanguage } from '@codemirror/language'
import { javascript } from '@codemirror/lang-javascript'
import { json } from '@codemirror/lang-json'
import { markdown } from '@codemirror/lang-markdown'
import { python } from '@codemirror/lang-python'
import { html } from '@codemirror/lang-html'
import { css } from '@codemirror/lang-css'
import { xml } from '@codemirror/lang-xml'
import { yaml } from '@codemirror/lang-yaml'
import { sql } from '@codemirror/lang-sql'
import { java } from '@codemirror/lang-java'
import { cpp } from '@codemirror/lang-cpp'
import { rust } from '@codemirror/lang-rust'
import { go } from '@codemirror/lang-go'
import { php } from '@codemirror/lang-php'
import { shell } from '@codemirror/legacy-modes/mode/shell'
import { toml } from '@codemirror/legacy-modes/mode/toml'
import { nginx } from '@codemirror/legacy-modes/mode/nginx'
import { dockerFile } from '@codemirror/legacy-modes/mode/dockerfile'
import { properties } from '@codemirror/legacy-modes/mode/properties'
/** The lowercased file extension of a path ('' when none). */
export function extOf(path: string): string {
const at = path.lastIndexOf('.')
if (at === -1) return ''
const base = path.slice(at + 1).toLowerCase()
return base.includes('/') || base.includes('\\') ? '' : base
}
/** Language key for an extension, or null for plain text. Pure (tested). */
export function languageKeyForExt(ext: string): string | null {
switch (ext) {
case 'js': case 'mjs': case 'cjs': return 'js'
case 'jsx': return 'jsx'
case 'ts': case 'mts': case 'cts': return 'ts'
case 'tsx': return 'tsx'
case 'json': case 'jsonc': return 'json'
case 'md': case 'markdown': return 'md'
case 'py': case 'pyw': return 'python'
case 'html': case 'htm': return 'html'
case 'css': return 'css'
case 'xml': case 'xsl': return 'xml'
case 'yaml': case 'yml': return 'yaml'
case 'sql': return 'sql'
case 'java': return 'java'
case 'c': case 'h': return 'c'
case 'cc': case 'cpp': case 'cxx': case 'hpp': case 'hh': case 'hxx': return 'cpp'
case 'rs': return 'rust'
case 'go': return 'go'
case 'php': return 'php'
case 'sh': case 'bash': case 'zsh': return 'shell'
case 'toml': return 'toml'
case 'nginx': case 'conf': return 'nginx'
case 'dockerfile': case 'docker': return 'dockerfile'
case 'properties': case 'env': return 'properties'
default: return null
}
}
const FACTORIES: Record<string, () => Language | LanguageSupport> = {
js: () => javascript({ jsx: true }),
jsx: () => javascript({ jsx: true }),
ts: () => javascript({ typescript: true }),
tsx: () => javascript({ typescript: true, jsx: true }),
json: () => json(),
md: () => markdown(),
python: () => python(),
html: () => html(),
css: () => css(),
xml: () => xml(),
yaml: () => yaml(),
sql: () => sql(),
java: () => java(),
c: () => cpp(),
cpp: () => cpp(),
rust: () => rust(),
go: () => go(),
php: () => php(),
shell: () => StreamLanguage.define(shell),
toml: () => StreamLanguage.define(toml),
nginx: () => StreamLanguage.define(nginx),
dockerfile: () => StreamLanguage.define(dockerFile),
properties: () => StreamLanguage.define(properties),
}
/** The CodeMirror language support for a path, or null for plain text. */
export function languageForPath(path: string): Language | LanguageSupport | null {
const key = languageKeyForExt(extOf(path))
return key === null ? null : FACTORIES[key]!()
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Layout push: when the sidebar is open it OCCUPIES the layout instead of
* floating over it — the app shell (#root, the AppFrame three-column grid)
* gives up the panel's width on the right. Only the center column is
* flexible (1fr), so the squeeze lands exactly on the conversation output
* and the input bar, like a VSCode sidebar. The fixed panel covers the
* vacated strip, which looks seamless because #root's background is the
* theme base.
*
* The width rides a CSS variable updated by the Sidebar shell (0 while
* collapsed); expand/collapse animates both the margin and the panel slide
* on the same theme duration. Width drags disable the transition so the
* layout tracks the pointer.
*/
#root {
margin-right: var(--dsh-sidebar-width, 0px);
transition: margin-right var(--ds-transition-duration-slow) var(--ds-ease-in-out);
}
body[data-dsh-sidebar-dragging] #root {
transition: none;
}
@media (prefers-reduced-motion: reduce) {
#root {
transition: none;
}
}
+159 -125
View File
@@ -1,136 +1,170 @@
/**
* apes-sidebar i18n dictionaries, registered into the DSH locale runtime
* under the `apesSidebar` namespace (follows the DSH i18n system: the
* active locale is the Host-backed preference, not the raw browser language).
*
* zh + en are primary. A third language falls back to en (the better-sidebar
* convention: every new zh key must also have an en entry).
*
* The module tracks the active locale in a small external store so `t()`
* always resolves against the current locale, and React components re-render
* on locale change via {@link useApesLocale} (useSyncExternalStore).
* Minimal zh/en copy for the sidebar. The plugin renders outside the slot
* system's locale seat, so it reads the browser language directly (the DSH
* locale preference and the browser language agree in practice; both default
* from navigator.language anyway).
*/
import { useSyncExternalStore } from 'react'
import type { ApesLocaleService } from '../context-types.ts'
const NS = 'apesSidebar'
const zh: Record<string, string> = {
'tab.tasks.title': '任务',
'tab.agents.title': 'Agent',
'tab.messages.title': '消息',
'tab.apps.title': '应用',
'tab.profile.title': '账户',
'tasks.empty': '暂无会话',
'tasks.running': '运行中',
'tasks.current': '当前',
'tasks.subagent': '子代理',
'tasks.showInactive': '显示非运行会话',
'agents.empty': '暂无子代理',
'agents.hint': '子代理会话会在此按父会话分组展示。',
'apps.empty': '暂无应用',
'apps.hint': '工作区应用与外部入口将在此展示。',
'messages.empty': '暂无消息',
'messages.hint': '会话消息流与提醒将在此汇总。',
'profile.model': '模型',
'profile.provider': '提供商',
'profile.unknown': '未知',
'profile.hint': '账户与运行配置概览。',
const zh = {
explorer: '资源管理器',
git: '源代码管理',
terminal: '终端',
editor: '编辑器',
newTab: '新建标签页',
openExplorer: '资源管理器',
openGit: 'Git 面板',
newTerminal: '新终端',
terminalLimit: '终端数量已达上限 (3)',
close: '关闭',
collapse: '折叠侧边栏',
expand: '展开侧边栏',
expandFullscreen: '展开为全屏',
restoreFullscreen: '还原宽度',
terminalError: '终端连接失败',
terminalConnectFailed: '终端多次连接失败',
terminalRetry: '重试',
preview: '预览',
edit: '编辑',
refresh: '刷新',
save: '保存',
saved: '已保存',
unsaved: '未保存',
saveFailed: '保存失败',
truncation: '文件过大,仅显示前 512KB',
binary: '二进制文件,无法预览',
loading: '加载中…',
error: '加载失败',
retry: '重试',
splitLeft: '向左分栏',
splitRight: '向右分栏',
splitUp: '向上分栏',
splitDown: '向下分栏',
notRepo: '当前目录不是 git 仓库',
noChanges: '没有变更',
stage: '暂存',
unstage: '取消暂存',
stageAll: '全部暂存',
unstageAll: '全部取消暂存',
commitPlaceholder: '提交信息 (Ctrl+Enter)',
commit: '提交',
commitError: '提交失败',
branch: '分支',
checkoutError: '切换分支失败',
history: '历史',
changes: '变更',
staged: '已暂存',
unstaged: '未暂存',
produced: '本次产出',
producedOpen: '在侧边栏中打开',
disconnected: '终端连接断开,重连中…',
exited: '终端进程已退出',
noSession: '选择一个会话以使用侧边栏',
hiddenFiles: '隐藏文件',
parent: '上级目录',
copied: '已复制',
copy: '复制',
newFile: '新文件',
openEditor: '打开编辑器',
gitDetail: '查看变更详情',
referenceFile: '@文件',
copyRelative: '复制相对地址',
copyAbsolute: '复制绝对地址',
download: '下载',
settingsNav: '侧边卡片',
settingsOpenTitle: '新会话默认打开',
settingsOpenDesc: '新建会话时自动展开侧边卡片;已存在的会话保持各自布局',
settingsWidthTitle: '默认宽度占比',
settingsWidthDesc: '新建会话时侧边卡片占窗口宽度的百分比 (20–60)',
settingsWidthSuffix: '%',
settingsSaveFailed: '保存失败',
settingsConflict: '设置已被其他窗口修改,请重试',
}
const en: Record<string, string> = {
'tab.tasks.title': 'Tasks',
'tab.agents.title': 'Agents',
'tab.messages.title': 'Messages',
'tab.apps.title': 'Apps',
'tab.profile.title': 'Profile',
'tasks.empty': 'No sessions yet',
'tasks.running': 'running',
'tasks.current': 'current',
'tasks.subagent': 'subagent',
'tasks.showInactive': 'Show inactive sessions',
'agents.empty': 'No subagents yet',
'agents.hint': 'Subagent sessions appear here, grouped by parent.',
'apps.empty': 'No apps yet',
'apps.hint': 'Workspace apps and external entry points appear here.',
'messages.empty': 'No messages yet',
'messages.hint': 'Session message flow and reminders are aggregated here.',
'profile.model': 'Model',
'profile.provider': 'Provider',
'profile.unknown': 'Unknown',
'profile.hint': 'Account and runtime configuration overview.',
const en: Record<keyof typeof zh, string> = {
explorer: 'Explorer',
git: 'Source Control',
terminal: 'Terminal',
editor: 'Editor',
newTab: 'New tab',
openExplorer: 'Explorer',
openGit: 'Git panel',
newTerminal: 'New terminal',
terminalLimit: 'Terminal limit reached (3)',
close: 'Close',
collapse: 'Collapse sidebar',
expand: 'Expand sidebar',
expandFullscreen: 'Expand to full width',
restoreFullscreen: 'Restore width',
terminalError: 'Terminal connection failed',
terminalConnectFailed: 'Terminal failed to connect repeatedly',
terminalRetry: 'Retry',
preview: 'Preview',
edit: 'Edit',
refresh: 'Refresh',
save: 'Save',
saved: 'Saved',
unsaved: 'Unsaved',
saveFailed: 'Save failed',
truncation: 'File too large — showing the first 512KB',
binary: 'Binary file, preview unavailable',
loading: 'Loading…',
error: 'Failed to load',
retry: 'Retry',
splitLeft: 'Split left',
splitRight: 'Split right',
splitUp: 'Split up',
splitDown: 'Split down',
notRepo: 'This directory is not a git repository',
noChanges: 'No changes',
stage: 'Stage',
unstage: 'Unstage',
stageAll: 'Stage all',
unstageAll: 'Unstage all',
commitPlaceholder: 'Commit message (Ctrl+Enter)',
commit: 'Commit',
commitError: 'Commit failed',
branch: 'Branch',
checkoutError: 'Branch switch failed',
history: 'History',
changes: 'Changes',
staged: 'Staged',
unstaged: 'Unstaged',
produced: 'Produced',
producedOpen: 'Open in sidebar',
disconnected: 'Terminal disconnected, reconnecting…',
exited: 'Terminal process exited',
noSession: 'Select a conversation to use the sidebar',
hiddenFiles: 'Hidden files',
parent: 'Parent directory',
copied: 'Copied',
copy: 'Copy',
newFile: 'New file',
openEditor: 'Open editor',
gitDetail: 'View change details',
referenceFile: '@file',
copyRelative: 'Copy relative path',
copyAbsolute: 'Copy absolute path',
download: 'Download',
settingsNav: 'Side card',
settingsOpenTitle: 'Open by default for new conversations',
settingsOpenDesc: 'Expand the side card automatically for brand-new conversations; existing conversations keep their own layouts',
settingsWidthTitle: 'Default width share',
settingsWidthDesc: 'The side card\'s default share of the window width for new conversations (20–60)',
settingsWidthSuffix: '%',
settingsSaveFailed: 'Failed to save',
settingsConflict: 'The setting changed in another window — please retry',
}
const DICTS: Record<string, Record<string, string>> = { zh, en }
/** Translate a copy key in the browser's language (zh → zh, else en). */
export type CopyKey = keyof typeof zh
/** The currently resolved active locale code ('zh' default). */
let activeLocale = 'zh'
const listeners = new Set<() => void>()
function notify(): void {
for (const fn of listeners) fn()
export function t(key: CopyKey): string {
const lang = typeof navigator !== 'undefined' ? navigator.language : 'en'
const dict = lang.toLowerCase().startsWith('zh') ? zh : en
return dict[key]
}
/** The active locale snapshot (uSES-safe: returns the same code string). */
export function getActiveLocale(): string {
return activeLocale
}
export function subscribeLocale(fn: () => void): () => void {
listeners.add(fn)
return () => {
listeners.delete(fn)
}
}
/**
* Re-render the calling component whenever the active locale changes.
* Returns the active locale code; components re-resolve `t(key)` against it.
*/
export function useApesLocale(): string {
return useSyncExternalStore(subscribeLocale, getActiveLocale, getActiveLocale)
}
/** Translate a key in the active locale; falls back to en, then the key. */
export function t(key: string): string {
const dict = DICTS[activeLocale] ?? en
return dict[key] ?? en[key] ?? key
}
/**
* Register the dictionaries into the locale runtime AND subscribe to its
* active-locale changes, keeping {@link activeLocale} in sync. Returns the
* combined disposer (locale registrations + the snapshot subscription).
*/
export function registerLocales(locale: ApesLocaleService | undefined): () => void {
if (!locale) return () => undefined
const disposes = [
locale.register(NS, 'zh', zh),
locale.register(NS, 'en', en),
]
let unsub: (() => void) | undefined
try {
// Seed the active locale from the runtime snapshot (uSES-safe read).
activeLocale = locale.getSnapshot().active || 'zh'
unsub = locale.subscribe(() => {
const next = locale.getSnapshot().active || 'en'
if (next !== activeLocale) {
activeLocale = next
notify()
}
})
} catch {
// Locale runtime unavailable (very early boot): stay on the default.
}
return () => {
unsub?.()
for (const d of disposes) {
try {
d()
} catch {
// Disposer already run (double-dispose) — ignore.
}
}
}
/** Whether the browser language is Chinese (used for selectors). */
export function isZh(): boolean {
return typeof navigator !== 'undefined' && navigator.language.toLowerCase().startsWith('zh')
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Path projection helpers shared by the explorer rows: a path relative to
* the session cwd (for the @-reference button and "copy relative path").
* The fs-tree joins with '/' even on Windows, so both separators normalize
* to '/' before comparison.
*/
/**
* The path relative to the session's working directory.
* @param cwd - the explorer root (absolute).
* @param path - an absolute entry path from the fs-tree.
* @returns the relative path with '/' separators ('.' for the cwd itself),
* or `path` unchanged when it lies outside the cwd.
*
* The prefix test is case-insensitive: Windows paths (and macOS's
* case-insensitive volumes) may arrive with different casing than the cwd
* row, and the containment decision must not depend on it. The returned
* relative text keeps the caller's own casing.
*/
export function relativeTo(cwd: string, path: string): string {
const base = cwd.replace(/[\\/]+$/, '')
const norm = (value: string): string => value.replace(/\\/g, '/')
const nBase = norm(base)
const nPath = norm(path)
if (nPath === nBase) return '.'
if (nPath.toLowerCase().startsWith(`${nBase.toLowerCase()}/`)) return nPath.slice(nBase.length + 1)
return path
}
+58
View File
@@ -0,0 +1,58 @@
/**
* Client-side read of the user-facing "Side card" preferences. The host owns
* the namespace through the settings seam (in-process); the DSH settings RPC
* domain only serves allowlisted namespaces to configuration clients, so the
* client reads and writes THIS namespace through the plugin's own fenced
* /sidebar routes instead (api.settingsGet/settingsUpdate). Any failure
* (route rejected, namespace absent, a field of the wrong type, a value out
* of the contract range) falls back to the schema defaults — the side card
* must keep working exactly as composed when the settings surface is missing.
*/
import type { api } from './api.ts'
import {
clampWidthPercent,
SIDEBAR_PREFS_DEFAULTS,
type SidebarPrefs,
} from '../prefs-shared.ts'
export { SIDEBAR_PREFS_DEFAULTS, clampWidthPercent }
export type { SidebarPrefs }
/** The settings wire face the preferences need (a subset of the plugin api). */
export type SidebarSettingsClient = Pick<typeof api, 'settingsGet' | 'settingsUpdate'>
/**
* Validate one raw resolved value into {@link SidebarPrefs}. Used for the
* settings.get payload AND the settings.update response (both carry the
* layered resolved value); any malformed field falls back to its default.
* @param value - the raw resolved section from the settings wire.
* @returns validated prefs (always well-formed).
*/
export function parsePrefs(value: unknown): SidebarPrefs {
if (value === null || typeof value !== 'object') return { ...SIDEBAR_PREFS_DEFAULTS }
const record = value as Record<string, unknown>
return {
openByDefault: typeof record.openByDefault === 'boolean'
? record.openByDefault
: SIDEBAR_PREFS_DEFAULTS.openByDefault,
defaultWidthPercent: typeof record.defaultWidthPercent === 'number' && Number.isFinite(record.defaultWidthPercent)
? clampWidthPercent(record.defaultWidthPercent)
: SIDEBAR_PREFS_DEFAULTS.defaultWidthPercent,
}
}
/**
* Read the resolved side card preferences through the plugin's settings route.
* @param settings - the settings wire face (the plugin api by default).
* @returns validated prefs, or the schema defaults when the route rejects,
* the namespace is absent, or a stored value violates the contract.
*/
export async function loadPrefs(settings: SidebarSettingsClient): Promise<SidebarPrefs> {
try {
const view = await settings.settingsGet()
return parsePrefs(view.value)
} catch {
// Transport/fence rejection or a malformed response: keep the defaults.
return { ...SIDEBAR_PREFS_DEFAULTS }
}
}
+87
View File
@@ -0,0 +1,87 @@
/**
* Pure derivation of one turn's produced files from finalized conversation
* nodes — a structural replica of ui-deliverables' `producedForClosing`
* (the mutation tools' follow-along `locations`, by render intent: a diff
* card or a generic edit card; reads/deletes/failures produce nothing).
* Kept dependency-free so the takeover logic is unit-testable and the
* replica is easy to diff against upstream when it drifts.
*/
/** Paths a tool-result view reports as produced, by render intent. */
export function producedPaths(view: unknown): readonly string[] {
if (view === null || typeof view !== 'object') return []
const record = view as { card?: unknown; kind?: unknown; locations?: unknown }
const isEdit = record.card === 'diff' || (record.card === 'generic' && record.kind === 'edit')
if (!isEdit) return []
if (!Array.isArray(record.locations)) return []
const paths: string[] = []
for (const location of record.locations) {
if (location !== null && typeof location === 'object' && typeof (location as { path?: unknown }).path === 'string') {
paths.push((location as { path: string }).path)
}
}
return paths
}
/**
* Files produced by the turn the assistant at `seq` closes. Accumulation
* resets on turn boundaries (a user message, or a node reporting a different
* turn number); paths keep first-seen order and appear once.
* @param nodes - snapshot nodes in surface order (structural, unknown-safe).
* @param seq - the closing assistant's seq (the render site's anchor).
* @returns produced paths; empty when the turn wrote nothing.
*/
export function producedForClosing(nodes: readonly unknown[], seq: number): readonly string[] {
let pending: string[] = []
let seen = new Set<string>()
let turn: number | undefined
for (const node of nodes) {
if (node === null || typeof node !== 'object') continue
const record = node as { kind?: unknown; isError?: unknown; callView?: unknown; turn?: unknown; seq?: unknown }
if (record.kind === 'tool-result') {
if (record.isError === true) continue
for (const path of producedPaths(record.callView)) {
if (seen.has(path)) continue
seen.add(path)
pending.push(path)
}
continue
}
if (record.kind === 'user') {
turn = undefined
pending = []
seen = new Set()
} else if (typeof record.turn === 'number') {
if (turn !== undefined && record.turn !== turn) {
pending = []
seen = new Set()
}
turn = record.turn
}
if (record.kind === 'assistant' && record.seq === seq) return pending
}
return []
}
/**
* Claim the turn-tail chain only when the closing turn produced files.
* @param owner - the turn-tail owner currency ({nodes, seq}).
* @returns produced paths as the matched value, or null to decline.
*/
export function selectProducedFiles(owner: unknown): readonly string[] | null {
const record = owner as { nodes?: unknown; seq?: unknown } | null
if (record === null || typeof record !== 'object') return null
if (!Array.isArray(record.nodes) || typeof record.seq !== 'number') return null
const paths = producedForClosing(record.nodes, record.seq)
return paths.length === 0 ? null : paths
}
/** Resolve a (possibly relative) path against the session cwd for the sidebar. */
export function resolveSidebarPath(cwd: string | undefined, path: string): string {
const absolute = path.startsWith('/') || /^[A-Za-z]:[\\/]/.test(path)
if (absolute) return path
const base = cwd ?? ''
if (base === '') return path
const separator = base.includes('\\') ? '\\' : '/'
return `${base.replace(/[\\/]+$/, '')}${separator}${path}`
}
File diff suppressed because it is too large Load Diff
+269
View File
@@ -0,0 +1,269 @@
/**
* The split-pane workbench: renders the recursive split tree. A split lays
* children out row- or column-wise with draggable dividers (fractional
* sizes); a leaf renders its tab strip plus the active tab's content.
*
* Splitting is VSCode-style DRAG-TO-EDGE, not buttons: while dragging a tab
* over a pane, a drop overlay shows five zones — four edges (left/right/up/
* down) that split the pane with the tab in a fresh leaf, and the center
* that merges the tab into the pane. The tree and all operations live in
* state.ts; this file is pure presentation over them.
*/
import { Fragment, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import clsx from 'clsx'
import type { SidebarState, SidebarTab, SplitNode } from './state.ts'
import type { DropZone } from './state.ts'
import { TabBar, type NewTabOption, parseDrag, type TabDragPayload } from './TabBar.tsx'
import { tabTypeIcon } from './icons.tsx'
import css from './sidebar.module.css'
/** Actions the workbench needs (bound to the store by the sidebar shell). */
export interface WorkbenchActions {
closeTab: (paneId: string, tabId: string) => void
activateTab: (paneId: string, tabId: string) => void
/** Make a pane the target of newly opened tabs (click focus). */
focusPane: (paneId: string) => void
/** VSCode drag gesture: edge → split the target pane, center → merge. */
moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => void
/** Reorder within a pane (drop onto another tab inserts before it). */
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => void
resizeSplit: (splitId: string, index: number, deltaFrac: number) => void
}
/** One divider: pointer-capture drag translating px deltas into fractions.
* Deltas are incremental — each move reports the displacement since the
* previous move — because the store adds every reported delta to the pane
* sizes; a cumulative (since-pointer-down) delta would be re-added on each
* move and the divider would run away from the cursor. */
function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => void }) {
const { dir, onResize } = props
// `last` is the previous pointer position while dragging; `size` is the
// container size at pointer-down (constant during the drag), used to
// normalize the px delta into a fraction.
const last = useRef({ x: 0, y: 0, size: 0 })
const [dragging, setDragging] = useState(false)
return (
<div
className={clsx(css.divider, dir === 'row' ? css.dividerRow : css.dividerCol, dragging && css.dividerActive)}
onPointerDown={(event) => {
event.preventDefault()
event.currentTarget.setPointerCapture(event.pointerId)
const box = event.currentTarget.parentElement?.getBoundingClientRect()
last.current = {
x: event.clientX,
y: event.clientY,
size: box === undefined ? 1 : (dir === 'row' ? box.width : box.height),
}
setDragging(true)
}}
onPointerMove={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
const delta = dir === 'row' ? event.clientX - last.current.x : event.clientY - last.current.y
onResize(delta / Math.max(1, last.current.size))
last.current.x = event.clientX
last.current.y = event.clientY
}}
onPointerUp={(event) => {
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
event.currentTarget.releasePointerCapture(event.pointerId)
setDragging(false)
}}
/>
)
}
/** Map a pointer position inside a pane to the VSCode drop zone (25% edges). */
function zoneAt(event: React.DragEvent, pane: HTMLElement): DropZone {
const rect = pane.getBoundingClientRect()
if (rect.width === 0 || rect.height === 0) return 'center'
const x = (event.clientX - rect.left) / rect.width
const y = (event.clientY - rect.top) / rect.height
if (x < 0.25) return 'left'
if (x > 0.75) return 'right'
if (y < 0.25) return 'up'
if (y > 0.75) return 'down'
return 'center'
}
/** The icon of one openable type card (mirror of the + menu options). */
/**
* An empty pane's welcome cards: the openable types as cards, clicked to
* open (instead of a bare "this pane is empty" message).
*/
function PaneEmptyCards(props: {
newTabOptions: NewTabOption[]
onNewTab: (optionId: string) => void
}) {
const { newTabOptions, onNewTab } = props
return (
<div className={css.paneEmptyCards}>
{newTabOptions.map(option => (
<button
key={option.id}
type="button"
className={css.paneCard}
disabled={option.disabled === true}
title={option.label}
onClick={() => { onNewTab(option.id) }}
>
{option.icon ?? tabTypeIcon(option.id as SidebarTab['type'], 16)}
<span>{option.label}</span>
</button>
))}
</div>
)
}
/** A leaf: tab strip + active content + VSCode-style drop target for tabs. */
function LeafView(props: {
leaf: { id: string; tabs: SidebarTab[]; active: string | null }
newTabOptions: NewTabOption[]
actions: WorkbenchActions
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab) => ReactNode
}) {
const { leaf, newTabOptions, actions, onNewTab, renderTab } = props
const [dropZone, setDropZone] = useState<DropZone | null>(null)
const activeTab = leaf.tabs.find(tab => tab.id === leaf.active) ?? leaf.tabs[leaf.tabs.length - 1]
return (
<div
className={clsx(css.pane, dropZone !== null && css.paneDrop)}
onPointerDown={() => { actions.focusPane(leaf.id) }}
onDragOver={(event) => {
event.preventDefault()
const zone = zoneAt(event, event.currentTarget)
setDropZone(zone)
}}
onDragLeave={(event) => {
// Only clear when the pointer left the pane entirely (not onto a child).
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) {
setDropZone(null)
}
}}
onDrop={(event) => {
event.preventDefault()
const zone = dropZone ?? zoneAt(event, event.currentTarget)
setDropZone(null)
const payload = parseDrag(event.dataTransfer.getData('application/x-dsh-tab'))
if (payload !== null) actions.moveTabToEdge(payload, leaf.id, zone)
}}
>
{dropZone !== null && <div className={clsx(css.dropOverlay, css[`drop${dropZone[0]!.toUpperCase()}${dropZone.slice(1)}`])} />}
{/*
The tab strip renders even for an empty pane: the + menu must stay
reachable when the pane has no tabs (fresh split, or the last tab was
dragged out), so a new tab can always be created or dragged in.
*/}
<TabBar
paneId={leaf.id}
tabs={leaf.tabs}
active={leaf.active}
onActivate={(tabId) => { actions.activateTab(leaf.id, tabId) }}
onClose={(tabId) => { actions.closeTab(leaf.id, tabId) }}
onNewTab={onNewTab}
newTabOptions={newTabOptions}
onDropTab={(payload, before) => {
if (before === null) actions.moveTabToEdge(payload, leaf.id, 'center')
else actions.moveTabBefore(payload, leaf.id, before)
}}
/>
{leaf.tabs.length > 0 ? (
/*
Every tab stays MOUNTED (inactive ones hidden), so switching tabs
never tears down the content: a terminal keeps its pty connection
and scrollback, an editor keeps its CodeMirror view and unsaved
draft, explorer/git keep their loaded data. The unmount (and the
terminal's close frame) happens only when a tab is truly closed.
*/
<div className={css.paneContent}>
{leaf.tabs.map(tab => (
<div
key={tab.id}
className={clsx(css.paneTab, tab.id !== activeTab?.id && css.paneTabHidden)}
>
{renderTab(tab)}
</div>
))}
</div>
) : (
<PaneEmptyCards newTabOptions={newTabOptions} onNewTab={onNewTab} />
)}
</div>
)
}
/** Recursive node renderer. */
function NodeView(props: {
node: SplitNode
state: SidebarState
newTabOptions: NewTabOption[]
actions: WorkbenchActions
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab) => ReactNode
}) {
const { node, state, newTabOptions, actions, onNewTab, renderTab } = props
if (node.kind === 'leaf') {
return (
<LeafView
leaf={node}
newTabOptions={newTabOptions}
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
/>
)
}
const isRow = node.dir === 'row'
return (
<div className={clsx(css.split, isRow ? css.splitRow : css.splitCol)}>
{node.children.map((child, index) => (
<Fragment key={child.id}>
{index > 0 && (
<Divider
dir={node.dir}
onResize={(deltaFrac) => { actions.resizeSplit(node.id, index - 1, deltaFrac) }}
/>
)}
<div
className={css.splitChild}
style={{ flexGrow: node.sizes[index], flexBasis: 0, minWidth: 0, minHeight: 0 }}
>
<NodeView
node={child}
state={state}
newTabOptions={newTabOptions}
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
/>
</div>
</Fragment>
))}
</div>
)
}
/** The workbench: the split tree filling the sidebar body. */
export function Workbench(props: {
state: SidebarState
newTabOptions: NewTabOption[]
actions: WorkbenchActions
onNewTab: (optionId: string) => void
renderTab: (tab: SidebarTab) => ReactNode
}) {
const { state, newTabOptions, actions, onNewTab, renderTab } = props
return (
<div className={css.workbench}>
<NodeView
node={state.splits}
state={state}
newTabOptions={newTabOptions}
actions={actions}
onNewTab={onNewTab}
renderTab={renderTab}
/>
</div>
)
}
+658
View File
@@ -0,0 +1,658 @@
/**
* Per-session sidebar state: the panel geometry, the split-pane workbench
* tree, open tabs, and the explorer expansion set. One state instance per
* conversation id, persisted to localStorage under `dsh-sidebar:v1:<id>` so
* a reload restores the exact layout of the session it belongs to — switching
* conversations swaps the whole state (memory + isolation).
*
* The split tree is a recursive structure: a leaf holds a tab group, a split
* divides the space row- or column-wise with fractional sizes. All tree
* operations are pure functions over the node, unit-tested in tests/state.spec.ts.
*/
import { SIDEBAR_PREFS_DEFAULTS, type SidebarPrefs } from '../prefs-shared.ts'
export type TabType = 'explorer' | 'git' | 'editor' | 'terminal'
/** One open tab. `path` carries the file (editor) or is absent (explorer/git). */
export interface SidebarTab {
id: string
type: TabType
title: string
path?: string
}
/** A tab group. */
export interface SidebarLeaf {
kind: 'leaf'
id: string
tabs: SidebarTab[]
active: string | null
}
/** A recursive split between child panes (fractional sizes summing to 1). */
export interface SidebarSplit {
kind: 'split'
id: string
dir: 'row' | 'col'
sizes: number[]
children: SplitNode[]
}
export type SplitNode = SidebarLeaf | SidebarSplit
/** The full per-session state. */
export interface SidebarState {
panelOpen: boolean
width: number
/** The pane receiving newly opened tabs (last pane the user touched). */
activePane: string | null
/** Monotonic terminal tab counter (ids survive reloads). */
nextTerminal: number
/** Explorer expansion set (absolute directory paths). */
expanded: string[]
splits: SplitNode
}
export const PANEL_MIN = 280
export const PANEL_MAX = 640
export const PANEL_DEFAULT = 400
export const TAB_MAX_WIDTH = 160
export const TERMINAL_LIMIT = 3
let nextIdCounter = 0
/** Unique pane/tab id within one state instance. */
function uid(prefix: string): string {
nextIdCounter += 1
return `${prefix}:${nextIdCounter}`
}
/**
* The largest numeric suffix across a raw persisted state's counter ids
* (`pane:N` / `tab:N` / `split:N`). The uid counter is module-global and
* resets on every reload, so a split minted AFTER a reload would collide
* with the persisted ids (a fresh "pane:1" beside the persisted "pane:1");
* mapLeaf would then visit BOTH leaves and every open would land in both
* panes of the split. Seeding the counter past the persisted ids keeps
* fresh ids disjoint.
*/
function maxCounterId(parsed: unknown): number {
let max = 0
const consider = (id: unknown): void => {
if (typeof id !== 'string') return
const match = /^(?:pane|tab|split):(\d+)$/.exec(id)
if (match !== null) max = Math.max(max, Number(match[1]))
}
const walk = (node: unknown): void => {
if (node === null || typeof node !== 'object') return
const record = node as Record<string, unknown>
consider(record.id)
if (Array.isArray(record.tabs)) {
for (const tab of record.tabs) {
if (tab !== null && typeof tab === 'object') consider((tab as Record<string, unknown>).id)
}
}
if (Array.isArray(record.children)) {
for (const child of record.children) walk(child)
}
}
walk((parsed as Record<string, unknown> | null)?.splits)
return max
}
/** A fresh default state: one explorer tab in one pane, open per the caller's
* preference. `width` is the caller's preferred panel width (default
* PANEL_DEFAULT) and `panelOpen` whether the panel starts expanded (default
* true); the store seeds new sessions from the user's side card prefs. */
export function makeDefaultState(width = PANEL_DEFAULT, panelOpen = true): SidebarState {
const leaf: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
leaf.tabs = [{ id: uid('tab'), type: 'explorer', title: 'Explorer' }]
leaf.active = leaf.tabs[0]!.id
return {
panelOpen,
width,
activePane: leaf.id,
nextTerminal: 1,
expanded: [],
splits: leaf,
}
}
/** Walk the tree and apply `visit` to the leaf with the given id. */
export function mapLeaf(node: SplitNode, paneId: string, visit: (leaf: SidebarLeaf) => void): SplitNode {
if (node.kind === 'leaf') {
if (node.id === paneId) {
const copy: SidebarLeaf = { ...node, tabs: [...node.tabs] }
visit(copy)
return copy
}
return node
}
const split = node
return {
...split,
sizes: [...split.sizes],
children: split.children.map(child => mapLeaf(child, paneId, visit)),
}
}
/** The first leaf of the tree (fallback pane when activePane is gone). */
export function firstLeaf(node: SplitNode): SidebarLeaf {
if (node.kind === 'leaf') return node
return firstLeaf(node.children[0]!)
}
/** Find the leaf containing a tab id, if any. */
export function leafWithTab(node: SplitNode, tabId: string): SidebarLeaf | undefined {
if (node.kind === 'leaf') {
return node.tabs.some(tab => tab.id === tabId) ? node : undefined
}
for (const child of node.children) {
const found = leafWithTab(child, tabId)
if (found !== undefined) return found
}
return undefined
}
/** All leaves of the tree, depth-first. */
export function allLeaves(node: SplitNode): SidebarLeaf[] {
if (node.kind === 'leaf') return [node]
return node.children.flatMap(allLeaves)
}
/** Whether a tab exists anywhere in a state tree (any pane). */
export function tabOpenIn(state: SidebarState, tabId: string): boolean {
return allLeaves(state.splits).some(leaf => leaf.tabs.some(tab => tab.id === tabId))
}
/** Replace a leaf with a split of it plus a fresh empty leaf. */
export function splitLeafAt(node: SplitNode, paneId: string, dir: 'row' | 'col'): SplitNode {
const fresh: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [], active: null }
return mapLeaf(node, paneId, (leaf) => {
const target: SidebarLeaf = { ...leaf }
const split: SidebarSplit = {
kind: 'split',
id: uid('split'),
dir,
sizes: [0.5, 0.5],
children: [target, fresh],
}
Object.assign(leaf, split)
})
}
/**
* Split a leaf by inserting a fresh leaf holding `tab` beside it — the
* VSCode drag-to-edge gesture. `dir` is the split direction ('row' for
* left/right, 'col' for up/down); `front` places the new leaf first (left/
* up) or second (right/down).
* @returns the new tree plus the fresh leaf's id (the drop's active pane).
*/
export function insertLeafAt(
node: SplitNode,
paneId: string,
dir: 'row' | 'col',
tab: SidebarTab,
front: boolean,
): { node: SplitNode; leafId: string } {
const fresh: SidebarLeaf = { kind: 'leaf', id: uid('pane'), tabs: [tab], active: tab.id }
const leafId = fresh.id
const next = mapLeaf(node, paneId, (leaf) => {
const target: SidebarLeaf = { ...leaf }
const split: SidebarSplit = {
kind: 'split',
id: uid('split'),
dir,
sizes: [0.5, 0.5],
children: front ? [fresh, target] : [target, fresh],
}
Object.assign(leaf, split)
})
return { node: next, leafId }
}
/** Where a tab drop lands on a pane: an edge creates a split, center merges. */
export type DropZone = 'left' | 'right' | 'up' | 'down' | 'center'
/**
* The VSCode drag gesture: move a tab out of its pane and either merge it
* into the target pane (center) or split the target pane with the tab in a
* fresh leaf (edge). The source pane collapses when it empties.
*/
export function moveTabToEdge(
state: SidebarState,
fromPane: string,
tabId: string,
toPane: string,
zone: DropZone,
): SidebarState {
if (fromPane === toPane && zone === 'center') {
// Dropped back onto its own pane's center: reorder to the end.
return moveTab(state, fromPane, tabId, toPane, -1)
}
const source = leafWithTab(state.splits, tabId)
if (source === undefined) return state
const tab = source.tabs.find(candidate => candidate.id === tabId)!
let emptied = false
let splits = mapLeaf(state.splits, source.id, (leaf) => {
leaf.tabs = leaf.tabs.filter(candidate => candidate.id !== tabId)
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
if (leaf.tabs.length === 0) emptied = true
})
if (emptied) splits = removeLeafAt(splits, source.id)
if (zone === 'center') {
splits = mapLeaf(splits, toPane, (leaf) => {
leaf.tabs = [...leaf.tabs, tab]
leaf.active = tab.id
})
return { ...state, splits, activePane: toPane }
}
const dir = zone === 'left' || zone === 'right' ? 'row' : 'col'
const result = insertLeafAt(splits, toPane, dir, tab, zone === 'left' || zone === 'up')
return { ...state, splits: result.node, activePane: result.leafId }
}
/**
* Remove a leaf from the tree. A split left with one child promotes that
* child; removing the last leaf yields an empty leaf.
*/
export function removeLeafAt(node: SplitNode, paneId: string): SplitNode {
if (node.kind === 'leaf') return node.id === paneId ? { ...node, tabs: [], active: null } : node
const children = node.children.filter(child => !(child.kind === 'leaf' && child.id === paneId))
if (children.length === node.children.length) {
return {
...node,
sizes: [...node.sizes],
children: node.children.map(child => removeLeafAt(child, paneId)),
}
}
if (children.length === 1) return children[0]!
return { ...node, sizes: [...node.sizes], children }
}
/** Close a tab; an emptied leaf is removed (unless it is the only pane). */
export function closeTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
let emptied = false
const splits = mapLeaf(state.splits, paneId, (leaf) => {
leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
if (leaf.tabs.length === 0) emptied = true
})
return { ...state, splits: emptied ? removeLeafAt(splits, paneId) : splits }
}
/** Activate a tab in its pane. */
export function activateTab(state: SidebarState, paneId: string, tabId: string): SidebarState {
return {
...state,
activePane: paneId,
splits: mapLeaf(state.splits, paneId, (leaf) => {
if (leaf.tabs.some(tab => tab.id === tabId)) leaf.active = tabId
}),
}
}
/** Whether a tab type is single-instance per session (explorer/git). */
function isSingle(type: TabType): boolean {
return type === 'explorer' || type === 'git'
}
/**
* Open a tab (or focus its existing instance). Single-instance types focus
* the existing tab wherever it lives; editors dedupe by path. The tab lands
* in the active pane (or the first pane when the active one is gone).
*/
export function openTab(state: SidebarState, tab: SidebarTab): SidebarState {
let targetId = state.activePane ?? firstLeaf(state.splits).id
// A stale activePane (its pane was closed since) must not swallow the
// open: fall back to the first pane instead of dropping the tab.
if (!allLeaves(state.splits).some(leaf => leaf.id === targetId)) {
targetId = firstLeaf(state.splits).id
}
if (isSingle(tab.type)) {
for (const leaf of allLeaves(state.splits)) {
const existing = leaf.tabs.find(candidate => candidate.type === tab.type)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
}
if (tab.type === 'editor' && tab.path !== undefined) {
for (const leaf of allLeaves(state.splits)) {
const existing = leaf.tabs.find(candidate => candidate.type === 'editor' && candidate.path === tab.path)
if (existing !== undefined) return activateTab(state, leaf.id, existing.id)
}
}
return {
...state,
activePane: targetId,
splits: mapLeaf(state.splits, targetId, (leaf) => {
leaf.tabs = [...leaf.tabs, tab]
leaf.active = tab.id
}),
}
}
/** Move a tab from one pane to another (insert at index; -1 appends). */
export function moveTab(state: SidebarState, fromPane: string, tabId: string, toPane: string, index = -1): SidebarState {
let moved: SidebarTab | undefined
let emptied = false
let splits = mapLeaf(state.splits, fromPane, (leaf) => {
const found = leaf.tabs.find(tab => tab.id === tabId)
if (found === undefined) return
moved = found
leaf.tabs = leaf.tabs.filter(tab => tab.id !== tabId)
if (leaf.active === tabId) leaf.active = leaf.tabs[leaf.tabs.length - 1]?.id ?? null
if (leaf.tabs.length === 0) emptied = true
})
if (moved === undefined) return state
if (emptied) splits = removeLeafAt(splits, fromPane)
splits = mapLeaf(splits, toPane, (leaf) => {
const insertAt = index >= 0 && index <= leaf.tabs.length ? index : leaf.tabs.length
leaf.tabs = [...leaf.tabs.slice(0, insertAt), moved!, ...leaf.tabs.slice(insertAt)]
leaf.active = moved!.id
})
return { ...state, splits, activePane: toPane }
}
/** Split the active pane (or the pane containing the active tab). */
export function splitPane(state: SidebarState, dir: 'row' | 'col'): SidebarState {
const paneId = state.activePane ?? firstLeaf(state.splits).id
return { ...state, splits: splitLeafAt(state.splits, paneId, dir) }
}
/** Toggle the panel open/closed (opening restores the previous layout). */
export function togglePanel(state: SidebarState): SidebarState {
return { ...state, panelOpen: !state.panelOpen }
}
/** Set the panel width (clamped to the contract range; the upper bound is
* the viewport so the fullscreen expansion can fill the window). */
export function setWidth(state: SidebarState, width: number): SidebarState {
const max = typeof window !== 'undefined' ? Math.max(PANEL_MIN, window.innerWidth) : PANEL_MAX
return { ...state, width: Math.min(max, Math.max(PANEL_MIN, Math.round(width))) }
}
/** Toggle a directory in the explorer expansion set. */
export function toggleExpanded(state: SidebarState, path: string): SidebarState {
const expanded = state.expanded.includes(path)
? state.expanded.filter(item => item !== path)
: [...state.expanded, path]
return { ...state, expanded }
}
/** Adjust one split divider: `i` is the left/top child index, delta in fractions. */
export function resizeSplit(node: SplitNode, splitId: string, index: number, delta: number): SplitNode {
if (node.kind === 'leaf') return node
if (node.id === splitId) {
const sizes = [...node.sizes]
const left = Math.min(0.92, Math.max(0.08, sizes[index]! + delta))
const right = Math.min(0.92, Math.max(0.08, sizes[index + 1]! - delta))
sizes[index] = left
sizes[index + 1] = right
return { ...node, sizes }
}
return {
...node,
sizes: [...node.sizes],
children: node.children.map(child => resizeSplit(child, splitId, index, delta)),
}
}
// ── The per-session store ──────────────────────────────────────────────────
const STORAGE_PREFIX = 'dsh-sidebar:v1'
/** Immutable snapshot handed to React (replaced only on real changes). */
export interface SidebarSnapshot {
sessionId: string | undefined
state: SidebarState | undefined
}
/** Default panel width for one viewport: the prefs percent of the window,
* clamped to the panel floor (a tiny percent must stay usable) and to the
* viewport (a large one must never cover the whole window). */
export function defaultWidthFor(viewport: number, percent: number): number {
return Math.min(viewport, Math.max(PANEL_MIN, Math.round(viewport * percent / 100)))
}
function loadState(sessionId: string, prefs: SidebarPrefs): SidebarState {
try {
const raw = localStorage.getItem(`${STORAGE_PREFIX}:${sessionId}`)
if (raw !== null) {
const parsed = JSON.parse(raw) as unknown
// Seed the uid counter past the persisted ids (it resets on reload);
// sanitize re-ids any duplicates the pre-seeding counter left behind.
nextIdCounter = maxCounterId(parsed)
const sanitized = sanitizeState(parsed)
if (sanitized !== undefined) return sanitized
}
} catch {
// Corrupt or unavailable storage: fall through to the default.
}
// New sessions seed from the user's side card prefs: the width is the
// chosen percent of the window (clamped to the panel floor and the
// viewport so a huge percent can never crush the app shell), and the
// panel starts open only when the preference says so.
const viewport = typeof window !== 'undefined' ? window.innerWidth : undefined
const width = viewport === undefined
? PANEL_DEFAULT
: defaultWidthFor(viewport, prefs.defaultWidthPercent)
return makeDefaultState(width, prefs.openByDefault)
}
/**
* Structural validation of one persisted state. A malformed or stale shape
* (older layouts, hand-edited storage) must fall back to the default instead
* of crashing the panel on every reload; the restored width is also clamped
* to the current viewport so a stale fullscreen width can never crush the
* app shell (margin-right larger than the window) or cover the whole screen.
* @returns a clean state, or undefined to fall back to the default.
*/
export function sanitizeState(parsed: unknown): SidebarState | undefined {
if (parsed === null || typeof parsed !== 'object') return undefined
const record = parsed as Record<string, unknown>
if (typeof record.panelOpen !== 'boolean') return undefined
if (typeof record.width !== 'number' || !Number.isFinite(record.width)) return undefined
if (typeof record.nextTerminal !== 'number' || !Number.isInteger(record.nextTerminal) || record.nextTerminal < 1) {
return undefined
}
if (typeof record.activePane !== 'string' && record.activePane !== null) return undefined
if (!Array.isArray(record.expanded) || record.expanded.some(item => typeof item !== 'string')) return undefined
const reid = new Map<string, string>()
const splits = sanitizeNode(record.splits, new Set(), reid)
if (splits === undefined) return undefined
const maxWidth = typeof window !== 'undefined' ? window.innerWidth : Infinity
return {
panelOpen: record.panelOpen,
width: Math.max(PANEL_MIN, Math.min(record.width, maxWidth)),
// A stale duplicate pane id may have been re-ided; follow the rename so
// new tabs still land in the pane the user was using.
activePane: typeof record.activePane === 'string' ? (reid.get(record.activePane) ?? record.activePane) : null,
nextTerminal: record.nextTerminal,
expanded: record.expanded as string[],
splits,
}
}
/**
* One tree node id, deduplicated against the ids already seen in this
* state. Duplicates are exactly the pre-seeding counter-reset corruption
* (a "pane:1"/"split:1" minted after a reload beside the persisted ones):
* keeping both would make mapLeaf visit two leaves at once and every open
* would land in both panes, so the repeat gets a fresh id.
* @returns the id to use (the original, or a fresh uid for repeats).
*/
function uniqueNodeId(id: string, seen: Set<string>, reid: Map<string, string>): string {
if (!seen.has(id)) {
seen.add(id)
return id
}
const prefix = /^split:\d+$/.test(id) ? 'split' : 'pane'
const fresh = uid(prefix)
seen.add(fresh)
reid.set(id, fresh)
return fresh
}
/** Validate one split-tree node (leaf or split) and rebuild it cleanly. */
function sanitizeNode(node: unknown, seen: Set<string>, reid: Map<string, string>): SplitNode | undefined {
if (node === null || typeof node !== 'object') return undefined
const record = node as Record<string, unknown>
if (record.kind === 'leaf') {
if (typeof record.id !== 'string' || !Array.isArray(record.tabs)) return undefined
const tabs: SidebarTab[] = []
for (const tab of record.tabs) {
if (tab === null || typeof tab !== 'object') return undefined
const candidate = tab as Record<string, unknown>
if (typeof candidate.id !== 'string' || typeof candidate.title !== 'string') return undefined
if (
candidate.type !== 'explorer' && candidate.type !== 'git'
&& candidate.type !== 'editor' && candidate.type !== 'terminal'
) {
return undefined
}
tabs.push({
id: candidate.id,
type: candidate.type,
title: candidate.title,
...(typeof candidate.path === 'string' ? { path: candidate.path } : {}),
})
}
const active = typeof record.active === 'string' ? record.active : null
if (active !== null && !tabs.some(tab => tab.id === active)) return undefined
return { kind: 'leaf', id: uniqueNodeId(record.id, seen, reid), tabs, active }
}
if (record.kind === 'split') {
if (typeof record.id !== 'string' || (record.dir !== 'row' && record.dir !== 'col')) return undefined
if (!Array.isArray(record.children) || !Array.isArray(record.sizes)) return undefined
const children: SplitNode[] = []
for (const child of record.children) {
const clean = sanitizeNode(child, seen, reid)
if (clean === undefined) return undefined
children.push(clean)
}
if (children.length < 2) return undefined
if (
record.sizes.length !== children.length
|| record.sizes.some(size => typeof size !== 'number' || !Number.isFinite(size) || size <= 0)
) {
return undefined
}
return { kind: 'split', id: uniqueNodeId(record.id, seen, reid), dir: record.dir, sizes: record.sizes as number[], children }
}
return undefined
}
/** The session-scoped store: one state per conversation, localStorage-backed. */
export class SidebarStore {
private readonly bySession = new Map<string, SidebarState>()
private snapshot: SidebarSnapshot = { sessionId: undefined, state: undefined }
private readonly listeners = new Set<() => void>()
private persistTimer: number | undefined
/** User-facing side card prefs seeding brand-new session states (defaults until the settings RPC resolves). */
private prefs: SidebarPrefs = { ...SIDEBAR_PREFS_DEFAULTS }
/** Replace the side card prefs (the settings RPC result / settings page write). */
setPrefs(prefs: SidebarPrefs): void {
this.prefs = { ...prefs }
}
/** The current side card prefs (seeds new sessions; persisted states win). */
getPrefs(): SidebarPrefs {
return { ...this.prefs }
}
/** Select a session (or none); loads its persisted state. */
setSession(sessionId: string | undefined): void {
if (this.snapshot.sessionId === sessionId) return
if (sessionId === undefined) {
this.snapshot = { sessionId: undefined, state: undefined }
} else {
let state = this.bySession.get(sessionId)
if (state === undefined) {
state = loadState(sessionId, this.prefs)
this.bySession.set(sessionId, state)
} else {
// Cache hit: another session's load/ops may have left the uid
// counter below THIS session's persisted ids — re-seed so fresh
// pane/split ids can never collide with its tree.
nextIdCounter = maxCounterId(state)
}
this.snapshot = { sessionId, state }
}
this.notify()
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => { this.listeners.delete(listener) }
}
getSnapshot(): SidebarSnapshot {
return this.snapshot
}
/** Mutate the current session's state (no-op without a session). */
update(mutator: (draft: SidebarState) => void): void {
const sessionId = this.snapshot.sessionId
const state = this.snapshot.state
if (sessionId === undefined || state === undefined) return
const draft = structuredClone(state)
mutator(draft)
this.bySession.set(sessionId, draft)
this.snapshot = { sessionId, state: draft }
this.schedulePersist(sessionId, draft)
this.notify()
}
/**
* Whether a tab still exists in its session's state. Views use this on
* unmount to tell "the tab was closed" (release the terminal now) from
* "the tree re-rendered / the conversation switched" (the tab is still
* open — keep the terminal alive through the host's reconnect grace).
* Checks the session's own map entry (the current snapshot may already
* point at another session when a conversation switch unmounts the old
* one's tabs).
*/
tabOpen(sessionId: string, tabId: string): boolean {
const state = this.bySession.get(sessionId)
?? (this.snapshot.sessionId === sessionId ? this.snapshot.state : undefined)
return state !== undefined && tabOpenIn(state, tabId)
}
/** Apply a pure reducer (returns the next state). */
reduce(reducer: (state: SidebarState) => SidebarState): void {
const sessionId = this.snapshot.sessionId
const state = this.snapshot.state
if (sessionId === undefined || state === undefined) return
const next = reducer(state)
this.bySession.set(sessionId, next)
this.snapshot = { sessionId, state: next }
this.schedulePersist(sessionId, next)
this.notify()
}
private schedulePersist(sessionId: string, state: SidebarState): void {
window.clearTimeout(this.persistTimer)
this.persistTimer = window.setTimeout(() => {
try {
localStorage.setItem(`${STORAGE_PREFIX}:${sessionId}`, JSON.stringify(state))
} catch {
// Storage full or unavailable: layout memory is best-effort.
}
}, 200)
}
private notify(): void {
for (const listener of [...this.listeners]) listener()
}
}
/**
* Create one sidebar store instance. Production code calls this only from
* the client plugin's `apply` (the instance is handed to components as a
* prop); tests call it directly. No module-level singleton: the store's
* lifetime belongs to the plugin activation, exactly like the official
* `createXXXStore()` factory rule.
*/
export function createSidebarStore(): SidebarStore {
return new SidebarStore()
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Live theme access for surfaces that cannot consume the token colors
* directly — xterm's palette and CodeMirror's theme extensions need concrete
* values, but the app's scheme flips at runtime (ui-layout's ThemePresenter
* projects prefers-color-scheme and the user's choice onto
* body[data-ds-dark-theme] and html { color-scheme }). This module reads the
* resolved scheme and token values, and notifies subscribers on flips, so
* the terminal and the editor re-theme in place instead of freezing in the
* scheme they happened to be created under.
*/
/** Whether the app shell resolved to the dark scheme.
*
* The presenter sets `html { color-scheme }` together with the body palette
* attribute, so a set color-scheme means the decision is authoritative (an
* absent attribute is then LIGHT even when the OS prefers dark — the user
* chose light). Before the presenter has run, fall back to the OS media
* query as the best guess.
*/
export function isDarkScheme(): boolean {
if (typeof document === 'undefined') return true
const decided = document.documentElement.style.colorScheme !== ''
if (decided) return document.body.hasAttribute('data-ds-dark-theme')
return typeof matchMedia !== 'undefined' && matchMedia('(prefers-color-scheme: dark)').matches
}
/** One token's computed value on <body> ('' while the theme has not applied). */
export function tokenValue(name: string): string {
if (typeof document === 'undefined') return ''
return getComputedStyle(document.body).getPropertyValue(name).trim()
}
/**
* Subscribe to color-scheme flips (the presenter toggles the body
* attribute). The callback fires after the attribute changed; re-read the
* scheme inside it.
* @returns the disposer.
*/
export function subscribeColorScheme(callback: () => void): () => void {
if (typeof document === 'undefined') return () => {}
const observer = new MutationObserver(() => { callback() })
observer.observe(document.body, { attributes: true, attributeFilter: ['data-ds-dark-theme'] })
return () => { observer.disconnect() }
}
+81
View File
@@ -0,0 +1,81 @@
/**
* Serializable configuration and defaults for the sidebar host half. Loader
* schema validation normally fills defaults; {@link resolveSidebarConfig}
* applies the same defaults for direct callers that bypass the Loader.
* @module dsh-better-sidebar/config
*/
import z from 'schemastery'
import {
SIDEBAR_PREFS_DEFAULTS,
SIDEBAR_PREFS_NS,
WIDTH_PERCENT_DEFAULT,
WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN,
type SidebarPrefs,
} from './prefs-shared.ts'
export {
SIDEBAR_PREFS_DEFAULTS,
SIDEBAR_PREFS_NS,
WIDTH_PERCENT_DEFAULT,
WIDTH_PERCENT_MAX,
WIDTH_PERCENT_MIN,
type SidebarPrefs,
} from './prefs-shared.ts'
/** Tunable sidebar host limits (every field optional; defaults fill in). */
export interface SidebarConfig {
/** Read cap of one text file (bytes); larger files return truncated. */
readLimit?: number
/** Media route cap (bytes); larger binaries are refused. */
mediaLimit?: number
/** Explorer row bound of one level. */
listLimit?: number
/** Terminals per session. */
terminalsPerSession?: number
/** How long a disconnected terminal process survives awaiting a reconnect. */
reconnectGraceMs?: number
}
/** Schemastery schema for the plugin configuration. */
export const Config: z<SidebarConfig> = z.object({
readLimit: z.number().step(1).min(1).default(512 * 1024),
mediaLimit: z.number().step(1).min(1).default(20 * 1024 * 1024),
listLimit: z.number().step(1).min(1).default(1000),
terminalsPerSession: z.number().step(1).min(1).default(3),
reconnectGraceMs: z.number().step(1).min(0).default(30_000),
})
/** Fully defaulted sidebar host settings. */
export interface ResolvedSidebarConfig {
readLimit: number
mediaLimit: number
listLimit: number
terminalsPerSession: number
reconnectGraceMs: number
}
/**
* Apply direct-call defaults after Loader schema validation has normally run.
*
* @param config - Deployment-provided sidebar host settings.
* @returns Complete settings consumed by the host half.
*/
export function resolveSidebarConfig(config: SidebarConfig | undefined): ResolvedSidebarConfig {
return {
readLimit: config?.readLimit ?? 512 * 1024,
mediaLimit: config?.mediaLimit ?? 20 * 1024 * 1024,
listLimit: config?.listLimit ?? 1000,
terminalsPerSession: config?.terminalsPerSession ?? 3,
reconnectGraceMs: config?.reconnectGraceMs ?? 30_000,
}
}
// ── User-facing "Side card" preferences ─────────────────────────────────────
/** Schemastery schema for the user-facing preferences (validated by the settings service). */
export const PrefsSchema: z<SidebarPrefs> = z.object({
openByDefault: z.boolean().default(true),
defaultWidthPercent: z.number().step(1).min(WIDTH_PERCENT_MIN).max(WIDTH_PERCENT_MAX).default(WIDTH_PERCENT_DEFAULT),
})
+187 -27
View File
@@ -1,33 +1,193 @@
/**
* Structural types for the cordis services apes-sidebar consumes.
*
* PHASE 1: the sidebar tab + session types now come from the REAL
* `dsh-better-sidebar` package (type-only imports in `src/client/index.tsx`):
* `TabDescriptor` / `TabComponentProps` / `BetterSidebarService` /
* `SidebarSessionSummary` / `SidebarSessionList`. The Phase 0 placeholder
* mirrors (`ApesSidebarTabDescriptor`, `ApesSidebarService`,
* `ApesSessionSummary`, `ApesSessionList`, `ApesSessionsService`, and the
* `ApesContext` intersection) were deleted — they are superseded by the
* peer's authoritative types and would drift.
*
* What remains here is the ONE service face apes-sidebar still defines
* itself: the locale runtime mirror (`ApesLocaleService`). `dsh-better-sidebar`
* publishes its own `SidebarLocaleService`, but locales.ts pre-dates the type
* import and stays on this local mirror to keep the i18n module independent
* of the peer (it must compile even when the peer is absent).
*
* This file must stay FREE of Node.js types: it is part of the
* CLIENT-reachable declaration graph, so a Node import here would leak into
* browser-only consumer builds.
* Structural types for the cordis services this plugin consumes, plus the
* Context augmentation both halves share. A third-party plugin resolves
* outside the DSH monorepo's single cordis instance, so the upstream
* `declare module 'cordis'` augmentations do not reach this Context — and
* the npm cordis package does not declare the DSH-vendored runtime members
* (`ctx.effect`, service properties). The members below mirror the actual
* runtime shapes this plugin touches:
* - httpServer: @deepseek-ai/dsh-host-webserver
* - sessions: host side @deepseek-ai/dsh-session (SessionStore), client
* side the runtime ISessions list feed
* - conversation: client side ui-conversation's IConversation (composer
* draft), read lazily through `ctx.get` — cross-plugin service reads need
* an inject declaration, so the direct property is never typed here
* - loader: @cordisjs/plugin-loader (entry options)
* - slots: the client runtime SlotsService
* - effect: the DSH-vendored cordis lifecycle helper
* Drift from upstream is contained to this file.
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { Duplex } from 'node:stream'
import type { Context } from 'cordis'
/** One named webserver route (mirror of the host-webserver WebRoute). */
export interface SidebarWebRoute {
kind: 'exact' | 'prefix'
path: string
handler: (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
}
/** One exact-path HTTP upgrade registration (mirror of WebUpgradeRoute). */
export interface SidebarWebUpgradeRoute {
path: string
handler: (req: IncomingMessage, socket: Duplex, head: Buffer) => void | Promise<void>
}
/** The httpServer service face this plugin uses. */
export interface SidebarHttpServer {
register(route: SidebarWebRoute): () => void
registerUpgrade(route: SidebarWebUpgradeRoute): () => void
}
/** A published session's header slice the sidebar reads (authoritative cwd). */
export interface SidebarSessionHeader {
cwd?: string
}
/** The host session store face (`ctx.sessions.get(id)` returns the live session). */
export interface SidebarSessionStore {
get(id: string): { header: SidebarSessionHeader } | undefined
}
/** One loader entry's options slice (the connection row's resolved config). */
export interface SidebarLoaderEntry {
options: { name: string; config?: unknown }
}
/** The loader face used to read the connection row's trustedHosts config. */
export interface SidebarLoader {
entries(): Iterable<SidebarLoaderEntry>
}
/** Registration options the sidebar passes to `ctx.slots.register` (subset of the real options). */
export interface SidebarSlotRegisterOptions {
name: string
key?: string
id?: string
order?: number
label?: string | (() => string)
/** Chain routing selector (returns the matched value, or null to pass on). */
select?: (owner: unknown) => unknown
priority?: number
locale?: string
registrant?: string
/** Business-face factory; args depend on the slot scope. */
inject?: (...args: any[]) => Record<string, unknown>
children?: Record<string, unknown>
}
/** The client slots service face (register returns the disposer). */
export interface SidebarSlotsService {
register(options: SidebarSlotRegisterOptions, component: unknown): () => void
/**
* Run a callback for each declaration lifetime of a slot (the runtime
* SlotsService.inject): a no-op while the slot is undeclared, so the
* settings section registration waits for the settings shell.
*/
inject(key: string, callback: () => () => void): () => void
}
/** The client session list row the sidebar reads (cwd for the explorer). */
export interface SidebarSessionSummary {
cwd?: string
displayTitle: string
}
/** The client session list snapshot the sidebar subscribes to. */
export interface SidebarSessionList {
current: string | undefined
byId: Record<string, SidebarSessionSummary>
}
/** The client sessions service face (only the list feed is needed). */
export interface SidebarSessionsService {
list: {
getSnapshot(): SidebarSessionList
subscribe(fn: () => void): () => void
}
/**
* Resolve an Agent-scoped context view for one session (mirror of the
* runtime ISessions.scope) — the ticket `ctx.conversation.input.for`
* requires to reach that session's composer.
*/
scope(id: string): Context | undefined
}
/** The composer draft face the sidebar reaches through `ctx.conversation.input`. */
export interface SidebarSessionInput {
/** The live input store (draft read for append). */
state: {
getSnapshot(): { draft: string }
}
/** Replace the draft text (the input machine's single public write path). */
setDraft(text: string): void
}
/** The composer draft face the sidebar reaches through `ctx.get('conversation')`. */
export interface SidebarConversation {
input: {
for(actx: Context): SidebarSessionInput
}
}
/**
* The client locale service face (mirror of dsh-client-locale's LocaleRuntime).
* Only the slices apes-sidebar's i18n module touches: snapshot read, snapshot
* subscription, and per-namespace dictionary registration.
* The invariant service face (mirror of @deepseek-ai/dsh-invariants'
* InvariantService). The upstream augmentation does not reach this Context
* (dual-cordis-instance resolution), so the register signature is restated
* structurally, exactly like the other service faces above.
*/
export interface ApesLocaleService {
getSnapshot(): { active: string }
subscribe(fn: () => void): () => void
register(ns: string, locale: string, dict: Record<string, string>): () => void
export interface SidebarInvariantsService {
/** Reserve one package's checks and install them in the service's child fiber. */
register(
packageName: string,
installer: (ctx: Context, fail: (message: string) => never) => void | Promise<void>,
): () => void
}
/** The settings service face (mirror of @deepseek-ai/dsh-settings' Settings). */
export interface SidebarSettingsService {
/**
* Register one namespace schema (the resolved value layers schema defaults,
* then the composition base, then the user document).
*/
register<T>(
ns: string,
schema: unknown,
options?: { base?: Partial<T>; applies?: 'live' | 'restart' },
): {
get(): T
watch(callback: (next: T, prev: T) => void | Promise<void>): () => void
update(patch: object): Promise<void>
replace(section: object): Promise<void>
}
/** Redacted descriptors of every registered namespace (secrets stripped). */
describe(options?: { redactSecrets?: boolean }): Array<{
ns: string
value?: unknown
base?: unknown
user?: unknown
applies: 'live' | 'restart'
revision: number
}>
/** Service-level merge write with the revision guard (a stale writer is refused). */
update(ns: string, patch: object, expectedRevision?: number): Promise<void>
}
declare module 'cordis' {
interface Context {
httpServer: SidebarHttpServer
sessions: SidebarSessionStore & SidebarSessionsService
loader: SidebarLoader
slots: SidebarSlotsService
settings: SidebarSettingsService
invariants: SidebarInvariantsService
/**
* Register a lifecycle callback (DSH-vendored cordis): runs at plugin
* activation; its returned cleanup runs at disposal.
*/
effect(fn: () => void | (() => void), label?: string): void
}
}
export type { Context }
+115
View File
@@ -0,0 +1,115 @@
/**
* Single-level directory listing for the sidebar explorer. Streams the level
* with opendir, sorts directories first then names (case-insensitive), and
* marks POSIX-hidden entries (dot-prefixed) for dimmed display. Symlinks are
* reported as files without probing their target — the explorer shows what
* dirent says, keeping the read cheap for arbitrarily large levels.
*/
import { opendir } from 'node:fs/promises'
import { basename, dirname, join, resolve } from 'node:path'
import { SidebarError } from './wire.ts'
/** One explorer row. */
export interface SidebarFsEntry {
name: string
path: string
isDir: boolean
hidden: boolean
}
/** One listed level. */
export interface SidebarFsListing {
path: string
entries: SidebarFsEntry[]
truncated: boolean
}
/** Directory-first, case-insensitive name ordering (VSCode explorer order). */
export function compareEntries(a: SidebarFsEntry, b: SidebarFsEntry): number {
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' })
}
/**
* List one directory level.
* @param path - absolute directory path.
* @param maxEntries - row bound of one level (extra rows flag `truncated`).
* @returns the sorted listing.
* @throws {SidebarError} fs-error when the level is unreadable or not a directory.
*/
export async function listDirectory(path: string, maxEntries = 1000): Promise<SidebarFsListing> {
let level
try {
level = await opendir(path)
} catch (error) {
throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400)
}
const rows: SidebarFsEntry[] = []
let overflow = 0
try {
for await (const dirent of level) {
if (rows.length >= maxEntries) {
overflow += 1
continue
}
rows.push({
name: dirent.name,
// Platform join: on Windows the level path uses '\' — a hardcoded '/'
// would leak mixed separators into every row's path.
path: join(path, dirent.name),
isDir: dirent.isDirectory(),
hidden: dirent.name.startsWith('.'),
})
}
} catch (error) {
throw new SidebarError('fs-error', `cannot list "${path}": ${messageOf(error)}`, 400)
}
rows.sort(compareEntries)
return { path, entries: rows, truncated: overflow > 0 }
}
/** The root row label of a listing: the last path segment (or the full path at the filesystem root). */
export function rootLabel(path: string): string {
const base = basename(path)
return base !== '' ? base : path
}
/** Parent of a path, or undefined at the filesystem root (the explorer's "up" target). */
export function parentOf(path: string): string | undefined {
const parent = dirname(path)
return parent === path ? undefined : parent
}
/** Normalize a caller-supplied path to an absolute, resolved path or throw fs-error. */
export function requireAbsolute(path: string): string {
if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) {
throw new SidebarError('fs-error', `"${path}" is not an absolute path`, 400)
}
return resolve(path)
}
/**
* Whether `target` lies under `base` (or equals it), tolerant of separator
* style and — on Windows, where the filesystem is case-insensitive — of
* letter case. The media route uses this instead of a raw `startsWith` so a
* case-mismatched or mixed-separator path can never be misclassified
* (e.g. `C:\Users\Me` vs `c:/users/me/file.png`).
* @param platform - filesystem semantics; injectable so both branches are
* unit-testable on any host.
*/
export function isWithin(base: string, target: string, platform: NodeJS.Platform = process.platform): boolean {
const norm = (value: string): string => value.replace(/[\\/]+/g, '/').replace(/\/$/, '')
const b = norm(base)
const t = norm(target)
if (platform === 'win32') {
const lb = b.toLowerCase()
const lt = t.toLowerCase()
return lt === lb || lt.startsWith(`${lb}/`)
}
return t === b || t.startsWith(`${b}/`)
}
/** Message text of an unknown thrown value. */
export function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
+192
View File
@@ -0,0 +1,192 @@
/**
* Git operations for the sidebar source-control panel. Everything goes
* through the system `git` binary spawned per request (no library, no state),
* with porcelain-parseable output formats (`-z` NUL framing, unit separators)
* so parsing never depends on locale or color config. All commands run with
* `-C <cwd>` on the session's working directory and `--no-pager` /
* `-c color.ui=false` so output stays machine-readable.
*
* Commits use the user's git global identity untouched (never sets
* user.name/user.email).
*/
import { spawn } from 'node:child_process'
/** A parsed `git status --porcelain=v1 -z` entry. */
export interface GitStatusEntry {
path: string
/** Two-letter index/worktree status (X Y), e.g. 'M ', ' M', 'A ', '??'. */
xy: string
}
/** The source-control panel snapshot. */
export interface GitStatusResult {
isRepo: boolean
branch?: string
entries: GitStatusEntry[]
}
/** One `git log` row. */
export interface GitLogEntry {
hash: string
subject: string
author: string
date: string
}
/** One git failure (stderr text as the message). */
export class GitCommandError extends Error {
constructor(
message: string,
readonly code = 'git-error',
readonly command: string,
) {
super(message)
}
}
/** Parse porcelain v1 -z output into entries (rename/copy pairs collapse to one row). */
export function parsePorcelainZ(output: string): GitStatusEntry[] {
const tokens = output.split('\0')
const entries: GitStatusEntry[] = []
let index = 0
while (index < tokens.length) {
const token = tokens[index]!
index += 1
if (token === '') continue
const xy = token.slice(0, 2)
const rest = token.slice(3)
entries.push({ path: rest, xy })
// Rename/copy entries carry the ORIGIN path as the next NUL field; the
// new path (the file as it exists now) is the display path.
if ((xy[0] === 'R' || xy[0] === 'C') && tokens[index] !== undefined && tokens[index] !== '') {
index += 1
}
}
return entries
}
/** Parse `git log --pretty=format:%h%x1f%s%x1f%an%x1f%ai` rows. */
export function parseLogLines(output: string): GitLogEntry[] {
const rows: GitLogEntry[] = []
for (const line of output.split('\n')) {
if (line === '') continue
const [hash, subject, author, date] = line.split('\x1f')
if (hash === undefined || subject === undefined) continue
rows.push({ hash, subject, author: author ?? '', date: date ?? '' })
}
return rows
}
/** Run one git command; resolves with stdout, rejects with GitCommandError. */
function runGit(cwd: string, args: string[], timeoutMs = 30_000): Promise<string> {
const full = ['-C', cwd, '--no-pager', '-c', 'color.ui=false', ...args]
return new Promise<string>((resolvePromise, reject) => {
const child = spawn('git', full, {
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
})
let stdout = ''
let stderr = ''
const timer = setTimeout(() => {
child.kill('SIGKILL')
reject(new GitCommandError(`git ${args[0] ?? ''} timed out after ${timeoutMs}ms`, 'git-error', args.join(' ')))
}, timeoutMs)
child.stdout.on('data', (chunk: Buffer) => { stdout += chunk.toString('utf8') })
child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') })
child.on('error', (error) => {
clearTimeout(timer)
reject(new GitCommandError(`cannot run git: ${error.message}`, 'git-error', args.join(' ')))
})
child.on('close', (code) => {
clearTimeout(timer)
if (code === 0) {
resolvePromise(stdout)
} else {
reject(new GitCommandError(stderr.trim() || `git exited with ${String(code)}`, 'git-error', args.join(' ')))
}
})
})
}
/** Whether the directory is inside a git work tree (exit-0 `git rev-parse`). */
export async function isGitRepo(cwd: string): Promise<boolean> {
try {
const out = await runGit(cwd, ['rev-parse', '--is-inside-work-tree'])
return out.trim() === 'true'
} catch {
return false
}
}
/** The current branch name (`git rev-parse --abbrev-ref HEAD`; 'HEAD' when detached). */
export async function currentBranch(cwd: string): Promise<string> {
const out = await runGit(cwd, ['rev-parse', '--abbrev-ref', 'HEAD'])
return out.trim()
}
/** Working-tree status (untracked included). */
export async function status(cwd: string): Promise<GitStatusResult> {
const repo = await isGitRepo(cwd)
if (!repo) return { isRepo: false, entries: [] }
const [branch, raw] = await Promise.all([
currentBranch(cwd).catch(() => 'HEAD'),
runGit(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=normal']),
])
return { isRepo: true, branch, entries: parsePorcelainZ(raw) }
}
/** Diff text of the worktree (unstaged) or the index (staged). */
export async function diff(cwd: string, path: string | undefined, staged: boolean): Promise<string> {
const args = ['diff', '--no-ext-diff', '--no-color', '-U3']
if (staged) args.push('--cached')
if (path !== undefined) args.push('--', path)
return runGit(cwd, args)
}
/** Stage paths (all when path is undefined). */
export async function stage(cwd: string, path: string | undefined): Promise<void> {
await runGit(cwd, ['add', '-A', ...(path !== undefined ? ['--', path] : [])])
}
/** Unstage paths (all when path is undefined). */
export async function unstage(cwd: string, path: string | undefined): Promise<void> {
await runGit(cwd, ['reset', '-q', ...(path !== undefined ? ['--', path] : [])])
}
/** Commit the staged changes with a message (global identity untouched). */
export async function commit(cwd: string, message: string): Promise<void> {
await runGit(cwd, ['commit', '-m', message])
}
/** Branch names (current first). */
export async function branches(cwd: string): Promise<{ current: string; names: string[] }> {
const [current, raw] = await Promise.all([
currentBranch(cwd).catch(() => 'HEAD'),
runGit(cwd, ['for-each-ref', '--format=%(refname:short)', 'refs/heads']),
])
const names = raw.split('\n').filter(line => line !== '')
return { current, names: names.includes(current) ? names : [current, ...names] }
}
/** Switch to an existing branch. */
export async function checkout(cwd: string, branch: string): Promise<void> {
await runGit(cwd, ['checkout', branch])
}
/** Recent commit history (newest first). */
export async function log(cwd: string, count = 30): Promise<GitLogEntry[]> {
const raw = await runGit(cwd, ['log', `-n ${count}`, '--pretty=format:%h%x1f%s%x1f%an%x1f%ai'])
return parseLogLines(raw)
}
/**
* Content of a file at a revision (`git show <rev>:<path>`), or null when the
* revision has no such path (a new/untracked file has no HEAD side).
*/
export async function show(cwd: string, rev: string, path: string): Promise<string | null> {
try {
return await runGit(cwd, ['show', `${rev}:${path}`])
} catch {
return null
}
}
+473 -50
View File
@@ -1,60 +1,483 @@
/**
* apes-sidebar host-half entry.
* dsh-better-sidebar host half: the /sidebar JSON API (explorer listing, file
* read/write, git), the /sidebar/file media route (images), and the terminal
* WebSocket upgrade. Every route passes the same browser-trust fence as the
* /api gateway — Host-header loopback or the connection row's `trustedHosts`
* (the `dsh web` launcher derives LAN IP literals per boot) — with the
* trustedHosts read live from the connection loader row so the fence never
* drifts from the deployment's.
*
* The host half is intentionally MINIMAL for Phase 0: it mounts a logger
* line so the cordis patch's `insert` row is exercised end-to-end (the
* scratch-profile mount smoke asserts this package's fiber activates).
*
* Phase 2 (Tutti capability layer) will add the host routes the tabs need:
* - subagent topology / job output replay (mirror of better-sidebar's
* subagent-live-route.ts + jobs-routes.ts)
* - agent-programmable open-tab registry (mirror of better-sidebar's
* agent-opens.ts)
* All host routes follow the better-sidebar trust-fence + session-isolation
* model (same-origin loopback fence, per-request cwd from the session store).
*
* The client half (the actual sidebar tabs) lives in src/client/index.tsx and
* is loaded through the package.json `dsh.client.inject` declaration; it
* registers tabs into `ctx.betterSidebar` (better-sidebar's registry).
*
* inject list: Phase 0 needs only `logger` (always present). Phase 2 will
* add `webServer` + `sessions` when the host routes land.
* All operations are conversation-scoped: requests carry a sessionId, the
* session's authoritative cwd comes from the session store, and terminal
* processes are keyed by session.
*/
export const name = 'apes-sidebar'
import { mkdir, open, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'
import { basename, dirname, extname } from 'node:path'
import type { IncomingMessage } from 'node:http'
import { WebSocket, WebSocketServer } from 'ws'
import type { Context } from './context-types.ts'
import {
Config,
PrefsSchema,
resolveSidebarConfig,
SIDEBAR_PREFS_NS,
type ResolvedSidebarConfig,
type SidebarConfig,
} from './config.ts'
import { isWithin, parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
import { isTrustedApiRequest } from './trust-fence.ts'
import * as git from './git.ts'
import { SettingsConflictError, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings'
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
/**
* The cordis services the host half injects.
*
* MUST stay empty: the host half uses `ctx.logger` only via optional-chaining
* (`log?.info?.()` inside try/catch) — it is a soft convenience, not a hard
* dependency. Declaring it in `inject` would make the entry wait on a
* `logger` service that no DSH host provider registers, leaving the entry
* `pending` forever and failing the plugin tree load (recovery mode).
* Compare dsh-better-sidebar, whose inject list is services the host
* actually provides (`webServer`/`sessions`/`webRuntime`/`tools`).
*/
export const inject = [] as const
export { Config }
export type { SidebarConfig, ResolvedSidebarConfig }
/**
* The cordis effect entry: runs once per activation on the Node half.
* Phase 0 — log a mount marker and validate the better-sidebar peer
* version (warn-only, never blocks mount).
*/
export function apply(ctx: { logger?: { info: (msg: string) => void }; effect?: (fn: () => void | (() => void)) => () => void }): void {
const log = ctx.logger
const mount = () => {
try {
log?.info?.('[apes-sidebar] host half mounted (Phase 0 skeleton)')
} catch {
// Never let a logging failure abort the fiber.
/** Plugin identity for cordis.yml rows. */
export const name = 'dsh-better-sidebar'
/** Services required before mounting: the webserver routes, the session store, and the loader's connection row. */
export const inject = ['httpServer', 'sessions', 'loader']
/** Content types for the media route, by extension. */
const MEDIA_TYPES: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.bmp': 'image/bmp',
'.ico': 'image/x-icon',
'.avif': 'image/avif',
}
/** The connection row's resolved trustedHosts (live read; the /api fence's own list). */
function trustedHostsOf(ctx: Context): string[] {
for (const entry of ctx.loader.entries()) {
if (entry.options.name === 'connection') {
const config = entry.options.config as { trustedHosts?: string[] } | undefined
return config?.trustedHosts ?? []
}
}
if (typeof ctx.effect === 'function') {
ctx.effect(() => {
mount()
return () => undefined
return []
}
/**
* Resolve a session's authoritative working directory. The attached session
* header wins; while the session is still hydrating from persistence (the
* web client attaches the current conversation a moment after page load, so
* the very first sidebar requests can arrive detached) the caller's own
* list-summary cwd is used; the process cwd is the last resort (blank
* sessions have no cwd anywhere yet). Never throws for a missing cwd, so
* explorer/git/terminal work from first paint instead of surfacing
* "session ... has no working directory".
*/
function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): string {
const session = ctx.sessions.get(sessionId)
const headerCwd = session?.header.cwd
if (headerCwd !== undefined && headerCwd !== '') return headerCwd
if (clientCwd !== undefined && clientCwd !== '') {
try {
return requireAbsolute(clientCwd)
} catch {
throw new SidebarError('bad-request', `invalid working directory "${clientCwd}"`)
}
}
return process.cwd()
}
/** Text read of a file with the size cap; binary detection via NUL probe. */
async function readText(path: string, readLimit: number): Promise<{ content: string; truncated: boolean; binary: boolean; size: number }> {
const info = await stat(path).catch((error: unknown) => {
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
})
if (info.isDirectory()) {
throw new SidebarError('fs-error', `"${path}" is a directory`, 400)
}
const size = info.size
const truncated = size > readLimit
const handle = await open(path, 'r').catch((error: unknown) => {
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
})
try {
const buffer = Buffer.alloc(Math.min(size, readLimit))
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
const slice = buffer.subarray(0, bytesRead)
const binary = slice.includes(0)
return { content: binary ? '' : slice.toString('utf8'), truncated, binary, size }
} finally {
await handle.close()
}
}
/** One API method dispatch table entry. */
type ApiMethod = (payload: unknown) => Promise<unknown> | unknown
/**
* The live face of the side card settings namespace, bound to the settings
* service when it is mounted. The DSH settings RPC domain only serves
* allowlisted namespaces (api-proxy exposedNamespaces), so the client reads
* and writes THIS namespace through the plugin's own fenced /sidebar routes,
* which call the seam in-process — no configuration-client gate involved.
*/
export interface SidebarSettingsFace {
/** The current resolved value + revision (undefined while the settings service is absent). */
get(): { value?: unknown; revision?: number }
/** Merge a patch (revision-guarded) and return the fresh resolved view. */
update(patch: Record<string, unknown>, expectedRevision?: number): Promise<{ value?: unknown; revision?: number }>
}
/** Build the API method table bound to the plugin context, pty manager, and resolved config. */
function buildApi(
ctx: Context,
ptyManager: PtyManager,
resolved: ResolvedSidebarConfig,
getSettings: () => SidebarSettingsFace | undefined,
): Record<string, ApiMethod> {
const cwdOf = (payload: unknown): { sessionId: string; cwd: string } => {
const sessionId = requireString(payload, 'sessionId')
const record = payload as { cwd?: unknown } | null
const clientCwd = typeof record?.cwd === 'string' && record.cwd !== '' ? record.cwd : undefined
return { sessionId, cwd: sessionCwdOf(ctx, sessionId, clientCwd) }
}
return {
'session.cwd': (payload) => {
const { sessionId, cwd } = cwdOf(payload)
return { sessionId, cwd, root: rootLabel(cwd), parent: parentOf(cwd) ?? null }
},
'fs.tree': async (payload) => {
const { cwd } = cwdOf(payload)
const record = payload as { path?: unknown }
const target = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path'))
return listDirectory(target, resolved.listLimit)
},
'fs.read': async (payload) => {
const { cwd } = cwdOf(payload)
const path = requireAbsolute(requireString(payload, 'path'))
const { content, truncated, binary, size } = await readText(path, resolved.readLimit)
if (binary) return { kind: 'binary', size, truncated }
return { kind: 'text', content, truncated }
},
'fs.write': async (payload) => {
const { cwd } = cwdOf(payload)
const path = requireAbsolute(requireString(payload, 'path'))
const content = requireString(payload, 'content')
const tmp = `${path}.dsh-sidebar-tmp-${process.pid}`
try {
await mkdir(dirname(path), { recursive: true })
await writeFile(tmp, content, 'utf8')
await rename(tmp, path)
} catch (error) {
await rm(tmp, { force: true }).catch(() => {})
throw new SidebarError('fs-error', `cannot write "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
}
return { ok: true }
},
'git.status': async (payload) => {
const { cwd } = cwdOf(payload)
return git.status(cwd)
},
'git.diff': async (payload) => {
const { cwd } = cwdOf(payload)
const record = payload as { path?: unknown; staged?: unknown }
const path = record.path === undefined ? undefined : requireAbsolute(requireString(payload, 'path'))
return { diff: await git.diff(cwd, path, record.staged === true) }
},
'git.stage': async (payload) => {
const { cwd } = cwdOf(payload)
const record = payload as { path?: unknown }
const path = record.path === undefined ? undefined : requireString(payload, 'path')
await git.stage(cwd, path)
return { ok: true }
},
'git.unstage': async (payload) => {
const { cwd } = cwdOf(payload)
const record = payload as { path?: unknown }
const path = record.path === undefined ? undefined : requireString(payload, 'path')
await git.unstage(cwd, path)
return { ok: true }
},
'git.commit': async (payload) => {
const { cwd } = cwdOf(payload)
const message = requireString(payload, 'message')
await git.commit(cwd, message)
return { ok: true }
},
'git.branch': async (payload) => {
const { cwd } = cwdOf(payload)
return git.branches(cwd)
},
'git.checkout': async (payload) => {
const { cwd } = cwdOf(payload)
await git.checkout(cwd, requireString(payload, 'branch'))
return { ok: true }
},
'git.log': async (payload) => {
const { cwd } = cwdOf(payload)
return git.log(cwd)
},
'git.show': async (payload) => {
const { cwd } = cwdOf(payload)
const path = requireAbsolute(requireString(payload, 'path'))
const rev = requireString(payload, 'rev')
return { content: await git.show(cwd, rev, path) }
},
// Release a terminal immediately. The WebSocket close frame already does
// this while the socket is open; this route covers the tab-close that
// happens while the socket is down (reconnect loop), so a closed tab can
// never hold the per-session quota until the reconnect grace expires.
'pty.close': (payload) => {
const sessionId = requireString(payload, 'sessionId')
const tab = requireString(payload, 'tab')
ptyManager.close(`${sessionId}:${tab}`)
return { ok: true }
},
// The side card preferences. The settings service is optional in the
// composition; while absent the routes report undefined and the client
// keeps the schema defaults. Writes are revision-guarded: a stale editor
// is refused with settings-conflict so a concurrent change is never
// silently overwritten (mirror of the settings seam's own guard).
'settings.get': () => {
const settings = getSettings()
return settings?.get() ?? { value: undefined, revision: undefined }
},
'settings.update': async (payload) => {
const settings = getSettings()
if (settings === undefined) {
throw new SidebarError('settings-rejected', 'the settings service is not mounted in this deployment', 503)
}
const record = payload as { patch?: unknown; expectedRevision?: unknown } | null
const patch = record?.patch
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
throw new SidebarError('bad-request', 'patch must be a plain object')
}
const expectedRevision = typeof record?.expectedRevision === 'number' ? record.expectedRevision : undefined
try {
return await settings.update(patch as Record<string, unknown>, expectedRevision)
} catch (error) {
if (error instanceof SettingsConflictError) {
throw new SidebarError('settings-conflict', error.message, 409)
}
throw new SidebarError('settings-rejected', error instanceof Error ? error.message : String(error), 400)
}
},
}
}
/**
* Plugin body: mount the fenced routes and the pty lifecycle.
* @param ctx - host plugin context (httpServer, sessions, loader).
* @param config - deployment-provided limits; the Loader validates against
* {@link Config} and fills defaults, direct callers get them from
* {@link resolveSidebarConfig}.
*/
export function apply(ctx: Context, config?: SidebarConfig): void {
// pnpm strips the executable bit from node-pty's prebuilt spawn-helper;
// restore it before any terminal can spawn (idempotent).
ensureSpawnHelper()
const resolved = resolveSidebarConfig(config)
const trustedHosts = trustedHostsOf(ctx)
const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHosts)
const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession)
// ── User-facing "Side card" preferences ──────────────────────────────────
// Register the namespace with the settings provider so the Settings page
// (client half) can render and persist the new-conversation defaults. The
// DSH settings RPC domain (api-proxy) only serves allowlisted namespaces to
// configuration clients, so the client reaches this namespace through the
// plugin's own fenced routes below ('settings.get'/'settings.update'),
// which call the seam in-process. Deployments without a settings service
// simply never fill the face and the client falls back to the defaults.
let settingsFace: SidebarSettingsFace | undefined
ctx.inject(['settings'], (sctx) => {
const ns: SettingsNamespace = settingsNamespace(SIDEBAR_PREFS_NS)
sctx.settings.register(ns, PrefsSchema)
const viewOf = (): { value?: unknown; revision?: number } => {
const descriptor = sctx.settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === ns)
return descriptor === undefined
? { value: undefined, revision: undefined }
: { value: descriptor.value, revision: descriptor.revision }
}
settingsFace = {
get: viewOf,
update: async (patch, expectedRevision) => {
await sctx.settings.update(ns, patch, expectedRevision)
return viewOf()
},
}
})
// ── JSON API ────────────────────────────────────────────────────────────
const api = buildApi(ctx, ptyManager, resolved, () => settingsFace)
ctx.effect(() => ctx.httpServer.register({
kind: 'prefix',
path: '/sidebar/api',
handler: async (req, res) => {
if (!fence(req)) {
writeJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
return
}
if (req.method !== 'POST') {
writeJson(res, 405, { ok: false, error: { code: 'method-error', message: 'method not allowed' } })
return
}
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
const method = pathname.startsWith('/sidebar/api/') ? pathname.slice('/sidebar/api/'.length) : undefined
if (method === undefined || method.includes('/')) {
writeError(res, new SidebarError('not-found', 'unknown sidebar API method', 404))
return
}
try {
const payload = await readJsonBody(req)
const handler = api[method]
if (handler === undefined) {
throw new SidebarError('not-found', `unknown sidebar API method "${method}"`, 404)
}
writeOk(res, await handler(payload))
} catch (error) {
writeError(res, error)
}
},
}), 'dsh-better-sidebar: /sidebar/api routes')
// ── Media route (images for the editor) ─────────────────────────────────
ctx.effect(() => ctx.httpServer.register({
kind: 'prefix',
path: '/sidebar/file',
handler: async (req, res) => {
if (!fence(req)) {
res.writeHead(403)
res.end('forbidden')
return
}
if (req.method !== 'GET') {
res.writeHead(405)
res.end()
return
}
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
const sessionId = url.searchParams.get('sessionId')
const raw = url.searchParams.get('path')
if (sessionId === null || raw === null) throw new SidebarError('bad-request', 'sessionId and path are required')
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
const path = requireAbsolute(raw)
if (!isWithin(cwd, path)) {
// Only files under the session cwd are served as media (the editor
// opens images from the explorer; produced files go through read).
// isWithin (not a raw startsWith) so case-mismatched Windows paths
// and mixed separators cannot be misclassified.
throw new SidebarError('fs-error', 'media path outside the session working directory', 403)
}
const info = await stat(path)
if (!info.isFile() || info.size > resolved.mediaLimit) {
throw new SidebarError('fs-error', 'not a file or too large', 400)
}
const type = MEDIA_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
const body = await readFile(path)
// Raw bytes either way (binary-safe); ?download=1 switches the
// disposition so the browser saves the file instead of showing it.
const headers: Record<string, string> = { 'content-type': type, 'cache-control': 'no-cache' }
if (url.searchParams.get('download') === '1') {
headers['content-disposition'] = `attachment; filename*=UTF-8''${encodeURIComponent(basename(path))}`
}
res.writeHead(200, headers)
res.end(body)
} catch (error) {
writeError(res, error)
}
},
}), 'dsh-better-sidebar: /sidebar/file media route')
// ── Terminal WebSocket ──────────────────────────────────────────────────
const wss = new WebSocketServer({ noServer: true })
ctx.effect(() => ctx.httpServer.registerUpgrade({
path: '/sidebar/ws/terminal',
handler: (req, socket, head) => {
if (!fence(req)) {
socket.destroy()
return
}
wss.handleUpgrade(req, socket, head, (ws) => { void attachTerminal(ctx, ptyManager, ws, req, resolved) })
},
}), 'dsh-better-sidebar: terminal WebSocket')
ctx.effect(() => () => {
ptyManager.disposeAll()
wss.close()
}, 'dsh-better-sidebar: teardown')
}
/** Wire one terminal socket to its pty: replay transcript, pump both ways. */
async function attachTerminal(
ctx: Context,
ptyManager: PtyManager,
ws: WebSocket,
req: IncomingMessage,
resolved: ResolvedSidebarConfig,
): Promise<void> {
try {
const url = new URL(req.url ?? '/', 'http://dsh.internal')
const sessionId = url.searchParams.get('sessionId')
const tabId = url.searchParams.get('tab')
if (sessionId === null || tabId === null) {
ws.close(1008, 'sessionId and tab are required')
return
}
const cwd = sessionCwdOf(ctx, sessionId, url.searchParams.get('cwd') ?? undefined)
const handle = ptyManager.open(sessionId, tabId, cwd, 80, 24)
// Replay the transcript, then follow live output.
if (handle.transcript !== '') ws.send(handle.transcript)
const onData = (data: string): void => {
if (ws.readyState === WebSocket.OPEN && ws.bufferedAmount < 4 * 1024 * 1024) {
ws.send(data)
}
}
const onExit = ({ exitCode }: { exitCode: number; signal?: number }): void => {
onData(`\r\n[process exited with code ${String(exitCode)}]\r\n`)
}
const dataSub = handle.pty.onData(onData)
const exitSub = handle.pty.onExit(onExit)
ws.on('message', (data) => {
const text = data.toString('utf8')
// Control frames are JSON with a known shape; anything else (including
// JSON that is not a recognized control) is terminal input, verbatim.
let control: { type?: unknown; cols?: unknown; rows?: unknown } | null = null
try {
const parsed: unknown = JSON.parse(text)
if (parsed !== null && typeof parsed === 'object') {
control = parsed as { type?: unknown; cols?: unknown; rows?: unknown }
}
} catch {
// Not JSON: terminal input.
}
if (control !== null && control.type === 'close') {
// The owning tab was closed: release the quota immediately.
ptyManager.scheduleClose(handle.key, 0)
return
}
if (handle.exited) return
if (
control !== null
&& control.type === 'resize'
&& typeof control.cols === 'number' && typeof control.rows === 'number'
) {
handle.pty.resize(Math.max(2, Math.floor(control.cols)), Math.max(2, Math.floor(control.rows)))
} else {
handle.pty.write(text)
}
})
} else {
mount()
ws.on('close', () => {
dataSub.dispose()
exitSub.dispose()
// A bare socket drop (refresh, tab switch) leaves the process alive
// for a grace period so a quick reconnect keeps it; the reconnect's
// open() cancels the pending close.
ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs)
})
} catch (error) {
ws.close(1011, error instanceof Error ? error.message : String(error))
}
}
+22 -79
View File
@@ -1,89 +1,32 @@
/**
* The apes-sidebar invariant surface: a minimal self-check companion module
* (mirror of better-sidebar's `lib/invariant.js`). Shipped as a separate
* entry so consumers can `import { assert } from 'apes-sidebar/invariant'`
* without pulling the host-half apply() graph.
*
* Kept dependency-free: this module must never import Node builtins or
* other @deepseek-ai packages, because it sits in the client-reachable
* declaration graph.
* Package-owned invariant companion for `dsh-better-sidebar`.
* @module dsh-better-sidebar/invariant
*/
/** Thrown by {@link assert} when the condition is falsy. */
export class InvariantError extends Error {
constructor(message: string) {
super(message)
this.name = 'InvariantError'
}
}
/* jscpd:ignore-start */
import type { Context } from './context-types.ts'
/** Assert a condition; throw an {@link InvariantError} with `message` when falsy. */
export function assert(condition: unknown, message: string): asserts condition {
if (!condition) throw new InvariantError(message)
}
const PACKAGE_NAME = 'dsh-better-sidebar'
/** Cordis companion plugin name. */
export const name = 'dsh-better-sidebar-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* The invariant installer signature: register one package's checks in the
* service's child fiber. `fail` throws (never returns) to abort the mount.
* No runtime invariant: the sidebar owns no service state or event protocol
* of its own — every route is mounted under the host's httpServer fence, the
* pty lifecycle is exercised by the smoke spec, and the panel's store is a
* plain snapshot registry asserted by the behavior specs. The route fence,
* pty quota, and store semantics are each observed through their seams.
*/
export type InvariantInstaller = (
ctx: unknown,
fail: (message: string) => never,
) => void | Promise<void>
const install: () => void = () => {}
/**
* Guard the better-sidebar peer dependency contract at host startup:
* apes-sidebar requires `dsh-better-sidebar` >= 0.18.0. The check is a
* no-op in the client bundle (it only runs on the Node half) and a warning
* (not a hard fail) so a fresh DSH without the peer still mounts — the
* tabs simply won't appear until the peer is present.
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export function assertBetterSidebarPeer(
logger: { warn: (msg: string) => void } | undefined,
): void {
let pkg: { version?: string } | undefined
try {
// Dynamic import so the client bundle never statically pulls a Node
// resolution path; the import is guarded and never awaited here.
pkg = (globalThis as { __apesPeer__?: unknown }).__apesPeer__ as { version?: string } | undefined
} catch {
pkg = undefined
}
const version = pkg?.version
if (version && compareSemver(version, '0.18.0') < 0) {
logger?.warn(
`[apes-sidebar] dsh-better-sidebar ${version} is older than the required 0.18.0 — `
+ 'sidebar tabs will not register. Upgrade dsh-better-sidebar.',
)
}
}
/** Compare two semver strings (numeric, prerelease-aware enough for rc tags). */
export function compareSemver(a: string, b: string): number {
const pa = parseSemver(a)
const pb = parseSemver(b)
if (pa.num[0] !== pb.num[0]) return pa.num[0] - pb.num[0]
if (pa.num[1] !== pb.num[1]) return pa.num[1] - pb.num[1]
if (pa.num[2] !== pb.num[2]) return pa.num[2] - pb.num[2]
// Both release, or both prerelease: lexicographic fallback on the tag.
const ta = pa.pre
const tb = pb.pre
if (ta === tb) return 0
if (ta === '') return 1
if (tb === '') return -1
return ta < tb ? -1 : ta > tb ? 1 : 0
}
interface ParsedSemver {
num: [number, number, number]
pre: string
}
function parseSemver(version: string): ParsedSemver {
const m = version.trim().match(/^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/)
if (!m) return { num: [0, 0, 0], pre: '' }
return {
num: [Number(m[1]), Number(m[2]), Number(m[3])],
pre: m[4] ?? '',
}
}
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */
+34
View File
@@ -0,0 +1,34 @@
/**
* Shared "Side card" preference vocabulary (types + constants), consumed by
* BOTH halves: the host registers the schemastery schema over these values
* (config.ts) and the client reads/writes them through the settings RPC
* (client/prefs.ts, client/SideCardSection.tsx). Kept free of schemastery so
* the browser bundle never pulls the schema runtime in.
*/
/** The user-settings namespace holding the side card preferences. */
export const SIDEBAR_PREFS_NS = 'dsh-better-sidebar'
/** User-facing side card preferences (new-conversation defaults). */
export interface SidebarPrefs {
/** Whether a brand-new conversation opens the side card by default. */
openByDefault: boolean
/** Default panel width as a percent of the window width (20–60). */
defaultWidthPercent: number
}
/** Range contract of {@link SidebarPrefs.defaultWidthPercent}. */
export const WIDTH_PERCENT_MIN = 20
export const WIDTH_PERCENT_MAX = 60
export const WIDTH_PERCENT_DEFAULT = 30
/** Fallback prefs used whenever the settings document is unreachable or malformed. */
export const SIDEBAR_PREFS_DEFAULTS: SidebarPrefs = {
openByDefault: true,
defaultWidthPercent: WIDTH_PERCENT_DEFAULT,
}
/** Clamp one width percent into the contract range (shared by schema and client reads). */
export function clampWidthPercent(value: number): number {
return Math.min(WIDTH_PERCENT_MAX, Math.max(WIDTH_PERCENT_MIN, Math.round(value)))
}
+199
View File
@@ -0,0 +1,199 @@
/**
* PTY session table for the sidebar terminals. One node-pty process per
* `${sessionId}:${tabId}` key; processes survive WebSocket disconnects
* (page refresh, tab switch) and reconnect to the same process by key.
* Output is mirrored into a bounded transcript ring (capped bytes) so a new
* connection replays history before live data. Sessions die only when the
* tab is closed or the plugin tears down.
*/
import { chmodSync, existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { createRequire } from 'node:module'
import * as nodePty from 'node-pty'
import { SidebarError } from './wire.ts'
/** Per-terminal transcript bound (bytes kept for replay). */
const TRANSCRIPT_LIMIT = 1 << 20
/**
* Restore the executable bit pnpm strips from node-pty's prebuilt
* spawn-helper (the macOS helper that forks and sets up the pty). Without it
* every spawn fails with `posix_spawnp failed`. Idempotent; mirrors
* @deepseek-ai/dsh-pty-local's ensure-spawn-helper postinstall, run at
* plugin activation so link-installed deployments get the fix too.
*/
export function ensureSpawnHelper(): void {
if (process.platform === 'win32') return
try {
const require = createRequire(import.meta.url)
const entry = require.resolve('node-pty')
const packageRoot = dirname(dirname(entry))
const candidates = [
join(packageRoot, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper'),
join(packageRoot, 'build', 'Release', 'spawn-helper'),
]
for (const helper of candidates) {
if (existsSync(helper)) chmodSync(helper, 0o755)
}
} catch {
// Resolution or chmod failure: the terminal surfaces its own spawn error.
}
}
/** One live terminal. */
export interface SidebarPty {
/** `${sessionId}:${tabId}` registry key. */
key: string
sessionId: string
tabId: string
/** The working directory the process was SPAWNED with (a reconnect that
* resolves a different authoritative cwd respawns instead of reusing —
* the page-load hydrate race can attach the real cwd after the first
* connect, and a shell in the wrong directory must not linger). */
cwd: string
pty: nodePty.IPty
/** Output accumulated since spawn (bounded; head dropped when over the limit). */
transcript: string
/** Whether the top-level process exited (transcript stays replayable). */
exited: boolean
exitCode?: number | null
}
/**
* The terminal registry. `maxPerSession` bounds concurrent processes per
* conversation (the client caps tabs at the same number).
*/
export class PtyManager {
private readonly sessions = new Map<string, SidebarPty>()
private readonly pendingCloses = new Map<string, ReturnType<typeof setTimeout>>()
constructor(
private readonly shell: string,
private readonly maxPerSession: number,
) {}
/** All live terminal keys of one session. */
keysOf(sessionId: string): string[] {
const keys: string[] = []
for (const handle of this.sessions.values()) {
if (handle.sessionId === sessionId) keys.push(handle.key)
}
return keys
}
/**
* Open (or reuse) the terminal for a session/tab key. A handle whose
* process already exited is replaced with a fresh spawn (reconnecting a
* dead terminal must yield a live shell, not an input sink), and so is a
* live handle whose spawn cwd differs from the now-authoritative one (the
* first connect of a page load can arrive before the session hydrates, so
* it fell back to the process cwd — reconnecting with the real cwd must
* restart the shell in the right directory). Reopening also cancels any
* pending scheduled close (a reconnect within the grace window keeps the
* process alive).
* @param sessionId - conversation id.
* @param tabId - client tab id.
* @param cwd - initial working directory (the session's cwd).
* @param cols - initial terminal width.
* @param rows - initial terminal height.
* @returns the live handle.
* @throws {SidebarError} pty-error when the per-session cap is reached.
*/
open(sessionId: string, tabId: string, cwd: string, cols: number, rows: number): SidebarPty {
const key = `${sessionId}:${tabId}`
this.cancelClose(key)
const existing = this.sessions.get(key)
if (existing !== undefined && !existing.exited && existing.cwd === cwd) return existing
if (existing !== undefined) this.close(key)
// Zombie cleanup: a session's exited handles (shell closed, tab dropped
// on an old host without the close frame) must not eat the quota.
for (const [candidate, handle] of [...this.sessions]) {
if (handle.sessionId === sessionId && handle.exited) this.close(candidate)
}
if (this.keysOf(sessionId).length >= this.maxPerSession) {
throw new SidebarError('pty-error', `terminal limit reached (${this.maxPerSession}) for this session`, 400)
}
const handle: SidebarPty = {
key,
sessionId,
tabId,
cwd,
pty: nodePty.spawn(this.shell, [], {
name: 'xterm-256color',
cols: Math.max(2, Math.floor(cols)),
rows: Math.max(2, Math.floor(rows)),
cwd,
env: { ...process.env },
}),
transcript: '',
exited: false,
}
handle.pty.onData((data) => {
handle.transcript += data
if (handle.transcript.length > TRANSCRIPT_LIMIT) {
handle.transcript = handle.transcript.slice(handle.transcript.length - TRANSCRIPT_LIMIT)
}
})
handle.pty.onExit(({ exitCode }) => {
handle.exited = true
handle.exitCode = exitCode
})
this.sessions.set(key, handle)
return handle
}
/**
* Schedule the terminal's destruction after `delayMs`. A tab close sends
* delay 0 (release the quota immediately); a bare socket drop (refresh,
* crash) uses the grace period so a quick reconnect keeps the process.
* `open()` cancels any pending close.
*/
scheduleClose(key: string, delayMs: number): void {
const handle = this.sessions.get(key)
if (handle === undefined) return
this.cancelClose(key)
const timer = setTimeout(() => { this.close(key) }, delayMs)
this.pendingCloses.set(key, timer)
}
/** Cancel a pending scheduled close (the terminal is being reopened). */
cancelClose(key: string): void {
const timer = this.pendingCloses.get(key)
if (timer !== undefined) {
clearTimeout(timer)
this.pendingCloses.delete(key)
}
}
/** Resolve a live handle by key, or undefined. */
get(key: string): SidebarPty | undefined {
return this.sessions.get(key)
}
/** Close a terminal and drop its state (the owning tab was closed). */
close(key: string): void {
this.cancelClose(key)
const handle = this.sessions.get(key)
if (handle === undefined) return
this.sessions.delete(key)
try {
handle.pty.kill()
} catch {
// Already exited or gone; nothing left to kill.
}
}
/** Close every terminal (plugin teardown). */
disposeAll(): void {
for (const timer of this.pendingCloses.values()) clearTimeout(timer)
this.pendingCloses.clear()
for (const key of [...this.sessions.keys()]) this.close(key)
}
}
/** The interactive shell for this platform (empty SHELL falls back). */
export function defaultShell(): string {
if (process.platform === 'win32') return 'powershell.exe'
const shell = process.env.SHELL
return shell !== undefined && shell.trim() !== '' ? shell : '/bin/bash'
}
+77
View File
@@ -0,0 +1,77 @@
/**
* Browser-trust fence for the sidebar routes, behaviorally identical to the
* /api gateway's fence in @deepseek-ai/dsh-client-connection
* (src/api-request-trust.ts + src/loopback-hostname.ts, BSD-3-Clause,
* copied here because the package does not export these helpers and the
* plugin must not depend on its internals). Host-header loopback or a
* configured trusted authority passes; cross-site browser markers refuse.
* This is a DNS-rebinding / cross-site defense, not authentication.
*/
import type { IncomingHttpHeaders } from 'node:http'
/** The request facts the fence reads (structural subset of IncomingMessage). */
interface ApiTrustRequest {
headers: IncomingHttpHeaders
}
function header(headers: IncomingHttpHeaders, name: string): string | undefined {
const value = headers[name]
return typeof value === 'string' ? value : undefined
}
/** Normalized URL of a Host-header authority, or undefined when unparsable. */
function parseAuthority(authority: string): URL | undefined {
try {
return new URL(`http://${authority}`)
} catch {
return undefined
}
}
/** Whether a normalized URL hostname names the local loopback authority. */
export function isLoopbackHostname(hostname: string): boolean {
if (hostname === 'localhost' || hostname === '[::1]') return true
const parts = hostname.split('.')
return parts.length === 4
&& parts[0] === '127'
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
}
/** Canonical authority form: hostname, or hostname:port when a port was written. */
function canonicalAuthority(entry: string, entryUrl: URL): string {
const port = entryUrl.port !== '' ? entryUrl.port : new URL(`https://${entry}`).port
return port === '' ? entryUrl.hostname : `${entryUrl.hostname}:${port}`
}
/** Whether the request authority matches a trustedHosts entry (exact or port-less). */
function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): boolean {
return trustedHosts.some((entry) => {
const entryUrl = parseAuthority(entry)
if (entryUrl === undefined) return false
return canonicalAuthority(entry, entryUrl) === entryUrl.hostname
? entryUrl.hostname === hostUrl.hostname
: entryUrl.host === hostUrl.host
})
}
/**
* Decide whether one sidebar request may reach the plugin routes.
* @param request - node HTTP request facts (headers).
* @param trustedHosts - non-loopback authorities this deployment serves.
* @returns true when the Host is ours (loopback or trusted) and browser markers are same-origin.
*/
export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean {
const host = header(request.headers, 'host')
if (host === undefined) return false
const hostUrl = parseAuthority(host)
if (hostUrl === undefined) return false
if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false
if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false
const origin = header(request.headers, 'origin')
if (origin === undefined) return true
try {
return new URL(origin).host === hostUrl.host
} catch {
return false
}
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Wire helpers for the /sidebar JSON API: bounded body reading, response
* writing, and the shared error envelope. Every API method returns
* `{ok: true, value}` on success and `{ok: false, error: {code, message}}`
* (HTTP 4xx/5xx matching the code) on failure.
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
/** Machine-readable error codes of the sidebar API. */
export type SidebarErrorCode =
| 'bad-request'
| 'not-found'
| 'forbidden'
| 'method-error'
| 'fs-error'
| 'git-error'
| 'pty-error'
| 'settings-rejected'
| 'settings-conflict'
| 'internal'
/** One API failure with its wire code and HTTP status. */
export class SidebarError extends Error {
constructor(
readonly code: SidebarErrorCode,
message: string,
readonly status = 400,
) {
super(message)
}
}
/** Body size bound of one JSON request (defense against unbounded reads). */
const MAX_BODY_BYTES = 1 << 20
/** Success envelope of one API method. */
export interface SidebarOk<T> { ok: true; value: T }
/** Failure envelope of one API method. */
export interface SidebarErr { ok: false; error: { code: SidebarErrorCode; message: string } }
/** Read and parse the JSON request body (bounded; malformed → bad-request). */
export async function readJsonBody(req: IncomingMessage): Promise<unknown> {
const chunks: Buffer[] = []
let total = 0
for await (const chunk of req) {
const buffer = typeof chunk === 'string' ? Buffer.from(chunk) : chunk
total += buffer.length
if (total > MAX_BODY_BYTES) {
throw new SidebarError('bad-request', 'request body too large')
}
chunks.push(buffer)
}
const text = Buffer.concat(chunks).toString('utf8')
if (text.trim() === '') return {}
try {
return JSON.parse(text) as unknown
} catch {
throw new SidebarError('bad-request', 'request body is not valid JSON')
}
}
/** Write a JSON response with the given status. */
export function writeJson(res: ServerResponse, status: number, body: unknown): void {
const payload = JSON.stringify(body)
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
res.end(payload)
}
/** Write the success envelope. */
export function writeOk(res: ServerResponse, value: unknown): void {
writeJson(res, 200, { ok: true, value })
}
/** Write the failure envelope for any thrown value (unknown → internal 500). */
export function writeError(res: ServerResponse, error: unknown): void {
if (error instanceof SidebarError) {
writeJson(res, error.status, { ok: false, error: { code: error.code, message: error.message } })
return
}
const message = error instanceof Error ? error.message : String(error)
writeJson(res, 500, { ok: false, error: { code: 'internal', message } })
}
/** Narrow an unknown payload value to a string, else throw bad-request. */
export function requireString(payload: unknown, key: string): string {
const record = payload as Record<string, unknown> | null
const value = record?.[key]
if (typeof value !== 'string' || value === '') {
throw new SidebarError('bad-request', `missing or invalid "${key}"`)
}
return value
}
-128
View File
@@ -1,128 +0,0 @@
/**
* Locale contract for the client half. The apes:* tabs are bilingual
* (zh default, en fallback) — the same shape tutti-os ships. This spec pins
* the i18n surface against the REAL locale module API:
* - t() resolves the active locale, falling back en → raw key
* - registerLocales() syncs activeLocale from a mock ApesLocaleService and
* keeps it in sync as the runtime emits new snapshots
* - subscribeLocale() re-render hook + clean detach
* The React hook (useApesLocale) needs a renderer; it is a thin
* useSyncExternalStore wrapper over the same getSnapshot/subscribe pair and
* is exercised indirectly here.
*/
import { describe, expect, it } from 'vitest'
import {
t,
getActiveLocale,
subscribeLocale,
registerLocales,
} from '../src/client/locales.ts'
import type { ApesLocaleService } from '../src/context-types.ts'
/** Build a mock ApesLocaleService whose active locale we can flip. */
function makeLocaleService(initialActive = 'zh'): {
service: ApesLocaleService
setActive: (loc: string) => void
registered: Array<{ ns: string; locale: string }>
} {
let active = initialActive
const subs = new Set<() => void>()
const registered: Array<{ ns: string; locale: string }> = []
const service: ApesLocaleService = {
getSnapshot: () => ({ active }),
subscribe: (fn) => {
subs.add(fn)
return () => subs.delete(fn)
},
register: (ns, locale) => {
registered.push({ ns, locale })
return () => {}
},
}
return {
service,
setActive: (loc) => {
active = loc
for (const fn of [...subs]) fn()
},
registered,
}
}
describe('locales module (real API)', () => {
it('resolves keys in the default zh locale', () => {
// Before any registerLocales call, activeLocale is the module default.
const active = getActiveLocale()
expect(['zh', 'en']).toContain(active)
// Every registered key resolves to a non-empty string.
expect(t('tab.tasks.title').length).toBeGreaterThan(0)
expect(t('tab.agents.title').length).toBeGreaterThan(0)
// A key in neither locale falls back to the raw key (flag the gap).
expect(t('does.not.exist')).toBe('does.not.exist')
})
it('registerLocales registers zh+en under the apesSidebar namespace', () => {
const { service, registered } = makeLocaleService('zh')
const dispose = registerLocales(service)
expect(registered.map((r) => r.locale).sort()).toEqual(['en', 'zh'])
expect(registered.every((r) => r.ns === 'apesSidebar')).toBe(true)
dispose() // clean disposer, no throw on double-run
expect(() => dispose()).not.toThrow()
})
it('registerLocales seeds activeLocale from the runtime snapshot', () => {
const { service } = makeLocaleService('en')
const dispose = registerLocales(service)
expect(getActiveLocale()).toBe('en')
expect(t('tab.tasks.title')).toBe('Tasks')
dispose()
})
it('keeps activeLocale in sync as the runtime emits a new active locale', () => {
const { service, setActive } = makeLocaleService('zh')
const dispose = registerLocales(service)
expect(getActiveLocale()).toBe('zh')
const before = t('tab.tasks.title')
setActive('en')
expect(getActiveLocale()).toBe('en')
expect(t('tab.tasks.title')).toBe('Tasks')
// The value actually changed.
expect(before).not.toBe(t('tab.tasks.title'))
dispose()
})
it('subscribeLocale notifies on locale change and detaches cleanly', () => {
const { service, setActive } = makeLocaleService('zh')
const dispose = registerLocales(service)
const seen: string[] = []
const unsub = subscribeLocale(() => seen.push(getActiveLocale()))
setActive('en')
expect(seen).toEqual(['en'])
unsub()
setActive('zh')
// After detach, no further observation.
expect(seen).toEqual(['en'])
dispose()
})
it('is a no-op (clean disposer) when the locale runtime is absent', () => {
const dispose = registerLocales(undefined)
expect(typeof dispose).toBe('function')
expect(() => dispose()).not.toThrow()
})
it('tolerates a runtime whose subscribe throws (early boot guard)', () => {
const service: ApesLocaleService = {
getSnapshot: () => ({ active: 'en' }),
subscribe: () => {
throw new Error('runtime not ready')
},
register: () => () => {},
}
// The try/catch in registerLocales swallows the subscribe failure and
// still returns a working disposer.
const dispose = registerLocales(service)
expect(typeof dispose).toBe('function')
expect(() => dispose()).not.toThrow()
})
})
+79
View File
@@ -0,0 +1,79 @@
/**
* Registry manifest consistency spec: guards the plugin-registry release form
* (`dsh.plugin.json`) against the build output and package.json, so the repo
* cannot drift into an uninstallable / uneable state:
* - `id` passes the registry's strict two-segment validation (native form),
* - `version` stays in sync with package.json,
* - the manifest entry files exist in the build output,
* - the registry client bundle registers exactly the manifest id (the
* browser-side `arrive()` contract), and the official client bundle keeps
* the package-name id (the client-modules compose contract) — the two
* bundles must never be swapped.
*
* Reads the built lib/ output, so run `pnpm build` first (the registry
* install validates main/client.main existence the same way).
*/
import { describe, expect, it } from 'vitest'
import { existsSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const ROOT = resolve(fileURLToPath(new URL('..', import.meta.url)))
/** The registry manifest face this spec touches (mirror of the registry's PluginManifest). */
interface PluginManifest {
id: string
version: string
main: string
description?: string
engines?: { dsh?: string }
contributes?: { tools?: string[]; skills?: string[] }
client?: { main?: string; inject?: string[]; immediately?: boolean }
}
interface PackageJson {
name: string
version: string
}
const manifest = JSON.parse(readFileSync(resolve(ROOT, 'dsh.plugin.json'), 'utf8')) as PluginManifest
const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf8')) as PackageJson
/** The registry's strict id validation (manifest.ts / client-modules registerExternal): exactly two lowercase slash-separated segments. */
const ID_PATTERN = /^(?!node_modules\/)(?:@[a-z0-9][a-z0-9-.]*\/[a-z0-9][a-z0-9-.]*|[a-z0-9][a-z0-9-.]*\/[a-z0-9][a-z0-9-.]*)$/
/** The registered `__ModuleLoader__.load({ id })` value of a built client bundle. */
function bundleId(file: string): string {
const source = readFileSync(resolve(ROOT, file), 'utf8')
const match = /load\(\{\s*id:\s*"([^"]+)"/.exec(source)
if (match === null) throw new Error(`${file} registers no __ModuleLoader__.load id — run \`pnpm build\` first`)
return match[1]!
}
describe('registry manifest consistency (dsh.plugin.json)', () => {
it('id is a valid two-segment registry id (native form)', () => {
expect(manifest.id).toMatch(ID_PATTERN)
})
it('version matches package.json', () => {
expect(manifest.version).toBe(pkg.version)
})
it('main and client.main exist in the build output', () => {
expect(existsSync(resolve(ROOT, manifest.main))).toBe(true)
expect(manifest.client?.main).toBeDefined()
expect(existsSync(resolve(ROOT, manifest.client!.main!))).toBe(true)
})
it('the registry bundle registers exactly the manifest id and the official bundle the package name (no swap)', () => {
const registryId = bundleId(manifest.client!.main!)
expect(registryId).toBe(manifest.id)
expect(bundleId('lib/client.js')).toBe(pkg.name)
expect(registryId).not.toBe(pkg.name)
})
it('contributes declares no tools or skills (the host half registers none)', () => {
expect(manifest.contributes?.tools).toEqual([])
expect(manifest.contributes?.skills).toEqual([])
})
})
+54
View File
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest'
import Loader from '@cordisjs/plugin-loader'
import * as sidebar from '../src/index.ts'
/**
* Run the real namespace export through `Loader.unwrapExports`; a stray
* default would discard `name`, `inject`, `Config`, and `apply`. Same guard
* the official plugin repos ship (dsh-external/turtle-ui,
* packages/ui/jsonrpc).
*/
describe('dsh-better-sidebar plugin export shape', () => {
it('has the namespace-plugin export shape (no stray default) so the Loader keeps name/inject/Config/apply', () => {
expect('default' in sidebar).toBe(false)
expect(typeof sidebar.apply).toBe('function')
const loader = Object.create(Loader.prototype) as Loader
const unwrapped = loader.unwrapExports(sidebar) as Record<string, unknown>
expect(unwrapped).toBe(sidebar)
expect(unwrapped.name).toBe('dsh-better-sidebar')
expect(unwrapped.inject).toEqual(['httpServer', 'sessions', 'loader'])
expect(unwrapped.Config).toBeDefined()
expect(typeof unwrapped.apply).toBe('function')
})
it('exports the schemastery Config with the documented tunable fields', () => {
const schema = sidebar.Config
expect(schema).toBeDefined()
// The resolved defaults mirror the pre-config constants.
const resolved = (schema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})(undefined)
expect(resolved.readLimit).toBe(512 * 1024)
expect(resolved.mediaLimit).toBe(20 * 1024 * 1024)
expect(resolved.listLimit).toBe(1000)
expect(resolved.terminalsPerSession).toBe(3)
expect(resolved.reconnectGraceMs).toBe(30_000)
})
it('registers the side card preferences schema with the documented defaults', async () => {
const { PrefsSchema, SIDEBAR_PREFS_NS } = await import('../src/config.ts')
expect(SIDEBAR_PREFS_NS).toBe('dsh-better-sidebar')
const resolved = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})(undefined)
expect(resolved.openByDefault).toBe(true)
expect(resolved.defaultWidthPercent).toBe(30)
// A stored overridden value resolves through (the range contract is
// enforced by the settings service on write).
const overridden = (PrefsSchema as unknown as {
(input: Record<string, unknown> | undefined): Record<string, unknown>
})({ openByDefault: false, defaultWidthPercent: 45 })
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45 })
})
})
+343 -92
View File
@@ -1,114 +1,365 @@
/**
* Phase 0 smoke spec: mounts the host plugin against a minimal fake context,
* verifies the plugin export shape (no stray default, so the cordis Loader
* keeps name/inject/apply), and exercises the invariant surface. Runs with
* `pnpm test`.
*
* The client half (lib/client.js closure factory) is a browser artifact — it
* registers tabs into the better-sidebar host registry at runtime, which
* needs a live DSH shell. The client registration contract is therefore
* covered by a focused unit spec (locales) + the build's purity gate; a full
* mount smoke (cordis scratch profile) lands in Phase 1 with the real tabs.
* Smoke spec: mounts the host plugin against a minimal fake context and
* exercises the real integrations — route registration, git against the
* actual repository, and a real directory listing. Runs with `pnpm test`.
*/
import { describe, expect, it } from 'vitest'
import * as host from '../src/index.ts'
import {
InvariantError,
assert,
assertBetterSidebarPeer,
compareSemver,
} from '../src/invariant.ts'
import { tmpdir } from 'node:os'
import { resolve as resolvePath } from 'node:path'
import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { apply } from '../src/index.ts'
import * as git from '../src/git.ts'
import { listDirectory } from '../src/fs-tree.ts'
import { defaultShell, PtyManager } from '../src/pty-manager.ts'
import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts'
/**
* Run the real namespace export through `Loader.unwrapExports`; a stray
* default would discard `name`, `inject`, and `apply`. Same guard the
* official plugin repos ship.
*/
describe('apes-sidebar host plugin shape', () => {
it('has the namespace-plugin export shape (no stray default)', () => {
expect('default' in host).toBe(false)
expect(typeof host.apply).toBe('function')
expect(host.name).toBe('apes-sidebar')
// inject MUST be empty: `logger` is used only via optional-chaining in
// apply(ctx) — declaring it as an injected service would make the entry
// wait on a provider DSH host does not register, blocking activation
// (recovery mode). See src/index.ts comment.
expect(host.inject).toEqual([])
})
interface FakeContext {
loader: { entries: () => never[] }
httpServer: {
register: (route: SidebarWebRoute) => () => void
registerUpgrade: (route: SidebarWebUpgradeRoute) => () => void
}
sessions: { get: (id: string) => { header: { cwd?: string } } | undefined }
effect: (fn: () => void | (() => void), label?: string) => void
/** The settings service never appears in the smoke context: the inject
* callback must never run (mirror of cordis' service-less inject). */
inject: (deps: readonly string[], callback: (sctx: never) => void) => () => void
}
it('apply mounts a logger line through the effect (Phase 0 minimal)', () => {
const logs: string[] = []
let effects = 0
const ctx = {
logger: { info: (msg: string) => logs.push(msg) },
effect: (fn: () => void | (() => void)) => {
effects += 1
describe('host plugin smoke', () => {
it('mounts the fenced routes', () => {
const routes: SidebarWebRoute[] = []
const upgrades: SidebarWebUpgradeRoute[] = []
const effects: Array<() => void | (() => void)> = []
const ctx: FakeContext = {
loader: { entries: () => [] },
httpServer: {
register: (route) => { routes.push(route); return () => {} },
registerUpgrade: (route) => { upgrades.push(route); return () => {} },
},
sessions: { get: () => undefined },
// The DSH-vendored cordis runs the registration effect immediately and
// keeps its cleanup for disposal.
effect: (fn) => {
const cleanup = fn()
return () => {
if (typeof cleanup === 'function') cleanup()
}
if (typeof cleanup === 'function') effects.push(cleanup)
},
// No settings service in the smoke context: the registration callback
// never runs (cordis' service-less inject behaves the same).
inject: () => () => {},
}
host.apply(ctx as never)
expect(effects).toBe(1)
expect(logs).toEqual(['[apes-sidebar] host half mounted (Phase 0 skeleton)'])
apply(ctx as never)
expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/file'])
expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal'])
// Teardown runs without throwing (pty manager has nothing open).
for (const cleanup of effects) cleanup()
})
it('apply survives a missing logger (degrades, never throws)', () => {
let effects = 0
it('runs git status/log/branches against this repository', async () => {
const cwd = process.cwd()
const status = await git.status(cwd)
expect(status.isRepo).toBe(true)
expect(typeof status.branch).toBe('string')
expect(Array.isArray(status.entries)).toBe(true)
const log = await git.log(cwd)
expect(log.length).toBeGreaterThan(0)
expect(log[0]!.hash).toMatch(/^[0-9a-f]{7,}$/)
const branches = await git.branches(cwd)
expect(branches.names).toContain(branches.current)
})
it('pty manager releases the quota on close and respawns after exit', async () => {
const manager = new PtyManager(defaultShell(), 3)
try {
const first = manager.open('s1', 't1', process.cwd(), 80, 24)
expect(manager.keysOf('s1')).toHaveLength(1)
// Tab-close semantics (close frame): quota released immediately.
manager.scheduleClose(first.key, 0)
await new Promise(resolve => setTimeout(resolve, 50))
expect(manager.keysOf('s1')).toHaveLength(0)
// Reopen spawns a fresh process.
const second = manager.open('s1', 't1', process.cwd(), 80, 24)
expect(second).not.toBe(first)
expect(manager.keysOf('s1')).toHaveLength(1)
// After the shell exits, a reconnect respawns instead of reusing the dead handle.
second.pty.write('exit\r')
const deadline = Date.now() + 5000
while (!second.exited && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 100))
}
expect(second.exited).toBe(true)
const third = manager.open('s1', 't1', process.cwd(), 80, 24)
expect(third.exited).toBe(false)
expect(third).not.toBe(second)
} finally {
manager.disposeAll()
}
})
it('pty manager: exited zombie handles do not consume the quota', async () => {
const manager = new PtyManager(defaultShell(), 1)
try {
const first = manager.open('s3', 't1', process.cwd(), 80, 24)
first.pty.write('exit\r')
const deadline = Date.now() + 5000
while (!first.exited && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 100))
}
expect(first.exited).toBe(true)
// Quota is 1; the exited handle is swept, so a NEW tab can still spawn.
const second = manager.open('s3', 't2', process.cwd(), 80, 24)
expect(second.exited).toBe(false)
expect(manager.keysOf('s3')).toHaveLength(1)
} finally {
manager.disposeAll()
}
})
it('pty manager: a reconnect within the grace period cancels the pending close', async () => {
const manager = new PtyManager(defaultShell(), 3)
try {
const handle = manager.open('s2', 't1', process.cwd(), 80, 24)
manager.scheduleClose(handle.key, 200)
manager.open('s2', 't1', process.cwd(), 80, 24)
await new Promise(resolve => setTimeout(resolve, 400))
expect(manager.get(handle.key)).toBeDefined()
} finally {
manager.disposeAll()
}
})
it('pty manager: reopening with a different cwd respawns in the new directory', async () => {
const manager = new PtyManager(defaultShell(), 3)
// A real second directory: os.tmpdir() exists on every platform ('/tmp'
// does not exist on Windows).
const other = tmpdir()
try {
const first = manager.open('s4', 't1', process.cwd(), 80, 24)
// The hydrate race: the first connect fell back to the process cwd,
// the reconnect carries the session's real cwd — the shell must move.
const second = manager.open('s4', 't1', other, 80, 24)
expect(second).not.toBe(first)
expect(second.cwd).toBe(other)
expect(manager.keysOf('s4')).toHaveLength(1)
// A same-cwd reconnect reattaches without respawning.
const third = manager.open('s4', 't1', other, 80, 24)
expect(third).toBe(second)
expect(manager.keysOf('s4')).toHaveLength(1)
} finally {
manager.disposeAll()
}
})
it('lists the repository root level', async () => {
const listing = await listDirectory(process.cwd(), 1000)
expect(listing.entries.some(entry => entry.name === 'src' && entry.isDir)).toBe(true)
expect(listing.entries.some(entry => entry.name === 'package.json' && !entry.isDir)).toBe(true)
expect(listing.truncated).toBe(false)
})
})
describe('session cwd resolution over the API route', () => {
interface CtxOverrides {
sessions?: { get: (id: string) => { header: { cwd?: string } } | undefined }
}
const mount = (overrides: CtxOverrides = {}): SidebarWebRoute => {
const routes: SidebarWebRoute[] = []
const ctx = {
effect: (fn: () => void | (() => void)) => {
effects += 1
fn()
loader: { entries: () => [] },
httpServer: {
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
},
sessions: overrides.sessions ?? { get: () => undefined },
// The vendored cordis runs registration effects immediately.
effect: (fn: () => void | (() => void)) => { fn() },
// No settings service: the namespace registration never runs.
inject: () => () => {},
}
apply(ctx as never)
return routes.find(route => route.path === '/sidebar/api')!
}
const invoke = async (
route: SidebarWebRoute,
method: string,
payload: unknown,
): Promise<{ ok: boolean; value?: { cwd: string }; error?: { message: string } }> => {
const body = Buffer.from(JSON.stringify(payload))
const req = {
method: 'POST',
url: `/sidebar/api/${method}`,
headers: { host: '127.0.0.1:3080' },
[Symbol.asyncIterator]: async function* () { yield body },
} as never
const out: { status: number; body: string } = { status: 200, body: '' }
const res = {
writeHead: (status: number) => { out.status = status },
end: (chunk: unknown) => { out.body += String(chunk ?? '') },
} as never
await route.handler(req, res)
return JSON.parse(out.body) as { ok: boolean; value?: { cwd: string }; error?: { message: string } }
}
it('uses the client summary cwd while the session is detached', async () => {
const route = mount()
const result = await invoke(route, 'session.cwd', { sessionId: 's-detached', cwd: '/tmp/summary-cwd' })
expect(result.ok).toBe(true)
// The summary cwd passes through requireAbsolute (platform resolve), so
// the expectation follows the platform's own normalization.
expect(result.value?.cwd).toBe(resolvePath('/tmp/summary-cwd'))
})
it('falls back to the process cwd with no summary cwd', async () => {
const route = mount()
const result = await invoke(route, 'session.cwd', { sessionId: 's-unknown' })
expect(result.ok).toBe(true)
expect(result.value?.cwd).toBe(process.cwd())
})
it('prefers the attached session header over the client summary', async () => {
const route = mount({
sessions: {
get: (id) => id === 's-attached' ? { header: { cwd: '/attached-cwd' } } : undefined,
},
})
const result = await invoke(route, 'session.cwd', { sessionId: 's-attached', cwd: '/tmp/summary-cwd' })
expect(result.ok).toBe(true)
expect(result.value?.cwd).toBe('/attached-cwd')
})
it('rejects a non-absolute client cwd', async () => {
const route = mount()
const result = await invoke(route, 'session.cwd', { sessionId: 's-detached', cwd: 'relative/path' })
expect(result.ok).toBe(false)
expect(result.error?.message).toMatch(/invalid working directory/)
})
it('pty.close releases a terminal key (and rejects a missing tab)', async () => {
const route = mount()
const result = await invoke(route, 'pty.close', { sessionId: 's-pty', tab: 't1' })
expect(result.ok).toBe(true)
const missing = await invoke(route, 'pty.close', { sessionId: 's-pty' })
expect(missing.ok).toBe(false)
})
})
describe('side card settings routes', () => {
/** A minimal settings seam: register/describe/update with the revision guard. */
const createFakeSettings = () => {
const namespaces = new Map<string, {
schema: unknown
value: Record<string, unknown> | undefined
revision: number
}>()
const resolve = (entry: { schema: unknown; value: Record<string, unknown> | undefined }): unknown => {
const schema = entry.schema as (input: unknown) => unknown
return entry.value === undefined ? schema(undefined) : schema(entry.value)
}
return {
register(ns: string, schema: unknown) {
namespaces.set(ns, { schema, value: undefined, revision: 0 })
return { get: () => ({}), watch: () => () => {}, update: async () => {}, replace: async () => {} }
},
describe() {
return [...namespaces.entries()].map(([ns, entry]) => ({
ns,
value: resolve(entry),
applies: 'live' as const,
revision: entry.revision,
}))
},
async update(ns: string, patch: Record<string, unknown>, expectedRevision?: number) {
const entry = namespaces.get(ns)
if (entry === undefined) throw new Error(`settings namespace "${ns}" is not registered`)
if (expectedRevision !== undefined && expectedRevision !== entry.revision) {
throw new SettingsConflictError(settingsNamespace(ns), expectedRevision, entry.revision)
}
entry.value = { ...entry.value, ...patch }
entry.revision += 1
},
}
expect(() => host.apply(ctx as never)).not.toThrow()
expect(effects).toBe(1)
}
const mountWithSettings = (settings?: unknown): SidebarWebRoute => {
const routes: SidebarWebRoute[] = []
const ctx = {
loader: { entries: () => [] },
httpServer: {
register: (route: SidebarWebRoute) => { routes.push(route); return () => {} },
registerUpgrade: (route: SidebarWebUpgradeRoute) => { void route; return () => {} },
},
sessions: { get: () => undefined },
effect: (fn: () => void | (() => void)) => { fn() },
inject: (deps: string[], callback: (sctx: { settings: unknown }) => void) => {
if (deps.includes('settings') && settings !== undefined) callback({ settings })
return () => {}
},
}
apply(ctx as never)
return routes.find(route => route.path === '/sidebar/api')!
}
const invoke = async (route: SidebarWebRoute, method: string, payload: unknown): Promise<{
ok: boolean
value?: unknown
error?: { code?: string; message: string }
}> => {
const body = Buffer.from(JSON.stringify(payload))
const req = {
method: 'POST',
url: `/sidebar/api/${method}`,
headers: { host: '127.0.0.1:3080' },
[Symbol.asyncIterator]: async function* () { yield body },
} as never
const out: { status: number; body: string } = { status: 200, body: '' }
const res = {
writeHead: (status: number) => { out.status = status },
end: (chunk: unknown) => { out.body += String(chunk ?? '') },
} as never
await route.handler(req, res)
return JSON.parse(out.body) as { ok: boolean; value?: unknown; error?: { code?: string; message: string } }
}
it('serves the schema defaults when the settings service is absent', async () => {
const route = mountWithSettings(undefined)
const result = await invoke(route, 'settings.get', {})
expect(result.ok).toBe(true)
expect(result.value).toEqual({ value: undefined, revision: undefined })
})
it('apply without an effect API still runs once synchronously', () => {
const logs: string[] = []
const ctx = { logger: { info: (msg: string) => logs.push(msg) } }
host.apply(ctx as never)
expect(logs).toEqual(['[apes-sidebar] host half mounted (Phase 0 skeleton)'])
})
})
it('reads the resolved prefs and writes a patch through the seam', async () => {
const route = mountWithSettings(createFakeSettings())
const read = await invoke(route, 'settings.get', {})
expect(read.ok).toBe(true)
expect(read.value).toEqual({ value: { openByDefault: true, defaultWidthPercent: 30 }, revision: 0 })
describe('apes-sidebar invariant surface', () => {
it('assert throws an InvariantError on a falsy condition', () => {
expect(() => assert(false, 'boom')).toThrow(InvariantError)
expect(() => assert(false, 'boom')).toThrow('boom')
// A truthy condition passes silently.
expect(() => assert(true, 'ok')).not.toThrow()
expect(() => assert(1, 'ok')).not.toThrow()
const written = await invoke(route, 'settings.update', { patch: { openByDefault: false } })
expect(written.ok).toBe(true)
const view = written.value as { value: { openByDefault: boolean; defaultWidthPercent: number }; revision: number }
expect(view.value.openByDefault).toBe(false)
expect(view.value.defaultWidthPercent).toBe(30)
expect(view.revision).toBe(1)
})
it('compareSemver orders versions (numeric + rc prerelease)', () => {
expect(compareSemver('0.17.9', '0.18.0')).toBeLessThan(0)
expect(compareSemver('0.18.0', '0.18.0')).toBe(0)
expect(compareSemver('0.19.0', '0.18.0')).toBeGreaterThan(0)
expect(compareSemver('1.0.0', '0.99.99')).toBeGreaterThan(0)
// Prerelease sorts below the release it qualifies.
expect(compareSemver('0.18.0-rc.1', '0.18.0')).toBeLessThan(0)
expect(compareSemver('0.18.0-rc.1', '0.18.0-rc.2')).toBeLessThan(0)
it('refuses a stale write with settings-conflict (409)', async () => {
const route = mountWithSettings(createFakeSettings())
await invoke(route, 'settings.update', { patch: { openByDefault: false } })
// The second write carries the pre-write revision: the seam refuses it.
const stale = await invoke(route, 'settings.update', {
patch: { defaultWidthPercent: 40 },
expectedRevision: 0,
})
expect(stale.ok).toBe(false)
expect(stale.error?.code).toBe('settings-conflict')
expect(stale.error?.message).toMatch(/changed since it was read/)
})
it('assertBetterSidebarPeer warns on an old peer (no logger → no throw)', () => {
const warnings: string[] = []
const warn = (msg: string) => warnings.push(msg)
// An old peer (0.17.x) triggers a warn.
;(globalThis as Record<string, unknown>).__apesPeer__ = { version: '0.17.9' }
assertBetterSidebarPeer({ warn })
expect(warnings).toHaveLength(1)
expect(warnings[0]).toContain('0.17.9')
// A new peer (0.18.x) does not.
warnings.length = 0
;(globalThis as Record<string, unknown>).__apesPeer__ = { version: '0.18.0' }
assertBetterSidebarPeer({ warn })
expect(warnings).toHaveLength(0)
// No logger at all: still no throw.
;(globalThis as Record<string, unknown>).__apesPeer__ = { version: '0.17.0' }
expect(() => assertBetterSidebarPeer(undefined)).not.toThrow()
delete (globalThis as Record<string, unknown>).__apesPeer__
it('rejects a non-object patch as bad-request', async () => {
const route = mountWithSettings(createFakeSettings())
const result = await invoke(route, 'settings.update', { patch: 'nope' })
expect(result.ok).toBe(false)
expect(result.error?.message).toMatch(/plain object/)
})
})
-267
View File
@@ -1,267 +0,0 @@
/**
* Phase 1 tab-registration spec: mounts the CLIENT half against a mock
* better-sidebar registry and verifies the five `apes:*` tabs register with
* the right descriptors, the disposer tears them down (HMR-safe), and a
* missing `betterSidebar` peer degrades without throwing.
*
* The React components are NOT rendered here (Phase 0 testing convention:
* node vitest environment, no DOM). The descriptors' `component` field is
* asserted to be a function; the JSX it returns is only evaluated when the
* host actually mounts the tab, which a live DSH shell does (Phase 1+ mount
* smoke). What this spec pins is the REGISTRATION CONTRACT: the ids, the
* title/order/single shape, and the disposer lifecycle.
*
* The mock registry records every registered descriptor and returns a
* disposer that removes it — mirroring the real better-sidebar
* `registerTab` (returns `() => void`, invoked by cordis on fiber teardown).
*/
import { describe, expect, it } from 'vitest'
import { apply, filterTasksRows } from '../src/client/index.tsx'
/** The five tab ids this plugin owns (the + menu contract). */
const EXPECTED_IDS = ['apes:tasks', 'apes:agents', 'apes:apps', 'apes:messages', 'apes:profile']
/** One registered descriptor shape the mock records (the fields we assert). */
interface Registered {
id: string
title: string | (() => string)
order?: number
single?: boolean
component: unknown
badge?: (ctx: unknown, scope: unknown, state: unknown) => string | number | null | undefined
}
/**
* A mock better-sidebar registry: `registerTab` records the descriptor and
* returns a disposer that removes it (the real service's contract).
*/
function makeMockRegistry() {
const registered: Registered[] = []
return {
registry: {
registerTab: (descriptor: Registered) => {
registered.push(descriptor)
return () => {
const i = registered.findIndex((r) => r.id === descriptor.id)
if (i !== -1) registered.splice(i, 1)
}
},
getTabs: () => registered,
subscribe: () => () => undefined,
},
registered,
}
}
/** A no-op locale service (registerLocales only needs register/getSnapshot/subscribe). */
function makeMockLocale() {
return {
getSnapshot: () => ({ active: 'zh' }),
subscribe: () => () => undefined,
register: () => () => undefined,
}
}
/**
* Drive a cordis effect: invoke the effect body, capture its returned
* disposer, and expose `teardown()` so a test can simulate fiber unmount.
*/
function makeEffectHarness() {
let disposer: (() => void) | undefined
return {
effect: (fn: () => void | (() => void)) => {
const result = fn()
if (typeof result === 'function') disposer = result
return () => disposer?.()
},
teardown: () => disposer?.(),
}
}
describe('apes-sidebar client tab registration (Phase 1)', () => {
it('registers all five apes:* tabs into the better-sidebar registry', () => {
const { registry, registered } = makeMockRegistry()
const eff = makeEffectHarness()
apply({ betterSidebar: registry, locale: makeMockLocale(), ...eff } as never)
expect(registered.map((r) => r.id).sort()).toEqual([...EXPECTED_IDS].sort())
expect(registry.getTabs()).toHaveLength(5)
})
it('gives every tab a title function, an ascending order, single-instance, and a component fn', () => {
const { registered } = makeMockRegistry()
const eff = makeEffectHarness()
apply({ betterSidebar: makeMockRegistry().registry, locale: makeMockLocale(), ...eff } as never)
// Re-run on the registry whose descriptors we captured.
const { registry, registered: reg2 } = makeMockRegistry()
const eff2 = makeEffectHarness()
apply({ betterSidebar: registry, locale: makeMockLocale(), ...eff2 } as never)
for (const d of reg2) {
expect(EXPECTED_IDS).toContain(d.id)
// title is a function (i18n-friendly) and resolves to a non-empty string.
expect(typeof d.title === 'function' ? typeof d.title() : typeof d.title).toBe('string')
if (typeof d.title === 'function') expect(d.title().length).toBeGreaterThan(0)
// order is present and numeric.
expect(typeof d.order).toBe('number')
// single-instance sugar (each apes:* tab is a singleton panel).
expect(d.single).toBe(true)
// component is a render function.
expect(typeof d.component).toBe('function')
}
// orders are strictly ascending in registration order.
const orders = reg2.map((d) => d.order as number)
for (let i = 1; i < orders.length; i++) {
expect(orders[i]!).toBeGreaterThan(orders[i - 1]!)
}
})
it('the combined disposer removes all five tabs (HMR-safe teardown)', () => {
const { registry } = makeMockRegistry()
const eff = makeEffectHarness()
apply({ betterSidebar: registry, locale: makeMockLocale(), ...eff } as never)
expect(registry.getTabs()).toHaveLength(5)
// Simulate fiber unmount: cordis invokes the effect's returned disposer.
eff.teardown()
expect(registry.getTabs()).toHaveLength(0)
// A second teardown is a safe no-op (double-dispose is swallowed).
expect(() => eff.teardown()).not.toThrow()
})
it('does not register when betterSidebar is absent (bare DSH degrades)', () => {
const eff = makeEffectHarness()
expect(() => apply({ locale: makeMockLocale(), ...eff } as never)).not.toThrow()
// No effect was run (the guard returns before ctx.effect).
// The effect harness captured nothing.
expect(eff.effect).toBeTypeOf('function')
})
it('registers synchronously when no effect API is present (non-cordis harness)', () => {
const { registry } = makeMockRegistry()
// No `effect` on the context → apply registers synchronously.
apply({ betterSidebar: registry, locale: makeMockLocale() } as never)
expect(registry.getTabs()).toHaveLength(5)
})
it('tab ids follow the apes:* namespace (never wb:* or builtin ids)', () => {
const { registered } = makeMockRegistry()
const eff = makeEffectHarness()
apply({ betterSidebar: makeMockRegistry().registry, locale: makeMockLocale(), ...eff } as never)
for (const d of registered) {
expect(d.id.startsWith('apes:')).toBe(true)
expect(d.id).not.toMatch(/^wb:/)
}
})
})
/**
* Build a mock `ctx.sessions.list.getSnapshot()` feed for badge assertions.
* `byId` maps session id → a summary with optional `running` / `origin`.
*/
function makeMockSessionsCtx(byId: Record<string, { running?: boolean; origin?: string }>) {
const snapshot = { current: undefined as string | undefined, byId }
return {
sessions: {
list: {
getSnapshot: () => snapshot,
subscribe: () => () => undefined,
},
},
}
}
/**
* Phase 2 badge contract: `apes:tasks` shows the running-session count,
* `apes:agents` shows the subagent count, and BOTH hide (return null) when
* their count is 0. The badge is a pure `(ctx) => …` function, so it is
* exercised directly against a mock sessions feed — no DOM, no rendering.
*/
describe('apes-sidebar tab badges (Phase 2)', () => {
/** Register into a fresh mock registry and return the captured descriptors. */
function registeredDescriptors() {
const { registry, registered } = makeMockRegistry()
const eff = makeEffectHarness()
apply({ betterSidebar: registry, locale: makeMockLocale(), ...eff } as never)
return registered
}
function badgeOf(registered: Registered[], id: string) {
return registered.find((d) => d.id === id)?.badge
}
it('apes:tasks badge is the running-session count (2 running → 2)', () => {
const badge = badgeOf(registeredDescriptors(), 'apes:tasks')
expect(badge).toBeTypeOf('function')
const ctx = makeMockSessionsCtx({
a: { running: true },
b: { running: true },
c: { running: false },
})
expect(badge!(ctx as never, {} as never, {} as never)).toBe(2)
})
it('apes:tasks badge is null when nothing is running (hidden pill)', () => {
const badge = badgeOf(registeredDescriptors(), 'apes:tasks')
const ctx = makeMockSessionsCtx({ a: { running: false }, b: {} })
expect(badge!(ctx as never, {} as never, {} as never)).toBeNull()
})
it('apes:agents badge is the subagent count (1 subagent → 1)', () => {
const badge = badgeOf(registeredDescriptors(), 'apes:agents')
expect(badge).toBeTypeOf('function')
const ctx = makeMockSessionsCtx({
parent: {},
child: { origin: 'subagent' },
other: { running: true }, // running but NOT a subagent → not counted
})
expect(badge!(ctx as never, {} as never, {} as never)).toBe(1)
})
it('apes:agents badge is null when there are no subagents (hidden pill)', () => {
const badge = badgeOf(registeredDescriptors(), 'apes:agents')
const ctx = makeMockSessionsCtx({ a: {}, b: { running: true } })
expect(badge!(ctx as never, {} as never, {} as never)).toBeNull()
})
it('apps/messages/profile have NO badge (information placeholders, no count)', () => {
const registered = registeredDescriptors()
for (const id of ['apes:apps', 'apes:messages', 'apes:profile']) {
expect(badgeOf(registered, id)).toBeUndefined()
}
})
})
/**
* Phase 2 per-tab state: the apes:tasks `showInactive` filter. When the user
* toggles the preference off, only running sessions render. This is a pure
* decision function (the hook's uSES↔settings wiring needs a render, but the
* filter itself is plain and unit-testable).
*/
describe('apes:tasks showInactive per-tab state filter (Phase 2)', () => {
const sessions = [
{ id: 'a', running: true },
{ id: 'b', running: false },
{ id: 'c', running: true },
{ id: 'd' }, // running undefined → treated as not running
]
it('showInactive=true renders every session', () => {
expect(filterTasksRows(sessions, true)).toHaveLength(4)
})
it('showInactive=false renders only running sessions (2 of 4)', () => {
const rows = filterTasksRows(sessions, false)
expect(rows).toHaveLength(2)
expect(rows.map((s) => s.id)).toEqual(['a', 'c'])
})
it('showInactive=false on an all-running list keeps everything', () => {
expect(filterTasksRows([{ running: true }, { running: true }], false)).toHaveLength(2)
})
it('showInactive=false on an all-inactive list yields empty', () => {
expect(filterTasksRows([{ running: false }, {}], false)).toHaveLength(0)
})
})
+515
View File
@@ -0,0 +1,515 @@
import { describe, expect, it } from 'vitest'
import { compareEntries, isWithin, parentOf, rootLabel, requireAbsolute } from '../src/fs-tree.ts'
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
import {
activateTab, allLeaves, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState,
moveTab, moveTabToEdge, openTab, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
type SidebarState, type SplitNode,
} from '../src/client/state.ts'
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
import { SIDEBAR_PREFS_DEFAULTS } from '../src/prefs-shared.ts'
import { extOf, languageKeyForExt } from '../src/client/lang.ts'
import { relativeTo } from '../src/client/paths.ts'
import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts'
import { defaultShell, ensureSpawnHelper } from '../src/pty-manager.ts'
describe('fs-tree', () => {
it('sorts directories first, then names case-insensitively', () => {
const rows = [
{ name: 'b.txt', path: '/x/b.txt', isDir: false, hidden: false },
{ name: 'A', path: '/x/A', isDir: true, hidden: false },
{ name: 'a.txt', path: '/x/a.txt', isDir: false, hidden: false },
{ name: '.hidden', path: '/x/.hidden', isDir: false, hidden: true },
]
expect(rows.sort(compareEntries).map(row => row.name)).toEqual(['A', '.hidden', 'a.txt', 'b.txt'])
})
it('derives root labels and parents', () => {
// POSIX-style inputs behave identically on both platforms (win32 parses '/'
// as a separator), so these assertions are platform-independent.
expect(rootLabel('/Users/me/code')).toBe('code')
expect(rootLabel('/')).toBe('/')
expect(parentOf('/Users/me/code')).toBe('/Users/me')
expect(parentOf('/')).toBeUndefined()
// Windows-drive roots and segments, asserted only where win32 semantics apply.
if (process.platform === 'win32') {
expect(rootLabel('C:\\')).toBe('C:\\')
expect(parentOf('C:\\')).toBeUndefined()
expect(rootLabel('C:\\Users\\me')).toBe('me')
expect(parentOf('C:\\Users\\me')).toBe('C:\\Users')
}
})
it('accepts absolute paths and rejects relative ones', () => {
// resolve() is platform-native: '/a/b' roots to the current drive on win32.
expect(requireAbsolute('/a/b')).toBe(process.platform === 'win32' ? '\\a\\b' : '/a/b')
if (process.platform === 'win32') {
expect(requireAbsolute('C:/proj')).toBe('C:\\proj')
}
expect(() => requireAbsolute('a/b')).toThrow(/not an absolute path/)
expect(() => requireAbsolute('../a')).toThrow(/not an absolute path/)
})
it('isWithin tolerates separators and (on win32) letter case', () => {
expect(isWithin('/work/proj', '/work/proj/src/a.ts')).toBe(true)
expect(isWithin('/work/proj', '/work/proj')).toBe(true)
expect(isWithin('/work/proj', '/work/proj2/a.ts')).toBe(false)
expect(isWithin('/work/proj', '/other/a.ts')).toBe(false)
// Mixed separators normalize on every platform.
expect(isWithin('C:\\Users\\me', 'C:/Users/me/src/a.ts')).toBe(true)
// Case sensitivity follows the platform's filesystem semantics (the
// platform parameter makes both branches assertable on any host).
expect(isWithin('C:\\Users\\Me', 'c:/users/me/file.png', 'win32')).toBe(true)
expect(isWithin('/Users/Me', '/users/me/file.png', 'win32')).toBe(true)
expect(isWithin('/Users/Me', '/users/me/file.png', 'linux')).toBe(false)
expect(isWithin('/Users/Me', '/users/me/file.png', 'darwin')).toBe(false)
// Windows drive-root containment.
expect(isWithin('C:\\', 'C:\\Users\\me\\a.png', 'win32')).toBe(true)
expect(isWithin('c:\\users', 'C:/USERS/me/b.png', 'win32')).toBe(true)
})
})
describe('git parsing', () => {
it('parses porcelain -z entries including renames', () => {
const output = ['M src/a.ts', ' M src/b.ts', '?? src/c.ts', 'R src/new.ts', 'src/old.ts', ''].join('\0')
const entries = parsePorcelainZ(output)
expect(entries).toEqual([
{ path: 'src/a.ts', xy: 'M ' },
{ path: 'src/b.ts', xy: ' M' },
{ path: 'src/c.ts', xy: '??' },
{ path: 'src/new.ts', xy: 'R ' },
])
})
it('parses log rows with unit separators', () => {
const rows = parseLogLines('abc1234\x1fFirst subject\x1fAlice\x1f2024-01-01 10:00:00 +0800\n')
expect(rows).toEqual([
{ hash: 'abc1234', subject: 'First subject', author: 'Alice', date: '2024-01-01 10:00:00 +0800' },
])
})
})
describe('sidebar state', () => {
const state = (): SidebarState => makeDefaultState()
it('opens tabs into the active pane and dedupes single-instance types', () => {
let s = state()
const gitTab = { id: 'git', type: 'git' as const, title: 'Git' }
s = openTab(s, gitTab)
expect(s.splits.kind).toBe('leaf')
expect((s.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
// Reopening git focuses the existing tab instead of duplicating.
const after = openTab(s, { id: 'git2', type: 'git' as const, title: 'Git' })
expect((after.splits as { tabs: unknown[] }).tabs).toHaveLength(2)
})
it('dedupes editors by path', () => {
let s = state()
const firstId = (s.splits as { tabs: { id: string }[] }).tabs[0]!.id
s = openTab(s, { id: 'e1', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
const after = openTab(s, { id: 'e2', type: 'editor', title: 'a.ts', path: '/p/a.ts' })
expect((after.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([firstId, 'e1'])
})
it('splits panes and moves tabs between them', () => {
let s = state()
s = splitPane(s, 'row')
expect(s.splits.kind).toBe('split')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
expect(split.children).toHaveLength(2)
const explorerId = (split.children[0] as { id: string }).id
const otherId = (split.children[1] as { id: string }).id
expect((split.children[1] as { tabs: unknown[] }).tabs).toHaveLength(0)
const explorerTab = ((split.children[0] as { tabs: { id: string }[] }).tabs[0]!).id
s = moveTab(s, explorerId, explorerTab, otherId)
// The source pane emptied and was removed; the target leaf is promoted.
expect(s.splits.kind).toBe('leaf')
expect((s.splits as { id: string }).id).toBe(otherId)
expect((s.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([explorerTab])
})
it('dragging a tab to a pane edge splits the pane with the tab in a fresh leaf', () => {
let s = state()
s = splitPane(s, 'row')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
const paneA = split.children[0] as { id: string; tabs: { id: string }[] }
const paneB = split.children[1] as { id: string; tabs: { id: string }[] }
const tabId = paneA.tabs[0]!.id
// 先给 paneB 一个 tab,然后拖 paneA 的 tab 到 paneB 的 right 边缘。
s = openTab(s, { id: 't2', type: 'terminal', title: 'T2' })
s = moveTabToEdge(s, paneA.id, tabId, paneB.id, 'right')
const after = s.splits as Extract<SplitNode, { kind: 'split' }>
// paneB 现在是 split(row) [旧leaf, 新leaf(tabId)];其父 split 仍存在。
const bSplit = after.children.find(child => child.kind === 'split') as Extract<SplitNode, { kind: 'split' }> | undefined
expect(bSplit).toBeDefined()
expect(bSplit!.dir).toBe('row')
const newLeaf = bSplit!.children[1] as { tabs: { id: string }[] }
expect(newLeaf.tabs.map(t => t.id)).toContain(tabId)
})
it('dragging a tab to a pane center merges it into the pane', () => {
let s = state()
s = splitPane(s, 'col')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
const paneA = split.children[0] as { id: string; tabs: { id: string }[] }
const paneB = split.children[1] as { id: string; tabs: { id: string }[] }
const tabId = paneA.tabs[0]!.id
s = moveTabToEdge(s, paneA.id, tabId, paneB.id, 'center')
// paneA 空了被移除,树退化为 paneB(含 tab)。
expect(s.splits.kind).toBe('leaf')
expect((s.splits as { tabs: { id: string }[] }).tabs.map(t => t.id)).toEqual([tabId])
})
it('dragging a tab back onto its own pane center reorders it', () => {
let s = state()
s = openTab(s, { id: 't2', type: 'terminal', title: 'T2' })
const leaf = s.splits as { id: string; tabs: { id: string }[] }
const first = leaf.tabs[0]!.id
s = moveTabToEdge(s, leaf.id, first, leaf.id, 'center')
const after = s.splits as { tabs: { id: string }[] }
expect(after.tabs[after.tabs.length - 1]!.id).toBe(first)
expect(after.tabs).toHaveLength(2)
})
it('closing the last tab removes the pane (promotes the sibling)', () => {
let s = state()
s = splitPane(s, 'col')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
const paneA = split.children[0] as { id: string; tabs: { id: string }[] }
const paneB = split.children[1] as { id: string }
const explorerId = paneA.tabs[0]!.id
// paneA gets a terminal; the explorer moves to paneB; closing the
// terminal empties paneA, which is removed, promoting paneB.
s = openTab(s, { id: 't', type: 'terminal', title: 'Terminal 1' })
s = moveTab(s, paneA.id, explorerId, paneB.id)
s = activateTab(s, paneA.id, 't')
s = closeTab(s, paneA.id, 't')
expect(s.splits.kind).toBe('leaf')
expect((s.splits as { id: string }).id).toBe(paneB.id)
})
it('resizes splits within the clamp range', () => {
let s = state()
s = splitPane(s, 'row')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
const id = split.id
s = { ...s, splits: resizeSplit(s.splits, id, 0, 0.2) }
const after = s.splits as Extract<SplitNode, { kind: 'split' }>
expect(after.sizes[0]).toBeCloseTo(0.7)
expect(after.sizes[1]).toBeCloseTo(0.3)
})
it('tracks explorer expansion and tab activation', () => {
let s = state()
s = toggleExpanded(s, '/p/a')
s = toggleExpanded(s, '/p/b')
expect(s.expanded).toEqual(['/p/a', '/p/b'])
s = toggleExpanded(s, '/p/a')
expect(s.expanded).toEqual(['/p/b'])
const leaf = s.splits as { id: string; tabs: { id: string }[]; active: string | null }
const tabId = leaf.tabs[0]!.id
const after = activateTab(s, leaf.id, tabId)
expect((after.splits as { active: string | null }).active).toBe(tabId)
})
it('tabOpenIn: a tab is open until it is truly closed, wherever it lives', () => {
let s = state()
const leaf = s.splits as { id: string; tabs: { id: string }[] }
const explorerId = leaf.tabs[0]!.id
expect(tabOpenIn(s, explorerId)).toBe(true)
// Moving the tab to another pane keeps it open.
s = splitPane(s, 'row')
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
const paneA = split.children[0] as { id: string; tabs: { id: string }[] }
const paneB = split.children[1] as { id: string }
s = moveTab(s, paneA.id, explorerId, paneB.id)
expect(tabOpenIn(s, explorerId)).toBe(true)
// Closing it removes it from the whole tree.
const target = s.splits as { id: string; tabs: { id: string }[] }
s = closeTab(s, target.id, explorerId)
expect(tabOpenIn(s, explorerId)).toBe(false)
// A terminal tab added later is open too.
s = openTab(s, { id: 'terminal:9', type: 'terminal', title: 'Terminal 9' })
expect(tabOpenIn(s, 'terminal:9')).toBe(true)
})
})
describe('pty helpers', () => {
it('falls back from an empty SHELL to a usable shell', () => {
const previous = process.env.SHELL
try {
process.env.SHELL = ''
expect(defaultShell()).toBe('/bin/bash')
delete process.env.SHELL
expect(defaultShell()).toBe('/bin/bash')
} finally {
if (previous === undefined) delete process.env.SHELL
else process.env.SHELL = previous
}
})
it('restores the spawn-helper executable bit idempotently', () => {
// On non-Windows the helper must exist and be executable after the fix.
if (process.platform === 'win32') return
ensureSpawnHelper()
ensureSpawnHelper()
const { existsSync } = require('node:fs') as typeof import('node:fs')
const { dirname, join } = require('node:path') as typeof import('node:path')
const { createRequire } = require('node:module') as typeof import('node:module')
const entry = createRequire(import.meta.url).resolve('node-pty')
const root = dirname(dirname(entry))
const helper = join(root, 'prebuilds', `${process.platform}-${process.arch}`, 'spawn-helper')
expect(existsSync(helper)).toBe(true)
const { statSync } = require('node:fs') as typeof import('node:fs')
expect((statSync(helper).mode & 0o111) !== 0).toBe(true)
})
})
describe('produced-files derivation', () => {
const diffResult = (path: string) => ({
kind: 'tool-result', isError: false, callView: { card: 'diff', locations: [{ path }] },
})
const editResult = (path: string) => ({
kind: 'tool-result', isError: false, callView: { card: 'generic', kind: 'edit', locations: [{ path }] },
})
it('collects diff/edit locations of the closing turn, first-seen order', () => {
const nodes = [
{ kind: 'assistant', seq: 1, turn: 1 },
diffResult('a.ts'),
editResult('b.ts'),
diffResult('a.ts'),
{ kind: 'assistant', seq: 2, turn: 1 },
]
expect(producedForClosing(nodes, 2)).toEqual(['a.ts', 'b.ts'])
})
it('resets on user messages and turn changes', () => {
const nodes = [
{ kind: 'assistant', seq: 1, turn: 1 },
diffResult('old.ts'),
{ kind: 'user' },
{ kind: 'assistant', seq: 2, turn: 2 },
diffResult('new.ts'),
{ kind: 'assistant', seq: 3, turn: 2 },
]
expect(producedForClosing(nodes, 3)).toEqual(['new.ts'])
})
it('ignores reads, deletes, errors, and unknown cards', () => {
const nodes = [
{ kind: 'assistant', seq: 1, turn: 1 },
{ kind: 'tool-result', isError: true, callView: { card: 'diff', locations: [{ path: 'x.ts' }] } },
{ kind: 'tool-result', isError: false, callView: { card: 'read', locations: [{ path: 'r.ts' }] } },
{ kind: 'tool-result', isError: false, callView: { card: 'generic', kind: 'delete', locations: [{ path: 'd.ts' }] } },
]
expect(producedForClosing(nodes, 1)).toEqual([])
})
it('selector claims only when files exist', () => {
expect(selectProducedFiles({ nodes: [{ kind: 'assistant', seq: 1, turn: 1 }], seq: 1 })).toBeNull()
expect(selectProducedFiles({ nodes: [diffResult('a.ts'), { kind: 'assistant', seq: 1, turn: 1 }], seq: 1 })).toEqual(['a.ts'])
expect(selectProducedFiles(null)).toBeNull()
})
it('resolves relative paths against the session cwd', () => {
expect(resolveSidebarPath('/work/proj', 'src/a.ts')).toBe('/work/proj/src/a.ts')
expect(resolveSidebarPath('/work/proj', '/abs/x.ts')).toBe('/abs/x.ts')
expect(resolveSidebarPath(undefined, 'a.ts')).toBe('a.ts')
})
})
describe('persisted state sanitization', () => {
it('accepts a well-formed state unchanged (node environment: no width clamp)', () => {
const state = makeDefaultState(400)
const clean = sanitizeState(JSON.parse(JSON.stringify(state)))
expect(clean).toEqual(state)
})
it('clamps undersized widths to the panel minimum', () => {
const state = { ...makeDefaultState(400), width: 10 }
const clean = sanitizeState(JSON.parse(JSON.stringify(state)))
expect(clean?.width).toBe(280)
})
it('rejects malformed shapes instead of crashing the panel', () => {
expect(sanitizeState(null)).toBeUndefined()
expect(sanitizeState('nope')).toBeUndefined()
expect(sanitizeState({})).toBeUndefined()
expect(sanitizeState({ ...makeDefaultState(400), width: 'wide' })).toBeUndefined()
expect(sanitizeState({ ...makeDefaultState(400), panelOpen: 1 })).toBeUndefined()
// A split whose sizes do not match its children is rejected.
const withSplit = JSON.parse(JSON.stringify(makeDefaultState(400)))
withSplit.splits = { kind: 'split', id: 's1', dir: 'row', sizes: [0.5], children: [] }
expect(sanitizeState(withSplit)).toBeUndefined()
// Unknown tab types (older layouts) are rejected.
const withBadTab = JSON.parse(JSON.stringify(makeDefaultState(400)))
withBadTab.splits.tabs[0].type = 'watcher'
expect(sanitizeState(withBadTab)).toBeUndefined()
// An active id that no tab carries is rejected.
const withBadActive = JSON.parse(JSON.stringify(makeDefaultState(400)))
withBadActive.splits.active = 'ghost-tab'
expect(sanitizeState(withBadActive)).toBeUndefined()
})
it('re-ids stale duplicate pane/split ids and follows the activePane rename', () => {
// The pre-seeding counter reset could mint a fresh "pane:1" beside the
// persisted "pane:1": mapLeaf then hit BOTH leaves and every open landed
// in both panes. Sanitize must give the repeat a fresh id.
const corrupted = JSON.parse(JSON.stringify(makeDefaultState(400)))
corrupted.activePane = 'pane:1'
corrupted.splits = {
kind: 'split',
id: 'split:1',
dir: 'col',
sizes: [0.5, 0.5],
children: [
{ kind: 'leaf', id: 'pane:1', tabs: [], active: null },
{ kind: 'leaf', id: 'pane:1', tabs: [{ id: 'tab:1', type: 'explorer', title: 'Explorer' }], active: 'tab:1' },
],
}
const clean = sanitizeState(corrupted)
expect(clean).toBeDefined()
const leaves = allLeaves(clean!.splits)
// The first occurrence keeps its id; the repeat gets a fresh unique one
// (exact suffix depends on the module-level uid counter, so assert shape).
expect(leaves[0]!.id).toBe('pane:1')
expect(new Set(leaves.map(leaf => leaf.id)).size).toBe(2)
expect(clean!.activePane).toBe(leaves[1]!.id)
// And an open must land in exactly one pane of the healed tree.
const opened = openTab(clean!, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' })
const owners = allLeaves(opened.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/a.ts'))
expect(owners).toHaveLength(1)
})
it('falls back from a stale active pane instead of dropping the open', () => {
let s = makeDefaultState()
const paneA = allLeaves(s.splits)[0]!.id
const explorerTab = allLeaves(s.splits)[0]!.tabs.find(tab => tab.type === 'explorer')!.id
s = closeTab(s, paneA, explorerTab)
s = openTab(s, { id: 'editor:/a.ts', type: 'editor', title: 'a.ts', path: '/a.ts' })
const split = insertLeafAt(s.splits, paneA, 'col', { id: 'terminal:1', type: 'terminal', title: 'Terminal 1' }, false)
s = { ...s, splits: split.node, activePane: paneA }
// Closing the editor empties paneA; the pane is removed but activePane
// still points at it. The next open must land in the surviving pane.
s = closeTab(s, paneA, 'editor:/a.ts')
s = openTab(s, { id: 'editor:/b.ts', type: 'editor', title: 'b.ts', path: '/b.ts' })
const owners = allLeaves(s.splits).filter(leaf => leaf.tabs.some(tab => tab.path === '/b.ts'))
expect(owners).toHaveLength(1)
expect(owners[0]!.tabs.some(tab => tab.type === 'terminal')).toBe(true)
})
})
describe('path helpers', () => {
it('derives relative paths under the cwd (and "." for the cwd itself)', () => {
expect(relativeTo('/Users/me/code', '/Users/me/code/src/main.ts')).toBe('src/main.ts')
expect(relativeTo('/Users/me/code', '/Users/me/code')).toBe('.')
expect(relativeTo('/Users/me/code/', '/Users/me/code/src/a/b.ts')).toBe('src/a/b.ts')
})
it('falls back to the path unchanged when it lies outside the cwd', () => {
expect(relativeTo('/Users/me/code', '/Users/other/x.ts')).toBe('/Users/other/x.ts')
expect(relativeTo('/Users/me/code', '/Users/me/codex/y.ts')).toBe('/Users/me/codex/y.ts')
})
it('handles windows roots and mixed separators', () => {
expect(relativeTo('C:\\Users\\me', 'C:\\Users\\me\\src\\a.ts')).toBe('src/a.ts')
expect(relativeTo('C:\\Users\\me', 'C:/Users/me/src/a.ts')).toBe('src/a.ts')
expect(relativeTo('C:\\Users\\me\\', 'C:\\Users\\me')).toBe('.')
})
it('containment is case-insensitive (windows/macOS case-insensitive volumes)', () => {
expect(relativeTo('C:\\Users\\Me', 'c:/users/me/src/a.ts')).toBe('src/a.ts')
expect(relativeTo('/Users/Me/code', '/users/me/code/src/main.ts')).toBe('src/main.ts')
// The returned relative text keeps the caller's own casing.
expect(relativeTo('C:\\Users\\me', 'C:\\Users\\Me\\SRC\\a.ts')).toBe('SRC/a.ts')
})
it('resolves produced paths against windows cwds', () => {
expect(resolveSidebarPath('C:\\work\\proj', 'src/a.ts')).toBe('C:\\work\\proj\\src/a.ts')
expect(resolveSidebarPath('C:\\work\\proj', 'C:\\abs\\x.ts')).toBe('C:\\abs\\x.ts')
expect(resolveSidebarPath('C:\\work\\proj\\', 'C:\\abs\\x.ts')).toBe('C:\\abs\\x.ts')
})
})
describe('editor language mapping', () => {
it('derives extensions from paths', () => {
expect(extOf('/a/b/main.tsx')).toBe('tsx')
expect(extOf('README.MD')).toBe('md')
expect(extOf('/a/b/.gitignore')).toBe('gitignore')
expect(extOf('noext')).toBe('')
})
it('maps common extensions to languages and falls back to plain text', () => {
expect(languageKeyForExt('tsx')).toBe('tsx')
expect(languageKeyForExt('js')).toBe('js')
expect(languageKeyForExt('py')).toBe('python')
expect(languageKeyForExt('yaml')).toBe('yaml')
expect(languageKeyForExt('sh')).toBe('shell')
expect(languageKeyForExt('md')).toBe('md')
expect(languageKeyForExt('txt')).toBeNull()
expect(languageKeyForExt('log')).toBeNull()
expect(languageKeyForExt('')).toBeNull()
})
})
describe('side card preferences', () => {
/** A fake settings wire face whose settingsGet resolves to one raw value. */
const wire = (value: unknown): SidebarSettingsClient => ({
settingsGet: async () => ({ value, revision: 1 }),
settingsUpdate: async () => ({ value, revision: 2 }),
})
const rejecting = (): SidebarSettingsClient => ({
settingsGet: async () => { throw new Error('route rejected') },
settingsUpdate: async () => { throw new Error('route rejected') },
})
it('falls back to the defaults when the settings route rejects', async () => {
expect(await loadPrefs(rejecting())).toEqual(SIDEBAR_PREFS_DEFAULTS)
})
it('falls back to the defaults when the value is absent or malformed', async () => {
expect(await loadPrefs(wire(undefined))).toEqual(SIDEBAR_PREFS_DEFAULTS)
expect(await loadPrefs(wire('garbage'))).toEqual(SIDEBAR_PREFS_DEFAULTS)
})
it('parses a valid value and clamps the percent into the contract range', async () => {
expect(await loadPrefs(wire({ openByDefault: false, defaultWidthPercent: 80 })))
.toEqual({ openByDefault: false, defaultWidthPercent: 60 })
})
it('falls back per-field when a stored field is malformed', async () => {
expect(await loadPrefs(wire({ openByDefault: 'yes', defaultWidthPercent: 33 })))
.toEqual({ openByDefault: true, defaultWidthPercent: 33 })
})
it('seeds new-session defaults from the store prefs (open flag + width)', () => {
const store = createSidebarStore()
// Node environment: no window → the width falls back to PANEL_DEFAULT,
// while the open flag still follows the preference.
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45 })
store.setSession('fresh-session')
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45 })
const snapshot = store.getSnapshot()
expect(snapshot.sessionId).toBe('fresh-session')
expect(snapshot.state?.panelOpen).toBe(false)
expect(snapshot.state?.width).toBe(400)
// The default prefs keep the panel open.
const openStore = createSidebarStore()
openStore.setSession('another-fresh')
expect(openStore.getSnapshot().state?.panelOpen).toBe(true)
})
it('derives the default width from the window percent with clamps', () => {
expect(defaultWidthFor(1440, 30)).toBe(432)
expect(defaultWidthFor(800, 30)).toBe(280) // the panel floor
expect(defaultWidthFor(1440, 100)).toBe(1440) // the viewport cap
})
it('makeDefaultState honors the open flag', () => {
expect(makeDefaultState().panelOpen).toBe(true)
expect(makeDefaultState(400, false).panelOpen).toBe(false)
expect(makeDefaultState(400, false).width).toBe(400)
})
})
+1 -1
View File
@@ -1,5 +1,5 @@
{
"//": "Declaration build for the shipped types surface: emits lib/types/*.d.ts from src. The typecheck gate stays `tsc --noEmit` on tsconfig.json; tsdown bundles the JS. Consumers resolve declarations through the package exports' types subpaths.",
"//": "Declaration build for the shipped types surface: emits lib/types/*.d.ts from src (the typecheck gate stays `tsc --noEmit` on tsconfig.json; tsdown bundles the JS). Consumers resolve declarations through the package exports' types subpaths.",
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
+3 -3
View File
@@ -4,8 +4,6 @@
"module": "esnext",
"moduleResolution": "bundler",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"jsx": "react-jsx",
"strict": true,
"noUncheckedIndexedAccess": true,
@@ -13,8 +11,10 @@
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"noEmit": true,
"types": ["node"]
},
"include": ["src", "tests", "tsdown.config.ts", "vitest.config.ts"]
"include": ["src", "tests", "tsdown.config.ts"]
}
+107 -103
View File
@@ -1,33 +1,44 @@
/**
* tsdown build for apes-sidebar:
* - host-half lib (lib/index.js + lib/invariant.js, ESM node)
* - the browser client bundle (lib/client.js, CJS closure factory) registered
* under the package-name id `apes-sidebar` (official profile channel).
* tsdown build for dsh-better-sidebar: the host-half lib (lib/index.js and
* the lib/invariant.js companion, ESM node) plus the two browser client
* bundles (lib/client.js and lib/client-registry.js, CJS closure factory) —
* one per install channel:
*
* The client bundle replicates the official DSH client-bundle preset
* (packages/client/tsdown.client.ts): externals resolve through the loader
* module table at runtime; everything else inlines; the purity gate rejects
* any other @deepseek-ai value import (cross-plugin collaboration goes
* through cordis services, never value imports); CSS compiles to hashed
* class maps and injects <style data-plugin> tags at factory execution;
* the artifact registers itself via window.__ModuleLoader__.load.
* - `lib/client.js` serves the official profile channel, registering with
* the package-name id `dsh-better-sidebar` (the client-modules compose
* keys on the package name; keep it in sync with package.json `name`),
* - `lib/client-registry.js` serves the plugin-registry channel
* (dsh.plugin.json), registering with the manifest id
* `dsh-external/dsh-better-sidebar` (the registry browser-side `arrive()`
* check requires bundle id === plugin id).
*
* Both bundles replicate the official DSH client-bundle preset
* (packages/client/tsdown.client.ts) and are compiled from the same
* src/client/index.tsx source — only the registered id and the output file
* name differ, so they cannot drift:
* - externals resolve through the loader module table at runtime (the
* PLATFORM_MODULES seed list from apps/web's platform.ts, plus the
* runtime/client exemption),
* - everything else is inlined into the bundle (xterm, clsx, ...),
* - the purity gate rejects any other @deepseek-ai value import: cross-plugin
* collaboration goes through cordis services, never value imports,
* - CSS Modules compile to hashed class maps and inject <style data-plugin>
* tags at factory execution,
* - each artifact registers itself via window.__ModuleLoader__.load({id,
* factory}) with the (require) => exports CJS closure shape.
*
* Types ship from lib/types (tsc -p tsconfig.build.json), not from tsdown.
*/
import { readFile } from 'node:fs/promises'
import { basename, dirname, join, relative, resolve as resolvePath, sep } from 'node:path'
import { builtinModules } from 'node:module'
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
import type { UserConfig } from 'tsdown'
import { transform } from 'lightningcss'
/** Node builtins must never survive into the browser module-loader factory. */
const NODE_BUILTINS = new Set([
...builtinModules,
...builtinModules.map(id => `node:${id}`),
])
const require = createRequire(import.meta.url)
/** Module specifiers the web shell shares into the frozen module table. */
/** Module specifiers the web shell shares into the frozen module table (the official PLATFORM_MODULES list, plus the runtime/client exemption). */
const CLIENT_EXTERNALS = [
'react',
'react/jsx-runtime',
@@ -35,18 +46,22 @@ const CLIENT_EXTERNALS = [
'react-dom/client',
'cordis',
'@deepseek-ai/dsh-client-ui-slots',
'@deepseek-ai/dsh-client-web-react',
'@deepseek-ai/dsh-client-ui-primitives',
'@deepseek-ai/dsh-client-schema-form',
'@deepseek-ai/dsh-client-runtime/client',
]
/**
* Wire/type layers a client bundle may inline (mirror of the official
* INLINE_SAFE list): browser-safe contract surfaces with no runtime identity
* to share.
* to share. Everything else under @deepseek-ai/* is either a module-table
* entry (external) or a leak the purity gate rejects.
*/
const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/
/** Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline. */
const CSS_VIRTUAL_PREFIX = '\0apes-css:'
const CSS_VIRTUAL_PREFIX = '\0dsh-css:'
const CSS_VIRTUAL_SUFFIX = '.mjs'
const REPOSITORY_ROOT = fileURLToPath(new URL('.', import.meta.url))
@@ -77,8 +92,10 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
/**
* One client bundle build for a plugin id. The same src/client/index.tsx is
* compiled once per install channel; only the registered id and the output
* file name differ, so they cannot drift.
* compiled twice with only the registered id and the output file name
* differing: the official channel uses the package name (`dsh-better-sidebar`)
* and the registry channel uses the manifest id
* (`dsh-external/dsh-better-sidebar`).
* @param pluginId - the `__ModuleLoader__.load({ id })` value and the
* data-plugin style-tag prefix of this bundle.
* @param entryFile - the output file name under lib/.
@@ -92,97 +109,79 @@ function clientBundle(pluginId: string, entryFile: string): UserConfig {
dts: false,
sourcemap: true,
clean: false,
deps: {
neverBundle: [...CLIENT_EXTERNALS],
alwaysBundle: (id: string) => !CLIENT_EXTERNALS.includes(id),
},
external: [...CLIENT_EXTERNALS],
define: {
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV ?? 'production'),
'import.meta.env.MODE': JSON.stringify(process.env.NODE_ENV ?? 'production'),
'import.meta.env': JSON.stringify({ MODE: process.env.NODE_ENV ?? 'production' }),
'import.meta.resolve': 'undefined',
},
inputOptions: {
resolve: {
conditionNames: ['browser', 'import', 'require', 'default'],
// External wins for module-table entries; every other dependency inlines.
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
plugins: [{
// Bundle purity gate (mirror of the official preset): platform seed
// entries stay external, inline-safe wire layers inline, and every
// other @deepseek-ai value import is a build error — a cross-plugin
// value import either inlines a duplicate runtime instance or requires
// a specifier the frozen module table cannot answer. Cross-plugin
// collaboration goes through cordis services instead. Type-only
// imports are erased and never reach this gate.
name: 'dsh-client-bundle-purity',
resolveId(source: string) {
if (!source.startsWith('@deepseek-ai/')) return null
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
throw new Error(
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
)
},
},
plugins: [purityGatePlugin(), makeCssPlugin(pluginId)],
}, {
name: 'dsh-css-inline',
resolveId(source: string, importer: string | undefined) {
if (!source.endsWith('.css')) return null
// Relative/absolute paths resolve against the importer; bare
// specifiers (e.g. 'xterm/css/xterm.css') resolve from the package.
let abs: string
if (source.startsWith('.') || source.startsWith('/') || /^[A-Za-z]:[\\/]/.test(source)) {
abs = importer === undefined ? source : resolvePath(dirname(importer), source)
} else {
abs = require.resolve(source)
}
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
},
async load(virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
this.addWatchFile(fileId)
const source = await readFile(fileId)
// CSS Modules (x.module.css) become hashed class maps; plain css
// (xterm's stylesheet) is inlined verbatim.
if (fileId.endsWith('.module.css')) {
const { code, exports: cssExports } = transform({
filename: fileId,
code: source,
cssModules: { pattern: `[hash]_[local]` },
minify: true,
})
const classMap: Record<string, string> = {}
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
return [
injectTag(pluginId, fileId, code.toString()),
`export default ${JSON.stringify(classMap)};`,
].join('\n')
}
return [
injectTag(pluginId, fileId, source.toString('utf8')),
'export default "";',
].join('\n')
},
}],
outputOptions: {
entryFileNames: entryFile,
sourcemapPathTransform: browserSourcePath,
banner: `window.__ModuleLoader__.load({ id: ${JSON.stringify(pluginId)}, factory: (require) => {`,
footer: `return module.exports; } });`,
intro: 'var module = { exports: {} }; var exports = module.exports;',
codeSplitting: false,
},
}
}
/** A rolldown plugin as tsdown's config accepts it (contextual `this` for load/resolveId). */
type BuildPlugin = NonNullable<UserConfig['plugins']>
/** The shared client-bundle purity gate. */
function purityGatePlugin(): BuildPlugin {
return {
name: 'apes-client-bundle-purity',
resolveId(source: string) {
if (NODE_BUILTINS.has(source)) {
throw new Error(
`client bundle purity: Node builtin "${source}" cannot run in the browser module table — `
+ 'select the dependency browser export or add an explicit browser implementation',
)
}
if (!source.startsWith('@deepseek-ai/')) return null
if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins
if (INLINE_SAFE.test(source)) return null // wire/type layer: inline is the point
throw new Error(
`client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS) and not an inline-safe wire layer — `
+ 'cross-plugin value imports are forbidden; collaborate through cordis services (type-only imports are erased and never reach this gate)',
)
},
}
}
/** The shared CSS-inline virtual-module plugin (one <style data-plugin> per file). */
function makeCssPlugin(pluginId: string): BuildPlugin {
return {
name: 'apes-css-inline',
resolveId(source: string, importer: string | undefined) {
if (!source.endsWith('.css')) return null
let abs: string
if (source.startsWith('.') || source.startsWith('/') || /^[A-Za-z]:[\\/]/.test(source)) {
abs = importer === undefined ? source : resolvePath(dirname(importer), source)
} else {
// Bare specifiers resolve from the package.
const { createRequire } = require('node:module')
abs = createRequire(import.meta.url).resolve(source)
}
return CSS_VIRTUAL_PREFIX + abs + CSS_VIRTUAL_SUFFIX
},
async load(virtualId: string) {
if (!virtualId.startsWith(CSS_VIRTUAL_PREFIX)) return null
const fileId = virtualId.slice(CSS_VIRTUAL_PREFIX.length, -CSS_VIRTUAL_SUFFIX.length)
this.addWatchFile(fileId)
const source = await readFile(fileId)
if (fileId.endsWith('.module.css')) {
const { code, exports: cssExports } = transform({
filename: fileId,
code: source,
cssModules: { pattern: `[hash]_[local]` },
minify: true,
})
const classMap: Record<string, string> = {}
for (const [local, exp] of Object.entries(cssExports ?? {})) classMap[local] = exp.name
return [
injectTag(pluginId, fileId, code.toString()),
`export default ${JSON.stringify(classMap)};`,
].join('\n')
}
return [
injectTag(pluginId, fileId, source.toString('utf8')),
'export default "";',
].join('\n')
},
}
}
@@ -196,8 +195,13 @@ export default [
target: 'es2024',
fixedExtension: false,
dts: false,
// clean stays off: the build script removes lib/ wholesale before tsc, so
// a tsdown clean here would wipe the lib/types declarations tsc just
// emitted (and `watch` must never touch them).
clean: false,
},
// Official profile channel: bundle id = package name (package.json `name`).
clientBundle('apes-sidebar', 'client.js'),
clientBundle('dsh-better-sidebar', 'client.js'),
// Plugin-registry channel: bundle id = manifest id (dsh.plugin.json `id`).
clientBundle('dsh-external/dsh-better-sidebar', 'client-registry.js'),
] satisfies UserConfig[]
-27
View File
@@ -1,27 +0,0 @@
/**
* Vitest config: inline the npm-published `@deepseek-ai/*` packages whose
* BUILT lib bundles css side-effect imports. Installed from the npm registry
* these packages live under `node_modules/.pnpm` and are externalized by
* vitest — Node then chokes on the `.css` import. Inlining routes them
* through Vite's transform, which stubs css imports (the default `css: false`).
*/
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
server: {
deps: {
inline: [/@deepseek-ai\/dsh-client-ui-primitives/],
},
},
exclude: [
'tests/e2e/**',
'**/.worktrees/**',
'**/node_modules/**',
'**/dist/**',
'**/cypress/**',
'**/.{idea,git,cache,output,temp}/**',
'**/{karma,rollup,webpack,vite,vitest,jest,ava,babel,nyc,cypress,tsup,build,eslint,prettier}.config.*',
],
},
})