diff --git a/README.md b/README.md index 4e492b3..f0c0381 100644 --- a/README.md +++ b/README.md @@ -82,7 +82,7 @@ ### 前置条件 - 已安装 DSH(`dsh web` 可运行),Node.js ≥ 20、pnpm ≥ 10 -- DSH 源码 checkout 位于 `~/.dsh/source/current`(`@deepseek-ai/*` 类型依赖以 `link:` 指向它;若 checkout 在其他路径,修改 `package.json` 中对应的 `link:` 路径) +- **构建/类型检查**需要 DSH 源码 checkout 位于 `~/.dsh/source/current`(`devDependencies` 中的 `@deepseek-ai/*` 以 `link:` 指向它;若 checkout 在其他路径,修改 `package.json` 中对应的 `link:` 路径)。**运行期不依赖该 checkout**——`@deepseek-ai/*` 声明在 `peerDependencies`,由 web profile 提供 ### 1. 克隆、安装、构建 @@ -90,10 +90,24 @@ git clone https://github.com/dsh-external/DSH-better-sidebar.git cd DSH-better-sidebar pnpm install -pnpm build # 产物: lib/index.js (host) + lib/client.js (client) +pnpm build # 产物: lib/index.js + lib/invariant.js (host) + lib/client.js (client) + lib/types/*.d.ts ``` -### 2. 注册到 web profile +> 运行期依赖(cordis、react、`@deepseek-ai/dsh-client-*` 等)按官方插件清单规范声明在 `peerDependencies`——web profile 已内置全部 peer 依赖,安装本插件无需额外装包;`dependencies` 只保留插件自有的运行时依赖(node-pty、ws、xterm、CodeMirror、schemastery 等)。 + +### 2. 注册到 web profile(安装) + +> **安装走 profile 清单注册,与 portal 无关**:安装 = 把插件登记进 web profile 的依赖(`dsh plugin` 命令或手动编辑 `package.json`)并加一行 cordis 挂载行;"portal" 只指侧边栏面板在页面上的**渲染方式**(见下文[规范符合性](#规范符合性)),不是安装通道。 + +**方式 A(推荐):`dsh plugin` 命令** + +```sh +dsh plugin --profile web add link:/绝对路径/DSH-better-sidebar +``` + +等价于在 `~/.dsh/profiles/web` 目录执行 `pnpm add link:...`:写入该 profile 的 `package.json` `dependencies` 并安装依赖。 + +**方式 B:手动编辑** 编辑 `~/.dsh/profiles/web/package.json`,在 `dependencies` 加入: @@ -101,7 +115,13 @@ pnpm build # 产物: lib/index.js (host) + lib/client.js (client) "dsh-better-sidebar": "link:/绝对路径/DSH-better-sidebar" ``` -编辑 `~/.dsh/profiles/web/cordis.patch.yml`,追加插件行: +然后安装依赖: + +```sh +(cd ~/.dsh/profiles/web && pnpm install) +``` + +**两种方式都要**在 `~/.dsh/profiles/web/cordis.patch.yml` 追加插件挂载行(`dsh plugin` 只管理依赖清单,不写挂载行): ```yaml - insert: @@ -109,12 +129,6 @@ pnpm build # 产物: lib/index.js (host) + lib/client.js (client) name: 'dsh-better-sidebar' ``` -然后安装依赖: - -```sh -(cd ~/.dsh/profiles/web && pnpm install) -``` - ### 3. 重启 GUI 并刷新页面 ```sh @@ -125,24 +139,41 @@ pm2 restart dsh-web # 或你自己的 dsh web 启动方式 ### 更新 -重新 `pnpm build`(host/client 双产物)→ `pm2 restart dsh-web` → 刷新页面;`link:` 引用无需重新 install。 +```sh +pnpm build # 产物: lib/index.js + lib/invariant.js (host) + lib/client.js (client) + lib/types +``` + +- 只改了 **client** 代码(`src/client/*`)→ 硬刷新页面即可(bundle 由服务器按请求读取) +- 改了 **host** 代码(`src/index.ts`、`src/config.ts` 等)→ `pm2 restart dsh-web` + 硬刷新 + +`link:` 引用无需重新 install。 ## 🛠️ 开发 ```sh pnpm typecheck # tsc --noEmit -pnpm test # vitest(单元 + 冒烟:真实 git/fs/node-pty 交互) -pnpm build # tsc + tsdown → lib/index.js + lib/client.js +pnpm test # vitest(单元 + 冒烟 + 插件形态 guard:真实 git/fs/node-pty 交互) +pnpm build # rm -rf lib && tsc(声明) + tsdown → lib/index.js + lib/invariant.js + lib/client.js + lib/types pnpm watch # tsdown --watch(client bundle 热重建) ``` +### 规范符合性 + +插件按 DSH 官方插件规范组织(参考 [dsh-external/turtle-ui](https://github.com/dsh-external/turtle-ui) 与 mainline `packages/client/AGENTS.md`)。**安装通道与渲染方式是两个独立概念**:安装始终走 profile 清单协议(`dsh plugin` / cordis.yml 行,见[快速开始](#快速开始));下面的 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 内联) +- **已知偏差**:侧边栏面板经 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 插件:`/sidebar/api/*` JSON API、`/sidebar/file` 媒体路由、`/sidebar/ws/terminal` WebSocket;fs / git / pty 服务 | +| 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` 闭包工厂):portal 侧边栏 + 各视图 + turnTail 拦截 | - 所有 API 携带 `sessionId`;cwd 权威值取自会话 header,会话未附加(页面加载竞态)时回退客户端摘要 cwd,再回退进程 cwd;终端按 `${sessionId}:${tabId}` 键控,重连时若权威 cwd 变化则重启 shell 到正确目录 diff --git a/package.json b/package.json index 6e56f9d..44c8dd0 100644 --- a/package.json +++ b/package.json @@ -5,29 +5,64 @@ "private": true, "type": "module", "main": "lib/index.js", + "types": "lib/types/index.d.ts", "exports": { ".": { + "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" }, + "engines": { + "node": ">=20" + }, "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-slots", + "@deepseek-ai/dsh-client-ui-conversation" + ], "platform": "web" }, "files": [ - "lib" + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts", + "src" ], "scripts": { - "build": "tsc --noEmit && tsdown", + "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-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", + "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", @@ -53,20 +88,25 @@ "@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-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", "@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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ee34277..f5d4a81 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -80,6 +80,9 @@ importers: node-pty: specifier: ^1.1.0 version: 1.1.0 + schemastery: + specifier: ^3.18.0 + version: 3.18.0 ws: specifier: ^8.18.0 version: 8.21.2 @@ -87,9 +90,15 @@ importers: specifier: ^5.3.0 version: 5.3.0 devDependencies: + '@cordisjs/plugin-loader': + specifier: ^1.0.0-rc.5 + version: 1.0.0-rc.5(cordis@4.0.0-rc.7) '@deepseek-ai/dsh-client-runtime': specifier: link:/Users/menghuan/.dsh/source/current/packages/client/runtime version: link:../../.dsh/source/current/packages/client/runtime + '@deepseek-ai/dsh-client-schema-form': + specifier: link:/Users/menghuan/.dsh/source/current/packages/client/schema-form + version: link:../../.dsh/source/current/packages/client/schema-form '@deepseek-ai/dsh-client-ui-conversation': specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-conversation version: link:../../.dsh/source/current/packages/client/ui-conversation @@ -99,9 +108,15 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-slots version: link:../../.dsh/source/current/packages/client/ui-slots + '@deepseek-ai/dsh-client-web-react': + specifier: link:/Users/menghuan/.dsh/source/current/packages/client/web-react + version: link:../../.dsh/source/current/packages/client/web-react '@deepseek-ai/dsh-host-webserver': specifier: link:/Users/menghuan/.dsh/source/current/packages/host/webserver version: link:../../.dsh/source/current/packages/host/webserver + '@deepseek-ai/dsh-invariants': + specifier: link:/Users/menghuan/.dsh/source/current/packages/support/invariants + version: link:../../.dsh/source/current/packages/support/invariants '@deepseek-ai/dsh-session': specifier: link:/Users/menghuan/.dsh/source/current/packages/core/session version: link:../../.dsh/source/current/packages/core/session @@ -119,7 +134,7 @@ importers: version: 8.18.1 cordis: specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-loader@1.0.0-rc.5) lightningcss: specifier: ^1.32.0 version: 1.33.0 @@ -207,6 +222,15 @@ packages: '@codemirror/view@6.43.8': resolution: {integrity: sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==} + '@cordisjs/plugin-loader@1.0.0-rc.5': + resolution: {integrity: sha512-084Wn2SzkFinbaASTq8blHOUqQt/oxZfX6gnrt0lnJ1CrystulFLL1+XVgF4o7lUMN9bHn4cfT1pMtkHprCtHw==} + peerDependencies: + cordis: ^4.0.0-rc.7 + node-addon-require-builtin: ^0.1.0 + peerDependenciesMeta: + node-addon-require-builtin: + optional: true + '@jridgewell/sourcemap-codec@1.5.5': resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} @@ -814,6 +838,9 @@ packages: scheduler@0.23.0: resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==} + schemastery@3.18.0: + resolution: {integrity: sha512-Jw2uxjoyyqc/yeurmChUEc/jbi8GsrdXV/KmqRUDZXJAXAmrJiPsz8vKa17l/VckyzljHZ9oGaul443CQiXxtA==} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -1179,6 +1206,11 @@ snapshots: style-mod: 4.1.3 w3c-keyname: 2.2.8 + '@cordisjs/plugin-loader@1.0.0-rc.5(cordis@4.0.0-rc.7)': + dependencies: + cordis: 4.0.0-rc.7(@cordisjs/plugin-loader@1.0.0-rc.5) + cosmokit: 1.8.1 + '@jridgewell/sourcemap-codec@1.5.5': {} '@lezer/common@1.5.2': {} @@ -1483,10 +1515,12 @@ snapshots: convert-source-map@2.0.0: {} - cordis@4.0.0-rc.7: + cordis@4.0.0-rc.7(@cordisjs/plugin-loader@1.0.0-rc.5): dependencies: '@standard-schema/spec': 1.1.0 cosmokit: 1.8.1 + optionalDependencies: + '@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7) cosmokit@1.8.1: {} @@ -1658,6 +1692,11 @@ snapshots: dependencies: loose-envify: 1.4.0 + schemastery@3.18.0: + dependencies: + '@standard-schema/spec': 1.1.0 + cosmokit: 1.8.1 + siginfo@2.0.0: {} source-map-js@1.2.1: {} diff --git a/src/client/Sidebar.tsx b/src/client/Sidebar.tsx index e6d806d..67c3083 100644 --- a/src/client/Sidebar.tsx +++ b/src/client/Sidebar.tsx @@ -16,8 +16,8 @@ import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutlin import type { Context, SidebarConversation } from '../context-types.ts' import { allLeaves, closeTab, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openTab, - resizeSplit, sidebarStore, setWidth, toggleExpanded, togglePanel, - TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarTab, type SplitNode, + 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' @@ -42,8 +42,9 @@ function TabContent(props: { onToggleDir: (path: string) => void onReferenceFile: (path: string) => void ctx: Context + store: SidebarStore }) { - const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx } = props + const { tab, sessionId, cwd, expanded, onToggleDir, onReferenceFile, ctx, store } = props const scope = { sessionId, cwd } switch (tab.type) { case 'explorer': @@ -53,14 +54,14 @@ function TabContent(props: { cwd={cwd} expanded={expanded} onToggle={onToggleDir} - onOpenFile={(path) => { openSidebarFile(ctx, sessionId, path) }} + onOpenFile={(path) => { openSidebarFile(ctx, store, sessionId, path) }} onReferenceFile={onReferenceFile} /> ) case 'git': return case 'terminal': - return + return case 'editor': return } @@ -83,8 +84,8 @@ function buildNewTabOptions(state: SidebarState): NewTabOption[] { ] } -export function Sidebar(props: { ctx: Context }) { - const { ctx } = props +export function Sidebar(props: { ctx: Context; store: SidebarStore }) { + const { ctx, store } = props // Current conversation (the sessions list feed). const sessionList = useSyncExternalStore( @@ -95,10 +96,10 @@ export function Sidebar(props: { ctx: Context }) { // Per-session sidebar state. const snapshot = useSyncExternalStore( - useCallback((callback: () => void) => sidebarStore.subscribe(callback), []), - useCallback(() => sidebarStore.getSnapshot(), []), + useCallback((callback: () => void) => store.subscribe(callback), [store]), + useCallback(() => store.getSnapshot(), [store]), ) - useEffect(() => { sidebarStore.setSession(current) }, [current]) + useEffect(() => { store.setSession(current) }, [current, store]) const state = snapshot.state const sessionId = snapshot.sessionId @@ -149,16 +150,16 @@ export function Sidebar(props: { ctx: Context }) { // 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 = sidebarStore.getSnapshot().state + const current = store.getSnapshot().state const leaf = current === undefined ? undefined : leafWithTab(current.splits, tabId) const tab = leaf?.tabs.find(candidate => candidate.id === tabId) - sidebarStore.reduce(s => closeTab(s, paneId, 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) => { - sidebarStore.reduce(s => ({ + store.reduce(s => ({ ...s, activePane: paneId, splits: mapLeaf(s.splits, paneId, (leaf) => { @@ -166,12 +167,12 @@ export function Sidebar(props: { ctx: Context }) { }), })) }, - focusPane: (paneId) => { sidebarStore.reduce(s => ({ ...s, activePane: paneId })) }, + focusPane: (paneId) => { store.reduce(s => ({ ...s, activePane: paneId })) }, moveTabToEdge: (payload: TabDragPayload, toPane: string, zone: DropZone) => { - sidebarStore.reduce(s => moveTabToEdge(s, payload.paneId, payload.tabId, toPane, zone)) + store.reduce(s => moveTabToEdge(s, payload.paneId, payload.tabId, toPane, zone)) }, moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => { - sidebarStore.reduce((s) => { + store.reduce((s) => { let index = -1 const source = leafWithTab(s.splits, beforeTabId) if (source !== undefined && source.id === toPane) { @@ -181,9 +182,9 @@ export function Sidebar(props: { ctx: Context }) { }) }, resizeSplit: (splitId, index, deltaFrac) => { - sidebarStore.reduce(s => ({ ...s, splits: resizeSplit(s.splits, splitId, index, deltaFrac) })) + store.reduce(s => ({ ...s, splits: resizeSplit(s.splits, splitId, index, deltaFrac) })) }, - }), [sessionId, cwd]) + }), [store, sessionId, cwd]) /** * The explorer's @-reference button: append `@` to the @@ -223,7 +224,7 @@ export function Sidebar(props: { ctx: Context }) { } const onNewTab = (optionId: string): void => { - sidebarStore.reduce((s) => { + store.reduce((s) => { if (optionId === 'explorer') { return openTab(s, { id: 'explorer', type: 'explorer', title: t('explorer') }) } @@ -256,9 +257,10 @@ export function Sidebar(props: { ctx: Context }) { sessionId={sessionId} cwd={cwd} expanded={state.expanded} - onToggleDir={(path) => { sidebarStore.reduce(s => toggleExpanded(s, path)) }} + onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }} onReferenceFile={referenceInChat} ctx={ctx} + store={store} /> ) @@ -271,7 +273,7 @@ export function Sidebar(props: { ctx: Context }) { type="button" className={css.toggleButton} aria-label={t('expand')} - onClick={() => { sidebarStore.reduce(togglePanel) }} + onClick={() => { store.reduce(togglePanel) }} > @@ -298,7 +300,7 @@ export function Sidebar(props: { ctx: Context }) { onPointerMove={(event) => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return const { startX, startWidth } = widthDrag.current - sidebarStore.reduce(s => setWidth(s, startWidth + (startX - event.clientX))) + store.reduce(s => setWidth(s, startWidth + (startX - event.clientX))) }} onPointerUp={(event) => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return @@ -314,7 +316,7 @@ export function Sidebar(props: { ctx: Context }) { title={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')} onClick={() => { const viewport = window.innerWidth - sidebarStore.reduce(s => setWidth(s, fullscreen + store.reduce(s => setWidth(s, fullscreen ? Math.max(280, Math.round((viewport - 320) / 2)) : viewport)) }} @@ -330,7 +332,7 @@ export function Sidebar(props: { ctx: Context }) { className={css.iconButton} aria-label={t('collapse')} title={t('collapse')} - onClick={() => { sidebarStore.reduce(togglePanel) }} + onClick={() => { store.reduce(togglePanel) }} > diff --git a/src/client/TerminalView.tsx b/src/client/TerminalView.tsx index e0192fa..9e72015 100644 --- a/src/client/TerminalView.tsx +++ b/src/client/TerminalView.tsx @@ -14,7 +14,7 @@ import { FitAddon } from '@xterm/addon-fit' import 'xterm/css/xterm.css' import { t } from './locales.ts' import type { SessionScope } from './api.ts' -import { sidebarStore } from './state.ts' +import type { SidebarStore } from './state.ts' import { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts' import css from './sidebar.module.css' @@ -60,8 +60,8 @@ function xtermTheme(): ITheme { } } -export function TerminalView(props: { scope: SessionScope; tabId: string }) { - const { scope, tabId } = props +export function TerminalView(props: { scope: SessionScope; tabId: string; store: SidebarStore }) { + const { scope, tabId, store } = props const hostRef = useRef(null) const [connected, setConnected] = useState(false) const [fatal, setFatal] = useState(null) @@ -180,7 +180,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string }) { // 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 (!sidebarStore.tabOpen(scope.sessionId, tabId) + if (!store.tabOpen(scope.sessionId, tabId) && socket !== null && socket.readyState === WebSocket.OPEN) { socket.send(JSON.stringify({ type: 'close' })) } @@ -188,7 +188,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string }) { term.dispose() connectRef.current = null } - }, [scope.sessionId, scope.cwd, tabId]) + }, [scope.sessionId, scope.cwd, tabId, store]) return (
diff --git a/src/client/index.tsx b/src/client/index.tsx index 2726a44..ca4c37c 100644 --- a/src/client/index.tsx +++ b/src/client/index.tsx @@ -8,6 +8,7 @@ import { Component, createElement, type ErrorInfo, type ReactNode } from 'react' import { createRoot } 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 { t } from './locales.ts' @@ -57,6 +58,11 @@ class SidebarBoundary extends Component<{ children: ReactNode }, { error: string * @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 + // registration (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. @@ -80,7 +86,7 @@ export function apply(ctx: Context): void { host.setAttribute('data-dsh-better-sidebar', '') document.body.appendChild(host) const root = createRoot(host) - root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx }))) + root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx, store: sidebarStore }))) return () => { root.unmount() host.remove() @@ -94,7 +100,7 @@ export function apply(ctx: Context): void { ctx.effect( () => { try { - return registerTurnTailInterception(ctx) + return registerTurnTailInterception(ctx, sidebarStore) } catch (error) { fail('interception', error) return undefined diff --git a/src/client/intercept.tsx b/src/client/intercept.tsx index 0610a8e..1a62fd3 100644 --- a/src/client/intercept.tsx +++ b/src/client/intercept.tsx @@ -8,19 +8,18 @@ */ import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { Context } from '../context-types.ts' -import { openTab } from './state.ts' -import { sidebarStore } from './state.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, sessionId: string, path: string): void { +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) - sidebarStore.reduce(state => openTab(state, { + store.reduce(state => openTab(state, { id: `editor:${absolute}`, type: 'editor', title, @@ -61,14 +60,14 @@ export function SidebarProducedFiles(props: { } /** Register the turn-tail interception (returns the disposer). */ -export function registerTurnTailInterception(ctx: Context): () => void { +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, sessionId, path) }, + openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) }, }), }, SidebarProducedFiles) } diff --git a/src/client/split-pane.tsx b/src/client/split-pane.tsx index d4647d0..28f6812 100644 --- a/src/client/split-pane.tsx +++ b/src/client/split-pane.tsx @@ -31,10 +31,17 @@ export interface WorkbenchActions { resizeSplit: (splitId: string, index: number, deltaFrac: number) => void } -/** One divider: pointer-capture drag translating px deltas into fractions. */ +/** 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 - const start = useRef({ x: 0, y: 0, size: 0 }) + // `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 ( @@ -44,7 +51,7 @@ function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => v event.preventDefault() event.currentTarget.setPointerCapture(event.pointerId) const box = event.currentTarget.parentElement?.getBoundingClientRect() - start.current = { + last.current = { x: event.clientX, y: event.clientY, size: box === undefined ? 1 : (dir === 'row' ? box.width : box.height), @@ -53,9 +60,10 @@ function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => v }} onPointerMove={(event) => { if (!event.currentTarget.hasPointerCapture(event.pointerId)) return - const delta = dir === 'row' ? event.clientX - start.current.x : event.clientY - start.current.y - const size = Math.max(1, start.current.size) - onResize(delta / size) + 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 diff --git a/src/client/state.ts b/src/client/state.ts index c778a47..022492d 100644 --- a/src/client/state.ts +++ b/src/client/state.ts @@ -624,5 +624,13 @@ export class SidebarStore { } } -/** Module-level store instance (one per page; the plugin mounts once). */ -export const sidebarStore = new SidebarStore() +/** + * 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() +} diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..847b0d0 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,56 @@ +/** + * 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' + +/** 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 = 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, + } +} diff --git a/src/context-types.ts b/src/context-types.ts index 3bfe11a..ec35c7b 100644 --- a/src/context-types.ts +++ b/src/context-types.ts @@ -125,12 +125,27 @@ export interface SidebarConversation { } } +/** + * 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 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 +} + declare module 'cordis' { interface Context { httpServer: SidebarHttpServer sessions: SidebarSessionStore & SidebarSessionsService loader: SidebarLoader slots: SidebarSlotsService + invariants: SidebarInvariantsService /** * Register a lifecycle callback (DSH-vendored cordis): runs at plugin * activation; its returned cleanup runs at disposal. diff --git a/src/index.ts b/src/index.ts index 6f63733..951546c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,28 +16,26 @@ import { 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, + resolveSidebarConfig, + type ResolvedSidebarConfig, + type SidebarConfig, +} from './config.ts' import { parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts' import { isTrustedApiRequest } from './trust-fence.ts' import * as git from './git.ts' import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts' import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts' +export { Config } +export type { SidebarConfig, ResolvedSidebarConfig } + /** Plugin identity for cordis.yml rows. */ export const name = 'dsh-better-sidebar' -/** Services required before mounting: the webserver routes and the session store. */ -export const inject = ['httpServer', 'sessions'] - -/** Read cap of one text file (bytes); larger files return truncated. */ -const READ_LIMIT = 512 * 1024 -/** Media route cap (bytes); larger binaries are refused. */ -const MEDIA_LIMIT = 20 * 1024 * 1024 -/** Explorer row bound of one level. */ -const LIST_LIMIT = 1000 -/** Terminals per session. */ -const TERMINALS_PER_SESSION = 3 -/** How long a disconnected terminal process survives awaiting a reconnect. */ -const TERMINAL_RECONNECT_GRACE_MS = 30_000 +/** 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 = { @@ -88,7 +86,7 @@ function sessionCwdOf(ctx: Context, sessionId: string, clientCwd?: string): stri } /** Text read of a file with the size cap; binary detection via NUL probe. */ -async function readText(path: string): Promise<{ content: string; truncated: boolean; binary: boolean; size: number }> { +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) }) @@ -96,12 +94,12 @@ async function readText(path: string): Promise<{ content: string; truncated: boo throw new SidebarError('fs-error', `"${path}" is a directory`, 400) } const size = info.size - const truncated = size > READ_LIMIT + 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, READ_LIMIT)) + 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) @@ -114,8 +112,8 @@ async function readText(path: string): Promise<{ content: string; truncated: boo /** One API method dispatch table entry. */ type ApiMethod = (payload: unknown) => Promise | unknown -/** Build the API method table bound to the plugin context and pty manager. */ -function buildApi(ctx: Context, ptyManager: PtyManager): Record { +/** Build the API method table bound to the plugin context, pty manager, and resolved config. */ +function buildApi(ctx: Context, ptyManager: PtyManager, resolved: ResolvedSidebarConfig): Record { const cwdOf = (payload: unknown): { sessionId: string; cwd: string } => { const sessionId = requireString(payload, 'sessionId') const record = payload as { cwd?: unknown } | null @@ -131,12 +129,12 @@ function buildApi(ctx: Context, ptyManager: PtyManager): Record { const { cwd } = cwdOf(payload) const path = requireAbsolute(requireString(payload, 'path')) - const { content, truncated, binary, size } = await readText(path) + const { content, truncated, binary, size } = await readText(path, resolved.readLimit) if (binary) return { kind: 'binary', size, truncated } return { kind: 'text', content, truncated } }, @@ -220,17 +218,21 @@ function buildApi(ctx: Context, ptyManager: PtyManager): Record isTrustedApiRequest(req, trustedHosts) - const ptyManager = new PtyManager(defaultShell(), TERMINALS_PER_SESSION) + const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession) // ── JSON API ──────────────────────────────────────────────────────────── - const api = buildApi(ctx, ptyManager) + const api = buildApi(ctx, ptyManager, resolved) ctx.effect(() => ctx.httpServer.register({ kind: 'prefix', path: '/sidebar/api', @@ -290,7 +292,7 @@ export function apply(ctx: Context): void { throw new SidebarError('fs-error', 'media path outside the session working directory', 403) } const info = await stat(path) - if (!info.isFile() || info.size > MEDIA_LIMIT) { + 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' @@ -312,7 +314,7 @@ export function apply(ctx: Context): void { socket.destroy() return } - wss.handleUpgrade(req, socket, head, (ws) => { void attachTerminal(ctx, ptyManager, ws, req) }) + wss.handleUpgrade(req, socket, head, (ws) => { void attachTerminal(ctx, ptyManager, ws, req, resolved) }) }, }), 'dsh-better-sidebar: terminal WebSocket') @@ -328,6 +330,7 @@ async function attachTerminal( ptyManager: PtyManager, ws: WebSocket, req: IncomingMessage, + resolved: ResolvedSidebarConfig, ): Promise { try { const url = new URL(req.url ?? '/', 'http://dsh.internal') @@ -386,7 +389,7 @@ async function attachTerminal( // 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, TERMINAL_RECONNECT_GRACE_MS) + ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs) }) } catch (error) { ws.close(1011, error instanceof Error ? error.message : String(error)) diff --git a/src/invariant.ts b/src/invariant.ts new file mode 100644 index 0000000..0f2d3b1 --- /dev/null +++ b/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `dsh-better-sidebar`. + * @module dsh-better-sidebar/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from './context-types.ts' + +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'] + +/** + * 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. + */ +const install: () => void = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/tests/plugin-shape.spec.ts b/tests/plugin-shape.spec.ts new file mode 100644 index 0000000..4872fe1 --- /dev/null +++ b/tests/plugin-shape.spec.ts @@ -0,0 +1,38 @@ +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 + 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 | undefined): Record + })(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) + }) +}) diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..ee9b223 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "//": "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, + "declaration": true, + "emitDeclarationOnly": true, + "outDir": "lib/types" + }, + "include": ["src"] +} diff --git a/tsdown.config.ts b/tsdown.config.ts index c5bc554..4506cbf 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -1,15 +1,22 @@ /** - * tsdown build for dsh-better-sidebar: the host-half lib (lib/index.js, ESM - * node) plus the browser client bundle (lib/client.js, CJS closure factory). + * tsdown build for dsh-better-sidebar: the host-half lib (lib/index.js and + * the lib/invariant.js companion, ESM node) plus the browser client bundle + * (lib/client.js, CJS closure factory). * - * The client bundle replicates the DSH client-bundle contract: - * - externals resolve through the loader module table at runtime (platform - * seed words from apps/web's seed.ts, plus the runtime/client exemption), + * The client bundle replicates the official DSH client-bundle preset + * (packages/client/tsdown.client.ts): + * - 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