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
This commit is contained in:
@@ -82,7 +82,7 @@
|
|||||||
### 前置条件
|
### 前置条件
|
||||||
|
|
||||||
- 已安装 DSH(`dsh web` 可运行),Node.js ≥ 20、pnpm ≥ 10
|
- 已安装 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. 克隆、安装、构建
|
### 1. 克隆、安装、构建
|
||||||
|
|
||||||
@@ -90,10 +90,24 @@
|
|||||||
git clone https://github.com/dsh-external/DSH-better-sidebar.git
|
git clone https://github.com/dsh-external/DSH-better-sidebar.git
|
||||||
cd DSH-better-sidebar
|
cd DSH-better-sidebar
|
||||||
pnpm install
|
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` 加入:
|
编辑 `~/.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-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
|
```yaml
|
||||||
- insert:
|
- insert:
|
||||||
@@ -109,12 +129,6 @@ pnpm build # 产物: lib/index.js (host) + lib/client.js (client)
|
|||||||
name: 'dsh-better-sidebar'
|
name: 'dsh-better-sidebar'
|
||||||
```
|
```
|
||||||
|
|
||||||
然后安装依赖:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
(cd ~/.dsh/profiles/web && pnpm install)
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. 重启 GUI 并刷新页面
|
### 3. 重启 GUI 并刷新页面
|
||||||
|
|
||||||
```sh
|
```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
|
```sh
|
||||||
pnpm typecheck # tsc --noEmit
|
pnpm typecheck # tsc --noEmit
|
||||||
pnpm test # vitest(单元 + 冒烟:真实 git/fs/node-pty 交互)
|
pnpm test # vitest(单元 + 冒烟 + 插件形态 guard:真实 git/fs/node-pty 交互)
|
||||||
pnpm build # tsc + tsdown → lib/index.js + lib/client.js
|
pnpm build # rm -rf lib && tsc(声明) + tsdown → lib/index.js + lib/invariant.js + lib/client.js + lib/types
|
||||||
pnpm watch # tsdown --watch(client bundle 热重建)
|
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 包同构):
|
一个 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 拦截 |
|
| client | `src/client/index.tsx` → `lib/client.js` | 浏览器 bundle(`__ModuleLoader__.load` 闭包工厂):portal 侧边栏 + 各视图 + turnTail 拦截 |
|
||||||
|
|
||||||
- 所有 API 携带 `sessionId`;cwd 权威值取自会话 header,会话未附加(页面加载竞态)时回退客户端摘要 cwd,再回退进程 cwd;终端按 `${sessionId}:${tabId}` 键控,重连时若权威 cwd 变化则重启 shell 到正确目录
|
- 所有 API 携带 `sessionId`;cwd 权威值取自会话 header,会话未附加(页面加载竞态)时回退客户端摘要 cwd,再回退进程 cwd;终端按 `${sessionId}:${tabId}` 键控,重连时若权威 cwd 变化则重启 shell 到正确目录
|
||||||
|
|||||||
+42
-2
@@ -5,29 +5,64 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "lib/index.js",
|
"main": "lib/index.js",
|
||||||
|
"types": "lib/types/index.d.ts",
|
||||||
"exports": {
|
"exports": {
|
||||||
".": {
|
".": {
|
||||||
|
"types": "./lib/types/index.d.ts",
|
||||||
"default": "./lib/index.js"
|
"default": "./lib/index.js"
|
||||||
},
|
},
|
||||||
|
"./invariant": {
|
||||||
|
"types": "./lib/types/invariant.d.ts",
|
||||||
|
"default": "./lib/invariant.js"
|
||||||
|
},
|
||||||
"./client": {
|
"./client": {
|
||||||
|
"types": "./lib/types/client/index.d.ts",
|
||||||
"default": "./lib/client.js"
|
"default": "./lib/client.js"
|
||||||
},
|
},
|
||||||
|
"./src/*": "./src/*",
|
||||||
"./package.json": "./package.json"
|
"./package.json": "./package.json"
|
||||||
},
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
"dshClient": {
|
"dshClient": {
|
||||||
|
"inject": [
|
||||||
|
"@deepseek-ai/dsh-client-runtime",
|
||||||
|
"@deepseek-ai/dsh-client-ui-slots",
|
||||||
|
"@deepseek-ai/dsh-client-ui-conversation"
|
||||||
|
],
|
||||||
"platform": "web"
|
"platform": "web"
|
||||||
},
|
},
|
||||||
"files": [
|
"files": [
|
||||||
"lib"
|
"lib/index.js",
|
||||||
|
"lib/invariant.js",
|
||||||
|
"lib/client.js",
|
||||||
|
"lib/types/**/*.d.ts",
|
||||||
|
"src"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc --noEmit && tsdown",
|
"build": "rm -rf lib && tsc -p tsconfig.build.json && tsdown",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"bundle": "tsdown",
|
"bundle": "tsdown",
|
||||||
"watch": "tsdown --watch",
|
"watch": "tsdown --watch",
|
||||||
|
"prepare": "tsdown",
|
||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"license": "MIT",
|
"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": {
|
"dependencies": {
|
||||||
"@codemirror/commands": "^6.10.4",
|
"@codemirror/commands": "^6.10.4",
|
||||||
"@codemirror/lang-cpp": "^6.0.3",
|
"@codemirror/lang-cpp": "^6.0.3",
|
||||||
@@ -53,20 +88,25 @@
|
|||||||
"@xterm/addon-fit": "^0.10.0",
|
"@xterm/addon-fit": "^0.10.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"node-pty": "^1.1.0",
|
"node-pty": "^1.1.0",
|
||||||
|
"schemastery": "^3.18.0",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
"xterm": "^5.3.0"
|
"xterm": "^5.3.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@deepseek-ai/dsh-client-runtime": "link:/Users/menghuan/.dsh/source/current/packages/client/runtime",
|
"@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-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-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-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-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-session": "link:/Users/menghuan/.dsh/source/current/packages/core/session",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/react": "~18.3.1",
|
"@types/react": "~18.3.1",
|
||||||
"@types/react-dom": "~18.3.1",
|
"@types/react-dom": "~18.3.1",
|
||||||
"@types/ws": "^8.5.10",
|
"@types/ws": "^8.5.10",
|
||||||
|
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
|
||||||
"cordis": "^4.0.0-rc.7",
|
"cordis": "^4.0.0-rc.7",
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
|
|||||||
Generated
+41
-2
@@ -80,6 +80,9 @@ importers:
|
|||||||
node-pty:
|
node-pty:
|
||||||
specifier: ^1.1.0
|
specifier: ^1.1.0
|
||||||
version: 1.1.0
|
version: 1.1.0
|
||||||
|
schemastery:
|
||||||
|
specifier: ^3.18.0
|
||||||
|
version: 3.18.0
|
||||||
ws:
|
ws:
|
||||||
specifier: ^8.18.0
|
specifier: ^8.18.0
|
||||||
version: 8.21.2
|
version: 8.21.2
|
||||||
@@ -87,9 +90,15 @@ importers:
|
|||||||
specifier: ^5.3.0
|
specifier: ^5.3.0
|
||||||
version: 5.3.0
|
version: 5.3.0
|
||||||
devDependencies:
|
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':
|
'@deepseek-ai/dsh-client-runtime':
|
||||||
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/runtime
|
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/runtime
|
||||||
version: link:../../.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':
|
'@deepseek-ai/dsh-client-ui-conversation':
|
||||||
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-conversation
|
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-conversation
|
||||||
version: link:../../.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':
|
'@deepseek-ai/dsh-client-ui-slots':
|
||||||
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-slots
|
specifier: link:/Users/menghuan/.dsh/source/current/packages/client/ui-slots
|
||||||
version: link:../../.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':
|
'@deepseek-ai/dsh-host-webserver':
|
||||||
specifier: link:/Users/menghuan/.dsh/source/current/packages/host/webserver
|
specifier: link:/Users/menghuan/.dsh/source/current/packages/host/webserver
|
||||||
version: link:../../.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':
|
'@deepseek-ai/dsh-session':
|
||||||
specifier: link:/Users/menghuan/.dsh/source/current/packages/core/session
|
specifier: link:/Users/menghuan/.dsh/source/current/packages/core/session
|
||||||
version: link:../../.dsh/source/current/packages/core/session
|
version: link:../../.dsh/source/current/packages/core/session
|
||||||
@@ -119,7 +134,7 @@ importers:
|
|||||||
version: 8.18.1
|
version: 8.18.1
|
||||||
cordis:
|
cordis:
|
||||||
specifier: ^4.0.0-rc.7
|
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:
|
lightningcss:
|
||||||
specifier: ^1.32.0
|
specifier: ^1.32.0
|
||||||
version: 1.33.0
|
version: 1.33.0
|
||||||
@@ -207,6 +222,15 @@ packages:
|
|||||||
'@codemirror/view@6.43.8':
|
'@codemirror/view@6.43.8':
|
||||||
resolution: {integrity: sha512-qtItTDssZ/5GFfi94hrILu9j/VUeFPDPkhovEfmWFj2ipTxnzPB8DdHgfbb8HYTzLTYhrndKmyQxXUz/PDLenw==}
|
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':
|
'@jridgewell/sourcemap-codec@1.5.5':
|
||||||
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
|
||||||
|
|
||||||
@@ -814,6 +838,9 @@ packages:
|
|||||||
scheduler@0.23.0:
|
scheduler@0.23.0:
|
||||||
resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
|
resolution: {integrity: sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw==}
|
||||||
|
|
||||||
|
schemastery@3.18.0:
|
||||||
|
resolution: {integrity: sha512-Jw2uxjoyyqc/yeurmChUEc/jbi8GsrdXV/KmqRUDZXJAXAmrJiPsz8vKa17l/VckyzljHZ9oGaul443CQiXxtA==}
|
||||||
|
|
||||||
siginfo@2.0.0:
|
siginfo@2.0.0:
|
||||||
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
|
||||||
|
|
||||||
@@ -1179,6 +1206,11 @@ snapshots:
|
|||||||
style-mod: 4.1.3
|
style-mod: 4.1.3
|
||||||
w3c-keyname: 2.2.8
|
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': {}
|
'@jridgewell/sourcemap-codec@1.5.5': {}
|
||||||
|
|
||||||
'@lezer/common@1.5.2': {}
|
'@lezer/common@1.5.2': {}
|
||||||
@@ -1483,10 +1515,12 @@ snapshots:
|
|||||||
|
|
||||||
convert-source-map@2.0.0: {}
|
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:
|
dependencies:
|
||||||
'@standard-schema/spec': 1.1.0
|
'@standard-schema/spec': 1.1.0
|
||||||
cosmokit: 1.8.1
|
cosmokit: 1.8.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@cordisjs/plugin-loader': 1.0.0-rc.5(cordis@4.0.0-rc.7)
|
||||||
|
|
||||||
cosmokit@1.8.1: {}
|
cosmokit@1.8.1: {}
|
||||||
|
|
||||||
@@ -1658,6 +1692,11 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
loose-envify: 1.4.0
|
loose-envify: 1.4.0
|
||||||
|
|
||||||
|
schemastery@3.18.0:
|
||||||
|
dependencies:
|
||||||
|
'@standard-schema/spec': 1.1.0
|
||||||
|
cosmokit: 1.8.1
|
||||||
|
|
||||||
siginfo@2.0.0: {}
|
siginfo@2.0.0: {}
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|||||||
+26
-24
@@ -16,8 +16,8 @@ import { IconChevronRightOutline14, IconFullscreenOutline16, IconPanelLeftOutlin
|
|||||||
import type { Context, SidebarConversation } from '../context-types.ts'
|
import type { Context, SidebarConversation } from '../context-types.ts'
|
||||||
import {
|
import {
|
||||||
allLeaves, closeTab, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openTab,
|
allLeaves, closeTab, leafWithTab, mapLeaf, moveTab, moveTabToEdge, openTab,
|
||||||
resizeSplit, sidebarStore, setWidth, toggleExpanded, togglePanel,
|
resizeSplit, setWidth, toggleExpanded, togglePanel,
|
||||||
TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarTab, type SplitNode,
|
TERMINAL_LIMIT, type DropZone, type SidebarState, type SidebarStore, type SidebarTab, type SplitNode,
|
||||||
} from './state.ts'
|
} from './state.ts'
|
||||||
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
import { Workbench, type WorkbenchActions } from './split-pane.tsx'
|
||||||
import type { NewTabOption } from './TabBar.tsx'
|
import type { NewTabOption } from './TabBar.tsx'
|
||||||
@@ -42,8 +42,9 @@ function TabContent(props: {
|
|||||||
onToggleDir: (path: string) => void
|
onToggleDir: (path: string) => void
|
||||||
onReferenceFile: (path: string) => void
|
onReferenceFile: (path: string) => void
|
||||||
ctx: Context
|
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 }
|
const scope = { sessionId, cwd }
|
||||||
switch (tab.type) {
|
switch (tab.type) {
|
||||||
case 'explorer':
|
case 'explorer':
|
||||||
@@ -53,14 +54,14 @@ function TabContent(props: {
|
|||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
expanded={expanded}
|
expanded={expanded}
|
||||||
onToggle={onToggleDir}
|
onToggle={onToggleDir}
|
||||||
onOpenFile={(path) => { openSidebarFile(ctx, sessionId, path) }}
|
onOpenFile={(path) => { openSidebarFile(ctx, store, sessionId, path) }}
|
||||||
onReferenceFile={onReferenceFile}
|
onReferenceFile={onReferenceFile}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
case 'git':
|
case 'git':
|
||||||
return <GitView scope={scope} />
|
return <GitView scope={scope} />
|
||||||
case 'terminal':
|
case 'terminal':
|
||||||
return <TerminalView scope={scope} tabId={tab.id} />
|
return <TerminalView scope={scope} tabId={tab.id} store={store} />
|
||||||
case 'editor':
|
case 'editor':
|
||||||
return <EditorView scope={scope} path={tab.path ?? ''} title={tab.title} />
|
return <EditorView scope={scope} path={tab.path ?? ''} title={tab.title} />
|
||||||
}
|
}
|
||||||
@@ -83,8 +84,8 @@ function buildNewTabOptions(state: SidebarState): NewTabOption[] {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar(props: { ctx: Context }) {
|
export function Sidebar(props: { ctx: Context; store: SidebarStore }) {
|
||||||
const { ctx } = props
|
const { ctx, store } = props
|
||||||
|
|
||||||
// Current conversation (the sessions list feed).
|
// Current conversation (the sessions list feed).
|
||||||
const sessionList = useSyncExternalStore(
|
const sessionList = useSyncExternalStore(
|
||||||
@@ -95,10 +96,10 @@ export function Sidebar(props: { ctx: Context }) {
|
|||||||
|
|
||||||
// Per-session sidebar state.
|
// Per-session sidebar state.
|
||||||
const snapshot = useSyncExternalStore(
|
const snapshot = useSyncExternalStore(
|
||||||
useCallback((callback: () => void) => sidebarStore.subscribe(callback), []),
|
useCallback((callback: () => void) => store.subscribe(callback), [store]),
|
||||||
useCallback(() => sidebarStore.getSnapshot(), []),
|
useCallback(() => store.getSnapshot(), [store]),
|
||||||
)
|
)
|
||||||
useEffect(() => { sidebarStore.setSession(current) }, [current])
|
useEffect(() => { store.setSession(current) }, [current, store])
|
||||||
|
|
||||||
const state = snapshot.state
|
const state = snapshot.state
|
||||||
const sessionId = snapshot.sessionId
|
const sessionId = snapshot.sessionId
|
||||||
@@ -149,16 +150,16 @@ export function Sidebar(props: { ctx: Context }) {
|
|||||||
// A closed terminal releases its pty immediately — including when its
|
// A closed terminal releases its pty immediately — including when its
|
||||||
// socket is mid-reconnect, where the unmount close frame never reaches
|
// socket is mid-reconnect, where the unmount close frame never reaches
|
||||||
// the host and the process would hold the quota until the grace ends.
|
// 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 leaf = current === undefined ? undefined : leafWithTab(current.splits, tabId)
|
||||||
const tab = leaf?.tabs.find(candidate => candidate.id === 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') {
|
if (sessionId !== undefined && tab?.type === 'terminal') {
|
||||||
void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
|
void api.ptyClose({ sessionId, cwd }, tabId).catch(() => { /* the host may already have released it */ })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
activateTab: (paneId, tabId) => {
|
activateTab: (paneId, tabId) => {
|
||||||
sidebarStore.reduce(s => ({
|
store.reduce(s => ({
|
||||||
...s,
|
...s,
|
||||||
activePane: paneId,
|
activePane: paneId,
|
||||||
splits: mapLeaf(s.splits, paneId, (leaf) => {
|
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) => {
|
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) => {
|
moveTabBefore: (payload: TabDragPayload, toPane: string, beforeTabId: string) => {
|
||||||
sidebarStore.reduce((s) => {
|
store.reduce((s) => {
|
||||||
let index = -1
|
let index = -1
|
||||||
const source = leafWithTab(s.splits, beforeTabId)
|
const source = leafWithTab(s.splits, beforeTabId)
|
||||||
if (source !== undefined && source.id === toPane) {
|
if (source !== undefined && source.id === toPane) {
|
||||||
@@ -181,9 +182,9 @@ export function Sidebar(props: { ctx: Context }) {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
resizeSplit: (splitId, index, deltaFrac) => {
|
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 `@<relative path>` to the
|
* The explorer's @-reference button: append `@<relative path>` to the
|
||||||
@@ -223,7 +224,7 @@ export function Sidebar(props: { ctx: Context }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onNewTab = (optionId: string): void => {
|
const onNewTab = (optionId: string): void => {
|
||||||
sidebarStore.reduce((s) => {
|
store.reduce((s) => {
|
||||||
if (optionId === 'explorer') {
|
if (optionId === 'explorer') {
|
||||||
return openTab(s, { id: 'explorer', type: 'explorer', title: t('explorer') })
|
return openTab(s, { id: 'explorer', type: 'explorer', title: t('explorer') })
|
||||||
}
|
}
|
||||||
@@ -256,9 +257,10 @@ export function Sidebar(props: { ctx: Context }) {
|
|||||||
sessionId={sessionId}
|
sessionId={sessionId}
|
||||||
cwd={cwd}
|
cwd={cwd}
|
||||||
expanded={state.expanded}
|
expanded={state.expanded}
|
||||||
onToggleDir={(path) => { sidebarStore.reduce(s => toggleExpanded(s, path)) }}
|
onToggleDir={(path) => { store.reduce(s => toggleExpanded(s, path)) }}
|
||||||
onReferenceFile={referenceInChat}
|
onReferenceFile={referenceInChat}
|
||||||
ctx={ctx}
|
ctx={ctx}
|
||||||
|
store={store}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -271,7 +273,7 @@ export function Sidebar(props: { ctx: Context }) {
|
|||||||
type="button"
|
type="button"
|
||||||
className={css.toggleButton}
|
className={css.toggleButton}
|
||||||
aria-label={t('expand')}
|
aria-label={t('expand')}
|
||||||
onClick={() => { sidebarStore.reduce(togglePanel) }}
|
onClick={() => { store.reduce(togglePanel) }}
|
||||||
>
|
>
|
||||||
<IconPanelLeftOutline16 size={16} />
|
<IconPanelLeftOutline16 size={16} />
|
||||||
</button>
|
</button>
|
||||||
@@ -298,7 +300,7 @@ export function Sidebar(props: { ctx: Context }) {
|
|||||||
onPointerMove={(event) => {
|
onPointerMove={(event) => {
|
||||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||||
const { startX, startWidth } = widthDrag.current
|
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) => {
|
onPointerUp={(event) => {
|
||||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||||
@@ -314,7 +316,7 @@ export function Sidebar(props: { ctx: Context }) {
|
|||||||
title={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
|
title={fullscreen ? t('restoreFullscreen') : t('expandFullscreen')}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const viewport = window.innerWidth
|
const viewport = window.innerWidth
|
||||||
sidebarStore.reduce(s => setWidth(s, fullscreen
|
store.reduce(s => setWidth(s, fullscreen
|
||||||
? Math.max(280, Math.round((viewport - 320) / 2))
|
? Math.max(280, Math.round((viewport - 320) / 2))
|
||||||
: viewport))
|
: viewport))
|
||||||
}}
|
}}
|
||||||
@@ -330,7 +332,7 @@ export function Sidebar(props: { ctx: Context }) {
|
|||||||
className={css.iconButton}
|
className={css.iconButton}
|
||||||
aria-label={t('collapse')}
|
aria-label={t('collapse')}
|
||||||
title={t('collapse')}
|
title={t('collapse')}
|
||||||
onClick={() => { sidebarStore.reduce(togglePanel) }}
|
onClick={() => { store.reduce(togglePanel) }}
|
||||||
>
|
>
|
||||||
<IconChevronRightOutline14 />
|
<IconChevronRightOutline14 />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { FitAddon } from '@xterm/addon-fit'
|
|||||||
import 'xterm/css/xterm.css'
|
import 'xterm/css/xterm.css'
|
||||||
import { t } from './locales.ts'
|
import { t } from './locales.ts'
|
||||||
import type { SessionScope } from './api.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 { isDarkScheme, subscribeColorScheme, tokenValue } from './theme.ts'
|
||||||
import css from './sidebar.module.css'
|
import css from './sidebar.module.css'
|
||||||
|
|
||||||
@@ -60,8 +60,8 @@ function xtermTheme(): ITheme {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TerminalView(props: { scope: SessionScope; tabId: string }) {
|
export function TerminalView(props: { scope: SessionScope; tabId: string; store: SidebarStore }) {
|
||||||
const { scope, tabId } = props
|
const { scope, tabId, store } = props
|
||||||
const hostRef = useRef<HTMLDivElement>(null)
|
const hostRef = useRef<HTMLDivElement>(null)
|
||||||
const [connected, setConnected] = useState(false)
|
const [connected, setConnected] = useState(false)
|
||||||
const [fatal, setFatal] = useState<string | null>(null)
|
const [fatal, setFatal] = useState<string | null>(null)
|
||||||
@@ -180,7 +180,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string }) {
|
|||||||
// the process to the host's reconnect grace: switching back or
|
// the process to the host's reconnect grace: switching back or
|
||||||
// refreshing reattaches the SAME shell instead of respawning one.
|
// refreshing reattaches the SAME shell instead of respawning one.
|
||||||
// (The host respawns on its own when the authoritative cwd changed.)
|
// (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 !== null && socket.readyState === WebSocket.OPEN) {
|
||||||
socket.send(JSON.stringify({ type: 'close' }))
|
socket.send(JSON.stringify({ type: 'close' }))
|
||||||
}
|
}
|
||||||
@@ -188,7 +188,7 @@ export function TerminalView(props: { scope: SessionScope; tabId: string }) {
|
|||||||
term.dispose()
|
term.dispose()
|
||||||
connectRef.current = null
|
connectRef.current = null
|
||||||
}
|
}
|
||||||
}, [scope.sessionId, scope.cwd, tabId])
|
}, [scope.sessionId, scope.cwd, tabId, store])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css.terminalWrap}>
|
<div className={css.terminalWrap}>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
import { Component, createElement, type ErrorInfo, type ReactNode } from 'react'
|
import { Component, createElement, type ErrorInfo, type ReactNode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import type { Context } from '../context-types.ts'
|
import type { Context } from '../context-types.ts'
|
||||||
|
import { createSidebarStore } from './state.ts'
|
||||||
import { Sidebar } from './Sidebar.tsx'
|
import { Sidebar } from './Sidebar.tsx'
|
||||||
import { registerTurnTailInterception } from './intercept.tsx'
|
import { registerTurnTailInterception } from './intercept.tsx'
|
||||||
import { t } from './locales.ts'
|
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).
|
* @param ctx - the client cordis context (slots, sessions).
|
||||||
*/
|
*/
|
||||||
export function apply(ctx: Context): void {
|
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
|
// 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
|
// silently: log with the plugin prefix and pin a visible diagnostic strip
|
||||||
// to the page so a blank panel is never the only symptom.
|
// 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', '')
|
host.setAttribute('data-dsh-better-sidebar', '')
|
||||||
document.body.appendChild(host)
|
document.body.appendChild(host)
|
||||||
const root = createRoot(host)
|
const root = createRoot(host)
|
||||||
root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx })))
|
root.render(createElement(SidebarBoundary, null, createElement(Sidebar, { ctx, store: sidebarStore })))
|
||||||
return () => {
|
return () => {
|
||||||
root.unmount()
|
root.unmount()
|
||||||
host.remove()
|
host.remove()
|
||||||
@@ -94,7 +100,7 @@ export function apply(ctx: Context): void {
|
|||||||
ctx.effect(
|
ctx.effect(
|
||||||
() => {
|
() => {
|
||||||
try {
|
try {
|
||||||
return registerTurnTailInterception(ctx)
|
return registerTurnTailInterception(ctx, sidebarStore)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
fail('interception', error)
|
fail('interception', error)
|
||||||
return undefined
|
return undefined
|
||||||
|
|||||||
@@ -8,19 +8,18 @@
|
|||||||
*/
|
*/
|
||||||
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
import { IconCodeOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||||
import type { Context } from '../context-types.ts'
|
import type { Context } from '../context-types.ts'
|
||||||
import { openTab } from './state.ts'
|
import { openTab, type SidebarStore } from './state.ts'
|
||||||
import { sidebarStore } from './state.ts'
|
|
||||||
import { t } from './locales.ts'
|
import { t } from './locales.ts'
|
||||||
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
|
import { resolveSidebarPath, selectProducedFiles } from './produced-files.ts'
|
||||||
import css from './sidebar.module.css'
|
import css from './sidebar.module.css'
|
||||||
|
|
||||||
/** Open a file in the sidebar's editor (used by the intercepted row and the explorer). */
|
/** 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 summary = ctx.sessions.list.getSnapshot().byId[sessionId]
|
||||||
const absolute = resolveSidebarPath(summary?.cwd, path)
|
const absolute = resolveSidebarPath(summary?.cwd, path)
|
||||||
const at = Math.max(absolute.lastIndexOf('/'), absolute.lastIndexOf('\\'))
|
const at = Math.max(absolute.lastIndexOf('/'), absolute.lastIndexOf('\\'))
|
||||||
const title = at === -1 ? absolute : absolute.slice(at + 1)
|
const title = at === -1 ? absolute : absolute.slice(at + 1)
|
||||||
sidebarStore.reduce(state => openTab(state, {
|
store.reduce(state => openTab(state, {
|
||||||
id: `editor:${absolute}`,
|
id: `editor:${absolute}`,
|
||||||
type: 'editor',
|
type: 'editor',
|
||||||
title,
|
title,
|
||||||
@@ -61,14 +60,14 @@ export function SidebarProducedFiles(props: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Register the turn-tail interception (returns the disposer). */
|
/** 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({
|
return ctx.slots.register({
|
||||||
name: 'conversation.chat.turnTail',
|
name: 'conversation.chat.turnTail',
|
||||||
select: selectProducedFiles,
|
select: selectProducedFiles,
|
||||||
priority: -1,
|
priority: -1,
|
||||||
registrant: 'dsh-better-sidebar',
|
registrant: 'dsh-better-sidebar',
|
||||||
inject: (sessionId: string) => ({
|
inject: (sessionId: string) => ({
|
||||||
openInSidebar: (path: string) => { openSidebarFile(ctx, sessionId, path) },
|
openInSidebar: (path: string) => { openSidebarFile(ctx, store, sessionId, path) },
|
||||||
}),
|
}),
|
||||||
}, SidebarProducedFiles)
|
}, SidebarProducedFiles)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,10 +31,17 @@ export interface WorkbenchActions {
|
|||||||
resizeSplit: (splitId: string, index: number, deltaFrac: number) => void
|
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 }) {
|
function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => void }) {
|
||||||
const { dir, onResize } = props
|
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)
|
const [dragging, setDragging] = useState(false)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -44,7 +51,7 @@ function Divider(props: { dir: 'row' | 'col'; onResize: (deltaFrac: number) => v
|
|||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.currentTarget.setPointerCapture(event.pointerId)
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
const box = event.currentTarget.parentElement?.getBoundingClientRect()
|
const box = event.currentTarget.parentElement?.getBoundingClientRect()
|
||||||
start.current = {
|
last.current = {
|
||||||
x: event.clientX,
|
x: event.clientX,
|
||||||
y: event.clientY,
|
y: event.clientY,
|
||||||
size: box === undefined ? 1 : (dir === 'row' ? box.width : box.height),
|
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) => {
|
onPointerMove={(event) => {
|
||||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||||
const delta = dir === 'row' ? event.clientX - start.current.x : event.clientY - start.current.y
|
const delta = dir === 'row' ? event.clientX - last.current.x : event.clientY - last.current.y
|
||||||
const size = Math.max(1, start.current.size)
|
onResize(delta / Math.max(1, last.current.size))
|
||||||
onResize(delta / size)
|
last.current.x = event.clientX
|
||||||
|
last.current.y = event.clientY
|
||||||
}}
|
}}
|
||||||
onPointerUp={(event) => {
|
onPointerUp={(event) => {
|
||||||
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
if (!event.currentTarget.hasPointerCapture(event.pointerId)) return
|
||||||
|
|||||||
+10
-2
@@ -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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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<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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
): () => void
|
||||||
|
}
|
||||||
|
|
||||||
declare module 'cordis' {
|
declare module 'cordis' {
|
||||||
interface Context {
|
interface Context {
|
||||||
httpServer: SidebarHttpServer
|
httpServer: SidebarHttpServer
|
||||||
sessions: SidebarSessionStore & SidebarSessionsService
|
sessions: SidebarSessionStore & SidebarSessionsService
|
||||||
loader: SidebarLoader
|
loader: SidebarLoader
|
||||||
slots: SidebarSlotsService
|
slots: SidebarSlotsService
|
||||||
|
invariants: SidebarInvariantsService
|
||||||
/**
|
/**
|
||||||
* Register a lifecycle callback (DSH-vendored cordis): runs at plugin
|
* Register a lifecycle callback (DSH-vendored cordis): runs at plugin
|
||||||
* activation; its returned cleanup runs at disposal.
|
* activation; its returned cleanup runs at disposal.
|
||||||
|
|||||||
+29
-26
@@ -16,28 +16,26 @@ import { dirname, extname } from 'node:path'
|
|||||||
import type { IncomingMessage } from 'node:http'
|
import type { IncomingMessage } from 'node:http'
|
||||||
import { WebSocket, WebSocketServer } from 'ws'
|
import { WebSocket, WebSocketServer } from 'ws'
|
||||||
import type { Context } from './context-types.ts'
|
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 { parentOf, requireAbsolute, listDirectory, rootLabel } from './fs-tree.ts'
|
||||||
import { isTrustedApiRequest } from './trust-fence.ts'
|
import { isTrustedApiRequest } from './trust-fence.ts'
|
||||||
import * as git from './git.ts'
|
import * as git from './git.ts'
|
||||||
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
|
import { defaultShell, ensureSpawnHelper, PtyManager } from './pty-manager.ts'
|
||||||
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
|
import { readJsonBody, requireString, SidebarError, writeError, writeJson, writeOk } from './wire.ts'
|
||||||
|
|
||||||
|
export { Config }
|
||||||
|
export type { SidebarConfig, ResolvedSidebarConfig }
|
||||||
|
|
||||||
/** Plugin identity for cordis.yml rows. */
|
/** Plugin identity for cordis.yml rows. */
|
||||||
export const name = 'dsh-better-sidebar'
|
export const name = 'dsh-better-sidebar'
|
||||||
|
|
||||||
/** Services required before mounting: the webserver routes and the session store. */
|
/** Services required before mounting: the webserver routes, the session store, and the loader's connection row. */
|
||||||
export const inject = ['httpServer', 'sessions']
|
export const inject = ['httpServer', 'sessions', 'loader']
|
||||||
|
|
||||||
/** 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
|
|
||||||
|
|
||||||
/** Content types for the media route, by extension. */
|
/** Content types for the media route, by extension. */
|
||||||
const MEDIA_TYPES: Record<string, string> = {
|
const MEDIA_TYPES: Record<string, string> = {
|
||||||
@@ -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. */
|
/** 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) => {
|
const info = await stat(path).catch((error: unknown) => {
|
||||||
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
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)
|
throw new SidebarError('fs-error', `"${path}" is a directory`, 400)
|
||||||
}
|
}
|
||||||
const size = info.size
|
const size = info.size
|
||||||
const truncated = size > READ_LIMIT
|
const truncated = size > readLimit
|
||||||
const handle = await open(path, 'r').catch((error: unknown) => {
|
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)
|
throw new SidebarError('fs-error', `cannot read "${path}": ${error instanceof Error ? error.message : String(error)}`, 400)
|
||||||
})
|
})
|
||||||
try {
|
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 { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
|
||||||
const slice = buffer.subarray(0, bytesRead)
|
const slice = buffer.subarray(0, bytesRead)
|
||||||
const binary = slice.includes(0)
|
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. */
|
/** One API method dispatch table entry. */
|
||||||
type ApiMethod = (payload: unknown) => Promise<unknown> | unknown
|
type ApiMethod = (payload: unknown) => Promise<unknown> | unknown
|
||||||
|
|
||||||
/** Build the API method table bound to the plugin context and pty manager. */
|
/** Build the API method table bound to the plugin context, pty manager, and resolved config. */
|
||||||
function buildApi(ctx: Context, ptyManager: PtyManager): Record<string, ApiMethod> {
|
function buildApi(ctx: Context, ptyManager: PtyManager, resolved: ResolvedSidebarConfig): Record<string, ApiMethod> {
|
||||||
const cwdOf = (payload: unknown): { sessionId: string; cwd: string } => {
|
const cwdOf = (payload: unknown): { sessionId: string; cwd: string } => {
|
||||||
const sessionId = requireString(payload, 'sessionId')
|
const sessionId = requireString(payload, 'sessionId')
|
||||||
const record = payload as { cwd?: unknown } | null
|
const record = payload as { cwd?: unknown } | null
|
||||||
@@ -131,12 +129,12 @@ function buildApi(ctx: Context, ptyManager: PtyManager): Record<string, ApiMetho
|
|||||||
const { cwd } = cwdOf(payload)
|
const { cwd } = cwdOf(payload)
|
||||||
const record = payload as { path?: unknown }
|
const record = payload as { path?: unknown }
|
||||||
const target = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path'))
|
const target = record.path === undefined ? cwd : requireAbsolute(requireString(payload, 'path'))
|
||||||
return listDirectory(target, LIST_LIMIT)
|
return listDirectory(target, resolved.listLimit)
|
||||||
},
|
},
|
||||||
'fs.read': async (payload) => {
|
'fs.read': async (payload) => {
|
||||||
const { cwd } = cwdOf(payload)
|
const { cwd } = cwdOf(payload)
|
||||||
const path = requireAbsolute(requireString(payload, 'path'))
|
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 }
|
if (binary) return { kind: 'binary', size, truncated }
|
||||||
return { kind: 'text', content, truncated }
|
return { kind: 'text', content, truncated }
|
||||||
},
|
},
|
||||||
@@ -220,17 +218,21 @@ function buildApi(ctx: Context, ptyManager: PtyManager): Record<string, ApiMetho
|
|||||||
/**
|
/**
|
||||||
* Plugin body: mount the fenced routes and the pty lifecycle.
|
* Plugin body: mount the fenced routes and the pty lifecycle.
|
||||||
* @param ctx - host plugin context (httpServer, sessions, loader).
|
* @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): void {
|
export function apply(ctx: Context, config?: SidebarConfig): void {
|
||||||
// pnpm strips the executable bit from node-pty's prebuilt spawn-helper;
|
// pnpm strips the executable bit from node-pty's prebuilt spawn-helper;
|
||||||
// restore it before any terminal can spawn (idempotent).
|
// restore it before any terminal can spawn (idempotent).
|
||||||
ensureSpawnHelper()
|
ensureSpawnHelper()
|
||||||
|
const resolved = resolveSidebarConfig(config)
|
||||||
const trustedHosts = trustedHostsOf(ctx)
|
const trustedHosts = trustedHostsOf(ctx)
|
||||||
const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHosts)
|
const fence = (req: IncomingMessage): boolean => isTrustedApiRequest(req, trustedHosts)
|
||||||
const ptyManager = new PtyManager(defaultShell(), TERMINALS_PER_SESSION)
|
const ptyManager = new PtyManager(defaultShell(), resolved.terminalsPerSession)
|
||||||
|
|
||||||
// ── JSON API ────────────────────────────────────────────────────────────
|
// ── JSON API ────────────────────────────────────────────────────────────
|
||||||
const api = buildApi(ctx, ptyManager)
|
const api = buildApi(ctx, ptyManager, resolved)
|
||||||
ctx.effect(() => ctx.httpServer.register({
|
ctx.effect(() => ctx.httpServer.register({
|
||||||
kind: 'prefix',
|
kind: 'prefix',
|
||||||
path: '/sidebar/api',
|
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)
|
throw new SidebarError('fs-error', 'media path outside the session working directory', 403)
|
||||||
}
|
}
|
||||||
const info = await stat(path)
|
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)
|
throw new SidebarError('fs-error', 'not a file or too large', 400)
|
||||||
}
|
}
|
||||||
const type = MEDIA_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
|
const type = MEDIA_TYPES[extname(path).toLowerCase()] ?? 'application/octet-stream'
|
||||||
@@ -312,7 +314,7 @@ export function apply(ctx: Context): void {
|
|||||||
socket.destroy()
|
socket.destroy()
|
||||||
return
|
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')
|
}), 'dsh-better-sidebar: terminal WebSocket')
|
||||||
|
|
||||||
@@ -328,6 +330,7 @@ async function attachTerminal(
|
|||||||
ptyManager: PtyManager,
|
ptyManager: PtyManager,
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
req: IncomingMessage,
|
req: IncomingMessage,
|
||||||
|
resolved: ResolvedSidebarConfig,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
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
|
// A bare socket drop (refresh, tab switch) leaves the process alive
|
||||||
// for a grace period so a quick reconnect keeps it; the reconnect's
|
// for a grace period so a quick reconnect keeps it; the reconnect's
|
||||||
// open() cancels the pending close.
|
// open() cancels the pending close.
|
||||||
ptyManager.scheduleClose(handle.key, TERMINAL_RECONNECT_GRACE_MS)
|
ptyManager.scheduleClose(handle.key, resolved.reconnectGraceMs)
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ws.close(1011, error instanceof Error ? error.message : String(error))
|
ws.close(1011, error instanceof Error ? error.message : String(error))
|
||||||
|
|||||||
@@ -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 */
|
||||||
@@ -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<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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
+44
-8
@@ -1,15 +1,22 @@
|
|||||||
/**
|
/**
|
||||||
* tsdown build for dsh-better-sidebar: the host-half lib (lib/index.js, ESM
|
* tsdown build for dsh-better-sidebar: the host-half lib (lib/index.js and
|
||||||
* node) plus the browser client bundle (lib/client.js, CJS closure factory).
|
* 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:
|
* The client bundle replicates the official DSH client-bundle preset
|
||||||
* - externals resolve through the loader module table at runtime (platform
|
* (packages/client/tsdown.client.ts):
|
||||||
* seed words from apps/web's seed.ts, plus the runtime/client exemption),
|
* - 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, ...),
|
* - 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>
|
* - CSS Modules compile to hashed class maps and inject <style data-plugin>
|
||||||
* tags at factory execution,
|
* tags at factory execution,
|
||||||
* - the artifact registers itself via window.__ModuleLoader__.load({id,
|
* - the artifact registers itself via window.__ModuleLoader__.load({id,
|
||||||
* factory}) with the (require) => exports CJS closure shape.
|
* 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 { readFile } from 'node:fs/promises'
|
||||||
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
|
import { basename, dirname, relative, resolve as resolvePath, sep } from 'node:path'
|
||||||
@@ -22,7 +29,7 @@ const require = createRequire(import.meta.url)
|
|||||||
|
|
||||||
const PLUGIN_ID = 'dsh-better-sidebar'
|
const PLUGIN_ID = 'dsh-better-sidebar'
|
||||||
|
|
||||||
/** Module specifiers the web shell shares into the frozen module table (apps/web/src/seed.ts). */
|
/** Module specifiers the web shell shares into the frozen module table (the official PLATFORM_MODULES list, plus the runtime/client exemption). */
|
||||||
const CLIENT_EXTERNALS = [
|
const CLIENT_EXTERNALS = [
|
||||||
'react',
|
'react',
|
||||||
'react/jsx-runtime',
|
'react/jsx-runtime',
|
||||||
@@ -36,6 +43,14 @@ const CLIENT_EXTERNALS = [
|
|||||||
'@deepseek-ai/dsh-client-runtime/client',
|
'@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. 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. */
|
/** Virtual-id wrapper keeping module CSS away from tsdown's own css pipeline. */
|
||||||
const CSS_VIRTUAL_PREFIX = '\0dsh-css:'
|
const CSS_VIRTUAL_PREFIX = '\0dsh-css:'
|
||||||
const CSS_VIRTUAL_SUFFIX = '.mjs'
|
const CSS_VIRTUAL_SUFFIX = '.mjs'
|
||||||
@@ -68,14 +83,17 @@ function browserSourcePath(source: string, sourcemapPath: string): string {
|
|||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
entry: { index: 'src/index.ts' },
|
entry: { index: 'src/index.ts', invariant: 'src/invariant.ts' },
|
||||||
outDir: 'lib',
|
outDir: 'lib',
|
||||||
format: ['esm'],
|
format: ['esm'],
|
||||||
platform: 'node',
|
platform: 'node',
|
||||||
target: 'es2024',
|
target: 'es2024',
|
||||||
fixedExtension: false,
|
fixedExtension: false,
|
||||||
dts: false,
|
dts: false,
|
||||||
clean: true,
|
// 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,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
entry: { client: 'src/client/index.tsx' },
|
entry: { client: 'src/client/index.tsx' },
|
||||||
@@ -94,6 +112,24 @@ export default [
|
|||||||
// External wins for module-table entries; every other dependency inlines.
|
// External wins for module-table entries; every other dependency inlines.
|
||||||
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
|
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
|
||||||
plugins: [{
|
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)',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}, {
|
||||||
name: 'dsh-css-inline',
|
name: 'dsh-css-inline',
|
||||||
resolveId(source: string, importer: string | undefined) {
|
resolveId(source: string, importer: string | undefined) {
|
||||||
if (!source.endsWith('.css')) return null
|
if (!source.endsWith('.css')) return null
|
||||||
|
|||||||
Reference in New Issue
Block a user